From 45c385e7a2c789110e8e5deea774b154a35bf086 Mon Sep 17 00:00:00 2001 From: Markus Hartung Date: Wed, 2 Sep 2026 08:27:39 -0300 Subject: [PATCH] feat(i18n): pure scaffold helpers for adding a locale to config, README and indexes --- scripts/i18n/lib/locale-scaffold.mjs | 232 ++++++++++ tests/unit/i18n-locale-scaffold.test.ts | 543 ++++++++++++++++++++++++ 2 files changed, 775 insertions(+) create mode 100644 scripts/i18n/lib/locale-scaffold.mjs create mode 100644 tests/unit/i18n-locale-scaffold.test.ts diff --git a/scripts/i18n/lib/locale-scaffold.mjs b/scripts/i18n/lib/locale-scaffold.mjs new file mode 100644 index 0000000000..6ff85829b0 --- /dev/null +++ b/scripts/i18n/lib/locale-scaffold.mjs @@ -0,0 +1,232 @@ +/** + * Pure text helpers for scaffolding a new locale across every surface that + * lists the supported languages. Text in, text out — no filesystem and no + * Prettier: the orchestrator (scripts/i18n/add-locale.mjs) reads each file, + * transforms it here and writes it back. + * + * `entry` is a `config/i18n.json` locale object (`code`, `label`, `name`, + * `native`, `english`, `flag`, optional `aliases`) plus one helper key that is + * never stored: `flagFile`, the `docs/assets/flags/` to use when the + * ISO-3166 file cannot be derived from the flag emoji (e.g. `sw` → `tz.svg`). + * `total` is the number of UI locales after the insertion, English included. + * + * insertLocaleEntry(configText, entry) config/i18n.json + * flagFileFor(entry) 🇬🇷 → "gr.svg" + * insertReadmeFlagLink(readmeText, entry, total) README.md language block + * insertDocsIndexRow(indexText, entry, total) docs/i18n/README.md + * insertI18nGuideRow(guideText, entry, total, rtlCodes) docs/guides/I18N.md locale table + * bumpCounts(text, total) "N languages" / "N translated documentation sets" + * buildMirrorStub({ heading, native, bar, body }) docs/i18n//llm.txt, CHANGELOG.md + * + * Every insertion leaves the existing lines exactly as they are and places the + * new one in `code` order (`localeCompare`, "en"): right before the first + * existing entry that sorts after it, or after the last one. Duplicates throw. + */ + +const REGIONAL_INDICATOR_A = 0x1f1e6; +const REGIONAL_INDICATOR_Z = REGIONAL_INDICATOR_A + 25; +const DEFAULT_RTL_CODES = ["ar", "fa", "he", "ur"]; + +const README_MARKER = /🌐 In \d+ languages<\/b>/; +const INDEX_SENTENCE = + /into \d+ languages; together with the English source, the UI supports \d+ locales/; +const INDEX_ROW = /^- .+ \(`([^`]+)`\): \[Docs Root\]/; +const GUIDE_HEADLINE = /supports \*\*\d+ languages\*\*/; +const GUIDE_HEADER = /^\| Code +\| Language +\| RTL +\| Google Translate Code +\|$/; +const GUIDE_ROW = /^\| `([^`]+)` *\|/; +const TABLE_SEPARATOR = /^\|(?: *:?-{3,}:? *\|)+$/; + +function compareCodes(a, b) { + return a.localeCompare(b, "en"); +} + +function requireCode(entry) { + if (!entry || typeof entry.code !== "string" || entry.code.length === 0) { + throw new Error("locale entry needs a non-empty code"); + } + return entry.code; +} + +function insertInCodeOrder(items, item, codeOf) { + const code = codeOf(item); + const at = items.findIndex((existing) => compareCodes(codeOf(existing), code) > 0); + return at === -1 ? [...items, item] : [...items.slice(0, at), item, ...items.slice(at)]; +} + +// Insert `row` among `rows` (`{ index, code }` of the existing locale rows in +// `lines`); every other line — headings, blank lines, table header, prose — +// stays exactly where it is. +function insertAmongRows(lines, rows, row, code, surface) { + if (rows.length === 0) throw new Error(`${surface}: no locale rows found`); + if (rows.some((existing) => existing.code === code)) { + throw new Error(`${surface}: ${code} is already listed`); + } + const next = rows.find((existing) => compareCodes(existing.code, code) > 0); + const at = next ? next.index : rows[rows.length - 1].index + 1; + return [...lines.slice(0, at), row, ...lines.slice(at)]; +} + +export function flagFileFor(entry) { + if (entry.flagFile) return entry.flagFile; + const codePoints = + typeof entry.flag === "string" ? [...entry.flag].map((char) => char.codePointAt(0)) : []; + const isRegionalPair = + codePoints.length === 2 && + codePoints.every((cp) => cp >= REGIONAL_INDICATOR_A && cp <= REGIONAL_INDICATOR_Z); + if (!isRegionalPair) { + throw new Error(`cannot derive a flag file from ${entry.flag}; pass flagFile explicitly`); + } + return ( + codePoints.map((cp) => String.fromCharCode(cp - REGIONAL_INDICATOR_A + 97)).join("") + ".svg" + ); +} + +export function insertLocaleEntry(configText, entry) { + const code = requireCode(entry); + const config = JSON.parse(configText); + if (!Array.isArray(config.locales)) throw new Error("config has no locales array"); + if (config.locales.some((locale) => locale.code === code)) { + throw new Error(`${code} is already configured`); + } + const stored = { ...entry }; + delete stored.flagFile; + config.locales = insertInCodeOrder(config.locales, stored, (locale) => locale.code); + return JSON.stringify(config, null, 2) + "\n"; +} + +export function insertReadmeFlagLink(readmeText, entry, total) { + const code = requireCode(entry); + const marker = readmeText.match(README_MARKER); + if (!marker || marker.index === undefined) throw new Error("README language block not found"); + const close = readmeText.indexOf("", marker.index); + if (close === -1) throw new Error("README language block is not closed"); + const href = `docs/i18n/${code}/README.md`; + if (readmeText.slice(marker.index, close).includes(`href="${href}"`)) { + throw new Error(`${code} is already linked in the README language block`); + } + const label = `${entry.native} (${code})`; + const link = ` ${label}`; + // `` sits on its own line in README.md; the link goes on the line before it. + const lineStart = readmeText.lastIndexOf("\n", close) + 1; + return ( + readmeText.slice(0, marker.index) + + `🌐 In ${total} languages` + + readmeText.slice(marker.index + marker[0].length, lineStart) + + `${link}\n` + + readmeText.slice(lineStart) + ); +} + +export function insertDocsIndexRow(indexText, entry, total) { + const code = requireCode(entry); + if (!INDEX_SENTENCE.test(indexText)) { + throw new Error("docs/i18n/README.md counts sentence not found"); + } + const lines = indexText + .replace( + INDEX_SENTENCE, + `into ${total - 1} languages; together with the English source, the UI supports ${total} locales` + ) + .split("\n"); + const rows = lines + .map((line, index) => ({ index, code: line.match(INDEX_ROW)?.[1] ?? null })) + .filter((candidate) => candidate.code !== null); + const row = `- ${entry.flag} **${entry.native}** (\`${code}\`): [Docs Root](./${code}/README.md)`; + return insertAmongRows(lines, rows, row, code, "docs/i18n/README.md").join("\n"); +} + +export function insertI18nGuideRow(guideText, entry, total, rtlCodes = DEFAULT_RTL_CODES) { + const code = requireCode(entry); + if (!GUIDE_HEADLINE.test(guideText)) throw new Error("docs/guides/I18N.md headline not found"); + const lines = guideText.replace(GUIDE_HEADLINE, `supports **${total} languages**`).split("\n"); + // The guide has other tables with backticked first cells, so the locale rows + // are the contiguous block right under the "Supported Locales" table header. + const header = lines.findIndex((line) => GUIDE_HEADER.test(line)); + if (header === -1 || !TABLE_SEPARATOR.test(lines[header + 1] ?? "")) { + throw new Error("docs/guides/I18N.md locale table not found"); + } + const rows = []; + for (let index = header + 2; index < lines.length; index += 1) { + const rowCode = lines[index].match(GUIDE_ROW)?.[1] ?? null; + if (rowCode === null) break; + rows.push({ index, code: rowCode }); + } + const cells = [ + `\`${code}\``, + entry.native, + rtlCodes.includes(code) ? "Yes" : "No", + `\`${code}\``, + ]; + const row = formatTableRow(cells, tableColumnWidths(lines[header], lines[header + 1])); + return insertAmongRows(lines, rows, row, code, "docs/guides/I18N.md").join("\n"); +} + +export function bumpCounts(text, total) { + return text + .replace(/\d+ languages/g, `${total} languages`) + .replace(/\d+ translated documentation sets/g, `${total - 1} translated documentation sets`); +} + +export function buildMirrorStub({ heading, native, bar, body }) { + return `# ${heading} (${native})\n\n${bar}\n\n---\n\n${body}`; +} + +// --- markdown table alignment ------------------------------------------------ +// +// docs/guides/I18N.md is Prettier-formatted, so every cell is padded to its +// column width. A new row is padded the same way when the table is aligned +// (header cell widths equal the separator dash counts, which is how Prettier +// emits it); otherwise the compact `| a | b |` form is used. + +function tableCells(line) { + return line.replace(/^\| ?/, "").replace(/ ?\|$/, "").split(" | "); +} + +function tableColumnWidths(headerLine, separatorLine) { + const headerWidths = tableCells(headerLine).map((cell) => cell.length); + const separatorWidths = tableCells(separatorLine).map((cell) => cell.length); + const aligned = + headerWidths.length === separatorWidths.length && + headerWidths.every((width, index) => width === separatorWidths[index]); + return aligned ? separatorWidths : null; +} + +function formatTableRow(cells, widths) { + const padded = + widths && widths.length === cells.length + ? cells.map( + (cell, index) => cell + " ".repeat(Math.max(0, widths[index] - displayWidth(cell))) + ) + : cells; + return `| ${padded.join(" | ")} |`; +} + +// Column width the way Prettier measures markdown tables: East Asian wide and +// fullwidth code points take two columns, combining diacritics none, everything +// else one. (Emoji never appear in these cells, so they are not special-cased.) +function displayWidth(text) { + let width = 0; + for (const char of text) { + const cp = char.codePointAt(0); + if (cp >= 0x0300 && cp <= 0x036f) continue; + width += isWide(cp) ? 2 : 1; + } + return width; +} + +function isWide(cp) { + return ( + (cp >= 0x1100 && cp <= 0x115f) || // Hangul Jamo + (cp >= 0x2e80 && cp <= 0x303e) || // CJK radicals, ideographic punctuation + (cp >= 0x3041 && cp <= 0x33ff) || // Hiragana, Katakana, Bopomofo, Hangul compat, CJK compat + (cp >= 0x3400 && cp <= 0x4dbf) || // CJK Extension A + (cp >= 0x4e00 && cp <= 0x9fff) || // CJK Unified Ideographs + (cp >= 0xa960 && cp <= 0xa97f) || // Hangul Jamo Extended-A + (cp >= 0xac00 && cp <= 0xd7a3) || // Hangul Syllables + (cp >= 0xf900 && cp <= 0xfaff) || // CJK Compatibility Ideographs + (cp >= 0xfe30 && cp <= 0xfe4f) || // CJK Compatibility Forms + (cp >= 0xff00 && cp <= 0xff60) || // Fullwidth Forms + (cp >= 0xffe0 && cp <= 0xffe6) || // Fullwidth signs + (cp >= 0x20000 && cp <= 0x3fffd) // CJK Extensions B and later + ); +} diff --git a/tests/unit/i18n-locale-scaffold.test.ts b/tests/unit/i18n-locale-scaffold.test.ts new file mode 100644 index 0000000000..4769919f34 --- /dev/null +++ b/tests/unit/i18n-locale-scaffold.test.ts @@ -0,0 +1,543 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { + insertLocaleEntry, + flagFileFor, + insertReadmeFlagLink, + insertDocsIndexRow, + insertI18nGuideRow, + bumpCounts, + buildMirrorStub, +} from "../../scripts/i18n/lib/locale-scaffold.mjs"; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", ".."); +const readRepo = (rel: string): string => readFileSync(path.join(ROOT, rel), "utf8"); + +type LocaleEntry = { + code: string; + label?: string; + name?: string; + native: string; + english?: string; + flag: string; + flagFile?: string; + aliases?: string[]; +}; + +type I18nConfig = { + $schema?: string; + default: string; + rtl: string[]; + uiOnly?: string[]; + docsExcluded?: string[]; + locales: LocaleEntry[]; +}; + +const el: LocaleEntry = { + code: "el", + label: "EL", + name: "Ελληνικά", + native: "Ελληνικά", + english: "Greek", + flag: "🇬🇷", +}; + +const EL_README_LINK = + ' Ελληνικά (el)'; +const EL_INDEX_ROW = "- 🇬🇷 **Ελληνικά** (`el`): [Docs Root](./el/README.md)"; + +const realConfig = (): I18nConfig => JSON.parse(readRepo("config/i18n.json")) as I18nConfig; + +// Column positions of every `|` — two table rows are aligned when these match. +const pipeColumns = (line: string): number[] => + [...line].flatMap((char, index) => (char === "|" ? [index] : [])); + +// --------------------------------------------------------------------------- +// insertLocaleEntry +// --------------------------------------------------------------------------- + +test("insertLocaleEntry keeps alphabetical order by code and rejects duplicates", () => { + const cfg = JSON.stringify({ + default: "en", + rtl: [], + locales: [{ code: "de" }, { code: "en" }, { code: "es" }], + }); + const out = JSON.parse(insertLocaleEntry(cfg, el)) as I18nConfig; + assert.deepEqual( + out.locales.map((l) => l.code), + ["de", "el", "en", "es"] + ); + assert.throws(() => insertLocaleEntry(JSON.stringify(out), el), /already configured/); +}); + +test("insertLocaleEntry inserts at both ends, strips flagFile and stores the entry's own keys", () => { + const cfg = JSON.stringify({ default: "en", rtl: [], locales: [{ code: "de" }, { code: "es" }] }); + const first = JSON.parse(insertLocaleEntry(cfg, { ...el, code: "aa" })) as I18nConfig; + assert.deepEqual( + first.locales.map((l) => l.code), + ["aa", "de", "es"] + ); + const last = JSON.parse( + insertLocaleEntry(cfg, { ...el, code: "zz", flagFile: "gr.svg", aliases: ["zz-x"] }) + ) as I18nConfig; + assert.deepEqual( + last.locales.map((l) => l.code), + ["de", "es", "zz"] + ); + assert.deepEqual(last.locales[2], { + code: "zz", + label: "EL", + name: "Ελληνικά", + native: "Ελληνικά", + english: "Greek", + flag: "🇬🇷", + aliases: ["zz-x"], + }); + assert.throws(() => insertLocaleEntry(cfg, { ...el, code: "" }), /code/); +}); + +test("insertLocaleEntry on the real config keeps every top-level key, its order and the existing code order", () => { + const raw = readRepo("config/i18n.json"); + const before = JSON.parse(raw) as I18nConfig; + const text = insertLocaleEntry(raw, { ...el, flagFile: "gr.svg" }); + assert.ok(text.endsWith("}\n")); + const after = JSON.parse(text) as I18nConfig; + + assert.deepEqual(Object.keys(after), Object.keys(before)); + const { locales: beforeLocales, ...beforeRest } = before; + const { locales: afterLocales, ...afterRest } = after; + assert.deepEqual(afterRest, beforeRest); + + // The 43 existing entries are untouched and keep their relative order. + assert.equal(afterLocales.length, beforeLocales.length + 1); + assert.deepEqual( + afterLocales.filter((l) => l.code !== "el"), + beforeLocales + ); + // ...and the new entry sits exactly where a full localeCompare("en") sort would put it. + const sorted = [...beforeLocales.map((l) => l.code), "el"].sort((a, b) => + a.localeCompare(b, "en") + ); + assert.deepEqual( + afterLocales.map((l) => l.code), + sorted + ); + assert.equal( + afterLocales.findIndex((l) => l.code === "el"), + beforeLocales.findIndex((l) => l.code === "en") + ); + assert.deepEqual( + afterLocales.find((l) => l.code === "el"), + el + ); +}); + +// --------------------------------------------------------------------------- +// flagFileFor +// --------------------------------------------------------------------------- + +test("flagFileFor derives the ISO-3166 file from the emoji and honours an override", () => { + assert.equal(flagFileFor(el), "gr.svg"); + assert.equal(flagFileFor({ ...el, code: "kn", flag: "🇮🇳", flagFile: "in.svg" }), "in.svg"); + // An override that differs from the derivation proves the override really wins. + assert.equal(flagFileFor({ ...el, code: "sw", flag: "🇰🇪", flagFile: "tz.svg" }), "tz.svg"); + assert.equal(flagFileFor({ ...el, code: "ja", flag: "🇯🇵" }), "jp.svg"); +}); + +test("flagFileFor rejects anything that is not a regional-indicator pair unless flagFile is given", () => { + assert.throws(() => flagFileFor({ ...el, flag: "🏳️" }), /pass flagFile explicitly/); + assert.throws(() => flagFileFor({ ...el, flag: "🇬" }), /pass flagFile explicitly/); + assert.throws(() => flagFileFor({ ...el, flag: "GR" }), /pass flagFile explicitly/); + const noFlag: Partial = { code: "x", native: "x" }; + assert.throws(() => flagFileFor(noFlag as LocaleEntry), /pass flagFile explicitly/); + assert.equal(flagFileFor({ ...el, flag: "🏳️", flagFile: "custom.svg" }), "custom.svg"); +}); + +test("flagFileFor reproduces the README flag file of every configured locale (legacy `in`/`sw` need flagFile)", () => { + const readme = readRepo("README.md"); + // Pre-existing README drift, exactly what the flagFile override is for: the legacy Hindi + // code `in` keeps in.svg although its emoji is 🇮🇩, and Kiswahili uses tz.svg for 🇰🇪. + const overrides: Record = { in: "in.svg", sw: "tz.svg" }; + for (const entry of realConfig().locales) { + const href = entry.code === "en" ? "README.md" : `docs/i18n/${entry.code}/README.md`; + const pattern = new RegExp( + `\n 🌐 In 43 languages\n

\n
English (en)\n\n`; + +test("insertReadmeFlagLink appends the link inside the block and bumps the headline", () => { + const out = insertReadmeFlagLink(README_BLOCK, el, 44); + assert.match(out, /In 44 languages/); + assert.match( + out, + /Ελληνικά \(el\)<\/a>\n<\/div>/ + ); +}); + +test("insertReadmeFlagLink lands after the last link, before , and only rewrites the marker", () => { + const link = (code: string, file: string): string => + ` ${code}`; + const lines = [ + "# OmniRoute", + "", + "Docs In 43 languages, see below.", + "", + '
', + " 🌐 In 43 languages", + "

", + link("de", "de.svg"), + link("es", "es.svg"), + link("fr", "fr.svg"), + "
", + "", + "
In 43 languages
", + "", + ]; + const out = insertReadmeFlagLink(lines.join("\n"), el, 44).split("\n"); + const expected = [...lines]; + expected[5] = " 🌐 In 44 languages"; + expected.splice(10, 0, EL_README_LINK); + assert.deepEqual(out, expected); +}); + +test("insertReadmeFlagLink rejects a duplicate link and a README without the language block", () => { + const once = insertReadmeFlagLink(README_BLOCK, el, 44); + assert.throws(() => insertReadmeFlagLink(once, el, 45), /already linked/); + assert.throws(() => insertReadmeFlagLink("# no block\n", el, 44), /language block not found/); +}); + +test("insertReadmeFlagLink on the real README adds exactly one line right before the block's ", () => { + const total = realConfig().locales.length; + const before = readRepo("README.md").split("\n"); + const after = insertReadmeFlagLink(before.join("\n"), el, total + 1).split("\n"); + assert.equal(after.length, before.length + 1); + + const marker = before.indexOf(` 🌐 In ${total} languages`); + const close = before.findIndex((line, index) => index > marker && line === ""); + assert.ok(marker > 0 && close > marker); + assert.equal(after[marker], ` 🌐 In ${total + 1} languages`); + assert.match(after[close - 1], /^ "); + assert.deepEqual( + [...after.slice(0, marker), ...after.slice(marker + 1, close), ...after.slice(close + 1)], + [...before.slice(0, marker), ...before.slice(marker + 1)] + ); +}); + +// --------------------------------------------------------------------------- +// insertDocsIndexRow +// --------------------------------------------------------------------------- + +const INDEX_FIXTURE = + "Translations of documentation into 42 languages; together with the English source, the UI supports 43 locales. Code blocks remain in English.\n\n---\n\n- 🇩🇪 **Deutsch** (`de`): [Docs Root](./de/README.md)\n- 🇪🇸 **Español** (`es`): [Docs Root](./es/README.md)\n"; + +test("insertDocsIndexRow inserts in code order and updates the counts sentence", () => { + const out = insertDocsIndexRow(INDEX_FIXTURE, el, 44); + assert.match( + out, + /into 43 languages; together with the English source, the UI supports 44 locales/ + ); + assert.equal(out.split("\n").indexOf(EL_INDEX_ROW), 5); +}); + +test("insertDocsIndexRow inserts at the beginning and the end of the row block and leaves other lines alone", () => { + const withTrailer = `${INDEX_FIXTURE}\nSee also [the guide](../guides/I18N.md).\n`; + const lines = withTrailer.split("\n"); + const sentence = lines[0] + .replace("into 42 languages", "into 43 languages") + .replace("supports 43 locales", "supports 44 locales"); + + const first = insertDocsIndexRow( + withTrailer, + { ...el, code: "aa", native: "Aa", flag: "🇦🇦" }, + 44 + ); + const aaRow = "- 🇦🇦 **Aa** (`aa`): [Docs Root](./aa/README.md)"; + assert.deepEqual(first.split("\n"), [sentence, ...lines.slice(1, 4), aaRow, ...lines.slice(4)]); + + const last = insertDocsIndexRow(withTrailer, { ...el, code: "zz", native: "Zz", flag: "🇿🇿" }, 44); + const zzRow = "- 🇿🇿 **Zz** (`zz`): [Docs Root](./zz/README.md)"; + assert.deepEqual(last.split("\n"), [sentence, ...lines.slice(1, 6), zzRow, ...lines.slice(6)]); +}); + +test("insertDocsIndexRow rejects a duplicate row, a missing sentence and an index without rows", () => { + assert.throws( + () => insertDocsIndexRow(INDEX_FIXTURE, { ...el, code: "de" }, 44), + /already listed/ + ); + assert.throws( + () => + insertDocsIndexRow("# x\n\n- 🇩🇪 **Deutsch** (`de`): [Docs Root](./de/README.md)\n", el, 44), + /counts sentence/ + ); + assert.throws( + () => insertDocsIndexRow(`${INDEX_FIXTURE.split("\n").slice(0, 4).join("\n")}\n`, el, 44), + /no locale rows/ + ); +}); + +test("insertDocsIndexRow on the real docs/i18n/README.md adds one row before `es` and rewrites only the sentence", () => { + const total = realConfig().locales.length; + const before = readRepo("docs/i18n/README.md").split("\n"); + const after = insertDocsIndexRow(before.join("\n"), el, total + 1).split("\n"); + assert.equal(after.length, before.length + 1); + + const sentence = before.findIndex((line) => + line.startsWith("Translations of documentation into") + ); + const es = before.findIndex((line) => line.includes("(`es`): [Docs Root]")); + assert.ok(sentence >= 0 && es > sentence); + assert.equal( + after[sentence], + before[sentence] + .replace(`into ${total - 1} languages`, `into ${total} languages`) + .replace(`supports ${total} locales`, `supports ${total + 1} locales`) + ); + assert.equal(after[es], EL_INDEX_ROW); + assert.deepEqual( + [...after.slice(0, sentence), ...after.slice(sentence + 1, es), ...after.slice(es + 1)], + [...before.slice(0, sentence), ...before.slice(sentence + 1)] + ); +}); + +// --------------------------------------------------------------------------- +// insertI18nGuideRow +// --------------------------------------------------------------------------- + +const GUIDE_FIXTURE = + "OmniRoute supports **43 languages** with full dashboard UI translation.\n\n| Code | Language | RTL | Google Translate Code |\n| --- | --- | --- | --- |\n| `de` | Deutsch | No | `de` |\n| `es` | Español | No | `es` |\n"; + +test("insertI18nGuideRow adds a table row in code order and bumps the headline", () => { + const out = insertI18nGuideRow(GUIDE_FIXTURE, el, 44); + assert.match(out, /supports \*\*44 languages\*\*/); + // headline (0), blank (1), header (2), separator (3), `de` (4), `el` (5), `es` (6) + assert.equal(out.split("\n")[4], "| `de` | Deutsch | No | `de` |"); + assert.equal(out.split("\n")[5], "| `el` | Ελληνικά | No | `el` |"); + assert.equal(out.split("\n")[6], "| `es` | Español | No | `es` |"); +}); + +test("insertI18nGuideRow inserts at both ends, takes RTL from rtlCodes and leaves other lines alone", () => { + const withTrailer = `${GUIDE_FIXTURE}\nRTL locales flip the layout.\n`; + const lines = withTrailer.split("\n"); + const headline = lines[0].replace("**43 languages**", "**44 languages**"); + + const first = insertI18nGuideRow(withTrailer, { ...el, code: "ar", native: "العربية" }, 44); + const arRow = "| `ar` | العربية | Yes | `ar` |"; + assert.deepEqual(first.split("\n"), [headline, ...lines.slice(1, 4), arRow, ...lines.slice(4)]); + + const last = insertI18nGuideRow(withTrailer, { ...el, code: "zz", native: "Zz" }, 44, ["zz"]); + const zzRow = "| `zz` | Zz | Yes | `zz` |"; + assert.deepEqual(last.split("\n"), [headline, ...lines.slice(1, 6), zzRow, ...lines.slice(6)]); + + const he = { ...el, code: "he", native: "עברית" }; + assert.equal( + insertI18nGuideRow(GUIDE_FIXTURE, he, 44).split("\n")[6], + "| `he` | עברית | Yes | `he` |" + ); + assert.equal( + insertI18nGuideRow(GUIDE_FIXTURE, he, 44, []).split("\n")[6], + "| `he` | עברית | No | `he` |" + ); +}); + +test("insertI18nGuideRow rejects a duplicate row, a missing headline and a guide without rows", () => { + assert.throws( + () => insertI18nGuideRow(GUIDE_FIXTURE, { ...el, code: "es" }, 44), + /already listed/ + ); + assert.throws(() => insertI18nGuideRow("| `de` | Deutsch | No | `de` |\n", el, 44), /headline/); + assert.throws( + () => insertI18nGuideRow(`${GUIDE_FIXTURE.split("\n").slice(0, 4).join("\n")}\n`, el, 44), + /no locale rows/ + ); +}); + +test("insertI18nGuideRow pads the new row to the column widths of a Prettier-aligned table", () => { + const aligned = [ + "OmniRoute supports **43 languages** with full dashboard UI translation.", + "", + "| Code | Language | RTL | Google Translate Code |", + "| ------- | -------------------- | --- | --------------------- |", + "| `de` | Deutsch | No | `de` |", + "| `zh-CN` | 中文 (简体) | No | `zh-CN` |", + "", + ].join("\n"); + + const greek = insertI18nGuideRow(aligned, el, 44).split("\n"); + assert.equal(greek[5], "| `el` | Ελληνικά | No | `el` |"); + assert.deepEqual(pipeColumns(greek[5]), pipeColumns(greek[4])); + + // CJK ideographs are two columns wide for Prettier, so 中文 (香港) gets the same 9 spaces + // of padding as the 中文 (简体) row above it — not the 13 a plain .length would give. + const hk = insertI18nGuideRow(aligned, { ...el, code: "zh-HK", native: "中文 (香港)" }, 44); + assert.equal( + hk.split("\n")[6], + "| `zh-HK` | 中文 (香港) | No | `zh-HK` |" + ); +}); + +test("insertI18nGuideRow only touches the locale table, not other tables with backticked cells", () => { + const guide = [ + "OmniRoute supports **43 languages** with full dashboard UI translation.", + "", + "| Variable | Default |", + "| --- | --- |", + "| `OMNIROUTE_LANG` | `en` |", + "| `zz` | `zz` |", + "", + "### Supported Locales", + "", + "| Code | Language | RTL | Google Translate Code |", + "| --- | --- | --- | --- |", + "| `de` | Deutsch | No | `de` |", + "| `es` | Español | No | `es` |", + "", + "| Other | Table |", + "| --- | --- |", + "| `aa` | after |", + "", + ]; + const out = insertI18nGuideRow(guide.join("\n"), el, 44).split("\n"); + const expected = [...guide]; + expected[0] = expected[0].replace("**43 languages**", "**44 languages**"); + expected.splice(12, 0, "| `el` | Ελληνικά | No | `el` |"); + assert.deepEqual(out, expected); + // A duplicate in a decoy table is not a duplicate locale row. + assert.doesNotThrow(() => + insertI18nGuideRow(guide.join("\n"), { ...el, code: "zz", native: "Zz" }, 44) + ); + assert.throws( + () => + insertI18nGuideRow( + "OmniRoute supports **43 languages**\n\n| `de` | Deutsch | No | `de` |\n", + el, + 44 + ), + /locale table not found/ + ); +}); + +test("insertI18nGuideRow on the real docs/guides/I18N.md aligns the new row with its neighbours", () => { + const config = realConfig(); + const total = config.locales.length; + const before = readRepo("docs/guides/I18N.md").split("\n"); + const after = insertI18nGuideRow(before.join("\n"), el, total + 1, config.rtl).split("\n"); + assert.equal(after.length, before.length + 1); + + const headline = before.findIndex((line) => line.includes(`supports **${total} languages**`)); + const es = before.findIndex((line) => line.startsWith("| `es` ")); + assert.ok(headline >= 0 && es > headline); + assert.equal( + after[headline], + before[headline].replace(`**${total} languages**`, `**${total + 1} languages**`) + ); + assert.match(after[es], /^\| `el` +\| Ελληνικά +\| No +\| `el` +\|$/); + assert.deepEqual(pipeColumns(after[es]), pipeColumns(before[es])); + assert.deepEqual(pipeColumns(after[es]), pipeColumns(before[es - 1])); + assert.deepEqual( + [...after.slice(0, headline), ...after.slice(headline + 1, es), ...after.slice(es + 1)], + [...before.slice(0, headline), ...before.slice(headline + 1)] + ); +}); + +// --------------------------------------------------------------------------- +// bumpCounts +// --------------------------------------------------------------------------- + +test("bumpCounts rewrites language and doc-set counts", () => { + assert.equal( + bumpCounts("next-intl with 43 languages\n42 translated documentation sets", 44), + "next-intl with 44 languages\n43 translated documentation sets" + ); +}); + +test("bumpCounts touches only those two phrases", () => { + const text = + "352 providers, 43 languages for UI, 42 translated documentation sets, 43 locales, 7 language packs, in 42 locales, 43 langs"; + assert.equal( + bumpCounts(text, 44), + "352 providers, 44 languages for UI, 43 translated documentation sets, 43 locales, 7 language packs, in 42 locales, 43 langs" + ); +}); + +test("bumpCounts on the real llm.txt rewrites exactly the three count lines, each by one", () => { + const total = realConfig().locales.length; + const before = readRepo("llm.txt").split("\n"); + const after = bumpCounts(before.join("\n"), total + 1).split("\n"); + assert.equal(after.length, before.length); + const changed = before + .map((line, index) => [line, after[index]]) + .filter(([from, to]) => from !== to); + assert.equal(changed.length, 3); + for (const [from, to] of changed) { + assert.match(from, /\d+ (languages|translated documentation sets)/); + assert.equal( + to, + from.replace(/\d+(?= (?:languages|translated documentation sets))/, (n) => + String(Number(n) + 1) + ) + ); + } +}); + +// --------------------------------------------------------------------------- +// buildMirrorStub +// --------------------------------------------------------------------------- + +test("buildMirrorStub produces the header + separator layout check-docs-sync expects", () => { + assert.equal( + buildMirrorStub({ + heading: "OmniRoute", + native: "Ελληνικά", + bar: "🌐 **Languages:** x", + body: "body\n", + }), + "# OmniRoute (Ελληνικά)\n\n🌐 **Languages:** x\n\n---\n\nbody\n" + ); +}); + +test("buildMirrorStub is accepted by check-docs-sync's separator logic and is a fixed point of sync-llm-mirrors", () => { + const body = "> OmniRoute is a proxy.\n\n## Section\n\ntext\n"; + const stub = buildMirrorStub({ + heading: "OmniRoute", + native: "Ελληνικά", + bar: "🌐 **Languages:** x", + body, + }); + + // scripts/check/check-docs-sync.mjs::extractI18nMirrorBody + const separator = stub.match(/^---\s*$/m); + assert.ok(separator && separator.index !== undefined); + const extracted = stub.slice(separator.index + separator[0].length).replace(/^\r?\n+/, ""); + assert.equal(extracted, body); + + // scripts/i18n/sync-llm-mirrors.mjs keeps the header up to `---` and re-appends the root body + const header = stub.slice(0, separator.index + separator[0].length).replace(/\n+$/, ""); + assert.equal(`${header}\n\n${body.trimStart()}`, stub); +}); + +test("buildMirrorStub reproduces a real mirror byte for byte", () => { + const mirror = readRepo("docs/i18n/pt-BR/llm.txt"); + const parts = mirror.match( + /^# (\S+) \((.+)\)\n\n(🌐 \*\*Languages:\*\* [^\n]+)\n\n---\n\n([\s\S]*)$/ + ); + assert.ok(parts, "docs/i18n/pt-BR/llm.txt does not have the five-part mirror layout"); + const [, heading, native, bar, body] = parts; + assert.equal(heading, "OmniRoute"); + assert.equal(native, realConfig().locales.find((l) => l.code === "pt-BR")?.native); + assert.equal(buildMirrorStub({ heading, native, bar, body }), mirror); +});