feat(i18n): 21-language pipeline, landing/docs/API wiring, landing+API translations

Shared Claude Code translation pipeline (scripts/i18n, no API key) plus Astro/VitePress/Scalar i18n wiring. Landing and API reference translated into all 20 languages; docs i18n wiring + English source anchors. The translated docs markdown (apps/docs/<locale>/**, 3,620 files) follows in a companion PR because it exceeds GitHub's per-PR CI file limit.
This commit is contained in:
SnapOtter
2026-07-11 13:01:55 +08:00
committed by GitHub
parent 2e91368816
commit 00b651c9f8
353 changed files with 564607 additions and 2594 deletions
+174
View File
@@ -0,0 +1,174 @@
// tests/unit/scripts/i18n/api-spec.test.ts
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import yaml from "js-yaml";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { makeApiSpecAdapter } from "../../../../scripts/i18n/adapters/api-spec.mjs";
import { hash } from "../../../../scripts/i18n/lib/hash.mjs";
// A tiny OpenAPI 3.1 document exercising every translatable prose family:
// info.description, a tag description, and an op summary + description.
// A schema description and a parameter name are present to prove they are NOT extracted.
const FIXTURE = {
openapi: "3.1.0",
info: { title: "Test API", version: "1.0.0", description: "Root prose to translate." },
tags: [{ name: "Tools", description: "Tag prose to translate." }],
paths: {
"/api/v1/tools/image/resize": {
post: {
operationId: "resizeImage",
tags: ["Tools"],
summary: "Resize",
description: "Resize an image to specific dimensions.",
parameters: [{ name: "width", in: "query", schema: { type: "integer" } }],
responses: { "200": { description: "Processed image (stays English)." } },
},
},
},
components: {
schemas: {
ToolResponse: {
type: "object",
properties: { jobId: { type: "string", description: "Schema prose stays English." } },
},
},
},
};
let dir: string;
let specPath: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "api-spec-i18n-"));
specPath = join(dir, "openapi.yaml");
writeFileSync(specPath, yaml.dump(FIXTURE, { lineWidth: -1 }), "utf8");
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
describe("api-spec adapter extract", () => {
it("yields exactly the prose fields with stable pointer ids", async () => {
const adapter = makeApiSpecAdapter({ dir });
const units = await adapter.extract();
const byId = new Map(units.map((u) => [u.id, u]));
expect([...byId.keys()].sort()).toEqual(
[
"info.description",
"paths./api/v1/tools/image/resize.post.description",
"paths./api/v1/tools/image/resize.post.summary",
"tags.Tools.description",
].sort(),
);
expect(byId.get("info.description")?.sourceText).toBe("Root prose to translate.");
expect(byId.get("tags.Tools.description")?.sourceText).toBe("Tag prose to translate.");
expect(byId.get("paths./api/v1/tools/image/resize.post.summary")?.sourceText).toBe("Resize");
expect(byId.get("paths./api/v1/tools/image/resize.post.description")?.sourceText).toBe(
"Resize an image to specific dimensions.",
);
});
it("does not extract schema, parameter, response, or operationId text", async () => {
const adapter = makeApiSpecAdapter({ dir });
const ids = (await adapter.extract()).map((u) => u.id);
expect(ids.some((id) => id.includes("responses"))).toBe(false);
expect(ids.some((id) => id.includes("parameters"))).toBe(false);
expect(ids.some((id) => id.includes("schemas"))).toBe(false);
expect(ids.some((id) => id.includes("operationId"))).toBe(false);
});
it("marks every unit as text kind (spec prose, not markdown structure)", async () => {
const adapter = makeApiSpecAdapter({ dir });
for (const unit of await adapter.extract()) {
expect(unit.kind).toBe("text");
}
});
});
describe("api-spec adapter write/load round-trip", () => {
it("writes a locale spec that is English with only prose fields replaced", async () => {
const adapter = makeApiSpecAdapter({ dir });
const entries = new Map([
[
"paths./api/v1/tools/image/resize.post.summary",
{
text: "Groesse aendern",
sourceHash: hash("Resize"),
provenance: "machine" as const,
outputHash: hash("Groesse aendern"),
},
],
]);
await adapter.write("de", entries);
// biome-ignore lint/suspicious/noExplicitAny: parsed YAML fixture assertions
const written = yaml.load(readFileSync(join(dir, "openapi.de.yaml"), "utf8")) as any;
// Translated prose replaced.
expect(written.paths["/api/v1/tools/image/resize"].post.summary).toBe("Groesse aendern");
// Untranslated prose falls back to English (we only supplied one entry).
expect(written.info.description).toBe("Root prose to translate.");
// Schemas, parameters, responses, operationId are byte-for-byte English.
expect(written.components.schemas.ToolResponse.properties.jobId.description).toBe(
"Schema prose stays English.",
);
expect(written.paths["/api/v1/tools/image/resize"].post.parameters[0].name).toBe("width");
expect(written.paths["/api/v1/tools/image/resize"].post.responses["200"].description).toBe(
"Processed image (stays English).",
);
expect(written.paths["/api/v1/tools/image/resize"].post.operationId).toBe("resizeImage");
});
it("stamps an x-i18n map with the source and output hashes", async () => {
const adapter = makeApiSpecAdapter({ dir });
const entry = {
text: "Groesse aendern",
sourceHash: hash("Resize"),
provenance: "machine" as const,
outputHash: hash("Groesse aendern"),
};
await adapter.write("de", new Map([["paths./api/v1/tools/image/resize.post.summary", entry]]));
// biome-ignore lint/suspicious/noExplicitAny: parsed YAML fixture assertions
const written = yaml.load(readFileSync(join(dir, "openapi.de.yaml"), "utf8")) as any;
expect(written["x-i18n"].locale).toBe("de");
const stamped = written["x-i18n"].entries["paths./api/v1/tools/image/resize.post.summary"];
expect(stamped.sourceHash).toBe(hash("Resize"));
expect(stamped.outputHash).toBe(hash("Groesse aendern"));
expect(stamped.provenance).toBe("machine");
});
it("load() reconstructs the same StoredEntries write() persisted", async () => {
const adapter = makeApiSpecAdapter({ dir });
const entries = new Map([
[
"tags.Tools.description",
{
text: "Werkzeuge",
sourceHash: hash("Tag prose to translate."),
provenance: "human" as const,
outputHash: hash("Werkzeuge"),
stale: true,
},
],
]);
await adapter.write("de", entries);
const loaded = await adapter.load("de");
const got = loaded.get("tags.Tools.description");
expect(got?.text).toBe("Werkzeuge");
expect(got?.sourceHash).toBe(hash("Tag prose to translate."));
expect(got?.provenance).toBe("human");
expect(got?.outputHash).toBe(hash("Werkzeuge"));
expect(got?.stale).toBe(true);
});
it("load() returns an empty Map when the locale spec is absent", async () => {
const adapter = makeApiSpecAdapter({ dir });
const loaded = await adapter.load("fr");
expect(loaded.size).toBe(0);
});
});
+53
View File
@@ -0,0 +1,53 @@
// tests/unit/scripts/i18n/batch.test.ts
import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import {
batchResultTranslator,
readDone,
writePending,
} from "../../../../scripts/i18n/lib/batch.mjs";
describe("batch handoff", () => {
it("writes pending units as masked {id, masked} pairs", () => {
const dir = mkdtempSync(join(tmpdir(), "i18n-batch-"));
const path = writePending(dir, "docs", "de", [
{ unit: { id: "a", sourceText: "Run `x` for {n}" }, srcHash: "h" },
]);
const parsed = JSON.parse(readFileSync(path, "utf8"));
expect(parsed[0].id).toBe("a");
expect(parsed[0].masked).not.toContain("`x`"); // masked
expect(parsed[0].masked).not.toContain("{n}");
});
it("batchResultTranslator restores tokens from the done map", async () => {
const dir = mkdtempSync(join(tmpdir(), "i18n-batch-"));
// Simulate a subagent producing a done file that keeps the mask tokens.
// Build the expected masked form by masking the same source the translator will mask.
const units = [{ id: "a", sourceText: "Run `x` for {n}" }];
// The subagent returns the translated masked text; here we fake "DE " prefix.
writePending(
dir,
"docs",
"de",
units.map((u) => ({ unit: u, srcHash: "h" })),
);
const pending = JSON.parse(readFileSync(join(dir, "docs.de.pending.json"), "utf8"));
const done = { a: `DE ${pending[0].masked}` };
writeFileSync(join(dir, "docs.de.done.json"), JSON.stringify(done));
const doneMap = readDone(dir, "docs", "de");
const translate = batchResultTranslator(doneMap);
const out = await translate(units, "de");
expect(out.get("a")).toContain("`x`"); // restored
expect(out.get("a")).toContain("{n}");
expect(out.get("a")).toContain("DE ");
});
it("batchResultTranslator returns null for a missing id", async () => {
const translate = batchResultTranslator(new Map());
const out = await translate([{ id: "z", sourceText: "hi" }], "de");
expect(out.get("z")).toBeNull();
});
});
@@ -0,0 +1,78 @@
// tests/unit/scripts/i18n/check-parity.test.ts
import { describe, expect, it } from "vitest";
import { checkAdapter } from "../../../../scripts/i18n/check-parity.mjs";
import { hash } from "../../../../scripts/i18n/lib/hash.mjs";
import { makeFakeAdapter } from "./fake-adapter";
// Seed a locale store on the fake adapter with a full, in-sync translation,
// so checkAdapter has something complete to validate against.
// biome-ignore lint/suspicious/noExplicitAny: fake in-memory adapter, untyped by design
async function seedComplete(adapter: any, locale: string) {
const units = await adapter.extract();
// biome-ignore lint/suspicious/noExplicitAny: stored entry shape mirrors core.mjs StoredEntry
const entries = new Map<string, any>();
for (const u of units) {
const sourceHash = hash(u.sourceText);
const text = `${locale}:${u.sourceText}`;
entries.set(u.id, {
text,
sourceHash,
provenance: "machine",
outputHash: hash(text),
stale: false,
});
}
await adapter.write(locale, entries);
}
describe("checkAdapter", () => {
it("passes when every locale has every unit with a matching source hash", async () => {
const adapter = makeFakeAdapter([
{ id: "a", sourceText: "hi" },
{ id: "b", sourceText: "bye" },
]);
await seedComplete(adapter, "de");
await seedComplete(adapter, "fr");
const report = await checkAdapter(adapter, ["de", "fr"]);
expect(report.ok).toBe(true);
expect(report.problems).toEqual([]);
});
it("fails and reports the unit when a locale is missing a translation", async () => {
const adapter = makeFakeAdapter([
{ id: "a", sourceText: "hi" },
{ id: "b", sourceText: "bye" },
]);
await seedComplete(adapter, "de");
// Drop unit "b" from the German store to simulate an untranslated unit.
// biome-ignore lint/suspicious/noExplicitAny: fake store is untyped by design
const de = adapter._store.get("de") as Map<string, any>;
de.delete("b");
const report = await checkAdapter(adapter, ["de"]);
expect(report.ok).toBe(false);
const joined = report.problems.join(" ");
expect(joined).toMatch(/de/);
expect(joined).toMatch(/\bb\b/);
expect(joined).toMatch(/missing/i);
});
it("fails when a stored source hash is stale (English moved under the translation)", async () => {
const adapter = makeFakeAdapter([{ id: "a", sourceText: "hi" }]);
await seedComplete(adapter, "de");
// Corrupt the stored sourceHash so it no longer matches the English source.
// biome-ignore lint/suspicious/noExplicitAny: fake store is untyped by design
const de = adapter._store.get("de") as Map<string, any>;
de.get("a").sourceHash = "deadbeefdead";
const report = await checkAdapter(adapter, ["de"]);
expect(report.ok).toBe(false);
expect(report.problems.join(" ")).toMatch(/stale/i);
});
it("skips en and treats it as the source, never a target", async () => {
const adapter = makeFakeAdapter([{ id: "a", sourceText: "hi" }]);
await seedComplete(adapter, "de");
// en is never seeded; it must be ignored even if passed in.
const report = await checkAdapter(adapter, ["en", "de"]);
expect(report.ok).toBe(true);
});
});
+21
View File
@@ -0,0 +1,21 @@
// tests/unit/scripts/i18n/claude.test.ts
import { describe, expect, it, vi } from "vitest";
import { makeTranslator } from "../../../../scripts/i18n/lib/claude.mjs";
describe("makeTranslator", () => {
it("masks input, sends one call per unit, restores tokens, returns id->text", async () => {
const send = vi.fn(async ({ text }) => `DE(${text})`);
const translate = makeTranslator({ send });
const units = [
{ id: "a", sourceText: "Hello `code`", kind: "markdown" },
{ id: "b", sourceText: "See {n} files", kind: "markdown" },
];
const out = await translate(units, "de");
// Tokens were restored, so the code span and placeholder survive verbatim.
expect(out.get("a")).toContain("`code`");
expect(out.get("b")).toContain("{n}");
expect(send).toHaveBeenCalledTimes(2);
// The masked text sent to the model must NOT contain the raw code span.
expect(send.mock.calls[0][0].text).not.toContain("`code`");
});
});
+101
View File
@@ -0,0 +1,101 @@
// tests/unit/scripts/i18n/core.test.ts
import { describe, expect, it, vi } from "vitest";
import { collectPending, runTranslation } from "../../../../scripts/i18n/core.mjs";
import { hash } from "../../../../scripts/i18n/lib/hash.mjs";
import { makeFakeAdapter } from "./fake-adapter";
const echo = (units: any[], locale: string) =>
Promise.resolve(new Map(units.map((u) => [u.id, `${locale}:${u.sourceText}`])));
describe("runTranslation", () => {
it("translates all units on a cold run", async () => {
const adapter = makeFakeAdapter([{ id: "a", sourceText: "hi" }]);
const summary = await runTranslation({ adapter, locales: ["de"], translate: echo });
expect(summary.de.translated).toBe(1);
expect((adapter._store.get("de") as Map<string, any>).get("a").text).toBe("de:hi");
});
it("skips unchanged units on a second run (hash-gating)", async () => {
const adapter = makeFakeAdapter([{ id: "a", sourceText: "hi" }]);
const translate = vi.fn(echo);
await runTranslation({ adapter, locales: ["de"], translate });
translate.mockClear();
const summary = await runTranslation({ adapter, locales: ["de"], translate });
expect(translate).not.toHaveBeenCalled();
expect(summary.de.skipped).toBe(1);
});
it("re-translates a machine unit when its source changes", async () => {
const units = [{ id: "a", sourceText: "hi" }];
const adapter = makeFakeAdapter(units);
await runTranslation({ adapter, locales: ["de"], translate: echo });
units[0].sourceText = "hello";
const summary = await runTranslation({ adapter, locales: ["de"], translate: echo });
expect(summary.de.translated).toBe(1);
expect((adapter._store.get("de") as Map<string, any>).get("a").text).toBe("de:hello");
});
it("marks a human-edited unit stale instead of overwriting when source changes", async () => {
const units = [{ id: "a", sourceText: "hi" }];
const adapter = makeFakeAdapter(units);
await runTranslation({ adapter, locales: ["de"], translate: echo });
// Simulate a human refining the translation in the store.
const entry = (adapter._store.get("de") as Map<string, any>).get("a");
entry.text = "menschlich";
// Source then changes upstream.
units[0].sourceText = "hello";
const summary = await runTranslation({ adapter, locales: ["de"], translate: echo });
expect(summary.de.stale).toBe(1);
expect(summary.de.translated).toBe(0);
expect((adapter._store.get("de") as Map<string, any>).get("a").text).toBe("menschlich");
});
it("records a failed unit and keeps prior text when validation fails", async () => {
const adapter = makeFakeAdapter([{ id: "a", sourceText: "keep `code` here" }]);
// Translator drops the code span -> validator rejects.
const bad = (units: any[], locale: string) =>
Promise.resolve(new Map(units.map((u) => [u.id, `${locale} no code`])));
const summary = await runTranslation({ adapter, locales: ["de"], translate: bad });
expect(summary.de.failed).toBe(1);
expect(summary.de.translated).toBe(0);
});
});
describe("collectPending", () => {
it("marks a new unit pending", () => {
const units = [{ id: "a", sourceText: "hi" }];
const { pending, merged, stats } = collectPending(units, new Map());
expect(pending.map((p) => p.unit.id)).toEqual(["a"]);
expect(stats).toMatchObject({ skipped: 0, stale: 0 });
expect(merged.has("a")).toBe(false);
});
it("skips a unit whose stored hash matches", () => {
const units = [{ id: "a", sourceText: "hi" }];
const stored = new Map([
["a", { text: "de", sourceHash: hash("hi"), provenance: "machine", outputHash: hash("de") }],
]);
const { pending, stats } = collectPending(units, stored);
expect(pending).toEqual([]);
expect(stats.skipped).toBe(1);
});
it("marks a human-edited unit stale when source changed, not pending", () => {
const units = [{ id: "a", sourceText: "hello" }];
const stored = new Map([
[
"a",
{
text: "menschlich",
sourceHash: hash("hi"),
provenance: "machine",
outputHash: hash("de"),
},
],
]);
const { pending, merged, stats } = collectPending(units, stored);
expect(pending).toEqual([]);
expect(stats.stale).toBe(1);
expect(merged.get("a")).toMatchObject({ provenance: "human", stale: true, text: "menschlich" });
});
});
+167
View File
@@ -0,0 +1,167 @@
// tests/unit/scripts/i18n/docs-md.test.ts
import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { beforeEach, describe, expect, it } from "vitest";
import { createDocsAdapter } from "../../../../scripts/i18n/adapters/docs-md.mjs";
let root: string;
async function seed(rel: string, body: string) {
const abs = join(root, rel);
await mkdir(join(abs, ".."), { recursive: true });
await writeFile(abs, body, "utf8");
}
beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), "docs-i18n-"));
});
describe("docs-md adapter", () => {
it("extracts one unit per root markdown file, keyed by relative path", async () => {
await seed(
"guide/getting-started.md",
"---\ndescription: Install it\n---\n# Getting Started\n\nHi.",
);
await seed("index.md", "---\nlayout: page\n---\n# Home\n");
const adapter = createDocsAdapter({ root });
const units = await adapter.extract();
const ids = units.map((u) => u.id).sort();
expect(ids).toEqual(["guide/getting-started.md", "index.md"]);
expect(units[0].kind).toBe("markdown");
});
it("injects stable explicit anchors into English headings, idempotently", async () => {
await seed("guide/x.md", "# Getting Started\n\n## Quick Start\n\n## Already {#pinned}\n");
const adapter = createDocsAdapter({ root });
await adapter.extract();
const onDisk = await readFile(join(root, "guide/x.md"), "utf8");
expect(onDisk).toContain("# Getting Started {#getting-started}");
expect(onDisk).toContain("## Quick Start {#quick-start}");
expect(onDisk).toContain("## Already {#pinned}"); // untouched
// Running extract again does not double-anchor.
await adapter.extract();
const again = await readFile(join(root, "guide/x.md"), "utf8");
expect(again).toBe(onDisk);
});
it("de-duplicates repeated heading slugs like markdown-it-anchor (-1, -2)", async () => {
await seed(
"guide/z.md",
"## Example Request\n\ntext\n\n## Example Request\n\nmore\n\n## Example Request\n",
);
const adapter = createDocsAdapter({ root });
await adapter.extract();
const onDisk = await readFile(join(root, "guide/z.md"), "utf8");
expect(onDisk).toContain("## Example Request {#example-request}");
expect(onDisk).toContain("## Example Request {#example-request-1}");
expect(onDisk).toContain("## Example Request {#example-request-2}");
});
it("does not treat fenced # lines as headings", async () => {
await seed("guide/y.md", "# Real Heading\n\n```bash\n# not a heading\n```\n");
const adapter = createDocsAdapter({ root });
await adapter.extract();
const onDisk = await readFile(join(root, "guide/y.md"), "utf8");
expect(onDisk).toContain("# Real Heading {#real-heading}");
expect(onDisk).toContain("# not a heading\n"); // still inside the fence, no anchor
expect(onDisk).not.toContain("# not a heading {#");
});
it("write persists translated body under the locale path with the source hash", async () => {
await seed(
"guide/x.md",
"---\ndescription: Install it\n---\n# Getting Started {#getting-started}\n\nHi.",
);
const adapter = createDocsAdapter({ root });
const units = await adapter.extract();
const entry = {
text: units[0].sourceText.replace("Hi.", "Hallo."),
sourceHash: "abc123def456",
provenance: "machine" as const,
outputHash: "0".repeat(12),
stale: false,
};
await adapter.write("de", new Map([[units[0].id, entry]]));
const out = await readFile(join(root, "de/guide/x.md"), "utf8");
expect(out).toContain("i18n_source_hash: abc123def456");
expect(out).toContain("{#getting-started}"); // anchor restored intact
expect(out).not.toContain("i18n_fallback");
});
it("write rewrites internal links to the locale path", async () => {
await seed("guide/x.md", "# X {#x}\n");
const adapter = createDocsAdapter({ root });
const units = await adapter.extract();
const entry = {
text: `${units[0].sourceText}\n\nSee [Config](/guide/configuration#embedded-mode) and [ext](https://example.test/guide/x).`,
sourceHash: "hash00000001",
provenance: "machine" as const,
outputHash: "0".repeat(12),
stale: false,
};
await adapter.write("de", new Map([[units[0].id, entry]]));
const out = await readFile(join(root, "de/guide/x.md"), "utf8");
expect(out).toContain("[Config](/de/guide/configuration#embedded-mode)");
expect(out).toContain("[ext](https://example.test/guide/x)"); // external untouched
});
it("load reads back a StoredEntry from a translated file's frontmatter", async () => {
await seed("guide/x.md", "# X {#x}\n");
await mkdir(join(root, "de/guide"), { recursive: true });
await writeFile(
join(root, "de/guide/x.md"),
"---\ni18n_source_hash: abc123def456\ni18n_provenance: human\n---\n# Iks {#x}\n",
"utf8",
);
const adapter = createDocsAdapter({ root });
const stored = await adapter.load("de");
const e = stored.get("guide/x.md");
expect(e?.sourceHash).toBe("abc123def456");
expect(e?.provenance).toBe("human");
expect(e?.text).toContain("# Iks {#x}");
});
it("writeFallback emits an English copy flagged for a missing translation", async () => {
await seed("guide/x.md", "---\ndescription: Install it\n---\n# X {#x}\n\nBody.");
const adapter = createDocsAdapter({ root });
await adapter.extract();
await adapter.writeFallback("de", "guide/x.md");
const out = await readFile(join(root, "de/guide/x.md"), "utf8");
expect(out).toContain("i18n_fallback: true");
expect(out).toContain("# X {#x}"); // English content preserved
});
it("docs pre-mask hides ::: markers and [[toc]] but keeps the container title translatable", async () => {
await seed("guide/x.md", "# X {#x}\n\n::: tip Try before installing\nBody.\n:::\n\n[[toc]]\n");
const adapter = createDocsAdapter({ root });
const units = await adapter.extract();
const src = units[0].sourceText;
expect(src).not.toContain(":::");
expect(src).not.toContain("[[toc]]");
expect(src).toContain("Try before installing"); // title label stays for the model
});
});
import { quoteFrontmatterScalars } from "../../../../scripts/i18n/adapters/docs-md.mjs";
describe("quoteFrontmatterScalars", () => {
it("quotes a bare description containing a colon", () => {
const out = quoteFrontmatterScalars(
"---\ndescription: Formats: over 55 inputs\ni18n_source_hash: abc\n---\nBody",
);
expect(out).toContain('description: "Formats: over 55 inputs"');
expect(out).toContain("i18n_source_hash: abc");
expect(out).toContain("Body");
});
it("leaves already-quoted and non-target values untouched", () => {
const src = '---\ntitle: "Already quoted: x"\ni18n_source_hash: abc\n---\nBody';
expect(quoteFrontmatterScalars(src)).toBe(src);
});
it("escapes embedded double quotes", () => {
const out = quoteFrontmatterScalars('---\ndescription: say "hi" now\n---\nB');
expect(out).toContain('description: "say \\"hi\\" now"');
});
});
+18
View File
@@ -0,0 +1,18 @@
// tests/unit/scripts/i18n/fake-adapter.ts
// In-memory adapter matching the contract in scripts/i18n/adapter-contract.md.
export function makeFakeAdapter(units: Array<{ id: string; sourceText: string; kind?: string }>) {
const store = new Map<string, Map<string, any>>(); // locale -> (id -> entry)
return {
name: "fake",
async extract() {
return units.map((u) => ({ kind: "markdown", ...u }));
},
async load(locale: string) {
return new Map(store.get(locale) ?? new Map());
},
async write(locale: string, entries: Map<string, any>) {
store.set(locale, new Map(entries));
},
_store: store,
};
}
+22
View File
@@ -0,0 +1,22 @@
// tests/unit/scripts/i18n/glossary.test.ts
import { describe, expect, it } from "vitest";
import { buildSystemPrompt, DO_NOT_TRANSLATE } from "../../../../scripts/i18n/lib/glossary.mjs";
describe("glossary", () => {
it("keeps the product name and format terms untranslated", () => {
expect(DO_NOT_TRANSLATE).toContain("SnapOtter");
expect(DO_NOT_TRANSLATE).toContain("WebP");
});
it("builds a system prompt naming the target language and the rules", () => {
const prompt = buildSystemPrompt("de");
expect(prompt).toContain("German");
expect(prompt).toContain("SnapOtter");
expect(prompt).toMatch(/do not translate|keep.*unchanged/i);
expect(prompt).toMatch(/⸤I18N/); // must warn about the token markers
});
it("throws on an unknown locale so typos fail loudly", () => {
expect(() => buildSystemPrompt("xx")).toThrow();
});
});
+18
View File
@@ -0,0 +1,18 @@
// tests/unit/scripts/i18n/hash.test.ts
import { describe, expect, it } from "vitest";
import { hash } from "../../../../scripts/i18n/lib/hash.mjs";
describe("hash", () => {
it("is stable and 12 hex chars", () => {
expect(hash("hello world")).toMatch(/^[0-9a-f]{12}$/);
expect(hash("hello world")).toBe(hash("hello world"));
});
it("changes when source changes", () => {
expect(hash("a")).not.toBe(hash("b"));
});
it("normalizes CRLF to LF so line-ending churn does not re-translate", () => {
expect(hash("line1\r\nline2")).toBe(hash("line1\nline2"));
});
});
+106
View File
@@ -0,0 +1,106 @@
// tests/unit/scripts/i18n/landing-seo.test.ts
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { makeLandingSeoAdapter } from "../../../../scripts/i18n/adapters/landing-seo.mjs";
import { hash } from "../../../../scripts/i18n/lib/hash.mjs";
let dir: string;
const ALTERNATIVES = [
{
slug: "smallpdf",
pageTitle: "Alternative to Smallpdf",
h1: "The alternative to Smallpdf",
metaDescription: "Self-hosted PDF tools.",
intro: "Smallpdf is hosted. SnapOtter is self-hosted.",
breadth: "One stack, five file types.",
rows: [{ feature: "Deployment", snapotter: "Self-hosted", competitor: "Hosted" }],
faqs: [{ q: "Is it free?", a: "Yes, AGPLv3." }],
},
];
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), "landing-seo-"));
});
afterEach(async () => {
await rm(dir, { recursive: true, force: true });
});
function make() {
return makeLandingSeoAdapter({
dir,
alternatives: ALTERNATIVES,
});
}
describe("landing-seo adapter", () => {
it("extracts only alternatives prose units with stable indexed ids", async () => {
const units = await make().extract();
const ids = units.map((u) => u.id);
expect(ids).toContain("alt:smallpdf:h1");
expect(ids).toContain("alt:smallpdf:metaDescription");
expect(ids).toContain("alt:smallpdf:rows.0.snapotter");
expect(ids).toContain("alt:smallpdf:faqs.0.q");
// Tool-detail pages are English-only, so no seo:* units are extracted.
expect(ids.some((id) => id.startsWith("seo:"))).toBe(false);
});
it("write then load round-trips StoredEntry with inline _sourceHash", async () => {
const adapter = make();
const entries = new Map([
[
"alt:smallpdf:h1",
{
text: "Die Alternative zu Smallpdf",
sourceHash: hash("The alternative to Smallpdf"),
provenance: "machine",
outputHash: hash("Die Alternative zu Smallpdf"),
stale: false,
},
],
]);
await adapter.write("de", entries);
const file = JSON.parse(await readFile(join(dir, "alternatives.de.json"), "utf8"));
expect(file["alt:smallpdf:h1"].text).toBe("Die Alternative zu Smallpdf");
expect(file["alt:smallpdf:h1"]._sourceHash).toBe(hash("The alternative to Smallpdf"));
expect(file["alt:smallpdf:h1"].provenance).toBe("machine");
const loaded = await adapter.load("de");
expect(loaded.get("alt:smallpdf:h1")).toEqual({
text: "Die Alternative zu Smallpdf",
sourceHash: hash("The alternative to Smallpdf"),
provenance: "machine",
outputHash: hash("Die Alternative zu Smallpdf"),
stale: false,
});
});
it("writes alt: ids to alternatives.<locale>.json and never a tool-seo file", async () => {
const adapter = make();
await adapter.write(
"de",
new Map([
[
"alt:smallpdf:intro",
{
text: "Smallpdf ist gehostet. SnapOtter ist selbst gehostet.",
sourceHash: hash("Smallpdf is hosted. SnapOtter is self-hosted."),
provenance: "machine",
outputHash: hash("Smallpdf ist gehostet. SnapOtter ist selbst gehostet."),
stale: false,
},
],
]),
);
const alt = JSON.parse(await readFile(join(dir, "alternatives.de.json"), "utf8"));
expect(alt["alt:smallpdf:intro"].text).toBe(
"Smallpdf ist gehostet. SnapOtter ist selbst gehostet.",
);
// No tool-seo file is ever written now that tool-detail pages are English-only.
await expect(readFile(join(dir, "tool-seo.de.json"), "utf8")).rejects.toThrow();
});
});
@@ -0,0 +1,71 @@
// tests/unit/scripts/i18n/landing-ui.test.ts
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { makeLandingUiAdapter } from "../../../../scripts/i18n/adapters/landing-ui.mjs";
import { hash } from "../../../../scripts/i18n/lib/hash.mjs";
let dir: string;
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), "landing-ui-"));
await writeFile(
join(dir, "en.json"),
JSON.stringify({ "nav.pricing": "Pricing", "nav.docs": "Docs" }, null, 2),
);
});
afterEach(async () => {
await rm(dir, { recursive: true, force: true });
});
describe("landing-ui adapter", () => {
it("extracts one text unit per English key", async () => {
const adapter = makeLandingUiAdapter({ dir });
const units = await adapter.extract();
expect(units).toHaveLength(2);
const byId = new Map(units.map((u) => [u.id, u]));
expect(byId.get("nav.pricing")).toEqual({
id: "nav.pricing",
sourceText: "Pricing",
kind: "text",
});
});
it("write then load round-trips a StoredEntry with inline hashes", async () => {
const adapter = makeLandingUiAdapter({ dir });
const entries = new Map([
[
"nav.pricing",
{
text: "Preise",
sourceHash: hash("Pricing"),
provenance: "machine",
outputHash: hash("Preise"),
stale: false,
},
],
]);
await adapter.write("de", entries);
// The runtime catalog is a flat string map, no metadata leaking into it.
const catalog = JSON.parse(await readFile(join(dir, "de.json"), "utf8"));
expect(catalog).toEqual({ "nav.pricing": "Preise" });
const loaded = await adapter.load("de");
expect(loaded.get("nav.pricing")).toEqual({
text: "Preise",
sourceHash: hash("Pricing"),
provenance: "machine",
outputHash: hash("Preise"),
stale: false,
});
});
it("load returns an empty map for a locale with no catalog yet", async () => {
const adapter = makeLandingUiAdapter({ dir });
const loaded = await adapter.load("fr");
expect(loaded.size).toBe(0);
});
});
@@ -0,0 +1,46 @@
// tests/unit/scripts/i18n/mask-docs-extensions.test.ts
import { describe, expect, it } from "vitest";
import { countStructures, mask, restore } from "../../../../scripts/i18n/lib/mask.mjs";
describe("mask: balanced-paren link URLs", () => {
it("round-trips a link whose URL contains balanced parentheses", () => {
const src = "See [the article](https://en.wikipedia.org/wiki/Foo_(bar)) for detail.";
const { masked, tokens } = mask(src);
expect(restore(masked, tokens)).toBe(src);
});
it("masks the WHOLE url including its inner parens, keeping the link intact", () => {
const src = "See [the article](https://en.wikipedia.org/wiki/Foo_(bar)) done.";
const { masked } = mask(src);
// The full URL is hidden; no fragment of it leaks into translatable text.
expect(masked).not.toContain("wikipedia");
expect(masked).not.toContain("(bar)");
// Link text stays translatable, and exactly one closing paren remains (the link's).
expect(masked).toContain("[the article]");
expect(masked.match(/\)/g)?.length ?? 0).toBe(1);
});
it("counts a paren-URL link as one link", () => {
const src = "[a](https://x.test/A_(b)) and [c](/d)";
expect(countStructures(src).links).toBe(2);
});
});
describe("mask: double-brace placeholders", () => {
it("round-trips and hides the entire {{token}} not just the inner {x}", () => {
const src = "Pattern is {{padded}} then {{index}}.";
const { masked, tokens } = mask(src);
expect(restore(masked, tokens)).toBe(src);
expect(masked).not.toContain("{{padded}}");
expect(masked).not.toContain("{padded}"); // inner must not leak either
});
it("counts each {{token}} as one placeholder", () => {
expect(countStructures("{{a}} {{b}} {c}").placeholders).toBe(3);
});
it("still masks single-brace {var} placeholders", () => {
const { masked } = mask("Hi {name}");
expect(masked).not.toContain("{name}");
});
});
+33
View File
@@ -0,0 +1,33 @@
// tests/unit/scripts/i18n/mask.test.ts
import { describe, expect, it } from "vitest";
import { countStructures, mask, restore } from "../../../../scripts/i18n/lib/mask.mjs";
describe("mask/restore", () => {
it("round-trips arbitrary markdown exactly", () => {
const src = [
"Upload a `file` then run:",
"",
"```bash",
"curl -X POST /api/v1/tools/image/convert",
"```",
"",
"See ![diagram](/img/x.png) and [the guide](/guide/x).",
"Greeting: {username}, you have {count} files.",
].join("\n");
const { masked, tokens } = mask(src);
expect(restore(masked, tokens)).toBe(src);
});
it("hides code, link URLs, and placeholders from the masked text", () => {
const { masked } = mask("Run `x` see [t](/u) for {name}");
expect(masked).not.toContain("`x`");
expect(masked).not.toContain("/u");
expect(masked).not.toContain("{name}");
expect(masked).toContain("[t]"); // link TEXT stays translatable
});
it("countStructures counts fences, inline code, links/images, placeholders", () => {
const src = "```\nc\n```\n`a` `b` [l](/x) ![i](/y) {p}";
expect(countStructures(src)).toEqual({ fences: 1, inlineCode: 2, links: 2, placeholders: 1 });
});
});
+17
View File
@@ -0,0 +1,17 @@
// tests/unit/scripts/i18n/registry.test.ts
import { describe, expect, it } from "vitest";
import { ADAPTERS, resolveSurfaces } from "../../../../scripts/i18n/adapters/registry.mjs";
describe("registry", () => {
it("exports an ADAPTERS object", () => {
expect(typeof ADAPTERS).toBe("object");
});
it("resolveSurfaces('all') returns all registered keys", () => {
expect(resolveSurfaces("all")).toEqual(Object.keys(ADAPTERS));
});
it("resolveSurfaces filters to known keys", () => {
expect(resolveSurfaces("nope")).toEqual([]);
});
});
@@ -0,0 +1,21 @@
// tests/unit/scripts/i18n/shared-i18n.test.ts
import { describe, expect, it } from "vitest";
import { loadToolStrings, localeCodes } from "../../../../scripts/i18n/lib/shared-i18n.mjs";
describe("shared-i18n", () => {
it("returns all 21 locale codes including en and ar", () => {
const codes = localeCodes();
expect(codes).toContain("en");
expect(codes).toContain("ar");
expect(codes).toContain("pt-BR");
expect(codes.length).toBe(21);
});
it("loads translated tool name/description for a locale", async () => {
const en = await loadToolStrings("en");
expect(en.convert).toBeTruthy();
expect(typeof en.convert.name).toBe("string");
const de = await loadToolStrings("de");
expect(de.convert.name).toBeTruthy();
});
});
+26
View File
@@ -0,0 +1,26 @@
// tests/unit/scripts/i18n/slugify.test.ts
import { describe, expect, it } from "vitest";
import { slugify } from "../../../../scripts/i18n/lib/slugify.mjs";
describe("slugify (VitePress/mdit-vue parity)", () => {
it("lowercases and dashes spaces", () => {
expect(slugify("Getting Started")).toBe("getting-started");
});
it("collapses punctuation runs to a single dash and trims", () => {
expect(slugify("File Processing (241 Tools)")).toBe("file-processing-241-tools");
expect(slugify("REST API & API Keys")).toBe("rest-api-api-keys");
});
it("strips combining marks via NFKD", () => {
expect(slugify("Café Details")).toBe("cafe-details");
});
it("prefixes a leading digit with underscore", () => {
expect(slugify("3 Steps")).toBe("_3-steps");
});
it("handles a plain two-word heading", () => {
expect(slugify("Quick Start")).toBe("quick-start");
});
});
+29
View File
@@ -0,0 +1,29 @@
// tests/unit/scripts/i18n/validate.test.ts
import { describe, expect, it } from "vitest";
import { validate } from "../../../../scripts/i18n/lib/validate.mjs";
const SRC = "```\ncode\n```\nUse `x` and [t](/u) with {name}.";
describe("validate", () => {
it("passes when structure is preserved", () => {
const translated = "```\ncode\n```\nBenutze `x` und [Text](/u) mit {name}.";
expect(validate(SRC, translated)).toEqual({ ok: true, errors: [] });
});
it("fails when a code fence is dropped", () => {
const translated = "Benutze `x` und [Text](/u) mit {name}.";
const result = validate(SRC, translated);
expect(result.ok).toBe(false);
expect(result.errors.join(" ")).toMatch(/fences/);
});
it("fails when a placeholder count changes", () => {
const translated = "```\ncode\n```\nBenutze `x` und [Text](/u) mit {name} {name}.";
expect(validate(SRC, translated).ok).toBe(false);
});
it("fails when a mask token leaked into the output", () => {
const translated = `${SRC}⸤I18N0⸥`;
expect(validate(SRC, translated).ok).toBe(false);
});
});