From fe28438dbc2d4e6704c74530b47cb98cb29578f5 Mon Sep 17 00:00:00 2001 From: Markus Hartung Date: Wed, 2 Sep 2026 09:10:41 -0300 Subject: [PATCH] feat(i18n): add-locale orchestrator (config, flag, ui, docs, cli, readme, bars, site) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One command brings a new locale to every surface, in order: config/i18n.json entry, flag SVG (lipis/flag-icons), dashboard catalog (scaffold + sync-ui-keys --translate-markers), docs mirrors (run-translation over the core set every existing locale carries, then the llm.txt / CHANGELOG.md stubs), CLI catalog (generate-locales --code + batched common/program translation), README flag link / docs index row / I18N guide row / locale counts (+ sync-llm-mirrors), language bars, and the marketing site (lang JSON, SUPPORTED_LANGS, dropdowns). Every phase checks presence first, so a re-run is a no-op apart from filling in what is still missing; --dry-run prints the whole plan and touches nothing (no files, network or child processes); --only / --skip select phases. - scripts/i18n/lib/docs-core-set.mjs: computeDocsCoreSet — intersection of the existing non-docsExcluded mirrors minus llm.txt / CHANGELOG.md / I18N.md and paths without an English source (22 files today). - scripts/i18n/lib/site-scaffold.mjs: addSupportedLang (re-flows the array in the file's own fill style) and addDropdownOption (code order, every menu). - bin/cli/scripts/generate-locales.mjs: --code= filter. - translate-backend parseBatchResponse: Object.hasOwn + trimmed values. - package.json: i18n:add-locale script. --- bin/cli/scripts/generate-locales.mjs | 11 +- package.json | 1 + scripts/i18n/add-locale.mjs | 799 +++++++++++++++++++++ scripts/i18n/lib/docs-core-set.mjs | 58 ++ scripts/i18n/lib/site-scaffold.mjs | 105 +++ scripts/i18n/lib/translate-backend.mjs | 12 +- tests/unit/i18n-add-locale-helpers.test.ts | 267 +++++++ tests/unit/i18n-translate-backend.test.ts | 24 + 8 files changed, 1270 insertions(+), 7 deletions(-) create mode 100644 scripts/i18n/add-locale.mjs create mode 100644 scripts/i18n/lib/docs-core-set.mjs create mode 100644 scripts/i18n/lib/site-scaffold.mjs create mode 100644 tests/unit/i18n-add-locale-helpers.test.ts diff --git a/bin/cli/scripts/generate-locales.mjs b/bin/cli/scripts/generate-locales.mjs index e90aa1ee8c..0da62a4618 100644 --- a/bin/cli/scripts/generate-locales.mjs +++ b/bin/cli/scripts/generate-locales.mjs @@ -6,7 +6,9 @@ * For top-tier languages, a translated `common` + `program` section is included. * All other keys fall back to `en` via i18n.mjs's existing fallback mechanism. * - * Run: node bin/cli/scripts/generate-locales.mjs [--force] + * Run: node bin/cli/scripts/generate-locales.mjs [--force] [--code=] + * --code= generate only that locale (scripts/i18n/add-locale.mjs uses it + * to scaffold the catalog of the locale it is adding) */ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs"; import { join, dirname } from "node:path"; @@ -17,8 +19,13 @@ const ROOT = join(__dirname, "..", "..", ".."); const LOCALES_DIR = join(__dirname, "..", "locales"); const I18N_CFG = join(ROOT, "config", "i18n.json"); const FORCE = process.argv.includes("--force"); +const ONLY = process.argv.find((arg) => arg.startsWith("--code="))?.slice("--code=".length) || null; const { locales } = JSON.parse(readFileSync(I18N_CFG, "utf8")); +if (ONLY && !locales.some((locale) => locale.code === ONLY)) { + console.error(`--code=${ONLY} is not listed in config/i18n.json`); + process.exit(1); +} // common + program translations for each language code. // Keys that are absent fall back to en automatically. @@ -888,7 +895,7 @@ const SCAFFOLD_ONLY = ["bn", "gu", "he", "in", "mr", "ms", "phi", "sw", "ta", "t let created = 0; let skipped = 0; -for (const locale of locales) { +for (const locale of locales.filter((candidate) => !ONLY || candidate.code === ONLY)) { const { code } = locale; if (code === "en" || code === "pt-BR") { skipped++; diff --git a/package.json b/package.json index 3453b27ae6..8532369d96 100644 --- a/package.json +++ b/package.json @@ -162,6 +162,7 @@ "i18n:check-ratio": "node scripts/i18n/check-translation-ratio.mjs", "i18n:check-ratio:update": "node scripts/i18n/check-translation-ratio.mjs --update", "i18n:sync-bars": "node scripts/i18n/sync-language-bars.mjs", + "i18n:add-locale": "node scripts/i18n/add-locale.mjs", "check:native-deps": "node scripts/check/check-native-deps.mjs", "check:node-runtime": "node --import tsx scripts/check/check-supported-node-runtime.ts", "check:pack-artifact": "node --import tsx scripts/build/validate-pack-artifact.ts", diff --git a/scripts/i18n/add-locale.mjs b/scripts/i18n/add-locale.mjs new file mode 100644 index 0000000000..5ac72b10e3 --- /dev/null +++ b/scripts/i18n/add-locale.mjs @@ -0,0 +1,799 @@ +#!/usr/bin/env node +/** + * OmniRoute — add one locale to every surface with a single command. + * + * npm run i18n:add-locale -- --code=el --english=Greek --native=Ελληνικά --flag=🇬🇷 \ + * [--aliases=el-gr] [--flag-file=gr.svg] [--rtl] [--docs=core|all] [--cli-full] [--force-cli] \ + * [--site-dir=../omnirouteSite] [--batch-size=40] [--only=] [--skip=] [--dry-run] + * + * Phases, in order. Every phase checks presence first, so re-running the command + * for an already-added locale is a no-op apart from re-translating whatever is + * still missing (`__MISSING__` markers, untranslated CLI / site keys): + * + * config config/i18n.json entry (+ aliases, rtl) — the single source of truth + * flag docs/assets/flags/.svg from lipis/flag-icons (MIT) when missing + * ui src/i18n/messages/.json — scaffold, then sync-ui-keys --translate-markers + * docs docs/i18n//** via run-translation — the core set every existing locale + * carries (lib/docs-core-set.mjs; --docs=all for the full source set) — then the + * llm.txt / CHANGELOG.md mirror stubs + * cli bin/cli/locales/.json — generate-locales --code scaffold, then the + * common + program sections (--cli-full: every section) translated in batches + * readme README flag link, docs/i18n/README.md row, docs/guides/I18N.md row, and the + * locale counts in llm.txt (+ sync-llm-mirrors), docs/README.md and + * docs/diagrams/i18n-flow.mmd + * bars sync-language-bars — every 🌐 Languages bar gains the new locale + * site /lang/.json (translated), js/i18n.js SUPPORTED_LANGS and the + * language dropdowns (lib/site-scaffold.mjs); node --check on the edited JS + * + * A real run ends with Prettier on the touched repo files. `--dry-run` prints every + * planned write / command — including the computed docs core set — and touches + * nothing: no files, no network, no child processes. + * + * The translating phases (ui, docs, cli, site) need OMNIROUTE_TRANSLATION_API_URL, + * _API_KEY and _MODEL (docs/guides/I18N.md → "Translation pipeline"); `.env` is loaded + * automatically. Child scripts run through execFileSync with an argument array — + * nothing is ever interpolated into a shell. + */ + +import { promises as fs, existsSync, readFileSync } from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import { execFileSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +import { computeDocsCoreSet, docsLocaleDirs } from "./lib/docs-core-set.mjs"; +import { buildMirrorBar } from "./lib/language-bar.mjs"; +import { + bumpCounts, + buildMirrorStub, + flagFileFor, + insertDocsIndexRow, + insertI18nGuideRow, + insertLocaleEntry, + insertReadmeFlagLink, +} from "./lib/locale-scaffold.mjs"; +import { addDropdownOption, addSupportedLang } from "./lib/site-scaffold.mjs"; +import { backendConfig, translateBatch, translateString } from "./lib/translate-backend.mjs"; + +const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(SCRIPT_DIR, "..", ".."); + +const PHASES = ["config", "flag", "ui", "docs", "cli", "readme", "bars", "site"]; +const PHASES_READING_CONFIG_FROM_DISK = ["ui", "docs", "cli", "bars"]; +const PHASES_TRANSLATING = ["ui", "docs", "cli", "site"]; +const FLAG_CDN = "https://raw.githubusercontent.com/lipis/flag-icons/main/flags/4x3/"; +const SITE_PAGES = ["index.html", "why/index.html", "viral/index.html"]; +const CLI_DEFAULT_SECTIONS = ["common", "program"]; +const MIRROR_STUBS = [ + ["llm.txt", "OmniRoute"], + ["CHANGELOG.md", "Changelog"], +]; +const PLACEHOLDER_PREFIX = "__MISSING__:"; +const LOCALE_CODE = /^[a-z]{2,3}(-[A-Z][A-Za-z]{1,3})?$/; +const ALIAS = /^[a-z]{2,3}(-[a-z0-9]{2,8})*$/; +const FORBIDDEN_KEYS = new Set(["__proto__", "prototype", "constructor"]); +const DEFAULT_BATCH_SIZE = 40; + +const USAGE = `Usage: node scripts/i18n/add-locale.mjs --code= --english= --native= --flag= [options] + + --code= locale code, e.g. el, pt-PT, zh-TW (required) + --english= English language name, e.g. Greek (required for a new locale) + --native= native language name, e.g. Ελληνικά (required for a new locale) + --flag= flag emoji, e.g. 🇬🇷 (required for a new locale) + --aliases= browser/OS tags resolving to this locale, e.g. el-gr + --flag-file= docs/assets/flags/ when it cannot be derived from the emoji + --rtl add the code to config/i18n.json "rtl" + --docs=core|all docs to translate: the core set every locale carries (default) or every source + --cli-full translate every CLI catalog section (default: common + program) + --force-cli retranslate CLI keys that already have a value + --site-dir= omnirouteSite checkout (relative to the repo root or absolute); skipped when absent + --batch-size= strings per translation request (default ${DEFAULT_BATCH_SIZE}) + --only= run only these phases + --skip= skip these phases + --dry-run print every planned write / command and touch nothing + +Phases, in order: ${PHASES.join(", ")} +The translating phases (${PHASES_TRANSLATING.join(", ")}) need OMNIROUTE_TRANSLATION_API_URL / _API_KEY / _MODEL +(docs/guides/I18N.md → "Translation pipeline"); .env is loaded automatically.`; + +// ----- .env loader --------------------------------------------------------- +// Same semantics as sync-ui-keys.mjs / run-translation.mjs: variables already +// set in the environment win over the file. +function loadDotEnv() { + const envPath = path.join(ROOT, ".env"); + if (!existsSync(envPath)) return; + try { + for (const rawLine of readFileSync(envPath, "utf8").split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith("#")) continue; + const eq = line.indexOf("="); + if (eq <= 0) continue; + const key = line.slice(0, eq).trim(); + if (!key || process.env[key] !== undefined) continue; + let value = line.slice(eq + 1); + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + process.env[key] = value; + } + } catch { + /* ignore — backendConfig() reports the missing variable */ + } +} + +// ----- CLI ----------------------------------------------------------------- + +function parsePhaseList(value, flag) { + const set = new Set( + value + .split(",") + .map((s) => s.trim()) + .filter(Boolean) + ); + for (const phase of set) { + if (!PHASES.includes(phase)) { + throw new Error(`${flag}: unknown phase "${phase}" (known: ${PHASES.join(", ")})`); + } + } + return set; +} + +function parseArgs(argv) { + const o = { + code: null, + english: null, + native: null, + flag: null, + aliases: [], + flagFile: null, + rtl: false, + docs: "core", + cliFull: false, + forceCli: false, + siteDir: null, + batchSize: DEFAULT_BATCH_SIZE, + only: null, + skip: new Set(), + dryRun: false, + }; + for (const arg of argv.slice(2)) { + const eq = arg.indexOf("="); + const key = eq === -1 ? arg : arg.slice(0, eq); + const value = eq === -1 ? "" : arg.slice(eq + 1); + switch (key) { + case "--help": + case "-h": + console.log(USAGE); + process.exit(0); + break; + case "--code": + o.code = value; + break; + case "--english": + o.english = value; + break; + case "--native": + o.native = value; + break; + case "--flag": + o.flag = value; + break; + case "--aliases": + o.aliases = value + .split(",") + .map((s) => s.trim().toLowerCase()) + .filter(Boolean); + break; + case "--flag-file": + o.flagFile = value; + break; + case "--rtl": + o.rtl = true; + break; + case "--docs": + o.docs = value; + break; + case "--cli-full": + o.cliFull = true; + break; + case "--force-cli": + o.forceCli = true; + break; + case "--site-dir": + o.siteDir = path.resolve(ROOT, value); + break; + case "--batch-size": + // Whole numbers only; NaN / 0 / negatives fall back to the default. + o.batchSize = Math.max(1, Math.floor(Number(value)) || DEFAULT_BATCH_SIZE); + break; + case "--only": + o.only = parsePhaseList(value, "--only"); + break; + case "--skip": + o.skip = parsePhaseList(value, "--skip"); + break; + case "--dry-run": + o.dryRun = true; + break; + default: + throw new Error(`unknown argument ${arg}\n\n${USAGE}`); + } + } + if (!o.code) throw new Error(`--code is required\n\n${USAGE}`); + if (!LOCALE_CODE.test(o.code)) { + throw new Error(`invalid locale code "${o.code}" (expected e.g. el, pt-PT, zh-TW)`); + } + if (!["core", "all"].includes(o.docs)) { + throw new Error(`--docs must be "core" or "all" (got "${o.docs}")`); + } + for (const alias of o.aliases) { + if (!ALIAS.test(alias)) + throw new Error(`invalid alias "${alias}" (lower-case BCP-47, e.g. el-gr)`); + } + return o; +} + +function selectPhases(o) { + return PHASES.filter((phase) => (!o.only || o.only.has(phase)) && !o.skip.has(phase)); +} + +// ----- Helpers ------------------------------------------------------------- + +const log = (...parts) => console.log("[add-locale]", ...parts); +const warn = (...parts) => console.warn("[add-locale] WARN", ...parts); +const dryTag = (ctx) => (ctx.dry ? "[DRY] " : ""); + +function insideRepo(file) { + const relative = path.relative(ROOT, file); + return Boolean(relative) && !relative.startsWith("..") && !path.isAbsolute(relative); +} + +/** Repo-relative POSIX path for files inside the repo, absolute otherwise (site files). */ +function display(file) { + return insideRepo(file) ? path.relative(ROOT, file).split(path.sep).join("/") : file; +} + +const readText = (file) => fs.readFile(file, "utf8"); +const readJson = async (file) => JSON.parse(await readText(file)); + +async function writeFile(ctx, file, text) { + log(`${dryTag(ctx)}write ${display(file)}`); + ctx.touched.add(file); + if (ctx.dry) return; + await fs.mkdir(path.dirname(file), { recursive: true }); + await fs.writeFile(file, text, "utf8"); +} + +function runNode(ctx, script, args = [], note = "") { + const command = ["node", display(script), ...args].join(" "); + log(`${dryTag(ctx)}${command}${note ? ` — ${note}` : ""}`); + if (ctx.dry) return; + execFileSync(process.execPath, [script, ...args], { cwd: ROOT, stdio: "inherit" }); +} + +/** `{ id: "a.b.c", text }` for every string leaf of a nested catalog. */ +function flattenLeaves(node, prefix = "", out = []) { + if (typeof node === "string") { + out.push({ id: prefix, text: node }); + } else if (node && typeof node === "object" && !Array.isArray(node)) { + for (const [key, value] of Object.entries(node)) { + if (FORBIDDEN_KEYS.has(key)) continue; + flattenLeaves(value, prefix ? `${prefix}.${key}` : key, out); + } + } + return out; +} + +function getDeep(node, id) { + let cursor = node; + for (const segment of id.split(".")) { + if (!cursor || typeof cursor !== "object") return undefined; + cursor = cursor[segment]; + } + return cursor; +} + +function setDeep(node, id, value) { + const segments = id.split("."); + if (segments.some((segment) => FORBIDDEN_KEYS.has(segment))) { + throw new Error(`refusing to write key ${id}`); + } + let cursor = node; + for (const segment of segments.slice(0, -1)) { + if (!cursor[segment] || typeof cursor[segment] !== "object") cursor[segment] = {}; + cursor = cursor[segment]; + } + cursor[segments[segments.length - 1]] = value; +} + +async function walkFiles(dir, out = []) { + for (const entry of await fs.readdir(dir, { withFileTypes: true })) { + const abs = path.join(dir, entry.name); + if (entry.isDirectory()) await walkFiles(abs, out); + else out.push(abs); + } + return out; +} + +/** Files under docs/ that carry a 🌐 Languages bar — what sync-language-bars rewrites. */ +async function countLanguageBarFiles() { + let count = 0; + for (const file of await walkFiles(path.join(ROOT, "docs"))) { + if (!file.endsWith(".md") && path.basename(file) !== "llm.txt") continue; + const text = await readText(file); + if (text.split("\n").some((line) => line.startsWith("🌐 **Languages:**"))) count += 1; + } + return count; +} + +/** + * Translates `entries` (`{ id, text }`) in batches of `--batch-size`; a batch + * that fails or cannot be parsed is retried one string at a time. Strings that + * still fail are left out of the result and recorded in `ctx.failures`, so a + * later run fills them in (the run exits 1 to flag them). + */ +async function translateEntries(ctx, entries, label) { + ctx.backend ??= backendConfig(); + const out = new Map(); + const size = ctx.o.batchSize; + const batches = Math.ceil(entries.length / size); + for (let i = 0; i < entries.length; i += size) { + const chunk = entries.slice(i, i + size); + try { + const translated = await translateBatch(chunk, ctx.entry, ctx.backend); + for (const { id } of chunk) out.set(id, translated.get(id)); + } catch (err) { + warn( + `${label}: batch ${i / size + 1}/${batches} failed (${err.message}) — retrying one by one` + ); + for (const { id, text } of chunk) { + try { + const value = await translateString(text, ctx.entry, ctx.backend); + if (!value) throw new Error("empty translation"); + out.set(id, value); + } catch (inner) { + ctx.failures.push(`${label}: ${id} (${inner.message})`); + } + } + } + log(`${label}: ${Math.min(i + size, entries.length)}/${entries.length} strings translated`); + } + return out; +} + +// ----- Phases -------------------------------------------------------------- + +async function phaseConfig(ctx) { + if (ctx.nextConfigText === ctx.configText) { + log(`config: ${ctx.code} already configured — nothing to change`); + return; + } + const changes = []; + if (!ctx.stored) changes.push(`add ${ctx.code} entry`); + if (ctx.o.rtl && !ctx.before.rtl.includes(ctx.code)) changes.push("add to rtl"); + log(`config: ${changes.join(", ")} (total locales → ${ctx.total})`); + await writeFile(ctx, ctx.configPath, ctx.nextConfigText); +} + +async function phaseFlag(ctx) { + const file = path.join(ROOT, "docs", "assets", "flags", ctx.flagFile); + if (existsSync(file)) { + log(`flag: ${display(file)} present`); + return; + } + const url = `${FLAG_CDN}${ctx.flagFile}`; + log(`${dryTag(ctx)}fetch ${url} → ${display(file)}`); + if (ctx.dry) return; + const res = await fetch(url); + if (!res.ok) { + throw new Error( + `flag download failed (${res.status}) for ${url} — place docs/assets/flags/${ctx.flagFile} manually (or pass --flag-file) and re-run` + ); + } + const svg = await res.text(); + if (!svg.trimStart().startsWith(" leaf.id)); + const missing = source.filter((leaf) => !have.has(leaf.id)).length; + const markers = (existing ?? []).filter((leaf) => + leaf.text.startsWith(PLACEHOLDER_PREFIX) + ).length; + log( + `ui: ${source.length} source keys — ${missing} missing, ${markers} __MISSING__ markers → ${missing + markers} to translate` + ); + if (missing + markers === 0) { + log(`ui: ${display(file)} already complete`); + return; + } + runNode( + ctx, + path.join(SCRIPT_DIR, "sync-ui-keys.mjs"), + [`--locale=${ctx.code}`, "--translate-markers", `--batch-size=${ctx.o.batchSize}`], + `writes ${display(file)}` + ); + ctx.touched.add(file); +} + +async function phaseDocs(ctx) { + const localeDir = path.join(ROOT, "docs", "i18n", ctx.code); + let files = null; + if (ctx.o.docs === "core") { + // The target locale is left out of the intersection: a partial earlier run + // of the same locale must not shrink the set it is being caught up to. + const peers = { + ...ctx.config, + locales: ctx.config.locales.filter((locale) => locale.code !== ctx.code), + }; + files = computeDocsCoreSet({ root: ROOT, config: peers }); + if (files.length === 0) { + throw new Error( + "docs: no existing locale mirror to derive the core set from — use --docs=all" + ); + } + log( + `docs: core set = ${files.length} files (carried by every one of the ${docsLocaleDirs({ root: ROOT, config: peers }).length} existing locale mirrors)` + ); + if (ctx.dry) { + for (const rel of files) { + const target = path.join(localeDir, rel); + log(`[DRY] write ${display(target)} (via run-translation)`); + ctx.touched.add(target); + } + } + } else { + log("docs: full source set (--docs=all)"); + } + runNode( + ctx, + path.join(SCRIPT_DIR, "run-translation.mjs"), + [`--locale=${ctx.code}`, ...(files ? [`--files=${files.join(",")}`] : [])], + `writes docs/i18n/${ctx.code}/**` + ); + if (!ctx.dry && !existsSync(localeDir)) { + warn(`docs: ${display(localeDir)} was not created — skipping the llm.txt / CHANGELOG.md stubs`); + return; + } + for (const [fileName, heading] of MIRROR_STUBS) { + const target = path.join(localeDir, fileName); + if (existsSync(target)) { + log(`docs: ${display(target)} present`); + continue; + } + const body = (await readText(path.join(ROOT, fileName))).replace(/^# .+\r?\n+/, ""); + const stub = buildMirrorStub({ + heading, + native: ctx.entry.native ?? ctx.entry.name, + bar: buildMirrorBar(fileName, ctx.code, ctx.config), + body, + }); + await writeFile(ctx, target, stub); + } +} + +async function phaseCli(ctx) { + const dir = path.join(ROOT, "bin", "cli", "locales"); + const catalogPath = path.join(dir, `${ctx.code}.json`); + const en = await readJson(path.join(dir, "en.json")); + const sections = ctx.o.cliFull + ? Object.keys(en) + : CLI_DEFAULT_SECTIONS.filter((section) => section in en); + if (existsSync(catalogPath)) { + log(`cli: ${display(catalogPath)} present`); + } else { + runNode( + ctx, + path.join(ROOT, "bin", "cli", "scripts", "generate-locales.mjs"), + [`--code=${ctx.code}`], + `scaffolds ${display(catalogPath)}` + ); + } + const catalog = existsSync(catalogPath) ? await readJson(catalogPath) : {}; + const source = flattenLeaves( + Object.fromEntries(sections.map((section) => [section, en[section]])) + ); + const pending = ctx.o.forceCli + ? source + : source.filter(({ id }) => typeof getDeep(catalog, id) !== "string"); + const scope = ctx.o.cliFull ? `every section (${sections.length})` : sections.join(" + "); + log( + `cli: ${source.length} keys in ${scope} — ${pending.length} to translate${ctx.o.forceCli ? " (--force-cli)" : ""}` + ); + if (pending.length === 0) { + log(`cli: ${display(catalogPath)} already translated`); + return; + } + if (ctx.dry) { + log(`[DRY] write ${display(catalogPath)}`); + ctx.touched.add(catalogPath); + return; + } + const translated = await translateEntries(ctx, pending, "cli"); + for (const [id, text] of translated) setDeep(catalog, id, text); + await writeFile(ctx, catalogPath, JSON.stringify(catalog, null, 2) + "\n"); +} + +async function phaseReadme(ctx) { + const { entry, total, code } = ctx; + const guideRow = new RegExp(`^\\| \`${code}\` +\\|`, "m"); + const edits = [ + [ + "README.md", + (t) => + t.includes(`href="docs/i18n/${code}/README.md"`) + ? t + : insertReadmeFlagLink(t, entry, total), + ], + [ + "docs/i18n/README.md", + (t) => (t.includes(`(\`${code}\`)`) ? t : insertDocsIndexRow(t, entry, total)), + ], + [ + "docs/guides/I18N.md", + (t) => (guideRow.test(t) ? t : insertI18nGuideRow(t, entry, total, ctx.config.rtl)), + ], + ["llm.txt", (t) => bumpCounts(t, total)], + [ + "docs/README.md", + (t) => + t.replace( + /in \d+ locales \(plus the English originals — \d+ languages in total\)/, + `in ${total - 1} locales (plus the English originals — ${total} languages in total)` + ), + ], + ["docs/diagrams/i18n-flow.mmd", (t) => t.replace(/\(\d+ langs\)/, `(${total} langs)`)], + ]; + let llmChanged = false; + for (const [rel, transform] of edits) { + const file = path.join(ROOT, rel); + const text = await readText(file); + const next = transform(text); + if (next === text) { + log(`readme: ${rel} up to date`); + continue; + } + await writeFile(ctx, file, next); + if (rel === "llm.txt") llmChanged = true; + } + // llm.txt mirrors are strict copies of the root body (check-docs-sync). + if (llmChanged) { + runNode(ctx, path.join(SCRIPT_DIR, "sync-llm-mirrors.mjs"), [], "re-syncs docs/i18n/*/llm.txt"); + } +} + +async function phaseBars(ctx) { + const note = `rewrites the 🌐 Languages bar of every file under docs/ that carries one (${await countLanguageBarFiles()} today) — adds ${ctx.entry.flag} [${ctx.code}]`; + runNode(ctx, path.join(SCRIPT_DIR, "sync-language-bars.mjs"), [], note); +} + +async function phaseSite(ctx) { + const siteDir = ctx.o.siteDir; + if (!siteDir) { + log("site: --site-dir not given — skipped"); + return; + } + if (!existsSync(siteDir)) { + warn(`site: ${siteDir} does not exist — skipping the site phase`); + return; + } + const sourcePath = path.join(siteDir, "lang", "_source.en.json"); + if (!existsSync(sourcePath)) { + throw new Error(`site: ${sourcePath} not found — is --site-dir the omnirouteSite checkout?`); + } + + // lang/.json — flat keys; only the ones without a translation yet. + const source = await readJson(sourcePath); + const langPath = path.join(siteDir, "lang", `${ctx.code}.json`); + const existing = existsSync(langPath) ? await readJson(langPath) : {}; + const entries = Object.entries(source) + .filter(([id, text]) => typeof text === "string" && typeof existing[id] !== "string") + .map(([id, text]) => ({ id, text })); + log( + `site: ${Object.keys(source).length} keys in lang/_source.en.json — ${entries.length} to translate` + ); + if (entries.length === 0) { + log(`site: ${langPath} already complete`); + } else if (ctx.dry) { + log(`[DRY] write ${langPath}`); + } else { + const translated = await translateEntries(ctx, entries, "site"); + const out = { ...existing }; + for (const [id, text] of translated) out[id] = text; + await writeFile(ctx, langPath, JSON.stringify(out, null, 2) + "\n"); + } + + // js/i18n.js — SUPPORTED_LANGS. + const jsPath = path.join(siteDir, "js", "i18n.js"); + const js = await readText(jsPath); + const nextJs = addSupportedLang(js, ctx.code); + if (nextJs === js) { + log(`site: js/i18n.js already lists ${ctx.code}`); + } else { + await writeFile(ctx, jsPath, nextJs); + log(`${dryTag(ctx)}node --check ${jsPath}`); + if (!ctx.dry) execFileSync(process.execPath, ["--check", jsPath], { stdio: "inherit" }); + } + + // Language dropdowns. + for (const page of SITE_PAGES) { + const file = path.join(siteDir, page); + if (!existsSync(file)) { + warn(`site: ${page} not found — skipped`); + continue; + } + const html = await readText(file); + const next = addDropdownOption(html, { + code: ctx.code, + flag: ctx.entry.flag, + native: ctx.entry.native ?? ctx.entry.name, + }); + if (next === html) log(`site: ${page} already lists ${ctx.code}`); + else await writeFile(ctx, file, next); + } +} + +const PHASE_RUNNERS = { + config: phaseConfig, + flag: phaseFlag, + ui: phaseUi, + docs: phaseDocs, + cli: phaseCli, + readme: phaseReadme, + bars: phaseBars, + site: phaseSite, +}; + +async function runPrettier(ctx, phases) { + // Repo files only (the site has its own tooling) and only the kinds Prettier + // has a parser for — llm.txt and the .mmd diagram would make it exit 2. + const files = new Set( + [...ctx.touched].filter((file) => insideRepo(file) && /\.(json|md)$/.test(file)) + ); + const localeDir = path.join(ROOT, "docs", "i18n", ctx.code); + if (!ctx.dry && phases.includes("docs") && existsSync(localeDir)) { + for (const file of await walkFiles(localeDir)) if (file.endsWith(".md")) files.add(file); + } + if (files.size === 0) return; + const bin = path.join(ROOT, "node_modules", "prettier", "bin", "prettier.cjs"); + if (!existsSync(bin)) { + warn("prettier is not installed (node_modules) — skipping the formatting pass"); + return; + } + const list = [...files].sort(); + log(`${dryTag(ctx)}prettier --write ${list.length} file(s): ${list.map(display).join(" ")}`); + if (ctx.dry) return; + execFileSync(process.execPath, [bin, "--write", "--log-level=warn", ...list], { + cwd: ROOT, + stdio: "inherit", + }); +} + +// ----- Main ---------------------------------------------------------------- + +async function main() { + loadDotEnv(); + const o = parseArgs(process.argv); + const phases = selectPhases(o); + if (phases.length === 0) throw new Error("no phase left to run (check --only / --skip)"); + + const configPath = path.join(ROOT, "config", "i18n.json"); + const configText = await readText(configPath); + const before = JSON.parse(configText); + const stored = before.locales.find((locale) => locale.code === o.code) ?? null; + if (!stored) { + for (const required of ["english", "native", "flag"]) { + if (!o[required]) { + throw new Error( + `--${required} is required (${o.code} is not in config/i18n.json yet)\n\n${USAGE}` + ); + } + } + } else { + for (const field of ["english", "native", "flag"]) { + if (o[field] && o[field] !== stored[field]) { + warn( + `--${field}=${o[field]} differs from config/i18n.json (${stored[field]}) — the stored value wins` + ); + } + } + } + + // The working entry: the stored one for a known locale, else built from the + // arguments. `flagFile` is a helper key that is never written to the config. + const entry = stored + ? { ...stored, ...(o.flagFile ? { flagFile: o.flagFile } : {}) } + : { + code: o.code, + label: o.code.toUpperCase(), + name: o.native, + native: o.native, + english: o.english, + flag: o.flag, + ...(o.aliases.length ? { aliases: o.aliases } : {}), + ...(o.flagFile ? { flagFile: o.flagFile } : {}), + }; + const flagFile = flagFileFor(entry); // fails fast for a flag the file name cannot be derived from + + // config/i18n.json after this run (in memory; the config phase writes it). + let nextConfigText = stored ? configText : insertLocaleEntry(configText, entry); + if (o.rtl) { + const cfg = JSON.parse(nextConfigText); + if (!cfg.rtl.includes(o.code)) { + cfg.rtl = [...cfg.rtl, o.code].sort(); + nextConfigText = JSON.stringify(cfg, null, 2) + "\n"; + } + } + const config = JSON.parse(nextConfigText); + const total = config.locales.length; + + const ctx = { + o, + dry: o.dryRun, + code: o.code, + entry, + flagFile, + stored, + before, + config, + total, + configPath, + configText, + nextConfigText, + touched: new Set(), + failures: [], + backend: null, + }; + + if (!o.dryRun) { + if (!stored && !phases.includes("config")) { + const needing = phases.filter((phase) => PHASES_READING_CONFIG_FROM_DISK.includes(phase)); + if (needing.length) { + throw new Error( + `config/i18n.json does not list ${o.code} yet and the ${needing.join(", ")} phase(s) read it from disk — run the config phase first` + ); + } + } + // Fail before the first write when a translating phase has no backend. + if (phases.some((phase) => PHASES_TRANSLATING.includes(phase))) ctx.backend = backendConfig(); + } + + log( + `${dryTag(ctx)}${o.code} (${entry.english ?? entry.name}, ${entry.flag}) — phases: ${phases.join(", ")} — locales after: ${total}` + ); + for (const phase of phases) { + log(`--- ${phase} ---`); + await PHASE_RUNNERS[phase](ctx); + } + log("--- prettier ---"); + await runPrettier(ctx, phases); + + if (ctx.failures.length) { + warn( + `${ctx.failures.length} string(s) could not be translated — re-run the command to fill them in:` + ); + for (const failure of ctx.failures) console.warn(` - ${failure}`); + process.exitCode = 1; + } + log( + `${dryTag(ctx)}done: ${o.code} (${entry.english ?? entry.name}) — total locales now ${total}` + ); + log( + "next: node --import tsx/esm --test tests/unit/i18n-locale-surfaces-parity.test.ts && npm run i18n:check-ui-coverage && npm run check:docs-all" + ); +} + +main().catch((err) => { + console.error("[add-locale] ERROR", err?.stack || err?.message || String(err)); + process.exit(1); +}); diff --git a/scripts/i18n/lib/docs-core-set.mjs b/scripts/i18n/lib/docs-core-set.mjs new file mode 100644 index 0000000000..670c017cc3 --- /dev/null +++ b/scripts/i18n/lib/docs-core-set.mjs @@ -0,0 +1,58 @@ +/** + * The docs "core set": the source documents every existing locale mirror carries. + * + * The translation pipeline (`scripts/i18n/run-translation.mjs`) knows ~150 + * source files, but only a core of them is translated in every locale under + * `docs/i18n/`. A new locale should reach parity with its peers rather than + * translate the full set, so the core is derived from the tree itself — the + * intersection of the relative paths present under every existing, + * non-`docsExcluded` locale directory — instead of being hard-coded. + * + * docsLocaleDirs({ root, config }) → ["ar", "az", …] locale codes whose + * docs/i18n// directory exists + * computeDocsCoreSet({ root, config }) → sorted repo-relative source paths + * + * Paths that are not translation targets are dropped from the result: the + * strict-mirror stubs (`llm.txt`, `CHANGELOG.md`), the operator-only + * `docs/guides/I18N.md`, and any mirror whose English source no longer exists + * at `/`. Synchronous and filesystem-only — no network, no writes. + */ +import { existsSync, readdirSync, statSync } from "node:fs"; +import path from "node:path"; + +export const CORE_SET_EXCLUDED = ["llm.txt", "CHANGELOG.md", "docs/guides/I18N.md"]; + +function walkFiles(dir, base = dir, out = []) { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const abs = path.join(dir, entry.name); + if (entry.isDirectory()) walkFiles(abs, base, out); + else if (entry.isFile()) out.push(path.relative(base, abs).split(path.sep).join("/")); + } + return out; +} + +function isDirectory(abs) { + return existsSync(abs) && statSync(abs).isDirectory(); +} + +function isFile(abs) { + return existsSync(abs) && statSync(abs).isFile(); +} + +export function docsLocaleDirs({ root, config }) { + const excluded = new Set(config.docsExcluded ?? ["en"]); + return config.locales + .map((locale) => locale.code) + .filter((code) => !excluded.has(code) && isDirectory(path.join(root, "docs", "i18n", code))); +} + +export function computeDocsCoreSet({ root, config }) { + let shared = null; + for (const code of docsLocaleDirs({ root, config })) { + const files = new Set(walkFiles(path.join(root, "docs", "i18n", code))); + shared = shared === null ? files : new Set([...shared].filter((rel) => files.has(rel))); + } + if (shared === null) return []; + const dropped = new Set(CORE_SET_EXCLUDED); + return [...shared].filter((rel) => !dropped.has(rel) && isFile(path.join(root, rel))).sort(); +} diff --git a/scripts/i18n/lib/site-scaffold.mjs b/scripts/i18n/lib/site-scaffold.mjs new file mode 100644 index 0000000000..5096ae4746 --- /dev/null +++ b/scripts/i18n/lib/site-scaffold.mjs @@ -0,0 +1,105 @@ +/** + * Pure text helpers for the marketing site (omnirouteSite — a separate repo + * mirrored next to this one; `scripts/i18n/add-locale.mjs --site-dir=…` points + * at it). Text in, text out — no filesystem. + * + * addSupportedLang(jsSource, code) js/i18n.js + * Adds `code` to the `const SUPPORTED_LANGS = [ … ];` literal, keeps the + * codes in `localeCompare(…, "en")` order and re-flows the literal in the + * file's own style: a multi-line array is filled line by line up to its + * existing width (80 columns in the site file), a single-line array stays + * on one line. Returns the input unchanged when the code is already listed. + * + * addDropdownOption(html, { code, flag, native }) index.html and friends + * Inserts + * + * into every language menu of the page — a menu being a contiguous run of + * `.lang-option` lines — in `data-lang` code order, with the indentation of + * its neighbours. Returns the input unchanged when every menu lists the code. + * + * Both throw when the anchor they edit cannot be found. + */ + +const SUPPORTED_LANGS_RE = /(const SUPPORTED_LANGS = \[)([\s\S]*?)(\];)/; +const OPTION_LINE_RE = /^([ \t]*) width) { + lines.push(current); + current = `${indent}${token}`; + } else { + current = candidate; + } + } + if (current) lines.push(current); + return lines; +} + +export function addSupportedLang(jsSource, code) { + const match = jsSource.match(SUPPORTED_LANGS_RE); + if (!match) throw new Error("SUPPORTED_LANGS array literal not found"); + const list = match[2]; + const quote = list.match(/["']/)?.[0] ?? '"'; + const codes = [...list.matchAll(/["']([^"']+)["']/g)].map((m) => m[1]); + if (codes.includes(code)) return jsSource; + codes.push(code); + codes.sort(compareCodes); + const items = codes.map((c) => `${quote}${c}${quote}`); + + let body; + if (list.includes("\n")) { + const indent = list.match(/\n([ \t]*)\S/)?.[1] ?? " "; + const width = Math.max(MIN_FILL_WIDTH, ...list.split("\n").map((line) => line.length)); + body = `\n${fillLines(items, indent, width).join("\n")}\n`; + } else { + body = items.join(", "); + } + return jsSource.replace( + SUPPORTED_LANGS_RE, + (_whole, open, _list, close) => `${open}${body}${close}` + ); +} + +export function addDropdownOption(html, { code, flag, native }) { + const eol = html.includes("\r\n") ? "\r\n" : "\n"; + const lines = html.split(eol); + const options = []; + lines.forEach((line, index) => { + const m = line.match(OPTION_LINE_RE); + if (m) options.push({ index, indent: m[1], code: m[2] }); + }); + if (options.length === 0) throw new Error("no .lang-option lines found (language menu missing)"); + + // One menu = one contiguous run of option lines. + const menus = []; + for (const option of options) { + const menu = menus[menus.length - 1]; + if (menu && option.index === menu[menu.length - 1].index + 1) menu.push(option); + else menus.push([option]); + } + + // Insert bottom-up so the indexes of the menus above stay valid. + for (const menu of [...menus].reverse()) { + if (menu.some((option) => option.code === code)) continue; + const next = menu.find((option) => compareCodes(option.code, code) > 0); + const last = menu[menu.length - 1]; + const at = next ? next.index : last.index + 1; + const indent = (next ?? last).indent; + lines.splice( + at, + 0, + `${indent}${flag} ${native}` + ); + } + return lines.join(eol); +} diff --git a/scripts/i18n/lib/translate-backend.mjs b/scripts/i18n/lib/translate-backend.mjs index b984ca9eed..444d2a3545 100644 --- a/scripts/i18n/lib/translate-backend.mjs +++ b/scripts/i18n/lib/translate-backend.mjs @@ -143,10 +143,11 @@ export const BATCH_SYSTEM = (englishName, native) => /** * Pure parser for a batch answer. Accepts a bare JSON object or one wrapped in * a ```json fence; anything else (prose around the object, arrays, invalid - * JSON) throws. Every id in `expectedIds` must be present with a non-empty + * JSON) throws. Every id in `expectedIds` must be present as an OWN property + * (an inherited name such as `constructor` counts as missing) with a non-empty * string value — an empty translation would replace the `__MISSING__` marker * for good, so it fails the batch instead (the per-string path rejects empty - * completions the same way). + * completions the same way). Values are trimmed, like `translateString` does. * * @param {string} text raw assistant content * @param {string[]} expectedIds ids the caller sent (and expects back) @@ -169,12 +170,13 @@ export function parseBatchResponse(text, expectedIds) { } const out = new Map(); for (const id of expectedIds) { - if (!(id in parsed)) throw new Error(`batch response missing id ${id}`); + if (!Object.hasOwn(parsed, id)) throw new Error(`batch response missing id ${id}`); if (typeof parsed[id] !== "string") { throw new Error(`batch response has non-string value for ${id}`); } - if (!parsed[id].trim()) throw new Error(`batch response has empty value for ${id}`); - out.set(id, parsed[id]); + const value = parsed[id].trim(); + if (!value) throw new Error(`batch response has empty value for ${id}`); + out.set(id, value); } return out; } diff --git a/tests/unit/i18n-add-locale-helpers.test.ts b/tests/unit/i18n-add-locale-helpers.test.ts new file mode 100644 index 0000000000..eb3d36a48c --- /dev/null +++ b/tests/unit/i18n-add-locale-helpers.test.ts @@ -0,0 +1,267 @@ +/** + * Helpers behind `scripts/i18n/add-locale.mjs`: + * - `computeDocsCoreSet` (scripts/i18n/lib/docs-core-set.mjs) — the docs a new + * locale must carry to be at parity with every existing locale mirror. + * - `addSupportedLang` / `addDropdownOption` (scripts/i18n/lib/site-scaffold.mjs) + * — the two text edits made to the marketing site (a separate repo, so the + * fixtures below are verbatim copies of its `js/i18n.js` and `index.html`). + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { computeDocsCoreSet, docsLocaleDirs } from "../../scripts/i18n/lib/docs-core-set.mjs"; +import { addDropdownOption, addSupportedLang } from "../../scripts/i18n/lib/site-scaffold.mjs"; + +type I18nConfig = { + default: string; + rtl: string[]; + docsExcluded?: string[]; + locales: Array<{ code: string; flag?: string; native?: string }>; +}; + +// --------------------------------------------------------------------------- +// computeDocsCoreSet +// --------------------------------------------------------------------------- + +function withTempRoot(fn: (root: string) => void): void { + const root = mkdtempSync(path.join(tmpdir(), "i18n-core-set-")); + try { + fn(root); + } finally { + rmSync(root, { recursive: true, force: true }); + } +} + +function touch(root: string, rel: string, body = "# x\n"): void { + const abs = path.join(root, rel); + mkdirSync(path.dirname(abs), { recursive: true }); + writeFileSync(abs, body); +} + +const CONFIG: I18nConfig = { + default: "en", + rtl: [], + docsExcluded: ["en"], + locales: [{ code: "aa" }, { code: "bb" }, { code: "cc" }, { code: "en" }], +}; + +test("computeDocsCoreSet intersects the locale mirrors and drops excluded / sourceless paths", () => { + withTempRoot((root) => { + // English sources. + for (const rel of [ + "README.md", + "docs/guides/USER_GUIDE.md", + "docs/ONLY_IN_AA.md", + "llm.txt", + "CHANGELOG.md", + "docs/guides/I18N.md", + ]) { + touch(root, rel); + } + // Two complete-ish locales: everything shared except ONLY_IN_AA.md; both carry + // the strict-mirror files, the operator-only guide and a mirror whose English + // source no longer exists (GHOST.md). + for (const locale of ["aa", "bb"]) { + for (const rel of [ + "README.md", + "docs/guides/USER_GUIDE.md", + "llm.txt", + "CHANGELOG.md", + "docs/guides/I18N.md", + "docs/GHOST.md", + ]) { + touch(root, `docs/i18n/${locale}/${rel}`); + } + } + touch(root, "docs/i18n/aa/docs/ONLY_IN_AA.md"); + // The docsExcluded locale has a (tiny) directory — it must not shrink the set. + touch(root, "docs/i18n/en/README.md"); + // The index file next to the locale directories is not a locale. + touch(root, "docs/i18n/README.md"); + // `cc` is configured but has no mirror directory yet (the locale being added). + + assert.deepEqual(docsLocaleDirs({ root, config: CONFIG }), ["aa", "bb"]); + assert.deepEqual(computeDocsCoreSet({ root, config: CONFIG }), [ + "README.md", + "docs/guides/USER_GUIDE.md", + ]); + }); +}); + +test("computeDocsCoreSet returns a sorted list and an empty one when no mirror exists", () => { + withTempRoot((root) => { + assert.deepEqual(computeDocsCoreSet({ root, config: CONFIG }), []); + for (const rel of ["docs/z.md", "docs/a.md", "AGENTS.md"]) { + touch(root, rel); + touch(root, `docs/i18n/aa/${rel}`); + } + assert.deepEqual(computeDocsCoreSet({ root, config: CONFIG }), [ + "AGENTS.md", + "docs/a.md", + "docs/z.md", + ]); + }); +}); + +test("computeDocsCoreSet excludes `en` by default when docsExcluded is absent", () => { + withTempRoot((root) => { + touch(root, "README.md"); + touch(root, "docs/i18n/aa/README.md"); + touch(root, "docs/i18n/en/OTHER.md"); + touch(root, "OTHER.md"); + const config: I18nConfig = { + default: "en", + rtl: [], + locales: [{ code: "aa" }, { code: "en" }], + }; + assert.deepEqual(computeDocsCoreSet({ root, config }), ["README.md"]); + }); +}); + +// --------------------------------------------------------------------------- +// addSupportedLang — js/i18n.js (fixture: verbatim lines of the site file) +// --------------------------------------------------------------------------- + +const I18N_JS = [ + 'const LANG_BASE = document.documentElement.dataset.langBase || "";', + "const SUPPORTED_LANGS = [", + ' "ar", "az", "bg", "bn", "cs", "da", "de", "en", "es", "fa", "fi", "fr", "gu",', + ' "he", "hi", "hu", "id", "in", "it", "ja", "ko", "mr", "ms", "nl", "no", "phi",', + ' "pl", "pt", "pt-BR", "ro", "ru", "sk", "sv", "sw", "ta", "te", "th", "tr",', + ' "uk-UA", "ur", "vi", "zh-CN",', + "];", + "// Right-to-left scripts — flip document direction when active.", + 'const RTL_LANGS = ["ar", "fa", "he", "ur"];', + "", +].join("\n"); + +const I18N_JS_WITH_EL = [ + 'const LANG_BASE = document.documentElement.dataset.langBase || "";', + "const SUPPORTED_LANGS = [", + ' "ar", "az", "bg", "bn", "cs", "da", "de", "el", "en", "es", "fa", "fi", "fr",', + ' "gu", "he", "hi", "hu", "id", "in", "it", "ja", "ko", "mr", "ms", "nl", "no",', + ' "phi", "pl", "pt", "pt-BR", "ro", "ru", "sk", "sv", "sw", "ta", "te", "th",', + ' "tr", "uk-UA", "ur", "vi", "zh-CN",', + "];", + "// Right-to-left scripts — flip document direction when active.", + 'const RTL_LANGS = ["ar", "fa", "he", "ur"];', + "", +].join("\n"); + +test("addSupportedLang inserts the code in order and re-flows the array at the file's width", () => { + assert.equal(addSupportedLang(I18N_JS, "el"), I18N_JS_WITH_EL); +}); + +test("addSupportedLang is idempotent and leaves an already-listed code untouched", () => { + assert.equal(addSupportedLang(I18N_JS, "de"), I18N_JS); + assert.equal(addSupportedLang(I18N_JS, "zh-CN"), I18N_JS); + assert.equal(addSupportedLang(addSupportedLang(I18N_JS, "el"), "el"), I18N_JS_WITH_EL); +}); + +test("addSupportedLang keeps region variants in localeCompare order and appends a last code", () => { + const withPtPt = addSupportedLang(I18N_JS, "pt-PT"); + assert.match(withPtPt, /"pt", "pt-BR", "pt-PT", "ro"/); + const withZhTw = addSupportedLang(I18N_JS, "zh-TW"); + assert.ok(withZhTw.includes(' "uk-UA", "ur", "vi", "zh-CN", "zh-TW",\n];')); + // Only the array literal changes — the rest of the file is byte-identical. + assert.equal( + withZhTw.split("const SUPPORTED_LANGS")[0], + I18N_JS.split("const SUPPORTED_LANGS")[0] + ); + assert.equal(withZhTw.split("];")[1], I18N_JS.split("];")[1]); +}); + +test("addSupportedLang keeps a single-line array on one line and rejects a file without the array", () => { + assert.equal( + addSupportedLang('const SUPPORTED_LANGS = ["en", "pt-BR"];\n', "el"), + 'const SUPPORTED_LANGS = ["el", "en", "pt-BR"];\n' + ); + assert.throws(() => addSupportedLang("const OTHER = [];\n", "el"), /SUPPORTED_LANGS/); +}); + +// --------------------------------------------------------------------------- +// addDropdownOption — index.html language menu (fixture: verbatim site lines) +// --------------------------------------------------------------------------- + +const OPTION = (code: string, label: string, extraClass = ""): string => + ` ${label}`; + +const DROPDOWN = [ + ' ", + "", +].join("\n"); + +const EL = { code: "el", flag: "🇬🇷", native: "Ελληνικά" }; +const EL_LINE = OPTION("el", "🇬🇷 Ελληνικά"); + +test("addDropdownOption inserts the exact option line in data-lang order", () => { + const out = addDropdownOption(DROPDOWN, EL); + const lines = out.split("\n"); + assert.equal(lines[3], EL_LINE); + assert.equal(lines[2], OPTION("de", "🇩🇪 Deutsch")); + assert.equal(lines[4], OPTION("en", "🇺🇸 English", " active")); + assert.equal(lines.length, DROPDOWN.split("\n").length + 1); +}); + +test("addDropdownOption is idempotent and leaves a listed code untouched", () => { + const once = addDropdownOption(DROPDOWN, EL); + assert.equal(addDropdownOption(once, EL), once); + assert.equal( + addDropdownOption(DROPDOWN, { code: "de", flag: "🇩🇪", native: "Deutsch" }), + DROPDOWN + ); +}); + +test("addDropdownOption places a first / last / region-variant code correctly", () => { + const first = addDropdownOption(DROPDOWN, { code: "aa", flag: "🏳️", native: "Aa" }).split("\n"); + assert.equal(first[1], OPTION("aa", "🏳️ Aa")); + assert.equal(first[2], OPTION("ar", "🇸🇦 العربية")); + + const last = addDropdownOption(DROPDOWN, { code: "zu", flag: "🇿🇦", native: "isiZulu" }).split( + "\n" + ); + assert.equal(last[7], OPTION("zu", "🇿🇦 isiZulu")); + assert.equal(last[8], " "); + + const variant = addDropdownOption(DROPDOWN, { + code: "pt-PT", + flag: "🇵🇹", + native: "Português (Portugal)", + }).split("\n"); + assert.equal(variant[5], OPTION("pt-BR", "🇧🇷 Português (Brasil)")); + assert.equal(variant[6], OPTION("pt-PT", "🇵🇹 Português (Portugal)")); + assert.equal(variant[7], OPTION("zh-CN", "🇨🇳 中文 (简体)")); +}); + +test("addDropdownOption handles the two-entry why/ page and every dropdown on a page", () => { + const why = [OPTION("en", "🇺🇸 English", " active"), OPTION("pt-BR", "🇧🇷 Português"), ""].join( + "\n" + ); + // "el" sorts before "en" — it becomes the first entry of that two-entry menu. + assert.equal( + addDropdownOption(why, EL), + [EL_LINE, OPTION("en", "🇺🇸 English", " active"), OPTION("pt-BR", "🇧🇷 Português"), ""].join("\n") + ); + + const twoMenus = `${DROPDOWN}\n