Spaces:
Runtime error
Runtime error
| /** | |
| * DEPRECATED 2026-05-13. Use `npm run i18n:run` | |
| * (scripts/i18n/run-translation.mjs) for docs translation. | |
| * | |
| * This Google-Translate-backed generator is kept for the `messages` and | |
| * `readme` modes which target `src/i18n/messages/*.json` and root README | |
| * variants — those are not yet handled by the new LLM pipeline. The `docs` | |
| * mode is superseded and will be removed in v3.10. | |
| */ | |
| import { promises as fs } from "node:fs"; | |
| import path from "node:path"; | |
| console.warn( | |
| "[generate-multilang] DEPRECATED: prefer `npm run i18n:run` for docs (this script will be removed in v3.10)." | |
| ); | |
| const ROOT = process.cwd(); | |
| const MESSAGES_DIR = path.join(ROOT, "src", "i18n", "messages"); | |
| const DOCS_DIR = path.join(ROOT, "docs"); | |
| const DOCS_I18N_DIR = path.join(DOCS_DIR, "i18n"); | |
| const PLACEHOLDER_PREFIX = "__MISSING__:"; | |
| const DOC_SOURCE_FILES = [ | |
| "API_REFERENCE.md", | |
| "ARCHITECTURE.md", | |
| "CODEBASE_DOCUMENTATION.md", | |
| "FEATURES.md", | |
| "TROUBLESHOOTING.md", | |
| "USER_GUIDE.md", | |
| "VM_DEPLOYMENT_GUIDE.md", | |
| ]; | |
| const LOCALE_SPECS = [ | |
| { | |
| code: "en", | |
| googleTl: "en", | |
| label: "EN", | |
| flag: "🇺🇸", | |
| languageName: "English", | |
| readmeName: "English", | |
| docsName: "English", | |
| }, | |
| { | |
| code: "pt-BR", | |
| googleTl: "pt", | |
| label: "PT-BR", | |
| flag: "🇧🇷", | |
| languageName: "Português (Brasil)", | |
| readmeName: "Português (Brasil)", | |
| docsName: "Português (Brasil)", | |
| }, | |
| { | |
| code: "es", | |
| googleTl: "es", | |
| label: "ES", | |
| flag: "🇪🇸", | |
| languageName: "Español", | |
| readmeName: "Español", | |
| docsName: "Español", | |
| }, | |
| { | |
| code: "fr", | |
| googleTl: "fr", | |
| label: "FR", | |
| flag: "🇫🇷", | |
| languageName: "Français", | |
| readmeName: "Français", | |
| docsName: "Français", | |
| }, | |
| { | |
| code: "it", | |
| googleTl: "it", | |
| label: "IT", | |
| flag: "🇮🇹", | |
| languageName: "Italiano", | |
| readmeName: "Italiano", | |
| docsName: "Italiano", | |
| }, | |
| { | |
| code: "ru", | |
| googleTl: "ru", | |
| label: "RU", | |
| flag: "🇷🇺", | |
| languageName: "Русский", | |
| readmeName: "Русский", | |
| docsName: "Русский", | |
| }, | |
| { | |
| code: "zh-CN", | |
| googleTl: "zh-CN", | |
| label: "ZH-CN", | |
| flag: "🇨🇳", | |
| languageName: "中文 (简体)", | |
| readmeName: "中文 (简体)", | |
| docsName: "中文 (简体)", | |
| }, | |
| { | |
| code: "de", | |
| googleTl: "de", | |
| label: "DE", | |
| flag: "🇩🇪", | |
| languageName: "Deutsch", | |
| readmeName: "Deutsch", | |
| docsName: "Deutsch", | |
| }, | |
| { | |
| code: "hi", | |
| googleTl: "hi", | |
| label: "HI", | |
| flag: "🇮🇳", | |
| languageName: "Hindi (India)", | |
| readmeName: "हिन्दी", | |
| docsName: "हिन्दी", | |
| }, | |
| { | |
| code: "th", | |
| googleTl: "th", | |
| label: "TH", | |
| flag: "🇹🇭", | |
| languageName: "ไทย", | |
| readmeName: "ไทย", | |
| docsName: "ไทย", | |
| }, | |
| { | |
| code: "tr", | |
| googleTl: "tr", | |
| label: "TR", | |
| flag: "🇹🇷", | |
| languageName: "Türkçe", | |
| readmeName: "Türkçe", | |
| docsName: "Türkçe", | |
| }, | |
| { | |
| code: "uk-UA", | |
| googleTl: "uk", | |
| label: "UK-UA", | |
| flag: "🇺🇦", | |
| languageName: "Українська", | |
| readmeName: "Українська", | |
| docsName: "Українська", | |
| }, | |
| { | |
| code: "ar", | |
| googleTl: "ar", | |
| label: "AR", | |
| flag: "🇸🇦", | |
| languageName: "العربية", | |
| readmeName: "العربية", | |
| docsName: "العربية", | |
| }, | |
| { | |
| code: "az", | |
| googleTl: "az", | |
| label: "AZ", | |
| flag: "🇦🇿", | |
| languageName: "Azərbaycan dili", | |
| readmeName: "Azərbaycan dili", | |
| docsName: "Azərbaycan dili", | |
| }, | |
| { | |
| code: "ja", | |
| googleTl: "ja", | |
| label: "JA", | |
| flag: "🇯🇵", | |
| languageName: "日本語", | |
| readmeName: "日本語", | |
| docsName: "日本語", | |
| }, | |
| { | |
| code: "vi", | |
| googleTl: "vi", | |
| label: "VI", | |
| flag: "🇻🇳", | |
| languageName: "Tiếng Việt", | |
| readmeName: "Tiếng Việt", | |
| docsName: "Tiếng Việt", | |
| }, | |
| { | |
| code: "bg", | |
| googleTl: "bg", | |
| label: "BG", | |
| flag: "🇧🇬", | |
| languageName: "Български", | |
| readmeName: "Български", | |
| docsName: "Български", | |
| }, | |
| { | |
| code: "bn", | |
| googleTl: "bn", | |
| label: "BN", | |
| flag: "🇧🇩", | |
| languageName: "বাংলা", | |
| readmeName: "বাংলা", | |
| docsName: "বাংলা", | |
| }, | |
| { | |
| code: "da", | |
| googleTl: "da", | |
| label: "DA", | |
| flag: "🇩🇰", | |
| languageName: "Dansk", | |
| readmeName: "Dansk", | |
| docsName: "Dansk", | |
| }, | |
| { | |
| code: "fi", | |
| googleTl: "fi", | |
| label: "FI", | |
| flag: "🇫🇮", | |
| languageName: "Suomi", | |
| readmeName: "Suomi", | |
| docsName: "Suomi", | |
| }, | |
| { | |
| code: "fa", | |
| googleTl: "fa", | |
| label: "FA", | |
| flag: "🇮🇷", | |
| languageName: "فارسی", | |
| readmeName: "فارسی", | |
| docsName: "فارسی", | |
| }, | |
| { | |
| code: "gu", | |
| googleTl: "gu", | |
| label: "GU", | |
| flag: "🇮🇳", | |
| languageName: "ગુજરાતી", | |
| readmeName: "ગુજરાતી", | |
| docsName: "ગુજરાતી", | |
| }, | |
| { | |
| code: "he", | |
| googleTl: "iw", | |
| label: "HE", | |
| flag: "🇮🇱", | |
| languageName: "עברית", | |
| readmeName: "עברית", | |
| docsName: "עברית", | |
| }, | |
| { | |
| code: "hu", | |
| googleTl: "hu", | |
| label: "HU", | |
| flag: "🇭🇺", | |
| languageName: "Magyar", | |
| readmeName: "Magyar", | |
| docsName: "Magyar", | |
| }, | |
| { | |
| code: "id", | |
| googleTl: "id", | |
| label: "ID", | |
| flag: "🇮🇩", | |
| languageName: "Bahasa Indonesia", | |
| readmeName: "Bahasa Indonesia", | |
| docsName: "Bahasa Indonesia", | |
| }, | |
| { | |
| code: "in", | |
| googleTl: "id", | |
| label: "IN", | |
| flag: "🇮🇩", | |
| languageName: "Bahasa Indonesia (Alt)", | |
| readmeName: "Bahasa Indonesia (Alt)", | |
| docsName: "Bahasa Indonesia (Alt)", | |
| }, | |
| { | |
| code: "ko", | |
| googleTl: "ko", | |
| label: "KO", | |
| flag: "🇰🇷", | |
| languageName: "한국어", | |
| readmeName: "한국어", | |
| docsName: "한국어", | |
| }, | |
| { | |
| code: "ms", | |
| googleTl: "ms", | |
| label: "MS", | |
| flag: "🇲🇾", | |
| languageName: "Bahasa Melayu", | |
| readmeName: "Bahasa Melayu", | |
| docsName: "Bahasa Melayu", | |
| }, | |
| { | |
| code: "mr", | |
| googleTl: "mr", | |
| label: "MR", | |
| flag: "🇮🇳", | |
| languageName: "मराठी", | |
| readmeName: "मराठी", | |
| docsName: "मराठी", | |
| }, | |
| { | |
| code: "nl", | |
| googleTl: "nl", | |
| label: "NL", | |
| flag: "🇳🇱", | |
| languageName: "Nederlands", | |
| readmeName: "Nederlands", | |
| docsName: "Nederlands", | |
| }, | |
| { | |
| code: "no", | |
| googleTl: "no", | |
| label: "NO", | |
| flag: "🇳🇴", | |
| languageName: "Norsk", | |
| readmeName: "Norsk", | |
| docsName: "Norsk", | |
| }, | |
| { | |
| code: "pt", | |
| googleTl: "pt", | |
| label: "PT", | |
| flag: "🇵🇹", | |
| languageName: "Português (Portugal)", | |
| readmeName: "Português (Portugal)", | |
| docsName: "Português (Portugal)", | |
| }, | |
| { | |
| code: "ro", | |
| googleTl: "ro", | |
| label: "RO", | |
| flag: "🇷🇴", | |
| languageName: "Română", | |
| readmeName: "Română", | |
| docsName: "Română", | |
| }, | |
| { | |
| code: "pl", | |
| googleTl: "pl", | |
| label: "PL", | |
| flag: "🇵🇱", | |
| languageName: "Polski", | |
| readmeName: "Polski", | |
| docsName: "Polski", | |
| }, | |
| { | |
| code: "sk", | |
| googleTl: "sk", | |
| label: "SK", | |
| flag: "🇸🇰", | |
| languageName: "Slovenčina", | |
| readmeName: "Slovenčina", | |
| docsName: "Slovenčina", | |
| }, | |
| { | |
| code: "sv", | |
| googleTl: "sv", | |
| label: "SV", | |
| flag: "🇸🇪", | |
| languageName: "Svenska", | |
| readmeName: "Svenska", | |
| docsName: "Svenska", | |
| }, | |
| { | |
| code: "sw", | |
| googleTl: "sw", | |
| label: "SW", | |
| flag: "🇰🇪", | |
| languageName: "Kiswahili", | |
| readmeName: "Kiswahili", | |
| docsName: "Kiswahili", | |
| }, | |
| { | |
| code: "ta", | |
| googleTl: "ta", | |
| label: "TA", | |
| flag: "🇮🇳", | |
| languageName: "தமிழ்", | |
| readmeName: "தமிழ்", | |
| docsName: "தமிழ்", | |
| }, | |
| { | |
| code: "te", | |
| googleTl: "te", | |
| label: "TE", | |
| flag: "🇮🇳", | |
| languageName: "తెలుగు", | |
| readmeName: "తెలుగు", | |
| docsName: "తెలుగు", | |
| }, | |
| { | |
| code: "phi", | |
| googleTl: "tl", | |
| label: "PHI", | |
| flag: "🇵🇭", | |
| languageName: "Filipino", | |
| readmeName: "Filipino", | |
| docsName: "Filipino", | |
| }, | |
| { | |
| code: "cs", | |
| googleTl: "cs", | |
| label: "CS", | |
| flag: "🇨🇿", | |
| languageName: "Čeština", | |
| readmeName: "Čeština", | |
| docsName: "Čeština", | |
| }, | |
| { | |
| code: "ur", | |
| googleTl: "ur", | |
| label: "UR", | |
| flag: "🇵🇰", | |
| languageName: "اردو", | |
| readmeName: "اردو", | |
| docsName: "اردو", | |
| }, | |
| ]; | |
| const EXISTING_README_CODES = new Set(["pt-BR", "es", "fr", "it", "ru", "zh-CN", "de"]); | |
| const RTL_LOCALES = new Set(["ar", "fa", "he", "ur"]); | |
| const URL_MAX_TEXT_LENGTH = 1800; | |
| const DELIMITER = "\n__OMNIROUTE_I18N_SEPARATOR__\n"; | |
| const DELIMITER_REGEX = /\n\s*__OMNIROUTE_I18N_SEPARATOR__\s*\n/g; | |
| const TRANSLATION_CACHE = new Map(); | |
| const REQUEST_TIMEOUT_MS = 20000; | |
| function parseMessageCoverageThreshold(args) { | |
| const raw = [...args] | |
| .find((arg) => arg.startsWith("--min-ui-coverage=") || arg.startsWith("--coverage-threshold=")) | |
| ?.split("=")[1]; | |
| if (raw === undefined) { | |
| return null; | |
| } | |
| const threshold = Number(raw); | |
| if (!Number.isFinite(threshold) || threshold < 0 || threshold > 100) { | |
| throw new Error(`Invalid message coverage threshold: ${raw}`); | |
| } | |
| return threshold; | |
| } | |
| function getReadmeFileName(code) { | |
| return code === "en" ? "README.md" : `README.${code}.md`; | |
| } | |
| function sleep(ms) { | |
| return new Promise((resolve) => setTimeout(resolve, ms)); | |
| } | |
| function isProbablyTranslatable(text) { | |
| if (!text.trim()) { | |
| return false; | |
| } | |
| return /[A-Za-z]/.test(text); | |
| } | |
| function maskBalancedCurlyBraces(input, stash) { | |
| let result = ""; | |
| let i = 0; | |
| while (i < input.length) { | |
| if (input[i] === "{") { | |
| let j = i; | |
| let depth = 0; | |
| while (j < input.length) { | |
| const ch = input[j]; | |
| if (ch === "{") { | |
| depth += 1; | |
| } else if (ch === "}") { | |
| depth -= 1; | |
| if (depth === 0) { | |
| j += 1; | |
| break; | |
| } | |
| } | |
| j += 1; | |
| } | |
| if (depth === 0 && j > i + 1) { | |
| result += stash(input.slice(i, j)); | |
| i = j; | |
| continue; | |
| } | |
| } | |
| result += input[i]; | |
| i += 1; | |
| } | |
| return result; | |
| } | |
| function protectText(input, options = {}) { | |
| const tokens = []; | |
| const stash = (value) => { | |
| const token = `__OMNI_TOKEN_${tokens.length}__`; | |
| tokens.push(value); | |
| return token; | |
| }; | |
| let output = input; | |
| if (options.markdown) { | |
| output = output.replace(/```[\s\S]*?```/g, stash); | |
| output = output.replace(/<table[\s\S]*?<\/table>/gi, stash); | |
| output = output.replace(/`[^`\n]+`/g, stash); | |
| output = output.replace(/!?\[[^\]]*\]\([^\)]+\)/g, stash); | |
| output = output.replace(/<img[^>]*>/gi, stash); | |
| } | |
| output = output.replace(/<\/?[a-zA-Z][^>]*>/g, stash); | |
| output = maskBalancedCurlyBraces(output, stash); | |
| return { output, tokens }; | |
| } | |
| function restoreText(input, tokens) { | |
| let output = input; | |
| for (let i = 0; i < tokens.length; i += 1) { | |
| output = output.replaceAll(`__OMNI_TOKEN_${i}__`, tokens[i]); | |
| } | |
| return output; | |
| } | |
| function parseTranslationPayload(payload) { | |
| if (!Array.isArray(payload) || !Array.isArray(payload[0])) { | |
| throw new Error("Invalid translation payload format"); | |
| } | |
| return payload[0].map((item) => item[0] || "").join(""); | |
| } | |
| async function translateTextRaw(text, targetLanguage, sourceLanguage = "en", attempt = 1) { | |
| if (!text) { | |
| return text; | |
| } | |
| const params = new URLSearchParams({ | |
| client: "gtx", | |
| sl: sourceLanguage, | |
| tl: targetLanguage, | |
| dt: "t", | |
| q: text, | |
| }); | |
| const url = `https://translate.googleapis.com/translate_a/single?${params.toString()}`; | |
| const controller = new AbortController(); | |
| const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); | |
| let response; | |
| try { | |
| response = await fetch(url, { | |
| signal: controller.signal, | |
| headers: { | |
| "User-Agent": "Mozilla/5.0 OmniRoute-I18N", | |
| }, | |
| }); | |
| } catch (error) { | |
| clearTimeout(timeout); | |
| if (attempt < 5) { | |
| await sleep(300 * attempt); | |
| return translateTextRaw(text, targetLanguage, sourceLanguage, attempt + 1); | |
| } | |
| throw error; | |
| } finally { | |
| clearTimeout(timeout); | |
| } | |
| if (!response.ok) { | |
| if ((response.status === 429 || response.status >= 500) && attempt < 5) { | |
| await sleep(300 * attempt); | |
| return translateTextRaw(text, targetLanguage, sourceLanguage, attempt + 1); | |
| } | |
| const body = await response.text(); | |
| throw new Error(`Translation request failed (${response.status}): ${body.slice(0, 200)}`); | |
| } | |
| const payload = await response.json(); | |
| return parseTranslationPayload(payload); | |
| } | |
| function getLocaleCache(targetLanguage) { | |
| if (!TRANSLATION_CACHE.has(targetLanguage)) { | |
| TRANSLATION_CACHE.set(targetLanguage, new Map()); | |
| } | |
| return TRANSLATION_CACHE.get(targetLanguage); | |
| } | |
| async function translateProtectedUnits(units, targetLanguage) { | |
| const translated = new Array(units.length); | |
| const cache = getLocaleCache(targetLanguage); | |
| const pendingIndices = []; | |
| const uniqueTexts = []; | |
| const uniqueIndexMap = new Map(); | |
| for (let i = 0; i < units.length; i += 1) { | |
| const text = units[i]; | |
| if (cache.has(text)) { | |
| translated[i] = cache.get(text); | |
| continue; | |
| } | |
| pendingIndices.push(i); | |
| if (!uniqueIndexMap.has(text)) { | |
| uniqueIndexMap.set(text, uniqueTexts.length); | |
| uniqueTexts.push(text); | |
| } | |
| } | |
| if (uniqueTexts.length > 0) { | |
| let chunk = []; | |
| let chunkLen = 0; | |
| const translatedUnique = new Array(uniqueTexts.length); | |
| const flushChunk = async () => { | |
| if (chunk.length === 0) { | |
| return; | |
| } | |
| const joined = chunk.join(DELIMITER); | |
| let translatedJoined; | |
| try { | |
| translatedJoined = await translateTextRaw(joined, targetLanguage); | |
| } catch (error) { | |
| translatedJoined = null; | |
| } | |
| if (translatedJoined) { | |
| const split = translatedJoined.split(DELIMITER_REGEX); | |
| if (split.length === chunk.length) { | |
| for (let i = 0; i < chunk.length; i += 1) { | |
| const originalText = chunk[i]; | |
| const translatedText = split[i]; | |
| const uniqueIdx = uniqueIndexMap.get(originalText); | |
| translatedUnique[uniqueIdx] = translatedText; | |
| cache.set(originalText, translatedText); | |
| } | |
| chunk = []; | |
| chunkLen = 0; | |
| return; | |
| } | |
| } | |
| for (const originalText of chunk) { | |
| const translatedText = await translateTextRaw(originalText, targetLanguage); | |
| const uniqueIdx = uniqueIndexMap.get(originalText); | |
| translatedUnique[uniqueIdx] = translatedText; | |
| cache.set(originalText, translatedText); | |
| } | |
| chunk = []; | |
| chunkLen = 0; | |
| }; | |
| for (const text of uniqueTexts) { | |
| const projected = chunkLen + text.length + DELIMITER.length; | |
| if (projected > URL_MAX_TEXT_LENGTH && chunk.length > 0) { | |
| await flushChunk(); | |
| } | |
| chunk.push(text); | |
| chunkLen += text.length + DELIMITER.length; | |
| if (chunkLen > URL_MAX_TEXT_LENGTH) { | |
| await flushChunk(); | |
| } | |
| } | |
| await flushChunk(); | |
| for (const index of pendingIndices) { | |
| const text = units[index]; | |
| const uniqueIdx = uniqueIndexMap.get(text); | |
| translated[index] = translatedUnique[uniqueIdx] || text; | |
| } | |
| } | |
| return translated; | |
| } | |
| async function translateStrings(values, targetLanguage, options = {}) { | |
| if (targetLanguage === "en") { | |
| return values.slice(); | |
| } | |
| const protectedValues = values.map((value) => protectText(value, options)); | |
| const maskedUnits = protectedValues.map((item) => item.output); | |
| const needsTranslation = maskedUnits.map((unit) => isProbablyTranslatable(unit)); | |
| const onlyForTranslation = []; | |
| const mapping = []; | |
| for (let i = 0; i < maskedUnits.length; i += 1) { | |
| if (!needsTranslation[i]) { | |
| continue; | |
| } | |
| mapping.push(i); | |
| onlyForTranslation.push(maskedUnits[i]); | |
| } | |
| const translatedUnits = await translateProtectedUnits(onlyForTranslation, targetLanguage); | |
| const finalMasked = maskedUnits.slice(); | |
| for (let i = 0; i < mapping.length; i += 1) { | |
| finalMasked[mapping[i]] = translatedUnits[i]; | |
| } | |
| return finalMasked.map((value, index) => restoreText(value, protectedValues[index].tokens)); | |
| } | |
| function collectStringLeaves(node, pathSoFar = [], output = []) { | |
| if (typeof node === "string") { | |
| output.push({ path: pathSoFar, value: node }); | |
| return output; | |
| } | |
| if (Array.isArray(node)) { | |
| node.forEach((item, index) => { | |
| collectStringLeaves(item, [...pathSoFar, index], output); | |
| }); | |
| return output; | |
| } | |
| if (node && typeof node === "object") { | |
| for (const key of Object.keys(node)) { | |
| collectStringLeaves(node[key], [...pathSoFar, key], output); | |
| } | |
| } | |
| return output; | |
| } | |
| function setByPath(target, pathTokens, value) { | |
| let current = target; | |
| for (let i = 0; i < pathTokens.length - 1; i += 1) { | |
| if (current[pathTokens[i]] === undefined) { | |
| current[pathTokens[i]] = typeof pathTokens[i + 1] === "number" ? [] : {}; | |
| } | |
| current = current[pathTokens[i]]; | |
| } | |
| current[pathTokens[pathTokens.length - 1]] = value; | |
| } | |
| async function ensureDir(dirPath) { | |
| await fs.mkdir(dirPath, { recursive: true }); | |
| } | |
| async function fileExists(filePath) { | |
| try { | |
| await fs.access(filePath); | |
| return true; | |
| } catch { | |
| return false; | |
| } | |
| } | |
| function buildRootReadmeLanguageBar() { | |
| const entries = LOCALE_SPECS.map((spec) => { | |
| const file = getReadmeFileName(spec.code); | |
| return `${spec.flag} [${spec.readmeName}](${file})`; | |
| }); | |
| return `🌐 **Available in:** ${entries.join(" | ")}`; | |
| } | |
| function upsertRootReadmeLanguageBar(content, languageBar) { | |
| const existingBarRegex = /^🌐 \*\*.*README.*$/m; | |
| if (existingBarRegex.test(content)) { | |
| return content.replace(existingBarRegex, languageBar); | |
| } | |
| const navLineRegex = /^\[🌐 .*$/m; | |
| const navLine = content.match(navLineRegex); | |
| if (navLine && typeof navLine.index === "number") { | |
| const insertAfter = navLine.index + navLine[0].length; | |
| return `${content.slice(0, insertAfter)}\n\n${languageBar}${content.slice(insertAfter)}`; | |
| } | |
| return `${languageBar}\n\n${content}`; | |
| } | |
| function buildDocsLanguageBar(docName, currentLocaleCode) { | |
| const entries = LOCALE_SPECS.map((spec) => { | |
| let targetPath; | |
| if (spec.code === "en") { | |
| targetPath = currentLocaleCode ? `../../${docName}` : docName; | |
| } else if (currentLocaleCode) { | |
| targetPath = `../${spec.code}/${docName}`; | |
| } else { | |
| targetPath = `i18n/${spec.code}/${docName}`; | |
| } | |
| return `${spec.flag} [${spec.docsName}](${targetPath})`; | |
| }); | |
| return `🌐 **Languages:** ${entries.join(" | ")}`; | |
| } | |
| function upsertDocsLanguageBar(content, languageBar) { | |
| const existingBarRegex = /^🌐 \*\*Languages:\*\*.*$/m; | |
| if (existingBarRegex.test(content)) { | |
| return content.replace(existingBarRegex, languageBar); | |
| } | |
| const firstHeadingRegex = /^(# .+\n?)/; | |
| if (firstHeadingRegex.test(content)) { | |
| return content.replace(firstHeadingRegex, `$1\n${languageBar}\n`); | |
| } | |
| return `${languageBar}\n\n${content}`; | |
| } | |
| function splitByParagraphs(markdown) { | |
| const parts = markdown.split(/(\n{2,})/g); | |
| return parts; | |
| } | |
| async function translateMarkdownDocument(content, targetLanguage) { | |
| if (targetLanguage === "en") { | |
| return content; | |
| } | |
| const protectedDoc = protectText(content, { markdown: true }); | |
| const parts = splitByParagraphs(protectedDoc.output); | |
| const translatableIndices = []; | |
| const translatableValues = []; | |
| for (let i = 0; i < parts.length; i += 1) { | |
| if (i % 2 === 1) { | |
| continue; | |
| } | |
| const part = parts[i]; | |
| if (!isProbablyTranslatable(part)) { | |
| continue; | |
| } | |
| translatableIndices.push(i); | |
| translatableValues.push(part); | |
| } | |
| const translated = await translateStrings(translatableValues, targetLanguage, { markdown: true }); | |
| for (let i = 0; i < translatableIndices.length; i += 1) { | |
| parts[translatableIndices[i]] = translated[i]; | |
| } | |
| const joined = parts.join(""); | |
| return restoreText(joined, protectedDoc.tokens); | |
| } | |
| async function generateMessageTranslations() { | |
| const args = new Set(process.argv.slice(2)); | |
| const coverageThreshold = parseMessageCoverageThreshold(args); | |
| const enPath = path.join(MESSAGES_DIR, "en.json"); | |
| const sourceRaw = await fs.readFile(enPath, "utf8"); | |
| const sourceJson = JSON.parse(sourceRaw); | |
| const leaves = collectStringLeaves(sourceJson); | |
| for (const spec of LOCALE_SPECS) { | |
| if (spec.code === "en") { | |
| continue; | |
| } | |
| const targetPath = path.join(MESSAGES_DIR, `${spec.code}.json`); | |
| let targetJson = {}; | |
| if (await fileExists(targetPath)) { | |
| const targetRaw = await fs.readFile(targetPath, "utf8"); | |
| try { | |
| targetJson = JSON.parse(targetRaw); | |
| } catch (e) { | |
| console.warn(`[messages] Failed to parse ${spec.code}.json`); | |
| } | |
| } | |
| const missingLeaves = leaves.filter((leaf) => { | |
| let current = targetJson; | |
| for (const token of leaf.path) { | |
| if (current === undefined || current === null) return true; | |
| current = current[token]; | |
| } | |
| return ( | |
| current === undefined || | |
| current === null || | |
| current === "" || | |
| (typeof current === "string" && current.startsWith(PLACEHOLDER_PREFIX)) | |
| ); | |
| }); | |
| const leavesToTranslate = coverageThreshold | |
| ? missingLeaves.slice( | |
| 0, | |
| Math.max( | |
| 0, | |
| Math.ceil((leaves.length * coverageThreshold) / 100) - | |
| (leaves.length - missingLeaves.length) | |
| ) | |
| ) | |
| : missingLeaves; | |
| if (leavesToTranslate.length === 0) { | |
| console.log(`[messages] ${spec.code} is up-to-date.`); | |
| continue; | |
| } | |
| const scope = coverageThreshold ? `to reach ${coverageThreshold}% UI coverage` : "missing keys"; | |
| console.log(`[messages] Translating ${leavesToTranslate.length} ${scope} for ${spec.code}...`); | |
| const sourceValues = leavesToTranslate.map((entry) => entry.value); | |
| const translatedValues = await translateStrings(sourceValues, spec.googleTl); | |
| translatedValues.forEach((value, index) => { | |
| setByPath(targetJson, leavesToTranslate[index].path, value); | |
| }); | |
| await fs.writeFile(targetPath, `${JSON.stringify(targetJson, null, 2)}\n`, "utf8"); | |
| } | |
| } | |
| async function generateReadmeTranslations() { | |
| const sourceReadmePath = path.join(ROOT, "README.md"); | |
| const sourceReadme = await fs.readFile(sourceReadmePath, "utf8"); | |
| for (const spec of LOCALE_SPECS) { | |
| if (spec.code === "en") { | |
| continue; | |
| } | |
| const targetFile = path.join(ROOT, getReadmeFileName(spec.code)); | |
| if (EXISTING_README_CODES.has(spec.code) || (await fileExists(targetFile))) { | |
| continue; | |
| } | |
| console.log(`[readme] Translating ${spec.code}...`); | |
| const translated = await translateMarkdownDocument(sourceReadme, spec.googleTl); | |
| await fs.writeFile(targetFile, translated, "utf8"); | |
| } | |
| const languageBar = buildRootReadmeLanguageBar(); | |
| for (const spec of LOCALE_SPECS) { | |
| const readmePath = path.join(ROOT, getReadmeFileName(spec.code)); | |
| const current = await fs.readFile(readmePath, "utf8"); | |
| const updated = upsertRootReadmeLanguageBar(current, languageBar); | |
| await fs.writeFile(readmePath, updated, "utf8"); | |
| } | |
| } | |
| async function generateDocsTranslations() { | |
| await ensureDir(DOCS_I18N_DIR); | |
| for (const docName of DOC_SOURCE_FILES) { | |
| const sourceDocPath = path.join(DOCS_DIR, docName); | |
| const sourceDocRaw = await fs.readFile(sourceDocPath, "utf8"); | |
| const sourceDocBar = buildDocsLanguageBar(docName, null); | |
| const sourceDocWithBar = upsertDocsLanguageBar(sourceDocRaw, sourceDocBar); | |
| await fs.writeFile(sourceDocPath, sourceDocWithBar, "utf8"); | |
| for (const spec of LOCALE_SPECS) { | |
| if (spec.code === "en") { | |
| continue; | |
| } | |
| const targetDir = path.join(DOCS_I18N_DIR, spec.code); | |
| await ensureDir(targetDir); | |
| const targetPath = path.join(targetDir, docName); | |
| if (await fileExists(targetPath)) { | |
| continue; | |
| } | |
| console.log(`[docs] Translating ${docName} -> ${spec.code}...`); | |
| const translated = await translateMarkdownDocument(sourceDocRaw, spec.googleTl); | |
| const withBar = upsertDocsLanguageBar(translated, buildDocsLanguageBar(docName, spec.code)); | |
| await fs.writeFile(targetPath, withBar, "utf8"); | |
| } | |
| } | |
| const indexLines = [ | |
| "# Multilingual Documentation", | |
| "", | |
| "This directory contains machine-assisted translations based on the English docs.", | |
| "", | |
| ...DOC_SOURCE_FILES.map((docName) => { | |
| const links = LOCALE_SPECS.map((spec) => { | |
| const link = spec.code === "en" ? `../${docName}` : `./${spec.code}/${docName}`; | |
| return `${spec.flag} [${spec.docsName}](${link})`; | |
| }).join(" | "); | |
| return `- **${docName}**: ${links}`; | |
| }), | |
| "", | |
| `Generated on ${new Date().toISOString().slice(0, 10)}.`, | |
| ]; | |
| await fs.writeFile(path.join(DOCS_I18N_DIR, "README.md"), `${indexLines.join("\n")}\n`, "utf8"); | |
| } | |
| async function main() { | |
| const args = new Set(process.argv.slice(2)); | |
| const runAll = args.size === 0 || args.has("all"); | |
| if (runAll || args.has("messages")) { | |
| await generateMessageTranslations(); | |
| } | |
| if (runAll || args.has("readme")) { | |
| await generateReadmeTranslations(); | |
| } | |
| if (runAll || args.has("docs")) { | |
| await generateDocsTranslations(); | |
| } | |
| console.log("Done."); | |
| } | |
| main().catch((error) => { | |
| console.error(error); | |
| process.exit(1); | |
| }); | |