feat(i18n): language bars generated from config (sync-language-bars)

This commit is contained in:
Markus Hartung
2026-09-02 07:15:29 -03:00
parent 8180f862cb
commit b84e47cabf
5 changed files with 220 additions and 31 deletions

View File

@@ -161,6 +161,7 @@
"i18n:check-glossary:ko": "node scripts/i18n/check-glossary-consistency.mjs --locale=ko",
"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",
"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",

View File

@@ -0,0 +1,50 @@
/**
* Shared builders for the `🌐 **Languages:** …` bars that head every English
* doc and every translated mirror. `config` is the parsed `config/i18n.json`
* (`locales[]` with `code`, `flag`, `native`); all paths are repo-relative POSIX
* (`docs/guides/USER_GUIDE.md`, `README.md`) and the links are relative to the
* file that carries the bar.
*
* buildMirrorBar(rel, locale, config) → docs/i18n/<locale>/… format:
* 🌐 **Languages:** 🇺🇸 [English](../../../README.md) · 🇸🇦 [ar](../ar/README.md) · …
* buildSourceBar(rel, config) → English source format:
* 🌐 **Languages:** 🇺🇸 [English](./USER_GUIDE.md) | 🇧🇷 [Português (Brasil)](../i18n/pt-BR/docs/guides/USER_GUIDE.md) | …
* replaceLanguageBar(markdown, bar) → swaps the first bar line, `null` when there is none.
*/
import path from "node:path";
const DOCS_I18N = "docs/i18n";
function mirrorPath(relSource, locale) {
return path.posix.join(DOCS_I18N, locale, relSource);
}
export function buildMirrorBar(relSource, locale, config) {
const targetDir = path.posix.dirname(mirrorPath(relSource, locale));
const parts = [`🇺🇸 [English](${path.posix.relative(targetDir, relSource)})`];
for (const entry of config.locales) {
if (entry.code === "en" || entry.code === locale) continue;
const peer = path.posix.relative(targetDir, mirrorPath(relSource, entry.code));
parts.push(`${entry.flag} [${entry.code}](${peer})`);
}
return `🌐 **Languages:** ${parts.join(" · ")}`;
}
export function buildSourceBar(relSource, config) {
const sourceDir = path.posix.dirname(relSource);
const parts = [`🇺🇸 [English](./${path.posix.basename(relSource)})`];
for (const entry of config.locales) {
if (entry.code === "en") continue;
const peer = path.posix.relative(sourceDir, mirrorPath(relSource, entry.code));
parts.push(`${entry.flag} [${entry.native ?? entry.name}](${peer})`);
}
return `🌐 **Languages:** ${parts.join(" | ")}`;
}
export function replaceLanguageBar(markdown, bar) {
const lines = markdown.split("\n");
const index = lines.findIndex((line) => line.startsWith("🌐 **Languages:**"));
if (index === -1) return null;
lines[index] = bar;
return lines.join("\n");
}

View File

