mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
fix(core,producer,cli): pre-flight validation for empty/malformed sub-compositions (#1831)
* fix(core,producer,cli): pre-flight validation for empty/malformed sub-compositions The #1 render failure bucket in production telemetry (PostHog project 356858, dashboard 1783183 "HyperFrames — Bottom-Line & Activation"; ~65-69K occurrences / ~27-28K affected users over 30 days, ~80% via AI-agent authoring flows) is a `data-composition-src` reference pointing at a scene file that is empty, malformed, or missing. Root cause, traced end-to-end: - The literal error "Composition HTML is empty or could not be parsed: <path>" is real (not a PostHog paraphrase) — thrown by a since-reverted guard in packages/core/src/compiler/inlineSubCompositions.ts (#1364), then changed to a silent skip in #1678 to avoid aborting renders on partial content during authoring. #1629 added per-assembler guards for 3 skill workflows (product-launch-video, faceless-explainer, pr-to-video), but general-video and hand-authored flows — where the dominant filename `scene-title.html` (40K+/68K of the bucket) originates — have no assembler and thus no guard. #1678 assumed the assembler guards from #1629 covered this pre-render; they only covered 3 of the many authoring flows. - On current `main`, an empty/malformed data-composition-src file no longer crashes or throws during render — it's silently dropped by the tolerant inliner. Reproduced locally: `hyperframes render` on a project with an empty scene-title.html "succeeds" after ~93s (two 45s pollSubCompositionTimelines timeouts) with the scene silently missing from the output video. `hyperframes validate` also reports "No console errors" for the same broken project. - The raw `Cannot destructure property 'firstElementChild' of 'documentElement' as it is null` crash reproduces directly against linkedom (the DOMParser polyfill packages/cli/src/utils/dom.ts installs in the real CLI runtime) for empty and non-HTML input — confirmed with a standalone repro script, not just inferred. jsdom/happy-dom (used in this repo's own test environment) are spec-compliant and never produce a null documentElement, which is why this needed a linkedom-specific test file. Fix: - New shared helper `checkSubCompositionUsability` (packages/core/src/compiler/subCompositionValidity.ts) is the single source of truth for "is this data-composition-src file usable" — mirrors the inliner's own parse/template/body logic so all callers agree. - `inlineSubCompositions.ts` (preview/studio bundling) now uses the shared helper internally but keeps its #1678 tolerant skip-and-continue behavior unchanged — mid-authoring iteration on a partial project must keep working. `onMissingComposition` now also receives a human-readable reason. - New render-only pre-flight (`assertSubCompositionsUsable` in packages/producer/src/services/htmlCompiler.ts) walks every data-composition-src reference (including nested ones, root-relative, matching parseSubCompositions' own resolution) before any compilation work starts, and throws naming every offending file at once. This is unconditional — not gated behind --strict — because a render that silently drops a scene is strictly worse than one that refuses to start. Confirmed locally: render now fails in ~0.4s with an actionable message instead of "succeeding" after 93s with a missing scene. - New `hyperframes lint` rule `missing_or_empty_sub_composition` (packages/cli/src/utils/lintProject.ts) surfaces the same check as a file-scoped, actionable lint error (already unconditional — lint exits 1 on any error). - `hyperframes validate` now also runs this check before launching a browser, so it no longer reports "No console errors" for a project with a broken sub-composition. - `packages/core/src/parsers/htmlParser.ts`: guarded every `documentElement`-may-be-null access (parseHtml, updateElementInHtml, addElementToHtml, removeElementFromHtml, extractCompositionMetadata, validateCompositionHtml) with a new typed `CompositionHtmlParseError` (or, for validateCompositionHtml's collect-and-report contract, a typed validation failure) instead of a raw crash. Tests: empty file, whitespace-only, malformed/non-HTML, missing file, nested sub-compositions (both happy path and broken-grandchild), and the happy path — at the shared-helper, lint, and render pre-flight layers. Not changed: the AI-agent authoring skills (skills/*). general-video and hand-authored flows have no assemble-index.mjs equivalent to guard, so the fix is at the CLI/render layer instead — flow-agnostic, covers every authoring path, and the skills' existing "run lint/validate and stop on failure" guidance now actually catches this class of mistake once run. Not run in this environment: the producer package's full regression-harness test suite (`bun test` in packages/producer) — it performs heavy real rendering (S3 asset downloads, Google Fonts fetches, full video encodes) and did not complete in a reasonable time in this sandbox. Verified instead via the targeted test file for all touched code (76/76 passing), whole-repo typecheck/build/oxlint, `fallow audit` (complexity/duplication/dead-code gate, clean), and manual end-to-end CLI runs (render/lint/validate) against reproduction projects, including a nested sub-composition scenario. CI should run the full producer suite before merge. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * refactor(parsers,lint): port empty-composition pre-flight to extracted packages Rebased onto main, which extracted @hyperframes/lint from core (lint depends only on parsers, not core). Relocate checkSubCompositionUsability from core to @hyperframes/parsers so both core (inliner) and lint can consume it without a core<->lint cycle; core keeps a @deprecated re-export shim. Correctness fixes from code review: - checkSubCompositionUsability now returns "no-composition-root" when the <template>/<body> content has no [data-composition-id] element (previously a marker-free placeholder body passed both guards). - lint's missing/empty sub-composition rule now only checks files reachable via data-composition-src from the root (matching render pre-flight), instead of a raw filesystem walk that false-positived on orphaned files. - drop `as string` cast in inlineSubCompositions in favor of an explicit null guard (per CLAUDE.md). Review-comment items: - move EmptyCompositionError JSDoc above the class (was above the adapter fn). - correct stale circular-ref comment to match actual silent-skip behavior. - rewrite self-contradicting lint message ("silently drop") to describe the new loud render-pre-flight abort. - add the __PLACEHOLDER__ (/^__[A-Z_]+__$/) skip to the render pre-flight so it agrees with lint. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
9c4d9e50a0
commit
cf573f7f3f
@@ -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:*",
|
||||
|
||||
@@ -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<ProjectLintResult, "results"> {
|
||||
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<ProjectLintResult, "results"> = {
|
||||
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"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<ProjectLintResult, "results">,
|
||||
): 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) {
|
||||
|
||||
@@ -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 `<html><body>
|
||||
<div data-composition-id="main" data-width="1920" data-height="1080" data-start="0" data-duration="10">
|
||||
<div data-composition-src="${srcPath}" data-composition-id="scene-title" data-start="0" data-duration="5"></div>
|
||||
</div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
|
||||
<script>window.__timelines = window.__timelines || {}; window.__timelines["main"] = gsap.timeline({ paused: true });</script>
|
||||
</body></html>`;
|
||||
}
|
||||
|
||||
function validSubCompHtml(): string {
|
||||
return `<!doctype html><html><body>
|
||||
<div data-composition-id="scene-title" data-width="1920" data-height="1080">
|
||||
<div class="title">Hello</div>
|
||||
</div>
|
||||
</body></html>`;
|
||||
}
|
||||
|
||||
// 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<string, string>,
|
||||
): 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 = `<html><body>
|
||||
<div data-composition-id="main" data-width="1920" data-height="1080" data-start="0" data-duration="10">
|
||||
<div data-composition-src="compositions/scene-title.html" data-composition-id="a" data-start="0" data-duration="5"></div>
|
||||
<div data-composition-src="compositions/scene-title.html" data-composition-id="b" data-start="5" data-duration="5"></div>
|
||||
</div>
|
||||
<script>window.__timelines = window.__timelines || {}; window.__timelines["main"] = gsap.timeline({ paused: true });</script>
|
||||
</body></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);
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -45,6 +45,12 @@ describe("inlineSubCompositions – #ID selector scoping divergence", () => {
|
||||
label: "valid-parse-empty-body",
|
||||
html: "<!doctype html><html><head></head><body></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<string | undefined> = [];
|
||||
|
||||
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"]')!;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
/** @deprecated Import from @hyperframes/parsers/sub-composition-validity */
|
||||
export * from "@hyperframes/parsers/sub-composition-validity";
|
||||
@@ -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";
|
||||
|
||||
@@ -54,6 +54,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@hyperframes/parsers": "workspace:*",
|
||||
"linkedom": "^0.18.12",
|
||||
"postcss": "^8.5.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -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 `<html><body>
|
||||
<div data-composition-id="${compId}" data-width="1920" data-height="1080" data-start="0" data-duration="10"></div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
|
||||
<script>window.__timelines = window.__timelines || {}; window.__timelines["${compId}"] = gsap.timeline({ paused: true });</script>
|
||||
</body></html>`;
|
||||
}
|
||||
|
||||
let dirs: string[] = [];
|
||||
|
||||
function makeProject(indexHtml: string, subComps?: Record<string, string>): 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 `<html><body>
|
||||
<div data-composition-id="main" data-width="1920" data-height="1080" data-start="0" data-duration="10">
|
||||
<div data-composition-src="${srcPath}" data-composition-id="scene-title" data-start="0" data-duration="5"></div>
|
||||
</div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
|
||||
<script>window.__timelines = window.__timelines || {}; window.__timelines["main"] = gsap.timeline({ paused: true });</script>
|
||||
</body></html>`;
|
||||
}
|
||||
|
||||
function validSubCompHtml(): string {
|
||||
return `<!doctype html><html><body>
|
||||
<div data-composition-id="scene-title" data-width="1920" data-height="1080">
|
||||
<div class="title">Hello</div>
|
||||
</div>
|
||||
</body></html>`;
|
||||
}
|
||||
|
||||
// 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<string, string>,
|
||||
): 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": "<!doctype html><html><body><p>TODO: scene content</p></body></html>",
|
||||
});
|
||||
|
||||
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 = `<html><body>
|
||||
<div data-composition-id="main" data-width="1920" data-height="1080" data-start="0" data-duration="10">
|
||||
<div data-composition-src="compositions/scene-title.html" data-composition-id="a" data-start="0" data-duration="5"></div>
|
||||
<div data-composition-src="compositions/scene-title.html" data-composition-id="b" data-start="5" data-duration="5"></div>
|
||||
</div>
|
||||
<script>window.__timelines = window.__timelines || {}; window.__timelines["main"] = gsap.timeline({ paused: true });</script>
|
||||
</body></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"),
|
||||
`<!doctype html><html><body>
|
||||
<div data-composition-id="old-draft" data-width="1920" data-height="1080">
|
||||
<div data-composition-src="compositions/does-not-exist.html" data-composition-id="ghost"></div>
|
||||
</div>
|
||||
</body></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"),
|
||||
`<!doctype html><html><body>
|
||||
<div data-composition-id="scene-title" data-width="1920" data-height="1080">
|
||||
<div data-composition-src="compositions/does-not-exist.html" data-composition-id="child"></div>
|
||||
</div>
|
||||
</body></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");
|
||||
});
|
||||
});
|
||||
@@ -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<ProjectLintResult
|
||||
...lintTextureMaskAssetNotFound(projectDir, allHtmlSources),
|
||||
...lintMultipleRootCompositions(projectDir),
|
||||
...lintDuplicateAudioTracks(allHtmlSources),
|
||||
...lintMissingOrEmptySubComposition(projectDir, rootHtml),
|
||||
];
|
||||
if (projectFindings.length > 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<string, { srcPath: string; problem: string }>();
|
||||
const visited = new Set<string>();
|
||||
|
||||
// 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 <template> or <body> containing an element with ` +
|
||||
`data-composition-id, data-width, and data-height. Preview/studio still tolerates and skips the ` +
|
||||
"scene while you author it. If a scene-authoring step is still running, wait for it to finish " +
|
||||
"before referencing the file, or re-run the step that generates it.",
|
||||
});
|
||||
}
|
||||
|
||||
return findings;
|
||||
}
|
||||
|
||||
@@ -81,6 +81,12 @@
|
||||
"node": "./dist/composition.js",
|
||||
"import": "./src/composition.ts",
|
||||
"types": "./src/composition.ts"
|
||||
},
|
||||
"./sub-composition-validity": {
|
||||
"bun": "./src/subCompositionValidity.ts",
|
||||
"node": "./dist/subCompositionValidity.js",
|
||||
"import": "./src/subCompositionValidity.ts",
|
||||
"types": "./src/subCompositionValidity.ts"
|
||||
}
|
||||
},
|
||||
"publishConfig": {
|
||||
@@ -130,6 +136,10 @@
|
||||
"./composition": {
|
||||
"import": "./dist/composition.js",
|
||||
"types": "./dist/composition.d.ts"
|
||||
},
|
||||
"./sub-composition-validity": {
|
||||
"import": "./dist/subCompositionValidity.js",
|
||||
"types": "./dist/subCompositionValidity.d.ts"
|
||||
}
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*
|
||||
* htmlParser.ts's null-`documentElement` guards, exercised against
|
||||
* **linkedom's** `DOMParser` — the implementation the CLI actually polyfills
|
||||
* onto `globalThis` in production (see `packages/cli/src/utils/dom.ts`,
|
||||
* `ensureDOMParser`). The rest of htmlParser.test.ts runs under
|
||||
* `@vitest-environment jsdom`, whose spec-compliant `DOMParser` always
|
||||
* synthesizes a full `<html><head><body>` document — even for `""` or
|
||||
* non-HTML text — so `documentElement` is never null there and the guards
|
||||
* added in this file can't be exercised under jsdom at all. linkedom
|
||||
* deviates from spec on exactly this point (confirmed directly against the
|
||||
* installed package): `parseFromString("", ...)` and
|
||||
* `parseFromString("just some text", ...)` both return a document with
|
||||
* `documentElement === null`. That's the actual, live crash path in the CLI
|
||||
* (`hyperframes info`, `hyperframes inspect`, Studio's edit endpoints, etc.)
|
||||
* this test suite reproduces and guards.
|
||||
*/
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { DOMParser as LinkedomDOMParser } from "linkedom";
|
||||
import {
|
||||
CompositionHtmlParseError,
|
||||
parseHtml,
|
||||
updateElementInHtml,
|
||||
addElementToHtml,
|
||||
removeElementFromHtml,
|
||||
validateCompositionHtml,
|
||||
extractCompositionMetadata,
|
||||
} from "./htmlParser.js";
|
||||
|
||||
const originalDOMParser = (globalThis as Record<string, unknown>).DOMParser;
|
||||
|
||||
beforeAll(() => {
|
||||
(globalThis as Record<string, unknown>).DOMParser = LinkedomDOMParser;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
(globalThis as Record<string, unknown>).DOMParser = originalDOMParser;
|
||||
});
|
||||
|
||||
const EMPTY_INPUTS = ["", " \n\t ", "just some plain text, no tags at all"];
|
||||
|
||||
describe("htmlParser.ts null-documentElement guards (linkedom, matches CLI runtime)", () => {
|
||||
it.each(EMPTY_INPUTS)("parseHtml throws CompositionHtmlParseError for %j", (html) => {
|
||||
expect(() => parseHtml(html)).toThrow(CompositionHtmlParseError);
|
||||
expect(() => parseHtml(html)).toThrow(/empty or could not be parsed/);
|
||||
});
|
||||
|
||||
it.each(EMPTY_INPUTS)(
|
||||
"updateElementInHtml returns the input unchanged when the target id isn't found (never reaches documentElement)",
|
||||
(html) => {
|
||||
// getElementById/queryByAttr both miss on a documentElement-less doc, so
|
||||
// this hits the existing `if (!el) return html;` early return before
|
||||
// ever touching documentElement — same safe behavior as "id not found"
|
||||
// on a normal document. No guard needed on this path; asserted here so
|
||||
// a future refactor that removes the early return doesn't regress into
|
||||
// the null-deref crash.
|
||||
expect(updateElementInHtml(html, "some-id", { name: "x" })).toBe(html);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(EMPTY_INPUTS)("addElementToHtml throws CompositionHtmlParseError for %j", (html) => {
|
||||
expect(() =>
|
||||
addElementToHtml(html, {
|
||||
type: "text",
|
||||
name: "Title",
|
||||
startTime: 0,
|
||||
duration: 5,
|
||||
zIndex: 0,
|
||||
} as never),
|
||||
).toThrow(CompositionHtmlParseError);
|
||||
});
|
||||
|
||||
it.each(EMPTY_INPUTS)("removeElementFromHtml throws CompositionHtmlParseError for %j", (html) => {
|
||||
expect(() => removeElementFromHtml(html, "some-id")).toThrow(CompositionHtmlParseError);
|
||||
});
|
||||
|
||||
it.each(EMPTY_INPUTS)(
|
||||
"extractCompositionMetadata throws CompositionHtmlParseError for %j",
|
||||
(html) => {
|
||||
expect(() => extractCompositionMetadata(html)).toThrow(CompositionHtmlParseError);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(EMPTY_INPUTS)(
|
||||
"validateCompositionHtml returns a typed failure (not a throw) for %j",
|
||||
(html) => {
|
||||
const result = validateCompositionHtml(html);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors[0]).toMatch(/empty or could not be parsed/);
|
||||
},
|
||||
);
|
||||
|
||||
it("happy path: parseHtml succeeds against linkedom for well-formed HTML", () => {
|
||||
const html = `<!doctype html><html><body>
|
||||
<div id="stage">
|
||||
<div id="text1" data-start="0" data-end="5" data-name="Title"><div>Hello</div></div>
|
||||
</div>
|
||||
</body></html>`;
|
||||
const result = parseHtml(html);
|
||||
expect(result.elements).toHaveLength(1);
|
||||
expect(result.elements[0]?.name).toBe("Title");
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,22 @@ import { removeAnimationFromScript } from "./gsapWriterAcorn.js";
|
||||
|
||||
const MEDIA_TYPES = new Set<string>(["video", "image", "audio"]);
|
||||
|
||||
/**
|
||||
* Thrown by htmlParser functions when the input HTML is empty or does not
|
||||
* parse to a document with a `documentElement` — the condition that,
|
||||
* unguarded, previously surfaced as a raw
|
||||
* `Cannot read properties of null (reading '...')` /
|
||||
* `Cannot destructure property 'firstElementChild' of 'documentElement' as
|
||||
* it is null` crash deep inside the DOM implementation instead of a clear,
|
||||
* catchable error naming which function received bad input.
|
||||
*/
|
||||
export class CompositionHtmlParseError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "CompositionHtmlParseError";
|
||||
}
|
||||
}
|
||||
|
||||
export interface ParsedHtml {
|
||||
elements: TimelineElement[];
|
||||
gsapScript: string | null;
|
||||
@@ -117,6 +133,7 @@ function parseResolutionFromCss(doc: Document, cssText: string | null): CanvasRe
|
||||
|
||||
function parseResolutionFromHtml(doc: Document): CanvasResolution | null {
|
||||
const htmlEl = doc.documentElement;
|
||||
if (!htmlEl) return null;
|
||||
const resolutionAttr = htmlEl.getAttribute("data-resolution");
|
||||
if (
|
||||
resolutionAttr === "landscape" ||
|
||||
@@ -166,6 +183,9 @@ export function parseHtml(html: string): ParsedHtml {
|
||||
let idCounter = 0;
|
||||
|
||||
const htmlEl = doc.documentElement;
|
||||
if (!htmlEl) {
|
||||
throw new CompositionHtmlParseError("parseHtml: input HTML is empty or could not be parsed");
|
||||
}
|
||||
const customStylesAttr = htmlEl.getAttribute("data-custom-styles");
|
||||
let customStyles: string | null = null;
|
||||
if (customStylesAttr) {
|
||||
@@ -591,6 +611,11 @@ export function updateElementInHtml(
|
||||
}
|
||||
}
|
||||
|
||||
if (!doc.documentElement) {
|
||||
throw new CompositionHtmlParseError(
|
||||
"updateElementInHtml: input HTML is empty or could not be parsed",
|
||||
);
|
||||
}
|
||||
return "<!DOCTYPE html>\n" + doc.documentElement.outerHTML;
|
||||
}
|
||||
|
||||
@@ -601,6 +626,12 @@ export function addElementToHtml(
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, "text/html");
|
||||
|
||||
if (!doc.documentElement) {
|
||||
throw new CompositionHtmlParseError(
|
||||
"addElementToHtml: input HTML is empty or could not be parsed",
|
||||
);
|
||||
}
|
||||
|
||||
// Prefer zoom container, fall back to stage, then container, then body
|
||||
const container =
|
||||
doc.querySelector("#stage-zoom-container") ||
|
||||
@@ -608,6 +639,10 @@ export function addElementToHtml(
|
||||
doc.querySelector("#stage") ||
|
||||
doc.body;
|
||||
|
||||
if (!container) {
|
||||
throw new CompositionHtmlParseError("addElementToHtml: input HTML has no <body>");
|
||||
}
|
||||
|
||||
const id = element.id || `element-${Date.now()}`;
|
||||
|
||||
let newEl: Element;
|
||||
@@ -716,6 +751,11 @@ function cascadeRemoveGsapById(doc: Document, elementId: string): void {
|
||||
export function removeElementFromHtml(html: string, elementId: string): string {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, "text/html");
|
||||
if (!doc.documentElement) {
|
||||
throw new CompositionHtmlParseError(
|
||||
"removeElementFromHtml: input HTML is empty or could not be parsed",
|
||||
);
|
||||
}
|
||||
doc.getElementById(elementId)?.remove();
|
||||
cascadeRemoveGsapById(doc, elementId);
|
||||
return "<!DOCTYPE html>\n" + doc.documentElement.outerHTML;
|
||||
@@ -731,6 +771,11 @@ export function extractCompositionMetadata(html: string): CompositionMetadata {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, "text/html");
|
||||
const htmlEl = doc.documentElement;
|
||||
if (!htmlEl) {
|
||||
throw new CompositionHtmlParseError(
|
||||
"extractCompositionMetadata: input HTML is empty or could not be parsed",
|
||||
);
|
||||
}
|
||||
|
||||
const compositionId = htmlEl.getAttribute("data-composition-id");
|
||||
const durationStr = htmlEl.getAttribute("data-composition-duration");
|
||||
@@ -798,6 +843,14 @@ export function validateCompositionHtml(html: string): ValidationResult {
|
||||
const doc = parser.parseFromString(html, "text/html");
|
||||
const htmlEl = doc.documentElement;
|
||||
|
||||
if (!htmlEl) {
|
||||
return {
|
||||
valid: false,
|
||||
errors: ["Composition HTML is empty or could not be parsed"],
|
||||
warnings: [],
|
||||
};
|
||||
}
|
||||
|
||||
const compositionId = htmlEl.getAttribute("data-composition-id");
|
||||
if (!compositionId) {
|
||||
errors.push("Missing data-composition-id attribute on <html> element");
|
||||
|
||||
@@ -2,6 +2,7 @@ export * from "./types.js";
|
||||
export * from "./gsapParserExports.js";
|
||||
export * from "./htmlParser.js";
|
||||
export * from "./hfIds.js";
|
||||
export * from "./subCompositionValidity.js";
|
||||
export { unrollComputedTimeline } from "./gsapUnroll.js";
|
||||
export { queryByAttr } from "./utils/cssSelector.js";
|
||||
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseHTML } from "linkedom";
|
||||
import { checkSubCompositionUsability, type ParsableDocumentLike } from "./subCompositionValidity";
|
||||
|
||||
function parse(html: string): ParsableDocumentLike {
|
||||
return parseHTML(html).document as unknown as ParsableDocumentLike;
|
||||
}
|
||||
|
||||
const VALID_HTML = `<template id="intro-template">
|
||||
<div id="intro" data-composition-id="intro" data-width="1920" data-height="1080">
|
||||
<div class="title">HELLO WORLD</div>
|
||||
</div>
|
||||
</template>`;
|
||||
|
||||
const VALID_HTML_NO_TEMPLATE = `<!doctype html>
|
||||
<html>
|
||||
<head></head>
|
||||
<body>
|
||||
<div data-composition-id="intro" data-width="1920" data-height="1080">
|
||||
<div class="title">HELLO WORLD</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
describe("checkSubCompositionUsability", () => {
|
||||
it("accepts a valid <template>-based sub-composition", () => {
|
||||
expect(checkSubCompositionUsability(VALID_HTML, parse)).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it("accepts a valid full-document sub-composition with no <template>", () => {
|
||||
expect(checkSubCompositionUsability(VALID_HTML_NO_TEMPLATE, parse)).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it("rejects an empty string without ever calling parseHtml (avoids the linkedom null-deref crash)", () => {
|
||||
let parseCalled = false;
|
||||
const spyParse = (html: string): ParsableDocumentLike => {
|
||||
parseCalled = true;
|
||||
return parse(html);
|
||||
};
|
||||
const result = checkSubCompositionUsability("", spyParse);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.reason).toBe("empty");
|
||||
expect(parseCalled).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects whitespace-only content", () => {
|
||||
const result = checkSubCompositionUsability(" \n\t ", parse);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.reason).toBe("empty");
|
||||
});
|
||||
|
||||
it("rejects null/undefined (file could not be read)", () => {
|
||||
expect(checkSubCompositionUsability(null, parse).ok).toBe(false);
|
||||
expect(checkSubCompositionUsability(undefined, parse).ok).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a valid-but-empty document (parses fine, no body content)", () => {
|
||||
const result = checkSubCompositionUsability(
|
||||
"<!doctype html><html><head></head><body></body></html>",
|
||||
parse,
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.reason).toBe("no-content");
|
||||
});
|
||||
|
||||
it("rejects plain text with no tags — this is the exact input that crashes linkedom's Document.head getter", () => {
|
||||
// linkedom's parseHTML("just some text") returns documentElement === null.
|
||||
// Any caller that touches .head/.body on that document (as linkedom's own
|
||||
// internals do) throws "Cannot destructure property 'firstElementChild'
|
||||
// of 'documentElement' as it is null." checkSubCompositionUsability must
|
||||
// detect this from `documentElement` alone, before touching .head/.body.
|
||||
const result = checkSubCompositionUsability("just some plain text, no tags at all", parse);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.reason).toBe("unparsable");
|
||||
});
|
||||
|
||||
it("rejects a <template> with only whitespace content", () => {
|
||||
const result = checkSubCompositionUsability("<template> \n </template>", parse);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.reason).toBe("no-content");
|
||||
});
|
||||
|
||||
it("rejects non-empty, parseable HTML with no data-composition-id anywhere (e.g. an AI-authored placeholder scene)", () => {
|
||||
const result = checkSubCompositionUsability(
|
||||
"<!doctype html><html><head></head><body><p>TODO: scene content</p></body></html>",
|
||||
parse,
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.reason).toBe("no-composition-root");
|
||||
});
|
||||
|
||||
it("rejects a <template> with content but no data-composition-id element inside", () => {
|
||||
const result = checkSubCompositionUsability(
|
||||
'<template><div class="title">HELLO WORLD</div></template>',
|
||||
parse,
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.reason).toBe("no-composition-root");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Shared "is this sub-composition file usable?" check.
|
||||
*
|
||||
* `data-composition-src` files are authored by AI agents far more often than
|
||||
* by humans clicking a UI. The dominant real-world failure is a scene worker
|
||||
* that dies mid-write (or a step that references a scene before writing it),
|
||||
* leaving an empty or partial `compositions/scene-*.html` on disk. Historically
|
||||
* this surfaced in three different ways depending on which code path touched
|
||||
* the file first:
|
||||
*
|
||||
* 1. A raw crash inside linkedom's `Document.head` getter — destructuring
|
||||
* `firstElementChild` off a `null` `documentElement` — when the file is
|
||||
* empty or contains no parseable markup.
|
||||
* 2. An actionable-but-late `Error` thrown deep inside the render compiler
|
||||
* (see git history: #1364), which aborted the whole render.
|
||||
* 3. A silent skip (see git history: #1678) that drops the scene from the
|
||||
* output with only a `console.warn`, producing a materially broken
|
||||
* video (missing scene, no error surfaced anywhere) with no clear
|
||||
* signal to the caller.
|
||||
*
|
||||
* This module gives every consumer (lint, render pre-flight, the tolerant
|
||||
* inliner) a single, shared definition of "usable" so they can never
|
||||
* disagree about whether a given file would render something. It lives in
|
||||
* `@hyperframes/parsers` (rather than `@hyperframes/core`, where it
|
||||
* originated) because `@hyperframes/lint` needs it too, and `lint` cannot
|
||||
* depend on `core` — `core` already depends on `lint` — so this shared,
|
||||
* dependency-free check lives in the common ancestor package both `core`
|
||||
* and `lint` already depend on.
|
||||
*
|
||||
* `inlineSubCompositions.ts` (in `@hyperframes/core`) intentionally stays
|
||||
* tolerant (skip + continue) for the preview/studio bundling path, where
|
||||
* partial content while iterating is expected. `lint` and the render
|
||||
* pre-flight check (`packages/producer/src/services/htmlCompiler.ts`) use
|
||||
* this helper to fail loudly and name the exact offending file, because a
|
||||
* render that silently drops a scene is strictly worse than a render that
|
||||
* refuses to start.
|
||||
*/
|
||||
|
||||
export type SubCompositionValidityReason =
|
||||
| "empty"
|
||||
| "unparsable"
|
||||
| "no-content"
|
||||
| "no-composition-root";
|
||||
|
||||
export interface SubCompositionValidity {
|
||||
ok: boolean;
|
||||
/** Present when `ok` is false. */
|
||||
reason?: SubCompositionValidityReason;
|
||||
/** Human-readable detail suitable for direct inclusion in an error message. */
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
/** Minimal shape both linkedom's `Document` and `happy-dom`'s satisfy. */
|
||||
export interface ParsableDocumentLike {
|
||||
documentElement: { outerHTML?: string } | null;
|
||||
body?: { innerHTML?: string | null } | null;
|
||||
querySelector(selector: string): { innerHTML?: string | null } | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether `html` (the raw file contents resolved for a
|
||||
* `data-composition-src` reference) is non-empty and parses to a document
|
||||
* that actually contains renderable content.
|
||||
*
|
||||
* Mirrors the content-detection steps in `inlineSubCompositions` exactly
|
||||
* (resolve → parse → find `<template>` or `<body>` content → parse that →
|
||||
* confirm a `[data-composition-id]` root exists in it), so a file that
|
||||
* passes this check is guaranteed to produce non-empty output from the
|
||||
* inliner, and a file that fails it is guaranteed to hit one of the
|
||||
* inliner's `onMissingComposition` branches.
|
||||
*
|
||||
* @param html Raw file contents, or `null`/`undefined` if the file could not
|
||||
* be read (e.g. missing from disk). Callers should distinguish "missing"
|
||||
* from "empty" in their own error message using a separate existence
|
||||
* check — this function only inspects content.
|
||||
* @param parseHtml Parse an HTML string into a document. Pass linkedom's
|
||||
* `parseHTML(html).document` or the core bundler's `parseHTMLContent`.
|
||||
*/
|
||||
export function checkSubCompositionUsability(
|
||||
html: string | null | undefined,
|
||||
parseHtml: (html: string) => ParsableDocumentLike,
|
||||
): SubCompositionValidity {
|
||||
if (html == null || !html.trim()) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "empty",
|
||||
detail: "the file is empty (0 bytes or whitespace-only)",
|
||||
};
|
||||
}
|
||||
|
||||
const compDoc = parseHtml(html);
|
||||
if (!compDoc.documentElement) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "unparsable",
|
||||
detail: "the file's contents could not be parsed as HTML",
|
||||
};
|
||||
}
|
||||
|
||||
// Find content: prefer <template>, fall back to <body> — same precedence
|
||||
// inlineSubCompositions uses when extracting the sub-composition's markup.
|
||||
const contentRoot = compDoc.querySelector("template");
|
||||
const contentHtml = contentRoot ? contentRoot.innerHTML || "" : compDoc.body?.innerHTML || "";
|
||||
if (!contentHtml.trim()) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "no-content",
|
||||
detail: "the file has no <template> or <body> content to render",
|
||||
};
|
||||
}
|
||||
|
||||
const contentDoc = parseHtml(contentHtml);
|
||||
if (!contentDoc.documentElement) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "unparsable",
|
||||
detail: "the file's <template>/<body> contents could not be parsed as HTML",
|
||||
};
|
||||
}
|
||||
|
||||
// The content must contain an actual composition root — the element the
|
||||
// inliner looks for (`contentDoc.querySelector("[data-composition-id]")`)
|
||||
// to know what to inject into the host. Well-formed but marker-free HTML
|
||||
// (e.g. an AI-authored placeholder like `<body><p>TODO</p></body>`) parses
|
||||
// fine and has non-empty content, but has nothing for the inliner to find.
|
||||
if (!contentDoc.querySelector("[data-composition-id]")) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "no-composition-root",
|
||||
detail:
|
||||
"the file's <template>/<body> content has no element with a data-composition-id attribute",
|
||||
};
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
@@ -13,6 +13,7 @@ export default defineConfig({
|
||||
slideshow: "src/slideshow/index.ts",
|
||||
assets: "src/assets.ts",
|
||||
composition: "src/composition.ts",
|
||||
subCompositionValidity: "src/subCompositionValidity.ts",
|
||||
},
|
||||
format: ["esm"],
|
||||
outDir: "dist",
|
||||
|
||||
@@ -71,6 +71,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",
|
||||
|
||||
@@ -492,31 +492,160 @@ describe("detectRenderModeHints", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("compileForRender skips empty sub-composition files instead of aborting", async () => {
|
||||
const projectDir = mkdtempSync(join(tmpdir(), "hf-empty-subcomp-"));
|
||||
// Shared fixture builder for the assertSubCompositionsUsable / EmptyCompositionError
|
||||
// pre-flight tests below. `subCompFiles` is a map of compositions/-relative
|
||||
// filename to raw file content (empty string / malformed text / valid HTML).
|
||||
// `hosts` is the data-composition-src host markup injected into the root
|
||||
// composition's timeline div, in order, at 1s each.
|
||||
function makeSubCompProject(
|
||||
dirPrefix: string,
|
||||
hosts: Array<{ id: string; src: string }>,
|
||||
subCompFiles: Record<string, string>,
|
||||
): string {
|
||||
const projectDir = mkdtempSync(join(tmpdir(), dirPrefix));
|
||||
const compositionsDir = join(projectDir, "compositions");
|
||||
mkdirSync(compositionsDir, { recursive: true });
|
||||
const hostMarkup = hosts
|
||||
.map(
|
||||
(h, i) =>
|
||||
`<div data-composition-id="${h.id}" data-composition-src="${h.src}" data-start="${i}" data-duration="1"></div>`,
|
||||
)
|
||||
.join("\n ");
|
||||
writeFileSync(
|
||||
join(projectDir, "index.html"),
|
||||
`<!DOCTYPE html>
|
||||
<html>
|
||||
<head></head>
|
||||
<body>
|
||||
<div data-composition-id="main" data-width="100" data-height="100" data-start="0" data-duration="1">
|
||||
<div data-composition-id="intro" data-composition-src="compositions/intro.html" data-start="0" data-duration="1"></div>
|
||||
<div data-composition-id="main" data-width="100" data-height="100" data-start="0" data-duration="${hosts.length}">
|
||||
${hostMarkup}
|
||||
</div>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
window.__timelines.main = { duration: function() { return 1; } };
|
||||
window.__timelines.main = { duration: function() { return ${hosts.length}; } };
|
||||
</script>
|
||||
</body>
|
||||
</html>`,
|
||||
);
|
||||
writeFileSync(join(compositionsDir, "intro.html"), "");
|
||||
for (const [name, content] of Object.entries(subCompFiles)) {
|
||||
writeFileSync(join(compositionsDir, name), content);
|
||||
}
|
||||
return projectDir;
|
||||
}
|
||||
|
||||
function validSubCompHtml(compId: string, label: string, nestedSrc?: string): string {
|
||||
const inner = nestedSrc
|
||||
? `<div data-composition-id="${label}" data-composition-src="${nestedSrc}" data-start="0" data-duration="1"></div>`
|
||||
: `<div class="title">${label}</div>`;
|
||||
return `<!doctype html><html><body>
|
||||
<div data-composition-id="${compId}" data-width="100" data-height="100">
|
||||
${inner}
|
||||
</div>
|
||||
</body></html>`;
|
||||
}
|
||||
|
||||
it("compileForRender aborts with EmptyCompositionError when a sub-composition file is empty", async () => {
|
||||
// The shared inliner (inlineSubCompositions.ts, packages/core) stays
|
||||
// tolerant of empty/unparsable sub-compositions — it skips the scene and
|
||||
// keeps going, silently, so preview/studio can keep iterating on a
|
||||
// partially-authored project. That tolerance is intentional and tested
|
||||
// separately in packages/core/src/compiler/inlineSubCompositions.test.ts.
|
||||
//
|
||||
// But a *render* that silently drops a scene produces a materially
|
||||
// broken video with no visible error — worse than refusing to render.
|
||||
// compileForRender (render-only) runs a pre-flight check
|
||||
// (assertSubCompositionsUsable, using the same checkSubCompositionUsability
|
||||
// helper the inliner and hyperframes lint use) before any compilation
|
||||
// work starts, and aborts immediately instead of silently producing a
|
||||
// broken render 45+ seconds later.
|
||||
const projectDir = makeSubCompProject(
|
||||
"hf-empty-subcomp-",
|
||||
[{ id: "intro", src: "compositions/intro.html" }],
|
||||
{ "intro.html": "" },
|
||||
);
|
||||
|
||||
await expect(
|
||||
compileForRender(projectDir, join(projectDir, "index.html"), projectDir),
|
||||
).rejects.toThrow(/compositions\/intro\.html/);
|
||||
});
|
||||
|
||||
it("compileForRender aborts naming every unusable sub-composition at once", async () => {
|
||||
const projectDir = makeSubCompProject(
|
||||
"hf-empty-subcomp-multi-",
|
||||
[
|
||||
{ id: "intro", src: "compositions/intro.html" },
|
||||
{ id: "outro", src: "compositions/outro.html" },
|
||||
],
|
||||
{ "intro.html": "", "outro.html": "not valid html at all, just text" },
|
||||
);
|
||||
|
||||
await expect(
|
||||
compileForRender(projectDir, join(projectDir, "index.html"), projectDir),
|
||||
).rejects.toThrow(/compositions\/intro\.html[\s\S]*compositions\/outro\.html/);
|
||||
});
|
||||
|
||||
it("compileForRender aborts when a data-composition-src reference points at a missing file", async () => {
|
||||
const projectDir = makeSubCompProject(
|
||||
"hf-missing-subcomp-",
|
||||
[{ id: "intro", src: "compositions/does-not-exist.html" }],
|
||||
{},
|
||||
);
|
||||
|
||||
await expect(
|
||||
compileForRender(projectDir, join(projectDir, "index.html"), projectDir),
|
||||
).rejects.toThrow(/compositions\/does-not-exist\.html/);
|
||||
});
|
||||
|
||||
it("compileForRender succeeds when the sub-composition file is valid (happy path)", async () => {
|
||||
const projectDir = makeSubCompProject(
|
||||
"hf-valid-subcomp-",
|
||||
[{ id: "intro", src: "compositions/intro.html" }],
|
||||
{ "intro.html": validSubCompHtml("intro", "Hello") },
|
||||
);
|
||||
|
||||
const result = await compileForRender(projectDir, join(projectDir, "index.html"), projectDir);
|
||||
expect(result.html).toContain("data-composition-id");
|
||||
});
|
||||
|
||||
it("compileForRender succeeds when a valid sub-composition itself references a nested valid sub-composition", async () => {
|
||||
// Regression guard: data-composition-src is always root-relative, even
|
||||
// from within a nested sub-composition (matches parseSubCompositions,
|
||||
// which threads the original projectDir unchanged through every
|
||||
// recursion level — never dirname(parentFile)). The pre-flight check
|
||||
// must resolve nested references the same way, or it aborts renders
|
||||
// that would have actually succeeded (false-positive abort).
|
||||
//
|
||||
// parent.html lives in compositions/ and references child.html using the
|
||||
// same root-relative "compositions/..." form — not "./child.html".
|
||||
const projectDir = makeSubCompProject(
|
||||
"hf-nested-subcomp-valid-",
|
||||
[{ id: "parent", src: "compositions/parent.html" }],
|
||||
{
|
||||
"parent.html": validSubCompHtml("parent", "child", "compositions/child.html"),
|
||||
"child.html": validSubCompHtml("child", "Nested Hello"),
|
||||
},
|
||||
);
|
||||
|
||||
const result = await compileForRender(projectDir, join(projectDir, "index.html"), projectDir);
|
||||
expect(result.html).toContain("data-composition-id");
|
||||
});
|
||||
|
||||
it("compileForRender aborts naming a broken nested (grandchild) sub-composition", async () => {
|
||||
// child.html is empty — the grandchild scene, referenced root-relative
|
||||
// from parent.html which itself lives in compositions/.
|
||||
const projectDir = makeSubCompProject(
|
||||
"hf-nested-subcomp-broken-",
|
||||
[{ id: "parent", src: "compositions/parent.html" }],
|
||||
{
|
||||
"parent.html": validSubCompHtml("parent", "child", "compositions/child.html"),
|
||||
"child.html": "",
|
||||
},
|
||||
);
|
||||
|
||||
await expect(
|
||||
compileForRender(projectDir, join(projectDir, "index.html"), projectDir),
|
||||
).rejects.toThrow(/compositions\/child\.html/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("detectShaderTransitionUsage", () => {
|
||||
|
||||
@@ -24,6 +24,10 @@ import {
|
||||
type UnresolvedElement,
|
||||
} from "@hyperframes/core";
|
||||
import { inlineSubCompositions as inlineSubCompositionsShared } from "@hyperframes/core/compiler";
|
||||
import {
|
||||
checkSubCompositionUsability,
|
||||
type ParsableDocumentLike,
|
||||
} from "@hyperframes/parsers/sub-composition-validity";
|
||||
import { extractMediaMetadata, extractAudioMetadata } from "../utils/ffprobe.js";
|
||||
import { isPathInside, toExternalAssetKey } from "../utils/paths.js";
|
||||
import {
|
||||
@@ -59,6 +63,131 @@ export interface CompiledComposition {
|
||||
hasShaderTransitions: boolean;
|
||||
}
|
||||
|
||||
/** Adapts linkedom's `parseHTML` to the `checkSubCompositionUsability` contract. */
|
||||
function parseSubCompHtmlForValidity(html: string): ParsableDocumentLike {
|
||||
return parseHTML(html).document as unknown as ParsableDocumentLike;
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown by {@link assertSubCompositionsUsable} when one or more
|
||||
* `data-composition-src` references resolve to a missing, empty, or
|
||||
* unparsable file. This is the render-path enforcement of the #1 render
|
||||
* failure bucket in production telemetry: a scene-authoring step (most
|
||||
* commonly an AI agent) writes the `data-composition-src` reference before,
|
||||
* or without ever, writing valid content into the scene file.
|
||||
*
|
||||
* Unlike the tolerant inliner (`packages/core/src/compiler/inlineSubCompositions.ts`,
|
||||
* intentionally kept lenient for preview/studio so mid-authoring iteration
|
||||
* doesn't break bundling), a render that silently drops a scene produces a
|
||||
* materially broken video with no visible error — strictly worse than
|
||||
* refusing to render. This check runs before any compilation work starts so
|
||||
* the failure is immediate and names every offending file at once, instead
|
||||
* of surfacing 45+ seconds later as a `pollSubCompositionTimelines` timeout
|
||||
* or a raw `Cannot destructure property 'firstElementChild' of
|
||||
* 'documentElement' as it is null` crash deep inside linkedom.
|
||||
*
|
||||
* Not exported — nothing needs `instanceof` narrowing on this today. Callers
|
||||
* catch it generically (`catch (err: unknown)`, matching on `.message`) the
|
||||
* same way they handle every other compile-time failure. Kept as a class
|
||||
* (not a plain `throw new Error(...)`) so the aggregated multi-file message
|
||||
* construction has a single, testable home.
|
||||
*/
|
||||
class EmptyCompositionError extends Error {
|
||||
readonly code = "EMPTY_COMPOSITION" as const;
|
||||
readonly problems: ReadonlyArray<{ srcPath: string; detail: string }>;
|
||||
|
||||
constructor(problems: ReadonlyArray<{ srcPath: string; detail: string }>) {
|
||||
const lines = problems.map((p) => ` - ${p.srcPath}: ${p.detail}`);
|
||||
super(
|
||||
`${problems.length} composition file${problems.length === 1 ? "" : "s"} referenced by ` +
|
||||
`data-composition-src cannot be rendered:\n${lines.join("\n")}\n\n` +
|
||||
"Check that each file referenced by data-composition-src contains valid HTML with a " +
|
||||
"<template> or <body> containing a [data-composition-id] element. If a scene-authoring " +
|
||||
"step is still running, wait for it to finish before referencing the file.",
|
||||
);
|
||||
this.name = "EmptyCompositionError";
|
||||
this.problems = problems;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively walk every `data-composition-src` reference reachable from
|
||||
* `html` (including nested sub-compositions) and verify each resolves to a
|
||||
* usable file — exists, non-empty, parses to HTML with renderable content.
|
||||
* Uses the same `checkSubCompositionUsability` helper the tolerant inliner
|
||||
* and `hyperframes lint` use, so all three agree on what counts as usable.
|
||||
*
|
||||
* Throws {@link EmptyCompositionError} naming every offending file at once
|
||||
* (not just the first one hit) if any reference is unusable. Call this
|
||||
* before any compilation work starts — it deliberately duplicates a small
|
||||
* amount of file-reading work that `parseSubCompositions` also does, in
|
||||
* exchange for failing in milliseconds instead of after the browser has
|
||||
* already launched and waited out a capture timeout.
|
||||
*/
|
||||
// fallow-ignore-next-line complexity
|
||||
function assertSubCompositionsUsable(
|
||||
html: string,
|
||||
projectDir: string,
|
||||
visited: Set<string> = new Set(),
|
||||
): void {
|
||||
const { document } = parseHTML(html);
|
||||
const hosts = [...document.querySelectorAll("[data-composition-src]")];
|
||||
const problems: Array<{ srcPath: string; detail: string }> = [];
|
||||
|
||||
for (const el of hosts) {
|
||||
const srcPath = el.getAttribute("data-composition-src");
|
||||
if (!srcPath) continue;
|
||||
if (/^__[A-Z_]+__$/.test(srcPath)) continue; // template placeholder, not a real reference — matches lint's skip
|
||||
|
||||
const filePath = resolve(projectDir, srcPath);
|
||||
// Circular reference guard. parseSubCompositions (below) silently
|
||||
// `continue`s on a repeat visit with no reporting at all — mirror that
|
||||
// silence here rather than pretend it surfaces an error somewhere else.
|
||||
if (visited.has(filePath)) continue;
|
||||
|
||||
if (!existsSync(filePath)) {
|
||||
problems.push({ srcPath, detail: "the file does not exist" });
|
||||
continue;
|
||||
}
|
||||
|
||||
const fileHtml = readFileSync(filePath, "utf-8");
|
||||
const validity = checkSubCompositionUsability(fileHtml, parseSubCompHtmlForValidity);
|
||||
if (!validity.ok) {
|
||||
problems.push({
|
||||
srcPath,
|
||||
detail: validity.detail ?? "the file is empty or could not be parsed",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Recurse into nested sub-compositions so a broken scene three levels
|
||||
// deep is still named directly instead of surfacing as a parent-level
|
||||
// "no error, just missing content" mystery.
|
||||
//
|
||||
// Pass `projectDir` unchanged (not dirname(filePath)) — data-composition-src
|
||||
// is always resolved root-relative, even from within a nested
|
||||
// sub-composition. This must match parseSubCompositions' own recursive
|
||||
// call below exactly (it threads the original projectDir through every
|
||||
// level too), or this pre-flight check resolves nested references to the
|
||||
// wrong path and aborts renders that would have actually succeeded.
|
||||
const nestedVisited = new Set(visited);
|
||||
nestedVisited.add(filePath);
|
||||
try {
|
||||
assertSubCompositionsUsable(fileHtml, projectDir, nestedVisited);
|
||||
} catch (err) {
|
||||
if (err instanceof EmptyCompositionError) {
|
||||
problems.push(...err.problems);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (problems.length > 0) {
|
||||
throw new EmptyCompositionError(problems);
|
||||
}
|
||||
}
|
||||
|
||||
export type RenderModeHintCode = "iframe" | "requestAnimationFrame" | "htmlInCanvas";
|
||||
|
||||
export interface RenderModeHint {
|
||||
@@ -617,8 +746,14 @@ function inlineSubCompositions(
|
||||
parseHtml: (htmlStr: string) => parseHTML(htmlStr).document as unknown as Document,
|
||||
scriptErrorLabel: "[Compiler] Composition script failed",
|
||||
compoundAuthoredRoot: true,
|
||||
onMissingComposition: (srcPath: string) => {
|
||||
console.warn(`[Compiler] Composition file missing or empty: ${srcPath}`);
|
||||
onMissingComposition: (srcPath: string, reason?: string) => {
|
||||
// In the render path this is normally unreachable — compileForRender
|
||||
// calls assertSubCompositionsUsable() before any of this runs, so a
|
||||
// hit here means the file changed on disk between that pre-flight
|
||||
// check and this later inline step (e.g. a concurrent scene-writer).
|
||||
console.warn(
|
||||
`[Compiler] Skipping sub-composition "${srcPath}": ${reason ?? "the file is missing or empty"}.`,
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -1367,6 +1502,15 @@ export async function compileForRender(
|
||||
options: CompileForRenderOptions = {},
|
||||
): Promise<CompiledComposition> {
|
||||
const rawHtml = rewriteUnresolvableGsapToCdn(readFileSync(htmlPath, "utf-8"), projectDir);
|
||||
|
||||
// Pre-flight: every data-composition-src reference must resolve to a
|
||||
// usable file before we spend any time compiling, launching a browser, or
|
||||
// waiting out a capture timeout. See EmptyCompositionError for why this is
|
||||
// unconditional (not gated behind --strict like lint warnings) — a render
|
||||
// that silently drops a scene is strictly worse than one that refuses to
|
||||
// start.
|
||||
assertSubCompositionsUsable(rawHtml, projectDir);
|
||||
|
||||
const { html: compiledHtml, unresolvedCompositions } = await compileHtmlFile(
|
||||
rawHtml,
|
||||
projectDir,
|
||||
|
||||
Reference in New Issue
Block a user