# Loom I18N

> Internationalization and localization patterns for multi-language applications.

- Skill: `cosmix/loom-i18n` (Agent Skill)
- Install (CLI): `npx skillmds@latest add cosmix/loom-i18n`
- Raw SKILL.md: https://api.skillmd.com/api/skills/cosmix/loom-i18n/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: cosmix (https://skillmd.com/u/cosmix)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/cosmix/loom-i18n

---


# Internationalization (i18n)

## Overview

Designing software to adapt to languages/regions without code changes (i18n), then adapting it per-locale (l10n). Covers translation architecture, ICU pluralization, `Intl`-based formatting, RTL/bidi, and libraries (i18next, react-intl/FormatJS, gettext).

## First: Check for an L10N.md Charter

Before writing or translating any string, check the project root for `L10N.md`. When it exists, it is the project's authoritative localization charter — read it in full and let it override the generic guidance in this skill wherever the two conflict. Derive from it:

- **Project context** — the domain the product operates in (financial, technological, medical, …). Domain dictates terminology register and tone: "credit" means one thing in a banking app and another in a game, and a payments product cannot afford a casual mistranslation of a regulated term.
- **What to translate directly and what not** — the charter's do-not-translate list: brand and product names, trademarks, legal or regulated terms, technical identifiers. Never translate an entry on that list, even when a natural target-language equivalent exists.
- **Branding and glossary** — approved per-locale renderings of recurring product terms. Reuse them verbatim; inventing a second translation for an established term fragments the product's voice across locales.

Only the **root** `L10N.md` is read. Some projects nest per-area charters (`server/L10N.md`) or per-locale overrides (`L10N/es.md`); those are not consulted here, so anything that must always apply belongs in the root file.

When `L10N.md` does not exist, skip this step entirely — proceed with the generic guidance below, and do not create the file unasked.

## The Rules That Prevent Rework

1. **Never concatenate translated fragments.** Word order, gender agreement, and grammar differ per language. Use one full-sentence key with named placeholders; the translator controls order.

   ```javascript
   // Wrong — impossible to translate; order is baked into code
   t("You have") + " " + count + " " + t("new messages");
   // Right — one message, interpolation + plural inside it
   t("inbox.newMessages", { count }); // "{count, plural, one {# new message} other {# new messages}}"
   ```

2. **Pluralize with CLDR categories, never `if (count === 1)`.** English has 2 forms; Russian/Polish 3–4; Arabic 6 (`zero one two few many other`). Which categories a language uses is defined by CLDR — use ICU MessageFormat / `Intl.PluralRules`, not hand-rolled logic.
3. **Format with `Intl`, never by hand.** Decimal/grouping separators, currency placement, date order, and calendars are locale data you will get wrong manually (`1,234.56` en-US vs `1.234,56` de-DE vs `1 234,56` fr-FR).
4. **A locale is language + region.** `en-US` vs `en-GB`: `12/25/2024` vs `25/12/2024`, `color` vs `colour`, different currency. Store and resolve full BCP-47 tags; fall back language-only → default.
5. **Store timestamps in UTC, format at display time** in the user's timezone/locale. Never store locale-formatted strings.
6. **RTL is not just `direction`.** Use CSS logical properties, isolate bidirectional runs, and mirror directional icons.

## Architecture

```text
src/locales/{en,de,ar}/{common,products,errors}.json   # one dir per locale, split by namespace
src/i18n/config.ts
```

- Externalize every user-facing string. Hardcoded text is the #1 bug — catch it with pseudolocalization (below) and lint rules.
- **Namespaces** split catalogs by feature/route so you lazy-load only what a view needs (huge for bundle size on large apps).
- **Fallback chain**: `de-AT → de → en`. Configure it; missing keys should degrade, not render blank or crash.
- Keys are **descriptive and hierarchical** (`products.card.addToCart`), never the source English text (brittle: fixing a typo breaks every locale's lookup). Add context when a word is ambiguous (`button.close` vs `proximity.close`).

```typescript
interface LocaleConfig {
  code: string;                 // "en-US" (BCP-47)
  direction: "ltr" | "rtl";
  currency: string;             // ISO 4217, "USD"
}
```

## Translation File Formats

**JSON (i18next / react-intl)** — most common. i18next plural keys use a suffix per CLDR category:

```json
{
  "welcome": "Welcome, {{name}}!",
  "items_one": "{{count}} item",
  "items_other": "{{count}} items",
  "price": "Price: {{price, currency}}"
}
```

⚠ i18next v4+ derives the suffix (`_one`, `_other`, `_few`, `_many`, `_zero`, `_two`) from `Intl.PluralRules` for the active language — you must provide every category the language needs (Arabic needs all six), and you must pass `{ count }` for suffix selection to fire.

**ICU MessageFormat** (react-intl/FormatJS, and i18next via a plugin) puts logic inside the string:

```text
{count, plural, =0 {No items} one {# item} other {# items}}
{gender, select, male {He} female {She} other {They}} liked your post.
{place, selectordinal, one {#st} two {#nd} few {#rd} other {#th}}
```

- `=0` is an *exact* match (checked before category); `one`/`other`/etc. are CLDR categories; `#` prints the number.
- Nest plural inside select for gender-correct counts. Don't over-nest — hand it to translators as one message.

**PO/gettext** (Python/PHP/Ruby): source string is the key, plurals via `msgid_plural` + `Plural-Forms` header. `xgettext` extracts, translators use Poedit.

## Formatting with `Intl`

One class over the standard `Intl` objects covers most needs. **Cache formatter instances** — constructing `Intl.*Format` is expensive; reuse per (locale, options).

```typescript
const nf = new Intl.NumberFormat("de-DE");
nf.format(1234567.89);                                              // "1.234.567,89"
new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR" }).format(99.9); // "99,90 €"
new Intl.NumberFormat("en", { notation: "compact" }).format(12000);// "12K"
new Intl.DateTimeFormat("ja-JP", { dateStyle: "long" }).format(d); // "2024年12月19日"
new Intl.RelativeTimeFormat("de", { numeric: "auto" }).format(-1, "day"); // "gestern"
new Intl.ListFormat("en", { type: "conjunction" }).format(["A","B","C"]); // "A, B, and C"
new Intl.PluralRules("ar").select(3);                              // "few"  → pick the message key
```

⚠ Gotchas:

- `dateStyle`/`timeStyle` can't be combined with individual component options (`year`, `month`, …) in one `DateTimeFormat` — pick one style. Pass `timeZone` for server-consistent output; default is the runtime's zone.
- Currency: pass an explicit ISO 4217 code; the locale controls *placement/symbol*, not *which* currency. `narrowSymbol` for "$" over "US$".
- Prefer `dateStyle` presets over custom component lists — presets already encode locale order (MDY vs DMY vs YMD); custom lists you assemble often leak English order.

## RTL & Bidirectional Text

**Use logical properties** so one stylesheet serves both directions — no `[dir=rtl]` overrides:

```css
.card {
  margin-inline-start: 1rem;   /* left in LTR, right in RTL */
  padding-inline: 1rem;
  border-inline-start: 3px solid;
  text-align: start;
}
.icon:dir(rtl) { transform: scaleX(-1); } /* mirror only directional icons: arrows, chevrons, back — NOT logos/checkmarks */
```

- Set `<html dir="rtl" lang="ar">`; flexbox/grid flow follows `dir` automatically.
- **Bidi isolation**: user-generated or opposite-direction text embedded in a sentence (an Arabic name in English UI, a phone number) can reorder surrounding punctuation. Wrap it in `<bdi>…</bdi>`, or `unicode-bidi: isolate`, or the Unicode isolates `⁨…⁩` (FSI/PDI) in plain strings. Numbers next to RTL text are the classic breakage.
- RTL testing must use *real* translated RTL content, not mirrored Lorem Ipsum — bidi bugs only surface with genuine strings.

## Locale Detection & Switching

Precedence: explicit URL/param → cookie/stored pref → `Accept-Language` (parse `q` weights, match against supported set) → default. Validate against your supported list before use.

```typescript
function parseAcceptLanguage(header: string): string[] {
  return header.split(",")
    .map(part => { const [code, q] = part.trim().split(";q="); return { code, q: parseFloat(q) || 1 }; })
    .sort((a, b) => b.q - a.q)
    .map(x => x.code);
}
```

On switch, update three things: the i18n instance, `document.documentElement.lang`, and `.dir`. Persist the choice. SPA switch should not require a reload.

## Sorting & Search

String comparison is locale-dependent — **never `.sort()` raw** for user-visible lists (default sorts by code point: `Z` < `a`, `ä` after `z`). Use `Intl.Collator`:

```typescript
const collator = new Intl.Collator("de", { sensitivity: "base", numeric: true });
list.sort(collator.compare); // locale-correct; numeric:true gives "file2" < "file10"
```

`sensitivity: "base"` ignores case/accents (good for search/dedup); `numeric: true` for natural number ordering. Reuse the collator instance.

## Libraries

**i18next** (framework-agnostic, plugin-rich):

```typescript
i18n.use(Backend).use(LanguageDetector).use(initReactI18next).init({
  fallbackLng: "en",
  supportedLngs: ["en", "de", "fr", "ar"],
  ns: ["common", "products"], defaultNS: "common",
  backend: { loadPath: "/locales/{{lng}}/{{ns}}.json" },   // lazy per-namespace
  detection: { order: ["querystring", "cookie", "navigator"], caches: ["cookie"] },
  interpolation: { escapeValue: false }, // React already escapes; escapeValue:true double-encodes
});
```

```tsx
const { t, i18n } = useTranslation(["products", "common"]);
t("products:price", { price });
t("common:items", { count });                 // plural via count
// Interpolation inside markup — use <Trans>, do NOT build strings from JSX children
<Trans i18nKey="products:promo" values={{ name }}>Check out <strong>{{ name }}</strong> today!</Trans>
```

⚠ `escapeValue: false` in React is correct (React escapes); leaving it `true` double-encodes. Outside React, keep escaping on to avoid XSS from interpolated values.

**react-intl / FormatJS** (ICU-native, standards-aligned):

```tsx
<IntlProvider locale={locale} messages={messages[locale]} defaultLocale="en">…</IntlProvider>
const intl = useIntl();
intl.formatMessage({ id: "app.items" }, { count: 5 });     // ICU plural in the message
<FormattedMessage id="app.greeting" values={{ name }} />
intl.formatNumber(1234.56, { style: "currency", currency: "EUR" });
```

Ships `@formatjs/cli` to extract messages and precompile ICU ASTs (faster runtime). Wire `onError` so missing IDs are caught in CI, not shipped blank.

**Python gettext:**

```python
t = gettext.translation("messages", localedir, languages=["de"], fallback=True)
_ = t.gettext; ngettext = t.ngettext
_("Welcome, %(name)s!") % {"name": user}
ngettext("%(count)d item", "%(count)d items", count) % {"count": count}
```

## Testing & QA

**Pseudolocalization** — the highest-ROI check. Transform the default locale into accented, expanded text to catch two bug classes at once:

```text
"Add to Cart"  →  "[!! Àdd tö Çårt ~~~~ ]"
```

- Untransformed on screen ⇒ a **hardcoded string** bypassing i18n.
- Padding (+30–40%, mimicking German/Finnish) ⇒ **truncation/overflow** before real translations exist.

Also test: longest language (German/Finnish) for overflow; a real RTL locale for layout+bidi; that number/date/currency render per locale (not just English); missing-key fallback path.

**Extraction tooling** (don't hand-roll AST walkers): `i18next-parser` for i18next catalogs, `@formatjs/cli extract` for react-intl, `xgettext`/Babel for gettext. Run in CI to fail on new untranslated keys and prune dead ones.

## Verify Before Done

- [ ] If `L10N.md` exists at the project root: translations follow its project context, do-not-translate list, and glossary
- [ ] Zero hardcoded user-facing strings (pseudoloc pass is clean)
- [ ] No concatenated sentence fragments; each message is one key with named placeholders
- [ ] Plurals use ICU/`PluralRules` categories (every category the language needs), not `count === 1`
- [ ] All numbers/dates/currencies/lists via `Intl` (formatters cached), not manual formatting
- [ ] Timestamps stored UTC; formatted at display in user tz/locale
- [ ] Fallback chain configured; missing keys degrade gracefully (no blanks/crashes)
- [ ] RTL: logical CSS properties, `dir`+`lang` on `<html>`, bidi isolation on embedded runs, directional icons mirrored
- [ ] User-visible lists sorted via `Intl.Collator`, not raw `.sort()`
- [ ] Locales lazy-loaded by namespace; switch updates i18n + `lang` + `dir` without reload
- [ ] Interpolation escaping correct for the runtime (React: `escapeValue:false`; server: escape on)
- [ ] Verified in ≥2 locales incl. one long-word and one RTL, with real translated content

