feat(i18n): adopt next-intl + migrate every visible string (#146) #153

Merged
james merged 2 commits from 146-i18n into main 2026-06-20 01:00:43 +00:00
Owner

Closes #146.

Summary

  • Adopt next-intl with single-locale-per-request resolution
    (?locale=carol_locale cookie → Accept-Language
    DEFAULT_LOCALE) — logic in lib/i18n/locales.ts, wired through
    lib/i18n/request.ts.
  • messages/en.json is the source-of-truth catalog. Sibling locales
    may be partial — missing keys silently fall back to English per-key.
    A smoke messages/es.json ships covering home, nav, and login.
  • Every visible string in app/(app)/, /login, /register, the
    home page, and shared components routes through useTranslations()
    (client) or getTranslations() (server).
  • Locale picker: server wrapper (locale-picker-server.tsx) renders
    null when SUPPORTED_LOCALES has one entry; client island writes
    the cookie via a server action and revalidatePath("/", "layout").
  • Exceptions documented in ADR-0025: /offline stays English-only
    (force-static can't read cookies); zod schema messages deferred to
    a follow-up.
  • DEFAULT_LOCALE + SUPPORTED_LOCALES added to README Operations
    table. CLAUDE.md Conventions gains the no-hardcoded-strings rule.
    ADR-0025 captures the library choice, resolution chain, and partial-
    catalog policy.

Test plan

  • npx tsc --noEmit clean
  • npm run lint clean
  • npx vitest run — 440 passed / 91 skipped, including 19 new
    tests in tests/i18n/resolve.test.ts covering the resolution
    chain (fallback, header negotiation, region-strip, env parsing)
  • npm run build succeeds
  • Hit /?locale=es with SUPPORTED_LOCALES=en,es → home page
    renders Spanish strings, falls back to English for unmigrated
    surfaces
  • Sign in with the locale picker visible → switching writes the
    carol_locale cookie and the new locale persists across pages
  • Accept-Language: es,en;q=0.5 with no cookie → home renders
    Spanish
Closes #146. ## Summary - Adopt `next-intl` with single-locale-per-request resolution (`?locale=` → `carol_locale` cookie → `Accept-Language` → `DEFAULT_LOCALE`) — logic in `lib/i18n/locales.ts`, wired through `lib/i18n/request.ts`. - `messages/en.json` is the source-of-truth catalog. Sibling locales may be partial — missing keys silently fall back to English per-key. A smoke `messages/es.json` ships covering `home`, `nav`, and `login`. - Every visible string in `app/(app)/`, `/login`, `/register`, the home page, and shared components routes through `useTranslations()` (client) or `getTranslations()` (server). - Locale picker: server wrapper (`locale-picker-server.tsx`) renders `null` when `SUPPORTED_LOCALES` has one entry; client island writes the cookie via a server action and `revalidatePath("/", "layout")`. - Exceptions documented in ADR-0025: `/offline` stays English-only (force-static can't read cookies); zod schema messages deferred to a follow-up. - `DEFAULT_LOCALE` + `SUPPORTED_LOCALES` added to README Operations table. CLAUDE.md Conventions gains the no-hardcoded-strings rule. ADR-0025 captures the library choice, resolution chain, and partial- catalog policy. ## Test plan - [x] `npx tsc --noEmit` clean - [x] `npm run lint` clean - [x] `npx vitest run` — 440 passed / 91 skipped, including 19 new tests in `tests/i18n/resolve.test.ts` covering the resolution chain (fallback, header negotiation, region-strip, env parsing) - [x] `npm run build` succeeds - [ ] Hit `/?locale=es` with `SUPPORTED_LOCALES=en,es` → home page renders Spanish strings, falls back to English for unmigrated surfaces - [ ] Sign in with the locale picker visible → switching writes the `carol_locale` cookie and the new locale persists across pages - [ ] `Accept-Language: es,en;q=0.5` with no cookie → home renders Spanish
feat(i18n): adopt next-intl + migrate every visible string (#146)
Some checks failed
PR / npm audit (pull_request) Failing after 22s
PR / Static analysis (pull_request) Successful in 46s
Commits / Conventional Commits (pull_request) Successful in 17s
PR / OSV-Scanner (pull_request) Successful in 25s
PR / Test (sqlite) (pull_request) Failing after 26s
PR / Build (pull_request) Failing after 26s
PR / Package age policy (soft) (pull_request) Successful in 30s
PR / Trivy (image) (pull_request) Failing after 3m2s
PR / Test (postgres) (pull_request) Failing after 19s
PR / Lint (pull_request) Failing after 20s
PR / Coverage (soft) (pull_request) Failing after 16s
PR / Typecheck (pull_request) Failing after 22s
Secrets / gitleaks (pull_request) Successful in 16s
c40623afa3
next-intl with single-locale-per-request resolution
(?locale → cookie → Accept-Language → DEFAULT_LOCALE) wired through
a tiny lib/i18n module. messages/en.json is the source-of-truth
catalog; sibling locales may be partial and silently fall back to
English per-key. A smoke messages/es.json covers home + login + nav.

Every visible string in app/(app)/, /login, /register, the home
page, and shared components flows through useTranslations() /
getTranslations(). The locale picker is a server/client split that
writes the carol_locale cookie via a server action and renders
nothing when SUPPORTED_LOCALES has one entry.

DEFAULT_LOCALE + SUPPORTED_LOCALES added to README Operations
table; CLAUDE.md Conventions gains a no-hardcoded-strings rule;
ADR-0023 captures the library choice, resolution chain, partial-
catalog policy, and the deferred zod-schema-message follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Trivy (container image)

Threshold: high  ·  Total findings: 121  ·  At/above threshold: 1

critical high medium low
0 1 50 70
severity id package installed / range fix
high CVE-2026-12151 undici 6.25.0 6.27.0, 7.28.0, 8.5.0
<!-- scanner-comment: trivy --> ### Trivy (container image) **Threshold:** `high` &nbsp;·&nbsp; **Total findings:** 121 &nbsp;·&nbsp; **At/above threshold:** 1 | critical | high | medium | low | |---:|---:|---:|---:| | 0 | 1 | 50 | 70 | | severity | id | package | installed / range | fix | |---|---|---|---|---| | high | [CVE-2026-12151](https://avd.aquasec.com/nvd/cve-2026-12151) | undici | 6.25.0 | `6.27.0, 7.28.0, 8.5.0` |
build: regenerate lockfile under npm 10 to add @swc/helpers peer dep
Some checks failed
Commits / Conventional Commits (pull_request) Successful in 8s
PR / OSV-Scanner (pull_request) Successful in 36s
PR / Static analysis (pull_request) Successful in 50s
Secrets / gitleaks (pull_request) Successful in 16s
PR / Package age policy (soft) (pull_request) Successful in 58s
PR / Typecheck (pull_request) Successful in 2m25s
PR / Lint (pull_request) Successful in 2m25s
PR / Test (sqlite) (pull_request) Successful in 2m33s
PR / npm audit (pull_request) Successful in 2m39s
PR / Coverage (soft) (pull_request) Successful in 2m32s
PR / Test (postgres) (pull_request) Failing after 2m46s
PR / Trivy (image) (pull_request) Failing after 3m28s
PR / Build (pull_request) Successful in 6m27s
1aec3342ba
npm 11 (Node 26 locally) and npm 10 (Node 22 in CI) treat optional
peer deps differently in lockfiles. npm 11 deduped `@swc/helpers`
to the top-level 0.5.15, npm 10 expects `@swc/helpers@0.5.23` (the
peer-satisfying version under next-intl's nested @swc/core) to be
present and fails `npm ci` with EUSAGE.

Regenerating with `npx npm@10 install --package-lock-only` adds
that nested entry. Other diffs are libc-field normalization
between the two npm versions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

📊 Test coverage

Patch coverage: 37.4% (49/131 added lines) ⚠️ (soft target ≥ 80%)

Overall (app/, lib/, db/, excluding UI per ADR-0019):

Metric Value Soft target
Lines 85.5% ≥ 50%
Branches 81.2% ≥ 75%
Functions 90.0% informational

Changed files in this PR (source only — tests excluded):

File Patch coverage Overall lines Branches
app/(app)/components/placeholder.tsx 0.0% (0/8) 0.0% 0.0%
app/(app)/components/top-nav.tsx 0.0% (0/12) 0.0% 0.0%
lib/dto/auth.ts 0.0% (0/10) 0.0% 0.0%
lib/i18n/locales.ts 92.5% (49/53) 92.5% 94.3%
lib/i18n/request.ts 0.0% (0/31) 0.0% 0.0%
lib/i18n/set-locale-action.ts 0.0% (0/17) 0.0% 0.0%

Soft thresholds per ADR-0019. Coverage is informational and does not block merge.

<!-- coverage-comment --> ## 📊 Test coverage **Patch coverage:** 37.4% (49/131 added lines) ⚠️ *(soft target ≥ 80%)* **Overall** (`app/`, `lib/`, `db/`, excluding UI per ADR-0019): | Metric | Value | Soft target | |---|---|---| | Lines | 85.5% ✅ | ≥ 50% | | Branches | 81.2% ✅ | ≥ 75% | | Functions | 90.0% | informational | **Changed files in this PR** (source only — tests excluded): | File | Patch coverage | Overall lines | Branches | |---|---|---|---| | `app/(app)/components/placeholder.tsx` | 0.0% (0/8) | 0.0% | 0.0% | | `app/(app)/components/top-nav.tsx` | 0.0% (0/12) | 0.0% | 0.0% | | `lib/dto/auth.ts` | 0.0% (0/10) | 0.0% | 0.0% | | `lib/i18n/locales.ts` | 92.5% (49/53) | 92.5% | 94.3% | | `lib/i18n/request.ts` | 0.0% (0/31) | 0.0% | 0.0% | | `lib/i18n/set-locale-action.ts` | 0.0% (0/17) | 0.0% | 0.0% | Soft thresholds per [ADR-0019](docs/adr/0019-coverage-soft-targets.md). Coverage is informational and does not block merge.
james merged commit 8c96b92f00 into main 2026-06-20 01:00:43 +00:00
james deleted branch 146-i18n 2026-06-20 01:00:43 +00:00
Sign in to join this conversation.
No description provided.