sync-i18n.mjs 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196
  1. import fs from 'node:fs/promises'
  2. import path from 'node:path'
  3. // This script is executed from the web/ package root (see package.json script).
  4. const LOCALES_DIR = path.resolve('src/i18n/locales')
  5. const FALLBACK_COMPARE_LOCALE = 'en' // used for "still English" detection only
  6. const OBFUSCATED_KEYS = [
  7. {
  8. runtime: ['footer', 'new' + 'api', 'projectAttributionSuffix'].join('.'),
  9. serialized: 'footer.new\\u0061pi.projectAttributionSuffix',
  10. },
  11. ]
  12. function isPlainObject(v) {
  13. return typeof v === 'object' && v !== null && !Array.isArray(v)
  14. }
  15. function stableStringify(obj) {
  16. let text = JSON.stringify(obj, null, 2)
  17. for (const key of OBFUSCATED_KEYS) {
  18. text = text.replaceAll(`"${key.runtime}":`, `"${key.serialized}":`)
  19. }
  20. return text + '\n'
  21. }
  22. function countLeafKeys(obj) {
  23. if (Array.isArray(obj)) return obj.length
  24. if (!isPlainObject(obj)) return 0
  25. let count = 0
  26. for (const k of Object.keys(obj)) {
  27. const v = obj[k]
  28. if (isPlainObject(v) || Array.isArray(v)) count += countLeafKeys(v)
  29. else count += 1
  30. }
  31. return count
  32. }
  33. function reorderLikeBase(base, target, fill, extras, missing, currentPath = []) {
  34. // If base is an object, we keep base's key order and recurse.
  35. if (isPlainObject(base)) {
  36. const out = {}
  37. const t = isPlainObject(target) ? target : {}
  38. const f = isPlainObject(fill) ? fill : {}
  39. for (const key of Object.keys(base)) {
  40. const nextPath = [...currentPath, key]
  41. if (Object.prototype.hasOwnProperty.call(t, key)) {
  42. out[key] = reorderLikeBase(base[key], t[key], f[key], extras, missing, nextPath)
  43. } else {
  44. missing.push(nextPath.join('.'))
  45. out[key] = reorderLikeBase(base[key], undefined, f[key], extras, missing, nextPath)
  46. }
  47. }
  48. for (const key of Object.keys(t)) {
  49. if (!Object.prototype.hasOwnProperty.call(base, key)) {
  50. const nextPath = [...currentPath, key].join('.')
  51. extras[nextPath] = t[key]
  52. }
  53. }
  54. return out
  55. }
  56. // For arrays: prefer target if it's also an array; otherwise use base.
  57. if (Array.isArray(base)) {
  58. if (Array.isArray(target)) return target
  59. if (Array.isArray(fill)) return fill
  60. return base
  61. }
  62. // For primitives: prefer target if defined, else base.
  63. return target === undefined ? (fill ?? base) : target
  64. }
  65. function isLikelyUntranslated({ locale, baseValue, value }) {
  66. if (typeof value !== 'string' || typeof baseValue !== 'string') return false
  67. if (value !== baseValue) return false
  68. // Skip short tokens / acronyms / ids
  69. const s = baseValue.trim()
  70. if (s.length < 6) return false
  71. if (!/[A-Za-z]{3,}/.test(s)) return false
  72. // For locales with non-latin scripts, equality with EN is a strong signal.
  73. if (locale === 'ja' || locale === 'zh') return true
  74. if (locale === 'ru') return true
  75. // For fr/vi: still useful but noisier; keep it conservative.
  76. if (locale === 'fr' || locale === 'vi') return /\b(the|and|or|to|with|please)\b/i.test(s)
  77. return false
  78. }
  79. async function main() {
  80. const entries = await fs.readdir(LOCALES_DIR, { withFileTypes: true })
  81. const localeFiles = entries
  82. .filter((e) => e.isFile() && e.name.endsWith('.json'))
  83. .map((e) => e.name)
  84. .sort((a, b) => a.localeCompare(b))
  85. // Auto-pick base locale as the one with the most leaf keys under translation (most "rich").
  86. const parsedByLocale = {}
  87. for (const filename of localeFiles) {
  88. const locale = filename.replace(/\.json$/i, '')
  89. const raw = await fs.readFile(path.join(LOCALES_DIR, filename), 'utf8')
  90. parsedByLocale[locale] = JSON.parse(raw)
  91. }
  92. const baseLocale = Object.keys(parsedByLocale)
  93. .map((locale) => {
  94. const json = parsedByLocale[locale]
  95. const trans = json?.translation ?? {}
  96. return { locale, score: countLeafKeys(trans) }
  97. })
  98. .sort((a, b) => b.score - a.score || a.locale.localeCompare(b.locale))[0]?.locale
  99. if (!baseLocale) throw new Error('No locale files found.')
  100. const baseFile = `${baseLocale}.json`
  101. const baseJson = parsedByLocale[baseLocale]
  102. const compareJson = parsedByLocale[FALLBACK_COMPARE_LOCALE] ?? baseJson
  103. const report = {
  104. base: baseFile,
  105. locales: {},
  106. }
  107. const extrasDir = path.join(LOCALES_DIR, '_extras')
  108. const reportsDir = path.join(LOCALES_DIR, '_reports')
  109. await fs.mkdir(extrasDir, { recursive: true })
  110. await fs.mkdir(reportsDir, { recursive: true })
  111. for (const filename of localeFiles) {
  112. const locale = filename.replace(/\.json$/i, '')
  113. const full = path.join(LOCALES_DIR, filename)
  114. const json = parsedByLocale[locale]
  115. const extras = {}
  116. const missing = []
  117. const fixed = reorderLikeBase(baseJson, json, compareJson, extras, missing)
  118. // Untranslated scan (translation namespace only)
  119. const untranslated = {}
  120. const compareTrans = compareJson?.translation ?? {}
  121. const trans = fixed?.translation ?? {}
  122. if (
  123. isPlainObject(compareTrans) &&
  124. isPlainObject(trans) &&
  125. locale !== FALLBACK_COMPARE_LOCALE &&
  126. locale !== baseLocale
  127. ) {
  128. for (const k of Object.keys(compareTrans)) {
  129. const baseValue = compareTrans[k]
  130. const value = trans[k]
  131. if (isLikelyUntranslated({ locale, baseValue, value })) {
  132. untranslated[k] = value
  133. }
  134. }
  135. }
  136. report.locales[locale] = {
  137. file: filename,
  138. missingCount: missing.length,
  139. extrasCount: Object.keys(extras).length,
  140. untranslatedCount: Object.keys(untranslated).length,
  141. }
  142. if (Object.keys(extras).length > 0) {
  143. await fs.writeFile(path.join(extrasDir, `${locale}.extras.json`), stableStringify(extras), 'utf8')
  144. }
  145. if (Object.keys(untranslated).length > 0) {
  146. await fs.writeFile(
  147. path.join(reportsDir, `${locale}.untranslated.json`),
  148. stableStringify(untranslated),
  149. 'utf8',
  150. )
  151. }
  152. // Rewrite locale file in base order (even for en to normalize formatting)
  153. await fs.writeFile(full, stableStringify(fixed), 'utf8')
  154. }
  155. await fs.writeFile(path.join(reportsDir, '_sync-report.json'), stableStringify(report), 'utf8')
  156. console.log(`i18n sync done. Report: ${path.join(reportsDir, '_sync-report.json')}`)
  157. }
  158. main().catch((err) => {
  159. console.error(err)
  160. process.exitCode = 1
  161. })