fix(fonts): harden localizer release diagnostics

This commit is contained in:
Miguel Ángel
2026-08-26 03:02:24 +00:00
parent d6de083411
commit 7c40efbc62
8 changed files with 104 additions and 15 deletions
+28 -5
View File
@@ -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(
"<!doctype html><html><head><title>x</title></head><body></body></html>",
"0.8.15",
{ producer: "0.8.15", localizer: "0.8.16" },
);
expect(stamped).toContain('<meta name="hyperframes-font-compiler-version" content="0.8.15">');
expect(stamped).toContain('<meta name="hyperframes-font-localizer-version" content="0.8.16">');
expect(stamped.indexOf("hyperframes-font-compiler-version")).toBeLessThan(
stamped.indexOf("</head>"),
);
});
it("inserts both diagnostic stamps after a doctype when no head close exists", () => {
const stamped = stampFontVersions("<!doctype html><main>x</main>", {
producer: "0.8.15",
localizer: "0.8.16",
});
expect(stamped).toMatch(
/^<!doctype html><meta name="hyperframes-font-compiler-version" content="0\.8\.15"><meta name="hyperframes-font-localizer-version" content="0\.8\.16">/,
);
});
it("inserts both diagnostic stamps at the start when no head or doctype exists", () => {
const stamped = stampFontVersions("<main>x</main>", {
producer: "0.8.15<script>",
localizer: "0.8.16<script>",
});
expect(stamped).toBe(
'<meta name="hyperframes-font-compiler-version" content="0.8.15script"><meta name="hyperframes-font-localizer-version" content="0.8.16script"><main>x</main>',
);
});
});
+20 -6
View File
@@ -4,15 +4,29 @@ 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 = `<meta name="hyperframes-font-compiler-version" content="${safeVersion}">`;
export interface FontVersions {
producer: string;
localizer: string;
}
function safeVersion(version: string): string {
return version.replace(/[^A-Za-z0-9.+-]/g, "") || "unknown";
}
/**
* Add post-hoc diagnostics for the producer resolver and the CLI wrapper that ran it.
* These stamps are traceability metadata, not an enforcement mechanism.
*/
export function stampFontVersions(html: string, versions: FontVersions): string {
const tags =
`<meta name="hyperframes-font-compiler-version" content="${safeVersion(versions.producer)}">` +
`<meta name="hyperframes-font-localizer-version" content="${safeVersion(versions.localizer)}">`;
const headClose = html.search(/<\/head\s*>/i);
if (headClose >= 0) return `${html.slice(0, headClose)}${tag}${html.slice(headClose)}`;
if (headClose >= 0) return `${html.slice(0, headClose)}${tags}${html.slice(headClose)}`;
const doctype = /^\s*<!doctype[^>]*>/i.exec(html);
if (!doctype) return `${tag}${html}`;
if (!doctype) return `${tags}${html}`;
const insertAt = doctype.index + doctype[0].length;
return `${html.slice(0, insertAt)}${tag}${html.slice(insertAt)}`;
return `${html.slice(0, insertAt)}${tags}${html.slice(insertAt)}`;
}
function safeErrorName(error: unknown): string {
+6 -3
View File
@@ -1,7 +1,7 @@
// fallow-ignore-file unused-file
import { injectDeterministicFontFaces } from "@hyperframes/producer";
import { runFontLocalize, stampFontCompilerVersion } from "./fontLocalize.js";
import { VERSION } from "./version.js";
import { runFontLocalize, stampFontVersions } from "./fontLocalize.js";
import { PRODUCER_VERSION, VERSION } from "./version.js";
async function readStdin(): Promise<string> {
const chunks: Buffer[] = [];
@@ -24,7 +24,10 @@ export async function main(): Promise<number> {
failClosedFontFetch: true,
allowSystemFontCapture: false,
});
return stampFontCompilerVersion(localized, VERSION);
return stampFontVersions(localized, {
producer: PRODUCER_VERSION,
localizer: VERSION,
});
},
);
}
+3
View File
@@ -1,2 +1,5 @@
declare const __CLI_VERSION__: string | undefined;
declare const __PRODUCER_VERSION__: string | undefined;
export const VERSION = typeof __CLI_VERSION__ !== "undefined" ? __CLI_VERSION__ : "0.0.0-dev";
export const PRODUCER_VERSION =
typeof __PRODUCER_VERSION__ !== "undefined" ? __PRODUCER_VERSION__ : "0.0.0-dev";
+4
View File
@@ -6,6 +6,9 @@ import { sourceAliases } from "../../scripts/package-subpaths.mjs";
const pkg = JSON.parse(readFileSync(new URL("./package.json", import.meta.url), "utf-8")) as {
version: string;
};
const producerPkg = JSON.parse(
readFileSync(new URL("../producer/package.json", import.meta.url), "utf-8"),
) as { version: string };
export default defineConfig({
entry: {
@@ -80,6 +83,7 @@ var __dirname = __hf_dirname(__filename);`,
],
define: {
__CLI_VERSION__: JSON.stringify(pkg.version),
__PRODUCER_VERSION__: JSON.stringify(producerPkg.version),
},
esbuildOptions(options) {
options.alias = {
@@ -198,6 +198,31 @@ describe("injectDeterministicFontFaces — failClosedFontFetch: true", () => {
expect((caught as FontFetchError).code).toBe(FONT_FETCH_FAILED);
});
it("fails closed when a secondary family in the authored cascade is unresolved", async () => {
const html = `<!doctype html><html><head><style>
body { font-family: "Inter", "Author Custom Fallback", sans-serif; }
</style></head><body><p>hello</p></body></html>`;
const fetchImpl = (async (input: string | URL | Request) => {
const family = new URL(input instanceof Request ? input.url : String(input)).searchParams.get(
"family",
);
return family?.startsWith("Inter:")
? new Response("/* bundled Inter is sufficient */", { status: 200 })
: new Response("", { status: 400 });
}) as unknown as typeof fetch;
const caught = await rejectedError(
injectDeterministicFontFaces(html, {
failClosedFontFetch: true,
allowSystemFontCapture: false,
fetchImpl,
}),
);
expect(caught).toBeInstanceOf(FontFetchError);
expect((caught as FontFetchError).familyName).toContain("Author Custom Fallback");
});
it("throws FontFetchUnavailableError on an exhausted 5xx response", async () => {
const caught = await rejectedError(
injectDeterministicFontFaces(HTML_REQUESTING_UNRESOLVED_FONT, {
@@ -47,11 +47,24 @@ describe("Google Fonts text subsetting", () => {
);
const text = url.searchParams.get("text") ?? "";
expect(encodeURIComponent(text).length).toBeLessThan(700);
for (const character of new Set("YOUR KIDNEY TRANSPLANT:WHAT HAPPENS NEXT")) {
expect(text).toContain(character);
}
});
it("covers capitalized words through the same case closure", async () => {
const url = await requestedGoogleFontUrl(
`<!doctype html><html><head><style>
h1 { font-family: "Inter", sans-serif; font-weight: 800; text-transform: capitalize; }
</style></head><body><h1>hello world</h1></body></html>`,
);
const text = url.searchParams.get("text") ?? "";
expect(text).toContain("H");
expect(text).toContain("W");
});
it("falls back to the full font when case closure exceeds the text URL budget", async () => {
const caseChangingCharacters = Array.from({ length: 0x500 }, (_, index) =>
String.fromCodePoint(index),
@@ -1201,11 +1201,15 @@ const GOOGLE_FONTS_TEXT_MAX_ENCODED_LENGTH = 1_700;
function extractGoogleFontsText(html: string): string | undefined {
const { document } = parseHTML(html);
const decodedBodyText = document.body?.textContent ?? "";
// Source + decoded text is an intentional over-approximation: base64, scripts, and class names
// collapse in the Set, while decoded entities contribute the glyphs the browser actually paints.
const characters = [...Array.from(html), ...Array.from(decodedBodyText)];
const uniqueCharacters = new Set<string>();
for (const character of characters) {
uniqueCharacters.add(character);
// CSS text-transform can render glyphs absent from the authored source.
// This closes locale-independent Unicode casing, including multi-code-point expansions such as
// ß -> SS. Locale/context transforms (for example Turkish İ) and CSS full-width/full-size-kana
// need a transform-aware follow-up rather than pretending this code-point closure is exhaustive.
for (const variant of `${character.toUpperCase()}${character.toLowerCase()}`) {
uniqueCharacters.add(variant);
}