diff --git a/bun.lock b/bun.lock index fa98e9f34..cac1f849d 100644 --- a/bun.lock +++ b/bun.lock @@ -22,7 +22,7 @@ }, "packages/aws-lambda": { "name": "@hyperframes/aws-lambda", - "version": "0.7.21", + "version": "0.7.22", "dependencies": { "@aws-sdk/client-s3": "^3.700.0", "@aws-sdk/client-sfn": "^3.700.0", @@ -54,7 +54,7 @@ }, "packages/cli": { "name": "@hyperframes/cli", - "version": "0.7.21", + "version": "0.7.22", "bin": { "hyperframes": "./dist/cli.js", }, @@ -103,7 +103,7 @@ }, "packages/core": { "name": "@hyperframes/core", - "version": "0.7.21", + "version": "0.7.22", "dependencies": { "@chenglou/pretext": "^0.0.5", "@hyperframes/lint": "workspace:*", @@ -128,7 +128,7 @@ }, "packages/engine": { "name": "@hyperframes/engine", - "version": "0.7.21", + "version": "0.7.22", "dependencies": { "@hono/node-server": "^1.13.0", "@hyperframes/core": "workspace:^", @@ -146,7 +146,7 @@ }, "packages/gcp-cloud-run": { "name": "@hyperframes/gcp-cloud-run", - "version": "0.7.21", + "version": "0.7.22", "dependencies": { "@google-cloud/storage": "^7.14.0", "@google-cloud/workflows": "^4.2.0", @@ -166,9 +166,10 @@ }, "packages/lint": { "name": "@hyperframes/lint", - "version": "0.7.21", + "version": "0.7.22", "dependencies": { "@hyperframes/parsers": "workspace:*", + "linkedom": "^0.18.12", "postcss": "^8.5.8", }, "devDependencies": { @@ -181,7 +182,7 @@ }, "packages/parsers": { "name": "@hyperframes/parsers", - "version": "0.7.21", + "version": "0.7.22", "dependencies": { "@babel/parser": "^7.27.0", "acorn": "^8.17.0", @@ -201,7 +202,7 @@ }, "packages/player": { "name": "@hyperframes/player", - "version": "0.7.21", + "version": "0.7.22", "dependencies": { "@hyperframes/core": "workspace:*", }, @@ -216,7 +217,7 @@ }, "packages/producer": { "name": "@hyperframes/producer", - "version": "0.7.21", + "version": "0.7.22", "dependencies": { "@fontsource/archivo-black": "^5.2.8", "@fontsource/eb-garamond": "^5.2.7", @@ -233,6 +234,7 @@ "@hyperframes/core": "workspace:^", "@hyperframes/engine": "workspace:^", "@hyperframes/lint": "workspace:^", + "@hyperframes/parsers": "workspace:^", "@hyperframes/studio-server": "workspace:^", "hono": "^4.6.0", "linkedom": "^0.18.12", @@ -259,7 +261,7 @@ }, "packages/sdk": { "name": "@hyperframes/sdk", - "version": "0.7.21", + "version": "0.7.22", "dependencies": { "@hyperframes/core": "workspace:*", "@hyperframes/parsers": "workspace:*", @@ -286,7 +288,7 @@ }, "packages/shader-transitions": { "name": "@hyperframes/shader-transitions", - "version": "0.7.21", + "version": "0.7.22", "dependencies": { "html2canvas": "^1.4.1", }, @@ -298,7 +300,7 @@ }, "packages/studio": { "name": "@hyperframes/studio", - "version": "0.7.21", + "version": "0.7.22", "dependencies": { "@codemirror/autocomplete": "^6.20.1", "@codemirror/commands": "^6.10.3", @@ -346,7 +348,7 @@ }, "packages/studio-server": { "name": "@hyperframes/studio-server", - "version": "0.7.21", + "version": "0.7.22", "dependencies": { "@hyperframes/core": "workspace:*", "@hyperframes/parsers": "workspace:*", diff --git a/packages/cli/src/commands/validate.test.ts b/packages/cli/src/commands/validate.test.ts index 8aeb9c801..22f628856 100644 --- a/packages/cli/src/commands/validate.test.ts +++ b/packages/cli/src/commands/validate.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; -import { shouldIgnoreRequestFailure } from "./validate.js"; +import { extractCompositionErrorsFromLint, shouldIgnoreRequestFailure } from "./validate.js"; +import type { ProjectLintResult } from "../utils/lintProject.js"; describe("shouldIgnoreRequestFailure", () => { it("ignores aborted media preload requests", () => { @@ -34,3 +35,107 @@ describe("shouldIgnoreRequestFailure", () => { ).toBe(false); }); }); + +describe("extractCompositionErrorsFromLint", () => { + // `bundleToSingleHtml` (the inliner validate.ts bundles through) is + // intentionally tolerant of missing/empty/unparsable data-composition-src + // files — it skips the scene and keeps going, silently, so `validate` + // would otherwise report "No console errors" for a project that renders a + // materially broken video. extractCompositionErrorsFromLint pulls the + // lintProject finding into validate's error list so this is a real + // validate failure instead. + function makeLintResult( + findings: Array<{ code: string; severity: "error" | "warning" | "info"; message: string }>, + ): Pick { + return { + results: [ + { + file: "index.html", + result: { + ok: findings.length === 0, + errorCount: 0, + warningCount: 0, + infoCount: 0, + findings, + }, + }, + ], + }; + } + + it("surfaces missing_or_empty_sub_composition errors as ConsoleEntry errors", () => { + const lintResult = makeLintResult([ + { + code: "missing_or_empty_sub_composition", + severity: "error", + message: + 'data-composition-src references "compositions/scene-title.html", but the file is empty.', + }, + ]); + + const errors = extractCompositionErrorsFromLint(lintResult); + + expect(errors).toEqual([ + { + level: "error", + text: 'data-composition-src references "compositions/scene-title.html", but the file is empty.', + }, + ]); + }); + + it("ignores unrelated lint finding codes", () => { + const lintResult = makeLintResult([ + { code: "audio_src_not_found", severity: "error", message: "unrelated" }, + { code: "root_missing_composition_id", severity: "error", message: "also unrelated" }, + ]); + + expect(extractCompositionErrorsFromLint(lintResult)).toEqual([]); + }); + + it("returns an empty array for a clean project", () => { + expect(extractCompositionErrorsFromLint(makeLintResult([]))).toEqual([]); + }); + + it("collects findings across multiple result files", () => { + const lintResult: Pick = { + results: [ + { + file: "index.html", + result: { + ok: false, + errorCount: 1, + warningCount: 0, + infoCount: 0, + findings: [ + { + code: "missing_or_empty_sub_composition", + severity: "error", + message: "scene-a is empty", + }, + ], + }, + }, + { + file: "compositions/nested.html", + result: { + ok: false, + errorCount: 1, + warningCount: 0, + infoCount: 0, + findings: [ + { + code: "missing_or_empty_sub_composition", + severity: "error", + message: "scene-b is empty", + }, + ], + }, + }, + ], + }; + + const errors = extractCompositionErrorsFromLint(lintResult); + expect(errors).toHaveLength(2); + expect(errors.map((e) => e.text)).toEqual(["scene-a is empty", "scene-b is empty"]); + }); +}); diff --git a/packages/cli/src/commands/validate.ts b/packages/cli/src/commands/validate.ts index c08ba6e71..824f5378c 100644 --- a/packages/cli/src/commands/validate.ts +++ b/packages/cli/src/commands/validate.ts @@ -2,8 +2,9 @@ import { defineCommand } from "citty"; import { existsSync, readFileSync } from "node:fs"; import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; -import { resolveProject } from "../utils/project.js"; +import { resolveProject, type ProjectDir } from "../utils/project.js"; import { normalizeErrorMessage } from "../utils/errorMessage.js"; +import type { ProjectLintResult } from "../utils/lintProject.js"; import { resolveCompositionViewportFromHtml } from "../utils/compositionViewport.js"; import { c } from "../ui/colors.js"; import { withMeta } from "../utils/updateCheck.js"; @@ -179,13 +180,40 @@ function loadContrastAuditScript(): string { throw new Error("Missing contrast audit browser script"); } +/** + * Pull the `missing_or_empty_sub_composition` lint findings out of a + * `lintProject` result and shape them as `ConsoleEntry`s. Extracted as a + * pure function so it's testable without a headless browser or a real + * project directory — see validate.test.ts. + */ +export function extractCompositionErrorsFromLint( + lintResult: Pick, +): ConsoleEntry[] { + return lintResult.results + .flatMap((r) => r.result.findings) + .filter((f) => f.code === "missing_or_empty_sub_composition" && f.severity === "error") + .map((f) => ({ level: "error" as const, text: f.message })); +} + async function validateInBrowser( - projectDir: string, + project: ProjectDir, opts: { timeout?: number; contrast?: boolean }, ): Promise<{ errors: ConsoleEntry[]; warnings: ConsoleEntry[]; contrast?: ContrastEntry[] }> { + const projectDir = project.dir; const { bundleToSingleHtml } = await import("@hyperframes/core/compiler"); const { ensureBrowser } = await import("../browser/manager.js"); const { serveStaticProjectHtml } = await import("../utils/staticProjectServer.js"); + const { lintProject } = await import("../utils/lintProject.js"); + + // Fail fast on missing/empty/unparsable data-composition-src references + // before spending time bundling and launching a browser. The bundler + // (bundleToSingleHtml → inlineSubCompositions) is intentionally tolerant of + // these — it skips the broken scene and keeps going, silently, with only a + // console.warn — so validate would otherwise report "No console errors" + // for a project that renders a materially broken video. Surface it as a + // real validate failure instead. + const lintResult = await lintProject(projectDir); + const compositionErrors = extractCompositionErrorsFromLint(lintResult); // `bundleToSingleHtml` now inlines the runtime IIFE by default, so the // previous post-bundle regex substitution (which matched `src="..."` on the @@ -194,7 +222,7 @@ async function validateInBrowser( const server = await serveStaticProjectHtml(projectDir, html); - const errors: ConsoleEntry[] = []; + const errors: ConsoleEntry[] = [...compositionErrors]; const warnings: ConsoleEntry[] = []; let contrast: ContrastEntry[] | undefined; const viewport = resolveCompositionViewportFromHtml(html); @@ -391,7 +419,7 @@ Examples: } try { - const result = await validateInBrowser(project.dir, { timeout, contrast: useContrast }); + const result = await validateInBrowser(project, { timeout, contrast: useContrast }); const exitCode = printValidationResult(result, asJson); process.exit(exitCode); } catch (err: unknown) { diff --git a/packages/cli/src/utils/lintProject.test.ts b/packages/cli/src/utils/lintProject.test.ts index 2491b947a..ed5df9f36 100644 --- a/packages/cli/src/utils/lintProject.test.ts +++ b/packages/cli/src/utils/lintProject.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, afterEach } from "vitest"; import { mkdirSync, mkdtempSync, writeFileSync, rmSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; +import type { HyperframeLintFinding } from "@hyperframes/core/lint"; import { lintProject, shouldBlockRender } from "./lintProject.js"; function tmpProject(name: string): string { @@ -1027,6 +1028,115 @@ describe("duplicate_audio_track", () => { }); }); +describe("missing_or_empty_sub_composition", () => { + function htmlWithSubComp(srcPath: string): string { + return ` +
+
+
+ + +`; + } + + function validSubCompHtml(): string { + return ` +
+
Hello
+
+`; + } + + // Shared assertion: lint a project referencing "compositions/scene-title.html" + // (or a custom srcPath) and return the missing_or_empty_sub_composition + // finding, if any, plus the raw lint result for callers that need totalErrors. + async function lintSubComp( + srcPath: string, + subCompFiles?: Record, + ): Promise<{ finding: HyperframeLintFinding | undefined; totalErrors: number }> { + const project = makeProject(htmlWithSubComp(srcPath), subCompFiles); + const { totalErrors, results } = await lintProject(project); + const finding = results + .flatMap((r) => r.result.findings) + .find((f) => f.code === "missing_or_empty_sub_composition"); + return { finding, totalErrors }; + } + + it.each([ + { + label: "empty", + content: "", + expectMessageContains: "empty", + }, + { + label: "whitespace-only", + content: " \n\t ", + expectMessageContains: "empty", + }, + { + label: "malformed / non-HTML", + content: "just some plain text, no tags at all", + expectMessageContains: "could not be parsed", + }, + ])( + "errors when the referenced sub-composition file is $label", + async ({ content, expectMessageContains }) => { + const { finding, totalErrors } = await lintSubComp("compositions/scene-title.html", { + "scene-title.html": content, + }); + + expect(totalErrors).toBeGreaterThan(0); + expect(finding).toBeDefined(); + expect(finding?.severity).toBe("error"); + expect(finding?.message).toContain(expectMessageContains); + }, + ); + + it("errors when the referenced sub-composition file does not exist", async () => { + // No subComps passed — compositions/ directory doesn't even exist. + const { finding, totalErrors } = await lintSubComp("compositions/does-not-exist.html"); + + expect(totalErrors).toBeGreaterThan(0); + expect(finding).toBeDefined(); + expect(finding?.message).toContain("compositions/does-not-exist.html"); + expect(finding?.message).toContain("does not exist"); + }); + + it("does not error when the referenced sub-composition file is valid (happy path)", async () => { + const { finding } = await lintSubComp("compositions/scene-title.html", { + "scene-title.html": validSubCompHtml(), + }); + expect(finding).toBeUndefined(); + }); + + it("does not error on a project with no data-composition-src references", async () => { + const project = makeProject(validHtml()); + const { results } = await lintProject(project); + const finding = results + .flatMap((r) => r.result.findings) + .find((f) => f.code === "missing_or_empty_sub_composition"); + expect(finding).toBeUndefined(); + }); + + it("dedupes a single bad reference into one finding even if repeated", async () => { + const html = ` +
+
+
+
+ +`; + const project = makeProject(html, { "scene-title.html": "" }); + + const { results } = await lintProject(project); + + const findings = results + .flatMap((r) => r.result.findings) + .filter((f) => f.code === "missing_or_empty_sub_composition"); + expect(findings).toHaveLength(1); + }); +}); + describe("shouldBlockRender", () => { it("default: does not block on errors", async () => { expect(shouldBlockRender(false, false, 5, 0)).toBe(false); diff --git a/packages/core/src/compiler/htmlBundler.ts b/packages/core/src/compiler/htmlBundler.ts index d5dfb74ca..21b5b554b 100644 --- a/packages/core/src/compiler/htmlBundler.ts +++ b/packages/core/src/compiler/htmlBundler.ts @@ -800,8 +800,10 @@ export async function bundleToSingleHtml( parseHostVariables: parseHostVariableValues, buildScopeSelector: (compId: string) => cssAttributeSelector("data-composition-id", compId), scriptErrorLabel: "[HyperFrames] composition script error:", - onMissingComposition: (srcPath: string) => { - console.warn(`[Bundler] Composition file not found: ${srcPath}`); + onMissingComposition: (srcPath: string, reason?: string) => { + console.warn( + `[Bundler] Skipping sub-composition "${srcPath}": ${reason ?? "the file could not be found"}.`, + ); }, }); const compStyleChunks: string[] = [...subCompResult.styles]; diff --git a/packages/core/src/compiler/index.ts b/packages/core/src/compiler/index.ts index c973c56ac..0de427c98 100644 --- a/packages/core/src/compiler/index.ts +++ b/packages/core/src/compiler/index.ts @@ -59,5 +59,15 @@ export { type InlineSubCompositionsResult, } from "./inlineSubCompositions"; +// Sub-composition usability check (shared between the inliner, lint, and the +// render pre-flight abort) — single source of truth for "is this +// data-composition-src file usable?" +export { + checkSubCompositionUsability, + type ParsableDocumentLike, + type SubCompositionValidity, + type SubCompositionValidityReason, +} from "./subCompositionValidity"; + // Asset-path primitives (shared across core, producer, CLI) export { CSS_URL_RE, PATH_ATTRS, isNonRelativeUrl, isPathInside } from "./assetPaths"; diff --git a/packages/core/src/compiler/inlineSubCompositions.test.ts b/packages/core/src/compiler/inlineSubCompositions.test.ts index c86583199..bf52cffc4 100644 --- a/packages/core/src/compiler/inlineSubCompositions.test.ts +++ b/packages/core/src/compiler/inlineSubCompositions.test.ts @@ -45,6 +45,12 @@ describe("inlineSubCompositions – #ID selector scoping divergence", () => { label: "valid-parse-empty-body", html: "", }, + // linkedom's parseHTML("just some text") returns documentElement === null. + // Any code that then touches .head/.body (as linkedom's own internals do) + // throws "Cannot destructure property 'firstElementChild' of + // 'documentElement' as it is null" — the #1 raw crash in production + // telemetry. Must be skipped gracefully, not crash. + { label: "malformed non-HTML text", html: "just some plain text, no tags at all" }, ])("skips $label sub-composition files gracefully", ({ html }) => { const document = makeHostDocument("intro"); const host = document.querySelector('[data-composition-src="intro.html"]')!; @@ -61,6 +67,21 @@ describe("inlineSubCompositions – #ID selector scoping divergence", () => { expect(result.scripts).toHaveLength(0); }); + it("passes the failure reason through to onMissingComposition", () => { + const document = makeHostDocument("intro"); + const host = document.querySelector('[data-composition-src="intro.html"]')!; + const reasons: Array = []; + + inlineSubCompositions(document, [host], { + resolveHtml: () => "", + parseHtml: (h) => parseHTML(h).document, + onMissingComposition: (_src, reason) => reasons.push(reason), + }); + + expect(reasons).toHaveLength(1); + expect(reasons[0]).toContain("empty"); + }); + it("producer path (no flattenInnerRoot): strips inner root, losing #id attribute", () => { const document = makeHostDocument("intro"); const host = document.querySelector('[data-composition-src="intro.html"]')!; diff --git a/packages/core/src/compiler/inlineSubCompositions.ts b/packages/core/src/compiler/inlineSubCompositions.ts index 14e8341dd..bf74cc6a2 100644 --- a/packages/core/src/compiler/inlineSubCompositions.ts +++ b/packages/core/src/compiler/inlineSubCompositions.ts @@ -19,6 +19,7 @@ import { wrapInlineScriptWithErrorBoundary, wrapScopedCompositionScript, } from "./compositionScoping"; +import { checkSubCompositionUsability } from "@hyperframes/parsers/sub-composition-validity"; // --------------------------------------------------------------------------- // Public interface @@ -101,10 +102,14 @@ export interface InlineSubCompositionsOptions { scriptErrorLabel?: string; /** - * Log a warning when a composition file cannot be resolved. + * Log a warning when a composition file cannot be resolved. `reason` is a + * short, human-readable explanation (e.g. "the file is empty (0 bytes or + * whitespace-only)") from `checkSubCompositionUsability` — present for + * every skip except when `resolveHtml` returns `null` (file not found, + * which callers detect themselves before calling `resolveHtml`). * Defaults to `console.warn`. */ - onMissingComposition?: (srcPath: string) => void; + onMissingComposition?: (srcPath: string, reason?: string) => void; } export interface InlineSubCompositionsResult { @@ -176,16 +181,26 @@ export function inlineSubCompositions( if (!src) continue; const compHtml = resolveHtml(src); - if (compHtml == null || !compHtml.trim()) { + // Shared with lint + render pre-flight (@hyperframes/parsers' + // subCompositionValidity.ts) so all three callers agree on what counts + // as a usable sub-composition file. This path stays intentionally + // tolerant (skip, don't throw) — preview and studio must keep bundling + // around a scene that's still being authored. Lint and the render + // pre-flight check use the same helper to fail loudly instead. + const validity = checkSubCompositionUsability(compHtml, parseHtml); + if (!validity.ok) { + onMissingComposition?.(src, validity.detail); + continue; + } + if (compHtml == null) { + // Unreachable in practice — checkSubCompositionUsability's "empty" + // reason already covers null/undefined — but this lets TypeScript + // narrow compHtml to `string` below without an `as T` assertion. onMissingComposition?.(src); continue; } const compDoc = parseHtml(compHtml); - if (!compDoc.documentElement) { - onMissingComposition?.(src); - continue; - } // Determine composition IDs let compId: string | null; diff --git a/packages/core/src/compiler/subCompositionValidity.ts b/packages/core/src/compiler/subCompositionValidity.ts new file mode 100644 index 000000000..7db554391 --- /dev/null +++ b/packages/core/src/compiler/subCompositionValidity.ts @@ -0,0 +1,2 @@ +/** @deprecated Import from @hyperframes/parsers/sub-composition-validity */ +export * from "@hyperframes/parsers/sub-composition-validity"; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index fe31c1354..c10c53abf 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -149,6 +149,12 @@ export { rewriteInlineStyleAssetUrls, } from "./compiler/rewriteSubCompPaths"; export { CSS_URL_RE, isNonRelativeUrl, isPathInside } from "./compiler/assetPaths"; +export { + checkSubCompositionUsability, + type ParsableDocumentLike, + type SubCompositionValidity, + type SubCompositionValidityReason, +} from "./compiler/subCompositionValidity"; export { queryByAttr } from "./utils/cssSelector"; export { decodeUrlPathVariants } from "./utils/urlPath"; export { parseAnimatedGifMetadata, type AnimatedGifMetadata } from "./media/gif"; diff --git a/packages/lint/package.json b/packages/lint/package.json index 3598c35ed..a35dd91a8 100644 --- a/packages/lint/package.json +++ b/packages/lint/package.json @@ -54,6 +54,7 @@ }, "dependencies": { "@hyperframes/parsers": "workspace:*", + "linkedom": "^0.18.12", "postcss": "^8.5.8" }, "devDependencies": { diff --git a/packages/lint/src/project.test.ts b/packages/lint/src/project.test.ts new file mode 100644 index 000000000..8fc82aa24 --- /dev/null +++ b/packages/lint/src/project.test.ts @@ -0,0 +1,211 @@ +import { describe, it, expect, afterEach } from "vitest"; +import { mkdirSync, mkdtempSync, writeFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import type { HyperframeLintFinding } from "./types.js"; +import { lintProject } from "./project.js"; + +function tmpProject(name: string): string { + return mkdtempSync(join(tmpdir(), `hf-lint-test-${name}-`)); +} + +function validHtml(compId = "main"): string { + return ` +
+ + +`; +} + +let dirs: string[] = []; + +function makeProject(indexHtml: string, subComps?: Record): string { + const dir = tmpProject("lint"); + dirs.push(dir); + writeFileSync(join(dir, "index.html"), indexHtml); + if (subComps) { + const compsDir = join(dir, "compositions"); + mkdirSync(compsDir, { recursive: true }); + for (const [name, html] of Object.entries(subComps)) { + writeFileSync(join(compsDir, name), html); + } + } + return dir; +} + +afterEach(() => { + for (const d of dirs) { + rmSync(d, { recursive: true, force: true }); + } + dirs = []; +}); + +describe("missing_or_empty_sub_composition", () => { + function htmlWithSubComp(srcPath: string): string { + return ` +
+
+
+ + +`; + } + + function validSubCompHtml(): string { + return ` +
+
Hello
+
+`; + } + + // Shared assertion: lint a project referencing "compositions/scene-title.html" + // (or a custom srcPath) and return the missing_or_empty_sub_composition + // finding, if any, plus the raw lint result for callers that need totalErrors. + async function lintSubComp( + srcPath: string, + subCompFiles?: Record, + ): Promise<{ finding: HyperframeLintFinding | undefined; totalErrors: number }> { + const project = makeProject(htmlWithSubComp(srcPath), subCompFiles); + const { totalErrors, results } = await lintProject(project); + const finding = results + .flatMap((r) => r.result.findings) + .find((f) => f.code === "missing_or_empty_sub_composition"); + return { finding, totalErrors }; + } + + it.each([ + { + label: "empty", + content: "", + expectMessageContains: "empty", + }, + { + label: "whitespace-only", + content: " \n\t ", + expectMessageContains: "empty", + }, + { + label: "malformed / non-HTML", + content: "just some plain text, no tags at all", + expectMessageContains: "could not be parsed", + }, + ])( + "errors when the referenced sub-composition file is $label", + async ({ content, expectMessageContains }) => { + const { finding, totalErrors } = await lintSubComp("compositions/scene-title.html", { + "scene-title.html": content, + }); + + expect(totalErrors).toBeGreaterThan(0); + expect(finding).toBeDefined(); + expect(finding?.severity).toBe("error"); + expect(finding?.message).toContain(expectMessageContains); + }, + ); + + it("errors when the referenced sub-composition file does not exist", async () => { + // No subComps passed — compositions/ directory doesn't even exist. + const { finding, totalErrors } = await lintSubComp("compositions/does-not-exist.html"); + + expect(totalErrors).toBeGreaterThan(0); + expect(finding).toBeDefined(); + expect(finding?.message).toContain("compositions/does-not-exist.html"); + expect(finding?.message).toContain("does not exist"); + }); + + it("errors when the referenced sub-composition file has content but no data-composition-id root", async () => { + const { finding, totalErrors } = await lintSubComp("compositions/scene-title.html", { + "scene-title.html": "

TODO: scene content

", + }); + + expect(totalErrors).toBeGreaterThan(0); + expect(finding).toBeDefined(); + expect(finding?.message).toContain("data-composition-id"); + }); + + it("does not error when the referenced sub-composition file is valid (happy path)", async () => { + const { finding } = await lintSubComp("compositions/scene-title.html", { + "scene-title.html": validSubCompHtml(), + }); + expect(finding).toBeUndefined(); + }); + + it("does not error on a project with no data-composition-src references", async () => { + const project = makeProject(validHtml()); + const { results } = await lintProject(project); + const finding = results + .flatMap((r) => r.result.findings) + .find((f) => f.code === "missing_or_empty_sub_composition"); + expect(finding).toBeUndefined(); + }); + + it("dedupes a single bad reference into one finding even if repeated", async () => { + const html = ` +
+
+
+
+ +`; + const project = makeProject(html, { "scene-title.html": "" }); + + const { results } = await lintProject(project); + + const findings = results + .flatMap((r) => r.result.findings) + .filter((f) => f.code === "missing_or_empty_sub_composition"); + expect(findings).toHaveLength(1); + }); + + // Regression: lint used to raw-filesystem-walk every .html under + // compositions/, regardless of whether the root composition actually + // references it. render's pre-flight (assertSubCompositionsUsable) only + // follows real data-composition-src references starting from the root, so + // an orphaned file with its own dangling reference made `lint`/`validate` + // fail even though `render` succeeds fine on the same project. + it("does not error on an orphaned, unreferenced file under compositions/ with a dangling reference inside it", async () => { + const project = makeProject(validHtml(), {}); + const archivedDir = join(project, "compositions", "archived"); + mkdirSync(archivedDir, { recursive: true }); + // Never referenced from index.html — this file is unreachable. + writeFileSync( + join(archivedDir, "old-draft.html"), + ` +
+
+
+`, + ); + + const { results, totalErrors } = await lintProject(project); + const finding = results + .flatMap((r) => r.result.findings) + .find((f) => f.code === "missing_or_empty_sub_composition"); + + expect(finding).toBeUndefined(); + expect(totalErrors).toBe(0); + }); + + it("still errors when a broken reference IS reachable from the root (nested, not just top-level)", async () => { + const project = makeProject(htmlWithSubComp("compositions/parent.html")); + mkdirSync(join(project, "compositions"), { recursive: true }); + writeFileSync( + join(project, "compositions", "parent.html"), + ` +
+
+
+`, + ); + + const { results, totalErrors } = await lintProject(project); + const finding = results + .flatMap((r) => r.result.findings) + .find((f) => f.code === "missing_or_empty_sub_composition"); + + expect(totalErrors).toBeGreaterThan(0); + expect(finding).toBeDefined(); + expect(finding?.message).toContain("compositions/does-not-exist.html"); + }); +}); diff --git a/packages/lint/src/project.ts b/packages/lint/src/project.ts index 82a5094f6..ef068c3e1 100644 --- a/packages/lint/src/project.ts +++ b/packages/lint/src/project.ts @@ -3,8 +3,16 @@ import { existsSync, readFileSync, readdirSync } from "node:fs"; import { dirname, extname, isAbsolute, join, posix, relative, resolve } from "node:path"; import { decodeUrlPathVariants } from "@hyperframes/parsers/composition"; import { rewriteAssetPath } from "@hyperframes/parsers/asset-paths"; +import { checkSubCompositionUsability } from "@hyperframes/parsers/sub-composition-validity"; +import { parseHTML } from "linkedom"; import { lintHyperframeHtml } from "./hyperframeLinter.js"; import type { HyperframeLintFinding, HyperframeLintResult } from "./types.js"; +import type { ParsableDocumentLike } from "@hyperframes/parsers/sub-composition-validity"; + +/** Adapts linkedom's `parseHTML` to the `checkSubCompositionUsability` contract. */ +function parseSubCompHtml(html: string): ParsableDocumentLike { + return parseHTML(html).document as unknown as ParsableDocumentLike; +} interface HtmlSource { html: string; @@ -221,6 +229,7 @@ export async function lintProject(projectDir: string): Promise 0) { for (const finding of projectFindings) { @@ -502,3 +511,102 @@ function lintDuplicateAudioTracks(htmlSources: HtmlSource[]): HyperframeLintFind } return findings; } + +/** + * Error if a `data-composition-src` reference points at a file that is + * missing, empty, or does not parse to usable HTML. This is the #1 render + * failure bucket in production telemetry: a scene-authoring step (an AI + * agent, most commonly) writes the reference before — or without ever — + * writing valid content into the scene file. + * + * The render pre-flight check (`assertSubCompositionsUsable` in + * `packages/producer/src/services/htmlCompiler.ts`) now aborts the render + * loudly and immediately when this happens, rather than silently dropping + * the scene — so catching it here, before the render even starts, means the + * failure surfaces at lint/validate time with the same message instead of + * only at render time. + * + * Only follows files actually reachable via `data-composition-src` starting + * from the root composition — mirroring the reachability semantics of + * `assertSubCompositionsUsable`. A raw filesystem walk of every `.html` + * under `compositions/` would flag orphaned/unreferenced files that the + * renderer never visits, producing false-positive lint/validate failures on + * projects that actually render fine. Lint, render, and the inliner must + * never disagree about whether a given file would actually render + * something. + */ +function lintMissingOrEmptySubComposition( + projectDir: string, + rootHtml: string, +): HyperframeLintFinding[] { + // Dedup by src path — the same reference can appear from nested sub-comps. + const checked = new Map(); + const visited = new Set(); + + // fallow-ignore-next-line complexity + const walk = (html: string): void => { + const compositionSrcRe = /<[^>]*\bdata-composition-src\s*=\s*["']([^"']+)["'][^>]*>/gi; + const scannable = maskNonScannableRanges(html); + let match: RegExpExecArray | null; + while ((match = compositionSrcRe.exec(scannable)) !== null) { + const srcPath = (match[1] ?? "").trim(); + if (!srcPath) continue; + if (/^__[A-Z_]+__$/.test(srcPath)) continue; // template placeholder + + // data-composition-src is always written root-relative (even from a + // nested sub-composition) — matches the resolution the renderer uses + // in packages/producer/src/services/htmlCompiler.ts (parseSubCompositions + // / assertSubCompositionsUsable). + const filePath = resolve(projectDir, srcPath); + + // Circular reference guard — same as assertSubCompositionsUsable. + // Already-visited files were already checked (or are mid-walk); skip + // re-checking/re-recursing but still let a later distinct reference to + // the same broken file surface (checked is keyed by srcPath, not filePath). + if (visited.has(filePath)) continue; + visited.add(filePath); + + if (!existsSync(filePath)) { + if (!checked.has(srcPath)) { + checked.set(srcPath, { srcPath, problem: "the file does not exist" }); + } + continue; + } + + const fileHtml = readFileSync(filePath, "utf-8"); + const validity = checkSubCompositionUsability(fileHtml, parseSubCompHtml); + if (!validity.ok) { + if (!checked.has(srcPath)) { + checked.set(srcPath, { + srcPath, + problem: validity.detail ?? "the file is empty or could not be parsed", + }); + } + continue; + } + + // Usable — recurse into it so nested references are validated too, + // but only because this file is itself reachable from the root. + walk(fileHtml); + } + }; + + walk(rootHtml); + + const findings: HyperframeLintFinding[] = []; + for (const { srcPath, problem } of checked.values()) { + findings.push({ + code: "missing_or_empty_sub_composition", + severity: "error", + message: `data-composition-src references "${srcPath}", but ${problem}.`, + fixHint: + `Fix this before rendering — the render pre-flight rejects unusable sub-compositions. ` + + `Write valid HTML into "${srcPath}" — it needs a