Unified plan synthesized from two independent research efforts and framework documentation research. Date: 2026-02-08
Migrate the Harvey frontend from i18next (runtime JSON loading via HTTP backend) to Lingui v5 (compile-time PO catalogs via Vite plugin). The migration covers the main app, Word Add-In, and Outlook Add-In. It is phased into atomic PRs grouped by product area for reviewability.
Current state:
- 26 i18next namespaces across 7 locales (en-US only has content; others are placeholders)
- 4,109 already-migrated i18next
t()/<Trans>call sites - 3,324 unlocalized JSX strings (1,266 customer-facing, 1,855 customer-admin, 203 internal)
- 4,586 catalog entries in
public/locales/en-US/ - No explicit
<I18nextProvider>— i18next installs globally viainitReactI18nextplugin - Add-ins (Word/Outlook) alias
i18nto the main app's config, sharing the same runtime
Target state:
- Single PO catalog per locale, compiled to JS by Vite plugin
- Source-string-as-ID mode (English source text = msgid in PO), with
contextfor disambiguation - Per-locale code splitting via dynamic
import()(only active locale loaded) - Smartling TMS integration via
@lingui/format-po-gettext
| Decision | Choice | Rationale |
|---|---|---|
| Catalog format | PO files via @lingui/format-po-gettext |
Smartling-native; critical: Smartling does NOT support ICU MessageFormat in PO files, so format-po-gettext (which converts plurals to native gettext format) is required instead of format-po |
| Catalog organization | Single .po file per locale |
Simpler tooling; ID prefixes provide logical namespacing |
| String IDs | Source-string-as-ID (Lingui default) | English source text IS the msgid; context (PO msgctxt) for disambiguation. Simpler migration, better translator UX, Smartling TM leverage. Changed from explicit keys after Phase 2a implementation. |
| ID structure | N/A — source string is the ID | No key construction needed; Lingui babel macro generates hash-based runtime IDs from source text |
| Vite plugin | Babel (@lingui/babel-plugin-lingui-macro) |
Matches current @vitejs/plugin-react; zero-risk additive change |
| Pseudo-localization | Yes — Lingui pseudoLocale config |
Built-in support, replaces i18next-pseudo |
| Word/Outlook Add-Ins | Migrated together, phased | Same i18n stack, alias to main config |
| TMS | Smartling | PO with gettext plurals is a native Smartling format |
| Migration approach | Direct migration by product area | No compatibility layer — research confirms direct migration is strongly preferred over shims |
| Coexistence | Lingui <I18nProvider> added alongside i18next's global plugin during migration |
English strings always render correctly; remove i18next after all call sites migrated |
Lingui macros (t, Trans, Plural, msg) need a compile-time transform. Two options exist:
Babel (@lingui/babel-plugin-lingui-macro) |
SWC (@lingui/swc-plugin) |
|
|---|---|---|
| Vite plugin | @vitejs/plugin-react (current) |
@vitejs/plugin-react-swc (different package) |
| Build speed | Slower (JS-based) | ~2-5x faster transforms |
| Maturity | Battle-tested | Newer, occasionally lags Lingui releases |
| Config change | Add one line to Babel config | Swap plugin-react → plugin-react-swc |
| Risk | None — additive | Medium — changes entire JSX transform pipeline |
Start with Babel. Consider SWC as a separate optimization PR later.
Smartling's ICU MessageFormat support is limited to JSON, Java Properties, Android XML, and YAML. PO files do not support ICU syntax in Smartling's translator UI. This means:
- Use
@lingui/format-po-gettext(not@lingui/format-po) - This formatter converts Lingui's ICU plural syntax into native gettext
msgid_plural/msgstr[N]entries - Smartling handles native gettext plurals natively in its translator UI
- Limitation:
select,selectOrdinal, and nested/multiple plurals in a single message are NOT supported in gettext format. If needed, those would require a JSON format upload to Smartling separately - Set
mergePlurals: truein the formatter config to avoid duplicate msgid issues - Set
lineNumbers: falseto reduce diff churn when source code moves
The codex team built a TypeScript AST scanner (scan-strings.ts) that walked every .ts/.tsx file across src/, word-add-in/src/, and outlook-add-in/src/. Results:
| Category | Count |
|---|---|
Already migrated to i18next (t(), <Trans>, i18n.t()) |
4,109 |
| Unlocalized JSX strings | 3,324 |
| — Customer-facing | 1,266 |
| — Customer-admin (settings, roles, admin) | 1,855 |
| — Internal (dev tools, debug) | 203 |
Catalog entries (public/locales/en-US/) |
4,586 |
Unlocalized by kind:
| Kind | Count |
|---|---|
jsx-text (plain text in JSX) |
2,027 |
jsx-attr (user-facing attributes: aria-label, title, placeholder, etc.) |
840 |
jsx-template (template literals in JSX) |
451 |
jsx-expr-string (string expressions in JSX) |
6 |
Full inventories:
string-inventory-unlocalized.md,string-inventory-migrated.md(i18next usage),string-catalog-en-us.mdinCode/Docs/lingui-migration-codex/
Scope: package.json, vite.config.ts, word-add-in/vite.config.mts, outlook-add-in/vite.config.mts, lingui.config.ts
-
Install packages:
# Runtime yarn add @lingui/core @lingui/react @lingui/macro @lingui/detect-locale # Build tools yarn add -D @lingui/cli @lingui/vite-plugin @lingui/format-po-gettext @lingui/babel-plugin-lingui-macro # ESLint yarn add -D eslint-plugin-lingui
-
Create
lingui.config.tsat repo root:import { defineConfig } from "@lingui/cli" import { formatter } from "@lingui/format-po-gettext" export default defineConfig({ locales: ["en-US", "en-AU", "en-CA", "en-GB", "de-DE", "es-ES", "es-MX"], sourceLocale: "en-US", pseudoLocale: "pseudo", fallbackLocales: { default: "en-US", pseudo: "en-US", }, catalogs: [ { path: "<rootDir>/locales/{locale}/messages", include: ["src", "word-add-in/src", "outlook-add-in/src"], }, ], format: formatter({ origins: true, lineNumbers: false, mergePlurals: true, }), })
-
Update
vite.config.ts(main app):import { lingui } from "@lingui/vite-plugin" // Update react() call: react({ babel: { plugins: ["@lingui/babel-plugin-lingui-macro"], }, }), lingui(),
-
Update
word-add-in/vite.config.mtsandoutlook-add-in/vite.config.mts(same pattern — add Babel plugin + Lingui Vite plugin). Both add-ins currently aliasi18nto the main frontend's path; they'll need to also alias the Lingui locale path. -
Add npm scripts to root
package.json:{ "i18n:extract": "lingui extract", "i18n:extract:clean": "lingui extract --clean", "i18n:compile": "lingui compile --strict", "i18n:compile:pseudo": "lingui compile --locale pseudo" } -
Gitignore: Add compiled
.jsoutput fromlocales/to.gitignore. Keep.pofiles tracked. -
Run
lingui extractto generate initial empty PO scaffold.
Commit policy: Each Phase 0 sub‑phase (0a, 0b, 0c) must be a single commit for later cherry‑pick PRs.
Scope: src/i18n/, src/index.tsx, src/app.tsx, language selector
Key architectural note: There is no <I18nextProvider> in the current app — i18next installs itself globally via i18n.use(initReactI18next). Lingui requires an explicit <I18nProvider> wrapper.
-
Create
src/i18n/lingui-setup.ts:import { i18n } from "@lingui/core" import { detect, fromStorage, fromNavigator } from "@lingui/detect-locale" export const defaultLocale = "en-US" export const isPseudoLocaleEnabled = import.meta.env.VITE_LOCALE === "pseudo" export async function activateLingui(locale: string) { const target = isPseudoLocaleEnabled ? "pseudo" : locale // Vite plugin compiles .po on import — no separate compile step needed in dev const { messages } = await import(`../../locales/${target}/messages.po`) i18n.load(target, messages) i18n.activate(target) } export function detectLocale(): string { return detect( fromStorage("appLanguage"), fromNavigator(), () => defaultLocale ) || defaultLocale }
-
Wrap app with
<I18nProvider>insrc/index.tsx:import { I18nProvider } from "@lingui/react" import { i18n } from "@lingui/core" // Add I18nProvider wrapping <App /> <I18nProvider i18n={i18n}> <App /> </I18nProvider>
-
Sync language changes in
src/app.tsx: After the existingchangeAppLanguage()call that updates i18next, also callactivateLingui():// In the language initialization logic: await changeAppLanguage(appLanguage) // existing i18next call await activateLingui(appLanguage) // new Lingui call
-
Language & Locale Handling (during coexistence)
Recommended order during migration:- Add Lingui activation before first render (prevents flicker).
- Sync runtime language changes (app startup + profile updates).
- Persist locale changes to storage (so detection works after i18next removal).
- Update telemetry tags once Lingui is the source of truth.
- Add‑ins activate Lingui when they are migrated.
Tasks:
- Activate before first render: Ensure
activateLingui()runs before the initial render (or inside the earliest bootstrap path) to avoid locale flicker on loading/auth screens. - Sync profile updates in
src/components/common/user-profile-store.tsx: After updating the user’sappLanguage, also callactivateLingui(profileData.appLanguage)so Lingui updates immediately when a user changes language in settings. - Persist locale for detection: Use the same storage key for both i18next and Lingui (e.g.,
appLanguageinlocalStorage) so@lingui/detect-localeresolves the user’s saved preference on app load. - Persist locale on change: When the user changes language, explicitly write the new locale to storage (e.g.,
localStorage.setItem('appLanguage', locale)), since i18next’s detector will be removed later. - Telemetry tags update: Replace any
i18n.languageusage in Sentry/Datadog tags with Lingui’si18n.localeafter Lingui becomes the source of truth. - Add-ins must also activate: Word/Outlook add-ins should call
activateLingui()on startup and when the stored language changes (do this when each add‑in is migrated).
-
Update
yarn start-pseudoinpackage.json:"start-pseudo": "ESLINT_NO_DEV_ERRORS=true PSEUDO_LOCALE=true VITE_LOCALE=pseudo vite"
-
Create
src/i18n/lingui-test-setup.ts(mirrorssrc/test/i18n-test-setup.tsx):import { i18n } from "@lingui/core" import { I18nProvider } from "@lingui/react" import { messages } from "../../locales/en-US/messages.po" i18n.load("en-US", messages) i18n.activate("en-US") export function LinguiTestWrapper({ children }) { return <I18nProvider i18n={i18n}>{children}</I18nProvider> }
Phase 0 Verification (before Phase 1):
- App boots with
<I18nProvider>and no runtime errors. VITE_LOCALE=pseudo PSEUDO_LOCALE=false yarn startshows pseudo‑localized text for any Lingui‑migrated strings (if any exist yet).yarn i18n:extractandyarn i18n:compile --strictcomplete successfully.
Scope: eslint.config.mts, src/eslint-plugins/
-
Add
eslint-plugin-linguirecommended rules (flat config):import pluginLingui from "eslint-plugin-lingui" pluginLingui.configs["flat/recommended"],
-
Add
lingui/no-unlocalized-stringsatwarnlevel for progressive enforcement:{ files: ["src/components/**/*.{ts,tsx}"], ignores: ["**/*.test.{ts,tsx}", "**/*.stories.{ts,tsx}", "**/*.cy.{ts,tsx}"], rules: { "lingui/no-unlocalized-strings": ["warn", { ignore: ["^[A-Z_]+$", "^\\d+$", "^[\\s.,;:!?]+$"], ignoreNames: [ "className", "styleName", "type", "key", "id", "data-testid", "href", "src", "alt", "name", "role", "testId", "to", ], ignoreFunctions: ["console.*", "*.debug", "*.log", "require", "Error"], }], }, }
-
Plan for
harvey/translation-key-format: This custom rule readspublic/locales/en-US/*.jsonat startup and validatest()keys exist. During migration it continues validating i18next calls. Once all call sites are migrated, it gets replaced bylingui extractdrift detection in CI. No changes needed in Phase 0.
Status: SUPERSEDED. Phase 1 was completed but is now obsolete. The explicit-key PO catalogs it produced were replaced by source-string-as-ID catalogs generated via
lingui extract --cleanin Phase 2a. The conversion script (scripts/convert-i18next-to-lingui.ts) and key map (scripts/i18next-to-lingui-key-map.json) have been deleted.
Write a one-time conversion script (scripts/convert-i18next-to-lingui.ts) that:
- Reads all 26 JSON files from
public/locales/en-US/ - Flattens nested keys with dot notation (
{"actions": {"add": "Add"}}→vault.actions.add) - Prefixes each key with its namespace (filename without
.json) - Converts i18next interpolation:
{{variable}}→{variable} - Converts plural suffixes (
_one,_other,_zero,_few,_many,_two) into ICU plural messages, whichformat-po-gettextwill then render as native gettext plurals:file_count_one/file_count_other→file_countwith{count, plural, one {# file} other {# files}}
- Flags
<Trans>markup entries (those with<tag>placeholders) as requiring manual review — Lingui uses<0>/<1>indexed placeholders - Outputs
locales/en-US/messages.poin gettext format - Outputs
scripts/i18next-to-lingui-key-map.jsonmapping oldnamespace:key→ newnamespace.keyfor reference
Example transform:
# Input: vault.json
{ "query_row": { "status_paused": "Paused" } }
# Output: messages.po
#. migrated from vault.json
msgid "vault.query_row.status_paused"
msgstr "Paused"
Run lingui compile to verify the generated PO produces valid output. Run lingui extract to merge with any new strings added since the scan.
The scanner script from the codex effort (scan-strings.ts) can be re-run before this step to get a fresh inventory. -- located in Code/Docs/Plans/lingui-migration-codex
Commit policy: Phase 1 should be a single commit for later cherry‑pick PRs.
Phase 1 Verification (before Phase 2):
yarn i18n:compile --strictsucceeds with the converted catalog.- Spot‑check a few migrated keys in
locales/en-US/messages.pofor correct IDs and plural conversion. VITE_LOCALE=pseudo PSEUDO_LOCALE=false yarn startstill boots (Lingui active) and no runtime errors occur from catalog loading.
Each PR converts components from useXxxTranslation() + t('key') to useLingui() + Lingui macros. Grouped by product area so domain experts can review.
Standardize on Lingui macros (@lingui/macro). Use runtime i18n._(msg) only for non‑React utilities where macros cannot be used.
Before (i18next):
import { useVaultTranslation } from '../hooks/use-vault-translation'
function MyComponent() {
const { t } = useVaultTranslation()
return <h1>{t('query_row.status_paused')}</h1>
}After (Lingui, source-string-as-ID macros):
import { t } from '@lingui/core/macro'
function MyComponent() {
return <h1>{t`Paused`}</h1>
}For JSX with embedded components:
// Before
<Trans i18nKey="vault.upload_count" count={n}>
Uploading <strong>{{count: n}}</strong> files
</Trans>
// After
import { Trans } from "@lingui/react/macro"
<Trans>
Uploading <strong>{n}</strong> files
</Trans>For constants/utils outside React components:
// Before: t('vault.actions.upload') in a constant
// After: use msg for lazy definition
import { msg } from "@lingui/core/macro"
const label = msg`Upload`
// Translate inside a component: i18n._(label)
// For msg() with ICU placeholders, keep object form:
const errorMsg = msg({ message: 'Failed to initialize the {name} file picker.' })
// Translate: i18n._({ ...errorMsg, values: { name } })For i18n.language reads:
// Before: i18n.language
// After: i18n.localeTranslator comment policy (apply during migration):
- Required for short/ambiguous strings, multi-meaning words, placeholders, legal/compliance text, and rich text with components.
- Optional for clear full sentences (the source text itself provides context).
- Use one-sentence comments describing UI surface and variable meaning.
| PR | Area | Migrated Call Sites (est.) | Unlocalized Strings (est.) | Reviewer Team | Status |
|---|---|---|---|---|---|
| 2a | Common/UI | ~300 | ~200 (actual: ~38 remain) | Platform | DONE — 38 unlocalized strings in 7 files (see audit below) |
| 2b | Vault | ~800 | ~150 (actual: 0 remain) | Vault | DONE — fully localized |
| 2c | Assistant | ~600 | ~250 (actual: 0 remain) | Assistant | DONE — fully localized |
| 2d | Workflows + WF Builder | ~700 | ~150 (actual: 0 remain) | Workflows | DONE — fully localized |
| 2e | Word Add-In | ~600 | ~100 (actual: 0 remain) | Word | DONE — 168 files changed, 154 source + 14 test files migrated, useWordTranslation deleted. PO catalogs: 2648 messages. |
| 2f | Outlook Add-In | ~150 | ~30 | Word | DONE — 33 files changed (20 source + 4 test), useOutlookTranslation deleted. PO catalogs: 2710 messages. |
| 2g | Settings | ~400 | ~300 | Platform | DONE — 169 files changed, 8 hooks deleted (useProfileTranslation, useExportFormatsTranslation, usePermsTranslation, useGroupsTranslation, useExternalConnectionsTranslation, useWorkspaceTranslation, usePlaybookTranslation, useIntegrationsTranslation). Refactored getResourceTypeLabel to Lingui t macro. 3 fragmented sentences → Trans components. ManageIntegrationInfoItem labels → msg descriptors. PO catalogs: 3355 messages. |
| 2h | Library | ~150 | ~30 | Library | DONE — 36 files changed (28 source + 8 PO), useLibraryTranslation deleted. Converted FilterOption.labelKey → MessageDescriptor, TAB_CONFIG.labelKey → MessageDescriptor. Removed TFunction param from savePrompt() and getPromptsTableColumns(). Dynamic key patterns → static maps. 4 Trans components converted. PO catalogs: 3480 messages. |
| 2i | Client Matters | ~100 | ~30 | Matters | DONE — 28 files changed (17 source + 2 test + 8 PO + 1 deleted hook), useMattersTranslation deleted. Dynamic connection key pattern → msg descriptor map (CONNECTION_LABEL). 1 fragmented sentence → Trans component (word-popover.tsx). Zod schema validation with t macro left as bounded debt. PO catalogs: 3534 messages. |
| 2j | Spaces & Sharing | ~150 | ~20 | Spaces | DONE — 54 files changed (42 source + 1 test + 3 deleted hooks + 8 PO), 4 hooks deleted (useSpacesTranslation, useAssistantTranslation, useCommonTranslation, useSharingTranslation). guest-legal-interstitial.tsx deferred edge case resolved (dynamic i18nKey → static Lingui Trans). Nested t macros extracted to variables (delete-space-dialog, leave-space-dialog). Fragmented sentences consolidated (playbook-item-card, workflow-item-card). Hardcoded fallbacks localized (shareable-resource-card). PO catalogs: 3650 messages. |
| 2k | Remaining (history, sidebar, office) | ~150 | ~50 | Platform | DONE — 55 files changed (44 source + 3 deleted hooks + 8 PO), 3 hooks deleted (useHistoryTranslation, useOfficeTranslation, useDevAuthInspectorTranslation). Migrated 13 history files, 28 office files, 1 sidebar file, 1 dev-auth-inspector file. Refactored TFunction parameter in dev-auth-inspector getRemoveBundleDescription(). Fixed pre-existing nested t macro in playbook-card.tsx. Updated 4 ESLint rule messages to reference Lingui. Converted react-i18next Trans+Trans i18nKey → Lingui Trans+Plural (history-bulk-delete-dialog.tsx). Migrated direct useTranslation usage in client-matter-select-controller-v2.tsx. PO catalogs: 3753 messages. |
| 2k-fix | Dev-auth-inspector unlocalize | 0 | 0 | Platform | DONE — Reverted dev-auth-inspector.tsx to plain strings (internal-only tool, no i18n needed). Removed @lingui/core/macro import, replaced ~30 t`` calls with string literals/template literals. PO catalogs unchanged at 3753 (all strings shared with other components). |
| 2l | Laggard strings sweep | 0 | ~270 | All | IN PROGRESS — Full codebase audit found ~540 unlocalized user-facing strings. Excluding internal admin pages (~270), localizing the remaining ~270 strings across workflows, vault, assistant, common, dashboard, client-matters, and customer-facing settings pages. See unlocalized-strings-audit.md for full inventory. |
Commit policy: Each sub‑phase (e.g., 2a, 2b, …) must be a single commit. We will cherry‑pick these commits later to form individual PRs.
Each PR:
- Converts all
useXxxTranslation()→useLingui()in the target area - Converts all
t('key')→t`English text`(source-string-as-ID) - Converts any
<Trans i18nKey=...>to Lingui<Trans>(no id needed) - Converts
i18n.language→i18n.locale - Removes or deprecates the old
useXxxTranslationhook file - Addresses unlocalized strings flagged by the scan (customer-facing ones; customer-admin can be a follow-up)
- Runs
lingui extractto verify all new IDs appear in the PO catalog - Escalates ESLint
lingui/no-unlocalized-stringstoerrorfor that area's file paths
Known Gotchas (learned from Phase 2a):
- Multi-line explicit-ID patterns: Single-line grep
t\(\{\s*id:misses cases wheret({andid:are on separate lines. Always use multiline regex (e.g.rg -U 't\(\{[\s\S]*?id:') to catch all explicit-ID calls during verification. i18n._()spread crashes extractor:i18n._({ ...descriptor, values: { x } })crashesbabel-plugin-extract-messagesbecause the extractor can't handleSpreadElementAST nodes. Usei18n._(descriptor, { x })instead (pass values as the second argument).msg()with ICU placeholders: Must use object formmsg({ message: 'Text {name}' })— tagged templatemsg`Text {name}`treats{name}as a literal JS template expression, not an ICU placeholder.
Verification (per PR, manual + scripted):
- Static code check: No
react-i18nextimports oruseXxxTranslation()hooks remain in the migrated area.- IMPORTANT: Also verify no explicit-ID Lingui calls remain using multiline grep:
rg -U 't\(\{[\s\S]*?id:' src/components/<area>/andrg -U 'msg\(\{[\s\S]*?id:' src/components/<area>/
- IMPORTANT: Also verify no explicit-ID Lingui calls remain using multiline grep:
- Catalog check:
yarn i18n:extract+yarn i18n:compile --strict(new IDs appear inlocales/en-US/messages.po). - Manual UI check with pseudo locales:
- Lingui pseudo only:
VITE_LOCALE=pseudo PSEUDO_LOCALE=false yarn start
Expect only Lingui-migrated strings to render pseudo‑localized. - i18next pseudo only:
VITE_LOCALE= PSEUDO_LOCALE=true yarn start
Expect only remaining i18next strings to render pseudo‑localized. - Both pseudo (optional):
VITE_LOCALE=pseudo PSEUDO_LOCALE=true yarn start
Expect all strings to be pseudo‑localized.
- Lingui pseudo only:
- Regression check: navigate the migrated surfaces (Common/UI, Vault, etc.) and confirm no regressions in labels, placeholders, tooltips, or empty states.
These paths contain internal-only tools and should have ESLint ignores:
src/components/settings/internal-admin/**
src/components/settings/experiment/**
src/components/settings/vault-inspector/**
src/components/settings/vault-review-jobs/**
src/components/settings/vault-testing/**
src/components/settings/file-upload-debugger/**
src/components/settings/document-debugger/**
src/components/settings/incident-management/**
src/components/settings/pwc/**
src/components/settings/user-management/**
src/components/settings/permissions/**
src/components/settings/models/**
src/components/settings/library/settings-library-events-manager.tsx
src/components/settings/workspace/workspace-details/workspace-state-management.tsx
src/components/settings/workspace/workspace-details/workspace-perm-migration-status.tsx
src/components/research/**
src/components/common/dev-auth-inspector/**
Copy this section (or reference it) when handing off a Phase 2 sub-phase to another agent. It is a self-contained checklist of everything an agent needs to know to correctly execute a migration phase.
You are migrating a section of the Harvey frontend from i18next to Lingui v5 (source-string-as-ID mode). The codebase is a React + TypeScript + Vite app. Lingui is already installed, configured, and has an active <I18nProvider> in the component tree. i18next is still active in parallel for unmigrated areas.
Your phase target area: [FILL IN: e.g., src/components/vault/]
Hooks to migrate: [FILL IN: e.g., useVaultTranslation, useVaultReviewTranslation]
JSON namespace files (for lookup): [FILL IN: e.g., public/locales/en-US/vault.json, public/locales/en-US/vault_review.json]
Find every file that imports the target translation hooks:
rg "useXxxTranslation" src/components/<area>/ --files-with-matchesAlso check for direct react-i18next imports (useTranslation, Trans, t from i18next) in the target area.
Do not partially migrate a file. Every file you touch must be fully converted — no mixing of i18next and Lingui calls in the same file when you're done.
For each file:
-
Remove the old hook import and call:
// DELETE these: import { useVaultTranslation } from 'components/vault/hooks/use-vault-translation' const { t } = useVaultTranslation()
-
Add the Lingui macro import (third-party section, NOT where the old hook was):
import { t } from '@lingui/core/macro' // or, if you also need plural/msg/Trans: import { msg, plural, t } from '@lingui/core/macro' import { Trans } from '@lingui/react/macro'
@linguiimports go in the third-party import section (alongside React, lodash, zustand, etc.), NOT in the local components section. -
Convert every
t('key')call tot`English text`:- Look up the key in the JSON namespace file to get the English text
- Simple:
t('key')→t`English text` - Interpolation:
t('key', { name })→t`Hello ${name}` - Ensure variables are
string | number, notundefined. Add?? ''or?? 0fallbacks if needed.
-
Convert plurals:
// Before: t('key', { count }) // where JSON has "key_one" / "key_other" or uses i18next plural syntax // After: import { plural } from '@lingui/core/macro' plural(count, { one: '# file', other: '# files' })
- Always use
#as the count placeholder in plural branches, NEVER${count} plural()returns a string directly — do NOT wrap it int``- Look at the JSON namespace for the
_one,_other,_zerosuffixed keys to get the plural forms
- Always use
-
Convert
<Trans>components:// Before: import { Trans } from 'react-i18next' <Trans i18nKey="vault.upload_count" count={n}> Uploading <strong>{{count: n}}</strong> files </Trans> // After: import { Trans } from '@lingui/react/macro' <Trans> Uploading <strong>{n}</strong> files </Trans>
-
Convert constants/static message descriptors:
// Before: t('key') in a module-level constant or array // After: import { msg } from '@lingui/core/macro' const label = msg`English text` // With ICU placeholders — MUST use object form: const err = msg({ message: 'Failed to load {name}' }) // To render at runtime: import { useLingui } from '@lingui/react' const { i18n } = useLingui() // or for non-React: import { i18n } from '@lingui/core' i18n._(label) i18n._({ ...err, values: { name } })
-
Convert
i18n.languagereads toi18n.locale.
While you have each file open, wrap any hardcoded user-facing strings you find:
- Button labels, titles, headings, descriptions, tooltips, placeholder text, error messages, aria-labels
- Use
t`Text`for inline strings,msgText`` for constants/descriptors - Skip: CSS class names, data-testid values, enum values, URLs, console.log messages, Error() messages, technical identifiers
For every test file associated with a migrated component:
-
Remove translation-related mocks:
// DELETE mocks like: vi.mock('components/vault/hooks/use-vault-translation', () => ({ useVaultTranslation: () => ({ t: (key: string) => key }), }))
-
Update assertions to use English text instead of translation keys:
// Before: expect(screen.getByText('vault.upload_files')).toBeInTheDocument() // After: expect(screen.getByText('Upload files')).toBeInTheDocument()
-
Remove unused translation imports from test files.
After migrating all files, check if any consumers of the hook remain outside your target area:
rg "useXxxTranslation" src/ --files-with-matchesIf zero results, delete the hook definition file (e.g., use-vault-translation.ts). If external consumers remain, leave it and note them for a future phase.
yarn i18n:extractThis updates all locales/*/messages.po files with new source-string msgids. Commit the PO catalog changes alongside the source changes.
| Pattern | Example |
|---|---|
| Simple string | t`Cancel` |
| Interpolation | t`Hello ${name}` (variable must be string|number) |
| Plural | plural(count, { one: '# item', other: '# items' }) |
| JSX with markup | <Trans>Click <strong>here</strong></Trans> |
| Static descriptor | msg`Upload` |
| Descriptor + ICU | msg({ message: 'Error with {name}' }) |
| Runtime render | i18n._(descriptor) or i18n._({ ...descriptor, values: { name } }) |
| Disambiguation | t({ message: 'Other', context: 'feedback.positive' }) |
| Dynamic key (rare) | i18n._(dynamicKey) — only when key is truly computed at runtime |
- Every call site in a touched file must be migrated. No partial migrations.
@linguiimports go in the third-party section, not where the old hook import was.- Variables in tagged templates must be
string | number, notundefined. Add?? ''fallback. - Plural branches use
#for the count, never${count}. plural()is standalone — do NOT nest it insidet``.msg()with ICU{name}placeholders must use object form — tagged template treats{name}as a JS expression.i18n._()with values: usei18n._({ ...descriptor, values: { x } })— spread the descriptor. Do NOT usei18n._(descriptor, { x })(wrong overload for TypeScript).- Do NOT use
tin React dependency arrays —tis a macro, not a React hook. - Run
lingui extractbefore committing to ensure PO catalogs are up to date. - Look up the actual English text from the JSON namespace file — do NOT guess or abbreviate. The English text must be exact.
- Zero imports of the old hook (
useXxxTranslation) remain in the target area - Zero
react-i18nextimports remain in the target area (unless a deferred edge case) - Zero explicit-ID Lingui calls:
rg -U 't\(\{[\s\S]*?id:' src/components/<area>/returns nothing -
yarn i18n:extractruns without errors - Test files updated: no translation mocks, assertions use English text
- All
@linguiimports are in the third-party section of each file - No
${count}in plural branches (use#instead) - No
undefinedvariables in tagged templates (add?? ''fallbacks)
dev-auth-inspector.tsx— usesTFunctiontype from i18next; migrate in cleanup phaseguest-legal-interstitial.tsx— uses<Trans>with dynamici18nKeyfrom i18next; needs special handlingfile-sources-dropdown-item.tsx— uses<Trans>with dynamici18nKey; deferred- Any file that builds i18next keys dynamically (string concatenation for keys) — flag for manual review
Scan of all files touched in Phases 2a–2c for hardcoded user-facing strings not yet wrapped in Lingui macros. This audit ensures step 6 of the PR checklist ("Addresses unlocalized strings flagged by the scan") is tracked.
| File | Strings | Details |
|---|---|---|
common/editor/components/editor-toolbar.tsx |
~20 | Button labels: "Bold", "Italic", "Underline", "Strikethrough", "Bulleted list", "Numbered list", "Blockquote", "Cut", "Copy", "Paste", "Undo", "Redo", "Heading 1/2/3", "Paragraph", "Change style", "Copied", conditional "Edit link"/"Add link" |
common/feedback/feedback-dialog.tsx |
4 | Dialog title "Give feedback for this response", subtitle "How can we improve? (optional)", button labels "Cancel", "Submit" |
common/flows/profile-setup/steps/profile-complete-step.tsx |
5 | "Creating your profile…", "Your profile is ready", "You can change your answers anytime…", "Back", "Continue to Harvey" |
common/flows/profile-setup/steps/profile-complete-later-step.tsx |
2 | "Back", "Continue to Harvey" |
common/sharing/resource-share-popover/resource-share-popover.tsx |
2 | Tooltip "Sharing is disabled", button "Share" |
common/sharing/share-popover.tsx |
4 | "Sharing is disabled", "Share", "Add users", "Grant {level} permission" / "Grant permission" |
settings/external-connections/external-connections-requests-page.tsx |
— | Still uses old i18next (Trans, useSharingTranslation, useExternalConnectionsTranslation). Not yet migrated to Lingui — belongs to future Phase 2g (Settings). |
ui/app-header.tsx |
1 | aria-label="Back" (screen-reader-facing) |
All 104 vault files scanned — zero unlocalized user-facing strings found. Every string is properly wrapped in t```, msg```, or plural().
All 85 assistant component files scanned — zero unlocalized user-facing strings found. Every string is properly wrapped.
| Phase | Files Scanned | Files with Gaps | Unlocalized Strings |
|---|---|---|---|
| 2a (Common/UI) | 56 | 8 | ~38 |
| 2b (Vault) | 104 | 0 | 0 |
| 2c (Assistant) | 85 | 0 | 0 |
| Total | 245 | 8 | ~38 |
Action items:
- editor-toolbar.tsx (20 strings) — Highest priority. All button
labelprops and tooltip text need `t```. - feedback-dialog.tsx (4 strings) — Dialog title/subtitle and button labels.
- profile-complete-step.tsx + profile-complete-later-step.tsx (7 strings) — Onboarding flow strings.
- resource-share-popover.tsx + share-popover.tsx (6 strings) — Sharing UI strings.
- app-header.tsx (1 string) — Single aria-label.
- external-connections-requests-page.tsx — Deferred to Phase 2g (Settings); still uses i18next hooks.
These should be addressed in a follow-up commit on the branch or included in the respective phase PRs before merge.
- Replace
I18nTestWrapperinsrc/test/i18n-test-setup.tsx:- During coexistence: Wrap with both i18next and Lingui providers
- After full migration: Use Lingui-only wrapper
- Update
getTranslationhelper to use Lingui'si18n._()instead of i18next'st() - Update
render-with-app.tsxto includeI18nProvider - Update Cypress helpers:
cypress/support/commands/component.tsxword-add-in/cypress/support/component.tsx
- Update Storybook to wrap stories with
I18nProvider
After all call sites are migrated and tests pass:
- Remove packages:
i18next,react-i18next,i18next-http-backend,i18next-browser-languagedetector,i18next-hmr,i18next-pseudo - Remove
src/i18n/config.ts(old i18next config) - Remove
initReactI18nextusage (i18next's global plugin installation) - Remove all
public/locales/JSON files and all 7 locale directories - Remove old ESLint plugins:
eslint-plugin-i18next,eslint-plugin-formatjs - Remove/replace
harvey/translation-key-formatcustom ESLint rule (replaced bylingui extractdrift detection in CI) - Remove
scripts/check-locales-json.js(no longer needed) - Update
navigator.languageban in ESLint to reference Lingui instead of i18next - Update all
use-*-translation.tshook files — these should already be removed in Phase 2 PRs - Update documentation:
docs/i18n-migration-guide.md,AGENTS.md,README.md - Update CODEOWNERS — change
public/locales/**paths tolocales/**PO file paths
- Add
lingui extract --clean+git diff --exit-codeto CI to detect catalog drift:- name: Check i18n catalog drift run: | yarn lingui extract --clean if ! git diff --quiet locales/; then echo "::error::Catalogs are out of date. Run 'yarn i18n:extract:clean' and commit." exit 1 fi
- Add
lingui compile --strictas a validation step (catches missing translations) - Verify per-locale bundle splitting (only active locale chunk loaded)
- Measure bundle size delta vs i18next
- Configure Smartling project for PO/Gettext file type
- Upload
locales/en-US/messages.poas source - Test round-trip: upload source → translate a few strings → download translated PO → verify
lingui compileworks - Set up Smartling webhook or CI step for automated uploads on merge to main
Date and time formatting is a separate concern from string localization but equally important for a fully internationalized app. This phase should begin after Phase 4 (i18next removal) is complete, or can run in parallel.
The codebase currently formats dates and times using a mix of approaches:
date-fns/formatanddate-fns/formatDistance— most common, hardcoded to'en-US'locale or using no locale at all (defaults to English)Intl.DateTimeFormat— used in some places, sometimes with hardcoded localetoLocaleDateString()/toLocaleTimeString()— scattered usage, sometimes without locale parameterdayjs— used in a few places (calendar components)- Hardcoded date format strings — e.g.,
'MMM d, yyyy','h:mm a','MM/dd/yyyy'
- Locale-aware formatting: Dates should respect the user's locale (e.g.,
02/10/2026in en-US vs10.02.2026in de-DE) - Relative time: "2 hours ago", "yesterday", "last week" need locale-aware formatting
- Calendar components: Date pickers and calendar views need locale-aware first-day-of-week, month names, etc.
- Timezone display: Timezone names should render in the user's language where possible
- Number formatting: Thousands separators, decimal marks vary by locale (1,000.50 vs 1.000,50)
Use the browser's built-in Intl APIs (which are locale-aware by default) combined with Lingui's i18n.locale for the active locale.
Utility module (src/utils/date-format.ts):
import { i18n } from '@lingui/core'
export function formatDate(date: Date | string | number, options?: Intl.DateTimeFormatOptions): string {
const d = new Date(date)
return new Intl.DateTimeFormat(i18n.locale, {
year: 'numeric',
month: 'short',
day: 'numeric',
...options,
}).format(d)
}
export function formatDateTime(date: Date | string | number, options?: Intl.DateTimeFormatOptions): string {
const d = new Date(date)
return new Intl.DateTimeFormat(i18n.locale, {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
...options,
}).format(d)
}
export function formatRelativeTime(date: Date | string | number): string {
const d = new Date(date)
const now = new Date()
const diffMs = now.getTime() - d.getTime()
const diffSec = Math.round(diffMs / 1000)
const diffMin = Math.round(diffSec / 60)
const diffHr = Math.round(diffMin / 60)
const diffDay = Math.round(diffHr / 24)
const rtf = new Intl.RelativeTimeFormat(i18n.locale, { numeric: 'auto' })
if (Math.abs(diffSec) < 60) return rtf.format(-diffSec, 'second')
if (Math.abs(diffMin) < 60) return rtf.format(-diffMin, 'minute')
if (Math.abs(diffHr) < 24) return rtf.format(-diffHr, 'hour')
if (Math.abs(diffDay) < 30) return rtf.format(-diffDay, 'day')
if (Math.abs(diffDay) < 365) return rtf.format(-Math.round(diffDay / 30), 'month')
return rtf.format(-Math.round(diffDay / 365), 'year')
}
export function formatNumber(value: number, options?: Intl.NumberFormatOptions): string {
return new Intl.NumberFormat(i18n.locale, options).format(value)
}Advantages:
- Zero additional dependencies (Intl is built into all modern browsers)
- Automatically correct for every locale the browser supports
- Reads
i18n.localeat call time — no stale locale issues - Consistent with Lingui's locale management
Disadvantages:
Intl.RelativeTimeFormatrequires manual time-unit bucketing (shown above)- Some older Safari versions may lack
Intl.RelativeTimeFormat(polyfillable)
Keep date-fns but pass the correct locale object.
import { i18n } from '@lingui/core'
import { format, formatDistance } from 'date-fns'
import { enUS, deDE, esES, enGB, enAU, enCA, es as esMX } from 'date-fns/locale'
const DATE_FNS_LOCALES: Record<string, Locale> = {
'en-US': enUS,
'de-DE': deDE,
'es-ES': esES,
'en-GB': enGB,
'en-AU': enAU,
'en-CA': enCA,
'es-MX': esMX,
}
function getDateFnsLocale(): Locale {
return DATE_FNS_LOCALES[i18n.locale] ?? enUS
}
export function formatDate(date: Date, pattern: string): string {
return format(date, pattern, { locale: getDateFnsLocale() })
}
export function formatRelative(date: Date, baseDate: Date): string {
return formatDistance(date, baseDate, {
addSuffix: true,
locale: getDateFnsLocale(),
})
}Advantages:
- Minimal changes —
date-fnsis already used throughout the codebase - Richer relative time formatting out of the box
Disadvantages:
- Adds ~5-15KB per locale to the bundle (date-fns locale data)
- Must maintain a manual locale mapping
- Format patterns (e.g.,
'MMM d, yyyy') are still English-centric
- Create
src/utils/date-format.tswith locale-aware helpers (Option A or B above) - Add tests for each helper across locales
- Export a
formatNumberhelper for locale-aware number formatting
- Find all
date-fns/formatanddate-fns/formatDistanceusage:rg "from 'date-fns" src/ --files-with-matches rg "toLocaleDateString|toLocaleTimeString" src/ --files-with-matches rg "Intl\.DateTimeFormat" src/ --files-with-matches
- Replace with the new locale-aware utilities
- For
date-fns/formatcalls with hardcoded locale or no locale, update to use the new helper - For
Intl.DateTimeFormatcalls, ensure they usei18n.localeinstead of hardcoded'en-US'
- Update
react-day-picker(used for date range pickers) to use the correct locale - Update any dayjs calendar components to use locale-aware formatting
- Ensure first-day-of-week respects locale (Monday in most of Europe, Sunday in US)
- Create or extend
formatNumberutility for:- Decimal/thousands separators (1,000.50 vs 1.000,50)
- Percentage formatting
- Currency formatting (if applicable)
- File size formatting (locale-aware)
- Find and replace hardcoded
toLocaleString()andIntl.NumberFormatcalls
Run this scan to find all date formatting sites:
# date-fns usage
rg "from 'date-fns" src/ --files-with-matches | wc -l
# Intl.DateTimeFormat
rg "Intl\.(DateTimeFormat|RelativeTimeFormat|NumberFormat)" src/ --files-with-matches | wc -l
# toLocaleString / toLocaleDateString
rg "toLocale(Date|Time)?String" src/ --files-with-matches | wc -l
# Hardcoded format patterns
rg "'(MMM|yyyy|MM/dd|dd/MM|HH:mm|h:mm)" src/ --files-with-matches | wc -l- Unit tests: Test each formatting helper with multiple locales (en-US, de-DE, es-ES at minimum)
- Visual regression: Check date displays in the app with pseudo-locale enabled
- Edge cases: Verify timezone-sensitive displays, daylight saving transitions, midnight boundary dates
Three complementary approaches ensure complete coverage:
Already built and run. Produces machine-readable inventories of all i18next-migrated and unlocalized strings with file:line locations and classification (customer/customer-admin/internal). Re-run before each Phase 2 PR.
Catches hardcoded strings in JSX at lint time. Progressively escalated from warn to error per product area as migration completes.
After migration, lingui extract --clean + git diff in CI catches any macros that weren't captured in the catalog or any stale entries.
Internal admin strings (203 found by scanner) are explicitly excluded via ESLint ignores. The scanner's classification heuristic flags these as internal or customer-admin — review the internal bucket to confirm no customer-facing strings are misclassified.
| Risk | Mitigation |
|---|---|
| English strings break during migration | Source-string-as-ID means the English text IS the msgid — always renders correctly even if PO translation is missing |
| Smartling rejects PO format | Using format-po-gettext (not format-po) which produces native gettext syntax Smartling supports |
select/selectOrdinal not supported in gettext |
Review catalog for these patterns; if needed, use Smartling's JSON format for those specific strings |
| Bundle size increases during coexistence | Temporary; i18next removed in Phase 4. Both libraries are ~5-10KB gzipped |
No <I18nextProvider> to swap |
i18next uses global plugin; add <I18nProvider> alongside it in index.tsx |
| Add-ins alias i18n to main app | Update aliases to include Lingui locale paths; test independently |
Custom ESLint rule harvey/translation-key-format |
Continues working during migration (validates i18next calls); replaced in Phase 4 |
| Test failures | Dual-provider test wrapper during coexistence; update incrementally |
| Reviewer fatigue | Product-area PRs keep scope focused; ~5-25 files each |
| Missed strings | Three-pronged audit: AST scanner + ESLint + lingui extract diff |
<Trans> markup conversion |
Flag <tag> → <0> placeholder conversions for manual review |
Multi-line t({ id }) missed by grep |
Always use multiline regex (rg -U) for verification; single-line grep misses t({\n id: |
i18n._() spread crashes extractor |
Use i18n._(descriptor, values) instead of i18n._({ ...descriptor, values }) |
Phase 0 ─── PR 0a (packages + Vite) ─┬─ PR 0b (providers + language sync) ─┬─ Phase 2 (all PRs)
PR 0c (ESLint) │ │
└─ Phase 1 (JSON → PO conversion) ────┘
│
Phase 2 ─── 2a through 2k (parallelizable across engineers) ────────────────┘
│
Phase 3 ─── 3a (test infrastructure) ← depends on Phase 2 progress
│
Phase 4 ─── 4a (remove i18next) ← after ALL Phase 2 + 3 PRs merged
4b (CI pipeline)
4c (Smartling integration)
Phase 2 PRs are independent of each other and can be worked on in parallel.
| Document | Location | Description |
|---|---|---|
Code/Docs/lingui-migration-claude/01-string-id-guide.md |
||
| String Audit Methodology | Code/Docs/lingui-migration-claude/02-string-audit.md |
Three-pronged approach to finding all strings |
| Call Site Audit | Code/Docs/lingui-migration-claude/03-call-site-audit.md |
Every file requiring migration, by product area |
| AST Scanner Script | Code/Docs/lingui-migration-codex/scan-strings.ts |
TypeScript scanner for string discovery |
| Unlocalized String Inventory | Code/Docs/lingui-migration-codex/string-inventory-unlocalized.md |
3,324 unlocalized strings with file:line |
| Migrated String Inventory | Code/Docs/lingui-migration-codex/string-inventory-migrated.md |
4,109 already-migrated i18next t() calls |
| en-US Catalog Keys | Code/Docs/lingui-migration-codex/string-catalog-en-us.md |
4,586 flattened catalog entries |
| Scanner Methodology | Code/Docs/lingui-migration-codex/scan-strings.ts |
How the AST scanner classifies strings |
- Lingui Installation Guide
- Lingui Vite Plugin
- Lingui Explicit vs Generated IDs
- Lingui Dynamic Loading
- Lingui Pseudolocalization
- Lingui ESLint Plugin
- Lingui Testing Guide
- Smartling Gettext PO/POT
- Smartling ICU MessageFormat Limitations
- @lingui/format-po-gettext
Current TMS: Smartling — requires @lingui/format-po-gettext because Smartling does not parse ICU MessageFormat syntax inside PO files. It does support native gettext plurals (msgid_plural/msgstr[N]), which format-po-gettext produces.
If moving to Lokalise: Lokalise is significantly more flexible. It supports both native gettext plurals AND ICU plural formatting in PO exports (configurable via a "Plural format: ICU" dropdown on download). This means either @lingui/format-po-gettext or @lingui/format-po would work with Lokalise.
Orphaned plurals problem with gettext format: With format-po-gettext, plural messages are split into separate msgid_plural/msgstr[N] entries. Translators see each plural form as a disconnected field rather than parts of a single message. The semantic relationship between one, few, many, other is implicit in the gettext header, not visible in the string itself. With ICU-in-PO (Lokalise), the full {count, plural, one {...} other {...}} stays as one cohesive unit — the translator sees the entire plural structure together, making it harder to accidentally translate forms inconsistently. This is another reason to move to Lokalise.
Recommendation for now: @lingui/format-po-gettext (Smartling requirement), but plan to switch to @lingui/format-po (ICU) when Lokalise is adopted. The switch is a one-line config change + lingui compile re-run — no source code changes needed.
What we lose with gettext that Lokalise+ICU would restore:
- Plural forms as a single cohesive unit (not orphaned entries)
selectandselectOrdinalsupport (not currently needed, but future-proofed)- Nested/multi-variable plurals in a single message
- Standard ICU tooling ecosystem compatibility
The following changes were made during Phase 0-1 implementation that differ from or extend the original plan:
After completing Phase 2a with explicit IDs, we switched all migrated files to Lingui's default source-string-as-ID mode. This was a significant improvement that simplifies all remaining migration work.
What changed:
- All
t({ id: 'key', message: 'Text' })→t`Text`(tagged templates) - All
msg({ id: 'key', message: 'Text' })→msg`Text`(or object form for ICU placeholders) contextparam added for disambiguation where same English text needs different translations- Fixed
i18n._()calls to pass values as second arg:i18n._(descriptor, values)(spread crashes the Lingui extractor) - Deleted
scripts/i18next-to-lingui-key-map.jsonandscripts/convert-i18next-to-lingui.ts - All PO catalogs regenerated via
lingui extract --clean(256 source-string entries)
Why:
- PO expects source text as msgid — translators see real English, not opaque keys
- Eliminates named-vs-positional variable mismatch bugs
- Smartling TM can leverage real English text for auto-translation
- Dramatically simplifies remaining Phase 2b-2k: just write
t`English text`instead of constructing explicit IDs - No key construction, no variable aliasing, no PO cross-referencing
Migration pattern for remaining phases is now:
// Before (i18next)
const { t } = useVaultTranslation()
return <h1>{t('query_row.status_paused')}</h1>
// After (Lingui source-string-as-ID)
import { t } from '@lingui/core/macro'
return <h1>{t`Paused`}</h1>
// Interpolation
t`Hello ${name}` // variable must be string|number, use ?? '' for undefined
// Plurals (standalone, no wrapping t())
plural(count, { one: '# file', other: '# files' })
// Message descriptors for constants
const label = msg`Upload`
// With ICU placeholders — keep object form:
const err = msg({ message: 'Error with {name}.' })
// Disambiguation (rare — only when same English text needs different translations)
t({ message: 'Other', context: 'feedback.positive' })
// Runtime with descriptors (pass values as 2nd arg — spread crashes extractor)
i18n._(descriptor, { name })See phase-2a-correction.md for full details on this change.
- Original:
VITE_LOCALE=pseudo(checked viaimport.meta.env.VITE_LOCALE) - Actual:
LINGUI_PSEUDO_LOCALE=true(checked viaprocess.env.LINGUI_PSEUDO_LOCALE) - Reason: Matches the naming convention of the existing
PSEUDO_LOCALEenv var used by i18next. Added to Vite'ssetEnv/envPluginprefixes so it's available asprocess.env.*in client code.
- Original plan: Fire-and-forget
activateLingui()before render - Actual:
root.render()is called inside afinallyblock afterawait activateLingui(). This prevents a flash of untranslated content. If activation fails, Sentry captures the error and the app still renders.
- Not in original plan but necessary:
user-profile-store.tsxnow callsactivateLingui()andlocalStorage.setItem(localeStorageKey, ...)when the user changes their app language in profile settings. Without this, Lingui would stay on the old locale after a profile language change.
- Added
declare module '*.po'toreact-app-env.d.tsso PO imports have proper typing.
- Added to
lingui.config.tsformatter options. This tellsformat-po-gettextto collapse plural forms into standard gettext entries during extract/compile.
- Original plan had bare
warnlevel. Now includesignore,ignoreNames(className, type, key, href, etc.), andignoreFunctions(console.*, Error) to reduce false positives.
- Exported from
lingui-setup.tsand used indetectLocale()anduser-profile-store.tsxinstead of hardcoded'appLanguage'string.
- Conversion script now outputs
scripts/i18next-to-lingui-manual-review.jsonwith structured review items (markup entries, zero forms) per locale — 259 items across all locales.
'pseudo'was not originally in thelocalesarray inlingui.config.ts. Added solingui extractgenerates a pseudo catalog andlingui compileapplies pseudolocalization.
| What | Command |
|---|---|
| Both systems | yarn start-pseudo |
| i18next only | PSEUDO_LOCALE=true npx vite |
| Lingui only | LINGUI_PSEUDO_LOCALE=true npx vite |
| Both + staging | PSEUDO_LOCALE=true LINGUI_PSEUDO_LOCALE=true yarn start-staging |