From 319ca61f62e4490650f96b104899aba67d0870be Mon Sep 17 00:00:00 2001 From: Miao Yang Date: Wed, 24 Jun 2026 13:58:52 +0800 Subject: [PATCH 01/10] fix: handle caption skin workflow --- packages/cli/src/utils/lintProject.test.ts | 13 +++++++++ packages/cli/src/utils/lintProject.ts | 1 + packages/core/src/lint/context.ts | 2 +- .../core/src/lint/hyperframeLinter.test.ts | 28 +++++++++++++++++++ skills/faceless-explainer/SKILL.md | 6 ++-- .../scripts/build-frame.mjs | 14 ++++++---- .../faceless-explainer/scripts/captions.mjs | 12 ++++++-- .../faceless-explainer/scripts/lib/tokens.mjs | 4 +-- skills/pr-to-video/SKILL.md | 6 ++-- skills/pr-to-video/scripts/build-frame.mjs | 14 ++++++---- skills/pr-to-video/scripts/captions.mjs | 12 ++++++-- skills/pr-to-video/scripts/lib/tokens.mjs | 4 +-- skills/product-launch-video/SKILL.md | 6 ++-- .../scripts/build-frame.mjs | 14 ++++++---- .../product-launch-video/scripts/captions.mjs | 12 ++++++-- .../scripts/lib/tokens.mjs | 4 +-- 16 files changed, 112 insertions(+), 40 deletions(-) diff --git a/packages/cli/src/utils/lintProject.test.ts b/packages/cli/src/utils/lintProject.test.ts index e31601aac9..aec22d15e2 100644 --- a/packages/cli/src/utils/lintProject.test.ts +++ b/packages/cli/src/utils/lintProject.test.ts @@ -906,6 +906,19 @@ describe("multiple_root_compositions", () => { expect(finding).toBeUndefined(); }); + it("ignores root-level caption-skin.html source files", async () => { + const project = makeProject(validHtml()); + writeFileSync( + join(project.dir, "caption-skin.html"), + '
', + ); + const { results } = await lintProject(project); + const finding = results[0]?.result.findings.find( + (f) => f.code === "multiple_root_compositions", + ); + expect(finding).toBeUndefined(); + }); + it("ignores HTML files without data-composition-id", async () => { const project = makeProject(validHtml()); writeFileSync(join(project.dir, "readme.html"), "Not a composition"); diff --git a/packages/cli/src/utils/lintProject.ts b/packages/cli/src/utils/lintProject.ts index 2fe61902ec..8558c852bf 100644 --- a/packages/cli/src/utils/lintProject.ts +++ b/packages/cli/src/utils/lintProject.ts @@ -477,6 +477,7 @@ function lintMultipleRootCompositions(projectDir: string): HyperframeLintFinding const rootHtmlFiles = readdirSync(projectDir).filter((f) => f.endsWith(".html")); const rootCompositions: string[] = []; for (const file of rootHtmlFiles) { + if (file === "caption-skin.html") continue; const content = readFileSync(join(projectDir, file), "utf-8"); if (/data-composition-id/i.test(content)) { rootCompositions.push(file); diff --git a/packages/core/src/lint/context.ts b/packages/core/src/lint/context.ts index 9de887df56..de1945bcca 100644 --- a/packages/core/src/lint/context.ts +++ b/packages/core/src/lint/context.ts @@ -29,7 +29,7 @@ export type { HyperframeLintFinding }; export function buildLintContext(html: string, options: HyperframeLinterOptions = {}): LintContext { const rawSource = html || ""; - let source = rawSource; + let source = rawSource.replace(//g, ""); const templateMatch = source.match(/]*>([\s\S]*)<\/template>/i); if (templateMatch?.[1]) source = templateMatch[1]; diff --git a/packages/core/src/lint/hyperframeLinter.test.ts b/packages/core/src/lint/hyperframeLinter.test.ts index 75cd202313..668ddb1985 100644 --- a/packages/core/src/lint/hyperframeLinter.test.ts +++ b/packages/core/src/lint/hyperframeLinter.test.ts @@ -63,4 +63,32 @@ describe("lintHyperframeHtml — orchestrator", () => { ); expect(missing).toHaveLength(0); }); + + it("ignores comments that mention template tags before the real template", async () => { + const html = ` + + + + + + + +`; + const result = await lintHyperframeHtml(html, { filePath: "compositions/my-comp.html" }); + const rootFindings = result.findings.filter( + (f) => f.code === "root_missing_composition_id" || f.code === "root_missing_dimensions", + ); + expect(rootFindings).toHaveLength(0); + }); }); diff --git a/skills/faceless-explainer/SKILL.md b/skills/faceless-explainer/SKILL.md index 1e801fc4fd..589e32a8a8 100644 --- a/skills/faceless-explainer/SKILL.md +++ b/skills/faceless-explainer/SKILL.md @@ -52,11 +52,11 @@ You make the one judgment call — **which preset**. Read `../hyperframes-creati node /scripts/build-frame.mjs --preset --hyperframes . ``` -The script does the rest deterministically: copies the preset's `FRAME.md` → `frame.md` and **remixes** it onto any brand tokens in `capture/extracted/tokens.json` (brand colors mapped onto the preset's color keys by role; the preset's display + body fonts swapped for the brand's), copies the preset's `caption-skin.html` verbatim, and self-validates (exits 1 on a broken mapping). Proceed as soon as it exits 0 — no hand-editing of the spec. +The script does the rest deterministically: copies the preset's `FRAME.md` → `frame.md` and **remixes** it onto any brand tokens in `capture/extracted/tokens.json` (brand colors mapped onto the preset's color keys by role; the preset's display + body fonts swapped for the brand's), copies the preset's caption skin to `.hyperframes/caption-skin.html`, and self-validates (exits 1 on a broken mapping). Proceed as soon as it exits 0 — no hand-editing of the spec. A faceless explainer usually has **no brand colors/fonts** (`tokens.json` colors/fonts empty) → the script keeps the preset's own palette, a complete shippable design. Only when the user named brand colors/fonts add them to `tokens.json` before running, and only adjust `frame.md` by hand afterward if a mapping truly needs it. -**Gate:** `build-frame.mjs` exited 0 — `frame.md` exists from a named preset, and (when the preset ships one) `caption-skin.html` is at the project root. +**Gate:** `build-frame.mjs` exited 0 — `frame.md` exists from a named preset, and (when the preset ships one) `.hyperframes/caption-skin.html` exists as the caption skin source. --- @@ -130,7 +130,7 @@ After audio timings exist, build captions in the background and assemble the ind `node /scripts/assemble-index.mjs --storyboard ./STORYBOARD.md --hyperframes .` -`captions.mjs` uses the project's `caption-skin.html` (copied in Step 2) as the caption look, injecting brand tokens from `frame.md`; with no skin present it renders the built-in default pill. `captions: skipped ()` is valid. Continue without captions when explicitly skipped. +`captions.mjs` uses the project's `.hyperframes/caption-skin.html` (copied in Step 2) as the caption look, injecting brand tokens from `frame.md`; with no skin present it renders the built-in default pill. `captions: skipped ()` is valid. Continue without captions when explicitly skipped. **Gate:** every frame is marked `animated`, `index.html` exists, and captions are built or explicitly skipped. diff --git a/skills/faceless-explainer/scripts/build-frame.mjs b/skills/faceless-explainer/scripts/build-frame.mjs index dabb9ff7d8..205eb34c2f 100644 --- a/skills/faceless-explainer/scripts/build-frame.mjs +++ b/skills/faceless-explainer/scripts/build-frame.mjs @@ -19,7 +19,7 @@ // fonts — the preset's display family → the brand display font, its body family → // the brand body font, wherever they appear. Empty brand fonts → kept. -import { copyFileSync, existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; +import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { @@ -214,8 +214,10 @@ if (brandColors.length && presetColors.length) { } if (inBlock && /^\S/.test(line)) inBlock = false; if (!inBlock) return line; - const m = line.match(/^(\s+)([\w-]+):\s*["']?[^"'\n]*["']?\s*$/); - if (m && newByKey.has(m[2])) return `${m[1]}${m[2]}: "${newByKey.get(m[2])}"`; + const m = line.match( + /^(\s+)([\w-]+):\s*(?:"[^"]*"|'[^']*'|#[0-9a-fA-F]{3,8}|rgba?\([^)]*\)|[^#\n]*?)(\s+#.*)?$/, + ); + if (m && newByKey.has(m[2])) return `${m[1]}${m[2]}: "${newByKey.get(m[2])}"${m[3] ?? ""}`; return line; }) .join("\n"); @@ -254,7 +256,9 @@ writeFileSync(framePath, md); const presetSkin = join(presetDir, presetName, "caption-skin.html"); let skinCopied = false; if (existsSync(presetSkin)) { - copyFileSync(presetSkin, join(hyperframesDir, "caption-skin.html")); + const skinDir = join(hyperframesDir, ".hyperframes"); + mkdirSync(skinDir, { recursive: true }); + copyFileSync(presetSkin, join(skinDir, "caption-skin.html")); skinCopied = true; } @@ -275,6 +279,6 @@ if (li != null && lc != null && li >= lc) { console.log(`✓ build-frame: ${presetName} → ${framePath}`); for (const s of summary) console.log(` ${s}`); console.log( - ` caption-skin.html: ${skinCopied ? "copied" : "preset ships none — captions will use the default pill"}`, + ` .hyperframes/caption-skin.html: ${skinCopied ? "copied" : "preset ships none — captions will use the default pill"}`, ); console.log(` self-check: keys preserved, ink darker than canvas ✓`); diff --git a/skills/faceless-explainer/scripts/captions.mjs b/skills/faceless-explainer/scripts/captions.mjs index 72035382fa..75a1289f63 100644 --- a/skills/faceless-explainer/scripts/captions.mjs +++ b/skills/faceless-explainer/scripts/captions.mjs @@ -15,8 +15,9 @@ // node captions.mjs build --storyboard ./STORYBOARD.md --audio-meta ./audio_meta.json --hyperframes . --out ./caption_groups.json // // CAPTION LOOK — two sources, picked automatically: -// 1. PRESET SKIN (preferred). If a project-local `caption-skin.html` exists (Step 2 -// copies the chosen frame-preset's skin into the project), it is the caption look. +// 1. PRESET SKIN (preferred). If a project-local `.hyperframes/caption-skin.html` +// exists (Step 2 copies the chosen frame-preset's skin into the project), it is +// the caption look. // It is a brand-token-strict skin with three reserved holes; this script fills them // and wraps the result in a