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