You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
134 lines
5.0 KiB
134 lines
5.0 KiB
/** |
|
* Merge t() keys from src into en, then regenerate all locale files with the same key set. |
|
* Missing non-English strings fall back to English. |
|
* |
|
* Run: npx tsx scripts/sync-i18n-locales.ts && npx prettier --write "src/i18n/locales/*.ts" |
|
*/ |
|
import fs from 'fs' |
|
import path from 'path' |
|
import { fileURLToPath } from 'url' |
|
import cs from '../src/i18n/locales/cs' |
|
import de from '../src/i18n/locales/de' |
|
import en from '../src/i18n/locales/en' |
|
import es from '../src/i18n/locales/es' |
|
import fr from '../src/i18n/locales/fr' |
|
import nl from '../src/i18n/locales/nl' |
|
import pl from '../src/i18n/locales/pl' |
|
import ru from '../src/i18n/locales/ru' |
|
import tr from '../src/i18n/locales/tr' |
|
import zh from '../src/i18n/locales/zh' |
|
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url)) |
|
const srcDir = path.join(__dirname, '..', 'src') |
|
const localesDir = path.join(__dirname, '..', 'src/i18n/locales') |
|
const overridesDir = path.join(__dirname, 'i18n-overrides') |
|
|
|
function loadOverrides(localeFile: string): Record<string, string> { |
|
if (localeFile === 'en.ts') return {} |
|
const p = path.join(overridesDir, localeFile.replace(/\.ts$/, '.json')) |
|
if (!fs.existsSync(p)) return {} |
|
try { |
|
return JSON.parse(fs.readFileSync(p, 'utf8')) as Record<string, string> |
|
} catch { |
|
return {} |
|
} |
|
} |
|
|
|
const PACKAGES: { file: string; translation: Record<string, string>; header?: string }[] = [ |
|
{ file: 'cs.ts', translation: cs.translation, header: '// NOTE: Untranslated strings fall back to English.\n' }, |
|
{ file: 'de.ts', translation: de.translation, header: '// NOTE: Untranslated strings fall back to English.\n' }, |
|
{ file: 'en.ts', translation: en.translation }, |
|
{ file: 'es.ts', translation: es.translation, header: '// NOTE: Untranslated strings fall back to English.\n' }, |
|
{ file: 'fr.ts', translation: fr.translation, header: '// NOTE: Untranslated strings fall back to English.\n' }, |
|
{ file: 'nl.ts', translation: nl.translation, header: '// NOTE: Untranslated strings fall back to English.\n' }, |
|
{ file: 'pl.ts', translation: pl.translation, header: '// NOTE: Untranslated strings fall back to English.\n' }, |
|
{ file: 'ru.ts', translation: ru.translation, header: '// NOTE: Untranslated strings fall back to English.\n' }, |
|
{ file: 'tr.ts', translation: tr.translation, header: '// NOTE: Untranslated strings fall back to English.\n' }, |
|
{ file: 'zh.ts', translation: zh.translation, header: '// NOTE: Untranslated strings fall back to English.\n' } |
|
] |
|
|
|
function walk(dir: string, acc: string[] = []): string[] { |
|
for (const name of fs.readdirSync(dir)) { |
|
const p = path.join(dir, name) |
|
const st = fs.statSync(p) |
|
if (st.isDirectory()) { |
|
if (name === 'node_modules' || name === 'dist') continue |
|
walk(p, acc) |
|
} else if (/\.(tsx|ts)$/.test(name)) acc.push(p) |
|
} |
|
return acc |
|
} |
|
|
|
function unquoteSingle(s: string) { |
|
return s.replace(/\\'/g, "'").replace(/\\\\/g, '\\') |
|
} |
|
function unquoteDouble(s: string) { |
|
return s.replace(/\\"/g, '"').replace(/\\\\/g, '\\') |
|
} |
|
|
|
function extractTKeys(content: string): Set<string> { |
|
const keys = new Set<string>() |
|
const re1 = /\bt\(\s*'((?:\\.|[^'\\])*)'/g |
|
let m |
|
while ((m = re1.exec(content)) !== null) { |
|
const raw = unquoteSingle(m[1]) |
|
if (raw.length > 0 && raw.length < 500) keys.add(raw) |
|
} |
|
const re2 = /\bt\(\s*"((?:\\.|[^"\\])*)"/g |
|
while ((m = re2.exec(content)) !== null) { |
|
const raw = unquoteDouble(m[1]) |
|
if (raw.length > 0 && raw.length < 500) keys.add(raw) |
|
} |
|
return keys |
|
} |
|
|
|
function formatKey(k: string): string { |
|
if (/^[A-Za-z_$][\w$]*$/.test(k)) return k |
|
return JSON.stringify(k) |
|
} |
|
|
|
function formatValue(v: string): string { |
|
return JSON.stringify(v) |
|
} |
|
|
|
function emitLocaleFile(translation: Record<string, string>, keyOrder: string[], headerComment?: string): string { |
|
const lines: string[] = ['export default {', ' translation: {'] |
|
if (headerComment) lines.push(` ${headerComment}`) |
|
for (const k of keyOrder) { |
|
const v = translation[k] |
|
if (v === undefined) continue |
|
lines.push(` ${formatKey(k)}: ${formatValue(v)},`) |
|
} |
|
lines.push(' }', '}', '') |
|
return lines.join('\n') |
|
} |
|
|
|
const used = new Set<string>() |
|
for (const f of walk(srcDir)) { |
|
const c = fs.readFileSync(f, 'utf8') |
|
for (const k of extractTKeys(c)) used.add(k) |
|
} |
|
|
|
const prevEn = { ...en.translation } as Record<string, string> |
|
const prevKeys = Object.keys(prevEn) |
|
const newOnly = [...used].filter((k) => !(k in prevEn)).sort() |
|
const keyOrder = [...prevKeys, ...newOnly] |
|
|
|
const mergedEn: Record<string, string> = {} |
|
for (const k of keyOrder) { |
|
mergedEn[k] = k in prevEn ? prevEn[k] : k |
|
} |
|
|
|
for (const pkg of PACKAGES) { |
|
const prev = pkg.translation as Record<string, string> |
|
const patch = loadOverrides(pkg.file) |
|
const out: Record<string, string> = {} |
|
for (const k of keyOrder) { |
|
const base = prev[k] !== undefined ? prev[k] : mergedEn[k] |
|
out[k] = patch[k] !== undefined ? patch[k] : base |
|
} |
|
const body = emitLocaleFile(out, keyOrder, pkg.header) |
|
fs.writeFileSync(path.join(localesDir, pkg.file), body, 'utf8') |
|
} |
|
|
|
console.log('Keys:', keyOrder.length, '| New from scan:', newOnly.length)
|
|
|