| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245 |
- /*
- * Synchronize keys of settings messages across locales using zh-CN as canonical.
- *
- * Supports both layouts:
- * - legacy: messages/<locale>/settings.json
- * - split: messages/<locale>/settings/ (recursive .json files, assembled by messages/<locale>/settings/index.ts)
- *
- * Behavior:
- * - Ensures every locale has exactly the same set of nested keys as canonical (zh-CN)
- * - Keeps existing translations where keys exist
- * - Fills missing keys with zh-CN text as placeholder
- * - Drops extra keys not present in zh-CN (applies consistently to all locales)
- */
- const fs = require("node:fs");
- const path = require("node:path");
- const LOCALES = ["en", "ja", "ru", "zh-TW"];
- const CANONICAL = "zh-CN";
- const DEFAULT_MESSAGES_DIR = path.join(process.cwd(), "messages");
- function getMessagesDir(messagesDir) {
- return messagesDir || DEFAULT_MESSAGES_DIR;
- }
- function isObject(v) {
- return v && typeof v === "object" && !Array.isArray(v);
- }
- function flatten(obj, prefix = "") {
- const out = {};
- for (const [k, v] of Object.entries(obj || {})) {
- const key = prefix ? `${prefix}.${k}` : k;
- if (isObject(v)) Object.assign(out, flatten(v, key));
- else out[key] = v;
- }
- return out;
- }
- function mergeWithCanonical(cn, target) {
- const result = Array.isArray(cn) ? [] : {};
- for (const [k, v] of Object.entries(cn)) {
- const tVal = target?.[k];
- if (isObject(v)) {
- // Canonical expects an object; only descend if target also has an object, else ignore target
- const tchild = isObject(tVal) ? tVal : {};
- result[k] = mergeWithCanonical(v, tchild);
- } else {
- // Canonical expects a leaf (string/number/bool/array/null). If target is an object, ignore it.
- if (Object.hasOwn(target || {}, k) && !isObject(tVal)) {
- result[k] = tVal;
- } else {
- result[k] = v;
- }
- }
- }
- return result;
- }
- function sortKeysDeep(obj) {
- if (!isObject(obj)) return obj;
- const out = {};
- for (const key of Object.keys(obj).sort()) {
- out[key] = sortKeysDeep(obj[key]);
- }
- return out;
- }
- function loadJSON(p) {
- return JSON.parse(fs.readFileSync(p, "utf8"));
- }
- function saveJSON(p, data) {
- fs.writeFileSync(p, `${JSON.stringify(data, null, 2)}\n`, "utf8");
- }
- function listJsonFiles(dir) {
- const out = [];
- const walk = (d) => {
- for (const entry of fs.readdirSync(d, { withFileTypes: true })) {
- const full = path.join(d, entry.name);
- if (entry.isDirectory()) {
- walk(full);
- continue;
- }
- if (entry.isFile() && entry.name.endsWith(".json")) out.push(full);
- }
- };
- if (fs.existsSync(dir)) walk(dir);
- return out;
- }
- function getPath(obj, segments) {
- let cur = obj;
- for (const s of segments) {
- if (!isObject(cur) || !Object.hasOwn(cur, s)) return undefined;
- cur = cur[s];
- }
- return cur;
- }
- function loadSplitSettings(locale, messagesDir) {
- const settingsDir = path.join(getMessagesDir(messagesDir), locale, "settings");
- if (!fs.existsSync(settingsDir)) return null;
- const top = {};
- for (const file of fs.readdirSync(settingsDir, { withFileTypes: true })) {
- if (file.isDirectory()) continue;
- if (!file.name.endsWith(".json")) continue;
- const name = file.name.replace(/\.json$/, "");
- const v = loadJSON(path.join(settingsDir, file.name));
- if (name === "strings") Object.assign(top, v);
- else top[name] = v;
- }
- const providersDir = path.join(settingsDir, "providers");
- const providers = {};
- if (fs.existsSync(providersDir)) {
- for (const file of fs.readdirSync(providersDir, { withFileTypes: true })) {
- if (file.isDirectory()) continue;
- if (!file.name.endsWith(".json")) continue;
- const name = file.name.replace(/\.json$/, "");
- const v = loadJSON(path.join(providersDir, file.name));
- if (name === "strings") Object.assign(providers, v);
- else providers[name] = v;
- }
- const formDir = path.join(providersDir, "form");
- const form = {};
- if (fs.existsSync(formDir)) {
- for (const file of fs.readdirSync(formDir, { withFileTypes: true })) {
- if (file.isDirectory()) continue;
- if (!file.name.endsWith(".json")) continue;
- const name = file.name.replace(/\.json$/, "");
- const v = loadJSON(path.join(formDir, file.name));
- if (name === "strings") Object.assign(form, v);
- else form[name] = v;
- }
- }
- providers.form = form;
- }
- top.providers = providers;
- return top;
- }
- function saveSplitSettingsFromCanonical(locale, merged, messagesDir) {
- const root = getMessagesDir(messagesDir);
- const cnSettingsDir = path.join(root, CANONICAL, "settings");
- const targetSettingsDir = path.join(root, locale, "settings");
- const cnFiles = listJsonFiles(cnSettingsDir).map((p) => path.relative(cnSettingsDir, p));
- for (const rel of cnFiles) {
- const cnPath = path.join(cnSettingsDir, rel);
- const targetPath = path.join(targetSettingsDir, rel);
- const segs = rel.replace(/\.json$/, "").split(path.sep);
- // special: strings.json means "spread into parent object"
- if (segs[segs.length - 1] === "strings") {
- const tmpl = loadJSON(cnPath);
- const parentSegs = segs.slice(0, -1);
- const parent = parentSegs.length ? getPath(merged, parentSegs) : merged;
- const out = {};
- for (const k of Object.keys(tmpl)) out[k] = parent?.[k];
- fs.mkdirSync(path.dirname(targetPath), { recursive: true });
- saveJSON(targetPath, sortKeysDeep(out));
- continue;
- }
- const v = getPath(merged, segs);
- fs.mkdirSync(path.dirname(targetPath), { recursive: true });
- saveJSON(targetPath, sortKeysDeep(v));
- }
- }
- function ensureSettings(locale, messagesDir) {
- const root = getMessagesDir(messagesDir);
- const cnSplit = loadSplitSettings(CANONICAL, root);
- const useSplit = Boolean(cnSplit);
- const cnPath = path.join(root, CANONICAL, "settings.json");
- const targetPath = path.join(root, locale, "settings.json");
- const cn = useSplit ? cnSplit : loadJSON(cnPath);
- const tSplit = loadSplitSettings(locale, root);
- const t = useSplit ? (tSplit ?? {}) : loadJSON(targetPath);
- const merged = mergeWithCanonical(cn, t);
- // Drop extras implicitly by not copying unknown keys; merged contains only canonical keys
- const sorted = sortKeysDeep(merged);
- // Stats
- const cnKeys = Object.keys(flatten(cn));
- const tKeys = Object.keys(flatten(t));
- const mergedKeys = Object.keys(flatten(sorted));
- const missingBefore = cnKeys.filter((k) => !tKeys.includes(k));
- const extraBefore = tKeys.filter((k) => !cnKeys.includes(k));
- const missingAfter = cnKeys.filter((k) => !mergedKeys.includes(k));
- const extraAfter = mergedKeys.filter((k) => !cnKeys.includes(k));
- if (useSplit) {
- saveSplitSettingsFromCanonical(locale, sorted, root);
- } else {
- saveJSON(targetPath, sorted);
- }
- return {
- locale,
- targetPath: useSplit ? path.join(root, locale, "settings") : targetPath,
- cnCount: cnKeys.length,
- before: { count: tKeys.length, missing: missingBefore.length, extra: extraBefore.length },
- after: { count: mergedKeys.length, missing: missingAfter.length, extra: extraAfter.length },
- };
- }
- function main() {
- const reports = [];
- for (const loc of LOCALES) {
- const legacy = path.join(DEFAULT_MESSAGES_DIR, loc, "settings.json");
- const split = path.join(DEFAULT_MESSAGES_DIR, loc, "settings");
- if (!fs.existsSync(legacy) && !fs.existsSync(split)) {
- console.error(`[skip] ${loc} has no settings messages`);
- continue;
- }
- reports.push(ensureSettings(loc, DEFAULT_MESSAGES_DIR));
- }
- // Print summary
- for (const r of reports) {
- console.log(
- `${r.locale}: cn=${r.cnCount}, before=${r.before.count} (-${r.before.missing} missing, +${r.before.extra} extra), after=${r.after.count} (-${r.after.missing} missing, +${r.after.extra} extra)`
- );
- }
- }
- module.exports = {
- ensureSettings,
- flatten,
- loadSplitSettings,
- mergeWithCanonical,
- sortKeysDeep,
- };
- if (require.main === module) {
- main();
- }
|