Skip to content

Instantly share code, notes, and snippets.

@TosinAF
Created March 3, 2026 18:54
Show Gist options
  • Select an option

  • Save TosinAF/58076db7ff466bee7edeceb8947b1cdf to your computer and use it in GitHub Desktop.

Select an option

Save TosinAF/58076db7ff466bee7edeceb8947b1cdf to your computer and use it in GitHub Desktop.
Lingui Migration Plan — master plan doc used to coordinate AI agents across 80+ parallel workspaces

Lingui Migration Plan: i18next → Lingui v5

Unified plan synthesized from two independent research efforts and framework documentation research. Date: 2026-02-08


Executive Summary

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 via initReactI18next plugin
  • Add-ins (Word/Outlook) alias i18n to 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 context for disambiguation
  • Per-locale code splitting via dynamic import() (only active locale loaded)
  • Smartling TMS integration via @lingui/format-po-gettext

Decision Log

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

Babel vs SWC Note

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-reactplugin-react-swc
Risk None — additive Medium — changes entire JSX transform pipeline

Start with Babel. Consider SWC as a separate optimization PR later.

Smartling + PO Format: Critical Detail

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: true in the formatter config to avoid duplicate msgid issues
  • Set lineNumbers: false to reduce diff churn when source code moves

Inventory Summary (from AST Scanner)

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.md in Code/Docs/lingui-migration-codex/


Phase 0: Lingui Infrastructure (no product behavior change)

PR 0a: Install Lingui packages and configure Vite

Scope: package.json, vite.config.ts, word-add-in/vite.config.mts, outlook-add-in/vite.config.mts, lingui.config.ts

  1. 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
  2. Create lingui.config.ts at 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,
      }),
    })
  3. Update vite.config.ts (main app):

    import { lingui } from "@lingui/vite-plugin"
    
    // Update react() call:
    react({
      babel: {
        plugins: ["@lingui/babel-plugin-lingui-macro"],
      },
    }),
    lingui(),
  4. Update word-add-in/vite.config.mts and outlook-add-in/vite.config.mts (same pattern — add Babel plugin + Lingui Vite plugin). Both add-ins currently alias i18n to the main frontend's path; they'll need to also alias the Lingui locale path.

  5. 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"
    }
  6. Gitignore: Add compiled .js output from locales/ to .gitignore. Keep .po files tracked.

  7. Run lingui extract to 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.

PR 0b: Set up Lingui provider, dynamic loading, and language sync

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.

  1. 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
    }
  2. Wrap app with <I18nProvider> in src/index.tsx:

    import { I18nProvider } from "@lingui/react"
    import { i18n } from "@lingui/core"
    
    // Add I18nProvider wrapping <App />
    <I18nProvider i18n={i18n}>
      <App />
    </I18nProvider>
  3. Sync language changes in src/app.tsx: After the existing changeAppLanguage() call that updates i18next, also call activateLingui():

    // In the language initialization logic:
    await changeAppLanguage(appLanguage)  // existing i18next call
    await activateLingui(appLanguage)     // new Lingui call
  4. Language & Locale Handling (during coexistence)
    Recommended order during migration:

    1. Add Lingui activation before first render (prevents flicker).
    2. Sync runtime language changes (app startup + profile updates).
    3. Persist locale changes to storage (so detection works after i18next removal).
    4. Update telemetry tags once Lingui is the source of truth.
    5. 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’s appLanguage, also call activateLingui(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., appLanguage in localStorage) so @lingui/detect-locale resolves 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.language usage in Sentry/Datadog tags with Lingui’s i18n.locale after 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).
  5. Update yarn start-pseudo in package.json:

    "start-pseudo": "ESLINT_NO_DEV_ERRORS=true PSEUDO_LOCALE=true VITE_LOCALE=pseudo vite"
  6. Create src/i18n/lingui-test-setup.ts (mirrors src/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):

  1. App boots with <I18nProvider> and no runtime errors.
  2. VITE_LOCALE=pseudo PSEUDO_LOCALE=false yarn start shows pseudo‑localized text for any Lingui‑migrated strings (if any exist yet).
  3. yarn i18n:extract and yarn i18n:compile --strict complete successfully.

PR 0c: ESLint configuration for Lingui

