mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-15 19:32:20 +03:00
feat(i18n): add-locale orchestrator (config, flag, ui, docs, cli, readme, bars, site)
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=<locale> filter. - translate-backend parseBatchResponse: Object.hasOwn + trimmed values. - package.json: i18n:add-locale script.
This commit is contained in:
@@ -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=<locale>]
|
||||
* --code=<locale> 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++;
|
||||
|
||||
@@ -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",
|
||||
|
||||
799
scripts/i18n/add-locale.mjs
Normal file
799
scripts/i18n/add-locale.mjs
Normal file
@@ -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=<phase,…>] [--skip=<phase,…>] [--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/<cc>.svg from lipis/flag-icons (MIT) when missing
|
||||
* ui src/i18n/messages/<code>.json — scaffold, then sync-ui-keys --translate-markers
|
||||
* docs docs/i18n/<code>/** 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/<code>.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 <site-dir>/lang/<code>.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=<code> --english=<name> --native=<name> --flag=<emoji> [options]
|
||||
|
||||
--code=<code> locale code, e.g. el, pt-PT, zh-TW (required)
|
||||
--english=<name> English language name, e.g. Greek (required for a new locale)
|
||||
--native=<name> native language name, e.g. Ελληνικά (required for a new locale)
|
||||
--flag=<emoji> flag emoji, e.g. 🇬🇷 (required for a new locale)
|
||||
--aliases=<csv> browser/OS tags resolving to this locale, e.g. el-gr
|
||||
--flag-file=<file> docs/assets/flags/<file> 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=<dir> omnirouteSite checkout (relative to the repo root or absolute); skipped when absent
|
||||
--batch-size=<n> strings per translation request (default ${DEFAULT_BATCH_SIZE})
|
||||
--only=<phase,…> run only these phases
|
||||
--skip=<phase,…> 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("<svg")) throw new Error(`flag download is not an SVG: ${url}`);
|
||||
await writeFile(ctx, file, svg);
|
||||
}
|
||||
|
||||
async function phaseUi(ctx) {
|
||||
const dir = path.join(ROOT, "src", "i18n", "messages");
|
||||
const file = path.join(dir, `${ctx.code}.json`);
|
||||
const source = flattenLeaves(await readJson(path.join(dir, "en.json")));
|
||||
const existing = existsSync(file) ? flattenLeaves(await readJson(file)) : null;
|
||||
// sync-ui-keys only fills locales that already exist on disk.
|
||||
if (!existing) await writeFile(ctx, file, "{}\n");
|
||||
const have = new Set((existing ?? []).map((leaf) => 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/<code>.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);
|
||||
});
|
||||
58
scripts/i18n/lib/docs-core-set.mjs
Normal file
58
scripts/i18n/lib/docs-core-set.mjs
Normal file
@@ -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/<code>/ 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 `<root>/<path>`. 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();
|
||||
}
|
||||
105
scripts/i18n/lib/site-scaffold.mjs
Normal file
105
scripts/i18n/lib/site-scaffold.mjs
Normal file
@@ -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
|
||||
* <a href="#" class="lang-option" data-lang="<code>" role="menuitem"><flag> <native></a>
|
||||
* 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]*)<a href="#" class="lang-option(?: [^"]*)?" data-lang="([^"]+)"/;
|
||||
const MIN_FILL_WIDTH = 80;
|
||||
|
||||
function compareCodes(a, b) {
|
||||
return a.localeCompare(b, "en");
|
||||
}
|
||||
|
||||
function fillLines(items, indent, width) {
|
||||
const lines = [];
|
||||
let current = "";
|
||||
for (const item of items) {
|
||||
const token = `${item},`;
|
||||
const candidate = current ? `${current} ${token}` : `${indent}${token}`;
|
||||
if (current && candidate.length > 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}<a href="#" class="lang-option" data-lang="${code}" role="menuitem">${flag} ${native}</a>`
|
||||
);
|
||||
}
|
||||
return lines.join(eol);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
267
tests/unit/i18n-add-locale-helpers.test.ts
Normal file
267
tests/unit/i18n-add-locale-helpers.test.ts
Normal file
@@ -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 =>
|
||||
` <a href="#" class="lang-option${extraClass}" data-lang="${code}" role="menuitem">${label}</a>`;
|
||||
|
||||
const DROPDOWN = [
|
||||
' <div id="langDropdown" class="lang-dropdown" role="menu">',
|
||||
OPTION("ar", "🇸🇦 العربية"),
|
||||
OPTION("de", "🇩🇪 Deutsch"),
|
||||
OPTION("en", "🇺🇸 English", " active"),
|
||||
OPTION("es", "🇪🇸 Español"),
|
||||
OPTION("pt-BR", "🇧🇷 Português (Brasil)"),
|
||||
OPTION("zh-CN", "🇨🇳 中文 (简体)"),
|
||||
" </div>",
|
||||
"",
|
||||
].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], " </div>");
|
||||
|
||||
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<footer>\n${DROPDOWN}`;
|
||||
const out = addDropdownOption(twoMenus, EL);
|
||||
assert.equal(out.split("\n").filter((line) => line === EL_LINE).length, 2);
|
||||
assert.equal(addDropdownOption(out, EL), out);
|
||||
});
|
||||
|
||||
test("addDropdownOption preserves CRLF endings and rejects a page without a language menu", () => {
|
||||
const crlf = DROPDOWN.replace(/\n/g, "\r\n");
|
||||
const out = addDropdownOption(crlf, EL);
|
||||
assert.ok(out.includes(`${OPTION("de", "🇩🇪 Deutsch")}\r\n${EL_LINE}\r\n`));
|
||||
assert.equal(out.split("\r\n").length, crlf.split("\r\n").length + 1);
|
||||
assert.throws(() => addDropdownOption("<div>no menu</div>\n", EL), /lang-option/);
|
||||
});
|
||||
@@ -149,6 +149,30 @@ test("parseBatchResponse rejects an empty or whitespace-only value", () => {
|
||||
assert.throws(() => parseBatchResponse('{"k1":" \\n"}', ["k1"]), /empty value for k1/);
|
||||
});
|
||||
|
||||
test("parseBatchResponse trims every value, like the per-string path does", () => {
|
||||
assert.deepEqual(
|
||||
[...parseBatchResponse('{"k1":" Salvar \\n","k2":"\\tCancelar"}', ["k1", "k2"])],
|
||||
[
|
||||
["k1", "Salvar"],
|
||||
["k2", "Cancelar"],
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
test("parseBatchResponse only honours own properties — inherited names count as missing", () => {
|
||||
// `"constructor" in {}` is true, so an `in` check would report the wrong
|
||||
// error (non-string value) for an id the model simply dropped.
|
||||
assert.throws(() => parseBatchResponse('{"k1":"a"}', ["constructor"]), /missing id constructor/);
|
||||
assert.throws(() => parseBatchResponse('{"k1":"a"}', ["toString"]), /missing id toString/);
|
||||
assert.deepEqual(
|
||||
[...parseBatchResponse('{"constructor":"x","toString":"y"}', ["constructor", "toString"])],
|
||||
[
|
||||
["constructor", "x"],
|
||||
["toString", "y"],
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// translateBatch — one chat request per batch, mapped back by id
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user