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

@@ -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);
});
}