3347619816
Cospend translations move to src/lib/i18n/cospend/{de,en}.ts with
satisfies-based key-set enforcement, mirroring the fitness layout
shipped earlier. cospendI18n.ts becomes the same kind of slim shim
exporting m, CospendLang, CospendKey while keeping every existing
helper (detectCospendLang, paymentCategoryName, splitDescription,
formatNextExecutionI18n, etc.) on the same surface.
Calendar gets the same treatment but with three locales (de/en/la)
and two namespaces — `ui` and the rite-1962-specific `ui1962`.
calendarI18n.ts now imports both as m / m1962, types them as
CalendarKey / Calendar1962Key, and routes t() / t1962() through
them. The 1962 fallback is per-namespace dir with file-prefixed
locale files (de_1962.ts etc.) so they can co-exist.
19 cospend route/component files and 3 calendar pages migrated to
the t.key / t1962.key syntax. Two notable hand fixes: UsersList.svelte
needed `as CospendLang` because the `lang` prop default uses an `as`
cast that breaks TS narrowing of m[lang]; and a sed pass converted
codemod-emitted t['camelCase'] to t.camelCase since the static-key
regex initially only matched snake_case.
The split + codemod scripts are now generic — split-i18n.ts takes
namespace, locales, optional marker and basename for multi-table
modules; codemod-i18n-t-to-m.ts takes module basename, fn name, and
m alias name (so t1962 / m1962 share the same machinery as t / m).
The fitness-specific one-shots are deleted, superseded.
133 lines
4.9 KiB
TypeScript
133 lines
4.9 KiB
TypeScript
/**
|
|
* Split a single-file i18n module (with an object literal whose values are
|
|
* `Record<locale, string>`) into per-locale files under
|
|
* src/lib/i18n/<namespace>/<locale>.ts.
|
|
*
|
|
* The first locale is the source of truth; others use `as const satisfies
|
|
* Record<keyof typeof <first>, string>` so missing translations fail
|
|
* type-checking.
|
|
*
|
|
* Run: pnpm exec vite-node scripts/split-i18n.ts <source> <namespace> <locales,csv> [--marker=<marker>] [--basename=<name>]
|
|
* 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 <source> <namespace> <locales,csv> [--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<string, string>;
|
|
}
|
|
|
|
const entries: Entry[] = [];
|
|
let m: RegExpExecArray | null;
|
|
while ((m = entryRe.exec(body)) !== null) {
|
|
const inner = m[2];
|
|
const values: Record<string, string> = {};
|
|
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<keyof typeof ${sourceLocale}, string>;`,
|
|
''
|
|
);
|
|
writeFileSync(path(loc), lines.join('\n'));
|
|
}
|
|
|
|
console.log(`wrote ${locales.map(path).join(', ')}`);
|