Files
flxn-app/.claude/skills/i18n/SKILL.md
T
yohlo 0a7b5fa00f
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 8s
CI/CD Pipeline / i18n Catalog Check (push) Failing after 10s
CI/CD Pipeline / Build and Push App Docker Image (push) Skipped
CI/CD Pipeline / Deploy to Kubernetes (push) Skipped
i18n
2026-08-08 15:29:40 -07:00

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/select from @lingui/core/macro — they bind a global i18n and bleed locale across SSR requests.
  • Carve-out: a component that only needs i18n._() (no t`` macro) may use useLinguifrom 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: LinguiProvider must stay ABOVE AuthProvider (src/features/core/components/providers.tsx). The localized query hooks (useServerQuery/useMe/etc.) call useLingui, so anything using them — including AuthProvider — must render inside it. LinguiProvider therefore reads the auth query with raw useQuery, never useAuth/useMe.
  • __root head() must not read match/context — doing so creates circular route-type inference that breaks beforeLoad typing 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: __root beforeLoad preloads the active locale on authed routes; LinguiProvider self-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/useCallback bodies that CACHE a computed string from t/i18n must list them in deps. (Callbacks that merely call t when invoked read the live locale and are safe.)
  • Tab labels: SwipeableTabs items need a stable value slug; label is the translated display string.

Catalog workflow — run after any string change

  1. bun run extract — regenerates ALL locale .po files from source, adds new msgids, removes stale ones. Never hand-edit msgids or en.po.
  2. 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.
  3. bun run i18n:check — fails if any locale has missing translations. CI (.gitea/workflows/ci-cd.yaml, i18n-check job) enforces this AND fails if committed catalogs are stale vs. source — always commit src/locales with string changes.
  4. bun run build runs 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.