Files
HiveGrid-Wireframes/hivegrid-cloud/docs/contributing/i18n.md
T

214 lines
8.2 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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)