Skip to content

refactor(webapp): split the translations into one file per domain and locale - #359

Merged
InstaZDLL merged 4 commits into
mainfrom
refactor/webapp-split-i18n
Oct 26, 2026
Merged

InstaZDLL merged 4 commits into
mainfrom
refactor/webapp-split-i18n

Conversation

@InstaZDLL

@InstaZDLL InstaZDLL commented Oct 26, 2026 •

Copy link
Copy Markdown
Owner

Phase A of the webapp restructuring, i18n part: webapp/src/state/i18n.tsx (2,481 lines) becomes a directory with one file per key-prefix family and locale. Every key, value and comment is moved, not changed, and no export or signature changes. Every import { … } from "./i18n" / "../state/i18n" resolves unchanged to state/i18n/index.tsx; i18n.test.ts did not need a path change.

Commits: the git mv alone; a first split that kept the original key order; then the regrouping by prefix plus the duplicate-key test (see "Key order" below for why the first split was replaced).

Old → new

Old New
state/i18n.tsx lines 1–8, 1189–1190, 2390–2481 (imports, TranslationKey, TranslationValue, Locale, context, storage key, plural rules, translate, I18nProvider, useI18n, LanguagePicker) state/i18n/index.tsx
const en = { … } as const (lines 10–1187) state/i18n/en/<domain>.ts × 17, assembled by en/index.ts
const fr: Record<TranslationKey, TranslationValue> = { … } (lines 1192–2388) state/i18n/fr/<domain>.ts × 17, assembled by fr/index.ts
— state/i18n/keys.test.ts (new: no key defined twice, every domain assembled)

The only visibility change: TranslationValue is now exported from index.tsx, because the French domain files type against it (rule 1).

New tree

Each prefix lives in exactly one file. Within a file, prefixes come in the order listed, and keys keep their original relative order.

state/i18n/index.tsx      107   context, translate, plural rules, provider/hook, LanguagePicker
state/i18n/keys.test.ts    43   no key in two domain files; every domain file assembled
state/i18n/en/index.ts     38   spreads the 17 domains
state/i18n/fr/index.ts     39   same; checked against Record<TranslationKey, TranslationValue>
                    en   fr   prefixes
  nav.ts            42   45   nav, notFound
  preferences.ts    48   51   preferences, replayGain, settings, appearance
  common.ts         23   26   common, file, card, rating
  library.ts       129  133   album, songs, albums, artists, artistImage, favourites, browse, search, genres, history, random, library
  clients.ts        48   51   clients, subsonic, credential, authorize
  quality.ts        81   86   quality
  track.ts         139  147   correction, trackMenu, properties, lyrics, canvas
  login.ts          28   31   login
  playlists.ts      84   88   playlists, import, queue, shares, share, bookmarks
  player.ts         77   81   player, playing, radio, offline
  admin.ts         121  126   admin, scan, maintenance, missing
  attention.ts      53   55   attention, artistSplit
  folders.ts       101  105   folders, filed, upload
  scrobbling.ts     78   88   scrobbling
  stats.ts          49   52   stats, mixes
  smart.ts          83   86   smart
  podcasts.ts       43   46   podcasts

Before: 1 file, 2,481 lines. After: 38 files, 2,751 lines including the new 43-line test (the rest of the difference is the 34 headers, the type imports, the openers/closers and the two assembly files). Largest file: 147 lines.

Key order is not preserved, and that is safe

The keys were added feature by feature, so prefixes interleave: the 975 keys form 86 runs of a single prefix across 57 prefixes (nav.* alone appeared in 10 places). Grouping by prefix therefore changes the Object.keys order of the assembled en and fr. That order is observable nowhere. Both objects are private to state/i18n and are only ever indexed by key in translate. Nothing exports them, iterates them or serialises them. Within each file, keys keep their original relative order.

Typing is as strict as before

Each en/<domain>.ts keeps as const, so TranslationKey = keyof typeof en is still the union of the 975 string literals. Each fr/<domain>.ts is Record<keyof typeof en<Domain>, TranslationValue>, so a missing or extra French key is an error in that file. The assembled fr is still Record<TranslationKey, TranslationValue>. I broke the typing on purpose and restored it each time; tsc exited 0 after each restore:

delete "nav.podcasts" from fr/nav.ts
src/state/i18n/fr/nav.ts(5,14): error TS2741: Property '"nav.podcasts"' is missing in type …
delete "podcasts.conflict" from fr/podcasts.ts (first split, same typing)
src/state/i18n/fr/podcasts.ts(5,14): error TS2741: Property '"podcasts.conflict"' is missing in type …
add "podcasts.extra" to fr/podcasts.ts (first split, same typing)
src/state/i18n/fr/podcasts.ts(41,3): error TS2353: Object literal may only specify known properties …
drop ...podcasts from fr/index.ts (first split, same typing)
src/state/i18n/fr/index.ts(17,14): error TS2740: Type '{ … }' is missing the following properties …

A duplicate key is an error again

