Files
MyAiDesk/nomifun-tauri/scripts/generate-i18n-types.mjs
T
freedak f7a720204a Update: 将子项目从 submodule 转为完整内容
- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用
- 添加所有子项目的完整源代码
- 保留原始 .git 为 .git.bak 备份
2026-07-04 19:20:46 +08:00

161 lines
6.4 KiB
JavaScript

#!/usr/bin/env node
/**
* generate-i18n-types.mjs — regenerate ui/src/renderer/services/i18n/i18n-keys.d.ts
* from the en-US locale JSON files (source of truth).
*
* Usage:
* node scripts/generate-i18n-types.mjs # write the d.ts
* node scripts/generate-i18n-types.mjs --check # no write; exit 1 if the
* # committed d.ts drifts from
* # the locale key set
*
* No dependencies. Node >= 16.
*
* Rules (mirrors the historical generator output):
* - Namespaces and their order come from locales/en-US/index.ts (runtime truth).
* - Keys are the dot-flattened paths of every leaf value, prefixed with the
* namespace; arrays flatten to numeric indices (e.g. `a.list.0`).
* - I18nKey union is sorted by UTF-16 code units; I18nModule keeps index.ts order.
* - Output uses LF line endings (repo-wide `.gitattributes`: `* text=auto eol=lf`).
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const i18nDir = path.join(repoRoot, 'ui', 'src', 'renderer', 'services', 'i18n');
const localeDir = path.join(i18nDir, 'locales', 'en-US');
const outFile = path.join(i18nDir, 'i18n-keys.d.ts');
const checkMode = process.argv.includes('--check');
/** Parse locales/en-US/index.ts: namespace export order + json file per namespace. */
function readNamespaces() {
const src = fs.readFileSync(path.join(localeDir, 'index.ts'), 'utf8');
const importMap = new Map(); // identifier -> json filename
const importRe = /import\s+(\w+)\s+from\s+'\.\/([\w.-]+)\.json'/g;
for (let m; (m = importRe.exec(src)); ) importMap.set(m[1], `${m[2]}.json`);
const block = src.match(/export\s+default\s*\{([\s\S]*?)\}/);
if (!block) throw new Error(`export default block not found in ${path.join(localeDir, 'index.ts')}`);
const names = block[1]
.split(',')
.map((s) => s.trim())
.filter(Boolean);
const namespaces = names.map((name) => {
// supports shorthand (`common`) and aliased (`starOffice: starOffice`) entries
const [exportName, ident = exportName] = name.split(':').map((s) => s.trim());
const file = importMap.get(ident);
if (!file) throw new Error(`namespace '${exportName}' in index.ts has no matching JSON import`);
return { name: exportName, file };
});
// Orphan JSON files (present on disk, not exported) are drift the runtime
// cannot see — surface them loudly but do not include their keys.
const referenced = new Set(namespaces.map((n) => n.file));
const orphans = fs
.readdirSync(localeDir)
.filter((f) => f.endsWith('.json') && !referenced.has(f));
for (const f of orphans) {
process.stderr.write(`warning: ${f} exists in en-US but is not exported by index.ts (keys excluded)\n`);
}
return namespaces;
}
/** Dot-flatten a JSON value into `out`; arrays become numeric segments. */
function flatten(value, prefix, out) {
if (Array.isArray(value)) {
value.forEach((v, i) => flatten(v, `${prefix}.${i}`, out));
} else if (value !== null && typeof value === 'object') {
for (const [k, v] of Object.entries(value)) flatten(v, `${prefix}.${k}`, out);
} else {
out.push(prefix);
}
}
function collectKeys(namespaces) {
const keys = [];
for (const { name, file } of namespaces) {
const json = JSON.parse(fs.readFileSync(path.join(localeDir, file), 'utf8'));
flatten(json, name, keys);
}
// Some locale files (e.g. settings.json) contain both a flat dotted key
// ("assistant.botToken") and a nested object ("assistant": { "botToken" })
// that flatten to the same path. The union type lists each key once, so we
// dedupe — but surface the collisions as a lint warning.
const seen = new Set();
const dupes = new Set();
for (const k of keys) (seen.has(k) ? dupes : seen).add(k);
if (dupes.size) {
process.stderr.write(
`warning: ${dupes.size} flattened key collisions (flat dotted key + nested object), deduped:\n ${[...dupes].join('\n ')}\n`,
);
}
return [...seen].sort(); // UTF-16 code unit order, matches historical output
}
const quote = (s) => `'${s.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
const union = (items) => items.map((k) => ` | ${quote(k)}`).join('\n');
function render(namespaces, keys) {
return [
'/* eslint-disable */',
'/**',
' * AUTO-GENERATED FILE - DO NOT EDIT',
' * Generated by scripts/generate-i18n-types.mjs',
' */',
'',
'export type I18nKey =',
`${union(keys)};`,
'',
'export type I18nModule =',
`${union(namespaces.map((n) => n.name))};`,
'',
].join('\n');
}
const normalize = (s) => s.replace(/\r\n/g, '\n');
function main() {
const namespaces = readNamespaces();
const keys = collectKeys(namespaces);
const generated = render(namespaces, keys);
const existing = fs.existsSync(outFile) ? normalize(fs.readFileSync(outFile, 'utf8')) : null;
if (checkMode) {
if (existing === generated) {
console.log(`i18n-keys.d.ts is up to date (${keys.length} keys, ${namespaces.length} modules)`);
return;
}
// Report drift at key granularity, then fall back to a text-level hint.
const extractKeys = (text) => {
const section = text.split('export type I18nModule')[0];
return new Set([...section.matchAll(/\|\s+'((?:[^'\\]|\\.)*)'/g)].map((m) => m[1]));
};
const oldKeys = existing ? extractKeys(existing) : new Set();
const newKeys = new Set(keys);
const missing = keys.filter((k) => !oldKeys.has(k)); // in locales, not in d.ts
const stale = [...oldKeys].filter((k) => !newKeys.has(k)); // in d.ts, not in locales
if (missing.length) console.error(`missing from d.ts (${missing.length}):\n ${missing.join('\n ')}`);
if (stale.length) console.error(`stale in d.ts (${stale.length}):\n ${stale.join('\n ')}`);
if (!missing.length && !stale.length) console.error('key sets match but file text differs (ordering/header/EOL)');
console.error('\ni18n-keys.d.ts is out of date — run: node scripts/generate-i18n-types.mjs');
process.exitCode = 1;
return;
}
if (existing === generated) {
console.log(`i18n-keys.d.ts already up to date (${keys.length} keys)`);
return;
}
fs.writeFileSync(outFile, generated, 'utf8');
console.log(`wrote ${path.relative(repoRoot, outFile)} (${keys.length} keys, ${namespaces.length} modules)`);
}
main();