i18n
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
---
|
||||
name: add-language
|
||||
description: Add or remove a UI language/locale — config, catalog generation, translation agents, SMS/date/meta integration, and verification. Use when asked to add, enable, support, or remove a language.
|
||||
---
|
||||
|
||||
# Adding a language
|
||||
|
||||
Example: adding French (`fr`). Five places name the locale set — update all:
|
||||
|
||||
1. `src/lib/i18n/index.ts` — `SUPPORTED_LOCALES` + `LOCALE_LABELS` (label in
|
||||
the language itself, no region clarifier: `fr: "Français"`).
|
||||
2. `lingui.config.ts` — `locales`.
|
||||
3. `src/lib/mantine/mantine-provider.tsx` — `DAYJS_LOCALE` map + the matching
|
||||
`import 'dayjs/locale/fr'` (calendar month/weekday names come from dayjs;
|
||||
without this, calendars stay English).
|
||||
4. `src/lib/i18n/meta.ts` — `OG_LOCALE_BY_LOCALE` (fr → fr_FR).
|
||||
5. `src/lib/twilio/index.ts` — `TWILIO_VERIFY_LOCALES` ONLY if Twilio Verify
|
||||
supports the code (https://www.twilio.com/docs/verify/supported-languages).
|
||||
Unsupported codes must stay out — the allowlist silently falls back to the
|
||||
service-default English SMS instead of failing the login send.
|
||||
|
||||
Then generate + translate:
|
||||
|
||||
6. `bun run extract` creates `src/locales/fr/messages.po` (every msgid, empty
|
||||
msgstr).
|
||||
7. Translate via a chunked agent workflow (this repo's proven recipe):
|
||||
- Split en.po entry blocks (split on blank lines, skip header) into ~60-entry
|
||||
chunks; one sonnet agent per chunk RETURNS translated entries (never let
|
||||
parallel agents edit one .po). Validate each returned chunk by msgid count;
|
||||
retry failures once.
|
||||
- Merge by msgid back into the locale .po. Match msgids by exact bytes —
|
||||
watch non-breaking spaces (\xa0) in msgids, which agents normalize away;
|
||||
patch those few by hand.
|
||||
- Agent rules: msgstr single-line, ICU placeholders/plural keywords and
|
||||
`<0>` tags preserved (translate only human words inside branches; plural
|
||||
categories follow the target language's CLDR set), FLXN/Flexxon/Spotify
|
||||
untranslated, casual sporty tone, register decided up front (tú/du/です・ます).
|
||||
8. `bun run i18n:check` must pass (0 missing). Optionally run per-locale
|
||||
native-reviewer agents over the full .po (fidelity, register, glossary
|
||||
consistency, ICU integrity) and apply their FIX lines.
|
||||
9. `bunx tsc --noEmit && bun run build`; confirm the new locale appears as its
|
||||
own lazy `messages-*.js` chunk in dist/client/assets.
|
||||
10. Manual check: switch language in Settings (persists to SuperTokens
|
||||
metadata), confirm UI + calendar + SMS behavior.
|
||||
|
||||
# Removing a language
|
||||
|
||||
Reverse of the above: delete from the five locale-set sites (skip Twilio if it
|
||||
was never listed), `rm -rf src/locales/<code>`, drop the dayjs import, then
|
||||
`bun run i18n:check` + tsc. Users with the removed locale saved in metadata
|
||||
fall back to English automatically via `resolveLocale`.
|
||||
|
||||
Fallback semantics: untranslated entries render English (msgid); unauth
|
||||
visitors get Accept-Language detection (SSR first paint on public routes is
|
||||
English, corrected after hydration); authed users get their saved locale
|
||||
server-rendered.
|
||||
@@ -0,0 +1,101 @@
|
||||
---
|
||||
name: i18n
|
||||
description: 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
|
||||
`useLingui` from 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 `t`Saved!`` |
|
||||
| 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 `t`Teams (${n})`` gives translators no plural branch |
|
||||
| Route header titles (loader/beforeLoad) | `title: msg`Manage {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._({ ...msg`Hi {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.
|
||||
Reference in New Issue
Block a user