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 or 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.",
+ fixHint: folder
+ ? `Point data-composition-src at the HTML file inside the folder, such as "${srcPath}/index.html".`
+ : `Fix this before rendering — the render pre-flight rejects unusable sub-compositions. ` +
+ `Write valid HTML into "${srcPath}" — it needs a or 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.",
});
}
diff --git a/packages/parsers/src/assetResolution.test.ts b/packages/parsers/src/assetResolution.test.ts
index bb9c83cf40..8db653eb21 100644
--- a/packages/parsers/src/assetResolution.test.ts
+++ b/packages/parsers/src/assetResolution.test.ts
@@ -1,5 +1,6 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest";
-import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
+import { execFileSync } from "node:child_process";
+import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, posix, win32 } from "node:path";
import {
@@ -7,6 +8,7 @@ import {
isUnresolvedAssetPlaceholder,
isWithinProjectRoot,
maskNonScannableRanges,
+ readProjectFile,
resolveProjectRelativeSrc,
} from "./assetResolution.js";
@@ -265,3 +267,37 @@ describe("isWithinProjectRoot", () => {
expect(isWithinProjectRoot("/project", "/project", posix)).toBe(true);
});
});
+
+describe("readProjectFile", () => {
+ let dir: string;
+ beforeAll(() => {
+ dir = mkdtempSync(join(tmpdir(), "hf-read-project-file-"));
+ writeFileSync(join(dir, "scene.html"), "");
+ mkdirSync(join(dir, "intro"));
+ });
+ afterAll(() => rmSync(dir, { recursive: true, force: true }));
+
+ it("reads a file's text", () => {
+ expect(readProjectFile(join(dir, "scene.html"))).toEqual({ kind: "file", text: "" });
+ });
+
+ it("names a folder instead of throwing EISDIR", () => {
+ expect(readProjectFile(join(dir, "intro"))).toEqual({ kind: "folder" });
+ });
+
+ it("reports a missing path, including one under a file", () => {
+ expect(readProjectFile(join(dir, "gone.html"))).toEqual({ kind: "missing" });
+ expect(readProjectFile(join(dir, "scene.html", "inner.html"))).toEqual({ kind: "missing" });
+ });
+
+ it("reports a name too long or a symlink loop as missing, as existsSync did", () => {
+ expect(readProjectFile(join(dir, `${"a".repeat(300)}.html`))).toEqual({ kind: "missing" });
+ symlinkSync(join(dir, "loop.html"), join(dir, "loop.html"));
+ expect(readProjectFile(join(dir, "loop.html"))).toEqual({ kind: "missing" });
+ });
+
+ it.skipIf(process.platform === "win32")("reports a named pipe without blocking on it", () => {
+ execFileSync("mkfifo", [join(dir, "pipe.html")]);
+ expect(readProjectFile(join(dir, "pipe.html"))).toEqual({ kind: "folder" });
+ });
+});
diff --git a/packages/parsers/src/assetResolution.ts b/packages/parsers/src/assetResolution.ts
index 0378a9d7ab..e5449ac75f 100644
--- a/packages/parsers/src/assetResolution.ts
+++ b/packages/parsers/src/assetResolution.ts
@@ -1,4 +1,4 @@
-import { existsSync } from "node:fs";
+import { closeSync, constants, existsSync, fstatSync, openSync, readFileSync } from "node:fs";
import { isAbsolute, join, posix, relative, resolve, sep } from "node:path";
import { decodeUrlPathVariants } from "./composition.js";
@@ -242,3 +242,28 @@ export function maskNonScannableRanges(html: string): string {
out = maskRange(out, /