/** * Split a single-file i18n module (with an object literal whose values are * `Record`) into per-locale files under * src/lib/i18n//.ts. * * The first locale is the source of truth; others use `as const satisfies * Record, string>` so missing translations fail * type-checking. * * Run: pnpm exec vite-node scripts/split-i18n.ts [--marker=] [--basename=] * e.g. ... cospendI18n.ts cospend de,en * ... calendarI18n.ts calendar de,en,la --marker='export const ui = {' --basename=de * * Defaults: marker = `const translations: Translations = {`, basename = first locale. */ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'; const [, , srcPath, namespace, localesCsv, ...flags] = process.argv; if (!srcPath || !namespace || !localesCsv) { console.error( 'usage: split-i18n.ts [--marker=...] [--basename=...]' ); process.exit(1); } const locales = localesCsv.split(',').map((s) => s.trim()).filter(Boolean); const markerFlag = flags.find((f) => f.startsWith('--marker=')); const startMarker = markerFlag ? markerFlag.slice('--marker='.length) : 'const translations: Translations = {'; const basenameFlag = flags.find((f) => f.startsWith('--basename=')); const fileBase = basenameFlag ? basenameFlag.slice('--basename='.length) : ''; const src = readFileSync(srcPath, 'utf8'); // Slice the translations object body const startIdx = src.indexOf(startMarker); if (startIdx === -1) throw new Error(`marker not found in ${srcPath}: ${startMarker}`); // Object literal can close with `};` or `} as const;` — pick the earliest match. const candA = src.indexOf('\n};', startIdx); const candB = src.indexOf('\n} as const', startIdx); const endIdx = candA < 0 ? candB : candB < 0 ? candA : Math.min(candA, candB); if (endIdx === -1) throw new Error('translations object end not found'); const body = src.slice(startIdx + startMarker.length, endIdx); // Match each translation entry boundary: `key: { ...inner... },`. Each // entry's body is then parsed independently for `loc: 'value'` pairs, so // locale order in the source file doesn't matter. const entryRe = /^\s*(\w+)\s*:\s*\{([\s\S]*?)\}\s*,?\s*$/gm; // Match `loc: '...'` OR `loc: "..."` (double quotes are used when the string // contains a literal apostrophe). const localeRe = /(\w+)\s*:\s*(?:'([^']*)'|"((?:\\.|[^"\\])*)")/g; function decodeJsString(raw: string, doubleQuoted: boolean): string { if (doubleQuoted) { // Already valid JSON (escapes preserved). Parse directly. return JSON.parse('"' + raw + '"'); } // Single-quoted: convert any \' → ' and escape literal " for JSON. const jsonReady = '"' + raw.replace(/\\'/g, "'").replace(/"/g, '\\"') + '"'; return JSON.parse(jsonReady); } interface Entry { key: string; values: Record; } const entries: Entry[] = []; let m: RegExpExecArray | null; while ((m = entryRe.exec(body)) !== null) { const inner = m[2]; const values: Record = {}; let lm: RegExpExecArray | null; while ((lm = localeRe.exec(inner)) !== null) { const single = lm[2]; const double = lm[3]; values[lm[1]] = single !== undefined ? decodeJsString(single, false) : decodeJsString(double, true); } for (const loc of locales) { if (!(loc in values)) { throw new Error(`entry "${m[1]}" is missing locale "${loc}"`); } } entries.push({ key: m[1], values }); } console.log(`extracted ${entries.length} entries`); const outDir = `src/lib/i18n/${namespace}`; mkdirSync(outDir, { recursive: true }); const sourceLocale = locales[0]; // Optional file prefix lets us split multiple tables into the same dir // (e.g. calendar `ui` → de.ts, calendar `ui1962` → de_1962.ts). const path = (loc: string) => `${outDir}/${fileBase ? `${loc}_${fileBase}` : loc}.ts`; // Write the source-of-truth locale (no satisfies clause). { const lines = [ '/** Generated by scripts/split-i18n.ts. */', `/** ${sourceLocale.toUpperCase()} ${namespace}${fileBase ? ` (${fileBase})` : ''} UI strings — source of truth for the key set. */`, '', `export const ${sourceLocale} = {` ]; for (const e of entries) lines.push(`\t${e.key}: ${JSON.stringify(e.values[sourceLocale])},`); lines.push('} as const;', ''); writeFileSync(path(sourceLocale), lines.join('\n')); } // Write the other locales with `satisfies` constraint. const sourceFile = fileBase ? `${sourceLocale}_${fileBase}` : sourceLocale; for (let i = 1; i < locales.length; i++) { const loc = locales[i]; const lines = [ '/** Generated by scripts/split-i18n.ts. */', `import type { ${sourceLocale} } from './${sourceFile}';`, '', `export const ${loc} = {` ]; for (const e of entries) lines.push(`\t${e.key}: ${JSON.stringify(e.values[loc])},`); lines.push( `} as const satisfies Record;`, '' ); writeFileSync(path(loc), lines.join('\n')); } console.log(`wrote ${locales.map(path).join(', ')}`);