From 9f30a6d7076d1e0261d4934ae6539151233826ec Mon Sep 17 00:00:00 2001 From: Srikar Sunchu Date: Mon, 14 Sep 2026 17:31:44 -0700 Subject: [PATCH] fix(skills): stage a preset's own fonts/ folder in every build-frame.mjs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The code-editorial preset ships licensed WOFF2 files and its FRAME.md promises build-frame.mjs stages them into assets/fonts/. Only the pr-to-video copy did, with the six code-editorial filenames hard-coded and a hard exit for any other preset that ships a fonts/ folder; the product-launch-video and faceless-explainer copies never read the folder at all, so a frame worker following the font_family_without_font_face rule found no file behind the preset's families and fell back to a system stack. Make preset font staging directory-driven and shared by all three workflows: each font file is assigned to the longest family the remixed frame.md still names, weight and style are read from the filename under the same naming contract captions.mjs parses, faces the brand remix replaced are skipped, faces the captured brand already staged are not staged twice, and OFL/LICENSE texts travel with any staged face. Both sources now emit one @font-face block in frame.md. The filename → face helpers move out of the brand-font block into a new lib/font-faces.mjs (byte-identical across the three skills, guarded by a test) so the two staging paths cannot drift. Review corrections folded in: the three build-frame.mjs copies are formatted with the repo-pinned oxfmt; captions.mjs imports the weight/style/format/family-key helpers from lib/font-faces.mjs instead of carrying a hand copy (the longest-family-first ranking all three sites rely on is one exported rankFontFamilies), and its test checks the import plus the negative "never assert font-style:normal" guard against the library; an overlapping-family test ("Inter" + "Inter Tight") pins the longest-family assignment; the skipped-file message reads "matching no family frame.md names" since a filename may never have named one. Robustness corrections: stagePresetFonts only considers plain files (statSync().isFile() in a try/catch), so a subdirectory or dangling symlink named like a font neither crashes copyFileSync with EISDIR nor stat with ENOENT; dedup is keyed on the face (family, numeric weight, style) rather than the staged filename, so a word-named brand file and a numeric-named preset file for the same face yield one @font-face rule with the brand's bytes; a preset face's staged bytes are refreshed from the preset on every run (a skills update may ship re-subsetted files), skipping only a self-copy, while a capture file never overwrites a file already at its staged path, so a face hand-placed in assets/fonts/ keeps winning over the download as it did before; and files that follow the staged-name contract (assets/fonts/ and a preset's fonts/) are matched by family-key prefix on the stem while only loosely named capture files get the single-family shortcut and substring match, so a rerun after brand tokens arrive cannot relabel preset faces already in assets/fonts/ as the brand font — not even a brand named "Garamond" against EBGaramond-*.woff2. Refs #3583 Co-Authored-By: Miguel Ángel --- skills-manifest.json | 14 +- skills/faceless-explainer/SKILL.md | 2 +- .../scripts/build-frame-fonts.test.mjs | 492 ++++++++++++++++++ .../scripts/build-frame.mjs | 158 +++--- .../faceless-explainer/scripts/captions.mjs | 74 +-- .../scripts/captions.test.mjs | 20 +- .../scripts/lib/font-faces.mjs | 162 ++++++ .../frame-presets/code-editorial/FRAME.md | 2 +- .../references/design-spec.md | 2 +- skills/pr-to-video/SKILL.md | 2 +- .../scripts/build-frame-fonts.test.mjs | 492 ++++++++++++++++++ skills/pr-to-video/scripts/build-frame.mjs | 193 +++---- skills/pr-to-video/scripts/captions.mjs | 74 +-- skills/pr-to-video/scripts/captions.test.mjs | 20 +- skills/pr-to-video/scripts/lib/font-faces.mjs | 162 ++++++ skills/product-launch-video/SKILL.md | 2 +- .../scripts/build-frame-fonts.test.mjs | 492 ++++++++++++++++++ .../scripts/build-frame.mjs | 158 +++--- .../product-launch-video/scripts/captions.mjs | 74 +-- .../scripts/captions.test.mjs | 20 +- .../scripts/lib/font-faces.mjs | 162 ++++++ 21 files changed, 2340 insertions(+), 437 deletions(-) create mode 100644 skills/faceless-explainer/scripts/build-frame-fonts.test.mjs create mode 100644 skills/faceless-explainer/scripts/lib/font-faces.mjs create mode 100644 skills/pr-to-video/scripts/build-frame-fonts.test.mjs create mode 100644 skills/pr-to-video/scripts/lib/font-faces.mjs create mode 100644 skills/product-launch-video/scripts/build-frame-fonts.test.mjs create mode 100644 skills/product-launch-video/scripts/lib/font-faces.mjs diff --git a/skills-manifest.json b/skills-manifest.json index b97145e191..09dc8b94ac 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -6,8 +6,8 @@ "files": 142 }, "faceless-explainer": { - "hash": "7ff529b85f588ffc", - "files": 24 + "hash": "7aba9532bad2aff1", + "files": 26 }, "figma": { "hash": "642cce1b5e211182", @@ -38,7 +38,7 @@ "files": 11 }, "hyperframes-creative": { - "hash": "7b6b7a78102da71b", + "hash": "89fe0459373edd39", "files": 78 }, "hyperframes-keyframes": { @@ -62,12 +62,12 @@ "files": 132 }, "pr-to-video": { - "hash": "bbb3db51176b7e44", - "files": 30 + "hash": "3e761da176d1abf7", + "files": 32 }, "product-launch-video": { - "hash": "d87f7ae140e88403", - "files": 30 + "hash": "b3183ca0a4f7f01b", + "files": 32 }, "remotion-to-hyperframes": { "hash": "13ade041e6416e5b", diff --git a/skills/faceless-explainer/SKILL.md b/skills/faceless-explainer/SKILL.md index aac17fa0c3..3fd4b9d691 100644 --- a/skills/faceless-explainer/SKILL.md +++ b/skills/faceless-explainer/SKILL.md @@ -71,7 +71,7 @@ When `BRIEF.md` names a `style_preset` — the user picked it by eye from the sh 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 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. +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), stages any font files the preset ships (its `fonts/` folder) into `assets/fonts/` with a ready-to-paste `@font-face` block in `frame.md`, 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. diff --git a/skills/faceless-explainer/scripts/build-frame-fonts.test.mjs b/skills/faceless-explainer/scripts/build-frame-fonts.test.mjs new file mode 100644 index 0000000000..0c3771503b --- /dev/null +++ b/skills/faceless-explainer/scripts/build-frame-fonts.test.mjs @@ -0,0 +1,492 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +import { + fontFamiliesNamed, + fontWeightInfo, + stageFontFile, + stagePresetFonts, +} from "./lib/font-faces.mjs"; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const buildFrameScript = join(scriptDir, "build-frame.mjs"); +const shippedPresetDir = resolve(scriptDir, "../../hyperframes-creative/frame-presets"); + +const tmpDirs = []; +const tmp = (prefix) => { + const dir = mkdtempSync(join(tmpdir(), prefix)); + tmpDirs.push(dir); + return dir; +}; +test.after(() => { + for (const dir of tmpDirs) rmSync(dir, { recursive: true, force: true }); +}); + +function write(path, contents) { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, contents); +} + +// A minimal preset: three families in the typography ramp, a legal color block so the +// script's self-check passes, and whatever the caller drops into fonts/. +function fakePreset(root, fonts = {}) { + const presetDir = join(root, "presets"); + const dir = join(presetDir, "acme"); + write( + join(dir, "FRAME.md"), + [ + "---", + "name: Acme — Frame", + "", + "colors:", + ' ink: "#141413"', + ' cream: "#FAF9F5"', + ' coral: "#CC785C"', + "", + "typography:", + ' display: { fontFamily: "Acme Sans", cqw: 6.7, weight: 700, lineHeight: 1.02 }', + ' body: { fontFamily: "Acme Sans", cqw: 1.5, weight: 400, lineHeight: 1.5 }', + ' code: { fontFamily: "Other Mono", cqw: 1.4, weight: 400, lineHeight: 1.6 }', + "---", + "", + "# Acme", + "", + "Prose about the look.", + "", + ].join("\n"), + ); + for (const [name, bytes] of Object.entries(fonts)) write(join(dir, "fonts", name), bytes); + return presetDir; +} + +function runBuildFrame(project, presetDir, preset = "acme") { + return execFileSync( + process.execPath, + [buildFrameScript, "--preset", preset, "--hyperframes", project, "--preset-dir", presetDir], + { encoding: "utf8" }, + ); +} + +const PRESET_FONTS = { + "AcmeSans-400.woff2": "sans-regular", + "AcmeSans-Bold.woff2": "sans-bold", + "AcmeSans-BoldItalic.woff2": "sans-bold-italic", + "OtherMono-700.ttf": "mono-bold", + "Stray-400.woff2": "no such family in the ramp", + "OFL-acme-sans.txt": "SIL Open Font License", +}; + +test("a preset's own fonts/ folder is staged by family, weight and style read from the filename", () => { + const root = tmp("build-frame-preset-fonts-"); + const project = join(root, "project"); + write(join(project, "capture/extracted/tokens.json"), '{"colors":[],"fonts":[]}'); + const out = runBuildFrame(project, fakePreset(root, PRESET_FONTS)); + + const stagedDir = join(project, "assets/fonts"); + assert.deepEqual(readdirSync(stagedDir).sort(), [ + "AcmeSans-400.woff2", + "AcmeSans-Bold-Italic.woff2", + "AcmeSans-Bold.woff2", + "OFL-acme-sans.txt", + "OtherMono-700.ttf", + ]); + assert.equal( + readFileSync(join(stagedDir, "AcmeSans-Bold-Italic.woff2"), "utf8"), + "sans-bold-italic", + ); + + const frameMd = readFileSync(join(project, "frame.md"), "utf8"); + assert.match(frameMd, /## Font loading \(auto-generated\)/); + assert.match(frameMd, /from the acme preset/); + for (const rule of [ + '@font-face{font-family:"Acme Sans";font-weight:400;font-style:normal;font-display:block;src:url("assets/fonts/AcmeSans-400.woff2") format("woff2");}', + '@font-face{font-family:"Acme Sans";font-weight:700;font-style:normal;font-display:block;src:url("assets/fonts/AcmeSans-Bold.woff2") format("woff2");}', + '@font-face{font-family:"Acme Sans";font-weight:700;font-style:italic;font-display:block;src:url("assets/fonts/AcmeSans-Bold-Italic.woff2") format("woff2");}', + '@font-face{font-family:"Other Mono";font-weight:700;font-style:normal;font-display:block;src:url("assets/fonts/OtherMono-700.ttf") format("truetype");}', + ]) { + assert.ok(frameMd.includes(rule), `frame.md should carry ${rule}`); + } + assert.equal((frameMd.match(/## Font loading/g) ?? []).length, 1, "one font-loading section"); + assert.doesNotMatch(frameMd, /Stray/); + + assert.match(out, /fonts: staged 4 preset face\(s\) → assets\/fonts\/ \(\+ OFL-acme-sans\.txt\)/); + assert.match( + out, + /skipped 1 preset file\(s\) matching no family frame\.md names: Stray-400\.woff2/, + ); + assert.match(out, /@font-face block for 4 face\(s\) appended to frame\.md/); +}); + +test("faces of a family the brand remix replaced are not staged; the rest still are", () => { + const root = tmp("build-frame-preset-fonts-remix-"); + const project = join(root, "project"); + write( + join(project, "capture/extracted/tokens.json"), + JSON.stringify({ colors: [], fonts: [{ family: "Brand Grotesk", weights: [400, 700] }] }), + ); + const out = runBuildFrame(project, fakePreset(root, PRESET_FONTS)); + + // Acme Sans (display + body) became Brand Grotesk, which ships no files here; Other Mono + // is untouched by the remix and keeps its preset-owned face. + assert.deepEqual(readdirSync(join(project, "assets/fonts")).sort(), [ + "OFL-acme-sans.txt", + "OtherMono-700.ttf", + ]); + const frameMd = readFileSync(join(project, "frame.md"), "utf8"); + assert.doesNotMatch(frameMd, /Acme Sans/); + assert.match(frameMd, /@font-face\{font-family:"Other Mono";font-weight:700/); + assert.doesNotMatch(frameMd, /AcmeSans-/); + assert.match(out, /fonts: staged 1 preset face\(s\)/); + assert.match( + out, + /skipped 4 preset file\(s\) matching no family frame\.md names: AcmeSans-400\.woff2, AcmeSans-Bold\.woff2, AcmeSans-BoldItalic\.woff2, Stray-400\.woff2/, + ); +}); + +test("a face the captured brand already staged is not staged twice from the preset", () => { + const root = tmp("build-frame-preset-fonts-dedup-"); + const project = join(root, "project"); + // The brand IS the preset's mono family and the capture downloaded its bold face. + write( + join(project, "capture/extracted/tokens.json"), + JSON.stringify({ colors: [], fonts: [{ family: "Other Mono", weights: [700] }] }), + ); + write(join(project, "capture/assets/fonts/othermono-latin-700-normal.woff2"), "captured-bold"); + runBuildFrame(project, fakePreset(root, { "OtherMono-700.woff2": "preset-bold" })); + + const stagedDir = join(project, "assets/fonts"); + assert.deepEqual(readdirSync(stagedDir).sort(), ["OtherMono-700.woff2"]); + assert.equal(readFileSync(join(stagedDir, "OtherMono-700.woff2"), "utf8"), "captured-bold"); + const frameMd = readFileSync(join(project, "frame.md"), "utf8"); + assert.equal((frameMd.match(/@font-face\{/g) ?? []).length, 1); +}); + +test("a brand face the capture named by weight word is not staged again from the preset's numeric file", () => { + const root = tmp("build-frame-preset-fonts-dedup-word-"); + const project = join(root, "project"); + write( + join(project, "capture/extracted/tokens.json"), + JSON.stringify({ colors: [], fonts: [{ family: "Other Mono", weights: [700] }] }), + ); + write(join(project, "capture/assets/fonts/OtherMono-Bold.woff2"), "captured-bold"); + runBuildFrame(project, fakePreset(root, { "OtherMono-700.woff2": "preset-bold" })); + + assert.deepEqual(readdirSync(join(project, "assets/fonts")).sort(), ["OtherMono-Bold.woff2"]); + const frameMd = readFileSync(join(project, "frame.md"), "utf8"); + assert.equal((frameMd.match(/@font-face\{/g) ?? []).length, 1); +}); + +test("a rerun after brand tokens arrive does not relabel previously staged preset faces as the brand", () => { + const root = tmp("build-frame-preset-fonts-rerun-"); + const project = join(root, "project"); + const presetDir = fakePreset(root, PRESET_FONTS); + // Run 1: no brand yet — the preset's own faces land in assets/fonts/. + write(join(project, "capture/extracted/tokens.json"), '{"colors":[],"fonts":[]}'); + runBuildFrame(project, presetDir); + assert.equal(existsSync(join(project, "assets/fonts/AcmeSans-400.woff2")), true); + // Run 2: one captured brand family with a Google-named file. Only the capture file may be + // claimed by the single-family shortcut; the preset bytes already in assets/fonts/ must + // not — "Sans" is even a substring of the staged "AcmeSans-*" names. + write( + join(project, "capture/extracted/tokens.json"), + JSON.stringify({ colors: [], fonts: [{ family: "Sans", weights: [400] }] }), + ); + write(join(project, "capture/assets/fonts/sans-regular.woff2"), "sans-regular"); + const out = runBuildFrame(project, presetDir); + + const stagedDir = join(project, "assets/fonts"); + assert.equal(readFileSync(join(stagedDir, "Sans-Regular.woff2"), "utf8"), "sans-regular"); + assert.equal( + existsSync(join(stagedDir, "Sans-400.woff2")), + false, + "AcmeSans bytes not relabeled", + ); + assert.equal(existsSync(join(stagedDir, "Sans-Bold.woff2")), false); + const frameMd = readFileSync(join(project, "frame.md"), "utf8"); + const sansRules = frameMd.match(/@font-face\{font-family:"Sans";[^\n]*/g) ?? []; + assert.equal(sansRules.length, 1); + assert.match(sansRules[0], /Sans-Regular\.woff2/); + assert.match(out, /fonts: staged 1 brand face\(s\)/); +}); + +test("a hash-named capture file is staged as the single captured brand family", () => { + const root = tmp("build-frame-capture-hash-"); + const project = join(root, "project"); + write( + join(project, "capture/extracted/tokens.json"), + JSON.stringify({ colors: [], fonts: [{ family: "Brand Grotesk", weights: [400] }] }), + ); + // The capture pipeline saves downloads under their content hash (see the CLI's + // fontMetadataExtractor): nothing in the name says "Brand Grotesk", so only the + // single-family shortcut can claim it — the prefix rule would leave the brand unstaged. + write(join(project, "capture/assets/fonts/19cfc7226ec3afaa-s.woff2"), "captured-regular"); + const out = runBuildFrame(project, fakePreset(root)); + + const stagedDir = join(project, "assets/fonts"); + assert.deepEqual(readdirSync(stagedDir).sort(), ["BrandGrotesk-Regular.woff2"]); + assert.equal( + readFileSync(join(stagedDir, "BrandGrotesk-Regular.woff2"), "utf8"), + "captured-regular", + ); + const frameMd = readFileSync(join(project, "frame.md"), "utf8"); + assert.match( + frameMd, + /@font-face\{font-family:"Brand Grotesk";font-weight:400;font-style:normal;[^\n]*BrandGrotesk-Regular\.woff2/, + ); + assert.match(out, /fonts: staged 1 brand face\(s\)/); +}); + +test("capture files of two brand families each go to the family whose key their name contains", () => { + const root = tmp("build-frame-capture-two-families-"); + const project = join(root, "project"); + write( + join(project, "capture/extracted/tokens.json"), + JSON.stringify({ + colors: [], + fonts: [ + { family: "Brand Grotesk", weights: [400] }, + { family: "Brand Mono", weights: [700] }, + ], + }), + ); + // CDN-style names carry the family INSIDE the stem, not at its start: substring matching + // is what tells the two downloads apart; a prefix match would stage neither. + write(join(project, "capture/assets/fonts/cdn-brandgrotesk-400.woff2"), "grotesk-regular"); + write(join(project, "capture/assets/fonts/cdn-brandmono-700.woff2"), "mono-bold"); + const out = runBuildFrame(project, fakePreset(root)); + + const stagedDir = join(project, "assets/fonts"); + assert.deepEqual(readdirSync(stagedDir).sort(), [ + "BrandGrotesk-400.woff2", + "BrandMono-700.woff2", + ]); + assert.equal(readFileSync(join(stagedDir, "BrandGrotesk-400.woff2"), "utf8"), "grotesk-regular"); + assert.equal(readFileSync(join(stagedDir, "BrandMono-700.woff2"), "utf8"), "mono-bold"); + const frameMd = readFileSync(join(project, "frame.md"), "utf8"); + assert.match( + frameMd, + /@font-face\{font-family:"Brand Grotesk";font-weight:400;[^\n]*BrandGrotesk-400\.woff2/, + ); + assert.match( + frameMd, + /@font-face\{font-family:"Brand Mono";font-weight:700;[^\n]*BrandMono-700\.woff2/, + ); + assert.match(out, /fonts: staged 2 brand face\(s\)/); +}); + +test("a capture file does not overwrite a face already staged in assets/fonts/", () => { + const root = tmp("build-frame-capture-keeps-staged-"); + const project = join(root, "project"); + write( + join(project, "capture/extracted/tokens.json"), + JSON.stringify({ colors: [], fonts: [{ family: "Brand Grotesk", weights: [400] }] }), + ); + write(join(project, "capture/assets/fonts/brand-grotesk-400.woff2"), "captured-regular"); + // Hand-placed (or previously staged) bytes under the staged name win over the download. + write(join(project, "assets/fonts/BrandGrotesk-400.woff2"), "hand-placed"); + runBuildFrame(project, fakePreset(root)); + + const stagedDir = join(project, "assets/fonts"); + assert.deepEqual(readdirSync(stagedDir).sort(), ["BrandGrotesk-400.woff2"]); + assert.equal(readFileSync(join(stagedDir, "BrandGrotesk-400.woff2"), "utf8"), "hand-placed"); + const frameMd = readFileSync(join(project, "frame.md"), "utf8"); + assert.equal((frameMd.match(/@font-face\{/g) ?? []).length, 1); +}); + +test("a rerun refreshes a preset face's staged bytes from the preset", () => { + const root = tmp("build-frame-preset-refresh-"); + const project = join(root, "project"); + const presetDir = fakePreset(root, PRESET_FONTS); + write(join(project, "capture/extracted/tokens.json"), '{"colors":[],"fonts":[]}'); + runBuildFrame(project, presetDir); + const staged = join(project, "assets/fonts/AcmeSans-400.woff2"); + assert.equal(readFileSync(staged, "utf8"), "sans-regular"); + // A skills update may ship re-subsetted files: the preset source is authoritative. + writeFileSync(staged, "stale bytes from an earlier run"); + runBuildFrame(project, presetDir); + assert.equal(readFileSync(staged, "utf8"), "sans-regular"); +}); + +test("a preset without a fonts/ folder stages nothing and appends no font-loading section", () => { + const root = tmp("build-frame-preset-no-fonts-"); + const project = join(root, "project"); + write(join(project, "capture/extracted/tokens.json"), '{"colors":[],"fonts":[]}'); + const out = runBuildFrame(project, fakePreset(root)); + assert.equal(existsSync(join(project, "assets/fonts")), false); + assert.doesNotMatch(readFileSync(join(project, "frame.md"), "utf8"), /## Font loading/); + assert.doesNotMatch(out, /fonts: staged/); +}); + +test("the shipped code-editorial preset stages its six licensed faces plus their licenses", () => { + const project = tmp("build-frame-code-editorial-"); + write(join(project, "capture/extracted/tokens.json"), '{"colors":[],"fonts":[]}'); + runBuildFrame(project, shippedPresetDir, "code-editorial"); + + const stagedDir = join(project, "assets/fonts"); + for (const name of [ + "EBGaramond-400.woff2", + "EBGaramond-700.woff2", + "Inter-400.woff2", + "Inter-700.woff2", + "JetBrainsMono-400.woff2", + "JetBrainsMono-700.woff2", + ]) { + assert.equal(existsSync(join(stagedDir, name)), true, `${name} should be staged`); + assert.equal(readFileSync(join(stagedDir, name)).subarray(0, 4).toString("ascii"), "wOF2"); + } + for (const family of ["eb-garamond", "inter", "jetbrains-mono"]) { + assert.equal(existsSync(join(stagedDir, `OFL-${family}.txt`)), true); + } + const frameMd = readFileSync(join(project, "frame.md"), "utf8"); + assert.match(frameMd, /@font-face\{font-family:"EB Garamond";font-weight:400/); + assert.match(frameMd, /@font-face\{font-family:"Inter";font-weight:700/); + assert.match(frameMd, /@font-face\{font-family:"JetBrains Mono";font-weight:400/); + assert.ok(!frameMd.includes("fonts.googleapis.com")); +}); + +test("fontFamiliesNamed reads every family in a ramp once", () => { + assert.deepEqual( + fontFamiliesNamed( + 'display: { fontFamily: "EB Garamond" }\nbody: { fontFamily: "Inter" }\nlead: { fontFamily: "Inter" }', + ), + ["EB Garamond", "Inter"], + ); +}); + +test("fontWeightInfo prefers a numeric axis and never reads a hash as a weight", () => { + assert.deepEqual(fontWeightInfo("Inter-700.woff2"), { n: 700, w: "700" }); + assert.deepEqual(fontWeightInfo("inter-latin-500-normal.woff2"), { n: 500, w: "500" }); + assert.deepEqual(fontWeightInfo("Newsreader-a1b200c3.woff2"), { n: 400, w: "Regular" }); + assert.deepEqual(fontWeightInfo("Acme-SemiBold.otf"), { n: 600, w: "SemiBold" }); +}); + +test("stageFontFile leaves a file already at the staged path alone unless refresh is set", () => { + const root = tmp("stage-font-file-keep-"); + write(join(root, "src/Acme-Bold.woff2"), "download"); + const outDir = join(root, "out"); + write(join(outDir, "Acme-Bold.woff2"), "hand-placed"); + const kept = stageFontFile({ + family: "Acme", + srcPath: join(root, "src/Acme-Bold.woff2"), + outDir, + stagedFaces: new Set(), + }); + assert.equal(kept.name, "Acme-Bold.woff2"); + assert.match(kept.rule, /Acme-Bold\.woff2/); + assert.equal(readFileSync(join(outDir, "Acme-Bold.woff2"), "utf8"), "hand-placed"); +}); + +test("stageFontFile refreshes stale bytes, keeps the first source's face and refuses non-font files", () => { + const root = tmp("stage-font-file-"); + write(join(root, "a/Acme-Bold.woff2"), "first"); + write(join(root, "b/acme-700.woff2"), "second"); + write(join(root, "a/OFL.txt"), "license"); + const outDir = join(root, "out"); + write(join(outDir, "Acme-Bold.woff2"), "stale bytes from an earlier run"); + const stagedFaces = new Set(); + const first = stageFontFile({ + family: "Acme", + srcPath: join(root, "a/Acme-Bold.woff2"), + outDir, + stagedFaces, + refresh: true, + }); + assert.equal(first.name, "Acme-Bold.woff2"); + assert.match(first.rule, /font-weight:700;font-style:normal/); + assert.equal( + stageFontFile({ family: "Acme", srcPath: join(root, "b/acme-700.woff2"), outDir, stagedFaces }), + null, + "a numeric and a word weight for the same face are one face — the first source wins", + ); + assert.equal(existsSync(join(outDir, "Acme-700.woff2")), false); + assert.equal( + stageFontFile({ family: "Acme", srcPath: join(root, "a/OFL.txt"), outDir, stagedFaces }), + null, + ); + assert.equal(readFileSync(join(outDir, "Acme-Bold.woff2"), "utf8"), "first"); +}); + +test("a font file goes to the longest family its name starts with, not the first that matches", () => { + const root = tmp("stage-preset-fonts-overlap-"); + const presetFontsDir = join(root, "fonts"); + write(join(presetFontsDir, "InterTight-700.woff2"), "tight-bold"); + write(join(presetFontsDir, "Inter-400.woff2"), "inter-regular"); + const outDir = join(root, "out"); + // "Inter" is a prefix of "Inter Tight": listed first so that only the length ranking, not + // the input order, can send InterTight-700 to the right family. + const result = stagePresetFonts({ + presetFontsDir, + outDir, + families: ["Inter", "Inter Tight"], + stagedFaces: new Set(), + }); + assert.deepEqual(result.staged, ["Inter-400.woff2", "InterTight-700.woff2"]); + assert.deepEqual(result.skipped, []); + assert.match(result.faces[0], /font-family:"Inter";font-weight:400;.*Inter-400\.woff2/); + assert.match( + result.faces[1], + /font-family:"Inter Tight";font-weight:700;.*InterTight-700\.woff2/, + ); + assert.equal(readFileSync(join(outDir, "InterTight-700.woff2"), "utf8"), "tight-bold"); + assert.equal(readFileSync(join(outDir, "Inter-400.woff2"), "utf8"), "inter-regular"); + + // A family whose key merely appears INSIDE a filename does not own it: after a remix + // renamed "EB Garamond" to "Garamond", the preset's EBGaramond files are dead weight. + const substring = stagePresetFonts({ + presetFontsDir, + outDir: join(root, "out2"), + families: ["Tight"], + stagedFaces: new Set(), + }); + assert.deepEqual(substring.staged, []); + assert.deepEqual(substring.skipped, ["Inter-400.woff2", "InterTight-700.woff2"]); +}); + +test("a subdirectory or dangling symlink named like a font file is neither staged nor a crash", () => { + const root = tmp("stage-preset-fonts-dir-entry-"); + const presetFontsDir = join(root, "fonts"); + mkdirSync(join(presetFontsDir, "Inter-700.woff2"), { recursive: true }); + mkdirSync(join(presetFontsDir, "LICENSE"), { recursive: true }); + symlinkSync(join(root, "gone.woff2"), join(presetFontsDir, "Inter-Bold.woff2")); + write(join(presetFontsDir, "Inter-400.woff2"), "inter-regular"); + const result = stagePresetFonts({ + presetFontsDir, + outDir: join(root, "out"), + families: ["Inter"], + stagedFaces: new Set(), + }); + assert.deepEqual(result.staged, ["Inter-400.woff2"]); + assert.deepEqual(result.skipped, []); + assert.deepEqual(result.licenses, []); +}); + +// font-faces.mjs ships once per creation workflow because each skill installs standalone; +// the three copies are meant to be byte-identical so a fix landing in one cannot drift. +test("font-faces.mjs is byte-identical across the three workflows that ship it", () => { + const [first, ...rest] = ["product-launch-video", "faceless-explainer", "pr-to-video"].map( + (skill) => ({ + skill, + source: readFileSync( + new URL(`../../${skill}/scripts/lib/font-faces.mjs`, import.meta.url), + "utf8", + ), + }), + ); + for (const other of rest) { + assert.equal(other.source, first.source, `${other.skill} drifted from ${first.skill}`); + } +}); diff --git a/skills/faceless-explainer/scripts/build-frame.mjs b/skills/faceless-explainer/scripts/build-frame.mjs index ec08e112a6..8ef9684a28 100644 --- a/skills/faceless-explainer/scripts/build-frame.mjs +++ b/skills/faceless-explainer/scripts/build-frame.mjs @@ -40,6 +40,15 @@ import { STATUS_ROLE_KEY, UA_DEFAULT_COLORS, } from "./lib/tokens.mjs"; +import { + familyOfFaceFile, + fontExtOf, + fontFamiliesNamed, + normFontName, + rankFontFamilies, + stageFontFile, + stagePresetFonts, +} from "./lib/font-faces.mjs"; const __dirname = dirname(fileURLToPath(import.meta.url)); const argv = process.argv.slice(2); @@ -428,89 +437,90 @@ if (brandFonts.length || (brandColors.length && presetColors.length)) { summary.push("brand-adaptation note prepended"); } -// ── stage brand font files + emit @font-face ────────────────────────────────── -// A brand font is rarely a Google font, so renaming the family in frame.md is not enough: -// nothing loads the actual face. If the capture downloaded font files, copy them to -// assets/fonts/ under CLEAN, face-named names (so captions.mjs' family-prefix matcher -// finds them too) and append a ready-to-paste, ROOT-RELATIVE @font-face block to frame.md. -// -// The staged NAME is a contract, not cosmetics: captions.mjs derives each face's weight and -// style back out of it. So the name has to carry every axis that distinguishes one face from -// another, and the dedup key has to be the whole face. Naming on weight alone made Google's -// two-file Newsreader download (upright + italic, both scoring "Regular") collide on one -// slot: the italic sorts first, took the name, the upright was never staged, and the block -// below then asserted font-style:normal over italic bytes. +// ── stage font files + emit @font-face ──────────────────────────────────────── +// Two sources feed assets/fonts/, in this order: +// 1. captured BRAND font files — a brand font is rarely a Google font, so renaming the +// family in frame.md is not enough: nothing loads the actual face. If the capture +// downloaded font files, stage them. +// 2. the PRESET's own fonts/ folder — a preset that ships licensed files (code-editorial) +// stages every face whose family the remixed frame.md still names, so Studio, +// snapshots, and renders resolve its type system offline instead of depending on a +// first-run Google Fonts fetch. A face for a family the brand remix replaced is dead +// weight and is skipped; a face the brand already staged is not staged twice. +// Files land under CLEAN, face-named names (so captions.mjs' family-prefix matcher finds +// them too — the name is a contract, see lib/font-faces.mjs) and ONE ready-to-paste, +// ROOT-RELATIVE @font-face block covering both sources is appended to frame.md. +const fontOutDir = join(hyperframesDir, "assets/fonts"); +const stagedFontFaces = new Set(); +const fontFaces = []; +const fontSources = []; if (brandFonts.length) { - const norm = (s) => - String(s) - .toLowerCase() - .replace(/[^a-z0-9]/g, ""); - const extOf = (f) => (f.match(/\.(woff2|woff|ttf|otf)$/i)?.[1] ?? "").toLowerCase(); - const FMT = { woff2: "woff2", woff: "woff", ttf: "truetype", otf: "opentype" }; - const weightInfo = (name) => { - const s = name.toLowerCase(); - // A numeric axis is the font's own answer, so it beats the word heuristic. Fontsource - // names every face that way and carries no weight WORD at all, so word-only parsing - // scored a whole family "Regular" and staged exactly one of its faces. - // - // A weight token must not be buried inside a longer run: this reads capture files, - // which are commonly hash-named, and "Newsreader-a1b200c3.woff2" is not a 200-weight - // face. Hence a non-digit before (which also stops "2100" reading as 100) and no - // alphanumeric after. "Roboto900.ttf" still parses. - const numeric = /(?:^|[^0-9])([1-9]00)(?![0-9a-z])/.exec(s); - if (numeric) return { n: Number(numeric[1]), w: numeric[1] }; - if (/black|heavy|ultra|extrabold/.test(s)) return { n: 800, w: "ExtraBold" }; - if (/semibold|demibold/.test(s)) return { n: 600, w: "SemiBold" }; - if (/bold/.test(s)) return { n: 700, w: "Bold" }; - if (/medium/.test(s)) return { n: 500, w: "Medium" }; - if (/light|thin/.test(s)) return { n: 300, w: "Light" }; - return { n: 400, w: "Regular" }; - }; - const styleOf = (name) => (/italic|oblique/i.test(name) ? "italic" : "normal"); const fams = [...new Set(brandFonts)]; - const srcDirs = [ - join(hyperframesDir, "capture/assets/fonts"), - join(hyperframesDir, "assets/fonts"), - ].filter((d) => existsSync(d)); + const captureDir = join(hyperframesDir, "capture/assets/fonts"); + const srcDirs = [captureDir, fontOutDir].filter((d) => existsSync(d)); const files = []; for (const d of srcDirs) - for (const f of readdirSync(d).sort()) if (extOf(f)) files.push({ d, f }); - // Single family → all font files belong to it (the common captured case, hash-named files - // included). Multiple families → assign each file to the longest family key its name contains. - const ranked = [...fams].sort((a, b) => norm(b).length - norm(a).length); - const famOf = (f) => - fams.length === 1 ? fams[0] : ranked.find((x) => norm(f).includes(norm(x))); - const outDir = join(hyperframesDir, "assets/fonts"); - const faces = []; - const stagedNames = new Set(); + for (const f of readdirSync(d).sort()) if (fontExtOf(f)) files.push({ d, f }); + // Capture files are loosely named (hash-named, or Google's "family-latin-400-normal"): a + // single captured family owns ALL of them, several are told apart by the longest family + // key the name contains. Files already in assets/fonts/ follow the staged-name contract + // and get neither shortcut — a previous run may have staged a PRESET's faces there, and a + // rerun after brand tokens arrive must not relabel EB Garamond's bytes as the brand font + // (not even for a brand named "Garamond"). + const ranked = rankFontFamilies(fams); + const famOf = (d, f) => { + if (d !== captureDir) return familyOfFaceFile(f, ranked); + if (fams.length === 1) return fams[0]; + return ranked.find((x) => normFontName(f).includes(x.key))?.family; + }; + const brandFaces = []; for (const { d, f } of files) { - const fam = famOf(f); + const fam = famOf(d, f); if (!fam) continue; - const { n, w } = weightInfo(f); - const style = styleOf(f); - const clean = `${fam.replace(/[^A-Za-z0-9]/g, "")}-${w}${style === "italic" ? "-Italic" : ""}.${extOf(f)}`; - if (stagedNames.has(clean)) continue; - mkdirSync(outDir, { recursive: true }); - if (!existsSync(join(outDir, clean))) copyFileSync(join(d, f), join(outDir, clean)); - stagedNames.add(clean); - faces.push( - `@font-face{font-family:"${fam}";font-weight:${n};font-style:${style};font-display:block;src:url("assets/fonts/${clean}") format("${FMT[extOf(f)]}");}`, - ); + const face = stageFontFile({ + family: fam, + srcPath: join(d, f), + outDir: fontOutDir, + stagedFaces: stagedFontFaces, + }); + if (face) brandFaces.push(face.rule); } - if (faces.length) { - md += - `\n\n## Font loading (auto-generated)\n\n` + - `The brand font ships as local files in \`assets/fonts/\` — do NOT link Google Fonts for it. ` + - `Paste this \`\n```\n"; - summary.push( - `fonts: staged ${stagedNames.size} face(s) → assets/fonts/ + @font-face in frame.md`, - ); + if (brandFaces.length) { + fontFaces.push(...brandFaces); + fontSources.push("the captured brand font"); + summary.push(`fonts: staged ${brandFaces.length} brand face(s) → assets/fonts/`); } } +const presetFonts = stagePresetFonts({ + presetFontsDir: join(presetDir, presetName, "fonts"), + outDir: fontOutDir, + families: fontFamiliesNamed(md), + stagedFaces: stagedFontFaces, +}); +if (presetFonts.staged.length) { + fontFaces.push(...presetFonts.faces); + fontSources.push(`the ${presetName} preset`); + summary.push( + `fonts: staged ${presetFonts.staged.length} preset face(s) → assets/fonts/` + + (presetFonts.licenses.length ? ` (+ ${presetFonts.licenses.join(", ")})` : ""), + ); +} +if (presetFonts.skipped.length) { + summary.push( + `fonts: skipped ${presetFonts.skipped.length} preset file(s) matching no family frame.md names: ${presetFonts.skipped.join(", ")}`, + ); +} +if (fontFaces.length) { + md += + `\n\n## Font loading (auto-generated)\n\n` + + `These faces ship as local files in \`assets/fonts/\` (from ${fontSources.join(" and ")}) — do NOT link Google Fonts for them. ` + + `Paste this \`\n```\n"; + summary.push(`fonts: @font-face block for ${fontFaces.length} face(s) appended to frame.md`); +} // ── write frame.md ──────────────────────────────────────────────────────────── const framePath = join(hyperframesDir, "frame.md"); diff --git a/skills/faceless-explainer/scripts/captions.mjs b/skills/faceless-explainer/scripts/captions.mjs index 8390686583..8d68a61aad 100644 --- a/skills/faceless-explainer/scripts/captions.mjs +++ b/skills/faceless-explainer/scripts/captions.mjs @@ -38,6 +38,14 @@ import { fileURLToPath } from "node:url"; import { parseStoryboard } from "./lib/storyboard.mjs"; import { captionBand, parseFormat } from "./lib/dimensions.mjs"; import { parseColors, parseFonts, semanticColors } from "./lib/tokens.mjs"; +import { + FONT_FORMAT, + fontExtOf, + fontStyleOf, + fontWeightInfo, + normFontName, + rankFontFamilies, +} from "./lib/font-faces.mjs"; const flag = (argv, name, def) => { const i = argv.indexOf(`--${name}`); @@ -300,58 +308,19 @@ function brandFontFaces(framePath, hyperframesDir) { { abs: join(hyperframesDir, "assets/fonts"), rel: "assets/fonts" }, { abs: join(hyperframesDir, "capture/assets/fonts"), rel: "capture/assets/fonts" }, ].filter((d) => existsSync(d.abs)); - const weightOf = (n) => { - const s = n.toLowerCase(); - // A numeric axis is the font's own answer, so it beats the word heuristic. Fontsource - // names every face this way ("inter-latin-500-normal.woff2") and carries no weight - // WORD at all, so word-only parsing collapsed a whole family onto 400 and shipped - // exactly one of its faces. - // - // A weight token must not be buried inside a longer run: capture/assets/fonts holds - // hash-named files, and "Newsreader-a1b200c3.woff2" is not a 200-weight face. Hence a - // non-digit before (which also stops "2100" reading as 100) and no alphanumeric after. - // "Roboto900.ttf" still parses — requiring separators on both sides would have lost it. - const numeric = /(?:^|[^0-9])([1-9]00)(?![0-9a-z])/.exec(s); - if (numeric) return Number(numeric[1]); - if (/black|heavy|ultra|extrabold/.test(s)) return 800; - if (/semibold|demibold/.test(s)) return 600; // before /bold/ — "demibold" contains "bold" - if (/bold/.test(s)) return 700; - if (/medium/.test(s)) return 500; - if (/light|thin/.test(s)) return 300; - return 400; // book / regular / roman - }; - // Weight is not the only axis in a filename. Google Fonts ships Newsreader as - // "Newsreader-Italic-VariableFont_opsz,wght.ttf" + "Newsreader-VariableFont_opsz,wght.ttf", - // and the italic sorts first — so without a style axis the italic file claimed the - // family's ONLY 400 slot, the upright file was dropped as a duplicate, and the face - // was declared with no `font-style`. @font-face is deliberately global (the composition - // CSS scoper exempts it, and it has to be), so the whole document then rendered that - // family in italics — captions italicizing every sibling composition. - const styleOf = (n) => (/italic|oblique/i.test(n) ? "italic" : "normal"); - const fmtOf = (f) => - /\.woff2$/i.test(f) - ? "woff2" - : /\.woff$/i.test(f) - ? "woff" - : /\.ttf$/i.test(f) - ? "truetype" - : "opentype"; - // Normalize away ALL non-alphanumerics (spaces, underscores, hyphens) on BOTH the - // family name and the filename. Real font files use "_" / "-" as word separators - // ("TT_Norms_Pro_Bold.woff2"), so stripping only whitespace never matched them — the - // family key "ttnormspro" failed `startsWith` against "tt_norms_pro_bold", and the - // function silently returned "" → captions shipped with NO @font-face for any - // underscore/hyphen-named brand font (e.g. TT Norms Pro), which is exactly the - // font_family_without_font_face bug. - const norm = (s) => s.toLowerCase().replace(/[^a-z0-9]/g, ""); + // Weight, style, format and the family-name key all come from lib/font-faces.mjs — the same + // helpers build-frame.mjs stages with — so a file staged under that naming contract parses + // back to the face it was staged as. Two axes matter: a numeric weight beats the word + // heuristic (Fontsource names carry no weight word), and style is read too, or the italic + // file of a weight would claim its only slot and @font-face (global by design) would + // italicise every sibling composition. The key strips ALL non-alphanumerics on both sides, + // since real files separate words with "_" / "-" ("TT_Norms_Pro_Bold.woff2"). const faces = []; const seen = new Set(); const claimed = new Set(); // each file is claimed by the MOST SPECIFIC family only // Match the longest family key first so "TT Norms Pro" can't swallow the files that // belong to "TT Norms Pro Mono" (its key is a prefix of the longer one's). - const ranked = [...families].sort((a, b) => norm(b).length - norm(a).length); - for (const fam of ranked) { - const key = norm(fam); + for (const { family: fam, key } of rankFontFamilies(families)) { for (const d of dirs) { let files = []; try { @@ -360,17 +329,18 @@ function brandFontFaces(framePath, hyperframesDir) { continue; } for (const f of files.sort()) { - if (!/\.(woff2|woff|ttf|otf)$/i.test(f)) continue; + const ext = fontExtOf(f); + if (!ext) continue; if (claimed.has(f)) continue; // a more specific family already took this file - if (!norm(f.replace(/\.(woff2|woff|ttf|otf)$/i, "")).startsWith(key)) continue; - const w = weightOf(f); - const style = styleOf(f); + if (!normFontName(f.slice(0, -(ext.length + 1))).startsWith(key)) continue; + const { n: w } = fontWeightInfo(f); + const style = fontStyleOf(f); const dedup = `${fam}-${w}-${style}`; if (seen.has(dedup)) continue; // one src per face; assets/fonts wins over capture seen.add(dedup); claimed.add(f); faces.push( - ` @font-face { font-family: '${fam}'; src: url('${d.rel}/${f}') format('${fmtOf(f)}'); font-weight: ${w}; font-style: ${style}; font-display: block; }`, + ` @font-face { font-family: '${fam}'; src: url('${d.rel}/${f}') format('${FONT_FORMAT[ext]}'); font-weight: ${w}; font-style: ${style}; font-display: block; }`, ); } } diff --git a/skills/faceless-explainer/scripts/captions.test.mjs b/skills/faceless-explainer/scripts/captions.test.mjs index 097623bd0d..489c1a5f4f 100644 --- a/skills/faceless-explainer/scripts/captions.test.mjs +++ b/skills/faceless-explainer/scripts/captions.test.mjs @@ -198,22 +198,34 @@ test("captions.mjs is byte-identical across the three workflows that ship it", ( test("every build-frame.mjs copy stages the style axis it promises", () => { for (const skill of ["product-launch-video", "faceless-explainer", "pr-to-video"]) { + // build-frame.mjs stages through lib/font-faces.mjs, so the naming contract lives there. const source = readFileSync( - new URL(`../../${skill}/scripts/build-frame.mjs`, import.meta.url), + new URL(`../../${skill}/scripts/lib/font-faces.mjs`, import.meta.url), "utf8", ); // The staged filename must carry the style, or the italic and upright faces of one // weight collide on a single name and only whichever sorts first survives. assert.match( source, - /const clean = `\$\{fam\.replace\(\/\[\^A-Za-z0-9\]\/g, ""\)\}-\$\{w\}\$\{style === "italic" \? "-Italic" : ""\}\./, - `${skill}/build-frame.mjs must keep the style token in the staged name`, + /const name = `\$\{family\.replace\(\/\[\^A-Za-z0-9\]\/g, ""\)\}-\$\{w\}\$\{style === "italic" \? "-Italic" : ""\}\./, + `${skill}/lib/font-faces.mjs must keep the style token in the staged name`, ); // ...and the emitted descriptor must report the real style, not a hardcoded normal. assert.doesNotMatch( source, /font-weight:\$\{n\};font-style:normal/, - `${skill}/build-frame.mjs must not assert font-style:normal over captured bytes`, + `${skill}/lib/font-faces.mjs must not assert font-style:normal over captured bytes`, + ); + // captions.mjs parses the staged names back; it must read them with the same helpers + // rather than a private copy that can drift from the staging side. + const captions = readFileSync( + new URL(`../../${skill}/scripts/captions.mjs`, import.meta.url), + "utf8", + ); + assert.match( + captions, + /import \{[^}]*\bfontWeightInfo\b[^}]*\} from "\.\/lib\/font-faces\.mjs"/, + `${skill}/captions.mjs must import its face helpers from lib/font-faces.mjs`, ); } }); diff --git a/skills/faceless-explainer/scripts/lib/font-faces.mjs b/skills/faceless-explainer/scripts/lib/font-faces.mjs new file mode 100644 index 0000000000..4935f4d641 --- /dev/null +++ b/skills/faceless-explainer/scripts/lib/font-faces.mjs @@ -0,0 +1,162 @@ +// font-faces.mjs — staging font files into assets/fonts/ + the @font-face rule for each. +// Shared by build-frame.mjs's two font sources: captured BRAND font files and a preset's +// own fonts/ folder. +// +// The staged NAME is a contract, not cosmetics: captions.mjs derives each face's weight and +// style back out of it. So the name has to carry every axis that distinguishes one face +// from another, and the dedup key has to be the whole face. Naming on weight alone made +// Google's two-file Newsreader download (upright + italic, both scoring "Regular") collide +// on one slot: the italic sorts first, took the name, the upright was never staged, and +// the @font-face block then asserted font-style:normal over italic bytes. + +import { copyFileSync, existsSync, mkdirSync, readdirSync, statSync } from "node:fs"; +import { basename, join, resolve } from "node:path"; + +export const FONT_FORMAT = { woff2: "woff2", woff: "woff", ttf: "truetype", otf: "opentype" }; + +/** Family-name key: lowercase alphanumerics only, so "EB Garamond" ≡ "EBGaramond-400.woff2"'s stem. */ +export const normFontName = (s) => + String(s) + .toLowerCase() + .replace(/[^a-z0-9]/g, ""); + +/** Lowercase font extension of a filename, or "" when it is not a font file. */ +export const fontExtOf = (f) => (f.match(/\.(woff2|woff|ttf|otf)$/i)?.[1] ?? "").toLowerCase(); + +/** Weight of a face from its filename: `{ n: 700, w: "Bold" }` (n numeric, w the staged-name token). */ +export function fontWeightInfo(name) { + const s = name.toLowerCase(); + // A numeric axis is the font's own answer, so it beats the word heuristic. Fontsource + // names every face that way and carries no weight WORD at all, so word-only parsing + // scored a whole family "Regular" and staged exactly one of its faces. + // + // A weight token must not be buried inside a longer run: this reads capture files, + // which are commonly hash-named, and "Newsreader-a1b200c3.woff2" is not a 200-weight + // face. Hence a non-digit before (which also stops "2100" reading as 100) and no + // alphanumeric after. "Roboto900.ttf" still parses. + const numeric = /(?:^|[^0-9])([1-9]00)(?![0-9a-z])/.exec(s); + if (numeric) return { n: Number(numeric[1]), w: numeric[1] }; + if (/black|heavy|ultra|extrabold/.test(s)) return { n: 800, w: "ExtraBold" }; + if (/semibold|demibold/.test(s)) return { n: 600, w: "SemiBold" }; + if (/bold/.test(s)) return { n: 700, w: "Bold" }; + if (/medium/.test(s)) return { n: 500, w: "Medium" }; + if (/light|thin/.test(s)) return { n: 300, w: "Light" }; + return { n: 400, w: "Regular" }; +} + +export const fontStyleOf = (name) => (/italic|oblique/i.test(name) ? "italic" : "normal"); + +/** + * Families as `{ family, key }` ranked longest key first, so a filename is matched against + * "TT Norms Pro Mono" before "TT Norms Pro" can swallow it. Duplicates and families whose key + * is empty (nothing to match on) are dropped. + */ +export function rankFontFamilies(families) { + return [...new Set(families)] + .map((family) => ({ family, key: normFontName(family) })) + .filter((x) => x.key) + .sort((a, b) => b.key.length - a.key.length); +} + +/** Every family a frame spec names — `fontFamily: "X"` in its typography ramp. */ +export function fontFamiliesNamed(md) { + const out = new Set(); + for (const m of String(md).matchAll(/fontFamily:\s*"([^"]+)"/g)) out.add(m[1].trim()); + return [...out]; +} + +/** + * The family a face-named file belongs to: the longest-keyed entry of `ranked` (see + * `rankFontFamilies`) whose key the filename's stem STARTS with, or `undefined`. Staged files + * and preset files follow the `-[-Italic].` contract, so a prefix + * match is exact — a brand named "Garamond" must not claim the preset's "EBGaramond-400.woff2". + */ +export const familyOfFaceFile = (fileName, ranked) => { + const stem = fileName.replace(/\.[^.]+$/, ""); + return ranked.find((x) => normFontName(stem).startsWith(x.key))?.family; +}; + +/** + * Copy one font file into `outDir` under its clean face name and return + * `{ name, rule }` — the staged filename and its root-relative @font-face rule — or + * `null` when that face is already staged (first source wins) or the file is not a font. + * `stagedFaces` is keyed on the FACE (family + numeric weight + style), not the filename: + * "Inter-Regular.ttf" and "inter-latin-400-normal.woff2" name the same face and would + * otherwise both be staged with two contradictory @font-face rules for it. Refresh scope: a + * file already at the staged path wins by default (the capture source — a hand-placed file, + * or the previous run's own output, is never overwritten by a download); `refresh: true` is + * passed for the preset source only, so a face the preset stages is re-copied on every run + * (a skills update may ship re-subsetted files) unless the source already IS the staged file. + */ +export function stageFontFile({ family, srcPath, outDir, stagedFaces, refresh = false }) { + const file = basename(srcPath); + const ext = fontExtOf(file); + if (!ext) return null; + const { n, w } = fontWeightInfo(file); + const style = fontStyleOf(file); + const faceKey = `${family}|${n}|${style}`; + if (stagedFaces.has(faceKey)) return null; + const name = `${family.replace(/[^A-Za-z0-9]/g, "")}-${w}${style === "italic" ? "-Italic" : ""}.${ext}`; + mkdirSync(outDir, { recursive: true }); + const dest = join(outDir, name); + if ((refresh || !existsSync(dest)) && resolve(srcPath) !== resolve(dest)) + copyFileSync(srcPath, dest); + stagedFaces.add(faceKey); + return { + name, + rule: `@font-face{font-family:"${family}";font-weight:${n};font-style:${style};font-display:block;src:url("assets/fonts/${name}") format("${FONT_FORMAT[ext]}");}`, + }; +} + +const LICENSE_FILE = /^(?:OFL|LICEN[CS]E|COPYING)\b/i; + +/** + * Stage a preset's own fonts/ folder. Each font file is assigned to the LONGEST family in + * `families` (the families the frame spec names) whose key its filename starts with; a file + * matching none is reported in `skipped` rather than staged — after a brand remix the + * preset's original family may no longer appear in frame.md, and its bytes would be dead + * weight. License texts shipped beside the fonts (OFL-*.txt, LICENSE…) travel with any + * staged face. Returns `{ faces, staged, skipped, licenses }`; a preset without a fonts/ + * folder yields all-empty arrays. + */ +export function stagePresetFonts({ presetFontsDir, outDir, families, stagedFaces }) { + const result = { faces: [], staged: [], skipped: [], licenses: [] }; + if (!existsSync(presetFontsDir)) return result; + const ranked = rankFontFamilies(families); + // Only plain files (symlinks resolved): a subdirectory named like a font would otherwise + // reach copyFileSync and throw EISDIR, and a dangling symlink would throw ENOENT on stat. + const isFile = (f) => { + try { + return statSync(join(presetFontsDir, f)).isFile(); + } catch { + return false; + } + }; + const entries = readdirSync(presetFontsDir).filter(isFile).sort(); + for (const f of entries) { + if (!fontExtOf(f)) continue; + const family = familyOfFaceFile(f, ranked); + if (!family) { + result.skipped.push(f); + continue; + } + const face = stageFontFile({ + family, + srcPath: join(presetFontsDir, f), + outDir, + stagedFaces, + refresh: true, + }); + if (!face) continue; + result.faces.push(face.rule); + result.staged.push(face.name); + } + if (result.staged.length) { + for (const f of entries) { + if (fontExtOf(f) || !LICENSE_FILE.test(f)) continue; + if (!existsSync(join(outDir, f))) copyFileSync(join(presetFontsDir, f), join(outDir, f)); + result.licenses.push(f); + } + } + return result; +} diff --git a/skills/hyperframes-creative/frame-presets/code-editorial/FRAME.md b/skills/hyperframes-creative/frame-presets/code-editorial/FRAME.md index aa15b65eab..767447c822 100644 --- a/skills/hyperframes-creative/frame-presets/code-editorial/FRAME.md +++ b/skills/hyperframes-creative/frame-presets/code-editorial/FRAME.md @@ -271,7 +271,7 @@ to the diff; commit/issue numbers are chrome. ## Known Gaps - **Motion intentionally out of scope.** frame.md specifies composition only. Code editorial's motion register — short cross-dissolves, no overshoot/bounce/elastic, coral the only "draw-on", numbers count up, code types on line by line — lives in the workflow's `motion-language.md` + `hyperframes-animation`, not here. -- **EB Garamond + Inter + JetBrains Mono ship as licensed local WOFF2 assets with this preset.** `build-frame.mjs` stages weights 400 + 700 into `assets/fonts/` and appends the exact `@font-face` block to the generated `frame.md`, so Studio, snapshots, and renders resolve them offline without a first-run Google Fonts fetch. Author display at **weight 400** (700 reads as a heavy bold, off-register), and treat italic as the browser-synthesized slant (acceptable for the pull-quote register; add a real italic face only if a project leans hard on it). EB Garamond is a warm old-style serif (low contrast, humanist); if it ever fails, fall to Georgia or another old-style serif — never to a sans. CJK: Noto Serif SC (display) / Noto Sans SC (body) / Noto Sans Mono CJK (code); the sentence-case warmth carries when the serif drops. +- **EB Garamond + Inter + JetBrains Mono ship as licensed local WOFF2 assets with this preset.** `build-frame.mjs` stages weights 400 + 700 into `assets/fonts/` (only for families the remixed `frame.md` still names — a family the brand replaced is skipped) and appends the matching `@font-face` block to the generated `frame.md`, so Studio, snapshots, and renders resolve them offline without a first-run Google Fonts fetch. Author display at **weight 400** (700 reads as a heavy bold, off-register), and treat italic as the browser-synthesized slant (acceptable for the pull-quote register; add a real italic face only if a project leans hard on it). EB Garamond is a warm old-style serif (low contrast, humanist); if it ever fails, fall to Georgia or another old-style serif — never to a sans. CJK: Noto Serif SC (display) / Noto Sans SC (body) / Noto Sans Mono CJK (code); the sentence-case warmth carries when the serif drops. - **Syntax colors (teal `#5DB8A6` / amber `#E8A55A` / status) are fixed decoration**, declared in §Colors — they are NOT in the remixable `colors:` block, so a brand remix never repaints them. - **The code itself is the `code-*` registry blocks**, not this preset — this preset owns only the surrounding warm-navy surface + mono chrome. - **9:16 / 1:1 are guidance**; verify the legibility floor and that the cream/tile warmth + one-coral discipline hold per ratio. diff --git a/skills/hyperframes-creative/references/design-spec.md b/skills/hyperframes-creative/references/design-spec.md index 696f91d95f..49f0dbd9a5 100644 --- a/skills/hyperframes-creative/references/design-spec.md +++ b/skills/hyperframes-creative/references/design-spec.md @@ -48,7 +48,7 @@ Optionally seed `frame.md` from a ready-made **frame-preset** in `[../frame-pres | `[daisy-days](../frame-presets/daisy-days/FRAME.md)` | Cheerful picture-book — 3px charcoal outlines, 6/4px hard offset shadows (no blur), nine sunny-garden pastels (cream + turquoise/soft-pink/butter/mint/lavender/peach/sky + coral accent), Fredoka One + Quicksand, generous radii (20–50px), hand-drawn SVG ornament layer (daisies/stars/suns/clouds/rainbows) | playful / childlike / sticker-sheet kawaii; a product that wants warmth and whimsy | | `[editorial-forest](../frame-presets/editorial-forest/FRAME.md)` | Serif-led literary-editorial — green / pink / cream editorial triad, Source Serif 4 weight 500 (opsz) for display + JetBrains Mono 500 uppercase chrome, flat paper depth (no shadows), 2px hairline rules, 6/8px card radii, monogram circle stamp | spacious / restrained / editorial; a product that wants quiet confidence and literary tone | -Each preset folder also ships a `frame-showcase.html` — a preview contact sheet of its frame treatments; open it to _see_ the look, never include it in a project. +Each preset folder also ships a `frame-showcase.html` — a preview contact sheet of its frame treatments; open it to _see_ the look, never include it in a project. A preset may also ship a `fonts/` folder of licensed local faces (`code-editorial` does, with the OFL texts beside them); a workflow's `build-frame.mjs` stages every face whose family the adopted `frame.md` still names into the project's `assets/fonts/`, so the preset's type system resolves offline in preview, snapshot, and render. ## Consuming it diff --git a/skills/pr-to-video/SKILL.md b/skills/pr-to-video/SKILL.md index 2edf6cf3aa..6f34e6c533 100644 --- a/skills/pr-to-video/SKILL.md +++ b/skills/pr-to-video/SKILL.md @@ -101,7 +101,7 @@ The style is fixed — **code-editorial** (warm editorial; a navy code surface b node /scripts/build-frame.mjs --preset code-editorial --hyperframes . ``` -The script copies the code-editorial preset's `FRAME.md` → `frame.md`, remixes it onto any brand tokens in `capture/extracted/tokens.json` (a PR has none → `colors:[]`/`fonts:[]` keeps code-editorial's own palette, a complete design), 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. +The script copies the code-editorial preset's `FRAME.md` → `frame.md`, remixes it onto any brand tokens in `capture/extracted/tokens.json` (a PR has none → `colors:[]`/`fonts:[]` keeps code-editorial's own palette, a complete design), stages any font files the preset ships (its `fonts/` folder) into `assets/fonts/` with a ready-to-paste `@font-face` block in `frame.md`, 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. **Gate:** `build-frame.mjs` exited 0 — `frame.md` exists from the code-editorial preset, and `.hyperframes/caption-skin.html` exists as the caption skin source. diff --git a/skills/pr-to-video/scripts/build-frame-fonts.test.mjs b/skills/pr-to-video/scripts/build-frame-fonts.test.mjs new file mode 100644 index 0000000000..0c3771503b --- /dev/null +++ b/skills/pr-to-video/scripts/build-frame-fonts.test.mjs @@ -0,0 +1,492 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +import { + fontFamiliesNamed, + fontWeightInfo, + stageFontFile, + stagePresetFonts, +} from "./lib/font-faces.mjs"; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const buildFrameScript = join(scriptDir, "build-frame.mjs"); +const shippedPresetDir = resolve(scriptDir, "../../hyperframes-creative/frame-presets"); + +const tmpDirs = []; +const tmp = (prefix) => { + const dir = mkdtempSync(join(tmpdir(), prefix)); + tmpDirs.push(dir); + return dir; +}; +test.after(() => { + for (const dir of tmpDirs) rmSync(dir, { recursive: true, force: true }); +}); + +function write(path, contents) { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, contents); +} + +// A minimal preset: three families in the typography ramp, a legal color block so the +// script's self-check passes, and whatever the caller drops into fonts/. +function fakePreset(root, fonts = {}) { + const presetDir = join(root, "presets"); + const dir = join(presetDir, "acme"); + write( + join(dir, "FRAME.md"), + [ + "---", + "name: Acme — Frame", + "", + "colors:", + ' ink: "#141413"', + ' cream: "#FAF9F5"', + ' coral: "#CC785C"', + "", + "typography:", + ' display: { fontFamily: "Acme Sans", cqw: 6.7, weight: 700, lineHeight: 1.02 }', + ' body: { fontFamily: "Acme Sans", cqw: 1.5, weight: 400, lineHeight: 1.5 }', + ' code: { fontFamily: "Other Mono", cqw: 1.4, weight: 400, lineHeight: 1.6 }', + "---", + "", + "# Acme", + "", + "Prose about the look.", + "", + ].join("\n"), + ); + for (const [name, bytes] of Object.entries(fonts)) write(join(dir, "fonts", name), bytes); + return presetDir; +} + +function runBuildFrame(project, presetDir, preset = "acme") { + return execFileSync( + process.execPath, + [buildFrameScript, "--preset", preset, "--hyperframes", project, "--preset-dir", presetDir], + { encoding: "utf8" }, + ); +} + +const PRESET_FONTS = { + "AcmeSans-400.woff2": "sans-regular", + "AcmeSans-Bold.woff2": "sans-bold", + "AcmeSans-BoldItalic.woff2": "sans-bold-italic", + "OtherMono-700.ttf": "mono-bold", + "Stray-400.woff2": "no such family in the ramp", + "OFL-acme-sans.txt": "SIL Open Font License", +}; + +test("a preset's own fonts/ folder is staged by family, weight and style read from the filename", () => { + const root = tmp("build-frame-preset-fonts-"); + const project = join(root, "project"); + write(join(project, "capture/extracted/tokens.json"), '{"colors":[],"fonts":[]}'); + const out = runBuildFrame(project, fakePreset(root, PRESET_FONTS)); + + const stagedDir = join(project, "assets/fonts"); + assert.deepEqual(readdirSync(stagedDir).sort(), [ + "AcmeSans-400.woff2", + "AcmeSans-Bold-Italic.woff2", + "AcmeSans-Bold.woff2", + "OFL-acme-sans.txt", + "OtherMono-700.ttf", + ]); + assert.equal( + readFileSync(join(stagedDir, "AcmeSans-Bold-Italic.woff2"), "utf8"), + "sans-bold-italic", + ); + + const frameMd = readFileSync(join(project, "frame.md"), "utf8"); + assert.match(frameMd, /## Font loading \(auto-generated\)/); + assert.match(frameMd, /from the acme preset/); + for (const rule of [ + '@font-face{font-family:"Acme Sans";font-weight:400;font-style:normal;font-display:block;src:url("assets/fonts/AcmeSans-400.woff2") format("woff2");}', + '@font-face{font-family:"Acme Sans";font-weight:700;font-style:normal;font-display:block;src:url("assets/fonts/AcmeSans-Bold.woff2") format("woff2");}', + '@font-face{font-family:"Acme Sans";font-weight:700;font-style:italic;font-display:block;src:url("assets/fonts/AcmeSans-Bold-Italic.woff2") format("woff2");}', + '@font-face{font-family:"Other Mono";font-weight:700;font-style:normal;font-display:block;src:url("assets/fonts/OtherMono-700.ttf") format("truetype");}', + ]) { + assert.ok(frameMd.includes(rule), `frame.md should carry ${rule}`); + } + assert.equal((frameMd.match(/## Font loading/g) ?? []).length, 1, "one font-loading section"); + assert.doesNotMatch(frameMd, /Stray/); + + assert.match(out, /fonts: staged 4 preset face\(s\) → assets\/fonts\/ \(\+ OFL-acme-sans\.txt\)/); + assert.match( + out, + /skipped 1 preset file\(s\) matching no family frame\.md names: Stray-400\.woff2/, + ); + assert.match(out, /@font-face block for 4 face\(s\) appended to frame\.md/); +}); + +test("faces of a family the brand remix replaced are not staged; the rest still are", () => { + const root = tmp("build-frame-preset-fonts-remix-"); + const project = join(root, "project"); + write( + join(project, "capture/extracted/tokens.json"), + JSON.stringify({ colors: [], fonts: [{ family: "Brand Grotesk", weights: [400, 700] }] }), + ); + const out = runBuildFrame(project, fakePreset(root, PRESET_FONTS)); + + // Acme Sans (display + body) became Brand Grotesk, which ships no files here; Other Mono + // is untouched by the remix and keeps its preset-owned face. + assert.deepEqual(readdirSync(join(project, "assets/fonts")).sort(), [ + "OFL-acme-sans.txt", + "OtherMono-700.ttf", + ]); + const frameMd = readFileSync(join(project, "frame.md"), "utf8"); + assert.doesNotMatch(frameMd, /Acme Sans/); + assert.match(frameMd, /@font-face\{font-family:"Other Mono";font-weight:700/); + assert.doesNotMatch(frameMd, /AcmeSans-/); + assert.match(out, /fonts: staged 1 preset face\(s\)/); + assert.match( + out, + /skipped 4 preset file\(s\) matching no family frame\.md names: AcmeSans-400\.woff2, AcmeSans-Bold\.woff2, AcmeSans-BoldItalic\.woff2, Stray-400\.woff2/, + ); +}); + +test("a face the captured brand already staged is not staged twice from the preset", () => { + const root = tmp("build-frame-preset-fonts-dedup-"); + const project = join(root, "project"); + // The brand IS the preset's mono family and the capture downloaded its bold face. + write( + join(project, "capture/extracted/tokens.json"), + JSON.stringify({ colors: [], fonts: [{ family: "Other Mono", weights: [700] }] }), + ); + write(join(project, "capture/assets/fonts/othermono-latin-700-normal.woff2"), "captured-bold"); + runBuildFrame(project, fakePreset(root, { "OtherMono-700.woff2": "preset-bold" })); + + const stagedDir = join(project, "assets/fonts"); + assert.deepEqual(readdirSync(stagedDir).sort(), ["OtherMono-700.woff2"]); + assert.equal(readFileSync(join(stagedDir, "OtherMono-700.woff2"), "utf8"), "captured-bold"); + const frameMd = readFileSync(join(project, "frame.md"), "utf8"); + assert.equal((frameMd.match(/@font-face\{/g) ?? []).length, 1); +}); + +test("a brand face the capture named by weight word is not staged again from the preset's numeric file", () => { + const root = tmp("build-frame-preset-fonts-dedup-word-"); + const project = join(root, "project"); + write( + join(project, "capture/extracted/tokens.json"), + JSON.stringify({ colors: [], fonts: [{ family: "Other Mono", weights: [700] }] }), + ); + write(join(project, "capture/assets/fonts/OtherMono-Bold.woff2"), "captured-bold"); + runBuildFrame(project, fakePreset(root, { "OtherMono-700.woff2": "preset-bold" })); + + assert.deepEqual(readdirSync(join(project, "assets/fonts")).sort(), ["OtherMono-Bold.woff2"]); + const frameMd = readFileSync(join(project, "frame.md"), "utf8"); + assert.equal((frameMd.match(/@font-face\{/g) ?? []).length, 1); +}); + +test("a rerun after brand tokens arrive does not relabel previously staged preset faces as the brand", () => { + const root = tmp("build-frame-preset-fonts-rerun-"); + const project = join(root, "project"); + const presetDir = fakePreset(root, PRESET_FONTS); + // Run 1: no brand yet — the preset's own faces land in assets/fonts/. + write(join(project, "capture/extracted/tokens.json"), '{"colors":[],"fonts":[]}'); + runBuildFrame(project, presetDir); + assert.equal(existsSync(join(project, "assets/fonts/AcmeSans-400.woff2")), true); + // Run 2: one captured brand family with a Google-named file. Only the capture file may be + // claimed by the single-family shortcut; the preset bytes already in assets/fonts/ must + // not — "Sans" is even a substring of the staged "AcmeSans-*" names. + write( + join(project, "capture/extracted/tokens.json"), + JSON.stringify({ colors: [], fonts: [{ family: "Sans", weights: [400] }] }), + ); + write(join(project, "capture/assets/fonts/sans-regular.woff2"), "sans-regular"); + const out = runBuildFrame(project, presetDir); + + const stagedDir = join(project, "assets/fonts"); + assert.equal(readFileSync(join(stagedDir, "Sans-Regular.woff2"), "utf8"), "sans-regular"); + assert.equal( + existsSync(join(stagedDir, "Sans-400.woff2")), + false, + "AcmeSans bytes not relabeled", + ); + assert.equal(existsSync(join(stagedDir, "Sans-Bold.woff2")), false); + const frameMd = readFileSync(join(project, "frame.md"), "utf8"); + const sansRules = frameMd.match(/@font-face\{font-family:"Sans";[^\n]*/g) ?? []; + assert.equal(sansRules.length, 1); + assert.match(sansRules[0], /Sans-Regular\.woff2/); + assert.match(out, /fonts: staged 1 brand face\(s\)/); +}); + +test("a hash-named capture file is staged as the single captured brand family", () => { + const root = tmp("build-frame-capture-hash-"); + const project = join(root, "project"); + write( + join(project, "capture/extracted/tokens.json"), + JSON.stringify({ colors: [], fonts: [{ family: "Brand Grotesk", weights: [400] }] }), + ); + // The capture pipeline saves downloads under their content hash (see the CLI's + // fontMetadataExtractor): nothing in the name says "Brand Grotesk", so only the + // single-family shortcut can claim it — the prefix rule would leave the brand unstaged. + write(join(project, "capture/assets/fonts/19cfc7226ec3afaa-s.woff2"), "captured-regular"); + const out = runBuildFrame(project, fakePreset(root)); + + const stagedDir = join(project, "assets/fonts"); + assert.deepEqual(readdirSync(stagedDir).sort(), ["BrandGrotesk-Regular.woff2"]); + assert.equal( + readFileSync(join(stagedDir, "BrandGrotesk-Regular.woff2"), "utf8"), + "captured-regular", + ); + const frameMd = readFileSync(join(project, "frame.md"), "utf8"); + assert.match( + frameMd, + /@font-face\{font-family:"Brand Grotesk";font-weight:400;font-style:normal;[^\n]*BrandGrotesk-Regular\.woff2/, + ); + assert.match(out, /fonts: staged 1 brand face\(s\)/); +}); + +test("capture files of two brand families each go to the family whose key their name contains", () => { + const root = tmp("build-frame-capture-two-families-"); + const project = join(root, "project"); + write( + join(project, "capture/extracted/tokens.json"), + JSON.stringify({ + colors: [], + fonts: [ + { family: "Brand Grotesk", weights: [400] }, + { family: "Brand Mono", weights: [700] }, + ], + }), + ); + // CDN-style names carry the family INSIDE the stem, not at its start: substring matching + // is what tells the two downloads apart; a prefix match would stage neither. + write(join(project, "capture/assets/fonts/cdn-brandgrotesk-400.woff2"), "grotesk-regular"); + write(join(project, "capture/assets/fonts/cdn-brandmono-700.woff2"), "mono-bold"); + const out = runBuildFrame(project, fakePreset(root)); + + const stagedDir = join(project, "assets/fonts"); + assert.deepEqual(readdirSync(stagedDir).sort(), [ + "BrandGrotesk-400.woff2", + "BrandMono-700.woff2", + ]); + assert.equal(readFileSync(join(stagedDir, "BrandGrotesk-400.woff2"), "utf8"), "grotesk-regular"); + assert.equal(readFileSync(join(stagedDir, "BrandMono-700.woff2"), "utf8"), "mono-bold"); + const frameMd = readFileSync(join(project, "frame.md"), "utf8"); + assert.match( + frameMd, + /@font-face\{font-family:"Brand Grotesk";font-weight:400;[^\n]*BrandGrotesk-400\.woff2/, + ); + assert.match( + frameMd, + /@font-face\{font-family:"Brand Mono";font-weight:700;[^\n]*BrandMono-700\.woff2/, + ); + assert.match(out, /fonts: staged 2 brand face\(s\)/); +}); + +test("a capture file does not overwrite a face already staged in assets/fonts/", () => { + const root = tmp("build-frame-capture-keeps-staged-"); + const project = join(root, "project"); + write( + join(project, "capture/extracted/tokens.json"), + JSON.stringify({ colors: [], fonts: [{ family: "Brand Grotesk", weights: [400] }] }), + ); + write(join(project, "capture/assets/fonts/brand-grotesk-400.woff2"), "captured-regular"); + // Hand-placed (or previously staged) bytes under the staged name win over the download. + write(join(project, "assets/fonts/BrandGrotesk-400.woff2"), "hand-placed"); + runBuildFrame(project, fakePreset(root)); + + const stagedDir = join(project, "assets/fonts"); + assert.deepEqual(readdirSync(stagedDir).sort(), ["BrandGrotesk-400.woff2"]); + assert.equal(readFileSync(join(stagedDir, "BrandGrotesk-400.woff2"), "utf8"), "hand-placed"); + const frameMd = readFileSync(join(project, "frame.md"), "utf8"); + assert.equal((frameMd.match(/@font-face\{/g) ?? []).length, 1); +}); + +test("a rerun refreshes a preset face's staged bytes from the preset", () => { + const root = tmp("build-frame-preset-refresh-"); + const project = join(root, "project"); + const presetDir = fakePreset(root, PRESET_FONTS); + write(join(project, "capture/extracted/tokens.json"), '{"colors":[],"fonts":[]}'); + runBuildFrame(project, presetDir); + const staged = join(project, "assets/fonts/AcmeSans-400.woff2"); + assert.equal(readFileSync(staged, "utf8"), "sans-regular"); + // A skills update may ship re-subsetted files: the preset source is authoritative. + writeFileSync(staged, "stale bytes from an earlier run"); + runBuildFrame(project, presetDir); + assert.equal(readFileSync(staged, "utf8"), "sans-regular"); +}); + +test("a preset without a fonts/ folder stages nothing and appends no font-loading section", () => { + const root = tmp("build-frame-preset-no-fonts-"); + const project = join(root, "project"); + write(join(project, "capture/extracted/tokens.json"), '{"colors":[],"fonts":[]}'); + const out = runBuildFrame(project, fakePreset(root)); + assert.equal(existsSync(join(project, "assets/fonts")), false); + assert.doesNotMatch(readFileSync(join(project, "frame.md"), "utf8"), /## Font loading/); + assert.doesNotMatch(out, /fonts: staged/); +}); + +test("the shipped code-editorial preset stages its six licensed faces plus their licenses", () => { + const project = tmp("build-frame-code-editorial-"); + write(join(project, "capture/extracted/tokens.json"), '{"colors":[],"fonts":[]}'); + runBuildFrame(project, shippedPresetDir, "code-editorial"); + + const stagedDir = join(project, "assets/fonts"); + for (const name of [ + "EBGaramond-400.woff2", + "EBGaramond-700.woff2", + "Inter-400.woff2", + "Inter-700.woff2", + "JetBrainsMono-400.woff2", + "JetBrainsMono-700.woff2", + ]) { + assert.equal(existsSync(join(stagedDir, name)), true, `${name} should be staged`); + assert.equal(readFileSync(join(stagedDir, name)).subarray(0, 4).toString("ascii"), "wOF2"); + } + for (const family of ["eb-garamond", "inter", "jetbrains-mono"]) { + assert.equal(existsSync(join(stagedDir, `OFL-${family}.txt`)), true); + } + const frameMd = readFileSync(join(project, "frame.md"), "utf8"); + assert.match(frameMd, /@font-face\{font-family:"EB Garamond";font-weight:400/); + assert.match(frameMd, /@font-face\{font-family:"Inter";font-weight:700/); + assert.match(frameMd, /@font-face\{font-family:"JetBrains Mono";font-weight:400/); + assert.ok(!frameMd.includes("fonts.googleapis.com")); +}); + +test("fontFamiliesNamed reads every family in a ramp once", () => { + assert.deepEqual( + fontFamiliesNamed( + 'display: { fontFamily: "EB Garamond" }\nbody: { fontFamily: "Inter" }\nlead: { fontFamily: "Inter" }', + ), + ["EB Garamond", "Inter"], + ); +}); + +test("fontWeightInfo prefers a numeric axis and never reads a hash as a weight", () => { + assert.deepEqual(fontWeightInfo("Inter-700.woff2"), { n: 700, w: "700" }); + assert.deepEqual(fontWeightInfo("inter-latin-500-normal.woff2"), { n: 500, w: "500" }); + assert.deepEqual(fontWeightInfo("Newsreader-a1b200c3.woff2"), { n: 400, w: "Regular" }); + assert.deepEqual(fontWeightInfo("Acme-SemiBold.otf"), { n: 600, w: "SemiBold" }); +}); + +test("stageFontFile leaves a file already at the staged path alone unless refresh is set", () => { + const root = tmp("stage-font-file-keep-"); + write(join(root, "src/Acme-Bold.woff2"), "download"); + const outDir = join(root, "out"); + write(join(outDir, "Acme-Bold.woff2"), "hand-placed"); + const kept = stageFontFile({ + family: "Acme", + srcPath: join(root, "src/Acme-Bold.woff2"), + outDir, + stagedFaces: new Set(), + }); + assert.equal(kept.name, "Acme-Bold.woff2"); + assert.match(kept.rule, /Acme-Bold\.woff2/); + assert.equal(readFileSync(join(outDir, "Acme-Bold.woff2"), "utf8"), "hand-placed"); +}); + +test("stageFontFile refreshes stale bytes, keeps the first source's face and refuses non-font files", () => { + const root = tmp("stage-font-file-"); + write(join(root, "a/Acme-Bold.woff2"), "first"); + write(join(root, "b/acme-700.woff2"), "second"); + write(join(root, "a/OFL.txt"), "license"); + const outDir = join(root, "out"); + write(join(outDir, "Acme-Bold.woff2"), "stale bytes from an earlier run"); + const stagedFaces = new Set(); + const first = stageFontFile({ + family: "Acme", + srcPath: join(root, "a/Acme-Bold.woff2"), + outDir, + stagedFaces, + refresh: true, + }); + assert.equal(first.name, "Acme-Bold.woff2"); + assert.match(first.rule, /font-weight:700;font-style:normal/); + assert.equal( + stageFontFile({ family: "Acme", srcPath: join(root, "b/acme-700.woff2"), outDir, stagedFaces }), + null, + "a numeric and a word weight for the same face are one face — the first source wins", + ); + assert.equal(existsSync(join(outDir, "Acme-700.woff2")), false); + assert.equal( + stageFontFile({ family: "Acme", srcPath: join(root, "a/OFL.txt"), outDir, stagedFaces }), + null, + ); + assert.equal(readFileSync(join(outDir, "Acme-Bold.woff2"), "utf8"), "first"); +}); + +test("a font file goes to the longest family its name starts with, not the first that matches", () => { + const root = tmp("stage-preset-fonts-overlap-"); + const presetFontsDir = join(root, "fonts"); + write(join(presetFontsDir, "InterTight-700.woff2"), "tight-bold"); + write(join(presetFontsDir, "Inter-400.woff2"), "inter-regular"); + const outDir = join(root, "out"); + // "Inter" is a prefix of "Inter Tight": listed first so that only the length ranking, not + // the input order, can send InterTight-700 to the right family. + const result = stagePresetFonts({ + presetFontsDir, + outDir, + families: ["Inter", "Inter Tight"], + stagedFaces: new Set(), + }); + assert.deepEqual(result.staged, ["Inter-400.woff2", "InterTight-700.woff2"]); + assert.deepEqual(result.skipped, []); + assert.match(result.faces[0], /font-family:"Inter";font-weight:400;.*Inter-400\.woff2/); + assert.match( + result.faces[1], + /font-family:"Inter Tight";font-weight:700;.*InterTight-700\.woff2/, + ); + assert.equal(readFileSync(join(outDir, "InterTight-700.woff2"), "utf8"), "tight-bold"); + assert.equal(readFileSync(join(outDir, "Inter-400.woff2"), "utf8"), "inter-regular"); + + // A family whose key merely appears INSIDE a filename does not own it: after a remix + // renamed "EB Garamond" to "Garamond", the preset's EBGaramond files are dead weight. + const substring = stagePresetFonts({ + presetFontsDir, + outDir: join(root, "out2"), + families: ["Tight"], + stagedFaces: new Set(), + }); + assert.deepEqual(substring.staged, []); + assert.deepEqual(substring.skipped, ["Inter-400.woff2", "InterTight-700.woff2"]); +}); + +test("a subdirectory or dangling symlink named like a font file is neither staged nor a crash", () => { + const root = tmp("stage-preset-fonts-dir-entry-"); + const presetFontsDir = join(root, "fonts"); + mkdirSync(join(presetFontsDir, "Inter-700.woff2"), { recursive: true }); + mkdirSync(join(presetFontsDir, "LICENSE"), { recursive: true }); + symlinkSync(join(root, "gone.woff2"), join(presetFontsDir, "Inter-Bold.woff2")); + write(join(presetFontsDir, "Inter-400.woff2"), "inter-regular"); + const result = stagePresetFonts({ + presetFontsDir, + outDir: join(root, "out"), + families: ["Inter"], + stagedFaces: new Set(), + }); + assert.deepEqual(result.staged, ["Inter-400.woff2"]); + assert.deepEqual(result.skipped, []); + assert.deepEqual(result.licenses, []); +}); + +// font-faces.mjs ships once per creation workflow because each skill installs standalone; +// the three copies are meant to be byte-identical so a fix landing in one cannot drift. +test("font-faces.mjs is byte-identical across the three workflows that ship it", () => { + const [first, ...rest] = ["product-launch-video", "faceless-explainer", "pr-to-video"].map( + (skill) => ({ + skill, + source: readFileSync( + new URL(`../../${skill}/scripts/lib/font-faces.mjs`, import.meta.url), + "utf8", + ), + }), + ); + for (const other of rest) { + assert.equal(other.source, first.source, `${other.skill} drifted from ${first.skill}`); + } +}); diff --git a/skills/pr-to-video/scripts/build-frame.mjs b/skills/pr-to-video/scripts/build-frame.mjs index b5f685598d..8ef9684a28 100644 --- a/skills/pr-to-video/scripts/build-frame.mjs +++ b/skills/pr-to-video/scripts/build-frame.mjs @@ -40,6 +40,15 @@ import { STATUS_ROLE_KEY, UA_DEFAULT_COLORS, } from "./lib/tokens.mjs"; +import { + familyOfFaceFile, + fontExtOf, + fontFamiliesNamed, + normFontName, + rankFontFamilies, + stageFontFile, + stagePresetFonts, +} from "./lib/font-faces.mjs"; const __dirname = dirname(fileURLToPath(import.meta.url)); const argv = process.argv.slice(2); @@ -358,41 +367,6 @@ if (brandFonts.length) { summary.push("fonts: no brand fonts — preset fonts kept"); } -// ── stage preset-owned offline font faces ──────────────────────────────────── -// PR ingestion has no captured brand fonts. Presets that own a type system must -// therefore carry their own licensed files instead of depending on a first-run -// Google Fonts fetch or a renderer-only embedding path that Studio workers cannot see. -const presetFontsDir = join(presetDir, presetName, "fonts"); -if (existsSync(presetFontsDir)) { - const fontSpecs = [ - ["EB Garamond", "EBGaramond", 400], - ["EB Garamond", "EBGaramond", 700], - ["Inter", "Inter", 400], - ["Inter", "Inter", 700], - ["JetBrains Mono", "JetBrainsMono", 400], - ["JetBrains Mono", "JetBrainsMono", 700], - ]; - const outDir = join(hyperframesDir, "assets/fonts"); - const faces = []; - for (const [family, stem, weight] of fontSpecs) { - const file = `${stem}-${weight}.woff2`; - const source = join(presetFontsDir, file); - if (!existsSync(source)) die(`preset font is missing: ${source}`); - mkdirSync(outDir, { recursive: true }); - copyFileSync(source, join(outDir, file)); - faces.push( - `@font-face{font-family:"${family}";font-weight:${weight};font-style:normal;font-display:block;src:url("assets/fonts/${file}") format("woff2");}`, - ); - } - md += - `\n\n## Font loading (preset-owned, offline)\n\n` + - `These licensed faces are staged in \`assets/fonts/\`. Paste this block inside every frame template; do not link Google Fonts:\n\n` + - "```html\n\n```\n"; - summary.push(`fonts: staged ${fontSpecs.length} preset face(s) for offline preview/render`); -} - // ── cap type weights to the brand font's available faces ────────────────────── // The remix swaps the font FAMILY but keeps the preset's weights; a brand font that ships // only e.g. 400/500 would faux-bold every 600/700 heading. Clamp each `typography:` weight @@ -463,89 +437,90 @@ if (brandFonts.length || (brandColors.length && presetColors.length)) { summary.push("brand-adaptation note prepended"); } -// ── stage brand font files + emit @font-face ────────────────────────────────── -// A brand font is rarely a Google font, so renaming the family in frame.md is not enough: -// nothing loads the actual face. If the capture downloaded font files, copy them to -// assets/fonts/ under CLEAN, face-named names (so captions.mjs' family-prefix matcher -// finds them too) and append a ready-to-paste, ROOT-RELATIVE @font-face block to frame.md. -// -// The staged NAME is a contract, not cosmetics: captions.mjs derives each face's weight and -// style back out of it. So the name has to carry every axis that distinguishes one face from -// another, and the dedup key has to be the whole face. Naming on weight alone made Google's -// two-file Newsreader download (upright + italic, both scoring "Regular") collide on one -// slot: the italic sorts first, took the name, the upright was never staged, and the block -// below then asserted font-style:normal over italic bytes. +// ── stage font files + emit @font-face ──────────────────────────────────────── +// Two sources feed assets/fonts/, in this order: +// 1. captured BRAND font files — a brand font is rarely a Google font, so renaming the +// family in frame.md is not enough: nothing loads the actual face. If the capture +// downloaded font files, stage them. +// 2. the PRESET's own fonts/ folder — a preset that ships licensed files (code-editorial) +// stages every face whose family the remixed frame.md still names, so Studio, +// snapshots, and renders resolve its type system offline instead of depending on a +// first-run Google Fonts fetch. A face for a family the brand remix replaced is dead +// weight and is skipped; a face the brand already staged is not staged twice. +// Files land under CLEAN, face-named names (so captions.mjs' family-prefix matcher finds +// them too — the name is a contract, see lib/font-faces.mjs) and ONE ready-to-paste, +// ROOT-RELATIVE @font-face block covering both sources is appended to frame.md. +const fontOutDir = join(hyperframesDir, "assets/fonts"); +const stagedFontFaces = new Set(); +const fontFaces = []; +const fontSources = []; if (brandFonts.length) { - const norm = (s) => - String(s) - .toLowerCase() - .replace(/[^a-z0-9]/g, ""); - const extOf = (f) => (f.match(/\.(woff2|woff|ttf|otf)$/i)?.[1] ?? "").toLowerCase(); - const FMT = { woff2: "woff2", woff: "woff", ttf: "truetype", otf: "opentype" }; - const weightInfo = (name) => { - const s = name.toLowerCase(); - // A numeric axis is the font's own answer, so it beats the word heuristic. Fontsource - // names every face that way and carries no weight WORD at all, so word-only parsing - // scored a whole family "Regular" and staged exactly one of its faces. - // - // A weight token must not be buried inside a longer run: this reads capture files, - // which are commonly hash-named, and "Newsreader-a1b200c3.woff2" is not a 200-weight - // face. Hence a non-digit before (which also stops "2100" reading as 100) and no - // alphanumeric after. "Roboto900.ttf" still parses. - const numeric = /(?:^|[^0-9])([1-9]00)(?![0-9a-z])/.exec(s); - if (numeric) return { n: Number(numeric[1]), w: numeric[1] }; - if (/black|heavy|ultra|extrabold/.test(s)) return { n: 800, w: "ExtraBold" }; - if (/semibold|demibold/.test(s)) return { n: 600, w: "SemiBold" }; - if (/bold/.test(s)) return { n: 700, w: "Bold" }; - if (/medium/.test(s)) return { n: 500, w: "Medium" }; - if (/light|thin/.test(s)) return { n: 300, w: "Light" }; - return { n: 400, w: "Regular" }; - }; - const styleOf = (name) => (/italic|oblique/i.test(name) ? "italic" : "normal"); const fams = [...new Set(brandFonts)]; - const srcDirs = [ - join(hyperframesDir, "capture/assets/fonts"), - join(hyperframesDir, "assets/fonts"), - ].filter((d) => existsSync(d)); + const captureDir = join(hyperframesDir, "capture/assets/fonts"); + const srcDirs = [captureDir, fontOutDir].filter((d) => existsSync(d)); const files = []; for (const d of srcDirs) - for (const f of readdirSync(d).sort()) if (extOf(f)) files.push({ d, f }); - // Single family → all font files belong to it (the common captured case, hash-named files - // included). Multiple families → assign each file to the longest family key its name contains. - const ranked = [...fams].sort((a, b) => norm(b).length - norm(a).length); - const famOf = (f) => - fams.length === 1 ? fams[0] : ranked.find((x) => norm(f).includes(norm(x))); - const outDir = join(hyperframesDir, "assets/fonts"); - const faces = []; - const stagedNames = new Set(); + for (const f of readdirSync(d).sort()) if (fontExtOf(f)) files.push({ d, f }); + // Capture files are loosely named (hash-named, or Google's "family-latin-400-normal"): a + // single captured family owns ALL of them, several are told apart by the longest family + // key the name contains. Files already in assets/fonts/ follow the staged-name contract + // and get neither shortcut — a previous run may have staged a PRESET's faces there, and a + // rerun after brand tokens arrive must not relabel EB Garamond's bytes as the brand font + // (not even for a brand named "Garamond"). + const ranked = rankFontFamilies(fams); + const famOf = (d, f) => { + if (d !== captureDir) return familyOfFaceFile(f, ranked); + if (fams.length === 1) return fams[0]; + return ranked.find((x) => normFontName(f).includes(x.key))?.family; + }; + const brandFaces = []; for (const { d, f } of files) { - const fam = famOf(f); + const fam = famOf(d, f); if (!fam) continue; - const { n, w } = weightInfo(f); - const style = styleOf(f); - const clean = `${fam.replace(/[^A-Za-z0-9]/g, "")}-${w}${style === "italic" ? "-Italic" : ""}.${extOf(f)}`; - if (stagedNames.has(clean)) continue; - mkdirSync(outDir, { recursive: true }); - if (!existsSync(join(outDir, clean))) copyFileSync(join(d, f), join(outDir, clean)); - stagedNames.add(clean); - faces.push( - `@font-face{font-family:"${fam}";font-weight:${n};font-style:${style};font-display:block;src:url("assets/fonts/${clean}") format("${FMT[extOf(f)]}");}`, - ); + const face = stageFontFile({ + family: fam, + srcPath: join(d, f), + outDir: fontOutDir, + stagedFaces: stagedFontFaces, + }); + if (face) brandFaces.push(face.rule); } - if (faces.length) { - md += - `\n\n## Font loading (auto-generated)\n\n` + - `The brand font ships as local files in \`assets/fonts/\` — do NOT link Google Fonts for it. ` + - `Paste this \`\n```\n"; - summary.push( - `fonts: staged ${stagedNames.size} face(s) → assets/fonts/ + @font-face in frame.md`, - ); + if (brandFaces.length) { + fontFaces.push(...brandFaces); + fontSources.push("the captured brand font"); + summary.push(`fonts: staged ${brandFaces.length} brand face(s) → assets/fonts/`); } } +const presetFonts = stagePresetFonts({ + presetFontsDir: join(presetDir, presetName, "fonts"), + outDir: fontOutDir, + families: fontFamiliesNamed(md), + stagedFaces: stagedFontFaces, +}); +if (presetFonts.staged.length) { + fontFaces.push(...presetFonts.faces); + fontSources.push(`the ${presetName} preset`); + summary.push( + `fonts: staged ${presetFonts.staged.length} preset face(s) → assets/fonts/` + + (presetFonts.licenses.length ? ` (+ ${presetFonts.licenses.join(", ")})` : ""), + ); +} +if (presetFonts.skipped.length) { + summary.push( + `fonts: skipped ${presetFonts.skipped.length} preset file(s) matching no family frame.md names: ${presetFonts.skipped.join(", ")}`, + ); +} +if (fontFaces.length) { + md += + `\n\n## Font loading (auto-generated)\n\n` + + `These faces ship as local files in \`assets/fonts/\` (from ${fontSources.join(" and ")}) — do NOT link Google Fonts for them. ` + + `Paste this \`\n```\n"; + summary.push(`fonts: @font-face block for ${fontFaces.length} face(s) appended to frame.md`); +} // ── write frame.md ──────────────────────────────────────────────────────────── const framePath = join(hyperframesDir, "frame.md"); diff --git a/skills/pr-to-video/scripts/captions.mjs b/skills/pr-to-video/scripts/captions.mjs index 8390686583..8d68a61aad 100644 --- a/skills/pr-to-video/scripts/captions.mjs +++ b/skills/pr-to-video/scripts/captions.mjs @@ -38,6 +38,14 @@ import { fileURLToPath } from "node:url"; import { parseStoryboard } from "./lib/storyboard.mjs"; import { captionBand, parseFormat } from "./lib/dimensions.mjs"; import { parseColors, parseFonts, semanticColors } from "./lib/tokens.mjs"; +import { + FONT_FORMAT, + fontExtOf, + fontStyleOf, + fontWeightInfo, + normFontName, + rankFontFamilies, +} from "./lib/font-faces.mjs"; const flag = (argv, name, def) => { const i = argv.indexOf(`--${name}`); @@ -300,58 +308,19 @@ function brandFontFaces(framePath, hyperframesDir) { { abs: join(hyperframesDir, "assets/fonts"), rel: "assets/fonts" }, { abs: join(hyperframesDir, "capture/assets/fonts"), rel: "capture/assets/fonts" }, ].filter((d) => existsSync(d.abs)); - const weightOf = (n) => { - const s = n.toLowerCase(); - // A numeric axis is the font's own answer, so it beats the word heuristic. Fontsource - // names every face this way ("inter-latin-500-normal.woff2") and carries no weight - // WORD at all, so word-only parsing collapsed a whole family onto 400 and shipped - // exactly one of its faces. - // - // A weight token must not be buried inside a longer run: capture/assets/fonts holds - // hash-named files, and "Newsreader-a1b200c3.woff2" is not a 200-weight face. Hence a - // non-digit before (which also stops "2100" reading as 100) and no alphanumeric after. - // "Roboto900.ttf" still parses — requiring separators on both sides would have lost it. - const numeric = /(?:^|[^0-9])([1-9]00)(?![0-9a-z])/.exec(s); - if (numeric) return Number(numeric[1]); - if (/black|heavy|ultra|extrabold/.test(s)) return 800; - if (/semibold|demibold/.test(s)) return 600; // before /bold/ — "demibold" contains "bold" - if (/bold/.test(s)) return 700; - if (/medium/.test(s)) return 500; - if (/light|thin/.test(s)) return 300; - return 400; // book / regular / roman - }; - // Weight is not the only axis in a filename. Google Fonts ships Newsreader as - // "Newsreader-Italic-VariableFont_opsz,wght.ttf" + "Newsreader-VariableFont_opsz,wght.ttf", - // and the italic sorts first — so without a style axis the italic file claimed the - // family's ONLY 400 slot, the upright file was dropped as a duplicate, and the face - // was declared with no `font-style`. @font-face is deliberately global (the composition - // CSS scoper exempts it, and it has to be), so the whole document then rendered that - // family in italics — captions italicizing every sibling composition. - const styleOf = (n) => (/italic|oblique/i.test(n) ? "italic" : "normal"); - const fmtOf = (f) => - /\.woff2$/i.test(f) - ? "woff2" - : /\.woff$/i.test(f) - ? "woff" - : /\.ttf$/i.test(f) - ? "truetype" - : "opentype"; - // Normalize away ALL non-alphanumerics (spaces, underscores, hyphens) on BOTH the - // family name and the filename. Real font files use "_" / "-" as word separators - // ("TT_Norms_Pro_Bold.woff2"), so stripping only whitespace never matched them — the - // family key "ttnormspro" failed `startsWith` against "tt_norms_pro_bold", and the - // function silently returned "" → captions shipped with NO @font-face for any - // underscore/hyphen-named brand font (e.g. TT Norms Pro), which is exactly the - // font_family_without_font_face bug. - const norm = (s) => s.toLowerCase().replace(/[^a-z0-9]/g, ""); + // Weight, style, format and the family-name key all come from lib/font-faces.mjs — the same + // helpers build-frame.mjs stages with — so a file staged under that naming contract parses + // back to the face it was staged as. Two axes matter: a numeric weight beats the word + // heuristic (Fontsource names carry no weight word), and style is read too, or the italic + // file of a weight would claim its only slot and @font-face (global by design) would + // italicise every sibling composition. The key strips ALL non-alphanumerics on both sides, + // since real files separate words with "_" / "-" ("TT_Norms_Pro_Bold.woff2"). const faces = []; const seen = new Set(); const claimed = new Set(); // each file is claimed by the MOST SPECIFIC family only // Match the longest family key first so "TT Norms Pro" can't swallow the files that // belong to "TT Norms Pro Mono" (its key is a prefix of the longer one's). - const ranked = [...families].sort((a, b) => norm(b).length - norm(a).length); - for (const fam of ranked) { - const key = norm(fam); + for (const { family: fam, key } of rankFontFamilies(families)) { for (const d of dirs) { let files = []; try { @@ -360,17 +329,18 @@ function brandFontFaces(framePath, hyperframesDir) { continue; } for (const f of files.sort()) { - if (!/\.(woff2|woff|ttf|otf)$/i.test(f)) continue; + const ext = fontExtOf(f); + if (!ext) continue; if (claimed.has(f)) continue; // a more specific family already took this file - if (!norm(f.replace(/\.(woff2|woff|ttf|otf)$/i, "")).startsWith(key)) continue; - const w = weightOf(f); - const style = styleOf(f); + if (!normFontName(f.slice(0, -(ext.length + 1))).startsWith(key)) continue; + const { n: w } = fontWeightInfo(f); + const style = fontStyleOf(f); const dedup = `${fam}-${w}-${style}`; if (seen.has(dedup)) continue; // one src per face; assets/fonts wins over capture seen.add(dedup); claimed.add(f); faces.push( - ` @font-face { font-family: '${fam}'; src: url('${d.rel}/${f}') format('${fmtOf(f)}'); font-weight: ${w}; font-style: ${style}; font-display: block; }`, + ` @font-face { font-family: '${fam}'; src: url('${d.rel}/${f}') format('${FONT_FORMAT[ext]}'); font-weight: ${w}; font-style: ${style}; font-display: block; }`, ); } } diff --git a/skills/pr-to-video/scripts/captions.test.mjs b/skills/pr-to-video/scripts/captions.test.mjs index 14aefb8d4b..e6642dbf60 100644 --- a/skills/pr-to-video/scripts/captions.test.mjs +++ b/skills/pr-to-video/scripts/captions.test.mjs @@ -199,22 +199,34 @@ test("captions.mjs is byte-identical across the three workflows that ship it", ( test("every build-frame.mjs copy stages the style axis it promises", () => { for (const skill of ["product-launch-video", "faceless-explainer", "pr-to-video"]) { + // build-frame.mjs stages through lib/font-faces.mjs, so the naming contract lives there. const source = readFileSync( - new URL(`../../${skill}/scripts/build-frame.mjs`, import.meta.url), + new URL(`../../${skill}/scripts/lib/font-faces.mjs`, import.meta.url), "utf8", ); // The staged filename must carry the style, or the italic and upright faces of one // weight collide on a single name and only whichever sorts first survives. assert.match( source, - /const clean = `\$\{fam\.replace\(\/\[\^A-Za-z0-9\]\/g, ""\)\}-\$\{w\}\$\{style === "italic" \? "-Italic" : ""\}\./, - `${skill}/build-frame.mjs must keep the style token in the staged name`, + /const name = `\$\{family\.replace\(\/\[\^A-Za-z0-9\]\/g, ""\)\}-\$\{w\}\$\{style === "italic" \? "-Italic" : ""\}\./, + `${skill}/lib/font-faces.mjs must keep the style token in the staged name`, ); // ...and the emitted descriptor must report the real style, not a hardcoded normal. assert.doesNotMatch( source, /font-weight:\$\{n\};font-style:normal/, - `${skill}/build-frame.mjs must not assert font-style:normal over captured bytes`, + `${skill}/lib/font-faces.mjs must not assert font-style:normal over captured bytes`, + ); + // captions.mjs parses the staged names back; it must read them with the same helpers + // rather than a private copy that can drift from the staging side. + const captions = readFileSync( + new URL(`../../${skill}/scripts/captions.mjs`, import.meta.url), + "utf8", + ); + assert.match( + captions, + /import \{[^}]*\bfontWeightInfo\b[^}]*\} from "\.\/lib\/font-faces\.mjs"/, + `${skill}/captions.mjs must import its face helpers from lib/font-faces.mjs`, ); } }); diff --git a/skills/pr-to-video/scripts/lib/font-faces.mjs b/skills/pr-to-video/scripts/lib/font-faces.mjs new file mode 100644 index 0000000000..4935f4d641 --- /dev/null +++ b/skills/pr-to-video/scripts/lib/font-faces.mjs @@ -0,0 +1,162 @@ +// font-faces.mjs — staging font files into assets/fonts/ + the @font-face rule for each. +// Shared by build-frame.mjs's two font sources: captured BRAND font files and a preset's +// own fonts/ folder. +// +// The staged NAME is a contract, not cosmetics: captions.mjs derives each face's weight and +// style back out of it. So the name has to carry every axis that distinguishes one face +// from another, and the dedup key has to be the whole face. Naming on weight alone made +// Google's two-file Newsreader download (upright + italic, both scoring "Regular") collide +// on one slot: the italic sorts first, took the name, the upright was never staged, and +// the @font-face block then asserted font-style:normal over italic bytes. + +import { copyFileSync, existsSync, mkdirSync, readdirSync, statSync } from "node:fs"; +import { basename, join, resolve } from "node:path"; + +export const FONT_FORMAT = { woff2: "woff2", woff: "woff", ttf: "truetype", otf: "opentype" }; + +/** Family-name key: lowercase alphanumerics only, so "EB Garamond" ≡ "EBGaramond-400.woff2"'s stem. */ +export const normFontName = (s) => + String(s) + .toLowerCase() + .replace(/[^a-z0-9]/g, ""); + +/** Lowercase font extension of a filename, or "" when it is not a font file. */ +export const fontExtOf = (f) => (f.match(/\.(woff2|woff|ttf|otf)$/i)?.[1] ?? "").toLowerCase(); + +/** Weight of a face from its filename: `{ n: 700, w: "Bold" }` (n numeric, w the staged-name token). */ +export function fontWeightInfo(name) { + const s = name.toLowerCase(); + // A numeric axis is the font's own answer, so it beats the word heuristic. Fontsource + // names every face that way and carries no weight WORD at all, so word-only parsing + // scored a whole family "Regular" and staged exactly one of its faces. + // + // A weight token must not be buried inside a longer run: this reads capture files, + // which are commonly hash-named, and "Newsreader-a1b200c3.woff2" is not a 200-weight + // face. Hence a non-digit before (which also stops "2100" reading as 100) and no + // alphanumeric after. "Roboto900.ttf" still parses. + const numeric = /(?:^|[^0-9])([1-9]00)(?![0-9a-z])/.exec(s); + if (numeric) return { n: Number(numeric[1]), w: numeric[1] }; + if (/black|heavy|ultra|extrabold/.test(s)) return { n: 800, w: "ExtraBold" }; + if (/semibold|demibold/.test(s)) return { n: 600, w: "SemiBold" }; + if (/bold/.test(s)) return { n: 700, w: "Bold" }; + if (/medium/.test(s)) return { n: 500, w: "Medium" }; + if (/light|thin/.test(s)) return { n: 300, w: "Light" }; + return { n: 400, w: "Regular" }; +} + +export const fontStyleOf = (name) => (/italic|oblique/i.test(name) ? "italic" : "normal"); + +/** + * Families as `{ family, key }` ranked longest key first, so a filename is matched against + * "TT Norms Pro Mono" before "TT Norms Pro" can swallow it. Duplicates and families whose key + * is empty (nothing to match on) are dropped. + */ +export function rankFontFamilies(families) { + return [...new Set(families)] + .map((family) => ({ family, key: normFontName(family) })) + .filter((x) => x.key) + .sort((a, b) => b.key.length - a.key.length); +} + +/** Every family a frame spec names — `fontFamily: "X"` in its typography ramp. */ +export function fontFamiliesNamed(md) { + const out = new Set(); + for (const m of String(md).matchAll(/fontFamily:\s*"([^"]+)"/g)) out.add(m[1].trim()); + return [...out]; +} + +/** + * The family a face-named file belongs to: the longest-keyed entry of `ranked` (see + * `rankFontFamilies`) whose key the filename's stem STARTS with, or `undefined`. Staged files + * and preset files follow the `-[-Italic].` contract, so a prefix + * match is exact — a brand named "Garamond" must not claim the preset's "EBGaramond-400.woff2". + */ +export const familyOfFaceFile = (fileName, ranked) => { + const stem = fileName.replace(/\.[^.]+$/, ""); + return ranked.find((x) => normFontName(stem).startsWith(x.key))?.family; +}; + +/** + * Copy one font file into `outDir` under its clean face name and return + * `{ name, rule }` — the staged filename and its root-relative @font-face rule — or + * `null` when that face is already staged (first source wins) or the file is not a font. + * `stagedFaces` is keyed on the FACE (family + numeric weight + style), not the filename: + * "Inter-Regular.ttf" and "inter-latin-400-normal.woff2" name the same face and would + * otherwise both be staged with two contradictory @font-face rules for it. Refresh scope: a + * file already at the staged path wins by default (the capture source — a hand-placed file, + * or the previous run's own output, is never overwritten by a download); `refresh: true` is + * passed for the preset source only, so a face the preset stages is re-copied on every run + * (a skills update may ship re-subsetted files) unless the source already IS the staged file. + */ +export function stageFontFile({ family, srcPath, outDir, stagedFaces, refresh = false }) { + const file = basename(srcPath); + const ext = fontExtOf(file); + if (!ext) return null; + const { n, w } = fontWeightInfo(file); + const style = fontStyleOf(file); + const faceKey = `${family}|${n}|${style}`; + if (stagedFaces.has(faceKey)) return null; + const name = `${family.replace(/[^A-Za-z0-9]/g, "")}-${w}${style === "italic" ? "-Italic" : ""}.${ext}`; + mkdirSync(outDir, { recursive: true }); + const dest = join(outDir, name); + if ((refresh || !existsSync(dest)) && resolve(srcPath) !== resolve(dest)) + copyFileSync(srcPath, dest); + stagedFaces.add(faceKey); + return { + name, + rule: `@font-face{font-family:"${family}";font-weight:${n};font-style:${style};font-display:block;src:url("assets/fonts/${name}") format("${FONT_FORMAT[ext]}");}`, + }; +} + +const LICENSE_FILE = /^(?:OFL|LICEN[CS]E|COPYING)\b/i; + +/** + * Stage a preset's own fonts/ folder. Each font file is assigned to the LONGEST family in + * `families` (the families the frame spec names) whose key its filename starts with; a file + * matching none is reported in `skipped` rather than staged — after a brand remix the + * preset's original family may no longer appear in frame.md, and its bytes would be dead + * weight. License texts shipped beside the fonts (OFL-*.txt, LICENSE…) travel with any + * staged face. Returns `{ faces, staged, skipped, licenses }`; a preset without a fonts/ + * folder yields all-empty arrays. + */ +export function stagePresetFonts({ presetFontsDir, outDir, families, stagedFaces }) { + const result = { faces: [], staged: [], skipped: [], licenses: [] }; + if (!existsSync(presetFontsDir)) return result; + const ranked = rankFontFamilies(families); + // Only plain files (symlinks resolved): a subdirectory named like a font would otherwise + // reach copyFileSync and throw EISDIR, and a dangling symlink would throw ENOENT on stat. + const isFile = (f) => { + try { + return statSync(join(presetFontsDir, f)).isFile(); + } catch { + return false; + } + }; + const entries = readdirSync(presetFontsDir).filter(isFile).sort(); + for (const f of entries) { + if (!fontExtOf(f)) continue; + const family = familyOfFaceFile(f, ranked); + if (!family) { + result.skipped.push(f); + continue; + } + const face = stageFontFile({ + family, + srcPath: join(presetFontsDir, f), + outDir, + stagedFaces, + refresh: true, + }); + if (!face) continue; + result.faces.push(face.rule); + result.staged.push(face.name); + } + if (result.staged.length) { + for (const f of entries) { + if (fontExtOf(f) || !LICENSE_FILE.test(f)) continue; + if (!existsSync(join(outDir, f))) copyFileSync(join(presetFontsDir, f), join(outDir, f)); + result.licenses.push(f); + } + } + return result; +} diff --git a/skills/product-launch-video/SKILL.md b/skills/product-launch-video/SKILL.md index 60f2eeddad..3750815482 100644 --- a/skills/product-launch-video/SKILL.md +++ b/skills/product-launch-video/SKILL.md @@ -94,7 +94,7 @@ When `BRIEF.md` names a `style_preset` — the user picked it by eye from the sh 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 the brand tokens in `capture/extracted/tokens.json` (brand colors mapped onto the preset's color keys by role — ink, canvas, accents — keeping keys/structure/components; 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 to the next step 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 the brand tokens in `capture/extracted/tokens.json` (brand colors mapped onto the preset's color keys by role — ink, canvas, accents — keeping keys/structure/components; the preset's display + body fonts swapped for the brand's), stages any font files the preset ships (its `fonts/` folder) into `assets/fonts/` with a ready-to-paste `@font-face` block in `frame.md`, copies the preset's caption skin to `.hyperframes/caption-skin.html`, and self-validates (exits 1 on a broken mapping). Proceed to the next step as soon as it exits 0 — no hand-editing of the spec. `tokens.json` with no brand colors/fonts (e.g. no capture) → the script keeps the preset's own palette, a complete shippable design. If the brief names brand colors/fonts the capture missed, add them to `capture/extracted/tokens.json` before running (or use the user's `design.md` to populate it); only adjust `frame.md` by hand afterward if a mapping truly needs it. diff --git a/skills/product-launch-video/scripts/build-frame-fonts.test.mjs b/skills/product-launch-video/scripts/build-frame-fonts.test.mjs new file mode 100644 index 0000000000..0c3771503b --- /dev/null +++ b/skills/product-launch-video/scripts/build-frame-fonts.test.mjs @@ -0,0 +1,492 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +import { + fontFamiliesNamed, + fontWeightInfo, + stageFontFile, + stagePresetFonts, +} from "./lib/font-faces.mjs"; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const buildFrameScript = join(scriptDir, "build-frame.mjs"); +const shippedPresetDir = resolve(scriptDir, "../../hyperframes-creative/frame-presets"); + +const tmpDirs = []; +const tmp = (prefix) => { + const dir = mkdtempSync(join(tmpdir(), prefix)); + tmpDirs.push(dir); + return dir; +}; +test.after(() => { + for (const dir of tmpDirs) rmSync(dir, { recursive: true, force: true }); +}); + +function write(path, contents) { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, contents); +} + +// A minimal preset: three families in the typography ramp, a legal color block so the +// script's self-check passes, and whatever the caller drops into fonts/. +function fakePreset(root, fonts = {}) { + const presetDir = join(root, "presets"); + const dir = join(presetDir, "acme"); + write( + join(dir, "FRAME.md"), + [ + "---", + "name: Acme — Frame", + "", + "colors:", + ' ink: "#141413"', + ' cream: "#FAF9F5"', + ' coral: "#CC785C"', + "", + "typography:", + ' display: { fontFamily: "Acme Sans", cqw: 6.7, weight: 700, lineHeight: 1.02 }', + ' body: { fontFamily: "Acme Sans", cqw: 1.5, weight: 400, lineHeight: 1.5 }', + ' code: { fontFamily: "Other Mono", cqw: 1.4, weight: 400, lineHeight: 1.6 }', + "---", + "", + "# Acme", + "", + "Prose about the look.", + "", + ].join("\n"), + ); + for (const [name, bytes] of Object.entries(fonts)) write(join(dir, "fonts", name), bytes); + return presetDir; +} + +function runBuildFrame(project, presetDir, preset = "acme") { + return execFileSync( + process.execPath, + [buildFrameScript, "--preset", preset, "--hyperframes", project, "--preset-dir", presetDir], + { encoding: "utf8" }, + ); +} + +const PRESET_FONTS = { + "AcmeSans-400.woff2": "sans-regular", + "AcmeSans-Bold.woff2": "sans-bold", + "AcmeSans-BoldItalic.woff2": "sans-bold-italic", + "OtherMono-700.ttf": "mono-bold", + "Stray-400.woff2": "no such family in the ramp", + "OFL-acme-sans.txt": "SIL Open Font License", +}; + +test("a preset's own fonts/ folder is staged by family, weight and style read from the filename", () => { + const root = tmp("build-frame-preset-fonts-"); + const project = join(root, "project"); + write(join(project, "capture/extracted/tokens.json"), '{"colors":[],"fonts":[]}'); + const out = runBuildFrame(project, fakePreset(root, PRESET_FONTS)); + + const stagedDir = join(project, "assets/fonts"); + assert.deepEqual(readdirSync(stagedDir).sort(), [ + "AcmeSans-400.woff2", + "AcmeSans-Bold-Italic.woff2", + "AcmeSans-Bold.woff2", + "OFL-acme-sans.txt", + "OtherMono-700.ttf", + ]); + assert.equal( + readFileSync(join(stagedDir, "AcmeSans-Bold-Italic.woff2"), "utf8"), + "sans-bold-italic", + ); + + const frameMd = readFileSync(join(project, "frame.md"), "utf8"); + assert.match(frameMd, /## Font loading \(auto-generated\)/); + assert.match(frameMd, /from the acme preset/); + for (const rule of [ + '@font-face{font-family:"Acme Sans";font-weight:400;font-style:normal;font-display:block;src:url("assets/fonts/AcmeSans-400.woff2") format("woff2");}', + '@font-face{font-family:"Acme Sans";font-weight:700;font-style:normal;font-display:block;src:url("assets/fonts/AcmeSans-Bold.woff2") format("woff2");}', + '@font-face{font-family:"Acme Sans";font-weight:700;font-style:italic;font-display:block;src:url("assets/fonts/AcmeSans-Bold-Italic.woff2") format("woff2");}', + '@font-face{font-family:"Other Mono";font-weight:700;font-style:normal;font-display:block;src:url("assets/fonts/OtherMono-700.ttf") format("truetype");}', + ]) { + assert.ok(frameMd.includes(rule), `frame.md should carry ${rule}`); + } + assert.equal((frameMd.match(/## Font loading/g) ?? []).length, 1, "one font-loading section"); + assert.doesNotMatch(frameMd, /Stray/); + + assert.match(out, /fonts: staged 4 preset face\(s\) → assets\/fonts\/ \(\+ OFL-acme-sans\.txt\)/); + assert.match( + out, + /skipped 1 preset file\(s\) matching no family frame\.md names: Stray-400\.woff2/, + ); + assert.match(out, /@font-face block for 4 face\(s\) appended to frame\.md/); +}); + +test("faces of a family the brand remix replaced are not staged; the rest still are", () => { + const root = tmp("build-frame-preset-fonts-remix-"); + const project = join(root, "project"); + write( + join(project, "capture/extracted/tokens.json"), + JSON.stringify({ colors: [], fonts: [{ family: "Brand Grotesk", weights: [400, 700] }] }), + ); + const out = runBuildFrame(project, fakePreset(root, PRESET_FONTS)); + + // Acme Sans (display + body) became Brand Grotesk, which ships no files here; Other Mono + // is untouched by the remix and keeps its preset-owned face. + assert.deepEqual(readdirSync(join(project, "assets/fonts")).sort(), [ + "OFL-acme-sans.txt", + "OtherMono-700.ttf", + ]); + const frameMd = readFileSync(join(project, "frame.md"), "utf8"); + assert.doesNotMatch(frameMd, /Acme Sans/); + assert.match(frameMd, /@font-face\{font-family:"Other Mono";font-weight:700/); + assert.doesNotMatch(frameMd, /AcmeSans-/); + assert.match(out, /fonts: staged 1 preset face\(s\)/); + assert.match( + out, + /skipped 4 preset file\(s\) matching no family frame\.md names: AcmeSans-400\.woff2, AcmeSans-Bold\.woff2, AcmeSans-BoldItalic\.woff2, Stray-400\.woff2/, + ); +}); + +test("a face the captured brand already staged is not staged twice from the preset", () => { + const root = tmp("build-frame-preset-fonts-dedup-"); + const project = join(root, "project"); + // The brand IS the preset's mono family and the capture downloaded its bold face. + write( + join(project, "capture/extracted/tokens.json"), + JSON.stringify({ colors: [], fonts: [{ family: "Other Mono", weights: [700] }] }), + ); + write(join(project, "capture/assets/fonts/othermono-latin-700-normal.woff2"), "captured-bold"); + runBuildFrame(project, fakePreset(root, { "OtherMono-700.woff2": "preset-bold" })); + + const stagedDir = join(project, "assets/fonts"); + assert.deepEqual(readdirSync(stagedDir).sort(), ["OtherMono-700.woff2"]); + assert.equal(readFileSync(join(stagedDir, "OtherMono-700.woff2"), "utf8"), "captured-bold"); + const frameMd = readFileSync(join(project, "frame.md"), "utf8"); + assert.equal((frameMd.match(/@font-face\{/g) ?? []).length, 1); +}); + +test("a brand face the capture named by weight word is not staged again from the preset's numeric file", () => { + const root = tmp("build-frame-preset-fonts-dedup-word-"); + const project = join(root, "project"); + write( + join(project, "capture/extracted/tokens.json"), + JSON.stringify({ colors: [], fonts: [{ family: "Other Mono", weights: [700] }] }), + ); + write(join(project, "capture/assets/fonts/OtherMono-Bold.woff2"), "captured-bold"); + runBuildFrame(project, fakePreset(root, { "OtherMono-700.woff2": "preset-bold" })); + + assert.deepEqual(readdirSync(join(project, "assets/fonts")).sort(), ["OtherMono-Bold.woff2"]); + const frameMd = readFileSync(join(project, "frame.md"), "utf8"); + assert.equal((frameMd.match(/@font-face\{/g) ?? []).length, 1); +}); + +test("a rerun after brand tokens arrive does not relabel previously staged preset faces as the brand", () => { + const root = tmp("build-frame-preset-fonts-rerun-"); + const project = join(root, "project"); + const presetDir = fakePreset(root, PRESET_FONTS); + // Run 1: no brand yet — the preset's own faces land in assets/fonts/. + write(join(project, "capture/extracted/tokens.json"), '{"colors":[],"fonts":[]}'); + runBuildFrame(project, presetDir); + assert.equal(existsSync(join(project, "assets/fonts/AcmeSans-400.woff2")), true); + // Run 2: one captured brand family with a Google-named file. Only the capture file may be + // claimed by the single-family shortcut; the preset bytes already in assets/fonts/ must + // not — "Sans" is even a substring of the staged "AcmeSans-*" names. + write( + join(project, "capture/extracted/tokens.json"), + JSON.stringify({ colors: [], fonts: [{ family: "Sans", weights: [400] }] }), + ); + write(join(project, "capture/assets/fonts/sans-regular.woff2"), "sans-regular"); + const out = runBuildFrame(project, presetDir); + + const stagedDir = join(project, "assets/fonts"); + assert.equal(readFileSync(join(stagedDir, "Sans-Regular.woff2"), "utf8"), "sans-regular"); + assert.equal( + existsSync(join(stagedDir, "Sans-400.woff2")), + false, + "AcmeSans bytes not relabeled", + ); + assert.equal(existsSync(join(stagedDir, "Sans-Bold.woff2")), false); + const frameMd = readFileSync(join(project, "frame.md"), "utf8"); + const sansRules = frameMd.match(/@font-face\{font-family:"Sans";[^\n]*/g) ?? []; + assert.equal(sansRules.length, 1); + assert.match(sansRules[0], /Sans-Regular\.woff2/); + assert.match(out, /fonts: staged 1 brand face\(s\)/); +}); + +test("a hash-named capture file is staged as the single captured brand family", () => { + const root = tmp("build-frame-capture-hash-"); + const project = join(root, "project"); + write( + join(project, "capture/extracted/tokens.json"), + JSON.stringify({ colors: [], fonts: [{ family: "Brand Grotesk", weights: [400] }] }), + ); + // The capture pipeline saves downloads under their content hash (see the CLI's + // fontMetadataExtractor): nothing in the name says "Brand Grotesk", so only the + // single-family shortcut can claim it — the prefix rule would leave the brand unstaged. + write(join(project, "capture/assets/fonts/19cfc7226ec3afaa-s.woff2"), "captured-regular"); + const out = runBuildFrame(project, fakePreset(root)); + + const stagedDir = join(project, "assets/fonts"); + assert.deepEqual(readdirSync(stagedDir).sort(), ["BrandGrotesk-Regular.woff2"]); + assert.equal( + readFileSync(join(stagedDir, "BrandGrotesk-Regular.woff2"), "utf8"), + "captured-regular", + ); + const frameMd = readFileSync(join(project, "frame.md"), "utf8"); + assert.match( + frameMd, + /@font-face\{font-family:"Brand Grotesk";font-weight:400;font-style:normal;[^\n]*BrandGrotesk-Regular\.woff2/, + ); + assert.match(out, /fonts: staged 1 brand face\(s\)/); +}); + +test("capture files of two brand families each go to the family whose key their name contains", () => { + const root = tmp("build-frame-capture-two-families-"); + const project = join(root, "project"); + write( + join(project, "capture/extracted/tokens.json"), + JSON.stringify({ + colors: [], + fonts: [ + { family: "Brand Grotesk", weights: [400] }, + { family: "Brand Mono", weights: [700] }, + ], + }), + ); + // CDN-style names carry the family INSIDE the stem, not at its start: substring matching + // is what tells the two downloads apart; a prefix match would stage neither. + write(join(project, "capture/assets/fonts/cdn-brandgrotesk-400.woff2"), "grotesk-regular"); + write(join(project, "capture/assets/fonts/cdn-brandmono-700.woff2"), "mono-bold"); + const out = runBuildFrame(project, fakePreset(root)); + + const stagedDir = join(project, "assets/fonts"); + assert.deepEqual(readdirSync(stagedDir).sort(), [ + "BrandGrotesk-400.woff2", + "BrandMono-700.woff2", + ]); + assert.equal(readFileSync(join(stagedDir, "BrandGrotesk-400.woff2"), "utf8"), "grotesk-regular"); + assert.equal(readFileSync(join(stagedDir, "BrandMono-700.woff2"), "utf8"), "mono-bold"); + const frameMd = readFileSync(join(project, "frame.md"), "utf8"); + assert.match( + frameMd, + /@font-face\{font-family:"Brand Grotesk";font-weight:400;[^\n]*BrandGrotesk-400\.woff2/, + ); + assert.match( + frameMd, + /@font-face\{font-family:"Brand Mono";font-weight:700;[^\n]*BrandMono-700\.woff2/, + ); + assert.match(out, /fonts: staged 2 brand face\(s\)/); +}); + +test("a capture file does not overwrite a face already staged in assets/fonts/", () => { + const root = tmp("build-frame-capture-keeps-staged-"); + const project = join(root, "project"); + write( + join(project, "capture/extracted/tokens.json"), + JSON.stringify({ colors: [], fonts: [{ family: "Brand Grotesk", weights: [400] }] }), + ); + write(join(project, "capture/assets/fonts/brand-grotesk-400.woff2"), "captured-regular"); + // Hand-placed (or previously staged) bytes under the staged name win over the download. + write(join(project, "assets/fonts/BrandGrotesk-400.woff2"), "hand-placed"); + runBuildFrame(project, fakePreset(root)); + + const stagedDir = join(project, "assets/fonts"); + assert.deepEqual(readdirSync(stagedDir).sort(), ["BrandGrotesk-400.woff2"]); + assert.equal(readFileSync(join(stagedDir, "BrandGrotesk-400.woff2"), "utf8"), "hand-placed"); + const frameMd = readFileSync(join(project, "frame.md"), "utf8"); + assert.equal((frameMd.match(/@font-face\{/g) ?? []).length, 1); +}); + +test("a rerun refreshes a preset face's staged bytes from the preset", () => { + const root = tmp("build-frame-preset-refresh-"); + const project = join(root, "project"); + const presetDir = fakePreset(root, PRESET_FONTS); + write(join(project, "capture/extracted/tokens.json"), '{"colors":[],"fonts":[]}'); + runBuildFrame(project, presetDir); + const staged = join(project, "assets/fonts/AcmeSans-400.woff2"); + assert.equal(readFileSync(staged, "utf8"), "sans-regular"); + // A skills update may ship re-subsetted files: the preset source is authoritative. + writeFileSync(staged, "stale bytes from an earlier run"); + runBuildFrame(project, presetDir); + assert.equal(readFileSync(staged, "utf8"), "sans-regular"); +}); + +test("a preset without a fonts/ folder stages nothing and appends no font-loading section", () => { + const root = tmp("build-frame-preset-no-fonts-"); + const project = join(root, "project"); + write(join(project, "capture/extracted/tokens.json"), '{"colors":[],"fonts":[]}'); + const out = runBuildFrame(project, fakePreset(root)); + assert.equal(existsSync(join(project, "assets/fonts")), false); + assert.doesNotMatch(readFileSync(join(project, "frame.md"), "utf8"), /## Font loading/); + assert.doesNotMatch(out, /fonts: staged/); +}); + +test("the shipped code-editorial preset stages its six licensed faces plus their licenses", () => { + const project = tmp("build-frame-code-editorial-"); + write(join(project, "capture/extracted/tokens.json"), '{"colors":[],"fonts":[]}'); + runBuildFrame(project, shippedPresetDir, "code-editorial"); + + const stagedDir = join(project, "assets/fonts"); + for (const name of [ + "EBGaramond-400.woff2", + "EBGaramond-700.woff2", + "Inter-400.woff2", + "Inter-700.woff2", + "JetBrainsMono-400.woff2", + "JetBrainsMono-700.woff2", + ]) { + assert.equal(existsSync(join(stagedDir, name)), true, `${name} should be staged`); + assert.equal(readFileSync(join(stagedDir, name)).subarray(0, 4).toString("ascii"), "wOF2"); + } + for (const family of ["eb-garamond", "inter", "jetbrains-mono"]) { + assert.equal(existsSync(join(stagedDir, `OFL-${family}.txt`)), true); + } + const frameMd = readFileSync(join(project, "frame.md"), "utf8"); + assert.match(frameMd, /@font-face\{font-family:"EB Garamond";font-weight:400/); + assert.match(frameMd, /@font-face\{font-family:"Inter";font-weight:700/); + assert.match(frameMd, /@font-face\{font-family:"JetBrains Mono";font-weight:400/); + assert.ok(!frameMd.includes("fonts.googleapis.com")); +}); + +test("fontFamiliesNamed reads every family in a ramp once", () => { + assert.deepEqual( + fontFamiliesNamed( + 'display: { fontFamily: "EB Garamond" }\nbody: { fontFamily: "Inter" }\nlead: { fontFamily: "Inter" }', + ), + ["EB Garamond", "Inter"], + ); +}); + +test("fontWeightInfo prefers a numeric axis and never reads a hash as a weight", () => { + assert.deepEqual(fontWeightInfo("Inter-700.woff2"), { n: 700, w: "700" }); + assert.deepEqual(fontWeightInfo("inter-latin-500-normal.woff2"), { n: 500, w: "500" }); + assert.deepEqual(fontWeightInfo("Newsreader-a1b200c3.woff2"), { n: 400, w: "Regular" }); + assert.deepEqual(fontWeightInfo("Acme-SemiBold.otf"), { n: 600, w: "SemiBold" }); +}); + +test("stageFontFile leaves a file already at the staged path alone unless refresh is set", () => { + const root = tmp("stage-font-file-keep-"); + write(join(root, "src/Acme-Bold.woff2"), "download"); + const outDir = join(root, "out"); + write(join(outDir, "Acme-Bold.woff2"), "hand-placed"); + const kept = stageFontFile({ + family: "Acme", + srcPath: join(root, "src/Acme-Bold.woff2"), + outDir, + stagedFaces: new Set(), + }); + assert.equal(kept.name, "Acme-Bold.woff2"); + assert.match(kept.rule, /Acme-Bold\.woff2/); + assert.equal(readFileSync(join(outDir, "Acme-Bold.woff2"), "utf8"), "hand-placed"); +}); + +test("stageFontFile refreshes stale bytes, keeps the first source's face and refuses non-font files", () => { + const root = tmp("stage-font-file-"); + write(join(root, "a/Acme-Bold.woff2"), "first"); + write(join(root, "b/acme-700.woff2"), "second"); + write(join(root, "a/OFL.txt"), "license"); + const outDir = join(root, "out"); + write(join(outDir, "Acme-Bold.woff2"), "stale bytes from an earlier run"); + const stagedFaces = new Set(); + const first = stageFontFile({ + family: "Acme", + srcPath: join(root, "a/Acme-Bold.woff2"), + outDir, + stagedFaces, + refresh: true, + }); + assert.equal(first.name, "Acme-Bold.woff2"); + assert.match(first.rule, /font-weight:700;font-style:normal/); + assert.equal( + stageFontFile({ family: "Acme", srcPath: join(root, "b/acme-700.woff2"), outDir, stagedFaces }), + null, + "a numeric and a word weight for the same face are one face — the first source wins", + ); + assert.equal(existsSync(join(outDir, "Acme-700.woff2")), false); + assert.equal( + stageFontFile({ family: "Acme", srcPath: join(root, "a/OFL.txt"), outDir, stagedFaces }), + null, + ); + assert.equal(readFileSync(join(outDir, "Acme-Bold.woff2"), "utf8"), "first"); +}); + +test("a font file goes to the longest family its name starts with, not the first that matches", () => { + const root = tmp("stage-preset-fonts-overlap-"); + const presetFontsDir = join(root, "fonts"); + write(join(presetFontsDir, "InterTight-700.woff2"), "tight-bold"); + write(join(presetFontsDir, "Inter-400.woff2"), "inter-regular"); + const outDir = join(root, "out"); + // "Inter" is a prefix of "Inter Tight": listed first so that only the length ranking, not + // the input order, can send InterTight-700 to the right family. + const result = stagePresetFonts({ + presetFontsDir, + outDir, + families: ["Inter", "Inter Tight"], + stagedFaces: new Set(), + }); + assert.deepEqual(result.staged, ["Inter-400.woff2", "InterTight-700.woff2"]); + assert.deepEqual(result.skipped, []); + assert.match(result.faces[0], /font-family:"Inter";font-weight:400;.*Inter-400\.woff2/); + assert.match( + result.faces[1], + /font-family:"Inter Tight";font-weight:700;.*InterTight-700\.woff2/, + ); + assert.equal(readFileSync(join(outDir, "InterTight-700.woff2"), "utf8"), "tight-bold"); + assert.equal(readFileSync(join(outDir, "Inter-400.woff2"), "utf8"), "inter-regular"); + + // A family whose key merely appears INSIDE a filename does not own it: after a remix + // renamed "EB Garamond" to "Garamond", the preset's EBGaramond files are dead weight. + const substring = stagePresetFonts({ + presetFontsDir, + outDir: join(root, "out2"), + families: ["Tight"], + stagedFaces: new Set(), + }); + assert.deepEqual(substring.staged, []); + assert.deepEqual(substring.skipped, ["Inter-400.woff2", "InterTight-700.woff2"]); +}); + +test("a subdirectory or dangling symlink named like a font file is neither staged nor a crash", () => { + const root = tmp("stage-preset-fonts-dir-entry-"); + const presetFontsDir = join(root, "fonts"); + mkdirSync(join(presetFontsDir, "Inter-700.woff2"), { recursive: true }); + mkdirSync(join(presetFontsDir, "LICENSE"), { recursive: true }); + symlinkSync(join(root, "gone.woff2"), join(presetFontsDir, "Inter-Bold.woff2")); + write(join(presetFontsDir, "Inter-400.woff2"), "inter-regular"); + const result = stagePresetFonts({ + presetFontsDir, + outDir: join(root, "out"), + families: ["Inter"], + stagedFaces: new Set(), + }); + assert.deepEqual(result.staged, ["Inter-400.woff2"]); + assert.deepEqual(result.skipped, []); + assert.deepEqual(result.licenses, []); +}); + +// font-faces.mjs ships once per creation workflow because each skill installs standalone; +// the three copies are meant to be byte-identical so a fix landing in one cannot drift. +test("font-faces.mjs is byte-identical across the three workflows that ship it", () => { + const [first, ...rest] = ["product-launch-video", "faceless-explainer", "pr-to-video"].map( + (skill) => ({ + skill, + source: readFileSync( + new URL(`../../${skill}/scripts/lib/font-faces.mjs`, import.meta.url), + "utf8", + ), + }), + ); + for (const other of rest) { + assert.equal(other.source, first.source, `${other.skill} drifted from ${first.skill}`); + } +}); diff --git a/skills/product-launch-video/scripts/build-frame.mjs b/skills/product-launch-video/scripts/build-frame.mjs index 7f3bf0144f..a1c07e623c 100644 --- a/skills/product-launch-video/scripts/build-frame.mjs +++ b/skills/product-launch-video/scripts/build-frame.mjs @@ -41,6 +41,15 @@ import { STATUS_ROLE_KEY, UA_DEFAULT_COLORS, } from "./lib/tokens.mjs"; +import { + familyOfFaceFile, + fontExtOf, + fontFamiliesNamed, + normFontName, + rankFontFamilies, + stageFontFile, + stagePresetFonts, +} from "./lib/font-faces.mjs"; const __dirname = dirname(fileURLToPath(import.meta.url)); const argv = process.argv.slice(2); @@ -426,89 +435,90 @@ if (brandFonts.length || (brandColors.length && presetColors.length)) { summary.push("brand-adaptation note prepended"); } -// ── stage brand font files + emit @font-face ────────────────────────────────── -// A brand font is rarely a Google font, so renaming the family in frame.md is not enough: -// nothing loads the actual face. If the capture downloaded font files, copy them to -// assets/fonts/ under CLEAN, face-named names (so captions.mjs' family-prefix matcher -// finds them too) and append a ready-to-paste, ROOT-RELATIVE @font-face block to frame.md. -// -// The staged NAME is a contract, not cosmetics: captions.mjs derives each face's weight and -// style back out of it. So the name has to carry every axis that distinguishes one face from -// another, and the dedup key has to be the whole face. Naming on weight alone made Google's -// two-file Newsreader download (upright + italic, both scoring "Regular") collide on one -// slot: the italic sorts first, took the name, the upright was never staged, and the block -// below then asserted font-style:normal over italic bytes. +// ── stage font files + emit @font-face ──────────────────────────────────────── +// Two sources feed assets/fonts/, in this order: +// 1. captured BRAND font files — a brand font is rarely a Google font, so renaming the +// family in frame.md is not enough: nothing loads the actual face. If the capture +// downloaded font files, stage them. +// 2. the PRESET's own fonts/ folder — a preset that ships licensed files (code-editorial) +// stages every face whose family the remixed frame.md still names, so Studio, +// snapshots, and renders resolve its type system offline instead of depending on a +// first-run Google Fonts fetch. A face for a family the brand remix replaced is dead +// weight and is skipped; a face the brand already staged is not staged twice. +// Files land under CLEAN, face-named names (so captions.mjs' family-prefix matcher finds +// them too — the name is a contract, see lib/font-faces.mjs) and ONE ready-to-paste, +// ROOT-RELATIVE @font-face block covering both sources is appended to frame.md. +const fontOutDir = join(hyperframesDir, "assets/fonts"); +const stagedFontFaces = new Set(); +const fontFaces = []; +const fontSources = []; if (brandFonts.length) { - const norm = (s) => - String(s) - .toLowerCase() - .replace(/[^a-z0-9]/g, ""); - const extOf = (f) => (f.match(/\.(woff2|woff|ttf|otf)$/i)?.[1] ?? "").toLowerCase(); - const FMT = { woff2: "woff2", woff: "woff", ttf: "truetype", otf: "opentype" }; - const weightInfo = (name) => { - const s = name.toLowerCase(); - // A numeric axis is the font's own answer, so it beats the word heuristic. Fontsource - // names every face that way and carries no weight WORD at all, so word-only parsing - // scored a whole family "Regular" and staged exactly one of its faces. - // - // A weight token must not be buried inside a longer run: this reads capture files, - // which are commonly hash-named, and "Newsreader-a1b200c3.woff2" is not a 200-weight - // face. Hence a non-digit before (which also stops "2100" reading as 100) and no - // alphanumeric after. "Roboto900.ttf" still parses. - const numeric = /(?:^|[^0-9])([1-9]00)(?![0-9a-z])/.exec(s); - if (numeric) return { n: Number(numeric[1]), w: numeric[1] }; - if (/black|heavy|ultra|extrabold/.test(s)) return { n: 800, w: "ExtraBold" }; - if (/semibold|demibold/.test(s)) return { n: 600, w: "SemiBold" }; - if (/bold/.test(s)) return { n: 700, w: "Bold" }; - if (/medium/.test(s)) return { n: 500, w: "Medium" }; - if (/light|thin/.test(s)) return { n: 300, w: "Light" }; - return { n: 400, w: "Regular" }; - }; - const styleOf = (name) => (/italic|oblique/i.test(name) ? "italic" : "normal"); const fams = [...new Set(brandFonts)]; - const srcDirs = [ - join(hyperframesDir, "capture/assets/fonts"), - join(hyperframesDir, "assets/fonts"), - ].filter((d) => existsSync(d)); + const captureDir = join(hyperframesDir, "capture/assets/fonts"); + const srcDirs = [captureDir, fontOutDir].filter((d) => existsSync(d)); const files = []; for (const d of srcDirs) - for (const f of readdirSync(d).sort()) if (extOf(f)) files.push({ d, f }); - // Single family → all font files belong to it (the common captured case, hash-named files - // included). Multiple families → assign each file to the longest family key its name contains. - const ranked = [...fams].sort((a, b) => norm(b).length - norm(a).length); - const famOf = (f) => - fams.length === 1 ? fams[0] : ranked.find((x) => norm(f).includes(norm(x))); - const outDir = join(hyperframesDir, "assets/fonts"); - const faces = []; - const stagedNames = new Set(); + for (const f of readdirSync(d).sort()) if (fontExtOf(f)) files.push({ d, f }); + // Capture files are loosely named (hash-named, or Google's "family-latin-400-normal"): a + // single captured family owns ALL of them, several are told apart by the longest family + // key the name contains. Files already in assets/fonts/ follow the staged-name contract + // and get neither shortcut — a previous run may have staged a PRESET's faces there, and a + // rerun after brand tokens arrive must not relabel EB Garamond's bytes as the brand font + // (not even for a brand named "Garamond"). + const ranked = rankFontFamilies(fams); + const famOf = (d, f) => { + if (d !== captureDir) return familyOfFaceFile(f, ranked); + if (fams.length === 1) return fams[0]; + return ranked.find((x) => normFontName(f).includes(x.key))?.family; + }; + const brandFaces = []; for (const { d, f } of files) { - const fam = famOf(f); + const fam = famOf(d, f); if (!fam) continue; - const { n, w } = weightInfo(f); - const style = styleOf(f); - const clean = `${fam.replace(/[^A-Za-z0-9]/g, "")}-${w}${style === "italic" ? "-Italic" : ""}.${extOf(f)}`; - if (stagedNames.has(clean)) continue; - mkdirSync(outDir, { recursive: true }); - if (!existsSync(join(outDir, clean))) copyFileSync(join(d, f), join(outDir, clean)); - stagedNames.add(clean); - faces.push( - `@font-face{font-family:"${fam}";font-weight:${n};font-style:${style};font-display:block;src:url("assets/fonts/${clean}") format("${FMT[extOf(f)]}");}`, - ); + const face = stageFontFile({ + family: fam, + srcPath: join(d, f), + outDir: fontOutDir, + stagedFaces: stagedFontFaces, + }); + if (face) brandFaces.push(face.rule); } - if (faces.length) { - md += - `\n\n## Font loading (auto-generated)\n\n` + - `The brand font ships as local files in \`assets/fonts/\` — do NOT link Google Fonts for it. ` + - `Paste this \`\n```\n"; - summary.push( - `fonts: staged ${stagedNames.size} face(s) → assets/fonts/ + @font-face in frame.md`, - ); + if (brandFaces.length) { + fontFaces.push(...brandFaces); + fontSources.push("the captured brand font"); + summary.push(`fonts: staged ${brandFaces.length} brand face(s) → assets/fonts/`); } } +const presetFonts = stagePresetFonts({ + presetFontsDir: join(presetDir, presetName, "fonts"), + outDir: fontOutDir, + families: fontFamiliesNamed(md), + stagedFaces: stagedFontFaces, +}); +if (presetFonts.staged.length) { + fontFaces.push(...presetFonts.faces); + fontSources.push(`the ${presetName} preset`); + summary.push( + `fonts: staged ${presetFonts.staged.length} preset face(s) → assets/fonts/` + + (presetFonts.licenses.length ? ` (+ ${presetFonts.licenses.join(", ")})` : ""), + ); +} +if (presetFonts.skipped.length) { + summary.push( + `fonts: skipped ${presetFonts.skipped.length} preset file(s) matching no family frame.md names: ${presetFonts.skipped.join(", ")}`, + ); +} +if (fontFaces.length) { + md += + `\n\n## Font loading (auto-generated)\n\n` + + `These faces ship as local files in \`assets/fonts/\` (from ${fontSources.join(" and ")}) — do NOT link Google Fonts for them. ` + + `Paste this \`\n```\n"; + summary.push(`fonts: @font-face block for ${fontFaces.length} face(s) appended to frame.md`); +} // ── write frame.md ──────────────────────────────────────────────────────────── const framePath = join(hyperframesDir, "frame.md"); diff --git a/skills/product-launch-video/scripts/captions.mjs b/skills/product-launch-video/scripts/captions.mjs index 8390686583..8d68a61aad 100644 --- a/skills/product-launch-video/scripts/captions.mjs +++ b/skills/product-launch-video/scripts/captions.mjs @@ -38,6 +38,14 @@ import { fileURLToPath } from "node:url"; import { parseStoryboard } from "./lib/storyboard.mjs"; import { captionBand, parseFormat } from "./lib/dimensions.mjs"; import { parseColors, parseFonts, semanticColors } from "./lib/tokens.mjs"; +import { + FONT_FORMAT, + fontExtOf, + fontStyleOf, + fontWeightInfo, + normFontName, + rankFontFamilies, +} from "./lib/font-faces.mjs"; const flag = (argv, name, def) => { const i = argv.indexOf(`--${name}`); @@ -300,58 +308,19 @@ function brandFontFaces(framePath, hyperframesDir) { { abs: join(hyperframesDir, "assets/fonts"), rel: "assets/fonts" }, { abs: join(hyperframesDir, "capture/assets/fonts"), rel: "capture/assets/fonts" }, ].filter((d) => existsSync(d.abs)); - const weightOf = (n) => { - const s = n.toLowerCase(); - // A numeric axis is the font's own answer, so it beats the word heuristic. Fontsource - // names every face this way ("inter-latin-500-normal.woff2") and carries no weight - // WORD at all, so word-only parsing collapsed a whole family onto 400 and shipped - // exactly one of its faces. - // - // A weight token must not be buried inside a longer run: capture/assets/fonts holds - // hash-named files, and "Newsreader-a1b200c3.woff2" is not a 200-weight face. Hence a - // non-digit before (which also stops "2100" reading as 100) and no alphanumeric after. - // "Roboto900.ttf" still parses — requiring separators on both sides would have lost it. - const numeric = /(?:^|[^0-9])([1-9]00)(?![0-9a-z])/.exec(s); - if (numeric) return Number(numeric[1]); - if (/black|heavy|ultra|extrabold/.test(s)) return 800; - if (/semibold|demibold/.test(s)) return 600; // before /bold/ — "demibold" contains "bold" - if (/bold/.test(s)) return 700; - if (/medium/.test(s)) return 500; - if (/light|thin/.test(s)) return 300; - return 400; // book / regular / roman - }; - // Weight is not the only axis in a filename. Google Fonts ships Newsreader as - // "Newsreader-Italic-VariableFont_opsz,wght.ttf" + "Newsreader-VariableFont_opsz,wght.ttf", - // and the italic sorts first — so without a style axis the italic file claimed the - // family's ONLY 400 slot, the upright file was dropped as a duplicate, and the face - // was declared with no `font-style`. @font-face is deliberately global (the composition - // CSS scoper exempts it, and it has to be), so the whole document then rendered that - // family in italics — captions italicizing every sibling composition. - const styleOf = (n) => (/italic|oblique/i.test(n) ? "italic" : "normal"); - const fmtOf = (f) => - /\.woff2$/i.test(f) - ? "woff2" - : /\.woff$/i.test(f) - ? "woff" - : /\.ttf$/i.test(f) - ? "truetype" - : "opentype"; - // Normalize away ALL non-alphanumerics (spaces, underscores, hyphens) on BOTH the - // family name and the filename. Real font files use "_" / "-" as word separators - // ("TT_Norms_Pro_Bold.woff2"), so stripping only whitespace never matched them — the - // family key "ttnormspro" failed `startsWith` against "tt_norms_pro_bold", and the - // function silently returned "" → captions shipped with NO @font-face for any - // underscore/hyphen-named brand font (e.g. TT Norms Pro), which is exactly the - // font_family_without_font_face bug. - const norm = (s) => s.toLowerCase().replace(/[^a-z0-9]/g, ""); + // Weight, style, format and the family-name key all come from lib/font-faces.mjs — the same + // helpers build-frame.mjs stages with — so a file staged under that naming contract parses + // back to the face it was staged as. Two axes matter: a numeric weight beats the word + // heuristic (Fontsource names carry no weight word), and style is read too, or the italic + // file of a weight would claim its only slot and @font-face (global by design) would + // italicise every sibling composition. The key strips ALL non-alphanumerics on both sides, + // since real files separate words with "_" / "-" ("TT_Norms_Pro_Bold.woff2"). const faces = []; const seen = new Set(); const claimed = new Set(); // each file is claimed by the MOST SPECIFIC family only // Match the longest family key first so "TT Norms Pro" can't swallow the files that // belong to "TT Norms Pro Mono" (its key is a prefix of the longer one's). - const ranked = [...families].sort((a, b) => norm(b).length - norm(a).length); - for (const fam of ranked) { - const key = norm(fam); + for (const { family: fam, key } of rankFontFamilies(families)) { for (const d of dirs) { let files = []; try { @@ -360,17 +329,18 @@ function brandFontFaces(framePath, hyperframesDir) { continue; } for (const f of files.sort()) { - if (!/\.(woff2|woff|ttf|otf)$/i.test(f)) continue; + const ext = fontExtOf(f); + if (!ext) continue; if (claimed.has(f)) continue; // a more specific family already took this file - if (!norm(f.replace(/\.(woff2|woff|ttf|otf)$/i, "")).startsWith(key)) continue; - const w = weightOf(f); - const style = styleOf(f); + if (!normFontName(f.slice(0, -(ext.length + 1))).startsWith(key)) continue; + const { n: w } = fontWeightInfo(f); + const style = fontStyleOf(f); const dedup = `${fam}-${w}-${style}`; if (seen.has(dedup)) continue; // one src per face; assets/fonts wins over capture seen.add(dedup); claimed.add(f); faces.push( - ` @font-face { font-family: '${fam}'; src: url('${d.rel}/${f}') format('${fmtOf(f)}'); font-weight: ${w}; font-style: ${style}; font-display: block; }`, + ` @font-face { font-family: '${fam}'; src: url('${d.rel}/${f}') format('${FONT_FORMAT[ext]}'); font-weight: ${w}; font-style: ${style}; font-display: block; }`, ); } } diff --git a/skills/product-launch-video/scripts/captions.test.mjs b/skills/product-launch-video/scripts/captions.test.mjs index 097623bd0d..489c1a5f4f 100644 --- a/skills/product-launch-video/scripts/captions.test.mjs +++ b/skills/product-launch-video/scripts/captions.test.mjs @@ -198,22 +198,34 @@ test("captions.mjs is byte-identical across the three workflows that ship it", ( test("every build-frame.mjs copy stages the style axis it promises", () => { for (const skill of ["product-launch-video", "faceless-explainer", "pr-to-video"]) { + // build-frame.mjs stages through lib/font-faces.mjs, so the naming contract lives there. const source = readFileSync( - new URL(`../../${skill}/scripts/build-frame.mjs`, import.meta.url), + new URL(`../../${skill}/scripts/lib/font-faces.mjs`, import.meta.url), "utf8", ); // The staged filename must carry the style, or the italic and upright faces of one // weight collide on a single name and only whichever sorts first survives. assert.match( source, - /const clean = `\$\{fam\.replace\(\/\[\^A-Za-z0-9\]\/g, ""\)\}-\$\{w\}\$\{style === "italic" \? "-Italic" : ""\}\./, - `${skill}/build-frame.mjs must keep the style token in the staged name`, + /const name = `\$\{family\.replace\(\/\[\^A-Za-z0-9\]\/g, ""\)\}-\$\{w\}\$\{style === "italic" \? "-Italic" : ""\}\./, + `${skill}/lib/font-faces.mjs must keep the style token in the staged name`, ); // ...and the emitted descriptor must report the real style, not a hardcoded normal. assert.doesNotMatch( source, /font-weight:\$\{n\};font-style:normal/, - `${skill}/build-frame.mjs must not assert font-style:normal over captured bytes`, + `${skill}/lib/font-faces.mjs must not assert font-style:normal over captured bytes`, + ); + // captions.mjs parses the staged names back; it must read them with the same helpers + // rather than a private copy that can drift from the staging side. + const captions = readFileSync( + new URL(`../../${skill}/scripts/captions.mjs`, import.meta.url), + "utf8", + ); + assert.match( + captions, + /import \{[^}]*\bfontWeightInfo\b[^}]*\} from "\.\/lib\/font-faces\.mjs"/, + `${skill}/captions.mjs must import its face helpers from lib/font-faces.mjs`, ); } }); diff --git a/skills/product-launch-video/scripts/lib/font-faces.mjs b/skills/product-launch-video/scripts/lib/font-faces.mjs new file mode 100644 index 0000000000..4935f4d641 --- /dev/null +++ b/skills/product-launch-video/scripts/lib/font-faces.mjs @@ -0,0 +1,162 @@ +// font-faces.mjs — staging font files into assets/fonts/ + the @font-face rule for each. +// Shared by build-frame.mjs's two font sources: captured BRAND font files and a preset's +// own fonts/ folder. +// +// The staged NAME is a contract, not cosmetics: captions.mjs derives each face's weight and +// style back out of it. So the name has to carry every axis that distinguishes one face +// from another, and the dedup key has to be the whole face. Naming on weight alone made +// Google's two-file Newsreader download (upright + italic, both scoring "Regular") collide +// on one slot: the italic sorts first, took the name, the upright was never staged, and +// the @font-face block then asserted font-style:normal over italic bytes. + +import { copyFileSync, existsSync, mkdirSync, readdirSync, statSync } from "node:fs"; +import { basename, join, resolve } from "node:path"; + +export const FONT_FORMAT = { woff2: "woff2", woff: "woff", ttf: "truetype", otf: "opentype" }; + +/** Family-name key: lowercase alphanumerics only, so "EB Garamond" ≡ "EBGaramond-400.woff2"'s stem. */ +export const normFontName = (s) => + String(s) + .toLowerCase() + .replace(/[^a-z0-9]/g, ""); + +/** Lowercase font extension of a filename, or "" when it is not a font file. */ +export const fontExtOf = (f) => (f.match(/\.(woff2|woff|ttf|otf)$/i)?.[1] ?? "").toLowerCase(); + +/** Weight of a face from its filename: `{ n: 700, w: "Bold" }` (n numeric, w the staged-name token). */ +export function fontWeightInfo(name) { + const s = name.toLowerCase(); + // A numeric axis is the font's own answer, so it beats the word heuristic. Fontsource + // names every face that way and carries no weight WORD at all, so word-only parsing + // scored a whole family "Regular" and staged exactly one of its faces. + // + // A weight token must not be buried inside a longer run: this reads capture files, + // which are commonly hash-named, and "Newsreader-a1b200c3.woff2" is not a 200-weight + // face. Hence a non-digit before (which also stops "2100" reading as 100) and no + // alphanumeric after. "Roboto900.ttf" still parses. + const numeric = /(?:^|[^0-9])([1-9]00)(?![0-9a-z])/.exec(s); + if (numeric) return { n: Number(numeric[1]), w: numeric[1] }; + if (/black|heavy|ultra|extrabold/.test(s)) return { n: 800, w: "ExtraBold" }; + if (/semibold|demibold/.test(s)) return { n: 600, w: "SemiBold" }; + if (/bold/.test(s)) return { n: 700, w: "Bold" }; + if (/medium/.test(s)) return { n: 500, w: "Medium" }; + if (/light|thin/.test(s)) return { n: 300, w: "Light" }; + return { n: 400, w: "Regular" }; +} + +export const fontStyleOf = (name) => (/italic|oblique/i.test(name) ? "italic" : "normal"); + +/** + * Families as `{ family, key }` ranked longest key first, so a filename is matched against + * "TT Norms Pro Mono" before "TT Norms Pro" can swallow it. Duplicates and families whose key + * is empty (nothing to match on) are dropped. + */ +export function rankFontFamilies(families) { + return [...new Set(families)] + .map((family) => ({ family, key: normFontName(family) })) + .filter((x) => x.key) + .sort((a, b) => b.key.length - a.key.length); +} + +/** Every family a frame spec names — `fontFamily: "X"` in its typography ramp. */ +export function fontFamiliesNamed(md) { + const out = new Set(); + for (const m of String(md).matchAll(/fontFamily:\s*"([^"]+)"/g)) out.add(m[1].trim()); + return [...out]; +} + +/** + * The family a face-named file belongs to: the longest-keyed entry of `ranked` (see + * `rankFontFamilies`) whose key the filename's stem STARTS with, or `undefined`. Staged files + * and preset files follow the `-[-Italic].` contract, so a prefix + * match is exact — a brand named "Garamond" must not claim the preset's "EBGaramond-400.woff2". + */ +export const familyOfFaceFile = (fileName, ranked) => { + const stem = fileName.replace(/\.[^.]+$/, ""); + return ranked.find((x) => normFontName(stem).startsWith(x.key))?.family; +}; + +/** + * Copy one font file into `outDir` under its clean face name and return + * `{ name, rule }` — the staged filename and its root-relative @font-face rule — or + * `null` when that face is already staged (first source wins) or the file is not a font. + * `stagedFaces` is keyed on the FACE (family + numeric weight + style), not the filename: + * "Inter-Regular.ttf" and "inter-latin-400-normal.woff2" name the same face and would + * otherwise both be staged with two contradictory @font-face rules for it. Refresh scope: a + * file already at the staged path wins by default (the capture source — a hand-placed file, + * or the previous run's own output, is never overwritten by a download); `refresh: true` is + * passed for the preset source only, so a face the preset stages is re-copied on every run + * (a skills update may ship re-subsetted files) unless the source already IS the staged file. + */ +export function stageFontFile({ family, srcPath, outDir, stagedFaces, refresh = false }) { + const file = basename(srcPath); + const ext = fontExtOf(file); + if (!ext) return null; + const { n, w } = fontWeightInfo(file); + const style = fontStyleOf(file); + const faceKey = `${family}|${n}|${style}`; + if (stagedFaces.has(faceKey)) return null; + const name = `${family.replace(/[^A-Za-z0-9]/g, "")}-${w}${style === "italic" ? "-Italic" : ""}.${ext}`; + mkdirSync(outDir, { recursive: true }); + const dest = join(outDir, name); + if ((refresh || !existsSync(dest)) && resolve(srcPath) !== resolve(dest)) + copyFileSync(srcPath, dest); + stagedFaces.add(faceKey); + return { + name, + rule: `@font-face{font-family:"${family}";font-weight:${n};font-style:${style};font-display:block;src:url("assets/fonts/${name}") format("${FONT_FORMAT[ext]}");}`, + }; +} + +const LICENSE_FILE = /^(?:OFL|LICEN[CS]E|COPYING)\b/i; + +/** + * Stage a preset's own fonts/ folder. Each font file is assigned to the LONGEST family in + * `families` (the families the frame spec names) whose key its filename starts with; a file + * matching none is reported in `skipped` rather than staged — after a brand remix the + * preset's original family may no longer appear in frame.md, and its bytes would be dead + * weight. License texts shipped beside the fonts (OFL-*.txt, LICENSE…) travel with any + * staged face. Returns `{ faces, staged, skipped, licenses }`; a preset without a fonts/ + * folder yields all-empty arrays. + */ +export function stagePresetFonts({ presetFontsDir, outDir, families, stagedFaces }) { + const result = { faces: [], staged: [], skipped: [], licenses: [] }; + if (!existsSync(presetFontsDir)) return result; + const ranked = rankFontFamilies(families); + // Only plain files (symlinks resolved): a subdirectory named like a font would otherwise + // reach copyFileSync and throw EISDIR, and a dangling symlink would throw ENOENT on stat. + const isFile = (f) => { + try { + return statSync(join(presetFontsDir, f)).isFile(); + } catch { + return false; + } + }; + const entries = readdirSync(presetFontsDir).filter(isFile).sort(); + for (const f of entries) { + if (!fontExtOf(f)) continue; + const family = familyOfFaceFile(f, ranked); + if (!family) { + result.skipped.push(f); + continue; + } + const face = stageFontFile({ + family, + srcPath: join(presetFontsDir, f), + outDir, + stagedFaces, + refresh: true, + }); + if (!face) continue; + result.faces.push(face.rule); + result.staged.push(face.name); + } + if (result.staged.length) { + for (const f of entries) { + if (fontExtOf(f) || !LICENSE_FILE.test(f)) continue; + if (!existsSync(join(outDir, f))) copyFileSync(join(presetFontsDir, f), join(outDir, f)); + result.licenses.push(f); + } + } + return result; +}