Skip to content

refactor(i18n): delete 100 orphaned strings, and a detector so they cannot return - #595

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

refactor(i18n): delete 100 orphaned strings, and a detector so they cannot return#595
mrviduus merged 1 commit into
mainfrom
chore/locale-orphans

Conversation

@mrviduus

@mrviduus mrviduus commented Sep 9, 2026

Copy link
Copy Markdown
Owner

Slice 5 of five. Nothing had ever checked whether a key was still used, and 100 of 656 were not — a whole review namespace, most of vocabulary, onboarding copy for a flow that has since changed. They cost bundle size on a phone and they read as intent: the next person opening the file could not tell which strings the apps actually show.

I got this wrong first, and the tests caught it

The detector originally looked at the mobile app alone and reported 278 orphans. Deleting those removed 244 keys the website renders, and missing-keys.test.ts failed instantly with Footer.tsx → footer.privacy.

The reason is slice 3: web no longer keeps its own copy of a shared string, so a key used only by the website now lives only in shared. "Unused by mobile" and "unused" stopped being the same question the moment the duplicates went away. Rewritten to check both consumers, 278 became 100.

That is also why the test lives in packages/shared/src/i18n/unusedKeys.test.ts and not in either app: the catalogue has two consumers, and a test that can only see one of them is the bug I just made.

The detector is deliberately narrow

A wrong answer here deletes a working screen. An earlier general version called privacy.* dead — 49 keys behind a Google Play store-listing URL — because they are reached through a shared list of key names rather than a literal t('…').

A key counts as referenced if the exact dotted path appears quoted anywhere in either app, or it matches one of the dynamic templates. DYNAMIC is the complete set of t(`…${…}`) shapes in both apps, collected by grepping for them rather than guessed. Adding a template to an app without listing it here makes this test call live keys dead — loudly, which is the point.

The allowlist is asserted by set equality and is empty today. Equality also fails when an allowlisted key starts being used, so the list cannot quietly rot.

Verification

Mutation-verified: planting common.neverUsedAnywhere fails with expected [ 'common.neverUsedAnywhere' ] to deeply equal [].

Both golden fixtures: 100 keys removed, zero values changed. Browser-checked home, /en/books, /en/vocabulary, /en/stats and /en/privacy — real headings, no key literals leaking through.

Where the five slices land

before after
apps/web/src/locales/en.json 1193 keys, 72 KB 668, 40 KB
packages/shared/src/i18n/en.json 670 keys, 42 KB 556, 33 KB
identical copies across both files 523 0
unexplained divergences 24 0 — eight deliberate, each with a reason
dead keys never checked 114 removed, a detector keeps them out

🤖 Generated with Claude Code

https://claude.ai/code/session_011rgEMvYYi4Egj99dVtvm6E

…annot return

Slice 5 of five. Nothing had ever checked whether a key was still used, and 100 of
656 were not — a whole `review` namespace, most of `vocabulary`, `onboarding` copy
for a flow that has since changed. They cost bundle size on a phone and they read
as intent: the next person opening the file could not tell which strings the apps
actually show.

**I got this wrong first and the tests caught it.** The detector originally looked
at the mobile app alone and reported 278 orphans. Deleting those removed 244 keys
the WEBSITE renders, and `missing-keys.test.ts` failed instantly with
`Footer.tsx → footer.privacy`. The reason is slice 3: web no longer keeps its own
copy of a shared string, so a key used only by the website now lives only in
shared. "Unused by mobile" and "unused" stopped being the same question the moment
the duplicates went away. Rewritten to check both consumers; 278 became 100.

That is also why the test lives in `packages/shared/src/i18n/unusedKeys.test.ts`
and not in either app: the catalogue has two consumers and a test that can only see
one of them is the bug I just made.

The detector is deliberately narrow, because a wrong answer here deletes a working
screen. An earlier general version called `privacy.*` dead — 49 keys behind a
Google Play store-listing URL — because they are reached through a shared list of
key names rather than a literal `t('…')`. A key counts as referenced if the exact
dotted path appears quoted anywhere in either app, or matches one of the dynamic
templates; DYNAMIC is the complete set of `` t(`…${…}`) `` shapes in both apps,
collected by grepping for them rather than guessed. Adding a template to an app
without listing it here makes this test call live keys dead — loudly, which is the
point.

The allowlist is asserted by set EQUALITY and is empty today. Equality also fails
when an allowlisted key starts being used, so the list cannot quietly rot.

Mutation-verified: planting `common.neverUsedAnywhere` fails with
`expected [ 'common.neverUsedAnywhere' ] to deeply equal []`.

Both golden fixtures: 100 keys removed, **zero values changed**. Browser-checked
home, /en/books, /en/vocabulary, /en/stats and /en/privacy — real headings, no key
literals leaking through.

786 web + 451 shared + 385 mobile tests green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011rgEMvYYi4Egj99dVtvm6E
@mrviduus
mrviduus merged commit d8d6e86 into main Sep 9, 2026
10 checks passed
@mrviduus
mrviduus deleted the chore/locale-orphans branch September 9, 2026 04:23
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