check-i18n-parity.mjs 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174
  1. #!/usr/bin/env node
  2. // Verifies parity across locale files (en / zh-CN / zh-TW):
  3. // 1. Leaf-key sets are identical
  4. // 2. Each leaf's {{placeholder}} set is identical
  5. // 3. Plural suffixes: every en key ending in _plural / _one / _other must
  6. // exist in every other locale, and other locales must not introduce an
  7. // _one key that en does not have.
  8. // Malformed input (missing `export default`, parse errors, non-string leaves,
  9. // unsupported property kinds) fails loudly instead of silently passing the gate.
  10. // Exits 1 with a diagnostic report on any failure, else exits 0.
  11. import fs from 'node:fs';
  12. import path from 'node:path';
  13. import url from 'node:url';
  14. const scriptDir = path.dirname(url.fileURLToPath(import.meta.url));
  15. const frontendDir = path.resolve(scriptDir, '..');
  16. const localesDir = path.join(frontendDir, 'src/i18n/locales');
  17. const tsPath = path.join(frontendDir, 'node_modules/typescript/lib/typescript.js');
  18. const tsModule = await import(url.pathToFileURL(tsPath).href);
  19. const ts = tsModule.default ?? tsModule;
  20. function collectLeaves(node, prefix, leaves) {
  21. if (!ts.isObjectLiteralExpression(node)) return;
  22. for (const prop of node.properties) {
  23. if (!ts.isPropertyAssignment(prop)) {
  24. console.error(
  25. `Unsupported property kind ${ts.SyntaxKind[prop.kind]} at "${prefix}" ` +
  26. `(locale files must use plain \`key: value\` assignments — no spread, shorthand, methods, or accessors).`,
  27. );
  28. process.exit(1);
  29. }
  30. let name;
  31. if (ts.isIdentifier(prop.name)) name = prop.name.text;
  32. else if (ts.isStringLiteral(prop.name) || ts.isNoSubstitutionTemplateLiteral(prop.name)) name = prop.name.text;
  33. else if (ts.isComputedPropertyName(prop.name)) {
  34. console.error(`ComputedPropertyName not allowed in locale file at path "${prefix}"`);
  35. process.exit(1);
  36. } else {
  37. console.error(`Unsupported property-name kind ${ts.SyntaxKind[prop.name.kind]} at "${prefix}"`);
  38. process.exit(1);
  39. }
  40. const p = prefix ? `${prefix}.${name}` : name;
  41. if (ts.isObjectLiteralExpression(prop.initializer)) {
  42. collectLeaves(prop.initializer, p, leaves);
  43. } else {
  44. const value = extractStringValue(prop.initializer, p);
  45. leaves.set(p, value);
  46. }
  47. }
  48. }
  49. function extractStringValue(node, keyPath) {
  50. if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return node.text;
  51. if (ts.isTemplateExpression(node)) {
  52. let out = node.head.text;
  53. for (const span of node.templateSpans) {
  54. out += '${' + span.expression.getText() + '}';
  55. out += span.literal.text;
  56. }
  57. return out;
  58. }
  59. console.error(
  60. `Non-string leaf at "${keyPath}" (kind=${ts.SyntaxKind[node.kind]}): ${node.getText()}\n` +
  61. `Locale files must only contain string or template literals as leaf values.`,
  62. );
  63. process.exit(1);
  64. }
  65. function loadLocale(filePath) {
  66. const src = fs.readFileSync(filePath, 'utf8');
  67. const sf = ts.createSourceFile(filePath, src, ts.ScriptTarget.Latest, true);
  68. if (sf.parseDiagnostics && sf.parseDiagnostics.length > 0) {
  69. console.error(`${filePath}: ${sf.parseDiagnostics.length} parse error(s):`);
  70. for (const d of sf.parseDiagnostics.slice(0, 10)) {
  71. const msg = typeof d.messageText === 'string' ? d.messageText : d.messageText.messageText;
  72. const { line, character } = sf.getLineAndCharacterOfPosition(d.start ?? 0);
  73. console.error(` ${line + 1}:${character + 1} ${msg}`);
  74. }
  75. process.exit(1);
  76. }
  77. const leaves = new Map();
  78. let foundExport = false;
  79. ts.forEachChild(sf, (n) => {
  80. if (ts.isExportAssignment(n)) {
  81. foundExport = true;
  82. collectLeaves(n.expression, '', leaves);
  83. }
  84. });
  85. if (!foundExport) {
  86. console.error(`${filePath}: no \`export default\` found — locale files must use \`export default { ... }\`.`);
  87. process.exit(1);
  88. }
  89. if (leaves.size === 0) {
  90. console.error(`${filePath}: \`export default\` resolved to zero leaves — file is empty or not a nested object.`);
  91. process.exit(1);
  92. }
  93. return leaves;
  94. }
  95. const locales = {
  96. en: loadLocale(path.join(localesDir, 'en.ts')),
  97. 'zh-CN': loadLocale(path.join(localesDir, 'zh-CN.ts')),
  98. 'zh-TW': loadLocale(path.join(localesDir, 'zh-TW.ts')),
  99. };
  100. let failed = false;
  101. const MAX_REPORT = 20;
  102. function reportList(label, items) {
  103. if (items.length === 0) return;
  104. failed = true;
  105. console.error(`\n[${label}] (${items.length})`);
  106. items.slice(0, MAX_REPORT).forEach((i) => console.error(` ${i}`));
  107. if (items.length > MAX_REPORT) console.error(` ... and ${items.length - MAX_REPORT} more`);
  108. }
  109. // Check 1: key set equality
  110. const enKeys = new Set(locales.en.keys());
  111. for (const [code, map] of Object.entries(locales)) {
  112. if (code === 'en') continue;
  113. const keys = new Set(map.keys());
  114. const missing = [...enKeys].filter((k) => !keys.has(k)).sort();
  115. const extra = [...keys].filter((k) => !enKeys.has(k)).sort();
  116. reportList(`${code}: missing keys vs en`, missing);
  117. reportList(`${code}: extra keys vs en`, extra);
  118. }
  119. // Check 2: placeholder set equality per leaf
  120. const placeholderRe = /\{\{[^{}]+\}\}/g;
  121. for (const [code, map] of Object.entries(locales)) {
  122. if (code === 'en') continue;
  123. const mismatches = [];
  124. for (const [key, enValue] of locales.en) {
  125. const otherValue = map.get(key);
  126. if (otherValue === undefined) continue;
  127. const enPlaceholders = new Set((enValue.match(placeholderRe) ?? []));
  128. const otherPlaceholders = new Set((otherValue.match(placeholderRe) ?? []));
  129. const missingPh = [...enPlaceholders].filter((p) => !otherPlaceholders.has(p));
  130. const extraPh = [...otherPlaceholders].filter((p) => !enPlaceholders.has(p));
  131. if (missingPh.length || extraPh.length) {
  132. mismatches.push(`${key}: en=${[...enPlaceholders].join(',') || '∅'} vs ${code}=${[...otherPlaceholders].join(',') || '∅'}`);
  133. }
  134. }
  135. reportList(`${code}: placeholder mismatch vs en`, mismatches);
  136. }
  137. // Check 3: plural suffix presence + reverse _one guard
  138. for (const [code, map] of Object.entries(locales)) {
  139. if (code === 'en') continue;
  140. const pluralIssues = [];
  141. for (const key of enKeys) {
  142. if (key.endsWith('_plural') && !map.has(key)) pluralIssues.push(`missing _plural key: ${key}`);
  143. if (key.endsWith('_one') && !map.has(key)) pluralIssues.push(`missing _one key: ${key}`);
  144. if (key.endsWith('_other') && !map.has(key)) pluralIssues.push(`missing _other key: ${key}`);
  145. }
  146. for (const key of map.keys()) {
  147. if (key.endsWith('_one') && !enKeys.has(key)) {
  148. pluralIssues.push(`unexpected _one not present in en: ${key}`);
  149. }
  150. }
  151. reportList(`${code}: plural key mismatch`, pluralIssues);
  152. }
  153. if (failed) {
  154. console.error('\n❌ i18n parity check failed.');
  155. process.exit(1);
  156. }
  157. console.log(`All locales in parity (en / zh-CN / zh-TW):`);
  158. for (const [code, map] of Object.entries(locales)) {
  159. console.log(` ${code}: ${map.size} leaves`);
  160. }