| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196 |
- import fs from 'node:fs/promises'
- import path from 'node:path'
- // This script is executed from the web/ package root (see package.json script).
- const LOCALES_DIR = path.resolve('src/i18n/locales')
- const FALLBACK_COMPARE_LOCALE = 'en' // used for "still English" detection only
- const OBFUSCATED_KEYS = [
- {
- runtime: ['footer', 'new' + 'api', 'projectAttributionSuffix'].join('.'),
- serialized: 'footer.new\\u0061pi.projectAttributionSuffix',
- },
- ]
- function isPlainObject(v) {
- return typeof v === 'object' && v !== null && !Array.isArray(v)
- }
- function stableStringify(obj) {
- let text = JSON.stringify(obj, null, 2)
- for (const key of OBFUSCATED_KEYS) {
- text = text.replaceAll(`"${key.runtime}":`, `"${key.serialized}":`)
- }
- return text + '\n'
- }
- function countLeafKeys(obj) {
- if (Array.isArray(obj)) return obj.length
- if (!isPlainObject(obj)) return 0
- let count = 0
- for (const k of Object.keys(obj)) {
- const v = obj[k]
- if (isPlainObject(v) || Array.isArray(v)) count += countLeafKeys(v)
- else count += 1
- }
- return count
- }
- function reorderLikeBase(base, target, fill, extras, missing, currentPath = []) {
- // If base is an object, we keep base's key order and recurse.
- if (isPlainObject(base)) {
- const out = {}
- const t = isPlainObject(target) ? target : {}
- const f = isPlainObject(fill) ? fill : {}
- for (const key of Object.keys(base)) {
- const nextPath = [...currentPath, key]
- if (Object.prototype.hasOwnProperty.call(t, key)) {
- out[key] = reorderLikeBase(base[key], t[key], f[key], extras, missing, nextPath)
- } else {
- missing.push(nextPath.join('.'))
- out[key] = reorderLikeBase(base[key], undefined, f[key], extras, missing, nextPath)
- }
- }
- for (const key of Object.keys(t)) {
- if (!Object.prototype.hasOwnProperty.call(base, key)) {
- const nextPath = [...currentPath, key].join('.')
- extras[nextPath] = t[key]
- }
- }
- return out
- }
- // For arrays: prefer target if it's also an array; otherwise use base.
- if (Array.isArray(base)) {
- if (Array.isArray(target)) return target
- if (Array.isArray(fill)) return fill
- return base
- }
- // For primitives: prefer target if defined, else base.
- return target === undefined ? (fill ?? base) : target
- }
- function isLikelyUntranslated({ locale, baseValue, value }) {
- if (typeof value !== 'string' || typeof baseValue !== 'string') return false
- if (value !== baseValue) return false
- // Skip short tokens / acronyms / ids
- const s = baseValue.trim()
- if (s.length < 6) return false
- if (!/[A-Za-z]{3,}/.test(s)) return false
- // For locales with non-latin scripts, equality with EN is a strong signal.
- if (locale === 'ja' || locale === 'zh') return true
- if (locale === 'ru') return true
- // For fr/vi: still useful but noisier; keep it conservative.
- if (locale === 'fr' || locale === 'vi') return /\b(the|and|or|to|with|please)\b/i.test(s)
- return false
- }
- async function main() {
- const entries = await fs.readdir(LOCALES_DIR, { withFileTypes: true })
- const localeFiles = entries
- .filter((e) => e.isFile() && e.name.endsWith('.json'))
- .map((e) => e.name)
- .sort((a, b) => a.localeCompare(b))
- // Auto-pick base locale as the one with the most leaf keys under translation (most "rich").
- const parsedByLocale = {}
- for (const filename of localeFiles) {
- const locale = filename.replace(/\.json$/i, '')
- const raw = await fs.readFile(path.join(LOCALES_DIR, filename), 'utf8')
- parsedByLocale[locale] = JSON.parse(raw)
- }
- const baseLocale = Object.keys(parsedByLocale)
- .map((locale) => {
- const json = parsedByLocale[locale]
- const trans = json?.translation ?? {}
- return { locale, score: countLeafKeys(trans) }
- })
- .sort((a, b) => b.score - a.score || a.locale.localeCompare(b.locale))[0]?.locale
- if (!baseLocale) throw new Error('No locale files found.')
- const baseFile = `${baseLocale}.json`
- const baseJson = parsedByLocale[baseLocale]
- const compareJson = parsedByLocale[FALLBACK_COMPARE_LOCALE] ?? baseJson
- const report = {
- base: baseFile,
- locales: {},
- }
- const extrasDir = path.join(LOCALES_DIR, '_extras')
- const reportsDir = path.join(LOCALES_DIR, '_reports')
- await fs.mkdir(extrasDir, { recursive: true })
- await fs.mkdir(reportsDir, { recursive: true })
- for (const filename of localeFiles) {
- const locale = filename.replace(/\.json$/i, '')
- const full = path.join(LOCALES_DIR, filename)
- const json = parsedByLocale[locale]
- const extras = {}
- const missing = []
- const fixed = reorderLikeBase(baseJson, json, compareJson, extras, missing)
- // Untranslated scan (translation namespace only)
- const untranslated = {}
- const compareTrans = compareJson?.translation ?? {}
- const trans = fixed?.translation ?? {}
- if (
- isPlainObject(compareTrans) &&
- isPlainObject(trans) &&
- locale !== FALLBACK_COMPARE_LOCALE &&
- locale !== baseLocale
- ) {
- for (const k of Object.keys(compareTrans)) {
- const baseValue = compareTrans[k]
- const value = trans[k]
- if (isLikelyUntranslated({ locale, baseValue, value })) {
- untranslated[k] = value
- }
- }
- }
- report.locales[locale] = {
- file: filename,
- missingCount: missing.length,
- extrasCount: Object.keys(extras).length,
- untranslatedCount: Object.keys(untranslated).length,
- }
- if (Object.keys(extras).length > 0) {
- await fs.writeFile(path.join(extrasDir, `${locale}.extras.json`), stableStringify(extras), 'utf8')
- }
- if (Object.keys(untranslated).length > 0) {
- await fs.writeFile(
- path.join(reportsDir, `${locale}.untranslated.json`),
- stableStringify(untranslated),
- 'utf8',
- )
- }
- // Rewrite locale file in base order (even for en to normalize formatting)
- await fs.writeFile(full, stableStringify(fixed), 'utf8')
- }
- await fs.writeFile(path.join(reportsDir, '_sync-report.json'), stableStringify(report), 'utf8')
-
- console.log(`i18n sync done. Report: ${path.join(reportsDir, '_sync-report.json')}`)
- }
- main().catch((err) => {
-
- console.error(err)
- process.exitCode = 1
- })
|