diff --git a/packages/cli/bin/hyperframes-localize-fonts.mjs b/packages/cli/bin/hyperframes-localize-fonts.mjs
new file mode 100644
index 0000000000..0992603056
--- /dev/null
+++ b/packages/cli/bin/hyperframes-localize-fonts.mjs
@@ -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();
+}
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..86a9d23844
--- /dev/null
+++ b/packages/cli/src/fontLocalize.test.ts
@@ -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("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");
+ });
+});
+
+describe("stampFontVersions", () => {
+ it("records producer and localizer versions inside the document head", () => {
+ const stamped = stampFontVersions(
+ "