@@ -36,6 +36,7 @@ import crypto from "node:crypto";
import process from "node:process";
import { fileURLToPath, pathToFileURL } from "node:url";
import { normalizeLocaleText } from "./glossary-normalize.mjs";
import { buildMirrorBar } from "./lib/language-bar.mjs";
// ----- .env loader --------------------------------------------------------
// Loads variables from a local `.env` (gitignored) into process.env without
@@ -268,36 +269,6 @@ function targetPathFor(relSource, locale) {
return path.join(DOCS_I18N_DIR, locale, relSource);
}
function relativeBackToRoot(targetAbsPath) {
// From the target file's directory back to the repo root, used to build the
// "🇺🇸 English" link in the language bar.
const targetDir = path.dirname(targetAbsPath);
const rel = path.relative(targetDir, ROOT);
return rel === "" ? "." : rel;
}
function buildLanguageBar(relSource, locale, config) {
const targetAbs = targetPathFor(relSource, locale);
const targetDir = path.dirname(targetAbs);
const rootRel = relativeBackToRoot(targetAbs);
const parts = [];
// English link → source file relative to target dir.
const enRel = path.relative(targetDir, path.join(ROOT, relSource));
parts.push(`🇺🇸 [English](${enRel.split(path.sep).join("/")})`);
for (const entry of config.locales) {
if (entry.code === "en" || entry.code === locale) continue;
const peerAbs = targetPathFor(relSource, entry.code);
const peerRel = path.relative(targetDir, peerAbs).split(path.sep).join("/");
parts.push(`${entry.flag} [${entry.code}](${peerRel})`);
}
return `🌐 **Languages:** ${parts.join(" · ")}`;
// Quiet the unused linter warning for rootRel — kept here for future expansion.
void rootRel;
}
function extractTopHeading(markdown) {
const m = markdown.match(/^# (.+)\r?\n/);
return m ? m[1].trim() : null;
@@ -586,7 +557,7 @@ async function main() {
const heading = topHeading
? `# ${topHeading} (${localeEntry.native})`
: `# ${path.basename(task.rel, ".md")} (${localeEntry.native})`;
const langBar = buildLanguageBar(task.rel, task.locale, config);
const langBar = buildMirrorBar(task.rel, task.locale, config);
const rawContent = `${heading}\n\n${langBar}\n\n---\n\n${translatedBody.trim()}\n`;
// Pre-format with Prettier (markdown parser) so the on-disk content
// matches what `lint-staged` would produce. This keeps `target_hash`

View File

@@ -0,0 +1,67 @@
#!/usr/bin/env node
/**
* Rewrites every `🌐 **Languages:** …` bar from config/i18n.json:
* - English sources (root MDs + docs/**.md that already carry a bar) → buildSourceBar
* - mirrors under docs/i18n/<locale>/ → buildMirrorBar
* Never inserts a bar where there is none. Idempotent. `--dry-run` lists files.
*/
import { promises as fs, existsSync } from "node:fs";
import path from "node:path";
import process from "node:process";
import { fileURLToPath, pathToFileURL } from "node:url";
import { buildMirrorBar, buildSourceBar, replaceLanguageBar } from "./lib/language-bar.mjs";
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
async function walkMd(dir, out = []) {
for (const entry of await fs.readdir(dir, { withFileTypes: true })) {
const abs = path.join(dir, entry.name);
if (entry.isDirectory()) await walkMd(abs, out);
else if (entry.name.endsWith(".md") || entry.name === "llm.txt") out.push(abs);
}
return out;
}
export async function syncLanguageBars({ root = ROOT, dryRun = false } = {}) {
const config = JSON.parse(await fs.readFile(path.join(root, "config", "i18n.json"), "utf8"));
const changed = [];
// 1. English sources under docs/ (excluding docs/i18n).
for (const abs of await walkMd(path.join(root, "docs"))) {
const rel = path.relative(root, abs).split(path.sep).join("/");
if (rel.startsWith("docs/i18n/")) continue;
const text = await fs.readFile(abs, "utf8");
const next = replaceLanguageBar(text, buildSourceBar(rel, config));
if (next && next !== text) {
changed.push(rel);
if (!dryRun) await fs.writeFile(abs, next, "utf8");
}
}
// 2. Mirrors.
for (const entry of config.locales) {
const dir = path.join(root, "docs", "i18n", entry.code);
if (!existsSync(dir)) continue;
for (const abs of await walkMd(dir)) {
const relInMirror = path.relative(dir, abs).split(path.sep).join("/");
const text = await fs.readFile(abs, "utf8");
const next = replaceLanguageBar(text, buildMirrorBar(relInMirror, entry.code, config));
if (next && next !== text) {
changed.push(path.relative(root, abs).split(path.sep).join("/"));
if (!dryRun) await fs.writeFile(abs, next, "utf8");
}
}
}
return changed;
}
const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
if (isDirectRun) {
const dryRun = process.argv.includes("--dry-run");
syncLanguageBars({ dryRun })
.then((changed) =>
console.log(`[i18n-bars] ${dryRun ? "would update" : "updated"} ${changed.length} file(s)`)
)
.catch((err) => {
console.error("[i18n-bars] ERROR", err?.stack || err?.message || String(err));
process.exit(1);
});
}

View File

@@ -0,0 +1,100 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import {
buildMirrorBar,
buildSourceBar,
replaceLanguageBar,
} from "../../scripts/i18n/lib/language-bar.mjs";
import { syncLanguageBars } from "../../scripts/i18n/sync-language-bars.mjs";
const config = {
locales: [
{ code: "en", flag: "🇺🇸", native: "English" },
{ code: "ar", flag: "🇸🇦", native: "العربية" },
{ code: "pt-BR", flag: "🇧🇷", native: "Português (Brasil)" },
],
};
test("buildMirrorBar links English back to the source and every other locale sideways", () => {
// Every link is the minimal relative path (path.posix.relative), the same form the
// pre-refactor buildLanguageBar in run-translation.mjs emitted: from
// docs/i18n/ar/docs/guides/ the English source is four levels up, not "<root>/docs/…".
assert.equal(
buildMirrorBar("docs/guides/USER_GUIDE.md", "ar", config),
"🌐 **Languages:** 🇺🇸 [English](../../../../guides/USER_GUIDE.md) · 🇧🇷 [pt-BR](../../../pt-BR/docs/guides/USER_GUIDE.md)"
);
assert.equal(
buildMirrorBar("README.md", "pt-BR", config),
"🌐 **Languages:** 🇺🇸 [English](../../../README.md) · 🇸🇦 [ar](../ar/README.md)"
);
});
test("buildSourceBar lists every locale with its native name and a pipe separator", () => {
assert.equal(
buildSourceBar("docs/guides/USER_GUIDE.md", config),
"🌐 **Languages:** 🇺🇸 [English](./USER_GUIDE.md) | 🇸🇦 [العربية](../i18n/ar/docs/guides/USER_GUIDE.md) | 🇧🇷 [Português (Brasil)](../i18n/pt-BR/docs/guides/USER_GUIDE.md)"
);
});
test("replaceLanguageBar swaps only the bar line and returns null when there is none", () => {
const md = "# T\n\n🌐 **Languages:** old\n\n---\nbody\n";
assert.equal(
replaceLanguageBar(md, "🌐 **Languages:** new"),
"# T\n\n🌐 **Languages:** new\n\n---\nbody\n"
);
assert.equal(replaceLanguageBar("# T\n\nbody\n", "x"), null);
});
test("syncLanguageBars rewrites stale bars from config, lists them in dry-run and leaves bar-less files alone", async () => {
const root = mkdtempSync(path.join(os.tmpdir(), "i18n-language-bar-"));
try {
mkdirSync(path.join(root, "config"), { recursive: true });
writeFileSync(path.join(root, "config", "i18n.json"), JSON.stringify(config), "utf8");
// English source with a stale bar.
const enDoc = path.join(root, "docs", "guides", "X.md");
mkdirSync(path.dirname(enDoc), { recursive: true });
const enBefore = "# X\n\n🌐 **Languages:** stale\n\n---\n\nbody\n";
writeFileSync(enDoc, enBefore, "utf8");
// Mirror with a stale bar.
const arDoc = path.join(root, "docs", "i18n", "ar", "docs", "guides", "X.md");
mkdirSync(path.dirname(arDoc), { recursive: true });
const arBefore = "# X (العربية)\n\n🌐 **Languages:** stale\n\n---\n\ncorpo\n";
writeFileSync(arDoc, arBefore, "utf8");
// Mirror without any bar — must never gain one.
const bareDoc = path.join(root, "docs", "i18n", "pt-BR", "docs", "guides", "X.md");
mkdirSync(path.dirname(bareDoc), { recursive: true });
const bareText = "# X (Português)\n\nsem barra\n";
writeFileSync(bareDoc, bareText, "utf8");
const expectedChanged = ["docs/guides/X.md", "docs/i18n/ar/docs/guides/X.md"];
// Dry-run: reports the stale files and writes nothing.
assert.deepEqual(await syncLanguageBars({ root, dryRun: true }), expectedChanged);
assert.equal(readFileSync(enDoc, "utf8"), enBefore);
assert.equal(readFileSync(arDoc, "utf8"), arBefore);
assert.equal(readFileSync(bareDoc, "utf8"), bareText);
// Apply: both stale bars are regenerated in place, the rest of each file is untouched.
assert.deepEqual(await syncLanguageBars({ root, dryRun: false }), expectedChanged);
assert.equal(
readFileSync(enDoc, "utf8"),
"# X\n\n🌐 **Languages:** 🇺🇸 [English](./X.md) | 🇸🇦 [العربية](../i18n/ar/docs/guides/X.md) | 🇧🇷 [Português (Brasil)](../i18n/pt-BR/docs/guides/X.md)\n\n---\n\nbody\n"
);
assert.equal(
readFileSync(arDoc, "utf8"),
"# X (العربية)\n\n🌐 **Languages:** 🇺🇸 [English](../../../../guides/X.md) · 🇧🇷 [pt-BR](../../../pt-BR/docs/guides/X.md)\n\n---\n\ncorpo\n"
);
assert.equal(readFileSync(bareDoc, "utf8"), bareText);
// Idempotent: a second pass finds nothing left to update.
assert.deepEqual(await syncLanguageBars({ root, dryRun: true }), []);
} finally {
rmSync(root, { recursive: true, force: true });
}
});