diff --git a/packages/cli/src/commands/render.test.ts b/packages/cli/src/commands/render.test.ts index 452e7991e3..c1179c7545 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 }); + } + }, 60_000); +}); + // 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 86503f6e3f..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,14 @@ export default defineCommand({ // fallow-ignore-next-line complexity async run({ args }) { // ── Resolve project ──────────────────────────────────────────────────── - const project = resolveProject(args.dir); + const hasExplicitComposition = hasExplicitCompositionArg(args.composition); + 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 +694,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 +711,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 +809,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 +846,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 +938,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 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);