From b95ddd74d57509e189704617010d86a8bf2dee63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Fri, 10 Jul 2026 22:32:26 -0400 Subject: [PATCH] fix(cli): honor direct entries in keyframe shots (#2217) * fix(cli): honor direct entries in keyframe shots * fix(core): rebase entry-authored asset paths for direct-entry bundling --- packages/cli/src/commands/keyframes.test.ts | 21 +++- packages/cli/src/commands/keyframes.ts | 43 ++++--- packages/cli/src/commands/motionShot.ts | 4 +- .../core/src/compiler/htmlBundler.test.ts | 43 +++++++ packages/core/src/compiler/htmlBundler.ts | 113 ++++++++++++++++-- 5 files changed, 200 insertions(+), 24 deletions(-) diff --git a/packages/cli/src/commands/keyframes.test.ts b/packages/cli/src/commands/keyframes.test.ts index 39e5c3281..14e489dbd 100644 --- a/packages/cli/src/commands/keyframes.test.ts +++ b/packages/cli/src/commands/keyframes.test.ts @@ -1,12 +1,31 @@ +import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { beforeAll, describe, expect, it } from "vitest"; import { ensureDOMParser } from "../utils/dom.js"; -import { collectShotSelectors, surfaceComposition } from "./keyframes.js"; +import { collectShotSelectors, resolveScope, surfaceComposition } from "./keyframes.js"; beforeAll(() => ensureDOMParser()); const wrap = (script: string) => `
`; +describe("keyframes direct composition scope", () => { + it("keeps the project root and passes the nested HTML entry to --shot", () => { + const projectDir = mkdtempSync(join(tmpdir(), "hf-keyframes-target-")); + const compositionsDir = join(projectDir, "compositions"); + mkdirSync(compositionsDir); + writeFileSync(join(projectDir, "index.html"), wrap("")); + const scenePath = join(compositionsDir, "scene.html"); + writeFileSync(scenePath, wrap("")); + + const scope = resolveScope({ target: scenePath }); + + expect(scope.projectDir).toBe(projectDir); + expect(scope.entryFile).toBe("compositions/scene.html"); + }); +}); + describe("keyframes multi-stroke traces", () => { it("composites ≥2 position strokes on one element into a single trace", () => { const html = wrap(` diff --git a/packages/cli/src/commands/keyframes.ts b/packages/cli/src/commands/keyframes.ts index 48fc4971e..7d4c83078 100644 --- a/packages/cli/src/commands/keyframes.ts +++ b/packages/cli/src/commands/keyframes.ts @@ -1,6 +1,6 @@ import { defineCommand } from "citty"; import { existsSync, readFileSync, statSync } from "node:fs"; -import { resolve, dirname, basename } from "node:path"; +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"; import { c } from "../ui/colors.js"; @@ -710,6 +710,7 @@ async function runOnionShot( comps: SurfacedComposition[], allComps: SurfacedComposition[], projectDir: string | undefined, + entryFile: string | undefined, args: ShotArgs & { selector?: string }, ): Promise { const { captureMotionPathShot } = await import("./motionShot.js"); @@ -722,32 +723,34 @@ async function runOnionShot( console.log(c.dim(guardError)); return true; } - const saved = await captureMotionPathShot( - projectDir!, - requests, - resolve(args.shot!), - onionShotOptions(args), - ); + const saved = await captureMotionPathShot(projectDir!, requests, resolve(args.shot!), { + ...onionShotOptions(args), + entryFile, + }); printOnionShotSaved(saved, requests.length); return false; } // Resolve the command target (a project dir or a single .html) into surfaced // compositions, applying the optional --selector filter. -function resolveScope(args: { target?: string; selector?: string }): { +export function resolveScope(args: { target?: string; selector?: string }): { comps: SurfacedComposition[]; allComps: SurfacedComposition[]; projectName: string; projectDir: string | undefined; + entryFile: string | undefined; } { const raw = args.target?.trim(); let comps: SurfacedComposition[]; let projectName: string; let projectDir: string | undefined; + let entryFile: string | undefined; if (raw && raw.endsWith(".html") && existsSync(raw) && statSync(raw).isFile()) { - comps = [surfaceComposition(readFileSync(raw, "utf-8"), basename(raw), raw)]; - projectName = basename(raw); - projectDir = dirname(raw); + const entryPath = resolve(raw); + comps = [surfaceComposition(readFileSync(entryPath, "utf-8"), basename(entryPath), entryPath)]; + projectName = basename(entryPath); + projectDir = findProjectRoot(entryPath); + entryFile = relative(projectDir, entryPath).split(sep).join("/"); } else { const project = resolveProject(raw); comps = collectCompositions(project.indexPath); @@ -777,7 +780,19 @@ function resolveScope(args: { target?: string; selector?: string }): { cmp.anime.length > 0, ); } - return { comps, allComps, projectName, projectDir }; + return { comps, allComps, projectName, projectDir, entryFile }; +} + +function findProjectRoot(entryPath: string): string { + const entryDir = dirname(entryPath); + let candidate = entryDir; + for (;;) { + if (existsSync(join(candidate, "index.html"))) return candidate; + if (existsSync(join(candidate, ".git"))) return entryDir; + const parent = dirname(candidate); + if (parent === candidate) return entryDir; + candidate = parent; + } } function isEmptyComposition(cmp: SurfacedComposition): boolean { @@ -917,12 +932,12 @@ function createKeyframesCommand(options: Partial = {}) ); console.log(); } - const { comps: rawComps, allComps, projectName, projectDir } = resolveScope(args); + const { comps: rawComps, allComps, projectName, projectDir, entryFile } = resolveScope(args); const comps = filterCompositionsByRuntime(rawComps, runtime); // --shot: 3D onion-skin self-verify screenshot. Returns true when the command // should stop (guard failure) so run() stays small. - if (args.shot && (await runOnionShot(comps, allComps, projectDir, args))) return; + if (args.shot && (await runOnionShot(comps, allComps, projectDir, entryFile, args))) return; if (args.json) { console.log( diff --git a/packages/cli/src/commands/motionShot.ts b/packages/cli/src/commands/motionShot.ts index 5db13f6b2..0bf1061c6 100644 --- a/packages/cli/src/commands/motionShot.ts +++ b/packages/cli/src/commands/motionShot.ts @@ -36,6 +36,8 @@ interface ScopeResolution { } export interface ShotOptions { + /** Project-relative HTML entry to render. Defaults to `index.html`. */ + entryFile?: string; /** Equal-time samples across the (windowed) timeline. Default 9. */ samples?: number; /** "path" = ghosts at real positions + path; "strip" = filmstrip by time. */ @@ -619,7 +621,7 @@ export async function captureMotionPathShot( const { serveStaticProjectHtml } = await import("../utils/staticProjectServer.js"); const { bundleToSingleHtml } = await import("@hyperframes/core/compiler"); - const html = await bundleToSingleHtml(projectDir); + const html = await bundleToSingleHtml(projectDir, { entryFile: opts.entryFile }); const server = await serveStaticProjectHtml( projectDir, html, diff --git a/packages/core/src/compiler/htmlBundler.test.ts b/packages/core/src/compiler/htmlBundler.test.ts index f28e40ee1..e283a5d56 100644 --- a/packages/core/src/compiler/htmlBundler.test.ts +++ b/packages/core/src/compiler/htmlBundler.test.ts @@ -52,6 +52,49 @@ function tryCreateSymlink(target: string, path: string, type: "dir" | "file"): b } describe("bundleToSingleHtml", () => { + it("bundles a direct composition entry with paths relative to its file", async () => { + const dir = makeTempProject({ + "index.html": "wrong entry", + "compositions/scene.html": ` +
direct scene
+ `, + "compositions/scene.css": ".direct-scene { color: rgb(1, 2, 3); }", + }); + + const bundled = await bundleToSingleHtml(dir, { entryFile: "compositions/scene.html" }); + + expect(bundled).toContain("direct scene"); + expect(bundled).not.toContain("wrong entry"); + expect(bundled).toContain(".direct-scene { color: rgb(1, 2, 3); }"); + }); + + it("rebases direct-entry authored asset paths before inlining", async () => { + const spriteSvg = ''; + const bgSvg = ''; + const dir = makeTempProject({ + "index.html": "wrong entry", + "compositions/scene.html": ` + + +
+ +
+ + `, + "compositions/sprite.svg": spriteSvg, + "compositions/bg.svg": bgSvg, + }); + + const bundled = await bundleToSingleHtml(dir, { entryFile: "compositions/scene.html" }); + const spriteDataUrl = `data:image/svg+xml;base64,${Buffer.from(spriteSvg).toString("base64")}`; + const bgDataUrl = `data:image/svg+xml;base64,${Buffer.from(bgSvg).toString("base64")}`; + + expect(bundled).toContain(`src="${spriteDataUrl}"`); + expect(bundled).toContain(`url("${bgDataUrl}")`); + expect(bundled).not.toContain("./sprite.svg"); + expect(bundled).not.toContain("./bg.svg"); + }); + it("does not merge author scripts into the runtime bootstrap placeholder", async () => { const dir = makeTempProject({ "index.html": ` diff --git a/packages/core/src/compiler/htmlBundler.ts b/packages/core/src/compiler/htmlBundler.ts index 029839f3a..763a74860 100644 --- a/packages/core/src/compiler/htmlBundler.ts +++ b/packages/core/src/compiler/htmlBundler.ts @@ -3,7 +3,7 @@ export { FLATTENED_INNER_ROOT_STRIP_ATTRS } from "../runtime/flattenedRoot"; import { parseHostVariableValues } from "../runtime/getVariables"; import { cssVariableName } from "../tokenSlug"; import { readFileSync, existsSync } from "fs"; -import { join, resolve, relative, dirname, isAbsolute, sep } from "path"; +import { resolve, relative, dirname, isAbsolute, sep } from "path"; import { CSS_URL_RE, isNonRelativeUrl } from "./assetPaths.js"; import { transformSync } from "esbuild"; import { compileHtml, type MediaDurationProber } from "./htmlCompiler"; @@ -133,6 +133,89 @@ function rebaseCssUrls(css: string, cssFileDir: string, projectDir: string): str }); } +function rebaseRelativePath(urlValue: string, fromDir: string, toDir: string): string { + const { basePath, suffix } = splitUrlSuffix(urlValue.trim()); + if (!basePath) return urlValue; + const absolutePath = resolve(fromDir, basePath); + const rebased = relative(resolve(toDir), absolutePath).split(sep).join("/"); + return appendSuffixToUrl(rebased, suffix); +} + +function rebaseSrcsetPaths(srcsetValue: string, fromDir: string, toDir: string): string { + if (!srcsetValue) return srcsetValue; + return srcsetValue + .split(",") + .map((rawCandidate) => { + const candidate = rawCandidate.trim(); + if (!candidate) return candidate; + const parts = candidate.split(/\s+/); + const first = parts[0] ?? ""; + if (parts.length === 0 || !isRelativeUrl(first)) return candidate; + parts[0] = rebaseRelativePath(first, fromDir, toDir); + return parts.join(" "); + }) + .join(", "); +} + +function rebaseColorGradingLutPath(value: string, fromDir: string, toDir: string): string { + if (!value.trim().startsWith("{")) return value; + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch { + return value; + } + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return value; + + const lut = Reflect.get(parsed, "lut"); + if (typeof lut === "string") { + if (!isRelativeUrl(lut)) return value; + Reflect.set(parsed, "lut", rebaseRelativePath(lut, fromDir, toDir)); + return JSON.stringify(parsed); + } + if (typeof lut !== "object" || lut === null || Array.isArray(lut)) return value; + const lutSrc = Reflect.get(lut, "src"); + if (typeof lutSrc !== "string" || !isRelativeUrl(lutSrc)) return value; + Reflect.set(lut, "src", rebaseRelativePath(lutSrc, fromDir, toDir)); + return JSON.stringify(parsed); +} + +function rebaseEntryAuthoredAssetPaths( + document: Document, + sourceDir: string, + projectDir: string, +): void { + for (const styleEl of [...document.querySelectorAll("style")]) { + styleEl.textContent = rebaseCssUrls(styleEl.textContent || "", sourceDir, projectDir); + } + for (const el of [...document.querySelectorAll("[style]")]) { + const styleAttr = el.getAttribute("style"); + if (styleAttr) el.setAttribute("style", rebaseCssUrls(styleAttr, sourceDir, projectDir)); + } + for (const el of [...document.querySelectorAll("[src], [href], [poster], [xlink\\:href]")]) { + if (el.tagName === "LINK" && (el.getAttribute("rel") || "").toLowerCase() === "stylesheet") + continue; + if (el.tagName === "SCRIPT" && el.hasAttribute("src")) continue; + for (const attr of ["src", "href", "poster", "xlink:href"] as const) { + const value = el.getAttribute(attr); + if (!value || !isRelativeUrl(value)) continue; + el.setAttribute(attr, rebaseRelativePath(value, sourceDir, projectDir)); + } + } + for (const el of [...document.querySelectorAll("[srcset]")]) { + const srcset = el.getAttribute("srcset"); + if (srcset) el.setAttribute("srcset", rebaseSrcsetPaths(srcset, sourceDir, projectDir)); + } + for (const el of [...document.querySelectorAll(`[${HF_COLOR_GRADING_ATTR}]`)]) { + const value = el.getAttribute(HF_COLOR_GRADING_ATTR); + if (value) + el.setAttribute( + HF_COLOR_GRADING_ATTR, + rebaseColorGradingLutPath(value, sourceDir, projectDir), + ); + } +} + function inlineCssFile( css: string, cssFileDir: string, @@ -581,6 +664,8 @@ function stripJsCommentsParserSafe(source: string): string { } export interface BundleOptions { + /** Project-relative HTML entry to bundle. Defaults to `index.html`. */ + entryFile?: string; /** Optional media duration prober (e.g., ffprobe). If omitted, media durations are not resolved. */ probeMediaDuration?: MediaDurationProber; /** @@ -692,11 +777,19 @@ export async function bundleToSingleHtml( projectDir: string, options?: BundleOptions, ): Promise { - const indexPath = join(projectDir, "index.html"); - if (!existsSync(indexPath)) throw new Error("index.html not found in project directory"); + const entryFile = options?.entryFile ?? "index.html"; + const indexPath = resolveWithinProject(projectDir, entryFile); + if (!indexPath || !existsSync(indexPath)) { + throw new Error(`${entryFile} not found in project directory`); + } + const sourceDir = dirname(indexPath); + const resolveEntryPath = (relativePath: string): string | null => { + const resolved = resolve(sourceDir, relativePath); + return isSafePath(projectDir, resolved) ? resolved : null; + }; const rawHtml = readFileSync(indexPath, "utf-8"); - const compiled = await compileHtml(rawHtml, projectDir, options?.probeMediaDuration); + const compiled = await compileHtml(rawHtml, sourceDir, options?.probeMediaDuration); const staticGuard = await validateHyperframeHtmlContract(compiled); if (!staticGuard.isValid) { @@ -708,13 +801,17 @@ export async function bundleToSingleHtml( const withInterceptor = injectInterceptor(compiled, options?.runtime ?? "inline"); const document = parseHTMLContent(withInterceptor); + if (resolve(sourceDir) !== resolve(projectDir)) { + rebaseEntryAuthoredAssetPaths(document, sourceDir, projectDir); + } + // Inline local CSS const localCssChunks: string[] = []; let cssAnchorPlaced = false; for (const el of [...document.querySelectorAll('link[rel="stylesheet"]')]) { const href = el.getAttribute("href"); if (!href || !isRelativeUrl(href)) continue; - const cssPath = resolveWithinProject(projectDir, href); + const cssPath = resolveEntryPath(href); if (!cssPath) continue; const css = safeReadFile(cssPath); if (css == null) continue; @@ -746,7 +843,7 @@ export async function bundleToSingleHtml( for (const el of [...document.querySelectorAll("script[src]")]) { const src = el.getAttribute("src"); if (!src || !isRelativeUrl(src)) continue; - const jsPath = resolveWithinProject(projectDir, src); + const jsPath = resolveEntryPath(src); const js = jsPath ? safeReadFile(jsPath) : null; if (js == null) continue; localJsChunks.push(js); @@ -781,7 +878,7 @@ export async function bundleToSingleHtml( const subCompResult = inlineSubCompositions(document, subCompositionHosts, { resolveHtml: (srcPath: string) => { if (!isRelativeUrl(srcPath)) return null; - const compPath = resolveWithinProject(projectDir, srcPath); + const compPath = resolveEntryPath(srcPath); return compPath ? safeReadFile(compPath) : null; }, parseHtml: parseHTMLContent, @@ -814,7 +911,7 @@ export async function bundleToSingleHtml( if (seenCompScriptSrcs.has(extSrc)) continue; seenCompScriptSrcs.add(extSrc); if (isRelativeUrl(extSrc)) { - const jsPath = resolveWithinProject(projectDir, extSrc); + const jsPath = resolveEntryPath(extSrc); const js = jsPath ? safeReadFile(jsPath) : null; if (js != null) { compScriptChunks.push(js);