Scope: eslint.config.mts, src/eslint-plugins/

  1. Add eslint-plugin-lingui recommended rules (flat config):

    import pluginLingui from "eslint-plugin-lingui"
    
    pluginLingui.configs["flat/recommended"],
  2. Add lingui/no-unlocalized-strings at warn level 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"],
        }],
      },
    }
  3. Plan for harvey/translation-key-format: This custom rule reads public/locales/en-US/*.json at startup and validates t() keys exist. During migration it continues validating i18next calls. Once all call sites are migrated, it gets replaced by lingui extract drift detection in CI. No changes needed in Phase 0.


Phase 1: Catalog Conversion Script

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 --clean in Phase 2a. The conversion script (scripts/convert-i18next-to-lingui.ts) and key map (scripts/i18next-to-lingui-key-map.json) have been deleted.

PR 1: Convert i18next JSON → Lingui PO catalog

Write a one-time conversion script (scripts/convert-i18next-to-lingui.ts) that:

  1. Reads all 26 JSON files from public/locales/en-US/
  2. Flattens nested keys with dot notation ({"actions": {"add": "Add"}}vault.actions.add)
  3. Prefixes each key with its namespace (filename without .json)
  4. Converts i18next interpolation: {{variable}}{variable}
  5. Converts plural suffixes (_one, _other, _zero, _few, _many, _two) into ICU plural messages, which format-po-gettext will then render as native gettext plurals:
    • file_count_one / file_count_otherfile_count with {count, plural, one {# file} other {# files}}
  6. Flags <Trans> markup entries (those with <tag> placeholders) as requiring manual review — Lingui uses <0>/<1> indexed placeholders
  7. Outputs locales/en-US/messages.po in gettext format
  8. Outputs scripts/i18next-to-lingui-key-map.json mapping old namespace:key → new namespace.key for 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):

  1. yarn i18n:compile --strict succeeds with the converted catalog.
  2. Spot‑check a few migrated keys in locales/en-US/messages.po for correct IDs and plural conversion.
  3. VITE_LOCALE=pseudo PSEUDO_LOCALE=false yarn start still boots (Lingui active) and no runtime errors occur from catalog loading.

Phase 2: Migrate Call Sites by Product Area

Each PR converts components from useXxxTranslation() + t('key') to useLingui() + Lingui macros. Grouped by product area so domain experts can review.

Migration Pattern

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.locale

Translator 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 Schedule by Product Area

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:

  1. Converts all useXxxTranslation()useLingui() in the target area
  2. Converts all t('key')t`English text` (source-string-as-ID)
  3. Converts any <Trans i18nKey=...> to Lingui <Trans> (no id needed)
  4. Converts i18n.languagei18n.locale
  5. Removes or deprecates the old useXxxTranslation hook file
  6. Addresses unlocalized strings flagged by the scan (customer-facing ones; customer-admin can be a follow-up)
  7. Runs lingui extract to verify all new IDs appear in the PO catalog
  8. Escalates ESLint lingui/no-unlocalized-strings to error for that area's file paths

Known Gotchas (learned from Phase 2a):

  • Multi-line explicit-ID patterns: Single-line grep t\(\{\s*id: misses cases where t({ and id: 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 } }) crashes babel-plugin-extract-messages because the extractor can't handle SpreadElement AST nodes. Use i18n._(descriptor, { x }) instead (pass values as the second argument).
  • msg() with ICU placeholders: Must use object form msg({ message: 'Text {name}' }) — tagged template msg`Text {name}` treats {name} as a literal JS template expression, not an ICU placeholder.

Verification (per PR, manual + scripted):

  1. Static code check: No react-i18next imports or useXxxTranslation() 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>/ and rg -U 'msg\(\{[\s\S]*?id:' src/components/<area>/
  2. Catalog check: yarn i18n:extract + yarn i18n:compile --strict (new IDs appear in locales/en-US/messages.po).
  3. 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.
  4. Regression check: navigate the migrated surfaces (Common/UI, Vault, etc.) and confirm no regressions in labels, placeholders, tooltips, or empty states.

Excluded from Localization (Internal Admin)

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/**

Agent Phase Migration Prompt

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.

Context

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]

Step-by-Step Procedure

1. Identify all files to migrate

Find every file that imports the target translation hooks:

rg "useXxxTranslation" src/components/<area>/ --files-with-matches

Also check for direct react-i18next imports (useTranslation, Trans, t from i18next) in the target area.

2. For EACH file, migrate every call site

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:

  1. Remove the old hook import and call:

    // DELETE these:
    import { useVaultTranslation } from 'components/vault/hooks/use-vault-translation'
    const { t } = useVaultTranslation()
  2. 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'

    @lingui imports go in the third-party import section (alongside React, lodash, zustand, etc.), NOT in the local components section.

  3. Convert every t('key') call to t`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, not undefined. Add ?? '' or ?? 0 fallbacks if needed.
  4. 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 in t ``
    • Look at the JSON namespace for the _one, _other, _zero suffixed keys to get the plural forms
  5. 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>
  6. 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 } })
  7. Convert i18n.language reads to i18n.locale.

3. Wrap unlocalized user-facing strings

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

4. Update test files

For every test file associated with a migrated component:

  1. Remove translation-related mocks:

    // DELETE mocks like:
    vi.mock('components/vault/hooks/use-vault-translation', () => ({
      useVaultTranslation: () => ({ t: (key: string) => key }),
    }))
  2. 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()
  3. Remove unused translation imports from test files.

5. Delete the hook definition files (if zero consumers remain)

After migrating all files, check if any consumers of the hook remain outside your target area:

rg "useXxxTranslation" src/ --files-with-matches

If 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.

6. Run lingui extract to update PO catalogs

yarn i18n:extract

This updates all locales/*/messages.po files with new source-string msgids. Commit the PO catalog changes alongside the source changes.

