mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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`");
|
||||
});
|
||||
});
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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"');
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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"));
|
||||
});
|
||||
});
|
||||
@@ -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}");
|
||||
});
|
||||
});
|
||||
@@ -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  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)  {p}";
|
||||
expect(countStructures(src)).toEqual({ fences: 1, inlineCode: 2, links: 2, placeholders: 1 });
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user