apply-translations.mjs 4.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137
  1. // Apply a batch of translations to locale files in-place.
  2. //
  3. // Usage: node scripts/apply-translations.mjs <translation-file.json>
  4. //
  5. // The translation file is a JSON object shaped like:
  6. // {
  7. // "de": { "nav.system": "System", "common.optional": "Optional" },
  8. // "fr": { "nav.archives": "Archives" },
  9. // ...
  10. // }
  11. //
  12. // For each (locale, dottedKey, newValue) entry, the script uses the
  13. // TypeScript parser to locate the leaf at that exact dotted path, then
  14. // rewrites the string literal in place — preserving all other content
  15. // (comments, ordering, formatting, surrounding code) untouched.
  16. import fs from 'node:fs';
  17. import path from 'node:path';
  18. import process from 'node:process';
  19. import url from 'node:url';
  20. const scriptDir = path.dirname(url.fileURLToPath(import.meta.url));
  21. const frontendDir = path.resolve(scriptDir, '..');
  22. const localesDir = path.join(frontendDir, 'src/i18n/locales');
  23. const tsPath = path.join(frontendDir, 'node_modules/typescript/lib/typescript.js');
  24. const tsModule = await import(url.pathToFileURL(tsPath).href);
  25. const ts = tsModule.default ?? tsModule;
  26. // Walk the locale's AST, building a map of dottedPath -> string-literal node.
  27. function collectLeafNodes(node, prefix, out) {
  28. if (!ts.isObjectLiteralExpression(node)) return;
  29. for (const prop of node.properties) {
  30. if (!ts.isPropertyAssignment(prop)) continue;
  31. let name;
  32. if (ts.isIdentifier(prop.name)) name = prop.name.text;
  33. else if (ts.isStringLiteral(prop.name)) name = prop.name.text;
  34. else continue;
  35. const p = prefix ? `${prefix}.${name}` : name;
  36. if (ts.isObjectLiteralExpression(prop.initializer)) {
  37. collectLeafNodes(prop.initializer, p, out);
  38. } else if (
  39. ts.isStringLiteral(prop.initializer) ||
  40. ts.isNoSubstitutionTemplateLiteral(prop.initializer)
  41. ) {
  42. out.set(p, prop.initializer);
  43. }
  44. }
  45. }
  46. function loadLocaleNodes(filePath) {
  47. const src = fs.readFileSync(filePath, 'utf8');
  48. const sf = ts.createSourceFile(filePath, src, ts.ScriptTarget.Latest, true);
  49. const leaves = new Map();
  50. ts.forEachChild(sf, (n) => {
  51. if (ts.isExportAssignment(n)) collectLeafNodes(n.expression, '', leaves);
  52. });
  53. return { src, leaves };
  54. }
  55. function literalReplacement(node, newValue) {
  56. // Re-emit the literal preserving its quote style. Locale files use either
  57. // single-quoted strings or backtick template-literal-with-no-substitutions.
  58. const original = node.getText();
  59. const quote = original.startsWith('`') ? '`' : original[0]; // ' or `
  60. if (quote === '`') {
  61. return '`' + newValue.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$\{/g, '\\${') + '`';
  62. }
  63. const esc = newValue.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
  64. return `'${esc}'`;
  65. }
  66. function applyToLocale(code, map) {
  67. const file = path.join(localesDir, `${code}.ts`);
  68. if (!fs.existsSync(file)) {
  69. throw new Error(`Locale file not found: ${file}`);
  70. }
  71. let { src, leaves } = loadLocaleNodes(file);
  72. // Apply edits in reverse-position order so earlier edits don't shift later positions.
  73. const edits = [];
  74. const errors = [];
  75. let applied = 0;
  76. let skipped = 0;
  77. for (const [dottedKey, newValue] of Object.entries(map)) {
  78. const node = leaves.get(dottedKey);
  79. if (!node) {
  80. errors.push(`${code}: key "${dottedKey}" not found in locale file`);
  81. continue;
  82. }
  83. if (node.text === newValue) {
  84. skipped++;
  85. continue;
  86. }
  87. edits.push({
  88. start: node.getStart(),
  89. end: node.getEnd(),
  90. replacement: literalReplacement(node, newValue),
  91. });
  92. applied++;
  93. }
  94. edits.sort((a, b) => b.start - a.start);
  95. for (const e of edits) {
  96. src = src.slice(0, e.start) + e.replacement + src.slice(e.end);
  97. }
  98. if (errors.length) {
  99. console.error(`\n[${code}] errors:`);
  100. for (const e of errors) console.error(` ${e}`);
  101. }
  102. if (applied > 0) {
  103. fs.writeFileSync(file, src, 'utf8');
  104. }
  105. console.log(`[${code}] applied=${applied} skipped(same)=${skipped} errors=${errors.length}`);
  106. return { applied, skipped, errors };
  107. }
  108. async function main() {
  109. const arg = process.argv[2];
  110. if (!arg) {
  111. console.error('Usage: node apply-translations.mjs <translation-file.json>');
  112. process.exit(2);
  113. }
  114. const data = JSON.parse(fs.readFileSync(arg, 'utf8'));
  115. let totalErrors = 0;
  116. for (const [code, map] of Object.entries(data)) {
  117. const { errors } = applyToLocale(code, map);
  118. totalErrors += errors.length;
  119. }
  120. if (totalErrors > 0) {
  121. console.error(`\n${totalErrors} key(s) failed to apply.`);
  122. process.exit(1);
  123. }
  124. }
  125. main();