From e89809d898c077f61b3e4e025a026db833e40ed6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sun, 12 Jul 2026 22:59:52 +0000 Subject: [PATCH 1/3] fix(render): allow explicit entry without index --- packages/cli/src/commands/render.ts | 19 +++++++++++++------ packages/cli/src/utils/project.test.ts | 12 ++++++++++++ packages/cli/src/utils/project.ts | 18 ++++++++++++++---- 3 files changed, 39 insertions(+), 10 deletions(-) diff --git a/packages/cli/src/commands/render.ts b/packages/cli/src/commands/render.ts index 86503f6e3f..864f0d2c00 100644 --- a/packages/cli/src/commands/render.ts +++ b/packages/cli/src/commands/render.ts @@ -388,12 +388,19 @@ export default defineCommand({ // fallow-ignore-next-line complexity async run({ args }) { // ── Resolve project ──────────────────────────────────────────────────── - const project = resolveProject(args.dir); + const compositionArg = args.composition?.trim(); + const hasExplicitComposition = + compositionArg != null && + compositionArg !== "" && + compositionArg !== "." && + compositionArg !== "./"; + const project = resolveProject(args.dir, { requireIndex: !hasExplicitComposition }); // ── Resolve composition entry file ───────────────────────────────────── // Needed early: fps default below must read the actual render target, not // always index.html. const entryFile = resolveCompositionEntryArg(args.composition, project.dir, statSync); + const renderTarget = entryFile ? resolve(project.dir, entryFile) : project.indexPath; // ── Validate fps ─────────────────────────────────────────────────────── // Accept either integer (`30`) or ffmpeg-style rational (`30000/1001`). @@ -691,7 +698,7 @@ export default defineCommand({ preparedBatch = batchModule.prepareBatchRender({ batchPath, outputTemplate: batchOutputTemplate, - indexPath: project.indexPath, + indexPath: renderTarget, strictVariables: args["strict-variables"] ?? false, quiet: quiet || batchJson, json: batchJson, @@ -708,7 +715,6 @@ export default defineCommand({ // deck-native path. Best-effort — never block a render on this probe. if (!quiet) { try { - const renderTarget = entryFile ? resolve(project.dir, entryFile) : project.indexPath; const { slideshowIslandRegex } = await import("@hyperframes/core/slideshow"); if (slideshowIslandRegex("i").test(readFileSync(renderTarget, "utf8"))) { console.log( @@ -807,7 +813,9 @@ export default defineCommand({ // ── Pre-render lint ────────────────────────────────────────────────── { - const explicitEntry = args.composition && args.composition !== "." ? entryFile : undefined; + // lintProject's explicit-entry contract is an absolute source path; + // entryFile is project-relative for the producer. + const explicitEntry = entryFile ? renderTarget : undefined; const lintResult = await lintProject(project.dir, explicitEntry); if (!quiet && (lintResult.totalErrors > 0 || lintResult.totalWarnings > 0)) { console.log(""); @@ -842,7 +850,6 @@ export default defineCommand({ if (outputResolution) { let resolutionIssue: { message: string; kind: OutputResolutionIssueKind } | undefined; try { - const renderTarget = entryFile ? resolve(project.dir, entryFile) : project.indexPath; resolutionIssue = await checkRenderResolutionPreflight( readFileSync(renderTarget, "utf8"), outputResolution, @@ -935,7 +942,7 @@ export default defineCommand({ // ── Validate --variables against data-composition-variables ───────── const strictVariables = args["strict-variables"] ?? false; if (variables && Object.keys(variables).length > 0) { - const issues = validateVariablesAgainstProject(project.indexPath, variables); + const issues = validateVariablesAgainstProject(renderTarget, variables); reportVariableIssues(issues, { strict: strictVariables, quiet }); } diff --git a/packages/cli/src/utils/project.test.ts b/packages/cli/src/utils/project.test.ts index 7aadcd48e2..519e619c43 100644 --- a/packages/cli/src/utils/project.test.ts +++ b/packages/cli/src/utils/project.test.ts @@ -32,6 +32,18 @@ describe("resolveProjectOrThrow", () => { } }); + it("accepts a directory without index.html when an explicit entry will be resolved", () => { + const dir = mkdtempSync(join(tmpdir(), "hf-explicit-entry-project-")); + try { + const project = resolveProjectOrThrow(dir, { requireIndex: false }); + expect(project.dir).toBe(dir); + expect(project.indexPath).toBe(join(dir, "index.html")); + expect(project.name).toBe(basename(dir)); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + it("accepts a directory with index.html", () => { const dir = mkdtempSync(join(tmpdir(), "hf-valid-project-")); try { diff --git a/packages/cli/src/utils/project.ts b/packages/cli/src/utils/project.ts index 95ab576bed..d13c1f0d02 100644 --- a/packages/cli/src/utils/project.ts +++ b/packages/cli/src/utils/project.ts @@ -9,6 +9,10 @@ export interface ProjectDir { indexPath: string; } +export interface ResolveProjectOptions { + requireIndex?: boolean; +} + export class InvalidProjectError extends Error { readonly title: string; readonly hint?: string; @@ -23,7 +27,10 @@ export class InvalidProjectError extends Error { } } -export function resolveProjectOrThrow(dirArg: string | undefined): ProjectDir { +export function resolveProjectOrThrow( + dirArg: string | undefined, + options: ResolveProjectOptions = {}, +): ProjectDir { const trimmed = dirArg?.trim(); if (trimmed === "#") { throw new InvalidProjectError( @@ -40,7 +47,7 @@ export function resolveProjectOrThrow(dirArg: string | undefined): ProjectDir { if (!existsSync(dir) || !statSync(dir).isDirectory()) { throw new InvalidProjectError("Not a directory: " + dir); } - if (!existsSync(indexPath)) { + if (options.requireIndex !== false && !existsSync(indexPath)) { throw new InvalidProjectError( "No composition found in " + dir, "No index.html file found.", @@ -51,9 +58,12 @@ export function resolveProjectOrThrow(dirArg: string | undefined): ProjectDir { return { dir, name, indexPath }; } -export function resolveProject(dirArg: string | undefined): ProjectDir { +export function resolveProject( + dirArg: string | undefined, + options: ResolveProjectOptions = {}, +): ProjectDir { try { - return resolveProjectOrThrow(dirArg); + return resolveProjectOrThrow(dirArg, options); } catch (err) { if (err instanceof InvalidProjectError) { // Self-exit (not a throw) so the cli.ts wrapper never sees it — report From 716adc6093181edea76236b756aeb20a8f1e2d05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Mon, 13 Jul 2026 01:52:42 +0000 Subject: [PATCH 2/3] fix(render): unify explicit composition handling --- packages/cli/src/commands/render.test.ts | 39 +++++++++++++++++++++++ packages/cli/src/commands/render.ts | 8 ++--- packages/cli/src/utils/renderArgs.test.ts | 12 +++++++ packages/cli/src/utils/renderArgs.ts | 13 ++++++-- 4 files changed, 64 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/commands/render.test.ts b/packages/cli/src/commands/render.test.ts index 452e7991e3..dccfe734fc 100644 --- a/packages/cli/src/commands/render.test.ts +++ b/packages/cli/src/commands/render.test.ts @@ -1,5 +1,8 @@ // fallow-ignore-file code-duplication import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; const producerState = vi.hoisted(() => ({ createdJobs: [] as Array>, @@ -1065,4 +1068,40 @@ describe("render fps arg definition", () => { }); }); +describe("render command explicit composition", () => { + it("renders an explicit composition from a project with no index.html", async () => { + const projectDir = mkdtempSync(join(tmpdir(), "hf-render-explicit-")); + const outputPath = join(projectDir, "out.mp4"); + writeFileSync( + join(projectDir, "standalone.html"), + ` +
+ + `, + ); + vi.useFakeTimers(); + + try { + const command = (await import("./render.js")).default; + await command.run?.({ + args: { + dir: projectDir, + composition: "standalone.html", + output: outputPath, + quiet: true, + quality: "standard", + format: "mp4", + }, + } as never); + + expect(producerState.createdJobs.at(-1)).toMatchObject({ + entryFile: "standalone.html", + }); + } finally { + vi.clearAllTimers(); + rmSync(projectDir, { recursive: true, force: true }); + } + }); +}); + // Variables-helper tests live in `../utils/variables.test.ts`. diff --git a/packages/cli/src/commands/render.ts b/packages/cli/src/commands/render.ts index 864f0d2c00..f4e3e5df61 100644 --- a/packages/cli/src/commands/render.ts +++ b/packages/cli/src/commands/render.ts @@ -8,6 +8,7 @@ import { } from "../utils/variables.js"; import { parseGifLoopArg, + hasExplicitCompositionArg, resolveBrowserTimeoutMsArg, resolveCompositionEntryArg, resolveDefaultFpsArg, @@ -388,12 +389,7 @@ export default defineCommand({ // fallow-ignore-next-line complexity async run({ args }) { // ── Resolve project ──────────────────────────────────────────────────── - const compositionArg = args.composition?.trim(); - const hasExplicitComposition = - compositionArg != null && - compositionArg !== "" && - compositionArg !== "." && - compositionArg !== "./"; + const hasExplicitComposition = hasExplicitCompositionArg(args.composition); const project = resolveProject(args.dir, { requireIndex: !hasExplicitComposition }); // ── Resolve composition entry file ───────────────────────────────────── diff --git a/packages/cli/src/utils/renderArgs.test.ts b/packages/cli/src/utils/renderArgs.test.ts index 397d8cc51e..3db8d6344c 100644 --- a/packages/cli/src/utils/renderArgs.test.ts +++ b/packages/cli/src/utils/renderArgs.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { MAX_PAGE_NAVIGATION_TIMEOUT_SECONDS, + hasExplicitCompositionArg, parseBrowserTimeoutMsArg, parseCompositionEntryArg, parseGifLoopArg, @@ -104,6 +105,17 @@ describe("parseBrowserTimeoutMsArg", () => { }); describe("parseCompositionEntryArg", () => { + it("uses one sentinel classifier for default and explicit composition values", () => { + expect([undefined, "", " ", ".", "./"].map(hasExplicitCompositionArg)).toEqual([ + false, + false, + false, + false, + false, + ]); + expect(hasExplicitCompositionArg("./compositions/intro.html")).toBe(true); + }); + const PROJECT = "/proj"; const stat = makeStat({ [resolve(PROJECT, "index.html")]: "file", diff --git a/packages/cli/src/utils/renderArgs.ts b/packages/cli/src/utils/renderArgs.ts index f7cd98a5c5..db23734ed6 100644 --- a/packages/cli/src/utils/renderArgs.ts +++ b/packages/cli/src/utils/renderArgs.ts @@ -128,6 +128,15 @@ export type CompositionEntryParseResult = | { ok: true; value: string | undefined } | { ok: false; error: CompositionEntryParseError }; +function normalizeCompositionEntryArg(raw: string | undefined): string | undefined { + const trimmed = raw?.trim().replace(/^\.\//, "") || undefined; + return !trimmed || trimmed === "." ? undefined : trimmed; +} + +export function hasExplicitCompositionArg(raw: string | undefined): boolean { + return normalizeCompositionEntryArg(raw) !== undefined; +} + /** * Parse and validate `--composition ` into a project-relative * entry file (or `undefined` for the index.html default). @@ -146,11 +155,11 @@ export function parseCompositionEntryArg( projectDir: string, stat: (path: string) => Stats, ): CompositionEntryParseResult { - const trimmed = raw?.trim().replace(/^\.\//, "") || undefined; + const trimmed = normalizeCompositionEntryArg(raw); // Normalize the project-root shorthands to "no entry override" so the // producer falls back to index.html instead of statSync-ing the dir // and later blowing up with EISDIR inside readFileSync(). - if (!trimmed || trimmed === ".") return { ok: true, value: undefined }; + if (!trimmed) return { ok: true, value: undefined }; const absProjectDir = resolve(projectDir); const entryPath = resolve(absProjectDir, trimmed); From e3e847c1cd98a9b45c99543ad0a0a62edd56c191 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Mon, 13 Jul 2026 02:29:53 +0000 Subject: [PATCH 3/3] test(cli): allow explicit render integration under CI load --- packages/cli/src/commands/render.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/commands/render.test.ts b/packages/cli/src/commands/render.test.ts index dccfe734fc..c1179c7545 100644 --- a/packages/cli/src/commands/render.test.ts +++ b/packages/cli/src/commands/render.test.ts @@ -1101,7 +1101,7 @@ describe("render command explicit composition", () => { vi.clearAllTimers(); rmSync(projectDir, { recursive: true, force: true }); } - }); + }, 60_000); }); // Variables-helper tests live in `../utils/variables.test.ts`.