8.2 KiB
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 AIetc.) — 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:
- Create
apps/web/public/locales/{de,en}/<namespace>.json - Add the namespace to
i18nNamespacesinpackages/i18n/src/index.ts - Run
pnpm verify:i18nso 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) overoneMember+manyMembers
ICU plurals
Use the ICU-MessageFormat syntax for pluralisation. The CLDR ruleset covers 60+ languages; never re-implement.
{
"itemsSelected": "{count, plural, =0 {No items selected} one {# item selected} other {# items selected}}"
}
In components:
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:
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:
User.locale(set via the profile locale-picker)Tenant.defaultLocaleAccept-Languageheader (browser default)- App fallback (
de, seepackages/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 inroot.tsx -
Tailwind ships
rtl:andltr:modifier variants (registered intailwind.config.ts); use them where direction matters:<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 thanrtl: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) — seecompliance-audit-exportand the security notes in the foundation spec
Adding a new locale
- Add the locale code to
supportedLocalesinpackages/i18n/src/index.ts. If it's RTL, also add it tortlLocales - Create
apps/web/public/locales/<locale>/{translations,errors,mail,common,settings,billing,crm,auth,emailMarketing}.jsonby copying the English files - Run
pnpm verify:i18n— the parity check enforces all namespaces have matching keys - 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:
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 parityscripts/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=0on 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/i18nhelpers, 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)