refactor(i18n): merge the shared catalogue under web's, no keys moved yet - #592
Merged
Conversation
… 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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.tsdeep-mergestranslations.enfrom@textstack/sharedunderlocales/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
Object.assign(shared.en, web)would mutate the module-cached shared catalogue for every other importer in the process — the shared package's ownt(), 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.tArrayis real API and "append" is a surprising default for a translation.The collision guard earned its place on its first run
highlights.emptyis 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 readshighlights.emptynext to its ownhighlights.emptySubtitle. And this exact shape has already shipped a bug once —apps/mobile/src/lib/i18nKeys.test.ts:60records it:Resolved by renaming the shared string to
highlights.emptyTitle, pairing with theemptySubtitlealready beside it. One mobile line, one fixture line.The type
TranslationData = typeof enis gone.en.jsonis an overlay now, so the literal type could not describe the catalogue. Nothing consumed it structurally, andmissing-keys.test.ts— every literalt('…')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/termsand/en/privacyall render real copy with no key literals leaking through — and that last page gates the production deploy viasmoke.spec.ts.🤖 Generated with Claude Code
https://claude.ai/code/session_011rgEMvYYi4Egj99dVtvm6E