In the single object literal, a repeated key was a compile error (TS1117). Across spreads, a later domain silently overwrites an earlier one, and the types do not see it. keys.test.ts restores the guard. For en and for fr, it collects every key of every domain file and asserts two things:

  • no key belongs to two files;
  • the domain keys equal the assembled object's keys, so a domain file that index.ts forgets to spread fails too.

The test lists the domain files with import.meta.glob, so a new domain file is covered without editing the test. It needs no separate "French domain has exactly its English domain's keys" check: the per-domain Record typing already enforces that in both directions (TS2741 / TS2353 above).

Proof by inversion, then restored:

"podcasts.conflict" duplicated into en/smart.ts and fr/smart.ts
tsc --noEmit                         -> exit 0 (the types cannot see it)
vitest run src/state/i18n/keys.test.ts
 FAIL  en translation domains > defines each key in one domain file only
   + [ [ "podcasts.conflict", [ "./en/podcasts.ts", "./en/smart.ts" ] ] ]
 FAIL  fr translation domains > defines each key in one domain file only
   + [ [ "podcasts.conflict", [ "./fr/podcasts.ts", "./fr/smart.ts" ] ] ]
 Tests  2 failed | 2 passed (4)

an extra en/orphan.ts that en/index.ts does not spread
 ×  assembles every domain file      + "orphan.key"
 Tests  1 failed | 3 passed (4)

Proofs (rule 4)

Translations. A throwaway script (not committed) evaluates the en and fr literals of the original file and imports the new en/index.ts and fr/index.ts:

en: 975 -> 975 keys; sorted-JSON equal true; deepStrictEqual true; Object.keys order equal false
fr: 975 -> 975 keys; sorted-JSON equal true; deepStrictEqual true; Object.keys order equal false

The same keys and the same values. Only the order differs, as explained above.

