From 744146eb6e71f3c108091fe981a7d63859a6287b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 25 Aug 2026 23:57:07 +0000 Subject: [PATCH 1/5] fix(fonts): cover rendered case variants in subsets --- .../deterministicFonts-textSubset.test.ts | 43 +++++++++++++++++++ .../src/services/deterministicFonts.ts | 22 +++++++--- 2 files changed, 58 insertions(+), 7 deletions(-) diff --git a/packages/producer/src/services/deterministicFonts-textSubset.test.ts b/packages/producer/src/services/deterministicFonts-textSubset.test.ts index e3b0a39cd7..ec3552e763 100644 --- a/packages/producer/src/services/deterministicFonts-textSubset.test.ts +++ b/packages/producer/src/services/deterministicFonts-textSubset.test.ts @@ -39,4 +39,47 @@ describe("Google Fonts text subsetting", () => { expect(new URL(requestedUrl).searchParams.get("text")).toContain("旅行"); }); + + it("includes case variants for transformed supplemental alias weights", async () => { + let requestedUrl = ""; + const fetchImpl = (async (input: unknown) => { + requestedUrl = String(input); + return new Response("", { status: 400 }); + }) as unknown as typeof fetch; + + await injectDeterministicFontFaces( + `

Your Kidney Transplant:
What Happens Next

`, + { fetchImpl, allowSystemFontCapture: false }, + ); + + const text = new URL(requestedUrl).searchParams.get("text") ?? ""; + for (const character of new Set("YOUR KIDNEY TRANSPLANT:WHAT HAPPENS NEXT")) { + expect(text).toContain(character); + } + }); + + it("falls back to the full font when case closure exceeds the text URL budget", async () => { + let requestedUrl = ""; + const fetchImpl = (async (input: unknown) => { + requestedUrl = String(input); + return new Response("", { status: 400 }); + }) as unknown as typeof fetch; + const caseChangingCharacters = Array.from({ length: 0x500 }, (_, index) => + String.fromCodePoint(index), + ) + .filter((character) => character.toUpperCase() !== character.toLowerCase()) + .slice(0, 300) + .join(""); + + await injectDeterministicFontFaces( + `

${caseChangingCharacters}

`, + { fetchImpl, allowSystemFontCapture: false }, + ); + + expect(new URL(requestedUrl).searchParams.has("text")).toBe(false); + }); }); diff --git a/packages/producer/src/services/deterministicFonts.ts b/packages/producer/src/services/deterministicFonts.ts index 0a20486de8..158bc681c0 100644 --- a/packages/producer/src/services/deterministicFonts.ts +++ b/packages/producer/src/services/deterministicFonts.ts @@ -1193,18 +1193,26 @@ export interface InjectDeterministicFontFacesOptions { } // Keep the complete CSS request under the broadly supported ~2 KB URL limit. -// Using unique source characters covers static text plus strings authored in -// scripts, while collapsing repeated prose and base64 assets to a tiny set. +// Using unique source/decoded characters plus deterministic case variants covers +// static text, strings authored in scripts, and CSS case transforms while +// collapsing repeated prose and base64 assets to a tiny set. const GOOGLE_FONTS_TEXT_MAX_ENCODED_LENGTH = 1_700; function extractGoogleFontsText(html: string): string | undefined { const { document } = parseHTML(html); const decodedBodyText = document.body?.textContent ?? ""; - const uniqueCharacters = [...new Set([...Array.from(html), ...Array.from(decodedBodyText)])].join( - "", - ); - return encodeURIComponent(uniqueCharacters).length <= GOOGLE_FONTS_TEXT_MAX_ENCODED_LENGTH - ? uniqueCharacters + const characters = [...Array.from(html), ...Array.from(decodedBodyText)]; + const uniqueCharacters = new Set(); + for (const character of characters) { + uniqueCharacters.add(character); + // CSS text-transform can render glyphs absent from the authored source. + for (const variant of `${character.toUpperCase()}${character.toLowerCase()}`) { + uniqueCharacters.add(variant); + } + } + const fontText = [...uniqueCharacters].join(""); + return encodeURIComponent(fontText).length <= GOOGLE_FONTS_TEXT_MAX_ENCODED_LENGTH + ? fontText : undefined; } From 38f8f9250a3f2a551a2e0a5f685ac4f64109e218 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 26 Aug 2026 00:01:01 +0000 Subject: [PATCH 2/5] feat(cli): expose deterministic font localization --- .../cli/bin/hyperframes-localize-fonts.mjs | 11 +++ packages/cli/package.json | 3 +- packages/cli/src/fontLocalize.test.ts | 72 +++++++++++++++++++ packages/cli/src/fontLocalize.ts | 39 ++++++++++ packages/cli/src/fontLocalizeCli.ts | 23 ++++++ packages/cli/tsup.config.ts | 1 + 6 files changed, 148 insertions(+), 1 deletion(-) create mode 100644 packages/cli/bin/hyperframes-localize-fonts.mjs create mode 100644 packages/cli/src/fontLocalize.test.ts create mode 100644 packages/cli/src/fontLocalize.ts create mode 100644 packages/cli/src/fontLocalizeCli.ts diff --git a/packages/cli/bin/hyperframes-localize-fonts.mjs b/packages/cli/bin/hyperframes-localize-fonts.mjs new file mode 100644 index 0000000000..7c341a2af3 --- /dev/null +++ b/packages/cli/bin/hyperframes-localize-fonts.mjs @@ -0,0 +1,11 @@ +#!/usr/bin/env node + +import { runtimeVersionError } from "../dist/runtimeVersion.js"; + +const error = runtimeVersionError(process.versions.node); +if (error) { + console.error(error); + process.exitCode = 1; +} else { + await import("../dist/fontLocalizeCli.js"); +} diff --git a/packages/cli/package.json b/packages/cli/package.json index 0caee8ea6b..00281dfb13 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -9,7 +9,8 @@ "directory": "packages/cli" }, "bin": { - "hyperframes": "./bin/hyperframes.mjs" + "hyperframes": "./bin/hyperframes.mjs", + "hyperframes-localize-fonts": "./bin/hyperframes-localize-fonts.mjs" }, "files": [ "bin", diff --git a/packages/cli/src/fontLocalize.test.ts b/packages/cli/src/fontLocalize.test.ts new file mode 100644 index 0000000000..43398d51c6 --- /dev/null +++ b/packages/cli/src/fontLocalize.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it, vi } from "vitest"; +import { runFontLocalize, type FontLocalizeIo } from "./fontLocalize.js"; + +function makeIo(input: string): { + io: FontLocalizeIo; + output: string[]; + errors: string[]; +} { + const output: string[] = []; + const errors: string[] = []; + return { + io: { + readInput: async () => input, + writeOutput: (value) => output.push(value), + writeError: (value) => errors.push(value), + }, + output, + errors, + }; +} + +describe("runFontLocalize", () => { + it("writes only the localized document to stdout", async () => { + const harness = makeIo("source"); + const localize = vi.fn(async () => "localized"); + + const exitCode = await runFontLocalize(harness.io, localize); + + expect(exitCode).toBe(0); + expect(localize).toHaveBeenCalledWith("source"); + expect(harness.output).toEqual(["localized"]); + expect(harness.errors).toEqual([]); + }); + + it("rejects blank input without calling the resolver", async () => { + const harness = makeIo(" \n"); + const localize = vi.fn(async (html: string) => html); + + const exitCode = await runFontLocalize(harness.io, localize); + + expect(exitCode).toBe(2); + expect(localize).not.toHaveBeenCalled(); + expect(harness.output).toEqual([]); + expect(harness.errors.join(" ")).toContain("input is empty"); + }); + + it("fails without echoing source HTML or resolver details", async () => { + const source = ''; + const harness = makeIo(source); + const localize = vi.fn(async () => { + throw new Error(`fetch failed for ${source}`); + }); + + const exitCode = await runFontLocalize(harness.io, localize); + + expect(exitCode).toBe(1); + expect(harness.output).toEqual([]); + expect(harness.errors.join(" ")).toContain("font localization failed (Error)"); + expect(harness.errors.join(" ")).not.toContain("signed.example"); + expect(harness.errors.join(" ")).not.toContain(""); + }); + + it("fails closed when the resolver returns an empty document", async () => { + const harness = makeIo("source"); + + const exitCode = await runFontLocalize(harness.io, async () => "\n"); + + expect(exitCode).toBe(1); + expect(harness.output).toEqual([]); + expect(harness.errors.join(" ")).toContain("empty output"); + }); +}); diff --git a/packages/cli/src/fontLocalize.ts b/packages/cli/src/fontLocalize.ts new file mode 100644 index 0000000000..4e01d71191 --- /dev/null +++ b/packages/cli/src/fontLocalize.ts @@ -0,0 +1,39 @@ +export interface FontLocalizeIo { + readInput(): Promise; + writeOutput(value: string): void; + writeError(value: string): void; +} + +function safeErrorName(error: unknown): string { + const name = error instanceof Error ? error.name : "UnknownError"; + return /^[A-Za-z][A-Za-z0-9]*$/.test(name) ? name : "Error"; +} + +/** + * Machine-only stdin/stdout boundary for deterministic font localization. + * Source HTML and resolver messages can contain signed URLs, so failures emit + * only a fixed category plus a sanitized error class. + */ +export async function runFontLocalize( + io: FontLocalizeIo, + localize: (html: string) => Promise, +): Promise { + const html = await io.readInput(); + if (!html.trim()) { + io.writeError("font localization input is empty\n"); + return 2; + } + + try { + const localized = await localize(html); + if (!localized.trim()) { + io.writeError("font localization failed (Error): empty output\n"); + return 1; + } + io.writeOutput(localized); + return 0; + } catch (error) { + io.writeError(`font localization failed (${safeErrorName(error)})\n`); + return 1; + } +} diff --git a/packages/cli/src/fontLocalizeCli.ts b/packages/cli/src/fontLocalizeCli.ts new file mode 100644 index 0000000000..e44b93a73f --- /dev/null +++ b/packages/cli/src/fontLocalizeCli.ts @@ -0,0 +1,23 @@ +import { injectDeterministicFontFaces } from "@hyperframes/producer"; +import { runFontLocalize } from "./fontLocalize.js"; + +async function readStdin(): Promise { + const chunks: Buffer[] = []; + for await (const chunk of process.stdin) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + return Buffer.concat(chunks).toString("utf8"); +} + +process.exitCode = await runFontLocalize( + { + readInput: readStdin, + writeOutput: (value) => process.stdout.write(value), + writeError: (value) => process.stderr.write(value), + }, + (html) => + injectDeterministicFontFaces(html, { + failClosedFontFetch: true, + allowSystemFontCapture: false, + }), +); diff --git a/packages/cli/tsup.config.ts b/packages/cli/tsup.config.ts index 4537e304e7..1e387b39e6 100644 --- a/packages/cli/tsup.config.ts +++ b/packages/cli/tsup.config.ts @@ -10,6 +10,7 @@ const pkg = JSON.parse(readFileSync(new URL("./package.json", import.meta.url), export default defineConfig({ entry: { cli: "src/cli.ts", + fontLocalizeCli: "src/fontLocalizeCli.ts", runtimeVersion: "src/runtimeVersion.ts", shaderTransitionWorker: "../producer/src/services/shaderTransitionWorker.ts", }, From ec68d40cc617272943069123383e7a9f6e463e93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 26 Aug 2026 00:08:00 +0000 Subject: [PATCH 3/5] fix(cli): keep font localizer process ownership explicit --- .../cli/bin/hyperframes-localize-fonts.mjs | 3 +- packages/cli/src/fontLocalizeCli.ts | 28 ++++++---- .../deterministicFonts-textSubset.test.ts | 56 +++++++------------ 3 files changed, 39 insertions(+), 48 deletions(-) diff --git a/packages/cli/bin/hyperframes-localize-fonts.mjs b/packages/cli/bin/hyperframes-localize-fonts.mjs index 7c341a2af3..0992603056 100644 --- a/packages/cli/bin/hyperframes-localize-fonts.mjs +++ b/packages/cli/bin/hyperframes-localize-fonts.mjs @@ -7,5 +7,6 @@ if (error) { console.error(error); process.exitCode = 1; } else { - await import("../dist/fontLocalizeCli.js"); + const { main } = await import("../dist/fontLocalizeCli.js"); + process.exitCode = await main(); } diff --git a/packages/cli/src/fontLocalizeCli.ts b/packages/cli/src/fontLocalizeCli.ts index e44b93a73f..eb6bef8aa7 100644 --- a/packages/cli/src/fontLocalizeCli.ts +++ b/packages/cli/src/fontLocalizeCli.ts @@ -1,3 +1,4 @@ +// fallow-ignore-file unused-file import { injectDeterministicFontFaces } from "@hyperframes/producer"; import { runFontLocalize } from "./fontLocalize.js"; @@ -9,15 +10,18 @@ async function readStdin(): Promise { return Buffer.concat(chunks).toString("utf8"); } -process.exitCode = await runFontLocalize( - { - readInput: readStdin, - writeOutput: (value) => process.stdout.write(value), - writeError: (value) => process.stderr.write(value), - }, - (html) => - injectDeterministicFontFaces(html, { - failClosedFontFetch: true, - allowSystemFontCapture: false, - }), -); +/** Standalone-entry main; the bin wrapper owns the actual process exit code. */ +export async function main(): Promise { + return runFontLocalize( + { + readInput: readStdin, + writeOutput: (value) => process.stdout.write(value), + writeError: (value) => process.stderr.write(value), + }, + (html) => + injectDeterministicFontFaces(html, { + failClosedFontFetch: true, + allowSystemFontCapture: false, + }), + ); +} diff --git a/packages/producer/src/services/deterministicFonts-textSubset.test.ts b/packages/producer/src/services/deterministicFonts-textSubset.test.ts index ec3552e763..d9285ea0d8 100644 --- a/packages/producer/src/services/deterministicFonts-textSubset.test.ts +++ b/packages/producer/src/services/deterministicFonts-textSubset.test.ts @@ -1,22 +1,28 @@ import { describe, expect, it } from "bun:test"; import { injectDeterministicFontFaces } from "./deterministicFonts.js"; +async function requestedGoogleFontUrl(html: string): Promise { + let requestedUrl = ""; + const fetchImpl = (async (input: unknown) => { + requestedUrl = String(input); + return new Response("", { status: 400 }); + }) as unknown as typeof fetch; + + await injectDeterministicFontFaces(html, { + fetchImpl, + allowSystemFontCapture: false, + }); + return new URL(requestedUrl); +} + describe("Google Fonts text subsetting", () => { it("sends the composition character set to the CSS API", async () => { - let requestedUrl = ""; - const fetchImpl = (async (input: unknown) => { - requestedUrl = String(input); - return new Response("", { status: 400 }); - }) as unknown as typeof fetch; - - await injectDeterministicFontFaces( + const url = await requestedGoogleFontUrl( `

