Skip to content
Back to skills

I18n Rules

ASecurity

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 /...

  • 12 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added September 28, 2026
developmenttypescriptpythonrustgojavarubyreactvuenextjsnode

Works with

  • api

Security analysis

A100/100

Scanned September 28, 2026

npx -y skills add Nmor/the-claude-council --skill i18n-rules --agent claude-code

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.

Security grade badge for I18n Rules
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/nmor-i18n-rules/badge)](https://www.skillsdirectory.com/skills/nmor-i18n-rules)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

SKILL.md
---
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

---

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…