Lines. A second script counts every non-blank line of the old file against the new files, minus their one-line headers. Every key, value, plural object and comment line is present exactly as often as before. Only three old lines have no verbatim counterpart, because they had to change form:

  • const en = { is now the per-domain export const <domain> = { openers;
  • const fr: Record<TranslationKey, TranslationValue> = { is now the per-domain French openers and the fr assembly object;
  • type TranslationValue = … is now export type TranslationValue = ….

vitest. bun run test: 40 files / 323 tests before; 41 / 327 after. The difference is exactly the new keys.test.ts (4 tests). Every pre-existing test is unchanged.

Playwright. bunx playwright test --list: 508 tests in 5 files before and after. Not run locally; left to CI.

Checks (in webapp/)

  • bunx biome check .: 10 warnings, the same as main (all noDescendingSpecificity in styles/refresh.css and styles/styles.css), none in the new files.
  • bun run typecheck: clean.
  • bun run test: 327 passed.
  • bun run build: builds.

The two section comments the regrouping made wrong

The original objects carried two section comments that described their neighbourhood in the single file: // RFC-024: lyrics from LRCLIB, statistics and mixes. and // Smart playlists and podcasts (RFC-025), kept together at the end. (the French object had only the first, in French). Once the keys are split by prefix, neither is true where it lands, so the last commit rewords them to say what each file holds:

  • en/track.ts, fr/track.ts: // RFC-024: lyrics from LRCLIB. The French copy is now in English, like every other comment.
  • en/stats.ts, fr/stats.ts: // RFC-024: listening statistics and mixes. This line is new.
  • en/smart.ts: // Smart playlists (RFC-025).
  • en/podcasts.ts: // Podcasts (RFC-025). This line is new.

These comment lines are the only content lines that differ from the old file; the line-count proof above was taken before this commit.

Noticed, not changed

  • Vitest 5's file-system module cache can hide a new domain file locally. That cache (node_modules/.vitest-cache, on by default) reuses the transformed keys.test.ts, including its expanded import.meta.glob. A domain file added after the test was last transformed is invisible to it until the test file changes or the cache is cleared. The orphan inversion above passed with the cache and failed with --fsModuleCache=false. CI installs fresh and keeps no node_modules cache, so CI is unaffected. Locally, bunx vitest run --fsModuleCache=false or --clearCache gives the true answer. This applies to any glob-based test in the repo.

Signed-off-by: InstaZDLL <github.105mh@8shield.net>
… locale

Signed-off-by: InstaZDLL <github.105mh@8shield.net>
@github-actions github-actions Bot added type: refactor Code refactoring scope: web Embedded web player (React) size: xl > 500 lines and removed type: refactor Code refactoring labels Oct 26, 2026
@coderabbitai

coderabbitai Bot commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review.

Recent review info
Run configuration
Commits

Reviewing files that changed from the base of the PR and between f4b3952 and d5fe3df.

Files selected for processing: 30
  • webapp/src/state/i18n.tsx
  • webapp/src/state/i18n/en/browse.ts
  • webapp/src/state/i18n/en/folders.ts
  • webapp/src/state/i18n/en/index.ts
  • webapp/src/state/i18n/en/library.ts
  • webapp/src/state/i18n/en/maintenance.ts
  • webapp/src/state/i18n/en/playing.ts
  • webapp/src/state/i18n/en/podcasts.ts
  • webapp/src/state/i18n/en/quality.ts
  • webapp/src/state/i18n/en/scrobbling.ts
  • webapp/src/state/i18n/en/settings.ts
  • webapp/src/state/i18n/en/shell.ts
  • webapp/src/state/i18n/en/smart.ts
  • webapp/src/state/i18n/en/stats.ts
  • webapp/src/state/i18n/en/track.ts
  • webapp/src/state/i18n/fr/browse.ts
  • webapp/src/state/i18n/fr/folders.ts
  • webapp/src/state/i18n/fr/index.ts
  • webapp/src/state/i18n/fr/library.ts
  • webapp/src/state/i18n/fr/maintenance.ts
  • webapp/src/state/i18n/fr/playing.ts
  • webapp/src/state/i18n/fr/podcasts.ts
  • webapp/src/state/i18n/fr/quality.ts
  • webapp/src/state/i18n/fr/scrobbling.ts
  • webapp/src/state/i18n/fr/settings.ts
  • webapp/src/state/i18n/fr/shell.ts
  • webapp/src/state/i18n/fr/smart.ts
  • webapp/src/state/i18n/fr/stats.ts
  • webapp/src/state/i18n/fr/track.ts
  • webapp/src/state/i18n/index.tsx

Included review availability: This review used your included allowance. 0 included reviews remain after this review. Your included PR review attempts over the past 7 days set your current allowance at 1 review per hour.

Walkthrough

Cette PR ajoute des catalogues de traduction anglais et français pour plusieurs domaines de l’application. Elle ajoute aussi un contexte React qui sélectionne la langue, traduit les messages et permet de choisir l’anglais ou le français.

Changes: Internationalisation web

Layer / File(s) Résumé
Catalogues anglais
webapp/src/state/i18n/en/*
Les fichiers définissent les libellés anglais pour la navigation, les réglages, la bibliothèque, la lecture, la maintenance et d’autres domaines. en/index.ts fusionne les catalogues.
Catalogues français
webapp/src/state/i18n/fr/*
Les fichiers ajoutent les traductions françaises typées selon les clés anglaises. fr/index.ts fusionne les catalogues.
Sélection et application de la locale
webapp/src/state/i18n/index.tsx
translate choisit les formes plurielles et remplace les paramètres. I18nProvider initialise et persiste la locale, synchronise l’attribut lang du document, et LanguagePicker permet de changer de langue.

Priority: Pending latest changes

Estimated code review effort: 3 (Moderate) | ~25 minutes

Change: Refactor

Sequence Diagram(s)

sequenceDiagram
  participant Navigateur
  participant I18nProvider
  participant localStorage
  participant Document
  participant LanguagePicker
  Navigateur->>I18nProvider: fournit la langue du navigateur
  I18nProvider->>localStorage: lit la locale enregistrée
  I18nProvider->>Document: définit document.documentElement.lang
  LanguagePicker->>I18nProvider: transmet la locale choisie
  I18nProvider->>localStorage: enregistre la nouvelle locale
  I18nProvider->>Document: met à jour document.documentElement.lang
Loading

Possibly related PRs

  • InstaZDLL/waveflow-server#337 : ajoute des clés de traduction aux réglages que cette PR répartit dans des catalogues par domaine.
  • InstaZDLL/waveflow-server#336 : ajoute des libellés pour les paroles, les statistiques et les mixes que cette PR place dans playing et stats.
  • InstaZDLL/waveflow-server#328 : ajoute des clés pour les fichiers manquants et la gestion des bibliothèques que cette PR place dans maintenance, folders et library.

Suggested labels: scope: web, size: xl

Merge Risk: Minimal · up to d5fe3

No specific regression is established. The translation-file split appears mergeable after normal validation.

Pre-merge checks: Passed 4 · Failed 1

Failed Failed checks: 1 warning

Check name Status Explanation Resolution
Docstring Coverage Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 4 functions across 29 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
Passed Passed checks: 4 passed
Check name Status Explanation
Title check Passed Le titre décrit clairement la modification principale : découper les traductions par domaine et par locale.
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Description check Passed La description est complète et couvre le résumé, les changements, les validations effectuées, les limites connues et les décisions techniques. Elle ne reprend pas exactement les titres du modèle, ne f…
Full details: Description check

Explanation

La description est complète et couvre le résumé, les changements, les validations effectuées, les limites connues et les décisions techniques. Elle ne reprend pas exactement les titres du modèle, ne fournit pas les cases à cocher du plan de test et ne contient pas la confirmation explicite AGPL/DCO, mais ces écarts ne bloquent pas l’évaluation.

Finishing Touches: 💡 1
📝 Generate docstrings: 💡
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added the type: refactor Code refactoring label Oct 26, 2026
…no key is defined twice

Signed-off-by: InstaZDLL <github.105mh@8shield.net>
@github-actions github-actions Bot added type: refactor Code refactoring and removed type: refactor Code refactoring labels Oct 26, 2026
…they now sit in

Signed-off-by: InstaZDLL <github.105mh@8shield.net>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

scope: web Embedded web player (React) size: xl > 500 lines type: refactor Code refactoring

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant