diff --git a/packages/cli/src/utils/lintProject.test.ts b/packages/cli/src/utils/lintProject.test.ts index c3773945d..bc0364838 100644 --- a/packages/cli/src/utils/lintProject.test.ts +++ b/packages/cli/src/utils/lintProject.test.ts @@ -324,6 +324,49 @@ describe("audio_src_not_found", () => { const occurrences = (finding?.message.match(/song\.mp3/g) ?? []).length; expect(occurrences).toBe(1); }); + + it("resolves sub-composition src relative to the sub-composition file (../assets/...)", () => { + // A sub-composition at compositions/captions.html referencing + // ../assets/bgm.mp3 means {projectRoot}/assets/bgm.mp3 — the bundler + // rewrites that path before serving, so the lint check has to mirror it. + const subComp = ` +
+ +
+ +`; + const project = makeProject(validHtml(), { "captions.html": subComp }); + mkdirSync(join(project.dir, "assets"), { recursive: true }); + writeFileSync(join(project.dir, "assets", "bgm.mp3"), "fake"); + + const { results } = lintProject(project); + + const first = results[0]; + expect(first).toBeDefined(); + const finding = first?.result.findings.find((f) => f.code === "audio_src_not_found"); + expect(finding).toBeUndefined(); + }); + + it("flags sub-composition src that resolves to a missing file via ../", () => { + const subComp = ` +
+ +
+ +`; + const project = makeProject(validHtml(), { "captions.html": subComp }); + // No assets/ directory at all. + + const { results } = lintProject(project); + + const first = results[0]; + expect(first).toBeDefined(); + const finding = first?.result.findings.find((f) => f.code === "audio_src_not_found"); + expect(finding).toBeDefined(); + // The original (un-rewritten) src is what surfaces in the message so the + // author can grep for it in their HTML. + expect(finding?.message).toContain("../assets/missing.mp3"); + }); }); describe("multiple_root_compositions", () => { diff --git a/packages/cli/src/utils/lintProject.ts b/packages/cli/src/utils/lintProject.ts index 3519baad3..c119a141f 100644 --- a/packages/cli/src/utils/lintProject.ts +++ b/packages/cli/src/utils/lintProject.ts @@ -2,8 +2,22 @@ import { existsSync, readFileSync, readdirSync } from "node:fs"; import { join, resolve, extname } from "node:path"; import { lintHyperframeHtml, type HyperframeLintResult } from "@hyperframes/core/lint"; import type { HyperframeLintFinding } from "@hyperframes/core/lint"; +import { rewriteAssetPath } from "@hyperframes/core"; import type { ProjectDir } from "./project.js"; +/** + * An HTML source paired with the sub-composition path it came from, if any. + * Sub-composition relative paths (`../assets/foo.mp3`) need to be resolved + * against the sub-composition's directory before checking the filesystem — + * the root index.html is the only source where a bare `resolve(projectDir, src)` + * is correct. + */ +interface HtmlSource { + html: string; + /** `data-composition-src` value (e.g. "compositions/scene.html"); undefined for the root. */ + compSrcPath?: string; +} + export interface ProjectLintResult { results: Array<{ file: string; result: HyperframeLintResult }>; totalErrors: number; @@ -32,14 +46,14 @@ export function lintProject(project: ProjectDir): ProjectLintResult { totalInfos += rootResult.infoCount; // Lint sub-compositions in compositions/ directory, collecting HTML for project-level checks - const allHtmlSources = [rootHtml]; + const allHtmlSources: HtmlSource[] = [{ html: rootHtml }]; const compositionsDir = resolve(project.dir, "compositions"); if (existsSync(compositionsDir)) { const files = readdirSync(compositionsDir).filter((f) => f.endsWith(".html")); for (const file of files) { const filePath = join(compositionsDir, file); const html = readFileSync(filePath, "utf-8"); - allHtmlSources.push(html); + allHtmlSources.push({ html, compSrcPath: `compositions/${file}` }); const result = lintHyperframeHtml(html, { filePath, isSubComposition: true }); results.push({ file: `compositions/${file}`, result }); totalErrors += result.errorCount; @@ -83,7 +97,10 @@ export function lintProject(project: ProjectDir): ProjectLintResult { * placing an audio file in the project but forgetting the