sync-i18n.mjs 5.8 KB

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