Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions packages/cli/bin/hyperframes-localize-fonts.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
#!/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 {
const { main } = await import("../dist/fontLocalizeCli.js");
process.exitCode = await main();
}
3 changes: 2 additions & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
109 changes: 109 additions & 0 deletions packages/cli/src/fontLocalize.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { describe, expect, it, vi } from "vitest";
import { runFontLocalize, stampFontVersions, 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("<html>source</html>");
const localize = vi.fn(async () => "<html>localized</html>");

const exitCode = await runFontLocalize(harness.io, localize);

expect(exitCode).toBe(0);
expect(localize).toHaveBeenCalledWith("<html>source</html>");
expect(harness.output).toEqual(["<html>localized</html>"]);
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 = '<html><img src="https://signed.example/secret"></html>';
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("<html>");
});

it("fails closed when the resolver returns an empty document", async () => {
const harness = makeIo("<html>source</html>");

const exitCode = await runFontLocalize(harness.io, async () => "\n");

expect(exitCode).toBe(1);
expect(harness.output).toEqual([]);
expect(harness.errors.join(" ")).toContain("empty output");
});
});

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>",
{ 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>',
);
});
});
64 changes: 64 additions & 0 deletions packages/cli/src/fontLocalize.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
export interface FontLocalizeIo {
readInput(): Promise<string>;
writeOutput(value: string): void;
writeError(value: string): void;
}

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)}${tags}${html.slice(headClose)}`;
const doctype = /^\s*<!doctype[^>]*>/i.exec(html);
if (!doctype) return `${tags}${html}`;
const insertAt = doctype.index + doctype[0].length;
return `${html.slice(0, insertAt)}${tags}${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";
}

/**
* 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<string>,
): Promise<number> {
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;
}
}
33 changes: 33 additions & 0 deletions packages/cli/src/fontLocalizeCli.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// fallow-ignore-file unused-file
import { injectDeterministicFontFaces } from "@hyperframes/producer";
import { runFontLocalize, stampFontVersions } from "./fontLocalize.js";
import { PRODUCER_VERSION, VERSION } from "./version.js";

async function readStdin(): Promise<string> {
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");
}

/** Standalone-entry main; the bin wrapper owns the actual process exit code. */
export async function main(): Promise<number> {
return runFontLocalize(
{
readInput: readStdin,
writeOutput: (value) => process.stdout.write(value),
writeError: (value) => process.stderr.write(value),
},
async (html) => {
const localized = await injectDeterministicFontFaces(html, {
failClosedFontFetch: true,
allowSystemFontCapture: false,
});
return stampFontVersions(localized, {
producer: PRODUCER_VERSION,
localizer: VERSION,
});
},
);
}
3 changes: 3 additions & 0 deletions packages/cli/src/version.ts
Original file line number Diff line number Diff line change
@@ -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";
5 changes: 5 additions & 0 deletions packages/cli/tsup.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,14 @@ 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: {
cli: "src/cli.ts",
fontLocalizeCli: "src/fontLocalizeCli.ts",
runtimeVersion: "src/runtimeVersion.ts",
shaderTransitionWorker: "../producer/src/services/shaderTransitionWorker.ts",
},
Expand Down Expand Up @@ -79,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 = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,42 +1,84 @@
import { describe, expect, it } from "bun:test";
import { injectDeterministicFontFaces } from "./deterministicFonts.js";

async function requestedGoogleFontUrl(html: string): Promise<URL> {
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(
`<!doctype html><html><head><style>
h1 { font-family: "Noto Performance Test", sans-serif; }
</style></head><body><h1>旅行ランキング</h1></body></html>`,
{ fetchImpl, allowSystemFontCapture: false },
);

const url = new URL(requestedUrl);
const text = url.searchParams.get("text") ?? "";
for (const character of new Set("旅行ランキング")) {
expect(text).toContain(character);
}
});

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(
`<!doctype html><html><head><style>
h1 { font-family: "Noto Performance Test", sans-serif; }
</style></head><body><h1>&#x65C5;&#34892;</h1></body></html>`,
{ 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 () => {
const url = await requestedGoogleFontUrl(
`<!doctype html><html><head><style>
h1 { font-family: "Inter", sans-serif; font-weight: 800; text-transform: uppercase; }
</style></head><body><h1>Your Kidney Transplant:<br/>What Happens Next</h1></body></html>`,
);

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),
)
.filter((character) => character.toUpperCase() !== character.toLowerCase())
.slice(0, 300)
.join("");

const url = await requestedGoogleFontUrl(
`<!doctype html><html><head><style>
p { font-family: "Inter", sans-serif; font-weight: 800; text-transform: uppercase; }
</style></head><body><p>${caseChangingCharacters}</p></body></html>`,
);

expect(url.searchParams.has("text")).toBe(false);
});
});
Loading
Loading