Patterns Quick Reference

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

Critical Rules (DO NOT violate)

  1. Every call site in a touched file must be migrated. No partial migrations.
  2. @lingui imports go in the third-party section, not where the old hook import was.
  3. Variables in tagged templates must be string | number, not undefined. Add ?? '' fallback.
  4. Plural branches use # for the count, never ${count}.
  5. plural() is standalone — do NOT nest it inside t ``.
  6. msg() with ICU {name} placeholders must use object form — tagged template treats {name} as a JS expression.
  7. i18n._() with values: use i18n._({ ...descriptor, values: { x } }) — spread the descriptor. Do NOT use i18n._(descriptor, { x }) (wrong overload for TypeScript).
  8. Do NOT use t in React dependency arrayst is a macro, not a React hook.
  9. Run lingui extract before committing to ensure PO catalogs are up to date.
  10. Look up the actual English text from the JSON namespace file — do NOT guess or abbreviate. The English text must be exact.

Verification Checklist (run before marking phase complete)

  • Zero imports of the old hook (useXxxTranslation) remain in the target area
  • Zero react-i18next imports 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:extract runs without errors
  • Test files updated: no translation mocks, assertions use English text
  • All @lingui imports are in the third-party section of each file
  • No ${count} in plural branches (use # instead)
  • No undefined variables in tagged templates (add ?? '' fallbacks)

Edge Cases to Defer (do NOT try to fix these)

  • dev-auth-inspector.tsx — uses TFunction type from i18next; migrate in cleanup phase
  • guest-legal-interstitial.tsx — uses <Trans> with dynamic i18nKey from i18next; needs special handling
  • file-sources-dropdown-item.tsx — uses <Trans> with dynamic i18nKey; deferred
  • Any file that builds i18next keys dynamically (string concatenation for keys) — flag for manual review

Unlocalized Strings Audit (Completed Phases)

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.

Phase 2a — Common/UI (8 files, ~38 strings)

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)

Phase 2b — Vault (0 files, 0 strings)

All 104 vault files scanned — zero unlocalized user-facing strings found. Every string is properly wrapped in t```, msg```, or plural().

Phase 2c — Assistant (0 files, 0 strings)

All 85 assistant component files scanned — zero unlocalized user-facing strings found. Every string is properly wrapped.

Summary & Next Steps

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:

  1. editor-toolbar.tsx (20 strings) — Highest priority. All button label props and tooltip text need `t```.
  2. feedback-dialog.tsx (4 strings) — Dialog title/subtitle and button labels.
  3. profile-complete-step.tsx + profile-complete-later-step.tsx (7 strings) — Onboarding flow strings.
  4. resource-share-popover.tsx + share-popover.tsx (6 strings) — Sharing UI strings.
  5. app-header.tsx (1 string) — Single aria-label.
  6. 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.


Phase 3: Tests, Storybook, and Cypress

