expand-translations.mjs 3.4 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586
  1. // Expand a "source-string → per-locale translations" map into the
  2. // per-(locale, dottedKey) shape that apply-translations.mjs consumes.
  3. //
  4. // Usage: node scripts/expand-translations.mjs <source-table.json> > out.json
  5. //
  6. // Source-table shape (one entry per unique English source string):
  7. // {
  8. // "Status": { "de": "Status", "fr": "Statut", "it": "Stato", "ja": "ステータス", "pt-BR": "Status", "zh-CN": "状态", "zh-TW": "狀態" },
  9. // ...
  10. // }
  11. //
  12. // Reads the current untranslated set from the live locale files (same logic
  13. // as dump-untranslated.mjs) and outputs:
  14. // { "de": { "<dottedKey>": "<translation>", ... }, "fr": {...}, ... }
  15. import fs from 'node:fs';
  16. import path from 'node:path';
  17. import process from 'node:process';
  18. import url from 'node:url';
  19. const scriptDir = path.dirname(url.fileURLToPath(import.meta.url));
  20. const localesDir = path.resolve(scriptDir, '../src/i18n/locales');
  21. const tsPath = path.resolve(scriptDir, '../node_modules/typescript/lib/typescript.js');
  22. const tsModule = await import(url.pathToFileURL(tsPath).href);
  23. const ts = tsModule.default ?? tsModule;
  24. function collectLeaves(node, prefix, leaves) {
  25. if (!ts.isObjectLiteralExpression(node)) return;
  26. for (const prop of node.properties) {
  27. if (!ts.isPropertyAssignment(prop)) continue;
  28. let name;
  29. if (ts.isIdentifier(prop.name)) name = prop.name.text;
  30. else if (ts.isStringLiteral(prop.name)) name = prop.name.text;
  31. else continue;
  32. const p = prefix ? `${prefix}.${name}` : name;
  33. if (ts.isObjectLiteralExpression(prop.initializer)) {
  34. collectLeaves(prop.initializer, p, leaves);
  35. } else if (ts.isStringLiteral(prop.initializer) || ts.isNoSubstitutionTemplateLiteral(prop.initializer)) {
  36. leaves.set(p, prop.initializer.text);
  37. }
  38. }
  39. }
  40. function loadLocale(filePath) {
  41. const sf = ts.createSourceFile(filePath, fs.readFileSync(filePath, 'utf8'), ts.ScriptTarget.Latest, true);
  42. const leaves = new Map();
  43. ts.forEachChild(sf, (n) => { if (ts.isExportAssignment(n)) collectLeaves(n.expression, '', leaves); });
  44. return leaves;
  45. }
  46. const arg = process.argv[2];
  47. if (!arg) {
  48. console.error('Usage: node expand-translations.mjs <source-table.json>');
  49. process.exit(2);
  50. }
  51. const table = JSON.parse(fs.readFileSync(arg, 'utf8'));
  52. const en = loadLocale(path.join(localesDir, 'en.ts'));
  53. const codes = ['de', 'fr', 'it', 'ja', 'pt-BR', 'zh-CN', 'zh-TW'];
  54. const out = Object.fromEntries(codes.map((c) => [c, {}]));
  55. const missingSources = new Set();
  56. for (const code of codes) {
  57. const map = loadLocale(path.join(localesDir, `${code}.ts`));
  58. for (const [key, enValue] of en) {
  59. const localeValue = map.get(key);
  60. if (localeValue === undefined) continue;
  61. if (localeValue !== enValue) continue; // already translated
  62. const entry = table[enValue];
  63. if (!entry) { missingSources.add(enValue); continue; }
  64. const translated = entry[code];
  65. if (translated === undefined) continue;
  66. // Always emit, even when translated === enValue, so the apply script can
  67. // either no-op or replace as needed. (Same value is a no-op via .text check.)
  68. out[code][key] = translated;
  69. }
  70. }
  71. if (missingSources.size > 0) {
  72. process.stderr.write(`\n[warn] ${missingSources.size} source strings not in table:\n`);
  73. for (const s of [...missingSources].sort()) {
  74. const preview = s.length > 80 ? s.slice(0, 77) + '...' : s;
  75. process.stderr.write(` ${JSON.stringify(preview)}\n`);
  76. }
  77. }
  78. process.stdout.write(JSON.stringify(out, null, 2));