mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:21:10 +02:00
chore(i18n): add coverage audit + migration inventory
Translation infrastructure (@mana/shared-i18n + svelte-i18n + 35
per-module locale files with ~3500 lines across de/en/it/fr/es) is fully
wired, but 65/78 modules still hardcode German in .svelte templates
rather than calling {$_('module.key')}.
Adds:
- scripts/audit-i18n-coverage.mjs — scans lib/modules/**/*.svelte for
hardcoded German keywords (Abbrechen, Speichern, Löschen, etc.) in
files that don't import $_(). Reports per-module hit counts,
bucket (FULL/PARTIAL/NONE), and whether the locale file exists.
Supports --summary and --top N flags.
- pnpm run audit:i18n-coverage wires it into the audit:* family
(reporting only, not a CI gate — existing debt would fail
validate:all otherwise).
- docs/optimizable/i18n-migration-inventory.md — priority list,
per-module workflow, and prevention plan.
Top offenders: broadcast (26 hits), articles (24), events (23),
invoices (22), quiz (20), stretch (20), library (19), profile (17),
skilltree (15, PARTIAL), calendar (14, PARTIAL). Modules without a
locale file (broadcast/articles/events/invoices/…) need the locale
stubs scaffolded first.
Real string migration is per-site careful work (key naming, 5-language
parity, UI visual QA) and is left for per-module follow-up sessions.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
07e35d79f0
commit
eec369bd04
3 changed files with 324 additions and 0 deletions
91
docs/optimizable/i18n-migration-inventory.md
Normal file
91
docs/optimizable/i18n-migration-inventory.md
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
# i18n Migration Inventory
|
||||
|
||||
**Status as of 2026-04-22.** Run `pnpm run audit:i18n-coverage` for current numbers.
|
||||
|
||||
## The gap
|
||||
|
||||
The unified Mana web app has `@mana/shared-i18n` + `svelte-i18n`
|
||||
**fully wired** — per-module translation files live under
|
||||
`apps/mana/apps/web/src/lib/i18n/locales/{module}/{de,en,it,fr,es}.json`
|
||||
for 35 modules, with ~3500 lines of German and matching English /
|
||||
Italian / French / Spanish. The infrastructure is done.
|
||||
|
||||
What's missing is the **usage**: most module `.svelte` templates
|
||||
hardcode German strings like `"Abbrechen"`, `"+ Neues Mood"`,
|
||||
`"Keine Daten"` instead of calling `{$_('module.key')}`.
|
||||
|
||||
Scanning `lib/modules/**/*.svelte`:
|
||||
|
||||
- **FULL** (4 modules) — all files import `$_()` from svelte-i18n
|
||||
- **PARTIAL** (9 modules) — mixed usage
|
||||
- **NONE** (65 modules) — German hardcoded throughout
|
||||
|
||||
## Why not auto-migrate?
|
||||
|
||||
String migration is per-site careful work:
|
||||
- pick a key name (or reuse existing one from the locale file)
|
||||
- pick the German source text (may differ slightly from existing keys)
|
||||
- add key to all 5 language files (de/en/it/fr/es)
|
||||
- replace in template, test UI visually
|
||||
- tests covering copy may break
|
||||
|
||||
It's not a mechanical codemod. Each module is a session of its own.
|
||||
|
||||
## Priorities
|
||||
|
||||
Rank by `keyword-hits × user-impact`. Run the audit for current numbers:
|
||||
|
||||
```bash
|
||||
pnpm run audit:i18n-coverage --summary --top 20
|
||||
```
|
||||
|
||||
Top offenders (2026-04-22 snapshot):
|
||||
|
||||
| Rank | Module | Hits | Files | Locale? | Notes |
|
||||
|---|---|---|---|---|---|
|
||||
| 1 | broadcast | 26 | 10 | ✗ | Content broadcast compose + recipient UI |
|
||||
| 2 | articles | 24 | 16 | ✗ | Reader view, highlights, filter labels |
|
||||
| 3 | events | 23 | 12 | ✗ | RSVP, guest list, discovery |
|
||||
| 4 | invoices | 22 | 9 | ✗ | Business-critical: line items, payment states |
|
||||
| 5 | quiz | 20 | 3 | ✗ | Edit + play flows, question types |
|
||||
| 6 | stretch | 20 | 6 | ✗ | Session flows, reminders, routines |
|
||||
| 7 | library | 19 | 11 | ✗ | Book/media tracker: status, filtering |
|
||||
| 8 | profile | 17 | 4 | ✓ | Interview flow, context overview |
|
||||
| 9 | skilltree | 15 | 11 | ✓ | PARTIAL — modals done, ListView hardcoded |
|
||||
| 10 | calendar | 14 | 15 | ✓ | PARTIAL — EventForm done, ListView labels |
|
||||
|
||||
**Already FULL:** `body`, `todo`, `times`, and the modules whose
|
||||
keyword count is zero (either trivially small templates or consistent
|
||||
use of `$_()`).
|
||||
|
||||
## Recommended workflow per module
|
||||
|
||||
1. **If no locale file exists** — run the skilltree/moodlit layout as
|
||||
a template. Start with: `nav.*`, `common.{save,cancel,delete,confirm}`,
|
||||
`list.{empty,count,newItem}`, `create.{title,save,namePlaceholder}`,
|
||||
plus module-specific vocabulary.
|
||||
2. **Extract German strings** into `de.json` — keep them verbatim so
|
||||
existing copy doesn't regress.
|
||||
3. **Translate to `en.json`** (required for sync across language
|
||||
switch). Keep `it/fr/es.json` with English fallbacks if translator
|
||||
not available.
|
||||
4. **Replace in templates** — `import { _ } from 'svelte-i18n'` at the
|
||||
top, then `{$_('module.key')}` inline. For attributes:
|
||||
`placeholder={$_('module.namePlaceholder')}`.
|
||||
5. **Update `i18n/index.ts`** — add the module to `registerLocale()` if
|
||||
it isn't already listed (broadcast/articles/events/invoices/quiz/
|
||||
stretch/library/wishes/guides/habits/dreams/firsts/companion/
|
||||
ai-missions all need entries).
|
||||
6. **Re-run the audit** — `pnpm run audit:i18n-coverage --summary` —
|
||||
expect the module to move from NONE → FULL.
|
||||
7. **Manual browser test** — the audit is pattern-based; visual QA
|
||||
catches anything it missed.
|
||||
|
||||
## Prevention (future work)
|
||||
|
||||
The audit currently reports only — it doesn't fail CI. A future step
|
||||
could graduate it to a gate that:
|
||||
- blocks PRs that *add* hardcoded German to ListViews already migrated
|
||||
- continues to tolerate existing debt until it's cleared
|
||||
|
||||
Until then: `audit:i18n-coverage` is the contract.
|
||||
|
|
@ -32,6 +32,7 @@
|
|||
"audit:coupling": "node scripts/audit-module-coupling.mjs",
|
||||
"audit:complexity": "node scripts/audit-complexity.mjs",
|
||||
"audit:map": "node scripts/build-complexity-map.mjs",
|
||||
"audit:i18n-coverage": "node scripts/audit-i18n-coverage.mjs",
|
||||
"generate:dockerfiles": "node scripts/generate-dockerfiles.mjs",
|
||||
"setup:env": "node scripts/generate-env.mjs",
|
||||
"setup:secrets": "node scripts/setup-secrets.mjs",
|
||||
|
|
|
|||
232
scripts/audit-i18n-coverage.mjs
Normal file
232
scripts/audit-i18n-coverage.mjs
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* Audit i18n coverage across module UI files.
|
||||
*
|
||||
* Background: @mana/shared-i18n + svelte-i18n are fully wired. Per-module
|
||||
* translation files exist under `apps/mana/apps/web/src/lib/i18n/locales/
|
||||
* {module}/{de,en,it,fr,es}.json` for ~35 modules. Yet most module
|
||||
* `.svelte` templates hardcode German strings instead of calling `$_()`.
|
||||
*
|
||||
* This audit flags the gap without being a blocker: it prints a per-module
|
||||
* report of `.svelte` files that likely contain hardcoded German UI
|
||||
* strings AND don't yet import from `svelte-i18n` / `$_`. The stats guide
|
||||
* migration priorities without forcing a failing check.
|
||||
*
|
||||
* Detection heuristic: look for common German UI keywords inside Svelte
|
||||
* template text nodes (Abbrechen, Speichern, Löschen, Hinzufügen,
|
||||
* Erstellen, Bearbeiten, etc.). Not foolproof — can miss embedded
|
||||
* placeholder text and hit false positives — but good enough to prioritise.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/audit-i18n-coverage.mjs # full report
|
||||
* node scripts/audit-i18n-coverage.mjs --summary # one-line per module
|
||||
* node scripts/audit-i18n-coverage.mjs --top 10 # top N offenders only
|
||||
*/
|
||||
|
||||
import { readFileSync, readdirSync, existsSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = join(__dirname, '..');
|
||||
const MODULES_DIR = join(REPO_ROOT, 'apps/mana/apps/web/src/lib/modules');
|
||||
const LOCALES_DIR = join(REPO_ROOT, 'apps/mana/apps/web/src/lib/i18n/locales');
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const SUMMARY = args.includes('--summary');
|
||||
const TOP_IDX = args.indexOf('--top');
|
||||
const TOP_N = TOP_IDX >= 0 ? Number(args[TOP_IDX + 1] || 10) : null;
|
||||
|
||||
// Common German UI keywords that indicate hardcoded strings. Not every hit
|
||||
// is a real violation (e.g. code comments, type names) — we scan only
|
||||
// Svelte template bodies (between <script> blocks) and classname-free
|
||||
// contexts.
|
||||
const GERMAN_KEYWORDS = [
|
||||
// Actions
|
||||
'Abbrechen',
|
||||
'Speichern',
|
||||
'Löschen',
|
||||
'Bearbeiten',
|
||||
'Hinzufügen',
|
||||
'Erstellen',
|
||||
'Anlegen',
|
||||
'Zurück',
|
||||
'Weiter',
|
||||
'Senden',
|
||||
'Laden',
|
||||
'Schließen',
|
||||
'Öffnen',
|
||||
// Empty / count
|
||||
'Keine',
|
||||
'Noch keine',
|
||||
'Noch kein',
|
||||
'Alle',
|
||||
'Neue',
|
||||
'Neuer',
|
||||
'Neues',
|
||||
'Neu',
|
||||
// Confirm
|
||||
'Bist du sicher',
|
||||
'wirklich löschen',
|
||||
'wirklich entfernen',
|
||||
// Labels
|
||||
'Einstellungen',
|
||||
'Übersicht',
|
||||
'Fortschritt',
|
||||
'Beschreibung',
|
||||
'Name',
|
||||
// Status
|
||||
'Offen',
|
||||
'Erledigt',
|
||||
'Fertig',
|
||||
'In Arbeit',
|
||||
'Archiviert',
|
||||
'Pausiert',
|
||||
'Aktiv',
|
||||
];
|
||||
|
||||
const KEYWORD_RE = new RegExp(`\\b(${GERMAN_KEYWORDS.join('|')})\\b`, 'g');
|
||||
|
||||
function hasI18nImport(src) {
|
||||
return /from\s+['"]svelte-i18n['"]/.test(src) || /\$_\s*\(/.test(src);
|
||||
}
|
||||
|
||||
/** Extract template bodies — roughly "everything outside <script>" but
|
||||
* including <style> text. Good enough for keyword-counting. */
|
||||
function stripScriptBlocks(src) {
|
||||
return src.replace(/<script[\s\S]*?<\/script>/g, '');
|
||||
}
|
||||
|
||||
function countKeywords(src) {
|
||||
KEYWORD_RE.lastIndex = 0;
|
||||
const matches = src.match(KEYWORD_RE);
|
||||
return matches ? matches.length : 0;
|
||||
}
|
||||
|
||||
function listSvelteFiles(dir) {
|
||||
const out = [];
|
||||
function walk(d) {
|
||||
for (const ent of readdirSync(d, { withFileTypes: true })) {
|
||||
const p = join(d, ent.name);
|
||||
if (ent.isDirectory()) walk(p);
|
||||
else if (ent.isFile() && ent.name.endsWith('.svelte')) out.push(p);
|
||||
}
|
||||
}
|
||||
walk(dir);
|
||||
return out;
|
||||
}
|
||||
|
||||
function localeBytes(moduleName) {
|
||||
const deJson = join(LOCALES_DIR, moduleName, 'de.json');
|
||||
if (!existsSync(deJson)) return 0;
|
||||
try {
|
||||
return readFileSync(deJson, 'utf8').length;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function analyze() {
|
||||
const moduleNames = readdirSync(MODULES_DIR, { withFileTypes: true })
|
||||
.filter((e) => e.isDirectory())
|
||||
.map((e) => e.name)
|
||||
.sort();
|
||||
|
||||
const reports = [];
|
||||
|
||||
for (const mod of moduleNames) {
|
||||
const modDir = join(MODULES_DIR, mod);
|
||||
const files = listSvelteFiles(modDir);
|
||||
if (files.length === 0) continue;
|
||||
|
||||
let usesI18n = 0;
|
||||
let hardcodedFiles = 0;
|
||||
let totalKeywordHits = 0;
|
||||
const offenders = [];
|
||||
|
||||
for (const file of files) {
|
||||
const src = readFileSync(file, 'utf8');
|
||||
const hasI18n = hasI18nImport(src);
|
||||
const template = stripScriptBlocks(src);
|
||||
const hits = countKeywords(template);
|
||||
|
||||
if (hasI18n) usesI18n++;
|
||||
if (hits > 0 && !hasI18n) {
|
||||
hardcodedFiles++;
|
||||
totalKeywordHits += hits;
|
||||
offenders.push({ file: file.slice(REPO_ROOT.length + 1), hits });
|
||||
}
|
||||
}
|
||||
|
||||
const localeExists = localeBytes(mod) > 0;
|
||||
reports.push({
|
||||
module: mod,
|
||||
totalFiles: files.length,
|
||||
usesI18n,
|
||||
hardcodedFiles,
|
||||
keywordHits: totalKeywordHits,
|
||||
localeExists,
|
||||
offenders: offenders.sort((a, b) => b.hits - a.hits),
|
||||
});
|
||||
}
|
||||
|
||||
return reports;
|
||||
}
|
||||
|
||||
function bucket(r) {
|
||||
if (r.keywordHits === 0 && r.usesI18n > 0) return 'FULL';
|
||||
if (r.usesI18n > 0) return 'PARTIAL';
|
||||
return 'NONE';
|
||||
}
|
||||
|
||||
function format(reports) {
|
||||
// Prioritise by keyword hits × coverage gap.
|
||||
const ranked = [...reports]
|
||||
.filter((r) => r.keywordHits > 0)
|
||||
.sort((a, b) => b.keywordHits - a.keywordHits);
|
||||
|
||||
const summary = {
|
||||
FULL: reports.filter((r) => bucket(r) === 'FULL').length,
|
||||
PARTIAL: reports.filter((r) => bucket(r) === 'PARTIAL').length,
|
||||
NONE: reports.filter((r) => bucket(r) === 'NONE').length,
|
||||
};
|
||||
|
||||
console.log(`\n── i18n coverage audit ────────────────────────────────\n`);
|
||||
console.log(`Modules scanned: ${reports.length}`);
|
||||
console.log(` FULL ${String(summary.FULL).padStart(3)} (all .svelte files import $_())`);
|
||||
console.log(
|
||||
` PARTIAL ${String(summary.PARTIAL).padStart(3)} (some use $_(), others hardcode German)`
|
||||
);
|
||||
console.log(` NONE ${String(summary.NONE).padStart(3)} (no $_(), German hardcoded)`);
|
||||
console.log(``);
|
||||
|
||||
if (SUMMARY) {
|
||||
for (const r of ranked.slice(0, TOP_N ?? ranked.length)) {
|
||||
console.log(
|
||||
` ${bucket(r).padEnd(8)} ${String(r.keywordHits).padStart(4)} hits ` +
|
||||
`${r.usesI18n}/${r.totalFiles} files i18n ${r.module}` +
|
||||
(r.localeExists ? '' : ' [no locale file]')
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const shown = TOP_N ? ranked.slice(0, TOP_N) : ranked;
|
||||
console.log(`Top ${shown.length} offenders (hardcoded German hits):\n`);
|
||||
for (const r of shown) {
|
||||
const tag = bucket(r);
|
||||
const locale = r.localeExists ? 'locale ✓' : 'locale ✗';
|
||||
console.log(
|
||||
` [${tag}] ${r.module} — ${r.keywordHits} hits across ${r.hardcodedFiles} file(s) (${r.usesI18n}/${r.totalFiles} already use i18n, ${locale})`
|
||||
);
|
||||
for (const o of r.offenders.slice(0, 5)) {
|
||||
console.log(` ${String(o.hits).padStart(3)} ${o.file}`);
|
||||
}
|
||||
if (r.offenders.length > 5) {
|
||||
console.log(` … +${r.offenders.length - 5} more file(s)`);
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
}
|
||||
|
||||
format(analyze());
|
||||
Loading…
Add table
Add a link
Reference in a new issue