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; +}