From 481dae1ed92ff5e65156a0fe18705ea4e7b32517 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 29 Sep 2026 07:58:17 +0000 Subject: [PATCH] fix: render names a folder composition source instead of crashing (#4613) * fix(producer): render names a data-composition-src that points at a folder instead of crashing * fix(lint): name a data-composition-src that points at a folder instead of crashing * fix(cli): compositions, keyframes and Studio insertion survive a folder composition source * fix(lint): tell a folder composition source to point at the HTML file inside it * fix: read a composition source through one file handle so its type check and read cannot race * fix(parsers): report a named pipe as a composition source instead of blocking on it * revert(parsers): open composition sources read-only again, which CodeQL reads as no file creation * fix(parsers): open composition sources with a private mode, skip pipes, and treat bad paths as missing --- .../cli/src/commands/compositions.test.ts | 13 +++++++ packages/cli/src/commands/compositions.ts | 9 +++-- packages/cli/src/commands/keyframes.test.ts | 21 +++++++++- packages/cli/src/commands/keyframes.ts | 15 +++++--- packages/lint/src/project.test.ts | 14 +++++++ packages/lint/src/project.ts | 33 +++++++++++----- packages/parsers/src/assetResolution.test.ts | 38 ++++++++++++++++++- packages/parsers/src/assetResolution.ts | 27 ++++++++++++- .../src/services/htmlCompiler.test.ts | 21 ++++++++++ .../producer/src/services/htmlCompiler.ts | 25 +++++++----- .../src/helpers/compositionInsertion.test.ts | 24 +++++++++++- .../src/helpers/compositionInsertion.ts | 18 +++++++-- 12 files changed, 222 insertions(+), 36 deletions(-) diff --git a/packages/cli/src/commands/compositions.test.ts b/packages/cli/src/commands/compositions.test.ts index 32c30caa1c..f340b94bbc 100644 --- a/packages/cli/src/commands/compositions.test.ts +++ b/packages/cli/src/commands/compositions.test.ts @@ -86,4 +86,17 @@ describe("parseSubComposition", () => { elementCount: 1, }); }); + + it("treats a data-composition-src that points at a folder as inline instead of crashing", () => { + const baseDir = mkdtempSync(join(tmpdir(), "hyperframes-compositions-")); + + try { + mkdirSync(join(baseDir, "compositions", "intro"), { recursive: true }); + const html = `
`; + + expect(parseCompositions(html, baseDir)).toMatchObject([{ id: "intro" }]); + } finally { + rmSync(baseDir, { recursive: true, force: true }); + } + }); }); diff --git a/packages/cli/src/commands/compositions.ts b/packages/cli/src/commands/compositions.ts index c467e8e001..a15cbdd554 100644 --- a/packages/cli/src/commands/compositions.ts +++ b/packages/cli/src/commands/compositions.ts @@ -1,6 +1,7 @@ import { defineCommand } from "citty"; import type { Example } from "./_examples.js"; -import { existsSync, readFileSync } from "node:fs"; +import { readFileSync } from "node:fs"; +import { readProjectFile } from "@hyperframes/parsers/asset-resolution"; import { resolve, dirname } from "node:path"; export const examples: Example[] = [ @@ -64,9 +65,9 @@ export function parseCompositions(html: string, baseDir: string): CompositionInf // If this references an external sub-composition, parse that file if (compositionSrc) { const subPath = resolve(baseDir, compositionSrc); - if (existsSync(subPath)) { - const subHtml = readFileSync(subPath, "utf-8"); - const subInfo = parseSubComposition(subHtml, id, width, height); + const sub = readProjectFile(subPath); + if (sub.kind === "file") { + const subInfo = parseSubComposition(sub.text, id, width, height); compositions.push({ ...subInfo, source: compositionSrc }); return; } diff --git a/packages/cli/src/commands/keyframes.test.ts b/packages/cli/src/commands/keyframes.test.ts index 23373137c3..7f717e3694 100644 --- a/packages/cli/src/commands/keyframes.test.ts +++ b/packages/cli/src/commands/keyframes.test.ts @@ -13,7 +13,12 @@ import { beforeAll, describe, expect, it, onTestFinished } from "vitest"; import type { ArgsDef } from "citty"; import { ensureDOMParser } from "../utils/dom.js"; import keyframesCommand from "./keyframes.js"; -import { collectShotSelectors, resolveScope, surfaceComposition } from "./keyframes.js"; +import { + collectCompositions, + collectShotSelectors, + resolveScope, + surfaceComposition, +} from "./keyframes.js"; import { ensureShotOutputDir } from "./motionShot.js"; // citty types `args` as Resolvable (object | promise | thunk); this @@ -366,3 +371,17 @@ describe("--layout strip help text", () => { expect(description.toLowerCase()).toContain("only when"); }); }); + +describe("keyframes project compositions", () => { + it("skips a data-composition-src that points at a folder instead of crashing", () => { + const dir = mkdtempSync(join(tmpdir(), "hf-keyframes-folder-src-")); + onTestFinished(() => rmSync(dir, { recursive: true, force: true })); + mkdirSync(join(dir, "compositions", "intro"), { recursive: true }); + writeFileSync( + join(dir, "index.html"), + `
`, + ); + + expect(collectCompositions(join(dir, "index.html"))).toHaveLength(1); + }); +}); diff --git a/packages/cli/src/commands/keyframes.ts b/packages/cli/src/commands/keyframes.ts index ba85a0b7fc..4fa7f4cd84 100644 --- a/packages/cli/src/commands/keyframes.ts +++ b/packages/cli/src/commands/keyframes.ts @@ -1,5 +1,6 @@ import { defineCommand } from "citty"; -import { existsSync, readFileSync, statSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; +import { readProjectFile } from "@hyperframes/parsers/asset-resolution"; import { resolve, dirname, basename, join, relative, sep } from "node:path"; import { parseGsapScript, type GsapAnimation } from "@hyperframes/core/gsap-parser"; import type { Example } from "./_examples.js"; @@ -573,7 +574,7 @@ function groupTraces(tweens: SurfacedTween[]): SurfacedTrace[] { return traces; } -function collectCompositions(indexPath: string): SurfacedComposition[] { +export function collectCompositions(indexPath: string): SurfacedComposition[] { const html = readFileSync(indexPath, "utf-8"); const baseDir = dirname(indexPath); const out: SurfacedComposition[] = [ @@ -589,9 +590,10 @@ function collectCompositions(indexPath: string): SurfacedComposition[] { const src = div.getAttribute("data-composition-src"); if (!src) continue; const subPath = resolve(baseDir, src); - if (!existsSync(subPath)) continue; + const sub = readProjectFile(subPath); + if (sub.kind !== "file") continue; const id = div.getAttribute("data-composition-id") ?? src; - out.push(surfaceComposition(readFileSync(subPath, "utf-8"), id, src)); + out.push(surfaceComposition(sub.text, id, src)); } return out; } @@ -769,9 +771,10 @@ export function resolveScope(args: { target?: string; selector?: string }): { let projectName: string; let projectDir: string | undefined; let entryFile: string | undefined; - if (raw && raw.endsWith(".html") && existsSync(raw) && statSync(raw).isFile()) { + const entry = raw?.endsWith(".html") ? readProjectFile(raw) : undefined; + if (raw && entry?.kind === "file") { const entryPath = resolve(raw); - comps = [surfaceComposition(readFileSync(entryPath, "utf-8"), basename(entryPath), entryPath)]; + comps = [surfaceComposition(entry.text, basename(entryPath), entryPath)]; projectName = basename(entryPath); projectDir = findProjectRoot(entryPath); entryFile = relative(projectDir, entryPath).split(sep).join("/"); diff --git a/packages/lint/src/project.test.ts b/packages/lint/src/project.test.ts index 61b12e0add..079c076416 100644 --- a/packages/lint/src/project.test.ts +++ b/packages/lint/src/project.test.ts @@ -269,6 +269,20 @@ describe("missing_or_empty_sub_composition", () => { expect(finding?.message).toContain("does not exist"); }); + it("errors, instead of crashing, when the referenced sub-composition is a folder", async () => { + const project = makeProject(htmlWithSubComp("compositions/scene-title"), {}); + mkdirSync(join(project, "compositions", "scene-title")); + writeFileSync(join(project, "compositions", "scene-title", "index.html"), validSubCompHtml()); + const { results } = await lintProject(project); + const finding = results + .flatMap((r) => r.result.findings) + .find((f) => f.code === "missing_or_empty_sub_composition"); + + expect(finding?.message).toContain("compositions/scene-title"); + expect(finding?.message).toContain("a folder, not an HTML file"); + expect(finding?.fixHint).toContain('"compositions/scene-title/index.html"'); + }); + 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

", diff --git a/packages/lint/src/project.ts b/packages/lint/src/project.ts index 9a116086bb..72a9981dea 100644 --- a/packages/lint/src/project.ts +++ b/packages/lint/src/project.ts @@ -12,6 +12,7 @@ import { isUnresolvedAssetPlaceholder, isWithinProjectRoot, maskNonScannableRanges, + readProjectFile, resolveExistingLocalAsset, resolveLocalAssetCandidates, resolveProjectRelativeSrc, @@ -615,7 +616,7 @@ function lintMissingOrEmptySubComposition( rootHtml: string, ): HyperframeLintFinding[] { // Dedup by src path — the same reference can appear from nested sub-comps. - const checked = new Map(); + const checked = new Map(); const visited = new Set(); // fallow-ignore-next-line complexity @@ -636,14 +637,25 @@ function lintMissingOrEmptySubComposition( if (visited.has(filePath)) continue; visited.add(filePath); - if (!existsSync(filePath)) { + const read = readProjectFile(filePath); + if (read.kind === "missing") { if (!checked.has(srcPath)) { checked.set(srcPath, { srcPath, problem: "the file does not exist" }); } continue; } + if (read.kind === "folder") { + if (!checked.has(srcPath)) { + checked.set(srcPath, { + srcPath, + problem: "it is a folder, not an HTML file", + folder: true, + }); + } + continue; + } - const fileHtml = readFileSync(filePath, "utf-8"); + const fileHtml = read.text; const validity = checkSubCompositionUsability(fileHtml, parseSubCompHtml); if (!validity.ok) { if (!checked.has(srcPath)) { @@ -664,17 +676,18 @@ function lintMissingOrEmptySubComposition( walk(rootHtml); const findings: HyperframeLintFinding[] = []; - for (const { srcPath, problem } of checked.values()) { + for (const { srcPath, problem, folder } 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