197 lines
6.1 KiB
JavaScript
Vendored
197 lines
6.1 KiB
JavaScript
Vendored
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
|
|
})
|
|
|
|
|