PR 3a: Update test infrastructure

  1. Replace I18nTestWrapper in src/test/i18n-test-setup.tsx:
    • During coexistence: Wrap with both i18next and Lingui providers
    • After full migration: Use Lingui-only wrapper
  2. Update getTranslation helper to use Lingui's i18n._() instead of i18next's t()
  3. Update render-with-app.tsx to include I18nProvider
  4. Update Cypress helpers:
    • cypress/support/commands/component.tsx
    • word-add-in/cypress/support/component.tsx
  5. Update Storybook to wrap stories with I18nProvider

Phase 4: Cleanup & Finalize

PR 4a: Remove i18next infrastructure

After all call sites are migrated and tests pass:

  1. Remove packages: i18next, react-i18next, i18next-http-backend, i18next-browser-languagedetector, i18next-hmr, i18next-pseudo
  2. Remove src/i18n/config.ts (old i18next config)
  3. Remove initReactI18next usage (i18next's global plugin installation)
  4. Remove all public/locales/ JSON files and all 7 locale directories
  5. Remove old ESLint plugins: eslint-plugin-i18next, eslint-plugin-formatjs
  6. Remove/replace harvey/translation-key-format custom ESLint rule (replaced by lingui extract drift detection in CI)
  7. Remove scripts/check-locales-json.js (no longer needed)
  8. Update navigator.language ban in ESLint to reference Lingui instead of i18next
  9. Update all use-*-translation.ts hook files — these should already be removed in Phase 2 PRs
  10. Update documentation: docs/i18n-migration-guide.md, AGENTS.md, README.md
  11. Update CODEOWNERS — change public/locales/** paths to locales/** PO file paths

PR 4b: CI pipeline update

  1. Add lingui extract --clean + git diff --exit-code to 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
  2. Add lingui compile --strict as a validation step (catches missing translations)
  3. Verify per-locale bundle splitting (only active locale chunk loaded)
  4. Measure bundle size delta vs i18next

PR 4c: Smartling integration

  1. Configure Smartling project for PO/Gettext file type
  2. Upload locales/en-US/messages.po as source
  3. Test round-trip: upload source → translate a few strings → download translated PO → verify lingui compile works
  4. Set up Smartling webhook or CI step for automated uploads on merge to main

Phase 5: Date & Time Localization

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.

Current State

The codebase currently formats dates and times using a mix of approaches:

  • date-fns/format and date-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 locale
  • toLocaleDateString() / toLocaleTimeString() — scattered usage, sometimes without locale parameter
  • dayjs — used in a few places (calendar components)
  • Hardcoded date format strings — e.g., 'MMM d, yyyy', 'h:mm a', 'MM/dd/yyyy'

Problems to Solve

  1. Locale-aware formatting: Dates should respect the user's locale (e.g., 02/10/2026 in en-US vs 10.02.2026 in de-DE)
  2. Relative time: "2 hours ago", "yesterday", "last week" need locale-aware formatting
  3. Calendar components: Date pickers and calendar views need locale-aware first-day-of-week, month names, etc.
  4. Timezone display: Timezone names should render in the user's language where possible
  5. Number formatting: Thousands separators, decimal marks vary by locale (1,000.50 vs 1.000,50)

Recommended Approach

Option A: Lingui + Intl APIs (Recommended)

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.locale at call time — no stale locale issues
  • Consistent with Lingui's locale management

Disadvantages:

  • Intl.RelativeTimeFormat requires manual time-unit bucketing (shown above)
  • Some older Safari versions may lack Intl.RelativeTimeFormat (polyfillable)

Option B: Lingui + date-fns with Locale

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-fns is 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

Migration Steps

PR 5a: Create date/time formatting utilities

  1. Create src/utils/date-format.ts with locale-aware helpers (Option A or B above)
  2. Add tests for each helper across locales
  3. Export a formatNumber helper for locale-aware number formatting

PR 5b: Migrate date formatting call sites

  1. Find all date-fns/format and date-fns/formatDistance usage:
    rg "from 'date-fns" src/ --files-with-matches
    rg "toLocaleDateString|toLocaleTimeString" src/ --files-with-matches
    rg "Intl\.DateTimeFormat" src/ --files-with-matches
  2. Replace with the new locale-aware utilities
  3. For date-fns/format calls with hardcoded locale or no locale, update to use the new helper
  4. For Intl.DateTimeFormat calls, ensure they use i18n.locale instead of hardcoded 'en-US'

PR 5c: Localize calendar and date picker components

  1. Update react-day-picker (used for date range pickers) to use the correct locale
  2. Update any dayjs calendar components to use locale-aware formatting
  3. Ensure first-day-of-week respects locale (Monday in most of Europe, Sunday in US)

PR 5d: Number formatting

  1. Create or extend formatNumber utility for:
    • Decimal/thousands separators (1,000.50 vs 1.000,50)
    • Percentage formatting
    • Currency formatting (if applicable)
    • File size formatting (locale-aware)
  2. Find and replace hardcoded toLocaleString() and Intl.NumberFormat calls

Inventory of Date/Time Patterns to Migrate

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

Testing Strategy

  1. Unit tests: Test each formatting helper with multiple locales (en-US, de-DE, es-ES at minimum)
  2. Visual regression: Check date displays in the app with pseudo-locale enabled
  3. Edge cases: Verify timezone-sensitive displays, daylight saving transitions, midnight boundary dates

Audit Strategy

Three complementary approaches ensure complete coverage:

1. AST Scanner (scan-strings.ts)

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.

2. ESLint lingui/no-unlocalized-strings

Catches hardcoded strings in JSX at lint time. Progressively escalated from warn to error per product area as migration completes.

3. lingui extract Drift Detection

After migration, lingui extract --clean + git diff in CI catches any macros that weren't captured in the catalog or any stale entries.

Admin String Exclusion

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

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 })

Timeline & Dependencies

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.


Reference Documents

Document Location Description
String ID Naming Guide Code/Docs/lingui-migration-claude/01-string-id-guide.md Best practices for naming explicit message IDsObsolete: source-string-as-ID mode eliminates manual ID naming
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

Key External References


Addendum: TMS Flexibility Note

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)
  • select and selectOrdinal support (not currently needed, but future-proofed)
  • Nested/multi-variable plurals in a single message
  • Standard ICU tooling ecosystem compatibility

Addendum: Implementation Deviations from Original Plan

The following changes were made during Phase 0-1 implementation that differ from or extend the original plan:

Switch to Source-String-as-ID (Phase 2a-Correction)

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)
  • context param 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.json and scripts/convert-i18next-to-lingui.ts
  • All PO catalogs regenerated via lingui extract --clean (256 source-string entries)

Why:

  1. PO expects source text as msgid — translators see real English, not opaque keys
  2. Eliminates named-vs-positional variable mismatch bugs
  3. Smartling TM can leverage real English text for auto-translation
  4. Dramatically simplifies remaining Phase 2b-2k: just write t`English text` instead of constructing explicit IDs
  5. 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.

Env Variable Change

  • Original: VITE_LOCALE=pseudo (checked via import.meta.env.VITE_LOCALE)
  • Actual: LINGUI_PSEUDO_LOCALE=true (checked via process.env.LINGUI_PSEUDO_LOCALE)
  • Reason: Matches the naming convention of the existing PSEUDO_LOCALE env var used by i18next. Added to Vite's setEnv/envPlugin prefixes so it's available as process.env.* in client code.

Async Render Gate (from codex branch review)

  • Original plan: Fire-and-forget activateLingui() before render
  • Actual: root.render() is called inside a finally block after await activateLingui(). This prevents a flash of untranslated content. If activation fails, Sentry captures the error and the app still renders.

User Profile Store Sync (from codex branch review)

  • Not in original plan but necessary: user-profile-store.tsx now calls activateLingui() and localStorage.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.

*.po TypeScript Declaration (from codex branch review)

  • Added declare module '*.po' to react-app-env.d.ts so PO imports have proper typing.

mergePlurals: true in Formatter Config (from codex branch review)

  • Added to lingui.config.ts formatter options. This tells format-po-gettext to collapse plural forms into standard gettext entries during extract/compile.

ESLint no-unlocalized-strings Ignore Config (from codex branch review)

  • Original plan had bare warn level. Now includes ignore, ignoreNames (className, type, key, href, etc.), and ignoreFunctions (console.*, Error) to reduce false positives.

localeStorageKey Constant (from codex branch review)

  • Exported from lingui-setup.ts and used in detectLocale() and user-profile-store.tsx instead of hardcoded 'appLanguage' string.

Manual Review JSON Output (from codex branch review)

  • Conversion script now outputs scripts/i18next-to-lingui-manual-review.json with structured review items (markup entries, zero forms) per locale — 259 items across all locales.

Pseudo Locale in locales Array

  • 'pseudo' was not originally in the locales array in lingui.config.ts. Added so lingui extract generates a pseudo catalog and lingui compile applies pseudolocalization.

Running Pseudo Locale (updated commands)

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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment