mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-16 11:52:26 +03:00
feat(i18n): language bars generated from config (sync-language-bars)
This commit is contained in:
@@ -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",
|
||||
|
||||
50
scripts/i18n/lib/language-bar.mjs
Normal file
50
scripts/i18n/lib/language-bar.mjs
Normal 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");
|
||||
}
|
||||
@@ -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`
|
||||
|
||||
67
scripts/i18n/sync-language-bars.mjs
Normal file
67
scripts/i18n/sync-language-bars.mjs
Normal 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);
|
||||
});
|
||||
}
|
||||
100
tests/unit/i18n-language-bar.test.ts
Normal file
100
tests/unit/i18n-language-bar.test.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user