refactor(i18n): split cospend + calendar per-locale, adopt t.key syntax

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.
This commit is contained in:
2026-05-01 12:47:46 +02:00
parent ac05367ee4
commit 3347619816
32 changed files with 1253 additions and 903 deletions
-104
View File
@@ -1,104 +0,0 @@
/**
* Migrate fitness call sites from t('key', lang) to t.key (or t[expr] for
* dynamic keys), where t = m[lang] derived once per file.
*
* Run: pnpm exec vite-node scripts/codemod-fitness-t-to-m.ts [--dry]
*/
import { readFileSync, writeFileSync, readdirSync, statSync } from 'node:fs';
import { join, extname } from 'node:path';
const DRY = process.argv.includes('--dry');
const ROOTS = ['src/routes/fitness', 'src/lib/components/fitness'];
const IMPORT_RE =
/import\s*\{([^}]+)\}\s*from\s*['"]\$lib\/js\/fitnessI18n['"]\s*;?/;
function walk(dir: string, out: string[] = []): string[] {
for (const name of readdirSync(dir)) {
const p = join(dir, name);
const s = statSync(p);
if (s.isDirectory()) walk(p, out);
else if (extname(p) === '.svelte' || extname(p) === '.ts') out.push(p);
}
return out;
}
function migrate(src: string): { code: string; changed: boolean } {
const m0 = IMPORT_RE.exec(src);
if (!m0) return { code: src, changed: false };
const items = m0[1].split(',').map((s) => s.trim()).filter(Boolean);
if (!items.includes('t')) return { code: src, changed: false };
// 1. Rewrite import: drop `t`, ensure `m` present.
const tIdx = items.indexOf('t');
items.splice(tIdx, 1);
if (!items.includes('m')) items.push('m');
let out = src.replace(IMPORT_RE, `import { ${items.join(', ')} } from '$lib/js/fitnessI18n';`);
// 2. Insert `const t = $derived(m[lang]);` at the right spot, depending
// on how `lang` enters scope.
// Pattern A: `const lang = $derived(...)` — derived from URL
// Pattern B: `let { ... lang ... } = $props()` — passed as prop (single or multi-line)
let inserted = false;
// Pattern A: derived. Allow up to two levels of nested parens inside
// $derived(...) so detectFitnessLang(page.url.pathname) matches.
const langDerivedRe =
/^([ \t]*)(const\s+lang\s*=\s*\$derived\((?:[^()]|\([^()]*\))+\)\s*;?)([ \t]*\n)/m;
if (langDerivedRe.test(out)) {
out = out.replace(langDerivedRe, (_, indent, decl, nl) => {
inserted = true;
return `${indent}${decl}${nl}${indent}const t = $derived(m[lang]);${nl}`;
});
}
// Pattern B: $props() destructure, possibly spanning multiple lines.
// Match any `let { ... } = $props()` and only insert if `lang` is in it.
if (!inserted) {
const propsRe =
/^([ \t]*)(let\s*\{[\s\S]*?\}\s*=\s*\$props\(\)\s*;?)([ \t]*\n)/m;
out = out.replace(propsRe, (full, indent, decl, nl) => {
if (!/\blang\b/.test(decl)) return full;
inserted = true;
return `${indent}${decl}${nl}${indent}const t = $derived(m[lang]);${nl}`;
});
}
if (!inserted) {
console.warn(` WARN: could not auto-insert \`const t = $derived(m[lang])\` — manual fix needed`);
}
// 3. Replace t('static_key', lang) → t.static_key
out = out.replace(
/\bt\(\s*['"]([a-z_][a-z0-9_]*)['"]\s*,\s*lang\s*\)/g,
't.$1'
);
// 4. Replace t(<expr>, lang) → t[<expr>] for any remaining call.
// Expression captured allows up to single-level nested parens, which
// covers our /** @type {FitnessKey} */ (expr) patterns.
out = out.replace(
/\bt\(((?:[^()]|\([^()]*\))+?)\s*,\s*lang\s*\)/g,
(match, expr) => {
const trimmed = expr.trim();
return `t[${trimmed}]`;
}
);
return { code: out, changed: out !== src };
}
let total = 0;
for (const root of ROOTS) {
for (const f of walk(root)) {
const orig = readFileSync(f, 'utf8');
const { code, changed } = migrate(orig);
if (!changed) continue;
if (!DRY) writeFileSync(f, code);
total++;
console.log(` ${f}`);
}
}
console.log(`\n${DRY ? '[dry] ' : ''}${total} files migrated`);
+126
View File
@@ -0,0 +1,126 @@
/**
* Migrate i18n call sites from t('key', lang) to t.key (or t[expr] for
* dynamic keys), where t = m[lang] derived once per file. Generic version
* — pass the i18n module path and the directories to scan.
*
* Usage:
* pnpm exec vite-node scripts/codemod-i18n-t-to-m.ts \
* --module=$lib/js/cospendI18n \
* --root=src/routes/'[cospendRoot=cospendRoot]' \
* --root=src/lib/components/cospend \
* [--dry]
*/
import { readFileSync, writeFileSync, readdirSync, statSync } from 'node:fs';
import { join, extname } from 'node:path';
const args = process.argv.slice(2);
const DRY = args.includes('--dry');
const modArg = args.find((a) => a.startsWith('--module='));
if (!modArg) {
console.error('missing --module=<path>');
process.exit(1);
}
const modulePath = modArg.slice('--module='.length);
const roots = args
.filter((a) => a.startsWith('--root='))
.map((a) => a.slice('--root='.length));
if (roots.length === 0) {
console.error('missing --root=<dir> (at least one)');
process.exit(1);
}
const fnFlag = args.find((a) => a.startsWith('--fn='));
const FN = fnFlag ? fnFlag.slice('--fn='.length) : 't';
const mFlag = args.find((a) => a.startsWith('--m='));
const M_NAME = mFlag ? mFlag.slice('--m='.length) : 'm';
// Match imports from any path ending in the module basename — call sites
// reach calendarI18n via wildly different relative-path depths, so we
// don't pin the full path.
function esc(s: string) {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
const IMPORT_RE = new RegExp(
`import\\s*\\{([^}]+)\\}\\s*from\\s*(['"])([^'"]*${esc(modulePath)})\\2\\s*;?`
);
function walk(dir: string, out: string[] = []): string[] {
for (const name of readdirSync(dir)) {
const p = join(dir, name);
const s = statSync(p);
if (s.isDirectory()) walk(p, out);
else if (extname(p) === '.svelte' || extname(p) === '.ts') out.push(p);
}
return out;
}
function migrate(src: string): { code: string; changed: boolean } {
const m0 = IMPORT_RE.exec(src);
if (!m0) return { code: src, changed: false };
const items = m0[1].split(',').map((s) => s.trim()).filter(Boolean);
if (!items.includes(FN)) return { code: src, changed: false };
const matchedPath = m0[3];
// 1. Rewrite import: drop FN, ensure M_NAME present. Preserve original path.
const fnIdx = items.indexOf(FN);
items.splice(fnIdx, 1);
if (!items.includes(M_NAME)) items.push(M_NAME);
let out = src.replace(IMPORT_RE, `import { ${items.join(', ')} } from '${matchedPath}';`);
// 2. Insert `const FN = $derived(M_NAME[lang]);` at the right spot.
const insertion = `const ${FN} = $derived(${M_NAME}[lang]);`;
let inserted = false;
const langDerivedRe =
/^([ \t]*)(const\s+lang\s*=\s*\$derived\((?:[^()]|\([^()]*\))+\)\s*;?)([ \t]*\n)/m;
if (langDerivedRe.test(out)) {
out = out.replace(langDerivedRe, (_, indent, decl, nl) => {
inserted = true;
return `${indent}${decl}${nl}${indent}${insertion}${nl}`;
});
}
if (!inserted) {
const propsRe =
/^([ \t]*)(let\s*\{[\s\S]*?\}\s*=\s*\$props(?:<[\s\S]*?>)?\(\)\s*;?)([ \t]*\n)/m;
out = out.replace(propsRe, (full, indent, decl, nl) => {
if (!/\blang\b/.test(decl)) return full;
inserted = true;
return `${indent}${decl}${nl}${indent}${insertion}${nl}`;
});
}
if (!inserted) {
console.warn(` WARN: could not auto-insert \`${insertion}\` — manual fix needed`);
}
// Build dynamic regex for FN(...) — escape `1962`-style suffixes.
const fnEsc = FN.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
// 3. FN('static_key', lang) → FN.static_key (snake_case OR camelCase identifier)
out = out.replace(
new RegExp(`\\b${fnEsc}\\(\\s*['"]([a-zA-Z_$][a-zA-Z0-9_$]*)['"]\\s*,\\s*lang\\s*\\)`, 'g'),
`${FN}.$1`
);
// 4. FN(<expr>, lang) → FN[<expr>]
out = out.replace(
new RegExp(`\\b${fnEsc}\\(((?:[^()]|\\([^()]*\\))+?)\\s*,\\s*lang\\s*\\)`, 'g'),
(_match, expr) => `${FN}[${expr.trim()}]`
);
return { code: out, changed: out !== src };
}
let total = 0;
for (const root of roots) {
for (const f of walk(root)) {
const orig = readFileSync(f, 'utf8');
const { code, changed } = migrate(orig);
if (!changed) continue;
if (!DRY) writeFileSync(f, code);
total++;
console.log(` ${f}`);
}
}
console.log(`\n${DRY ? '[dry] ' : ''}${total} files migrated`);
-74
View File
@@ -1,74 +0,0 @@
/**
* One-shot script: extract the translations object from fitnessI18n.ts
* into per-locale files src/lib/i18n/fitness/de.ts and en.ts.
*
* Run: pnpm exec vite-node scripts/split-fitness-i18n.ts
*/
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
const src = readFileSync('src/lib/js/fitnessI18n.ts', 'utf8');
// Slice out just the translations object body so we don't accidentally
// match function bodies or other constructs above/below.
const startMarker = 'const translations: Translations = {';
const startIdx = src.indexOf(startMarker);
if (startIdx === -1) throw new Error('translations object not found');
const endIdx = src.indexOf('\n};\n', startIdx);
if (endIdx === -1) throw new Error('translations object end not found');
const body = src.slice(startIdx + startMarker.length, endIdx);
// Match each entry. Single-line and multi-line variants both supported.
// Strings are single-quoted in the source and contain no escaped single
// quotes (the file uses unicode escapes like for apostrophes), so
// [^']* is safe.
const re =
/^\s*(\w+):\s*\{\s*\n?\s*en:\s*'([^']*)'\s*,\s*\n?\s*de:\s*'([^']*)'\s*,?\s*\n?\s*\}\s*,?/gm;
/**
* Convert a JS single-quoted string body into the actual string. The captured
* regex content is literal source text — escapes like `` are still 6 chars
* (`\`, `u`, `2`, `0`, `1`, `9`) and need decoding.
*/
function decodeJsString(raw: string): string {
const jsonReady = '"' + raw.replace(/\\'/g, "'").replace(/"/g, '\\"') + '"';
return JSON.parse(jsonReady);
}
const entries: Array<{ key: string; en: string; de: string }> = [];
let m: RegExpExecArray | null;
while ((m = re.exec(body)) !== null) {
entries.push({
key: m[1],
en: decodeJsString(m[2]),
de: decodeJsString(m[3])
});
}
console.log(`extracted ${entries.length} entries`);
mkdirSync('src/lib/i18n/fitness', { recursive: true });
// de.ts is the source of truth for the key set.
const deLines = [
'/** Generated by scripts/split-fitness-i18n.ts. */',
'/** German fitness UI strings — source of truth for the key set. */',
'',
'export const de = {'
];
for (const e of entries) deLines.push(`\t${e.key}: ${JSON.stringify(e.de)},`);
deLines.push('} as const;', '');
writeFileSync('src/lib/i18n/fitness/de.ts', deLines.join('\n'));
// en.ts uses `satisfies Record<keyof typeof de, string>` so any key missing
// from en that exists in de raises a TypeScript error.
const enLines = [
'/** Generated by scripts/split-fitness-i18n.ts. */',
"import type { de } from './de';",
'',
'export const en = {'
];
for (const e of entries) enLines.push(`\t${e.key}: ${JSON.stringify(e.en)},`);
enLines.push('} as const satisfies Record<keyof typeof de, string>;', '');
writeFileSync('src/lib/i18n/fitness/en.ts', enLines.join('\n'));
console.log('wrote src/lib/i18n/fitness/de.ts and en.ts');
+132
View File
@@ -0,0 +1,132 @@
/**
* 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(', ')}`);