From bb0313627a21d779cc1fff8769751ad5b2f4bf71 Mon Sep 17 00:00:00 2001 From: Vasyl Vdovychenko Date: Tue, 8 Sep 2026 22:44:07 -0400 Subject: [PATCH] refactor(i18n): delete 100 orphaned strings, and a detector so they cannot return MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_011rgEMvYYi4Egj99dVtvm6E --- .../__fixtures__/shared-catalog.golden.json | 102 +-------------- .../__fixtures__/web-catalog.golden.json | 100 --------------- packages/shared/src/i18n/en.json | 120 +----------------- packages/shared/src/i18n/unusedKeys.test.ts | 107 ++++++++++++++++ 4 files changed, 113 insertions(+), 316 deletions(-) create mode 100644 packages/shared/src/i18n/unusedKeys.test.ts diff --git a/apps/mobile/src/lib/__fixtures__/shared-catalog.golden.json b/apps/mobile/src/lib/__fixtures__/shared-catalog.golden.json index d1b3ba90..1d47242d 100644 --- a/apps/mobile/src/lib/__fixtures__/shared-catalog.golden.json +++ b/apps/mobile/src/lib/__fixtures__/shared-catalog.golden.json @@ -21,13 +21,7 @@ "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", "authors.bioAndBooks": "{name} - Biography and books", @@ -45,7 +39,6 @@ "bookDetail.backToBooks": "Back to Books", "bookDetail.backToHome": "Back to Home", "bookDetail.browseAll": "Browse all books", - "bookDetail.chaptersHeading": "Chapters", "bookDetail.continueReading": "Continue Reading", "bookDetail.downloadForOffline": "Download for offline", "bookDetail.downloading": "Downloading {progress}%...", @@ -133,11 +126,8 @@ "guest.signOutTitle": "Sign out and lose everything?", "highlights.emptySubtitle": "Select text while reading to highlight it", "highlights.emptyTitle": "No highlights yet", - "home.hero.cta": "Browse catalog", - "home.hero.subtitle": "Read classic literature. Build vocabulary. Track your progress.", "home.recentAuthors.title": "Recently Added Authors", "home.recentAuthors.viewAll": "View all authors", - "home.recentBooks.read": "Read", "home.recentBooks.title": "Recently Added Books", "home.recentBooks.viewAll": "View all books", "librarian.ask": "Ask", @@ -174,12 +164,10 @@ "library.actions.confirmDeleteTitle": "Delete this book?", "library.actions.delete": "Delete", "library.actions.deleting": "Deleting…", - "library.actions.downloadOriginal": "Download original", "library.actions.editMetadata": "Edit metadata", "library.actions.markFinished": "Mark as finished", "library.actions.markUnfinished": "Mark as unfinished", "library.actions.menu": "Options", - "library.actions.open": "Open", "library.actions.removeFromLibrary": "Remove from library", "library.actions.retry": "Re-process", "library.actions.retrying": "Retrying…", @@ -201,14 +189,9 @@ "library.discuss.label": "Talk this book over", "library.emptyLibrary": "Your library is empty.", "library.failed": "Failed", - "library.failedToProcess": "Failed to process", - "library.filter.all": "All", "library.filter.clear": "Clear filter", "library.filter.empty": "No books match this filter", - "library.filter.failed": "Failed", "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", @@ -223,7 +206,6 @@ "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", @@ -234,11 +216,9 @@ "library.resume.continue": "Continue", "library.resume.percentComplete": "{percent}% complete", "library.resume.start": "Start reading", - "library.saved": "Saved", "library.search.clear": "Clear search", "library.search.empty": "No books match \"{query}\"", "library.search.placeholder": "Search your library…", - "library.search.shortcut": "Cmd+F to focus", "library.shelves.continueReading.subtitle": "Pick up where you left off", "library.shelves.continueReading.title": "Continue reading", "library.shelves.empty.browseCatalog": "Browse catalog", @@ -266,17 +246,8 @@ "library.sort.progress": "Most read", "library.sort.recent": "Last read", "library.sort.title": "Title (A→Z)", - "library.sortBy": "Sort by", - "library.sortProgress": "Progress", - "library.sortRecent": "Recently Added", - "library.sortTitle": "Title", "library.status.all": "All", "library.status.ariaLabel": "Filter by status", - "library.status.empty.all": "No books match this filter", - "library.status.empty.failed": "These uploads need attention", - "library.status.empty.finished": "Finish your first book this month", - "library.status.empty.notStarted": "Looking good — you're on top of your library", - "library.status.empty.reading": "Open a book to start reading", "library.status.failed": "Failed", "library.status.finished": "Finished", "library.status.notStarted": "Not started", @@ -289,8 +260,6 @@ "library.timeMinAgo": "min ago", "library.timeYesterday": "yesterday", "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", @@ -301,27 +270,13 @@ "library.view.source": "Show", "library.view.title": "View", "nav.about": "About", - "nav.browseBooks": "Browse all books", "nav.catalog": "Catalog", "nav.discover": "Discover", "nav.library": "Library", "nav.profile": "Profile", - "nav.read": "Read", - "nav.stats": "Stats", "nav.vocabulary": "Vocabulary", - "onboarding.chooseLanguage": "Choose your language", - "onboarding.chooseLanguageSubtitle": "You can change this later in settings", - "onboarding.gotIt": "Got it", - "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", "privacy.aiBody1": "They are optional. If you do not use Book Chat, the tutor, the librarian, Explain or translation, nothing is sent to OpenAI.", "privacy.aiBody2": "AI output can be wrong. Explanations, translations and answers about a book are generated by a language model and should not be relied on as fact.", "privacy.aiBody3": "We do not train models on your content, and under OpenAI's API terms neither do they. We do keep a sample of prompts and responses, as described above, for quality and cost monitoring.", @@ -387,7 +342,6 @@ "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.clearChat": "Clear chat", "reader.ask.clearConfirm": "Clear this chat? This can't be undone.", "reader.ask.detachQuote": "Remove quoted passage", @@ -415,7 +369,6 @@ "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.later": "Later", "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", @@ -424,11 +377,8 @@ "reader.rareWordNotice.cta": "Add to SRS anyway", "reader.rareWordNotice.ctaBusy": "Adding…", "reader.rareWordNotice.title": "Rare word — saved to reference", - "reader.reviewNow": "Review Now", "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", @@ -438,18 +388,11 @@ "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.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", "search.foundEditions": "Found {total} books", - "search.foundResults": "Found {total} results", "search.hideChapters": "Hide chapters", "search.moreChapters": "+{count} more chapters", "search.next": "Next →", @@ -467,7 +410,6 @@ "stats.achievements": "Achievements", "stats.allTime": "All Time", "stats.authors": "Most Read Authors", - "stats.avgDaily": "Avg Daily", "stats.avgTimeToFinish": "Avg Days to Finish", "stats.avgWpm": "Avg Words/Min", "stats.bookLength": "Book Length", @@ -482,15 +424,11 @@ "stats.details": "Details", "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!", - "stats.goalSettings": "Goal Settings", "stats.hours": "Hours", "stats.languages": "Languages", - "stats.long": "Long", "stats.longestStreak": "Longest Streak", - "stats.medium": "Medium", "stats.noBookStats": "No book stats yet", "stats.noData": "No data yet", "stats.pace": "Pace", @@ -498,10 +436,7 @@ "stats.readingTimeByAuthor": "Reading Time by Author", "stats.readingTimeByGenre": "Reading Time by Genre", "stats.saveGoal": "Save", - "stats.short": "Short", "stats.signInPrompt": "Sign in to track your reading and unlock achievements.", - "stats.slow": "Slow", - "stats.streakCalendar": "Reading Activity", "stats.streakThreshold": "Min minutes for streak", "stats.streaks": "Streaks", "stats.summary": "Summary", @@ -572,47 +507,26 @@ "tutor.summary.done": "Session complete", "tutor.summary.studied": "Studied", "tutor.title": "Smart session", - "vocabulary.alreadySaved": "Already saved", "vocabulary.banner.budgetReached": "Weekly goal reached. See you next week!", "vocabulary.clusters.bonusCta": "Bonus: {n} related words", "vocabulary.clusters.fromBook": "from {book}", "vocabulary.clusters.start": "Start bonus", - "vocabulary.definition": "Definition", "vocabulary.dueDate": "Due", - "vocabulary.dueToday": "Due today", "vocabulary.editTranslation": "Edit translation", "vocabulary.empty": "No words saved yet. Select words while reading to add them.", - "vocabulary.filters.all": "All", - "vocabulary.filters.learning": "Learning", - "vocabulary.filters.mastered": "Mastered", - "vocabulary.filters.new": "New", "vocabulary.filters.search": "Search words...", - "vocabulary.learning": "Learning", "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.practice.cta": "Practice anyway", - "vocabulary.practice.subtitle": "Doesn't affect your SRS schedule", "vocabulary.practice.title": "Practice", - "vocabulary.retired.subtitle": "Graduated from review. Tap to bring back.", - "vocabulary.retired.title": "Mastered — retired", - "vocabulary.retired.unretire": "Bring back to review", "vocabulary.review.backToVocab": "Back to Vocabulary", - "vocabulary.review.check": "Check", - "vocabulary.review.chooseTranslation": "Choose the correct translation", - "vocabulary.review.closeEnough": "Close enough!", "vocabulary.review.correct": "Correct!", "vocabulary.review.correctAnswer": "Correct answer", "vocabulary.review.correctRate": "Correct rate", - "vocabulary.review.fillBlank": "What word fills the blank?", "vocabulary.review.fromBook": "from \"{title}\"", "vocabulary.review.next": "Next", - "vocabulary.review.promoted": "Level up!", "vocabulary.review.reviewed": "Reviewed", - "vocabulary.review.sessionComplete": "Session complete!", - "vocabulary.review.title": "Vocabulary Review", "vocabulary.review.wrong": "Not quite", - "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", @@ -634,25 +548,11 @@ "vocabulary.sort.due": "Due date", "vocabulary.sort.recent": "Recent", "vocabulary.sort.stage": "Stage", - "vocabulary.source": "Source", - "vocabulary.stage": "Stage", - "vocabulary.stages.0": "New", - "vocabulary.stages.1": "Recognition", - "vocabulary.stages.2": "Recall", - "vocabulary.stages.3": "Context", - "vocabulary.stages.4": "Mastered", - "vocabulary.startReview": "Start Review", - "vocabulary.stats.correctRate": "Correct rate", - "vocabulary.stats.reviewedToday": "Reviewed today", "vocabulary.stats.streak": "Review streak", "vocabulary.title": "Vocabulary", "vocabulary.totalWords": "Total words", - "vocabulary.translation": "Translation", "vocabulary.weeklyBudget.backToReading": "Back to reading", "vocabulary.weeklyBudget.emptyStateSubtitle": "You've hit your weekly review target. Read — new reviews unlock as old ones age out.", "vocabulary.weeklyBudget.label": "This week", - "vocabulary.weeklyBudget.progress": "{used} / {budget}", - "vocabulary.weeklyBudget.remaining": "{n} left this week", - "vocabulary.word": "Word", - "vocabulary.wordSaved": "Word saved" + "vocabulary.weeklyBudget.progress": "{used} / {budget}" } 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 ef1f9c1d..cae56992 100644 --- a/apps/web/src/locales/__tests__/__fixtures__/web-catalog.golden.json +++ b/apps/web/src/locales/__tests__/__fixtures__/web-catalog.golden.json @@ -21,13 +21,7 @@ "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", @@ -54,7 +48,6 @@ "bookDetail.backToBooks": "Back to Books", "bookDetail.backToHome": "Back to Home", "bookDetail.browseAll": "Browse all books", - "bookDetail.chaptersHeading": "Chapters", "bookDetail.continueReading": "Continue Reading", "bookDetail.downloadEpub": "Download EPUB", "bookDetail.downloadForOffline": "Download for offline", @@ -338,7 +331,6 @@ "home.features.upload.description": "Upload EPUB or PDF. Read your books with the full TextStack Reader toolkit.", "home.features.upload.title": "Your own books", "home.hero.brandLine": "TextStack — a reader for developers, students, and curious minds.", - "home.hero.cta": "Browse catalog", "home.hero.ctaPrimary": "Start reading", "home.hero.ctaReturning": "Continue where you left off", "home.hero.ctaSecondary": "Upload your book", @@ -346,7 +338,6 @@ "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.", @@ -360,7 +351,6 @@ "home.howItWorks.step3Title": "Master with flashcards", "home.recentAuthors.title": "Recently Added Authors", "home.recentAuthors.viewAll": "View all authors", - "home.recentBooks.read": "Read", "home.recentBooks.title": "Recently Added Books", "home.recentBooks.viewAll": "View all books", "home.recommended.subtitle": "Free, public-domain books — thousands ready to read.", @@ -423,12 +413,10 @@ "library.actions.confirmDeleteTitle": "Delete this book?", "library.actions.delete": "Delete", "library.actions.deleting": "Deleting…", - "library.actions.downloadOriginal": "Download original", "library.actions.editMetadata": "Edit metadata", "library.actions.markFinished": "Mark as finished", "library.actions.markUnfinished": "Mark as unfinished", "library.actions.menu": "Options", - "library.actions.open": "Open", "library.actions.removeFromLibrary": "Remove from library", "library.actions.retry": "Re-process", "library.actions.retrying": "Retrying…", @@ -497,14 +485,9 @@ "library.estimate.fromYourPace": "Based on your reading pace", "library.estimate.left": "{{time}} left", "library.failed": "Failed", - "library.failedToProcess": "Failed to process", - "library.filter.all": "All", "library.filter.clear": "Clear filter", "library.filter.empty": "No books match this filter", - "library.filter.failed": "Failed", "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", @@ -519,7 +502,6 @@ "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", @@ -563,12 +545,10 @@ "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", "library.search.empty": "No books match \"{query}\"", "library.search.placeholder": "Search your library…", - "library.search.shortcut": "Cmd+F to focus", "library.shelves.continueReading.subtitle": "Pick up where you left off", "library.shelves.continueReading.title": "Continue reading", "library.shelves.empty.browseCatalog": "Browse catalog", @@ -597,10 +577,6 @@ "library.sort.progress": "Progress (most read)", "library.sort.recent": "Recently opened", "library.sort.title": "Title (A→Z)", - "library.sortBy": "Sort by", - "library.sortProgress": "Progress", - "library.sortRecent": "Recently Added", - "library.sortTitle": "Title", "library.statsHeader.booksSuffix": "books", "library.statsHeader.booksYtd": "Books finished YTD", "library.statsHeader.collapse": "Hide stats", @@ -619,11 +595,6 @@ "library.statsHeader.yearlyGoal": "Year's goal", "library.status.all": "All", "library.status.ariaLabel": "Filter by status", - "library.status.empty.all": "No books match this filter", - "library.status.empty.failed": "These uploads need attention", - "library.status.empty.finished": "Finish your first book this month", - "library.status.empty.notStarted": "Looking good — you're on top of your library", - "library.status.empty.reading": "Open a book to start reading", "library.status.failed": "Failed", "library.status.finished": "Finished", "library.status.notStarted": "Not started", @@ -647,8 +618,6 @@ "library.timeMinAgo": "min ago", "library.timeYesterday": "yesterday", "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", @@ -704,7 +673,6 @@ "nav.aboutTextStack": "About TextStack Reader", "nav.books": "Books", "nav.brandTitle": "TextStack Reader - Learn languages through reading", - "nav.browseBooks": "Browse all books", "nav.browseGenres": "Browse genres", "nav.catalog": "Catalog", "nav.discover": "Discover", @@ -721,25 +689,12 @@ "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", @@ -826,7 +781,6 @@ "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}}", @@ -872,7 +826,6 @@ "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", @@ -900,7 +853,6 @@ "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", @@ -919,8 +871,6 @@ "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", @@ -932,7 +882,6 @@ "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", "reader.wordPopup.change": "Change", @@ -953,16 +902,10 @@ "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", "search.foundEditions": "Found {total} books", - "search.foundResults": "Found {total} results", "search.hideChapters": "Hide chapters", "search.moreChapters": "+{count} more chapters", "search.next": "Next →", @@ -996,7 +939,6 @@ "stats.achievements": "Achievements", "stats.allTime": "All Time", "stats.authors": "Most Read Authors", - "stats.avgDaily": "Avg Daily", "stats.avgTimeToFinish": "Avg Days to Finish", "stats.avgWpm": "Avg Words/Min", "stats.bookLength": "Book Length", @@ -1017,15 +959,11 @@ "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!", - "stats.goalSettings": "Goal Settings", "stats.hours": "Hours", "stats.languages": "Languages", - "stats.long": "Long", "stats.longestStreak": "Longest Streak", - "stats.medium": "Medium", "stats.noBookStats": "No book stats yet", "stats.noData": "No data yet", "stats.overview": "Overview", @@ -1034,10 +972,7 @@ "stats.readingTimeByAuthor": "Reading Time by Author", "stats.readingTimeByGenre": "Reading Time by Genre", "stats.saveGoal": "Save", - "stats.short": "Short", "stats.signInPrompt": "Sign in to track your reading and unlock achievements.", - "stats.slow": "Slow", - "stats.streakCalendar": "Reading Activity", "stats.streakThreshold": "Min minutes for streak", "stats.streaks": "Streaks", "stats.summary": "Summary", @@ -1145,7 +1080,6 @@ "userBook.enrichRetry": "Retry", "userBook.enriching": "Generating book details…", "userMenu.anonymousReader": "Anonymous reader", - "vocabulary.alreadySaved": "Already saved", "vocabulary.banner.budgetReached": "Weekly goal reached. See you next week!", "vocabulary.banner.dayStreak": "day streak", "vocabulary.banner.dueWaiting": "{n} words waiting for review", @@ -1168,7 +1102,6 @@ "vocabulary.columns.progress": "Progress", "vocabulary.columns.translation": "Translation", "vocabulary.columns.word": "Word", - "vocabulary.definition": "Definition", "vocabulary.deleteAll": "Delete all words", "vocabulary.deleteAllConfirm": "Click to confirm — cannot be undone", "vocabulary.deleteConfirm": "Remove", @@ -1180,22 +1113,16 @@ "vocabulary.due.today": "Today", "vocabulary.due.tomorrow": "Tomorrow", "vocabulary.dueDate": "Due", - "vocabulary.dueToday": "Due today", "vocabulary.editTranslation": "Edit translation", "vocabulary.empty": "No words saved yet. Select words while reading to add them.", "vocabulary.emptyPage.cta": "Browse library", "vocabulary.emptyPage.subtitle": "Open any book, tap a word — it will appear here ready for review.", "vocabulary.emptyPage.title": "Your vocabulary starts here", - "vocabulary.filters.all": "All", - "vocabulary.filters.learning": "Learning", "vocabulary.filters.lookups": "Reference", - "vocabulary.filters.mastered": "Mastered", - "vocabulary.filters.new": "New", "vocabulary.filters.pending": "Pending", "vocabulary.filters.search": "Search words...", "vocabulary.fromBook": "from {title}", "vocabulary.interval": "Next interval: {n} days", - "vocabulary.learning": "Learning", "vocabulary.listCount": "{n} words", "vocabulary.listTitle": "My vocabulary", "vocabulary.loadMore": "Load more", @@ -1208,7 +1135,6 @@ "vocabulary.lookups.promoteFailed": "Could not promote this word. Try again.", "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", @@ -1222,26 +1148,18 @@ "vocabulary.practice.flashcards": "Flashcards", "vocabulary.practice.length": "Length", "vocabulary.practice.mode": "Mode", - "vocabulary.practice.subtitle": "Doesn't affect your SRS schedule", "vocabulary.practice.title": "Practice", "vocabulary.practice.wordsUnit": "words", - "vocabulary.retired.subtitle": "Graduated from review. Tap to bring back.", - "vocabulary.retired.title": "Mastered — retired", - "vocabulary.retired.unretire": "Bring back to review", "vocabulary.review.almost": "Almost", "vocabulary.review.backToVocab": "Back to Vocabulary", "vocabulary.review.blitz": "Blitz", - "vocabulary.review.check": "Check", - "vocabulary.review.chooseTranslation": "Choose the correct translation", "vocabulary.review.classic": "Classic", - "vocabulary.review.closeEnough": "Close enough!", "vocabulary.review.continue": "Continue", "vocabulary.review.correct": "Correct!", "vocabulary.review.correctAnswer": "Correct answer", "vocabulary.review.correctRate": "Correct rate", "vocabulary.review.didYouRemember": "Did you remember?", "vocabulary.review.excellent": "Excellent!", - "vocabulary.review.fillBlank": "What word fills the blank?", "vocabulary.review.forgot": "Forgot", "vocabulary.review.fromBook": "from \"{title}\"", "vocabulary.review.greatWork": "Great work!", @@ -1257,18 +1175,14 @@ "vocabulary.review.practiceAgain": "Practice Again", "vocabulary.review.practiceMode": "Practice Mode", "vocabulary.review.practiceNote": "Correct answers don't change your review schedule.", - "vocabulary.review.promoted": "Level up!", "vocabulary.review.reviewDue": "Review Due Words", "vocabulary.review.reviewed": "Reviewed", - "vocabulary.review.sessionComplete": "Session complete!", "vocabulary.review.soundOff": "Sound off", "vocabulary.review.soundOn": "Sound on", "vocabulary.review.startAgain": "Start Again", "vocabulary.review.tapToFlip": "Tap to flip", - "vocabulary.review.title": "Vocabulary Review", "vocabulary.review.wordsReviewed": "words", "vocabulary.review.wrong": "Not quite", - "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", @@ -1290,30 +1204,16 @@ "vocabulary.sort.due": "Due date", "vocabulary.sort.recent": "Recent", "vocabulary.sort.stage": "Stage", - "vocabulary.source": "Source", "vocabulary.srsHint": "Spaced repetition — each word returns when it's due for review.", - "vocabulary.stage": "Stage", - "vocabulary.stages.0": "New", - "vocabulary.stages.1": "Recognition", - "vocabulary.stages.2": "Recall", - "vocabulary.stages.3": "Context", - "vocabulary.stages.4": "Mastered", "vocabulary.startPractice": "Practice", - "vocabulary.startReview": "Start Review", - "vocabulary.stats.correctRate": "Correct rate", "vocabulary.stats.practicedToday": "Practiced today", - "vocabulary.stats.reviewedToday": "Reviewed today", "vocabulary.stats.streak": "Review streak", "vocabulary.title": "Vocabulary", "vocabulary.totalWords": "Total words", - "vocabulary.translation": "Translation", "vocabulary.weeklyBudget.backToReading": "Back to reading", "vocabulary.weeklyBudget.emptyStateSubtitle": "You've hit your weekly review target. Read — new reviews unlock as old ones age out.", "vocabulary.weeklyBudget.label": "This week", "vocabulary.weeklyBudget.progress": "{used} / {budget}", - "vocabulary.weeklyBudget.remaining": "{n} left this week", - "vocabulary.word": "Word", - "vocabulary.wordSaved": "Word saved", "words.tabs.highlights": "Highlights", "words.tabs.vocabulary": "Vocabulary" } diff --git a/packages/shared/src/i18n/en.json b/packages/shared/src/i18n/en.json index 72cca5d3..70de22ba 100644 --- a/packages/shared/src/i18n/en.json +++ b/packages/shared/src/i18n/en.json @@ -73,18 +73,13 @@ } }, "home": { - "hero": { - "subtitle": "Read classic literature. Build vocabulary. Track your progress.", - "cta": "Browse catalog" - }, "recentAuthors": { "title": "Recently Added Authors", "viewAll": "View all authors" }, "recentBooks": { "title": "Recently Added Books", - "viewAll": "View all books", - "read": "Read" + "viewAll": "View all books" } }, "firstRun": { @@ -110,28 +105,14 @@ "nav": { "catalog": "Catalog", "library": "Library", - "stats": "Stats", "vocabulary": "Vocabulary", "about": "About", - "browseBooks": "Browse all books", - "read": "Read", "discover": "Discover", "profile": "Profile" }, "onboarding": { - "chooseLanguage": "Choose your language", - "chooseLanguageSubtitle": "You can change this later in settings", "nativeLanguageTitle": "What language do you know best?", - "nativeLanguageSubtitle": "We'll translate into it while you read. You can change it any time in Profile.", - "learnByReading": "Learn by reading", - "tapToTranslate": "Tap any word to see its translation", - "saveWords": "Save words to build your vocabulary", - "save": "Save", - "ready": "You're ready!", - "readySubtitle": "Saved words appear in your vocabulary\nfor spaced repetition review", - "next": "Next", - "skip": "Skip", - "gotIt": "Got it" + "nativeLanguageSubtitle": "We'll translate into it while you read. You can change it any time in Profile." }, "profile": { "vocabReminder": { @@ -203,12 +184,6 @@ "library": { "title": "My Library", "signInPrompt": "Sign in to save books to your library and track your reading progress.", - "saved": "Saved", - "uploads": "Uploads", - "sortRecent": "Recently Added", - "sortTitle": "Title", - "sortProgress": "Progress", - "sortBy": "Sort by", "loading": "Loading...", "emptyLibrary": "Your library is empty.", "browseBooks": "Browse Books", @@ -229,14 +204,11 @@ "chapters": "chapters", "processing": "Processing...", "failed": "Failed", - "noUploads": "No uploaded books yet.", - "uploadHint": "Click the + button to upload EPUB or PDF files.", "timeJustNow": "just now", "timeMinAgo": "min ago", "timeHoursAgo": "hours ago", "timeYesterday": "yesterday", "timeDaysAgo": "days ago", - "failedToProcess": "Failed to process", "sidebar": { "all": "All books", "uploads": "My uploads", @@ -286,11 +258,7 @@ "progress": "Most read" }, "filter": { - "all": "All", - "reading": "Reading", "finished": "Finished", - "notStarted": "Not started", - "failed": "Failed", "empty": "No books match this filter", "clear": "Clear filter" }, @@ -300,24 +268,15 @@ "notStarted": "Not started", "failed": "Failed", "all": "All", - "empty": { - "reading": "Open a book to start reading", - "finished": "Finish your first book this month", - "notStarted": "Looking good — you're on top of your library", - "failed": "These uploads need attention", - "all": "No books match this filter" - }, "ariaLabel": "Filter by status" }, "search": { "placeholder": "Search your library…", "empty": "No books match \"{query}\"", - "clear": "Clear search", - "shortcut": "Cmd+F to focus" + "clear": "Clear search" }, "actions": { "menu": "Options", - "open": "Open", "viewDetails": "View details", "markFinished": "Mark as finished", "markUnfinished": "Mark as unfinished", @@ -326,7 +285,6 @@ "retrying": "Retrying…", "cancel": "Cancel", "cancelling": "Cancelling…", - "downloadOriginal": "Download original", "removeFromLibrary": "Remove from library", "addToCollection": "Add to collection", "addToCollectionEmpty": "No collections yet — create one below", @@ -388,13 +346,7 @@ }, "addMenu": { "title": "Add to library", - "ariaLabel": "Add content menu", - "comingSoon": "Coming in a future update", "uploadFile": "Upload file", - "pasteUrl": "Paste URL", - "emailBook": "Email a book", - "browserExtension": "Browser extension", - "mobileApps": "Mobile apps", "browseAll": "Browse all books" }, "books": { @@ -441,7 +393,6 @@ "downloading": "Downloading {progress}%...", "availableOffline": "Available offline", "downloadForOffline": "Download for offline", - "chaptersHeading": "Chapters", "readChapter": "Read {title}", "words": "words", "viewAllChapters": "View all {count} chapters", @@ -474,7 +425,6 @@ "title": "Search", "enterQuery": "Enter a search query", "noResults": "No results found", - "foundResults": "Found {total} results", "chapter": "Chapter", "readOnline": "Read {title} online", "readOnlineFree": "{title} - Read online free", @@ -555,13 +505,10 @@ "totalTime": "Total Time", "booksFinished": "Books Finished", "currentStreak": "Current Streak", - "avgDaily": "Avg Daily", "dailyGoal": "Daily Goal", "goalMet": "Goal met!", - "streakCalendar": "Reading Activity", "weeklyChart": "This Week", "achievements": "Achievements", - "goalSettings": "Goal Settings", "dailyMinutesTarget": "Daily minutes target", "streakThreshold": "Min minutes for streak", "saveGoal": "Save", @@ -588,11 +535,6 @@ "avgTimeToFinish": "Avg Days to Finish", "readingTimeByGenre": "Reading Time by Genre", "readingTimeByAuthor": "Reading Time by Author", - "slow": "Slow", - "medium": "Medium", - "fast": "Fast", - "short": "Short", - "long": "Long", "noData": "No data yet", "emptyTitle": "No reading stats yet", "emptySubtitle": "Start reading a book to track your progress", @@ -661,7 +603,6 @@ "clearConfirm": "Clear this chat? This can't be undone.", "loadingHistory": "Loading chat…", "detachQuote": "Remove quoted passage", - "askAboutThis": "Ask about this", "startersTitle": "Try asking", "starters": { "summary": "Summarize what I've read so far", @@ -670,9 +611,6 @@ "attention": "What should I pay attention to?" } }, - "reviewNow": "Review Now", - "later": "Later", - "wordsSaved": "{count} word(s) saved", "toastWordAdded": "Added to vocabulary", "toastWordAddedCount": "Added · {count} this session", "toastTapToReview": "Tap to review", @@ -687,7 +625,6 @@ }, "vocab": { "queuedForTomorrow": "Daily cap reached — queued for tomorrow", - "savedToReference": "Saved to reference (rare word)", "tapAgainToStudy": "Tap again to study ({n} left)", "addedToSrs": "Added to your vocabulary", "addAnywayFailed": "Couldn't add word — please try again", @@ -703,49 +640,18 @@ "under": "Less than a minute left", "minutes": "{minutes} min left", "hours": "{hours} hr left", - "hoursMinutes": "{hours} hr {minutes} min left", - "inChapter": "left in chapter", - "inBook": "left in book" + "hoursMinutes": "{hours} hr {minutes} min left" } }, - "review": { - "excellent": "Excellent!", - "greatWork": "Great work!", - "keepPracticing": "Keep practicing!", - "dayStreak": "{count} day streak" - }, "vocabulary": { "title": "Vocabulary", "signInPrompt": "Sign in to build your personal vocabulary list.", "empty": "No words saved yet. Select words while reading to add them.", - "saveWord": "Save word", - "wordSaved": "Word saved", - "alreadySaved": "Already saved", - "startReview": "Start Review", - "dueToday": "Due today", "totalWords": "Total words", "mastered": "Mastered", - "learning": "Learning", - "word": "Word", - "translation": "Translation", - "definition": "Definition", - "stage": "Stage", - "source": "Source", "dueDate": "Due", - "noReviewDue": "No words due for review!", "editTranslation": "Edit translation", - "stages": { - "0": "New", - "1": "Recognition", - "2": "Recall", - "3": "Context", - "4": "Mastered" - }, "filters": { - "all": "All", - "new": "New", - "learning": "Learning", - "mastered": "Mastered", "search": "Search words..." }, "sort": { @@ -755,31 +661,21 @@ "stage": "Stage" }, "review": { - "title": "Vocabulary Review", - "chooseTranslation": "Choose the correct translation", - "fillBlank": "What word fills the blank?", "correct": "Correct!", - "closeEnough": "Close enough!", "wrong": "Not quite", "correctAnswer": "Correct answer", "next": "Next", - "sessionComplete": "Session complete!", "reviewed": "Reviewed", "correctRate": "Correct rate", - "promoted": "Level up!", - "check": "Check", "fromBook": "from \"{title}\"", "backToVocab": "Back to Vocabulary" }, "stats": { - "reviewedToday": "Reviewed today", - "correctRate": "Correct rate", "streak": "Review streak" }, "weeklyBudget": { "label": "This week", "progress": "{used} / {budget}", - "remaining": "{n} left this week", "emptyStateSubtitle": "You've hit your weekly review target. Read — new reviews unlock as old ones age out.", "backToReading": "Back to reading" }, @@ -788,13 +684,7 @@ }, "practice": { "title": "Practice", - "cta": "Practice anyway", - "subtitle": "Doesn't affect your SRS schedule" - }, - "retired": { - "title": "Mastered — retired", - "subtitle": "Graduated from review. Tap to bring back.", - "unretire": "Bring back to review" + "cta": "Practice anyway" }, "clusters": { "bonusCta": "Bonus: {n} related words", diff --git a/packages/shared/src/i18n/unusedKeys.test.ts b/packages/shared/src/i18n/unusedKeys.test.ts new file mode 100644 index 00000000..1b5f3829 --- /dev/null +++ b/packages/shared/src/i18n/unusedKeys.test.ts @@ -0,0 +1,107 @@ +import { describe, it, expect } from 'vitest' +import { readFileSync, readdirSync, statSync } from 'node:fs' +import { resolve, join } from 'node:path' + +/** + * Keys this catalogue carries that neither app renders. + * + * Nothing checked this before and 100 had accumulated — a whole `review` namespace, + * most of `vocabulary`, `onboarding` copy for a flow that changed. They cost bundle + * size on a phone and, worse, they read as intent: the next person opening the file + * cannot tell which strings are actually shown. + * + * **It checks BOTH consumers, and that is not a detail.** The web app no longer keeps + * its own copy of a shared string, so a key used only by the website lives only here. + * A first pass at this test looked at mobile alone and reported 278 orphans; deleting + * them removed 244 keys the website renders, and `apps/web/src/locales/__tests__/ + * missing-keys.test.ts` failed instantly with `Footer.tsx → footer.privacy`. Since + * slice 3, "unused by mobile" and "unused" are different questions. + * + * **The detector is deliberately narrow, because a wrong answer 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 when EITHER the exact dotted path appears quoted + * anywhere in either app or here (covering `t('a.b')` and key tables such as + * `PRIVACY_SECTIONS`), OR it matches one of the templates below. + * + * DYNAMIC is the complete set of `` t(`…${…}`) `` shapes in both apps, taken by + * grepping for them rather than guessed. Add a new template to an app without adding + * it here and this test starts calling live keys dead — loudly, which is the point. + */ +const DYNAMIC = [ + // mobile + /^contact\.reachOut\w+$/, + /^library\.badge\.\w+$/, + /^library\.sort\.\w+$/, + /^library\.status\.\w+$/, + /^reader\.ask\.starters\.\w+$/, + /^terms\.use\w+$/, + /^tutor\.exercise\.\w+$/, + // web + /^home\.comparison\.(competitors|features)\.\w+$/, + /^home\.faq\.\w+\.[aq]$/, + /^home\.features\.\w+\.(title|description)$/, + /^home\.testimonials\.\w+\.(name|quote|role)$/, + /^library\.shelves\.\w+\.(title|subtitle)$/, + // web's DeviceVerifyPage builds `${ns}.${suffix}` with ns chosen at runtime + /^(deviceVerify|connectExtension)\./, +] + +/** + * Kept on purpose despite having no reference, `key: 'why'`. Empty today. Asserted by + * EQUALITY, so a key that starts being used must also be removed from here — an + * allowlist nobody prunes stops meaning anything. + */ +const KNOWN_UNREFERENCED: Record = {} + +const ROOTS = [ + resolve(__dirname, '..'), // packages/shared/src + resolve(__dirname, '../../../../apps/mobile/src'), + resolve(__dirname, '../../../../apps/mobile/app'), + resolve(__dirname, '../../../../apps/web/src'), + resolve(__dirname, '../../../../apps/web/scripts'), +] + +function read(dir: string, out: string[] = []): string[] { + for (const entry of readdirSync(dir)) { + if (['node_modules', 'dist', '.expo', '__fixtures__', 'locales'].includes(entry)) continue + const p = join(dir, entry) + let isDir: boolean + try { isDir = statSync(p).isDirectory() } catch { continue } // ios/Pods has broken symlinks + if (isDir) read(p, out) + else if (/\.(ts|tsx|js|mjs)$/.test(entry) && !/\.test\.tsx?$/.test(entry)) out.push(p) + } + return out +} + +const source = ROOTS.flatMap(d => read(d)).map(p => readFileSync(p, 'utf8')).join('\n') + +type Node = Record +const flatten = (n: Node, p = '', o: Record = {}) => { + for (const [k, v] of Object.entries(n)) { + const q = p ? `${p}.${k}` : k + if (v !== null && typeof v === 'object' && !Array.isArray(v)) flatten(v as Node, q, o) + else o[q] = v + } + return o +} + +const catalog = flatten(JSON.parse(readFileSync(resolve(__dirname, 'en.json'), 'utf8'))) + +describe('shared catalogue has no orphans', () => { + it('found both apps', () => { + // A walk that silently returns nothing would make every key look dead and this + // whole file pass for the worst possible reason. + expect(source.length).toBeGreaterThan(500_000) + }) + + it('every key is reachable from one of the apps, or written down as deliberately not', () => { + const unreferenced = Object.keys(catalog).filter(key => { + if (["'", '"', '`'].some(q => source.includes(`${q}${key}${q}`))) return false + return !DYNAMIC.some(rx => rx.test(key)) + }) + expect(unreferenced.sort()).toEqual(Object.keys(KNOWN_UNREFERENCED).sort()) + }) +})