5.9 KiB
name, description
| name | description |
|---|---|
| i18n | Rules and workflow for user-facing strings — wrapping new text in Lingui macros, updating catalogs, and translating new entries. Use whenever adding, editing, or removing any user-visible text (components, hooks, toasts, route titles, server errors). |
i18n workflow (Lingui v6)
Every user-facing string must go through Lingui. English source text IS the
translation key (msgid); catalogs live in src/locales/<locale>/messages.po
(en, es, de, ja — es is Mexican Spanish). msgstr mirrors msgid in en.po; that
is normal for the source locale.
Wrapping rules
Allowed APIs — context-bound only (SSR-safe):
import { Trans, Plural, useLingui } from "@lingui/react/macro"import { msg } from "@lingui/core/macro"(descriptor only)- NEVER
t/plural/selectfrom@lingui/core/macro— they bind a global i18n and bleed locale across SSR requests. - Carve-out: a component that only needs
i18n._()(not`` macro) may useuseLinguifrom plain@lingui/react` (see header.tsx, mantine-provider) — it's the same context-bound hook, just without macro sugar.
Patterns:
| Context | Pattern |
|---|---|
| JSX text | <Trans>Match starts soon</Trans> |
| String props / expressions in a component or hook | const { t } = useLingui() at TOP LEVEL (unconditional — React Compiler), then tSaved!`` |
| Countable text in JSX | <Plural value={n} one="# point" other="# points" /> |
| Countable text in a string expression | i18n._({ ...msg{n, plural, one {# team} other {# teams}}, values: { n } }) — a bare tTeams (${n})`` gives translators no plural branch |
| Route header titles (loader/beforeLoad) | title: msgManage {name}, titleValues: { name } — resolved by Header |
| Plain .ts utils | take an i18n: I18n param and use msg descriptors (see src/features/predictions/utils.ts) |
| Server code (API routes, push, meta) | localizedFor(context.metadata) from src/lib/i18n/server-messages.ts (or createI18n(resolveLocale(...))) + msg descriptors |
| Server-fn error toasts | localized centrally via the ErrorType map in src/lib/i18n/error-messages.ts — add new ErrorTypes there, not per-call-site |
v6 signature: values ride inside the descriptor — i18n._({ ...msgHi {name}, values: { name } }). i18n._(descriptor, values) does NOT exist in v6.
Homographs: when the same English word means different things in different
places (e.g. "Home" = nav screen vs. home team), give one of them a context so
translators get separate entries: t({ message: "Home", context: "match team" })
— Spanish needs "Inicio" vs "Local" there. Same-spelling msgids silently share
one translation otherwise.
Never wrap: logger/console output, query keys, route paths, URLs, CSS values,
PB collection/field names, ids/slugs, ===-compared values, object keys,
brand names FLXN/Flexxon/Spotify, data-derived names, thrown errors that are
only logged (mapKnownError discards their text — only the ErrorType map's
strings reach users).
Architecture invariants (violating these breaks the app, not just a string)
- Provider order:
LinguiProvidermust stay ABOVEAuthProvider(src/features/core/components/providers.tsx). The localized query hooks (useServerQuery/useMe/etc.) calluseLingui, so anything using them — including AuthProvider — must render inside it. LinguiProvider therefore reads the auth query with rawuseQuery, neveruseAuth/useMe. __roothead() must not readmatch/context — doing so creates circular route-type inference that breaksbeforeLoadtyping in child routes. Meta stays DEFAULT_LOCALE (crawlers have no session anyway).- Babel order in vite.config.ts: lingui macro plugin BEFORE
react-compiler. Reversed order fails the build on
<Plural>. - Catalog loading:
__rootbeforeLoad preloads the active locale on authed routes;LinguiProviderself-loads a missing catalog client-side (public routes). Unauthenticated non-en visitors get an English SSR first paint on /login that corrects after hydration — known, accepted. useMemo/useCallbackbodies that CACHE a computed string fromt/i18nmust list them in deps. (Callbacks that merely calltwhen invoked read the live locale and are safe.)- Tab labels:
SwipeableTabsitems need a stablevalueslug;labelis the translated display string.
Catalog workflow — run after any string change
bun run extract— regenerates ALL locale .po files from source, adds new msgids, removes stale ones. Never hand-edit msgids or en.po.- Translate the new empty
msgstr ""entries in es/de/ja. Spawn one translation agent per locale (batch ~60 entries; have them RETURN the translated entries and merge by msgid — don't let parallel agents edit one file). Translation rules for agents:- msgid byte-identical; translate msgstr only, single line.
- Preserve ICU placeholders/plural structure (translate only the words
inside branches) and
<0>/</0>tags exactly. - Watch invisible characters: msgids can contain non-breaking spaces (\xa0) that agents silently normalize — verify by byte comparison, not visual.
- Registers: es = informal tú (Mexican), de = informal du, ja = polite-casual です/ます. FLXN/Flexxon/Spotify untranslated.
bun run i18n:check— fails if any locale has missing translations. CI (.gitea/workflows/ci-cd.yaml,i18n-checkjob) enforces this AND fails if committed catalogs are stale vs. source — always commitsrc/localeswith string changes.bun run buildruns extract automatically, so a forgotten extract can't ship hash-ids — but only step 3 catches untranslated entries.
Production strips English defaults from components; text lives only in the hash-keyed compiled catalog, so a msgid missing from the catalog renders as its hash. Non-en catalogs are separate lazy chunks — English users download nothing extra.