Internationalisation discipline — every user-facing string in a catalog (no inline strings); ICU MessageFormat for plurals + interpolation; Intl APIs for date / number / currency / list / collation; BCP 47 locale identifiers; RTL mirroring for Arabic / Hebrew / Persian / Urdu; locale-aware sort + search; per-country address + name formats; currency placement varies by locale; transactional emails / SMS / push routed through the same i18n pipeline. Auto-fires on i18n / locales / translations /...
Installs into .claude/skills of the current project.
Are you the author of I18n Rules?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/nmor-i18n-rules)
---
name: i18n-rules
description: Internationalisation discipline — every user-facing string in a catalog (no inline strings); ICU MessageFormat for plurals + interpolation; Intl APIs for date / number / currency / list / collation; BCP 47 locale identifiers; RTL mirroring for Arabic / Hebrew / Persian / Urdu; locale-aware sort + search; per-country address + name formats; currency placement varies by locale; transactional emails / SMS / push routed through the same i18n pipeline. Auto-fires on i18n / locales / translations / message catalogs.
paths:
- "**/i18n/**"
- "**/locales/**"
- "**/translations/**"
- "**/messages/**"
- "**/lang/**"
- "**/*.po"
- "**/*.pot"
- "**/*.xliff"
- "**/*.arb"
- "**/messages*.json"
- "**/translations*.json"
- "**/intl/**"
---
# i18n-rules
> Migrated 2026-06-02 from `~/.claude/rules/common/` as part of the lazy-rules-loading plan. Phase H
> will delete the source files to close the eager-load loop.
>
> **Size budget: 21 KB** — `token-budget.mjs --check`.
## Source files migrated
- `rules-library/common/i18n.md`
---
<!-- ============================================================
Section: i18n.md (from rules/common/)
============================================================ -->
## Internationalisation (i18n) Rule (Always-On, Global)
> Auto-fires on every file. Sister to `a11y.md` (RTL + text
> expansion overlap), `error-codes.md` (codes are i18n keys),
> `gdpr-ccpa.md` (per-region compliance), `task-intake-due-
> diligence.md` Q13. Standards: **Unicode CLDR** (Common Locale
> Data Repository), **ICU Message Format**, **BCP 47** (language
> tags), **ECMA-402** (Intl API), **RFC 5646** (language tags),
> **W3C Internationalization Working Group**.
### Core Principle
**Every user-facing string, number, date, currency, address, and
plural form is locale-aware. The system is designed to support
any locale from day one — even if launch is single-locale. Adding
a language later should not require touching application code.**
i18n is not just "translate the buttons." It's plural rules,
date/number formats, currency, RTL layout, name ordering, address
formats, sort orders, search collation, calendar systems, and
locale-aware error messages.
### Hard rules
#### 1. No hardcoded user-facing strings
Every user-visible string lives in a translation catalog
(`.json`, `.po`, `.xliff`, `.properties`, `.yml`). Code
references the KEY, not the string:
```typescript
// WRONG
toast.success("Order placed");
// RIGHT
toast.success(t('orders.placed.success'));
```
The catalog is the source of truth; translators work on the
catalog; code never embeds untranslated strings.
#### 2. Use ICU Message Format for plurals + interpolation
ICU MessageFormat (CLDR-based) handles plural rules across all
languages — including languages with multiple plural forms
(Polish: 4; Arabic: 6):
```text
"orders.count": "{count, plural,
=0 {No orders}
one {1 order}
other {# orders}
}"
```
Library support: `@formatjs/intl` (React/JS), `fluent` (Mozilla,
JS/Rust), `messageformat`, `i18next` (with ICU plugin).
NEVER concatenate translated fragments: `"You have " + count + "
orders"` is grammatically wrong in many languages. Always pass
the full sentence through the formatter.
#### 3. Use the platform Intl API
ECMA-402 (Intl) is universally available. Never hand-roll
formatting:
```typescript
// Numbers — locale-aware grouping + decimals
new Intl.NumberFormat('en-US').format(1234567.89); // "1,234,567.89"
new Intl.NumberFormat('de-DE').format(1234567.89); // "1.234.567,89"
new Intl.NumberFormat('fr-FR').format(1234567.89); // "1 234 567,89"
new Intl.NumberFormat('ar-EG').format(1234567.89); // "١٬٢٣٤٬٥٦٧٫٨٩"
// Currency
new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' })
.format(99.5); // "$99.50"
new Intl.NumberFormat('ja-JP', { style: 'currency', currency: 'JPY' })
.format(99.5); // "¥100" (no decimals)
// Dates
new Intl.DateTimeFormat('en-US', { dateStyle: 'long' })
.format(new Date()); // "May 26, 2026"
new Intl.DateTimeFormat('ja-JP-u-ca-japanese', { dateStyle: 'long' })
.format(new Date()); // "令和8年5月26日"
// Relative time
new Intl.RelativeTimeFormat('en').format(-1, 'day'); // "1 day ago"
new Intl.RelativeTimeFormat('fr').format(-1, 'day'); // "il y a 1 jour"
// List
new Intl.ListFormat('en', { style: 'long', type: 'conjunction' })
.format(['Apple', 'Banana', 'Cherry']); // "Apple, Banana, and Cherry"
new Intl.ListFormat('de').format(['Apfel', 'Banane', 'Kirsche']);
// "Apfel, Banane und Kirsche"
```
Equivalent APIs exist in every modern language (Java
`java.text.MessageFormat`, Python `babel`, Go `golang.org/x/text`,
Ruby `R18n`, etc.).
#### 4. Locale identification: BCP 47
The canonical format is BCP 47:
- Language: `en`, `fr`, `ar`, `zh`
- Language + region: `en-US`, `en-GB`, `pt-BR`, `pt-PT`
- Language + script: `zh-Hant` (Traditional), `zh-Hans` (Simplified)
- Full: `zh-Hant-HK`, `sr-Latn-RS` (Serbian in Latin script)
- Extensions: `en-US-u-ca-gregory-fw-mon` (Gregorian calendar,
week starts Monday)
The user's locale is stored on their profile + sent in
`Accept-Language` header + matched via Intl negotiation:
```typescript
const userLocale = Intl.getCanonicalLocales(req.user.locale ?? req.acceptsLanguages())[0];
```
#### 5. RTL languages mirror layout
Arabic, Hebrew, Persian, Urdu, and other RTL languages reverse
the reading direction. The layout MUST mirror:
- `<html dir="rtl">` (and `lang="ar"`) for RTL pages
- CSS logical properties (`margin-inline-start` not
`margin-left`, `padding-inline-end` not `padding-right`)
- `text-align: start` / `text-align: end` (not `left` / `right`)
- Icons reflect direction (arrow-back becomes arrow-forward in
RTL)
- BUT: numbers + Latin script don't flip; phone numbers stay
LTR even in RTL contexts
- BUT: progress bars + media seek bars typically don't flip
Test with at least one RTL locale (Arabic or Hebrew) — the
mirroring catches a class of "assumed left-to-right" bugs.
#### 6. Currency + payment locale
Different from display locale. A user in Germany browsing in
English might still expect prices in EUR:
| Concept | Source |
| --- | --- |
| **Display language** | User profile / Accept-Language |
| **Currency** | Account settings / billing region |
| **Tax calculation** | Billing address / VAT rules per country |
| **Date/time format** | Display language |
| **Number format** | Display language |
| **Phone format** | E.164 storage; libphonenumber-formatted by region |
| **Address format** | Per country (use a library — Google Address Components or `i18n-postal-address`) |
| **Name format** | Per culture (first/last vs family/given vs single name) |
#### 7. Text expansion + truncation
English → German: ~30% longer on average. English → French:
~25% longer. English → Russian: ~40% longer. Buttons + labels
designed for English break in other languages:
- Avoid fixed-width buttons / containers for text
- Reserve space for the longest reasonable translation
- Use ellipsis + tooltip for truncated long names — never silently
truncate
- Pseudo-localisation in dev: wrap every string in `[[ščẞ ... ščẞ]]`
(visible markers + accented characters + 30% padding) to surface
unlocalised strings + truncation issues
#### 8. Locale-aware errors
Per `error-codes.md` — codes are stable; messages translate.
Every error code has an i18n key:
```typescript
{
"errors.wallet_insufficient_funds": {
"en": "You don't have enough balance for this purchase.",
"fr": "Vous n'avez pas assez de solde pour cet achat.",
"ar": "لا يوجد لديك رصيد كافٍ لإتمام عملية الشراء هذه.",
"ja": "この購入を完了するための残高が不足しています。"
}
}
```
Error details that contain dynamic values (required vs available
balance) use ICU placeholders + Intl.NumberFormat for the
currency display.
#### 9. Translation memory + glossary
The translation infrastructure is more than catalog files:
- **Translation Management System (TMS)**: Crowdin, Lokalise,
POEditor, Phrase, Smartling
- **Translation memory** (TM): leverages prior translations for
consistency + cost reduction
- **Glossary**: brand terms, product names that should NOT be
translated; technical terms with prescribed translations
- **Context for translators**: screenshots, descriptions,
character limits, ICU rules
- **Locale QA pipeline**: native-speaker review for launch
locales; machine translation + post-edit for long-tail
Translators are not engineers — providing CONTEXT (where this
string appears, max length, gender/number variants) is the
engineering team's job.
#### 10. Locale fallback chain
User's preferred locale is `ko-KR`. The catalog has:
- `ko-KR.json` (most specific) — used first
- `ko.json` (language fallback) — used for missing keys
- `en.json` (default — typically the source language) — used
for missing keys above
The fallback chain is documented + the catalog includes coverage
metrics: "es-AR is 87% translated; missing keys fall back to es,
then en."
Untranslated strings should NEVER show as keys to the user
(`orders.placed.success` visible in the UI is a bug). They show
in the fallback language.
### Specific concerns
#### Plurals across languages
| Language | Plural forms | Categories |
| --- | --- | --- |
| English, Spanish | 2 | one, other |
| French | 2 | one, other (one includes 0 + 1) |
| Russian, Polish | 4 | one, few, many, other |
| Arabic | 6 | zero, one, two, few, many, other |
| Chinese, Japanese | 1 | other (no plural distinction) |
| Welsh | 6 | zero, one, two, few, many, other |
Hard-coding "1 item" / "{N} items" assumes English plurals; it
breaks in 6+ languages. Use ICU `plural` selector.
#### Date / time
| Format | Considerations |
| --- | --- |
| **Storage** | UTC ISO 8601 (`2026-05-26T14:32:18Z`); never local time |
| **Display** | User's timezone + locale-formatted (Intl.DateTimeFormat) |
| **Calendar systems** | Gregorian default; some users prefer Buddhist, Japanese, Islamic, Hebrew calendars |
| **Week start** | Monday (most of EU) vs Sunday (US) — Intl handles this |
| **First week** | ISO 8601 week 1 = first week with ≥ 4 days in the new year; US sometimes differs |
| **12 vs 24-hour clock** | Locale-determined |
#### Address formats
| Country | Format quirks |
| --- | --- |
| **US** | Street, City, State, ZIP |
| **UK** | House+Street, Town, County (optional), Postcode |
| **Japan** | Postcode FIRST, then prefecture → city → block → street → name |
| **Germany** | Street + house number, postcode + city |
| **Brazil** | Includes complement, CEP, neighborhood |
| **Egypt / Arabic** | Free-form often; building/street/city/governorate |
Use a library (Google Maps Address Components, `i18n-postal-
address`, Stripe Address Element) rather than hand-rolling.
#### Name formats
| Culture | Order |
| --- | --- |
| **Most Western** | Given Family |
| **East Asian** | Family Given (Chinese, Japanese, Korean, Vietnamese) |
| **Hungarian** | Family Given |
| **Single-name** | Single field (Indonesia, Iceland — patronymic) |
| **Arabic** | Multiple components (given, father's name, family name) |
Form: `first_name` + `last_name` is culturally biased. Use
`given_name` + `family_name` (CLDR terminology) OR a single
`full_name` field with a hint about ordering.
#### Search + sort collation
- Locale-aware sort: German `ä` sorts with `a` in DIN-1; with
`ae` in DIN-2 (phonebook); Swedish sorts `ä` after `z`
- Search match: case + accent insensitive in most user-facing
search ("café" matches "cafe")
- Use `Intl.Collator` for sort:
```typescript
arr.sort((a, b) => new Intl.Collator('de').compare(a, b));
```
### Per-stack tooling
#### Web (JS / TS)
- **react-intl** / **formatjs** — ICU MessageFormat in React
- **next-intl** — Next.js i18n
- **vue-i18n** — Vue 3
- **i18next** — framework-agnostic
- **lingui** — type-safe i18n
- **lit-localize** — Web Components
#### Mobile
- **iOS**: NSLocalizedString + `.strings` files + `.stringsdict`
for plurals
- **Android**: strings.xml + `<plurals>` element
- **React Native**: `i18next` + `react-native-localize`
- **Flutter**: `flutter_localizations` + ARB files
#### Backend
- **Node.js**: `@formatjs/intl` + `Intl`
- **Java**: `java.text.MessageFormat`, `ResourceBundle`, ICU4J
- **Python**: `babel` + `gettext`
- **Go**: `golang.org/x/text/language`, `go-i18n`
- **Ruby**: Rails i18n + `i18n-tasks`
- **.NET**: `IStringLocalizer<>` + `.resx` files
### Anti-patterns
#### Anti-pattern 1: "We'll add languages later"
Adding i18n after launch is 5-10x more expensive than building
i18n-aware from day one. Every concatenated string, every
hardcoded date format, every fixed-width button becomes a
multi-week refactor.
#### Anti-pattern 2: Auto-translate at runtime
Google Translate API on every request is slow, expensive,
inconsistent, and produces unprofessional output for product
strings. Use it ONLY for user-generated content (comments,
reviews) — and even then, with a "translated by machine"
disclaimer.
#### Anti-pattern 3: Localising only the UI
A localised UI that emails English receipts, sends English SMS
codes, and shows English error messages on backend failures is
not localised. ALL user-facing surfaces — including
transactional emails, SMS, push notifications, PDFs, support
chat — go through the same i18n pipeline.
#### Anti-pattern 4: One developer translates
The developer's "best-effort French" is worse than no translation
(it loses trust). Use professional translators or native
speakers; reserve machine translation for high-velocity / low-
quality-need surfaces with disclaimers.
#### Anti-pattern 5: Number / currency string concatenation
```typescript
// WRONG
return `$${amount}`;
// RIGHT
return new Intl.NumberFormat(locale, { style: 'currency', currency }).format(amount);
```
Currency placement (€100 vs 100€), thousands separator, decimal
separator, decimal precision (JPY has none) — all vary by locale.
### Cross-references
- `a11y.md` — RTL layouts, text expansion, screen-reader pronunciation
- `error-codes.md` — codes have i18n keys
- `gdpr-ccpa.md` — privacy notices in every supported locale
- `task-intake-due-diligence.md` Q13 (i18n)
- `documentation-requirements.md` — docs are i18n too for public
surfaces
- `feature-flags.md` — feature availability can vary by locale
- `audit-logging.md` — audit timestamps stored UTC
### Standards cited
- **Unicode CLDR (Common Locale Data Repository)** — the canonical
i18n data
- **ICU MessageFormat 4.6+**
- **ECMA-402: ECMAScript Internationalization API**
- **BCP 47 / RFC 5646** — Language tags
- **Unicode Standard 16.0** — Character handling
- **ISO 4217** — Currency codes
- **ISO 3166-1 alpha-2** — Country codes
- **W3C i18n Working Group recommendations**
### Why this rule exists
Most products start in English + one market. The "let's localise
later" plan looks reasonable until customer success starts
asking: "Can we sell to Germany?" The answer requires a
multi-week refactor that touches every UI string, every date
format, every currency display, every plural pattern, every form
field.
Costs of localising from day one:
- Catalog files instead of inline strings (zero ongoing cost)
- Intl.NumberFormat / DateTimeFormat instead of string templates
(already in the platform)
- ICU plural rules instead of `count === 1 ? "item" : "items"`
- 1 reusable translator workflow
Costs of localising after launch:
- Engineering refactor across the entire frontend
- New code paths for every formatter
- Test coverage for every locale
- Cultural review of UX patterns (e.g., name ordering, address
forms)
- Multiple deploy cycles to roll out per-locale
Build i18n-aware on day one even when launching in one language;
the catalog + Intl approach has zero overhead and saves quarter-
long retrofits later.
### Learning hooks
Per `~/.claude/rules/common/continuous-learning-mandate.md`:
**Signals to watch**:
- Hardcoded user-facing string shipped (rule 1 weakening — every string lives in catalog)
- Plural form expressed via `count === 1 ? "item" : "items"` (rule 2 violation — ICU MessageFormat
required)
- Hand-rolled number / date / currency formatter shipped (rule 3 weakening — Intl API mandatory)
- Locale identifier non-BCP-47 (rule 4 weakening)
- RTL layout missing on new UI for a locale that requires it (rule 5 weakening)
- Currency placement / decimal separator / thousands separator hardcoded (anti-pattern 5)
- Transactional email / SMS / push not routed through the i18n pipeline (anti-pattern 3 — partial
localisation)
- Auto-translate API used as the sole translation source for product strings (anti-pattern 2)
- Locale fallback chain produces visible key strings to users (rule 10 weakening)
**Refinement candidates**:
- New locale row when a launch locale gains support, including its plural form count + RTL flag
- New tooling row when a TMS / translation memory vendor becomes the team's choice
- Tightening of the "no concatenation" rule when a new context-sensitive surface (e.g., voice /
chat) emerges
- New cross-reference when a sister rule (a11y, error-codes) defines the i18n key shape the catalog
must align to
---