Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/mobile/app/highlights/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -302,7 +302,7 @@ export default function HighlightsScreen() {
onButtonPress={() => { setLoading(true); setAttempt(a => a + 1) }}
/>
) : highlights.length === 0 ? (
<EmptyState icon="color-wand-outline" title={t('highlights.empty')} subtitle={t('highlights.emptySubtitle')} />
<EmptyState icon="color-wand-outline" title={t('highlights.emptyTitle')} subtitle={t('highlights.emptySubtitle')} />
) : (
<SectionList
sections={sections}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -139,8 +139,8 @@
"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?",
"highlights.empty": "No highlights yet",
"highlights.emptySubtitle": "Select text while reading to highlight it",
"highlights.emptyTitle": "No highlights yet",
"home.hero.cta": "Browse catalog",
"home.hero.description": "TextStack is a reading platform for language learners. Thousands of classic books with built-in vocabulary tools, spaced repetition, and reading statistics.",
"home.hero.subtitle": "Read classic literature. Build vocabulary. Track your progress.",
Expand Down
12 changes: 8 additions & 4 deletions apps/web/src/hooks/useTranslation.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import { useCallback } from 'react'
import { useLanguage, SupportedLanguage } from '../context/LanguageContext'
import en from '../locales/en.json'
import { catalog, type TranslationNode } from '../locales/catalog'

type TranslationData = typeof en

const translations: Record<SupportedLanguage, TranslationData> = { 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<SupportedLanguage, TranslationNode> = { en: catalog }

function getNestedValue(obj: unknown, path: string): unknown {
const keys = path.split('.')
Expand Down
123 changes: 123 additions & 0 deletions apps/web/src/locales/__tests__/__fixtures__/web-catalog.golden.json

Large diffs are not rendered by default.

16 changes: 9 additions & 7 deletions apps/web/src/locales/__tests__/golden.test.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<string, unknown> = {}) {
Expand All @@ -32,7 +30,10 @@ function flatten(node: Node, prefix = '', out: Record<string, unknown> = {}) {
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<string, unknown>

describe('web translation catalog', () => {
Expand All @@ -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([])
})
Expand Down
72 changes: 72 additions & 0 deletions apps/web/src/locales/__tests__/mergeCatalog.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> } }
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)
})
})
2 changes: 1 addition & 1 deletion apps/web/src/locales/__tests__/missing-keys.test.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
67 changes: 67 additions & 0 deletions apps/web/src/locales/catalog.ts
Original file line number Diff line number Diff line change
@@ -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,
)
2 changes: 1 addition & 1 deletion packages/shared/src/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
Loading