旅行ランキング

`, - { fetchImpl, allowSystemFontCapture: false }, ); - const url = new URL(requestedUrl); const text = url.searchParams.get("text") ?? ""; for (const character of new Set("旅行ランキング")) { expect(text).toContain(character); @@ -24,48 +30,29 @@ describe("Google Fonts text subsetting", () => { }); it("includes decoded HTML entities from visible composition text", async () => { - let requestedUrl = ""; - const fetchImpl = (async (input: unknown) => { - requestedUrl = String(input); - return new Response("", { status: 400 }); - }) as unknown as typeof fetch; - - await injectDeterministicFontFaces( + const url = await requestedGoogleFontUrl( `

旅行

`, - { fetchImpl, allowSystemFontCapture: false }, ); - expect(new URL(requestedUrl).searchParams.get("text")).toContain("旅行"); + expect(url.searchParams.get("text")).toContain("旅行"); }); it("includes case variants for transformed supplemental alias weights", async () => { - let requestedUrl = ""; - const fetchImpl = (async (input: unknown) => { - requestedUrl = String(input); - return new Response("", { status: 400 }); - }) as unknown as typeof fetch; - - await injectDeterministicFontFaces( + const url = await requestedGoogleFontUrl( `

Your Kidney Transplant:
What Happens Next

`, - { fetchImpl, allowSystemFontCapture: false }, ); - const text = new URL(requestedUrl).searchParams.get("text") ?? ""; + const text = url.searchParams.get("text") ?? ""; for (const character of new Set("YOUR KIDNEY TRANSPLANT:WHAT HAPPENS NEXT")) { expect(text).toContain(character); } }); it("falls back to the full font when case closure exceeds the text URL budget", async () => { - let requestedUrl = ""; - const fetchImpl = (async (input: unknown) => { - requestedUrl = String(input); - return new Response("", { status: 400 }); - }) as unknown as typeof fetch; const caseChangingCharacters = Array.from({ length: 0x500 }, (_, index) => String.fromCodePoint(index), ) @@ -73,13 +60,12 @@ describe("Google Fonts text subsetting", () => { .slice(0, 300) .join(""); - await injectDeterministicFontFaces( + const url = await requestedGoogleFontUrl( `

${caseChangingCharacters}

`, - { fetchImpl, allowSystemFontCapture: false }, ); - expect(new URL(requestedUrl).searchParams.has("text")).toBe(false); + expect(url.searchParams.has("text")).toBe(false); }); }); From d6de083411d76a0819768b21d30a305500a92048 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 26 Aug 2026 00:23:16 +0000 Subject: [PATCH 4/5] feat(cli): stamp font compiler version in localized HTML --- packages/cli/src/fontLocalize.test.ts | 16 +++++++++++++++- packages/cli/src/fontLocalize.ts | 11 +++++++++++ packages/cli/src/fontLocalizeCli.ts | 11 +++++++---- 3 files changed, 33 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/fontLocalize.test.ts b/packages/cli/src/fontLocalize.test.ts index 43398d51c6..9637b2b0ea 100644 --- a/packages/cli/src/fontLocalize.test.ts +++ b/packages/cli/src/fontLocalize.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import { runFontLocalize, type FontLocalizeIo } from "./fontLocalize.js"; +import { runFontLocalize, stampFontCompilerVersion, type FontLocalizeIo } from "./fontLocalize.js"; function makeIo(input: string): { io: FontLocalizeIo; @@ -70,3 +70,17 @@ describe("runFontLocalize", () => { expect(harness.errors.join(" ")).toContain("empty output"); }); }); + +describe("stampFontCompilerVersion", () => { + it("records the compiler version inside the document head", () => { + const stamped = stampFontCompilerVersion( + "x", + "0.8.15", + ); + + expect(stamped).toContain(''); + expect(stamped.indexOf("hyperframes-font-compiler-version")).toBeLessThan( + stamped.indexOf(""), + ); + }); +}); diff --git a/packages/cli/src/fontLocalize.ts b/packages/cli/src/fontLocalize.ts index 4e01d71191..5dab4bdfed 100644 --- a/packages/cli/src/fontLocalize.ts +++ b/packages/cli/src/fontLocalize.ts @@ -4,6 +4,17 @@ export interface FontLocalizeIo { writeError(value: string): void; } +export function stampFontCompilerVersion(html: string, version: string): string { + const safeVersion = version.replace(/[^A-Za-z0-9.+-]/g, "") || "unknown"; + const tag = ``; + const headClose = html.search(/<\/head\s*>/i); + if (headClose >= 0) return `${html.slice(0, headClose)}${tag}${html.slice(headClose)}`; + const doctype = /^\s*]*>/i.exec(html); + if (!doctype) return `${tag}${html}`; + const insertAt = doctype.index + doctype[0].length; + return `${html.slice(0, insertAt)}${tag}${html.slice(insertAt)}`; +} + function safeErrorName(error: unknown): string { const name = error instanceof Error ? error.name : "UnknownError"; return /^[A-Za-z][A-Za-z0-9]*$/.test(name) ? name : "Error"; diff --git a/packages/cli/src/fontLocalizeCli.ts b/packages/cli/src/fontLocalizeCli.ts index eb6bef8aa7..50a7d3f20d 100644 --- a/packages/cli/src/fontLocalizeCli.ts +++ b/packages/cli/src/fontLocalizeCli.ts @@ -1,6 +1,7 @@ // fallow-ignore-file unused-file import { injectDeterministicFontFaces } from "@hyperframes/producer"; -import { runFontLocalize } from "./fontLocalize.js"; +import { runFontLocalize, stampFontCompilerVersion } from "./fontLocalize.js"; +import { VERSION } from "./version.js"; async function readStdin(): Promise { const chunks: Buffer[] = []; @@ -18,10 +19,12 @@ export async function main(): Promise { writeOutput: (value) => process.stdout.write(value), writeError: (value) => process.stderr.write(value), }, - (html) => - injectDeterministicFontFaces(html, { + async (html) => { + const localized = await injectDeterministicFontFaces(html, { failClosedFontFetch: true, allowSystemFontCapture: false, - }), + }); + return stampFontCompilerVersion(localized, VERSION); + }, ); } From 7c40efbc62bd7bd70bcfdb80cd55aef4bafb39d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 26 Aug 2026 03:02:24 +0000 Subject: [PATCH 5/5] fix(fonts): harden localizer release diagnostics --- packages/cli/src/fontLocalize.test.ts | 33 ++++++++++++++++--- packages/cli/src/fontLocalize.ts | 26 +++++++++++---- packages/cli/src/fontLocalizeCli.ts | 9 +++-- packages/cli/src/version.ts | 3 ++ packages/cli/tsup.config.ts | 4 +++ .../deterministicFonts-failClosed.test.ts | 25 ++++++++++++++ .../deterministicFonts-textSubset.test.ts | 13 ++++++++ .../src/services/deterministicFonts.ts | 6 +++- 8 files changed, 104 insertions(+), 15 deletions(-) diff --git a/packages/cli/src/fontLocalize.test.ts b/packages/cli/src/fontLocalize.test.ts index 9637b2b0ea..86a9d23844 100644 --- a/packages/cli/src/fontLocalize.test.ts +++ b/packages/cli/src/fontLocalize.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import { runFontLocalize, stampFontCompilerVersion, type FontLocalizeIo } from "./fontLocalize.js"; +import { runFontLocalize, stampFontVersions, type FontLocalizeIo } from "./fontLocalize.js"; function makeIo(input: string): { io: FontLocalizeIo; @@ -71,16 +71,39 @@ describe("runFontLocalize", () => { }); }); -describe("stampFontCompilerVersion", () => { - it("records the compiler version inside the document head", () => { - const stamped = stampFontCompilerVersion( +describe("stampFontVersions", () => { + it("records producer and localizer versions inside the document head", () => { + const stamped = stampFontVersions( "x", - "0.8.15", + { producer: "0.8.15", localizer: "0.8.16" }, ); expect(stamped).toContain(''); + expect(stamped).toContain(''); expect(stamped.indexOf("hyperframes-font-compiler-version")).toBeLessThan( stamped.indexOf(""), ); }); + + it("inserts both diagnostic stamps after a doctype when no head close exists", () => { + const stamped = stampFontVersions("
x
", { + producer: "0.8.15", + localizer: "0.8.16", + }); + + expect(stamped).toMatch( + /^/, + ); + }); + + it("inserts both diagnostic stamps at the start when no head or doctype exists", () => { + const stamped = stampFontVersions("
x
", { + producer: "0.8.15