f7a720204a
- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用 - 添加所有子项目的完整源代码 - 保留原始 .git 为 .git.bak 备份
161 lines
6.4 KiB
JavaScript
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();
|