Internationalization and localization patterns for multi-language applications.
Scanned 9/5/2026
Install to Claude Code
npx -y skills add cosmix/loom --skill loom-i18n --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Loom I18n?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/cosmix-loom-i18n)More formats (shields.io, HTML) on the badges page.
---
name: loom-i18n
description: Internationalization and localization patterns for multi-language applications.
triggers:
- i18n
- internationalization
- l10n
- localization
- translation
- translate
- locale
- language
- multilingual
- multi-language
- RTL
- right-to-left
- LTR
- bidirectional
- pluralization
- plural forms
- date format
- time format
- number format
- currency format
- timezone
- i18next
- react-intl
- FormatJS
- gettext
- ICU MessageFormat
- message format
- language detection
- language switching
- Accept-Language
- locale fallback
- translation keys
- translation files
- JSON translations
- PO files
- YAML translations
- react i18n
- React localization
- format date
- format number
- format currency
- format relative time
- Intl API
- NumberFormat
- DateTimeFormat
- RTL CSS
- logical properties
- direction-aware
- language code
- region code
- locale identifier
- BCP47
- ISO 639
- translation extraction
- pseudo-localization
- namespace
- translation namespace
- Intl.Collator
- Intl.PluralRules
- L10N.md
- localization charter
- do-not-translate
- glossary
- brand terms
---
# 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
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!