From cb6920d759e1a607dce4bfb56d19778bc26a85ba Mon Sep 17 00:00:00 2001 From: Vasyl Vdovychenko Date: Tue, 8 Sep 2026 18:53:35 -0400 Subject: [PATCH] refactor(i18n): merge the shared catalogue under web's, no keys moved yet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_011rgEMvYYi4Egj99dVtvm6E --- apps/mobile/app/highlights/index.tsx | 2 +- .../__fixtures__/shared-catalog.golden.json | 2 +- apps/web/src/hooks/useTranslation.ts | 12 +- .../__fixtures__/web-catalog.golden.json | 123 ++++++++++++++++++ apps/web/src/locales/__tests__/golden.test.ts | 16 ++- .../locales/__tests__/mergeCatalog.test.ts | 72 ++++++++++ .../locales/__tests__/missing-keys.test.ts | 2 +- apps/web/src/locales/catalog.ts | 67 ++++++++++ packages/shared/src/i18n/en.json | 2 +- 9 files changed, 283 insertions(+), 15 deletions(-) create mode 100644 apps/web/src/locales/__tests__/mergeCatalog.test.ts create mode 100644 apps/web/src/locales/catalog.ts diff --git a/apps/mobile/app/highlights/index.tsx b/apps/mobile/app/highlights/index.tsx index 537be8af9..863bb6cea 100644 --- a/apps/mobile/app/highlights/index.tsx +++ b/apps/mobile/app/highlights/index.tsx @@ -302,7 +302,7 @@ export default function HighlightsScreen() { onButtonPress={() => { setLoading(true); setAttempt(a => a + 1) }} /> ) : highlights.length === 0 ? ( - + ) : ( = { en } +// `typeof en` used to stand in for the catalogue's shape. It stopped being able to: +// `en.json` is now an overlay, not the whole thing, and the shared half arrives typed +// as a generic node. The literal type is given up on purpose — nothing consumed it +// structurally (`t(key: string)` and `getNestedValue(obj: unknown)` never did), and +// `missing-keys.test.ts`, which checks every literal `t('…')` against the real +// catalogue, was always the stronger guarantee. +const translations: Record = { en: catalog } function getNestedValue(obj: unknown, path: string): unknown { const keys = path.split('.') diff --git a/apps/web/src/locales/__tests__/__fixtures__/web-catalog.golden.json b/apps/web/src/locales/__tests__/__fixtures__/web-catalog.golden.json index 17624692a..cc88c885d 100644 --- a/apps/web/src/locales/__tests__/__fixtures__/web-catalog.golden.json +++ b/apps/web/src/locales/__tests__/__fixtures__/web-catalog.golden.json @@ -21,6 +21,15 @@ "about.techStackLabel": "Stack", "about.techStackValue": "ASP.NET Core 10 · PostgreSQL 16 · React 19 · React Native (Expo) · Ollama (Gemma 4) · OpenAI gpt-4.1-nano · Docker · Cloudflare Tunnel", "about.title": "About", + "addMenu.ariaLabel": "Add content menu", + "addMenu.browseAll": "Browse all books", + "addMenu.browserExtension": "Browser extension", + "addMenu.comingSoon": "Coming in a future update", + "addMenu.emailBook": "Email a book", + "addMenu.mobileApps": "Mobile apps", + "addMenu.pasteUrl": "Paste URL", + "addMenu.title": "Add to library", + "addMenu.uploadFile": "Upload file", "androidBeta.badgeAction": "Join the beta", "androidBeta.badgeLabel": "Android app", "androidBeta.badgeNote": "In closed testing — open to anyone with an Android phone.", @@ -115,6 +124,7 @@ "common.loading": "Loading...", "common.noAuthorsYet": "No authors available yet.", "common.noBooksYet": "No books available yet.", + "common.retry": "Try again", "common.save": "Save", "common.saving": "Saving…", "connectExtension.approve": "Approve", @@ -227,6 +237,11 @@ "dmca.seoTitle": "DMCA Takedown Policy - TextStack Reader", "dmca.title": "DMCA Takedown Policy", "dmca.updated": "Last updated: April 2026", + "firstRun.startReading.a11y": "Start reading Down the Rabbit-Hole, the first chapter of Alice's Adventures in Wonderland", + "firstRun.startReading.body": "Long-press any word while you read and it's translated where it stands. Save it, and it comes back for review later.", + "firstRun.startReading.cta": "Start reading", + "firstRun.startReading.meta": "“Down the Rabbit-Hole” · about 10 minutes", + "firstRun.startReading.title": "Learn a language by reading real books", "footer.authors": "Authors", "footer.contact": "Contact Us", "footer.deleteAccount": "Delete Account", @@ -235,6 +250,13 @@ "footer.privacy": "Privacy Policy", "footer.sitemap": "Sitemap", "footer.terms": "Terms of Service", + "guest.banner": "A free account keeps your books, highlights and vocabulary if you lose this phone — and lets you upload your own.", + "guest.createAccount": "Create free account", + "guest.signOut": "Sign out", + "guest.signOutCancel": "Keep reading", + "guest.signOutConfirm": "Sign out and lose it", + "guest.signOutMessage": "You're reading as a guest, so this phone is the only key to your account — there's no email or password to get back in with. Sign out and your saved books, highlights, vocabulary and reading progress are gone for good, and we can't recover them for you either.", + "guest.signOutTitle": "Sign out and lose everything?", "guest.uploadBook": "Upload your book", "guest.uploading": "Uploading...", "highlights.all": "All", @@ -243,6 +265,8 @@ "highlights.empty.subtitle": "Select text in any book to save a highlight.", "highlights.empty.title": "Your highlights will appear here", "highlights.emptyFiltered": "No highlights yet. Select text while reading to highlight it.", + "highlights.emptySubtitle": "Select text while reading to highlight it", + "highlights.emptyTitle": "No highlights yet", "highlights.filterByColor": "Filter by color", "highlights.flipCard": "Show details", "highlights.highlightCount": "{count} highlights", @@ -322,6 +346,7 @@ "home.hero.searchPlaceholder": "Search by title, author, or genre...", "home.hero.seoDescription": "Finish the tech book, paper, or classic you keep quitting. Contextual word explanations tied to the book's domain. Capped weekly SRS, offline reading. EPUB, PDF.", "home.hero.seoTitle": "TextStack — A reader that helps you finish dense books", + "home.hero.subtitle": "Read classic literature. Build vocabulary. Track your progress.", "home.hero.subtitleAfter": "— stay in flow, finish the book.", "home.hero.subtitleBefore": "Tap any word for a contextual explanation in", "home.hero.title": "Finish the book you keep quitting.", @@ -365,11 +390,13 @@ "librarian.ask": "Ask", "librarian.empty.subtitle": "Try rephrasing your request — add a theme, a comparable book, or a length.", "librarian.empty.title": "Couldn't find a good match", + "librarian.entry.hint": "Tell the librarian what you want to read", "librarian.error.retry": "Try again", "librarian.error.subtitle": "Something went wrong. Please try again.", "librarian.error.title": "Couldn't reach the librarian", "librarian.inputLabel": "What do you want to read?", "librarian.openBook": "Open {{title}}", + "librarian.pages": "pages", "librarian.pagesCount": "{{count}} pages", "librarian.placeholder": "Describe what you want to read — e.g. 'books like 1984 about surveillance, under 300 pages'", "librarian.reasoningLabel": "Here's what I found and why", @@ -478,12 +505,24 @@ "library.filter.finished": "Finished", "library.filter.notStarted": "Not started", "library.filter.reading": "Reading", + "library.firstBook.browse": "Or browse free classics", + "library.firstBook.copy": "Add a book you already want to finish. Tap any word while you read to translate it and save it to your vocabulary.", + "library.firstBook.guestBrowse": "Browse free books", + "library.firstBook.guestCopy": "Start with a free classic. Tap any word while you read to translate it and save it to your vocabulary.", + "library.firstBook.guestUpload": "Or upload your own book", + "library.firstBook.title": "Your reader is ready", + "library.firstBook.upload": "Upload a book", "library.insights.lead": "Conclusions your assistant wrote back into this book. Read these instead of re-reading the book.", "library.insights.title": "What you've worked out", "library.insights.wholeBook": "This book", "library.lastRead": "Last read", + "library.loadFailed.body": "Something went wrong on our side. Your books are safe.", + "library.loadFailed.title": "Couldn't load your library", "library.loading": "Loading...", "library.noUploads": "No uploaded books yet.", + "library.offline.body": "Your library will be here as soon as you reconnect.", + "library.offline.partial": "You're offline — showing downloaded books only.", + "library.offline.title": "You're offline", "library.palette.actions.signIn": "Sign in", "library.palette.actions.signOut": "Sign out", "library.palette.actions.toggleTheme": "Toggle theme", @@ -520,6 +559,10 @@ "library.readLater.tabBooks": "Books", "library.readLater.unread": "Unread", "library.readingProgress": "Reading Progress", + "library.resume.a11yResume": "Continue reading {title}, {percent} percent complete", + "library.resume.continue": "Continue", + "library.resume.percentComplete": "{percent}% complete", + "library.resume.start": "Start reading", "library.saved": "Saved", "library.search.clear": "Clear search", "library.search.contentToggle": "Search inside books", @@ -585,6 +628,8 @@ "library.status.finished": "Finished", "library.status.notStarted": "Not started", "library.status.reading": "Reading", + "library.storage.label": "Upload space", + "library.storage.nearlyFull": "Almost full — delete a book to free up space", "library.suggestedTags.add": "Add selected", "library.suggestedTags.dismiss": "Dismiss", "library.suggestedTags.empty": "No suggestions", @@ -604,6 +649,15 @@ "library.title": "My Library", "library.uploadHint": "Click the + button to upload EPUB or PDF files.", "library.uploads": "Uploads", + "library.view.done": "Done", + "library.view.grid": "Grid", + "library.view.layout": "Layout", + "library.view.list": "List", + "library.view.open": "View options", + "library.view.openFiltered": "View options — a filter is active", + "library.view.sort": "Sort by", + "library.view.source": "Show", + "library.view.title": "View", "mcp.copied": "Copied", "mcp.copy": "Copy", "mcp.intro": "Ask your reading library questions, look up highlights, save passages, and get explanations — straight from Claude, Cursor, or ChatGPT via the Model Context Protocol.", @@ -666,10 +720,26 @@ "nav.genres": "Genres", "nav.highlights": "Highlights", "nav.library": "Library", + "nav.profile": "Profile", + "nav.read": "Read", "nav.search": "Search", + "nav.stats": "Stats", "nav.vocabulary": "Vocabulary", + "onboarding.chooseLanguage": "Choose your language", + "onboarding.chooseLanguageSubtitle": "You can change this later in settings", "onboarding.ghostHintLabel": "Tap any word", + "onboarding.gotIt": "Got it", "onboarding.hintLabel": "Try tapping a word", + "onboarding.learnByReading": "Learn by reading", + "onboarding.nativeLanguageSubtitle": "We'll translate into it while you read. You can change it any time in Profile.", + "onboarding.nativeLanguageTitle": "What language do you know best?", + "onboarding.next": "Next", + "onboarding.ready": "You're ready!", + "onboarding.readySubtitle": "Saved words appear in your vocabulary\nfor spaced repetition review", + "onboarding.save": "Save", + "onboarding.saveWords": "Save words to build your vocabulary", + "onboarding.skip": "Skip", + "onboarding.tapToTranslate": "Tap any word to see its translation", "palette.empty": "Nothing matches that.", "palette.groups.actions": "Actions", "palette.groups.books": "Books", @@ -745,6 +815,18 @@ "privacy.transfersBody": "Several of the providers above — OpenAI, Microsoft, Google, Apple, Resend, Sentry and Cloudflare — process data in the United States and elsewhere. Using the features that depend on them transfers your data there.", "privacy.transfersHeading": "International transfers", "privacy.updated": "Last updated: 20 August 2026", + "profile.vocabReminder.afternoon": "Afternoon", + "profile.vocabReminder.evening": "Evening", + "profile.vocabReminder.label": "Daily review reminder", + "profile.vocabReminder.morning": "Morning", + "profile.vocabReminder.notificationBody": "Keep your streak going — your vocabulary is waiting.", + "profile.vocabReminder.notificationTitle": "Time to review", + "profile.vocabReminder.offSubtitle": "Off", + "profile.vocabReminder.permissionDeniedBody": "Enable notifications in Settings to receive review reminders.", + "profile.vocabReminder.permissionDeniedTitle": "Notifications off", + "profile.vocabReminder.unavailableBody": "Reminders aren't available in this build. Install the latest app version to enable them.", + "profile.vocabReminder.unavailableTitle": "Not available", + "reader.ask.askAboutThis": "Ask about this", "reader.ask.citation": "ch.{{ch}}", "reader.ask.citationFallback": "source", "reader.ask.citationPage": "p. {{page}}", @@ -752,13 +834,19 @@ "reader.ask.clearConfirm": "Clear this chat? This can't be undone.", "reader.ask.detachQuote": "Remove quoted passage", "reader.ask.empty": "Ask a question about the book — answers come only from chapters you've read.", + "reader.ask.error": "Could not get an answer", "reader.ask.indexFailed": "Preparation failed.", "reader.ask.indexRetry": "Retry", + "reader.ask.indexTimeout": "Indexing is taking too long. Try again.", + "reader.ask.indexing": "Indexing this book…", + "reader.ask.insufficient": "Not enough in the book to answer that.", "reader.ask.loadingHistory": "Loading chat…", + "reader.ask.notConfigured": "Ask is not available right now.", "reader.ask.placeholder": "Ask a question about what you've read…", "reader.ask.prepareCopy": "One-time setup so you can ask about this book.", "reader.ask.prepareCta": "Prepare this book for questions", "reader.ask.preparing": "Preparing this book… {{done}}/{{total}}", + "reader.ask.retry": "Retry", "reader.ask.send": "Ask", "reader.ask.signIn": "Sign in to ask questions about this book.", "reader.ask.signInCta": "Sign in", @@ -771,6 +859,9 @@ "reader.ask.startersTitle": "Try asking", "reader.ask.thinking": "Thinking…", "reader.ask.title": "Ask this book", + "reader.coachmarkDismiss": "Got it", + "reader.coachmarkSubtitle": "Press and hold a word to see its translation, then tap Save to add it to your vocabulary. Tap the page to show or hide the toolbars.", + "reader.coachmarkTitle": "Press and hold a word", "reader.explanationPopup.close": "Close", "reader.explanationPopup.copy": "Copy explanation", "reader.explanationPopup.loading": "Thinking…", @@ -781,6 +872,7 @@ "reader.image.reset": "Reset zoom", "reader.image.zoomIn": "Zoom in", "reader.image.zoomOut": "Zoom out", + "reader.later": "Later", "reader.noteEditor.cancel": "Cancel", "reader.noteEditor.close": "Close", "reader.noteEditor.deleteHighlight": "Delete highlight", @@ -800,10 +892,15 @@ "reader.originalLayout.sessionExpired": "Your session expired. Reload to keep reading the original pages.", "reader.originalLayout.zoomIn": "Zoom in", "reader.originalLayout.zoomOut": "Zoom out", + "reader.ownBookAsk.body": "Long-press, translate, save, review — it all works the same on an EPUB or PDF of your own. Those are the books you'll actually finish.", + "reader.ownBookAsk.cta": "Add my own book", + "reader.ownBookAsk.dismiss": "Not now", + "reader.ownBookAsk.title": "Now do that with your own book", "reader.rareWordNotice.body": "We don't add rare words to your review queue automatically, so your SRS stays focused on the vocabulary you'll actually encounter.", "reader.rareWordNotice.cta": "Add to SRS anyway", "reader.rareWordNotice.ctaBusy": "Adding…", "reader.rareWordNotice.title": "Rare word — saved to reference", + "reader.reviewNow": "Review Now", "reader.selectionToolbar.askAboutThis": "Ask about this", "reader.selectionToolbar.copy": "Copy selected text", "reader.selectionToolbar.explain": "Explain in context", @@ -820,11 +917,21 @@ "reader.studyBuddy.steps": "Steps", "reader.studyBuddy.thinking": "Investigating…", "reader.studyBuddy.title": "Help me understand this", + "reader.timeLeft.hours": "{hours} hr left", + "reader.timeLeft.hoursMinutes": "{hours} hr {minutes} min left", + "reader.timeLeft.inBook": "left in book", + "reader.timeLeft.inChapter": "left in chapter", + "reader.timeLeft.minutes": "{minutes} min left", + "reader.timeLeft.under": "Less than a minute left", + "reader.toastTapToReview": "Tap to review", + "reader.toastWordAdded": "Added to vocabulary", + "reader.toastWordAddedCount": "Added · {count} this session", "reader.toc.highlightsEmpty": "No highlights yet. Select text while reading to highlight it.", "reader.toc.highlightsTab": "Highlights", "reader.vocab.addAnywayFailed": "Couldn't add word — please try again", "reader.vocab.addedToSrs": "Added to your vocabulary", "reader.vocab.queuedForTomorrow": "Daily cap reached — queued for tomorrow", + "reader.vocab.saveNeedsAccount": "Saving words needs an account — this one wasn't kept. Sign in from Profile.", "reader.vocab.savedToReference": "Saved to reference (rare word)", "reader.vocab.tapAgainToStudy": "Tap again to study ({{n}} left)", "reader.wordPopup.allLanguages": "All languages", @@ -846,6 +953,11 @@ "reader.wordPopup.searchLanguage": "Search language...", "reader.wordPopup.translateTo": "Translate to...", "reader.wordPopup.translatingTo": "Translating to", + "reader.wordsSaved": "{count} word(s) saved", + "review.dayStreak": "{count} day streak", + "review.excellent": "Excellent!", + "review.greatWork": "Great work!", + "review.keepPracticing": "Keep practicing!", "search.chapter": "Chapter", "search.clearAll": "Clear all", "search.enterQuery": "Enter a search query", @@ -855,6 +967,9 @@ "search.moreChapters": "+{count} more chapters", "search.next": "Next →", "search.noResults": "No results found", + "search.offline.body": "Search needs a connection. Your downloaded books are in Library.", + "search.offline.catalog": "You're offline — the catalog needs a connection. Your downloaded books are in Library.", + "search.offline.title": "You're offline", "search.page": "Page {page} of {total}", "search.previous": "← Previous", "search.readOnline": "Read {title} online", @@ -900,6 +1015,8 @@ "stats.empty.cta": "Open a book", "stats.empty.subtitle": "Start reading to track streaks, time, and achievements.", "stats.empty.title": "Your reading stats will appear here", + "stats.emptySubtitle": "Start reading a book to track your progress", + "stats.emptyTitle": "No reading stats yet", "stats.fast": "Fast", "stats.genres": "Genres", "stats.goalMet": "Goal met!", @@ -909,6 +1026,7 @@ "stats.long": "Long", "stats.longestStreak": "Longest Streak", "stats.medium": "Medium", + "stats.noBookStats": "No book stats yet", "stats.noData": "No data yet", "stats.overview": "Overview", "stats.pace": "Pace", @@ -1090,6 +1208,7 @@ "vocabulary.lookups.taps": "{n} taps", "vocabulary.mastered": "Mastered", "vocabulary.noReviewDue": "No words due for review!", + "vocabulary.offline.body": "Your saved words will be here as soon as you reconnect.", "vocabulary.pending.dailyStatus": "Today: {used}/{cap} new words in SRS", "vocabulary.pending.dismiss": "Dismiss", "vocabulary.pending.dismissFailed": "Could not remove this word. Try again.", @@ -1151,6 +1270,9 @@ "vocabulary.saveWord": "Save word", "vocabulary.settings.autoRetire": "Auto-retire mastered words", "vocabulary.settings.autoRetireHint": "Stop reviewing words you've answered correctly 3× at long intervals.", + "vocabulary.settings.autoSpeak": "Say the word on a card", + "vocabulary.settings.autoSpeakHint": "Speaks each review card as it appears, without waiting for a tap.", + "vocabulary.settings.dailyCapRange": "Daily new words must be 5–100.", "vocabulary.settings.dailyNewCap": "Daily new words", "vocabulary.settings.dailyNewCapHint": "How many new words enter SRS per day (5–100). Extras go to Pending.", "vocabulary.settings.frequencyFilter": "Rare-word filter", @@ -1161,6 +1283,7 @@ "vocabulary.settings.title": "Vocabulary settings", "vocabulary.settings.weeklyBudget": "Weekly review budget", "vocabulary.settings.weeklyBudgetHint": "Max reviews per week (10–500). Prevents the \"847 due\" spiral.", + "vocabulary.settings.weeklyBudgetRange": "Weekly budget must be 10–500.", "vocabulary.signInPrompt": "Sign in to build your personal vocabulary list.", "vocabulary.sort.alphabetical": "A-Z", "vocabulary.sort.due": "Due date", diff --git a/apps/web/src/locales/__tests__/golden.test.ts b/apps/web/src/locales/__tests__/golden.test.ts index 306da069e..9ae47866f 100644 --- a/apps/web/src/locales/__tests__/golden.test.ts +++ b/apps/web/src/locales/__tests__/golden.test.ts @@ -1,10 +1,10 @@ import { describe, it, expect } from 'vitest' -import { readFileSync } from 'node:fs' -import { resolve } from 'node:path' +import { catalog } from '../catalog' import golden from './__fixtures__/web-catalog.golden.json' /** - * Every string the web app can render, pinned by exact value. + * Every string the web app can render, pinned by exact value — the MERGED catalogue, + * so it covers the keys web inherits from shared as well as its own. * * This exists to make a refactor reviewable. The locale files are about to stop * being two copies and become one source plus an overlay, and the diff of that @@ -19,8 +19,6 @@ import golden from './__fixtures__/web-catalog.golden.json' * `toMatchFileSnapshot` — a snapshot is regenerated with one `vitest -u`, and the * entire point is that changing a shipped string should cost a hand edit. */ -const CATALOG = resolve(__dirname, '../en.json') - type Node = { [k: string]: string | string[] | Node } function flatten(node: Node, prefix = '', out: Record = {}) { @@ -32,7 +30,10 @@ function flatten(node: Node, prefix = '', out: Record = {}) { return out } -const actual = flatten(JSON.parse(readFileSync(CATALOG, 'utf8'))) +// The MERGED catalogue — what the app actually resolves — flattened by this test +// rather than by re-implementing the merge. A test that reimplements the thing it +// checks agrees with itself and nothing else. +const actual = flatten(catalog as Node) const expected = golden as Record describe('web translation catalog', () => { @@ -49,7 +50,8 @@ describe('web translation catalog', () => { it('has not gained a key without the fixture being updated', () => { // The other direction matters too. A key added to the catalogue and never - // added here is a string nobody reviewed. + // added here is a string nobody reviewed — including one that arrives from + // shared, which web can now resolve whether or not it renders it. const added = Object.keys(actual).filter(k => !(k in expected)) expect(added).toEqual([]) }) diff --git a/apps/web/src/locales/__tests__/mergeCatalog.test.ts b/apps/web/src/locales/__tests__/mergeCatalog.test.ts new file mode 100644 index 000000000..b0eb0fe6b --- /dev/null +++ b/apps/web/src/locales/__tests__/mergeCatalog.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect } from 'vitest' +import { translations as shared } from '@textstack/shared' +import { mergeCatalog, catalog, type TranslationNode } from '../catalog' + +/** + * The merge itself. Small surface, but three of these five properties are the kind + * that pass in isolation and cause damage somewhere else. + */ +describe('mergeCatalog', () => { + it('takes the right-hand value at a leaf — an override is a decision', () => { + const out = mergeCatalog({ a: 'shared' }, { a: 'web' }) + expect(out.a).toBe('web') + }) + + it('merges deeply instead of replacing a whole subtree', () => { + // The failure this prevents: web overriding one string under `library.sort` + // and silently deleting every sibling it did not mention. + const out = mergeCatalog( + { library: { sort: { a: 'A', b: 'B' } } }, + { library: { sort: { b: 'B2' } } }, + ) as { library: { sort: Record } } + expect(out.library.sort).toEqual({ a: 'A', b: 'B2' }) + }) + + it('does not mutate either input', () => { + // The important one. Mutating the shared catalogue would change it for every + // other importer in the process — the shared package's own t(), and under + // vitest every other test file in the same worker. That is a merge that + // passes its own tests and corrupts somebody else's. + const base: TranslationNode = { keep: 'me', nested: { x: '1' } } + const over: TranslationNode = { nested: { x: '2' }, extra: 'new' } + const baseCopy = structuredClone(base) + const overCopy = structuredClone(over) + + mergeCatalog(base, over) + + expect(base).toEqual(baseCopy) + expect(over).toEqual(overCopy) + }) + + it('replaces arrays rather than concatenating them', () => { + const out = mergeCatalog({ points: ['a', 'b'] }, { points: ['c'] }) + expect(out.points).toEqual(['c']) + }) + + it('refuses a string-vs-object collision instead of picking one', () => { + // No correct answer exists: the result would depend on read order and the + // loser's subtree would vanish without a word. The message names the path. + expect(() => mergeCatalog({ a: { b: 'x' } }, { a: 'flat' })).toThrow(/collision at "a"/) + expect(() => mergeCatalog({ a: 'flat' }, { a: { b: 'x' } })).toThrow(/collision at "a"/) + }) + + it('reports the full path of a nested collision', () => { + expect(() => mergeCatalog({ a: { b: { c: 'x' } } }, { a: { b: 'flat' } })) + .toThrow(/collision at "a\.b"/) + }) +}) + +describe('the real catalogue', () => { + it('builds without a collision', () => { + expect(Object.keys(catalog).length).toBeGreaterThan(30) + }) + + it('left the shared catalogue untouched', () => { + // Guards the same hazard as the unit test above, but against the real module + // graph: if `catalog.ts` ever mutates on merge, this is what notices. + expect((shared.en as TranslationNode).common).not.toHaveProperty('__merged') + const sharedCommon = (shared.en as TranslationNode).common as TranslationNode + const mergedCommon = catalog.common as TranslationNode + expect(mergedCommon).not.toBe(sharedCommon) + }) +}) diff --git a/apps/web/src/locales/__tests__/missing-keys.test.ts b/apps/web/src/locales/__tests__/missing-keys.test.ts index 294574e6f..96305aa33 100644 --- a/apps/web/src/locales/__tests__/missing-keys.test.ts +++ b/apps/web/src/locales/__tests__/missing-keys.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from 'vitest' import { readFileSync, readdirSync, statSync } from 'fs' import { resolve, join } from 'path' -import en from '../en.json' +import { catalog as en } from '../catalog' /** * Every `t('some.key')` in the source must resolve to a string in en.json. diff --git a/apps/web/src/locales/catalog.ts b/apps/web/src/locales/catalog.ts new file mode 100644 index 000000000..1c2477ee0 --- /dev/null +++ b/apps/web/src/locales/catalog.ts @@ -0,0 +1,67 @@ +import { translations as sharedTranslations } from '@textstack/shared' +import overrides from './en.json' + +/** + * The web app's string catalogue: the shared source with web's own file laid over it. + * + * Strings used to live in two hand-maintained copies — `packages/shared/src/i18n/en.json` + * for mobile, this directory's `en.json` for web — sharing 547 key paths that nothing + * compared. 523 were identical and 24 had quietly drifted. Shared is the source now; + * `en.json` here holds what only the website has (SEO pages, DMCA, the MCP landing, the + * device-approval flow) plus a small set of deliberate overrides. + * + * **Web wins at every leaf.** An override is a decision, so it takes precedence — and + * because it is now the ONLY reason a key appears twice, every one of them is visible. + */ + +export interface TranslationNode { + [key: string]: string | string[] | TranslationNode +} + +const isNode = (v: unknown): v is TranslationNode => + v !== null && typeof v === 'object' && !Array.isArray(v) + +/** + * Deep merge, right-hand side wins, **returning a new tree**. + * + * The new tree is not tidiness. `Object.assign(shared.en, web)` would mutate the + * module-cached shared catalogue for every other importer in the process — including + * the shared package's own `t()`, and, under vitest, every other test file sharing the + * worker's module cache. That is the easiest possible way to write a merge that passes + * its own tests and corrupts somebody else's. + * + * Arrays replace rather than concatenate. There are none in either file today, but + * `tArray` is a real API and "append" would be a surprising default for a translation. + */ +export function mergeCatalog(base: TranslationNode, over: TranslationNode, path = ''): TranslationNode { + const out: TranslationNode = { ...base } + for (const [key, value] of Object.entries(over)) { + const here = path ? `${path}.${key}` : key + const existing = out[key] + if (isNode(existing) && isNode(value)) { + out[key] = mergeCatalog(existing, value, here) + continue + } + // A path that is a string on one side and an object on the other has no correct + // merge — the result would depend on which file was read first, and the loser's + // subtree would vanish silently. Refuse rather than pick. + if (isNode(existing) !== isNode(value) && existing !== undefined) { + throw new Error( + `Translation catalogue collision at "${here}": ` + + `${isNode(existing) ? 'object' : 'string'} in shared, ` + + `${isNode(value) ? 'object' : 'string'} in web. One of them has to change.`, + ) + } + out[key] = value + } + return out +} + +/** + * Built once, at module load. `useTranslation`'s `t` is memoised on `[language]`, and + * merging a 1200-key tree inside the hook would throw that away on every render. + */ +export const catalog: TranslationNode = mergeCatalog( + sharedTranslations.en as TranslationNode, + overrides as TranslationNode, +) diff --git a/packages/shared/src/i18n/en.json b/packages/shared/src/i18n/en.json index 08807087b..121b1fab3 100644 --- a/packages/shared/src/i18n/en.json +++ b/packages/shared/src/i18n/en.json @@ -614,7 +614,7 @@ "noBookStats": "No book stats yet" }, "highlights": { - "empty": "No highlights yet", + "emptyTitle": "No highlights yet", "emptySubtitle": "Select text while reading to highlight it" }, "terms": {