214 lines
8.2 KiB
Markdown
214 lines
8.2 KiB
Markdown
# i18n Contribution Guide
|
||
|
||
> Foundation work for #72 i18n-foundation. Targets contributors adding new
|
||
> UI surfaces or extending existing copy. The infrastructure is in place
|
||
> (ICU plurals, Intl helpers, RTL Tailwind variants, audit script, locale
|
||
> picker, Storybook toolbar) — what follows is the playbook.
|
||
|
||
## When to translate
|
||
|
||
Translate every user-facing string that:
|
||
|
||
- Renders in a React component that ships to the browser
|
||
- Renders in a transactional email body or subject (see
|
||
`packages/mail/src/templates-react/`)
|
||
- Renders in an error/help/empty-state surface accessible to a tenant user
|
||
|
||
Do **not** translate:
|
||
|
||
- Brand names (`ToolCTRL`, `Stripe`, `GitHub`, `Scira AI` etc.) — they're
|
||
proper nouns and stay constant across locales
|
||
- Developer-only tooling labels (Storybook globals, CLI prompts, log
|
||
messages, internal admin operator screens marked as such)
|
||
- Protocol identifiers (Authentik group names, SDK constants, etc.)
|
||
|
||
## Where translations live
|
||
|
||
```
|
||
apps/web/public/locales/
|
||
├── de/
|
||
│ ├── translations.json # Legacy single-file namespace (4000+ keys)
|
||
│ ├── errors.json
|
||
│ ├── mail.json
|
||
│ ├── common.json # Shared UI (buttons, generic labels) + ICU plurals
|
||
│ ├── settings.json # Per-area: settings surfaces
|
||
│ ├── billing.json # Per-area: billing surfaces
|
||
│ ├── crm.json # Per-area: CRM surfaces
|
||
│ ├── auth.json # Per-area: authentication surfaces
|
||
│ └── emailMarketing.json # Per-area: email marketing surfaces
|
||
└── en/ (same structure)
|
||
```
|
||
|
||
`translations.json` is the historical bag-of-keys. **New work should land
|
||
in the most specific existing namespace**: `common`, `settings`, `billing`,
|
||
`crm`, `auth`, or `emailMarketing`. Use namespace names exactly as registered
|
||
in `packages/i18n/src/index.ts`; the email marketing namespace is camelCase
|
||
(`emailMarketing`), not kebab-case.
|
||
|
||
When you add a brand-new namespace:
|
||
|
||
1. Create `apps/web/public/locales/{de,en}/<namespace>.json`
|
||
2. Add the namespace to `i18nNamespaces` in
|
||
`packages/i18n/src/index.ts`
|
||
3. Run `pnpm verify:i18n` so the registered namespace is checked for
|
||
locale/file parity
|
||
|
||
## Key naming
|
||
|
||
- Scope-first, dotted: `locale.title`, `action.save`,
|
||
`impersonation.actingAs`
|
||
- Keep keys stable; if copy changes drastically, mint a new key rather
|
||
than reusing the old one (helps with translation memory)
|
||
- Avoid embedding tokens in keys: prefer `memberCount` (plural-aware) over
|
||
`oneMember` + `manyMembers`
|
||
|
||
## ICU plurals
|
||
|
||
Use the ICU-MessageFormat syntax for pluralisation. The CLDR ruleset
|
||
covers 60+ languages; never re-implement.
|
||
|
||
```json
|
||
{
|
||
"itemsSelected": "{count, plural, =0 {No items selected} one {# item selected} other {# items selected}}"
|
||
}
|
||
```
|
||
|
||
In components:
|
||
|
||
```tsx
|
||
import { useTranslation } from "react-i18next";
|
||
import { formatIcu } from "@toolctrl/i18n";
|
||
|
||
function SelectionToolbar({ count }: { count: number }) {
|
||
const { t, i18n } = useTranslation("common");
|
||
return <span>{formatIcu(t("itemsSelected"), { count }, { locale: i18n.language })}</span>;
|
||
}
|
||
```
|
||
|
||
A `createUseTranslate()` factory in `@toolctrl/i18n` returns a hook that
|
||
auto-detects ICU syntax and routes to FormatJS — useful when most of your
|
||
keys are ICU-shaped. Plain (non-ICU) messages keep working through
|
||
i18next's `{{name}}` interpolation, so a gradual migration is safe.
|
||
|
||
### Pluralisation per language
|
||
|
||
| Locale | Cases used by CLDR |
|
||
| ------ | -------------------------------- |
|
||
| en | one, other |
|
||
| de | one, other |
|
||
| pl | one, few, many, other |
|
||
| ar | zero, one, two, few, many, other |
|
||
| ja, zh | other (no plural distinction) |
|
||
|
||
Polish and Arabic are the canonical test cases for "is the ICU pipeline
|
||
actually wired up correctly?" — see
|
||
`packages/i18n/src/icu/plural.test.ts`.
|
||
|
||
## Intl helpers
|
||
|
||
The `@toolctrl/i18n` package exports locale-aware Intl-API wrappers:
|
||
|
||
```tsx
|
||
import { formatCurrency, formatDate, formatNumber, formatPercent, formatList, formatRelativeTime } from "@toolctrl/i18n";
|
||
|
||
formatCurrency(1234.5, { locale, currency: "EUR" }); // "1.234,50 €" / "$1,234.50"
|
||
formatDate(new Date(), { locale, dateStyle: "medium" });
|
||
formatNumber(0.42, { locale, style: "decimal" });
|
||
formatPercent(0.25, { locale }); // "25%" / "25 %"
|
||
formatList(["A", "B", "C"], { locale }); // Oxford-comma aware
|
||
formatRelativeTime(earlier, { locale, relativeTo: new Date() });
|
||
```
|
||
|
||
All helpers accept an explicit `locale` so they're SSR-safe and produce
|
||
identical output on server + client.
|
||
|
||
## Locale resolution
|
||
|
||
When a request arrives, the server picks a locale in this order:
|
||
|
||
1. `User.locale` (set via the profile locale-picker)
|
||
2. `Tenant.defaultLocale`
|
||
3. `Accept-Language` header (browser default)
|
||
4. App fallback (`de`, see `packages/i18n/src/index.ts#defaultLocale`)
|
||
|
||
The resolved locale is stored in the i18n cookie (`_i18n`) and surfaced
|
||
to React via `useTranslation().i18n.language`. The picker UI lives in
|
||
`apps/web/app/modules/users/components/UserProfileSettings.tsx` (rendered
|
||
from `/app/<tenant>/settings/profile`) and POSTs to `/preferences`, which
|
||
calls the root `setLocale` action.
|
||
|
||
## RTL considerations
|
||
|
||
- `<html dir={getDirection(locale)}>` is set server-side in `root.tsx`
|
||
- Tailwind ships `rtl:` and `ltr:` modifier variants (registered in
|
||
`tailwind.config.ts`); use them where direction matters:
|
||
|
||
```tsx
|
||
<div className="ml-4 rtl:ml-0 rtl:mr-4">…</div>
|
||
<ChevronRightIcon className="rtl:rotate-180" />
|
||
```
|
||
|
||
- For text, prefer logical CSS properties (`ps-4` / `pe-4`) when adding
|
||
new layouts — even simpler than `rtl:` overrides
|
||
- Bidi-spoofing: never trust user-controlled strings to be LTR. The
|
||
display layer must strip Unicode bidi-override characters
|
||
(`U+202A`–`U+202E`, `U+2066`–`U+2069`) — see `compliance-audit-export`
|
||
and the security notes in the foundation spec
|
||
|
||
## Adding a new locale
|
||
|
||
1. Add the locale code to `supportedLocales` in
|
||
`packages/i18n/src/index.ts`. If it's RTL, also add it to
|
||
`rtlLocales`
|
||
2. Create `apps/web/public/locales/<locale>/{translations,errors,mail,common,settings,billing,crm,auth,emailMarketing}.json`
|
||
by copying the English files
|
||
3. Run `pnpm verify:i18n` — the parity check enforces all namespaces have
|
||
matching keys
|
||
4. Have the translation reviewed before merging
|
||
|
||
## Auditing hardcoded strings
|
||
|
||
The `scripts/i18n-audit.mjs` script scans web routes, modules,
|
||
route modules, and components for hardcoded English/German JSX text, then
|
||
records locale-sensitive formatting signals that should move to the
|
||
`@toolctrl/i18n` helpers over time. Run it before starting an extraction
|
||
sprint to baseline the count, and again after to measure progress:
|
||
|
||
```bash
|
||
node scripts/i18n-audit.mjs
|
||
# → docs/contributing/i18n-audit-<YYYY-MM-DD>.md
|
||
```
|
||
|
||
The current audit snapshot is `docs/contributing/i18n-audit-2026-05-25.md`.
|
||
Its re-run section is the active product-UI baseline: 0 hardcoded
|
||
route/component strings and 0 locale-formatting signals as of 2026-05-29.
|
||
The 2026-05-25 findings remain in that file as historical disposition only.
|
||
|
||
The repo-wide `pnpm verify:i18n` script enforces three guards:
|
||
|
||
- `scripts/i18n-check.ts` — per-locale namespace key parity
|
||
- `scripts/verify-hardcoded-strings.mjs` — repo-wide hardcoded-string
|
||
ratchet (active historical baseline: 14 brand/protocol/package
|
||
identifiers; JSX text, prop literals, and response literals are 0)
|
||
- `eslint --max-warnings=0` on the design-system packages
|
||
|
||
## Storybook
|
||
|
||
The Storybook `preview.tsx` exposes a `Locale` toolbar global. Switching
|
||
the value re-renders all stories with `<html lang/dir>` updated, so
|
||
`rtl:` Tailwind variants light up immediately. The default value is `en`;
|
||
`ar` is included as an RTL preview even though we don't ship Arabic
|
||
translations yet.
|
||
|
||
## Reference checklist for a new translated component
|
||
|
||
- [ ] Strings live in a per-area namespace (`common`, `settings`, …)
|
||
- [ ] DE and EN are both filled in (parity test enforces this)
|
||
- [ ] Plural-aware copy uses ICU syntax
|
||
- [ ] Currency/date/number rendering uses `@toolctrl/i18n` helpers,
|
||
passing the active locale
|
||
- [ ] Direction-sensitive Tailwind classes use `rtl:` / `ltr:` modifiers
|
||
(or logical CSS properties)
|
||
- [ ] No hardcoded Latin-alphabet copy in JSX text (audit clean for that
|
||
file)
|