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

8.2 KiB
Raw Blame History

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.

{
  "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:

  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:

    <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:

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)