diff --git a/package.json b/package.json index d9db12fdae..3453b27ae6 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/i18n/lib/language-bar.mjs b/scripts/i18n/lib/language-bar.mjs new file mode 100644 index 0000000000..b326a953a4 --- /dev/null +++ b/scripts/i18n/lib/language-bar.mjs @@ -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//โ€ฆ 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"); +} diff --git a/scripts/i18n/run-translation.mjs b/scripts/i18n/run-translation.mjs index 5a38c0c60d..91636931bc 100755 --- a/scripts/i18n/run-translation.mjs +++ b/scripts/i18n/run-translation.mjs @@ -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` diff --git a/scripts/i18n/sync-language-bars.mjs b/scripts/i18n/sync-language-bars.mjs new file mode 100644 index 0000000000..9f36201d46 --- /dev/null +++ b/scripts/i18n/sync-language-bars.mjs @@ -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// โ†’ 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); + }); +} diff --git a/tests/unit/i18n-language-bar.test.ts b/tests/unit/i18n-language-bar.test.ts new file mode 100644 index 0000000000..212950685e --- /dev/null +++ b/tests/unit/i18n-language-bar.test.ts @@ -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 "/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 }); + } +});