Skip to content

refactor(i18n): merge the shared catalogue under web's, no keys moved yet - #592

Merged
mrviduus merged 1 commit into
mainfrom
chore/locale-merge-machinery
Sep 9, 2026
Merged

refactor(i18n): merge the shared catalogue under web's, no keys moved yet#592
mrviduus merged 1 commit into
mainfrom
chore/locale-merge-machinery

Conversation

@mrviduus

@mrviduus mrviduus commented Sep 9, 2026

Copy link
Copy Markdown
Owner

Slice 2 of five. The machinery, with web's file still a full superset — so web wins at every leaf and the golden fixture's values are unchanged. This isolates "does the merge work" from "did we move the right keys": functionally the riskiest PR of the set, content-wise the safest.

apps/web/src/locales/catalog.ts deep-merges translations.en from @textstack/shared under locales/en.json, once at module load. No build config changed — the alias already exists and web already imports from that package.

Three properties that are load-bearing, not tidy

  • The merge builds a new tree. Object.assign(shared.en, web) would mutate the module-cached shared catalogue for every other importer in the process — the shared package's own t(), and under vitest every other test file in the worker. That is the easiest way to write a merge that passes its own tests and corrupts somebody else's. Asserted both as a unit and against the real module graph.
  • A string-vs-object collision throws rather than picking. There is no correct answer: the result would depend on read order and the loser's subtree would vanish silently.
  • Arrays replace, never concatenate. None exist today, but tArray is real API and "append" is a surprising default for a translation.

The collision guard earned its place on its first run

highlights.empty is a string in shared and an object in web. A leaf-by-leaf comparison cannot see this — a key that is a leaf in one file and a branch in the other never appears in both leaf sets, which is why my earlier count of divergences missed it.

Both are live: web reads highlights.empty.title/.subtitle/.cta; mobile reads highlights.empty next to its own highlights.emptySubtitle. And this exact shape has already shipped a bug onceapps/mobile/src/lib/i18nKeys.test.ts:60 records it:

Web shipped exactly that — t('highlights.empty') where the locale held { title, … }

Resolved by renaming the shared string to highlights.emptyTitle, pairing with the emptySubtitle already beside it. One mobile line, one fixture line.

The type

TranslationData = typeof en is gone. en.json is an overlay now, so the literal type could not describe the catalogue. Nothing consumed it structurally, and missing-keys.test.ts — every literal t('…') checked against the real catalogue — was always the stronger guarantee. Traded deliberately.

Verification

The golden fixture gains 123 keys and changes zero values: web can now resolve what it inherits from shared, and pinning that is the point.

Browser-checked, because types do not prove a screen still reads right: home, /en/books, /en/highlights, /en/terms and /en/privacy all render real copy with no key literals leaking through — and that last page gates the production deploy via smoke.spec.ts.

🤖 Generated with Claude Code

https://claude.ai/code/session_011rgEMvYYi4Egj99dVtvm6E

… yet

Slice 2 of five. The machinery, with web's file still a full superset — so web
wins at every leaf and the golden fixture's values are unchanged. This isolates
"does the merge work" from "did we move the right keys": functionally the riskiest
PR of the set, and content-wise the safest.

`apps/web/src/locales/catalog.ts` deep-merges `translations.en` from
@textstack/shared under `locales/en.json`, once at module load. No build config
changed — the alias already exists and web already imports from that package.

Three properties are load-bearing rather than tidy:

- **The merge builds a new tree.** `Object.assign(shared.en, web)` would mutate
  the module-cached shared catalogue for every other importer in the process —
  the shared package's own `t()`, and under vitest every other test file in the
  worker. That is the easiest way to write a merge that passes its own tests and
  corrupts somebody else's. Asserted both as a unit and against the real graph.
- **A string-vs-object collision throws** instead of picking. There is no correct
  answer: the result would depend on read order and the loser's subtree would
  vanish silently.
- **Arrays replace, never concatenate.** None exist today, but `tArray` is real
  API and "append" is a surprising default for a translation.

That collision guard earned its place on its first run. `highlights.empty` is a
STRING in shared and an OBJECT in web — a shape divergence a leaf-by-leaf
comparison cannot see, because a key that is a leaf in one file and a branch in
the other never appears in both leaf sets. Both are live: web reads
`highlights.empty.title/.subtitle/.cta`, mobile reads `highlights.empty` next to
its own `highlights.emptySubtitle`. And this exact shape has already shipped a bug
once — `apps/mobile/src/lib/i18nKeys.test.ts:60` records it: "Web shipped exactly
that — t('highlights.empty') where the locale held { title, … }". Resolved by
renaming the shared string to `highlights.emptyTitle`, which pairs with the
`emptySubtitle` already beside it. One mobile line, one fixture line.

`TranslationData = typeof en` is gone. `en.json` is an overlay now, not the whole
catalogue, so the literal type could not describe it. Nothing consumed it
structurally, and `missing-keys.test.ts` — every literal `t('…')` checked against
the real catalogue — was always the stronger guarantee. Traded deliberately.

The golden fixture gains 123 keys and changes **zero values**: web can now resolve
what it inherits from shared, and pinning that is the point. `missing-keys` and
the web golden both read the merged catalogue rather than the file.

Browser-checked, because types do not prove a screen still reads right: home,
/en/books, /en/highlights, /en/terms and /en/privacy all render real copy with no
key literals leaking through, and the privacy page still names its processors —
that page gates the production deploy via smoke.spec.ts.

788 web + 385 mobile tests green; both typechecks clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011rgEMvYYi4Egj99dVtvm6E
@mrviduus
mrviduus merged commit dbe5440 into main Sep 9, 2026
10 checks passed
@mrviduus
mrviduus deleted the chore/locale-merge-machinery branch September 9, 2026 03:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant