mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-03 22:02:08 +03:00
The zh-TW catalog was machine-translated with mainland-habit vocabulary and simplified->traditional conversions that picked the wrong homophone, and its README still advertised the v3.7-era figures. Terminology (docs/i18n/zh-TW/ + src/i18n/messages/zh-TW.json): - wrong-character conversions: 上遊->上游, 後臺->後台, 儀錶板->儀表板 - mainland habits: 默認->預設, 緩存->快取, 模塊->模組, 調用->呼叫, 字符串->字串, 全局->全域, 文檔->文件, 響應->回應 - consistency: 供應商/提供商->提供者 (提供者 was already 71% dominant), 型別->類型 for UI labels, 不活躍->未啟用 README figures synced to the English source: 231->290 providers, 17->19 routing strategies, 1.6B->1.53B free tokens, 50+->90+ free tiers, 11->40+ free forever, 87->104 MCP tools, 30->31 scopes. Root cause — the generator's post-translation pass was a hardcoded list that duplicated the glossary and was wired only into the deprecated generate-multilang.mjs, so the active run-translation.mjs pipeline applied nothing. Worse, its blanket /代碼/g -> 程式碼 rule would corrupt 控制代碼 (handle), 語系代碼 (locale code) and 錯誤代碼 (error code) on the next regeneration. Both scripts and the drift gate now share scripts/i18n/glossary-normalize.mjs, driven by scripts/i18n/glossary/<locale>.json as the single source of truth. Ambiguous terms carry blockedPrefixes so 型別->類型 can stay enforced without mangling 模型別名 (model alias) or 基本型別 (a programming data type); terms whose synonym is also a legitimate rendering elsewhere (代碼, 項目) are seeded with no synonyms and documented instead of blanket-rewritten. CI now runs the glossary gate for zh-TW alongside zh-CN.
128 lines
4.5 KiB
JavaScript
128 lines
4.5 KiB
JavaScript
/**
|
|
* OmniRoute — shared glossary terminology normalization.
|
|
*
|
|
* Single implementation of the "canonical term" rules declared in
|
|
* scripts/i18n/glossary/<locale>.json, used by three call sites so they can
|
|
* never disagree about what canonical means:
|
|
*
|
|
* - scripts/i18n/run-translation.mjs (active docs pipeline)
|
|
* - scripts/i18n/generate-multilang.mjs (deprecated legacy generator)
|
|
* - scripts/i18n/check-glossary-consistency.mjs (drift gate)
|
|
*
|
|
* Why `blockedPrefixes` exists: a synonym is matched as a plain substring, and
|
|
* Chinese compounds have no word separators, so a synonym can appear inside an
|
|
* unrelated term. 型別 ("type") sits across the character boundary of 模型別名
|
|
* ("model alias") and is also the correct rendering of a programming data type
|
|
* in 基本型別. Blocking on the preceding character keeps such a term enforced
|
|
* instead of forcing it to be dropped from the glossary entirely.
|
|
*/
|
|
|
|
import { readFileSync } from "node:fs";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
const GLOSSARY_DIR = path.join(SCRIPT_DIR, "glossary");
|
|
|
|
const cache = new Map();
|
|
|
|
/**
|
|
* @param {string} haystack
|
|
* @param {string} needle
|
|
* @param {string[]} [blockedPrefixes]
|
|
* @returns {boolean} true when at least one occurrence is NOT preceded by a blocked prefix
|
|
*/
|
|
export function hasUnblockedOccurrence(haystack, needle, blockedPrefixes = []) {
|
|
if (!haystack || !needle) return false;
|
|
const blocked = Array.isArray(blockedPrefixes) ? blockedPrefixes.filter(Boolean) : [];
|
|
let from = 0;
|
|
for (;;) {
|
|
const at = haystack.indexOf(needle, from);
|
|
if (at === -1) return false;
|
|
const prev = at === 0 ? "" : haystack.slice(at - 1, at);
|
|
if (!prev || !blocked.includes(prev)) return true;
|
|
from = at + 1;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Flatten a parsed glossary into applicable replacement rules. Concepts with an
|
|
* empty `synonyms` array are documentation-only and produce no rule.
|
|
*
|
|
* @param {object} glossary - parsed scripts/i18n/glossary/<locale>.json
|
|
* @returns {Array<{synonym: string, canonical: string, blockedPrefixes: string[]}>}
|
|
*/
|
|
export function buildReplacements(glossary) {
|
|
const terms = glossary && glossary.terms ? glossary.terms : {};
|
|
const replacements = [];
|
|
for (const def of Object.values(terms)) {
|
|
if (!def || !def.canonical) continue;
|
|
const synonyms = Array.isArray(def.synonyms) ? def.synonyms : [];
|
|
const blockedPrefixes = Array.isArray(def.blockedPrefixes) ? def.blockedPrefixes : [];
|
|
for (const synonym of synonyms) {
|
|
if (!synonym) continue;
|
|
replacements.push({ synonym, canonical: def.canonical, blockedPrefixes });
|
|
}
|
|
}
|
|
return replacements;
|
|
}
|
|
|
|
/**
|
|
* @param {string} locale
|
|
* @returns {Array<{synonym: string, canonical: string, blockedPrefixes: string[]}>}
|
|
*/
|
|
export function loadReplacements(locale) {
|
|
if (cache.has(locale)) return cache.get(locale);
|
|
let replacements = [];
|
|
try {
|
|
const raw = readFileSync(path.join(GLOSSARY_DIR, `${locale}.json`), "utf8");
|
|
replacements = buildReplacements(JSON.parse(raw));
|
|
} catch {
|
|
// No glossary for this locale (or unreadable) — normalization is optional.
|
|
replacements = [];
|
|
}
|
|
cache.set(locale, replacements);
|
|
return replacements;
|
|
}
|
|
|
|
/**
|
|
* Apply one replacement rule, skipping blocked occurrences.
|
|
*
|
|
* @param {string} text
|
|
* @param {{synonym: string, canonical: string, blockedPrefixes: string[]}} rule
|
|
* @returns {string}
|
|
*/
|
|
export function applyReplacement(text, rule) {
|
|
const { synonym, canonical, blockedPrefixes = [] } = rule;
|
|
if (!synonym || !text.includes(synonym)) return text;
|
|
let out = "";
|
|
let from = 0;
|
|
for (;;) {
|
|
const at = text.indexOf(synonym, from);
|
|
if (at === -1) return out + text.slice(from);
|
|
const prev = at === 0 ? "" : text.slice(at - 1, at);
|
|
const blocked = Boolean(prev) && blockedPrefixes.includes(prev);
|
|
out += text.slice(from, at) + (blocked ? synonym : canonical);
|
|
from = at + synonym.length;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Normalize a translated string to the locale's canonical terminology.
|
|
* Returns the input unchanged when the locale has no glossary.
|
|
*
|
|
* @param {string} text
|
|
* @param {string} locale
|
|
* @returns {string}
|
|
*/
|
|
export function normalizeLocaleText(text, locale) {
|
|
if (typeof text !== "string" || !text || !locale) return text;
|
|
const replacements = loadReplacements(locale);
|
|
if (replacements.length === 0) return text;
|
|
let result = text;
|
|
for (const rule of replacements) {
|
|
result = applyReplacement(result, rule);
|
|
}
|
|
return result;
|
|
}
|