refactor(i18n): split fitness translations into per-locale files
The fitness UI translation table previously lived as one combined
object in fitnessI18n.ts where every entry held both languages. That
hides drift (an English string can silently disappear without TypeScript
noticing) and makes adding strings a multi-edit dance.
Split into src/lib/i18n/fitness/{de,en}.ts. de.ts is the source of
truth for the key set; en.ts uses `as const satisfies
Record<keyof typeof de, string>` so any missing English translation is
a build-time error. fitnessI18n.ts now re-exports both as a typed
table m and adds FitnessLang/FitnessKey types — the existing
t/fitnessSlugs/fitnessLabels API stays so call sites don't churn.
The strict typing immediately surfaced one real bug: t('initializing_gps')
was being called from the active workout page but the key never existed
in the dictionary, so it had been rendering the literal string
'initializing_gps' through the fallback. Added the missing key in both
locales.
Tightened BodyPartCard.labelKey and the body-parts Step JSDoc to
FitnessKey instead of plain string so card data drift catches drift at
the data site, not the call site. Two dynamic-key sites (partKeyMap
fallbacks for unmapped measurement keys) are cast pragmatically.
The 360-entry split was done by a one-shot extraction script
(scripts/split-fitness-i18n.ts) — kept for re-use against
cospendI18n.ts and calendarI18n.ts in follow-up commits.
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* 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');
|
||||
Reference in New Issue
Block a user