Files
2026-05-27 23:29:53 +01:00

1024 lines
69 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# i18n
## Purpose
Internationalization core for the Suaveplan monorepo — namespaced message catalog with lazy loaders and missing-key hooks, ICU-like interpolation engine, pluralization rules for nine languages, BCP-47 locale resolver with cookie/query/header strategies, locale middleware, and date/currency formatting via `Intl`. Errors flow through `I18nError` extending `SuaveplanError`, with all options Zod-validated at the boundary.
## Requirements
### I18N-01 — Structured error hierarchy
An `I18nError` base class MUST extend `SuaveplanError` from `@suaveplan/error`. All errors thrown by this package MUST extend `I18nError`. Six error codes MUST be registered via `defineCodes` under the `I18N.*` namespace:
- `I18N.MISSING_KEY` — key not found in the catalog when strict mode is enabled and no `MissingKeyHandler` recovers a value
- `I18N.INVALID_LOCALE` — locale string fails BCP-47 structural validation
- `I18N.LOADER_FAILED` — async `MessageLoader` rejected or returned a value that does not satisfy `TranslationRecordSchema`
- `I18N.INTERPOLATION_ERROR` — template rendering failed (e.g. unclosed variable delimiter, formatter threw)
- `I18N.PLURAL_RULE_NOT_FOUND` — no plural rule is registered for the locale and strict mode is enabled
- `I18N.INVALID_OPTIONS` — engine constructor option schema (`CatalogOptionsSchema`, `InterpolationConfigSchema`, `ResolverConfigSchema`, formatter option schemas) failed Zod parse; `context.zodIssues` carries the issue array
Error codes MUST be auto-registered via a side-effect import in `src/index.ts` (same pattern as `@suaveplan/error`).
### I18N-02 — Zod schemas at the public boundary
The following Zod schemas MUST be exported and match the spec field names exactly:
- `TranslationRecordSchema` — recursive `z.record(z.string(), z.union([z.string(), z.lazy(...)]))` for nested translation trees; rejects arrays and non-string leaves
- `CatalogOptionsSchema` — validates all fields of `CatalogOptions` including optional `missingKeyHandler`, `defaultLocale`, and `strict?: boolean` (default `false`)
- `InterpolationConfigSchema` — validates `{ open: string (≥1, ≠ close), close: string (≥1), escape: string (length 0 or 1), maxIterations: int 1..100 }`
- `ResolverConfigSchema` — validates `{ strategies: ResolutionStrategy[] (non-empty), fallbackLocale: string (≥1), supportedLocales?: string[] }`
`MessageCatalog`, `InterpolationEngine`, and `LocaleResolver` constructors MUST call `.parse(options)` and re-throw failures as `I18nError` with the relevant code (`I18N.INVALID_LOCALE` for resolver, `I18N.INTERPOLATION_ERROR` for interpolation, `I18N.INVALID_OPTIONS` for catalog and any other engine schema mismatch) and `context.zodIssues` populated with the issue array.
### I18N-03 — Message catalog contract
`MessageCatalog<TLocale, TMessages>` MUST:
- Accept namespaced `TranslationRecord` trees; key lookup MUST use dot-path resolution within the namespace.
- Expose `set(locale, namespace, record)`, `load(locale, namespaceOrLoader, loader?)` (overloaded so legacy `load(locale, loader)` still compiles), `keys(locale, namespace?)` (scoped to namespace when provided), `has(key, locale?)`, `get(key, locale?)`, and `t(key, locale?)`.
- Return `LookupResult<TLocale> = { value: string | undefined; locale: TLocale; namespace: string; source: 'catalog' | 'fallback' | 'handler' | 'key' }` from `get`.
- Support async `MessageLoader` per `(locale, namespace)` pair; concurrent calls for the same `${locale}::${namespace ?? ''}` key MUST be deduplicated (single in-flight Promise) via an internal `Map<string, Promise<void>>`.
- Wrap loader rejection as `I18nError(I18N.LOADER_FAILED, 'i18n.loader.failed', { cause, context: { locale, namespace } })`. Loaded records MUST be structurally validated; failure surfaces the same `I18N.LOADER_FAILED` code.
- Invoke `MissingKeyHandler` (if registered) before throwing; if the handler returns a string, surface it with `source: 'handler'`.
- In strict mode (default `false`), throw `I18nError(I18N.MISSING_KEY)` when no value is found after handler fallback; otherwise return `{ value: undefined, ..., source: 'key' }` (and `t()` returns the key string).
- Provide typed key inference via `t<K extends DotPaths<TMessages> | (string & {})>(key: K, locale?)` and `get<K extends DotPaths<TMessages> | (string & {})>(key: K, locale?)`.
- `deepMerge(target, source)` MUST perform a deep structural merge of two `TranslationRecord` trees; source values win on scalar conflicts.
### I18N-04 — Interpolation engine contract
`InterpolationEngine` MUST:
- Replace `{variableName}` tokens (default delimiters configurable via `InterpolationConfig`) with values from `InterpolationValues`.
- Support a `ValueFormatter` map: `{ [variableName]: (value, locale) => string }` for locale-aware per-variable formatting.
- When `escapeHtml: true` in `InterpolationOptions`, HTML-escape all substituted values using `escapeHtmlChars` BEFORE insertion; raw template text is NOT escaped.
- Throw `I18nError(I18N.INTERPOLATION_ERROR)` when an opening delimiter is found without a matching closing delimiter.
- Wrap any thrown value from a `ValueFormatter` as `I18nError(I18N.INTERPOLATION_ERROR, 'i18n.interpolation.formatter_failed', { cause, context: { variable } })`.
- Accept canonical option keys `{ open, close, escape, maxIterations }`. Transitional aliases `{ prefix, suffix, maxDepth }` MUST be accepted and mapped to the canonical keys; mixing canonical and alias keys in the same options object MUST throw.
- `escapeHtmlChars(str)` MUST escape `&` → `&amp;`, `<` → `&lt;`, `>` → `&gt;`, `"` → `&quot;`, `'` → `&#39;`.
- `escapeRegex(str)` MUST escape all regex metacharacters: `. * + ? ^ $ { } [ ] | ( ) \`.
### I18N-05 — Pluralization engine contract
`PluralizationEngine` MUST:
- Maintain a `PluralRuleRegistry` that callers can write to via `registerRule(locale, rule)`. Built-in hand-written rules for Arabic, Chinese, English, French, German, Polish, Russian, Spanish, Welsh, and `defaultPluralRule` SHALL remain registered as overrides.
- `.getRule(locale)` selects in this order: (1) explicit override registered via `registerRule`, (2) `Intl.PluralRules(locale)` when the runtime supports it, (3) the hand-written rule registry, (4) `defaultPluralRule`.
- `.resolve(count, locale, messages, opts?)` MUST call the resolved rule, fall back to the base-language rule (strip region), then fall back to `defaultPluralRule`; return `PluralResolveResult { category: PluralCategory; value: string }`.
- `PluralCategory` MUST be `'zero' | 'one' | 'two' | 'few' | 'many' | 'other'`.
- In strict mode, throw `I18nError(I18N.PLURAL_RULE_NOT_FOUND)` when no rule exists for the locale and its base language and `Intl.PluralRules` is unavailable.
- `getBaseLocale(locale)` strips the region subtag (`en-US` → `en`).
- `normalizeLocale(locale)` normalizes to lowercase language + uppercase region (`EN-us` → `en-US`).
### I18N-06 — Message parser contract
`MessageParser` MUST parse ICU-like message strings into an AST of `MessageNode` values:
- `TextNode` — literal text segment
- `VariableNode` — `{name}` — simple variable substitution
- `PluralNode` — `{count, plural, one{…} other{…}}` — plural form with `PluralCategory` branches
- `SelectNode` — `{gender, select, male{…} female{…} other{…}}` — arbitrary select form
`renderNodes(nodes, values, opts?)` MUST render an AST to a string by substituting values; unknown `VariableNode` references fall back to the variable name in braces.
`extractVariables(message)` MUST return the deduplicated list of variable names referenced in a raw message string without fully parsing the AST.
### I18N-07 — Locale resolver contract
`LocaleResolver` MUST implement a pluggable strategy chain. Built-in `ResolutionStrategy` sources: `'cookie'`, `'query'`, `'header'` (Accept-Language), `'default'`. `.resolve(request, config)` MUST:
- Try each strategy in `config.strategies` order; return the first locale that is in `config.supportedLocales` (if provided) and passes BCP-47 validation.
- Fall back to `config.fallbackLocale` when no strategy yields a valid supported locale.
- Return `ResolvedLocale { locale: string; source: ResolutionStrategy | 'fallback'; direction: TextDirection }`.
The `LocaleResolver` constructor MUST validate options via `ResolverConfigSchema` (where `strategies` is the pluggable-chain field; the existing exact/alias/language/region matcher remains unchanged but now lives behind a `strategies: ['exact', 'alias', 'language', 'region', 'default']` default).
`normalizeLocaleCode(code)` MUST validate input against `/^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})*$/` and throw `I18nError(I18N.INVALID_LOCALE)` for non-matching strings (including empty).
### I18N-08 — Locale middleware contract
`createLocaleMiddleware(config)` MUST return a middleware function compatible with standard Node.js `(req, res, next)` signatures where `req` is `I18nRequest` (extends a plain object with `locale?: ResolvedLocale`). The middleware MUST:
- Invoke `detectLocale(request, config)` to resolve the locale.
- Attach `ResolvedLocale` to `request.locale` (with `direction: TextDirection`).
- When an optional `setHeader(name, value)` callback is supplied in `ExtendedMiddlewareConfig`, invoke `setHeader('Content-Language', resolved.locale)` after the lookup completes.
- Call `next()` unconditionally after attachment.
`getDirection(locale)` MUST return `'rtl'` for locales in `RTL_LOCALES`, otherwise `'ltr'`.
### I18N-09 — Date formatting contract
All date formatting functions MUST be backed by `Intl.DateTimeFormat` with no hard-coded locale-specific strings in implementation code.
- `formatDate(date, locale, opts?)` — full date + time per `DateFormatPreset` or custom `Intl.DateTimeFormatOptions`
- `formatDateOnly(date, locale, opts?)` — date portion only
- `formatTime(date, locale, opts?)` — time portion only
- `formatDateRange(start, end, locale, opts?)` — uses `Intl.DateTimeFormat.prototype.formatRange` where the runtime supports it; falls back to two separate formatted strings joined with an em-dash
- `getDayNames(locale, format?)` — returns 7-element array via `Intl.DateTimeFormat` part extraction; `format` is `'long' | 'short' | 'narrow'`
- `getMonthNames(locale, format?)` — returns 12-element array; same `format` options
`DateFormatPreset` MUST include at minimum: `'short'`, `'medium'`, `'long'`, `'full'`, `'relative'`.
### I18N-10 — Currency formatting contract
All currency formatting MUST be backed by `Intl.NumberFormat` with `style: 'currency'`.
- `formatCurrency(amount, currency, locale, opts?)` — full formatted string including symbol (e.g. `$1,234.56`)
- `formatCurrencyValue(amount, currency, locale, opts?)` — numeric value only, no symbol (e.g. `1,234.56`)
- `getCurrencySymbol(currency, locale)` — extracts the currency symbol via `Intl.NumberFormat` parts
- `getCurrencyName(currency, locale)` — localized currency name via `Intl.DisplayNames` with `type: 'currency'`
- `getCurrencyInfo(currency)` — returns `CurrencyInfo { code: CommonCurrency; numericCode: number; decimalDigits: number; name: string }`
`CommonCurrency` MUST be a typed string-literal union covering at minimum: `'USD'`, `'EUR'`, `'GBP'`, `'JPY'`, `'CNY'`, `'CAD'`, `'AUD'`, `'CHF'`, `'SEK'`, `'NOK'`, `'DKK'`, `'SGD'`, `'HKD'`, `'NZD'`, `'KRW'`, `'INR'`, `'BRL'`, `'MXN'`, `'ZAR'`, `'RUB'`.
### I18N-11 — Utility functions contract
- `flattenRecord(record, separator?)` MUST convert a nested `TranslationRecord` into a flat `Record<string, string>` using dot-separated paths; separator defaults to `'.'`
- `unflattenRecord(flat, separator?)` MUST reconstruct a nested object from flat dot-path keys; conflicts (key is both a leaf and a node prefix) MUST throw
- `countKeys(record)` MUST return the total count of string leaf values in a (nested) record
- `getNestedValue(record, key, separator?)` MUST return the string at the dot-path or `undefined` if absent
- `hasKey(record, key, separator?)` MUST return `true` iff the dot-path resolves to a string leaf
- `buildKey(...segments)` MUST join segments with `'.'`, filtering empty strings
- `parseKey(key, separator?)` MUST split on separator, returning `string[]`; `buildKey(...parseKey(key)) === key` for any valid key
- `getTextDirection(locale)` / `isRtl(locale)` MUST consult `RTL_LOCALES`
- `sanitize(html, opts?)` MUST strip disallowed tags and attributes; `SanitizeOptions` controls `allowedTags` and `allowedAttributes`; default `allowedTags` is `[]` (strip all tags)
### I18N-12 — OTel instrumentation
`src/observability/metrics.ts` MUST export an `i18nMetrics` singleton built from `@opentelemetry/api` `getMeter('suaveplan.i18n')`. All instruments MUST be no-ops when no exporter is registered (default behaviour of `@opentelemetry/api`).
- `translateTotal: Counter` — `i18n.translate.total`, incremented inside `MessageCatalog.get` with attributes `{ outcome: 'hit' | 'miss', locale }`.
- `localeResolveTotal: Counter` — `i18n.locale.resolve.total`, incremented inside `detectLocale` with attributes `{ strategy, locale }` (winning strategy name or `'fallback'`).
- `translateDuration: Histogram` — `i18n.translate.duration_ms`, records elapsed milliseconds for each `MessageCatalog.get` call with attribute `{ locale }`.
### I18N-13 — Property-based test requirements
The following properties MUST be verified with fast-check arbitrary inputs:
1. **flattenRecord/unflattenRecord round-trip** — for any depth-≤3 record of string leaves (no key collisions), `unflattenRecord(flattenRecord(r, sep), sep)` deep-equals `r`
2. **normalizeLocale idempotent** — `normalizeLocale(normalizeLocale(s)) === normalizeLocale(s)` for any non-empty ASCII string `s`
3. **HTML escaping completeness** — for any string value containing one or more of `<`, `>`, `&`, `"`, `'`, `InterpolationEngine.render` with `escapeHtml: true` MUST NOT emit any of those characters unescaped in the returned string
4. **buildKey/parseKey round-trip** — for any non-empty array of non-empty, dot-free strings `segs`, `parseKey(buildKey(...segs)) deep-equals segs`
### I18N-14 — Benchmark requirements
Benchmarks MUST be implemented with mitata and MUST exercise the following hot paths:
- `MessageCatalog.get` — 100 000 iterations on a pre-loaded catalog containing 1 000 keys spread across 3 namespaces and 2 locales
- `InterpolationEngine.render` — 10 000 iterations with a fixed template containing 5 named variable placeholders and a matching `InterpolationValues` map
- `PluralizationEngine.resolve` — 10 000 iterations cycling through counts `[0, 1, 2, 5, 11, 100]` for English, Polish, and Arabic rules
Baseline numbers MUST be published in `README.md` after the first green run.
### I18N-15 — Package hygiene
- `"sideEffects": false` in `package.json`; enforced by a tree-shake guard test in `tests/`
- Vitest coverage thresholds MUST be set to 100/100/100/100 (statements/branches/functions/lines); provider MUST be `istanbul`
- No `it.skip`, `test.skip`, `describe.skip`, or `.only` left in `src/` or `tests/`
- Zero browser-specific or Node.js-specific globals in implementation code — pure TypeScript that runs in Bun, Node, Deno, and browser environments; `Intl` API is universally available and permitted
- All internal workspace dependencies use `workspace:^`; all external dependencies use Bun catalog entries
### I18N-16 — `INVALID_OPTIONS` error code
A new code `I18N.INVALID_OPTIONS` MUST be registered alongside the other `I18N.*` codes (see I18N-01) and used by all engines when their constructor option schema fails Zod parse. The thrown `I18nError` MUST carry `context.zodIssues` populated with the issue array from the Zod failure.
### I18N-17 — Typed key inference
`@suaveplan/i18n` MUST export `DotPaths<T>` (template-literal recursion over a `TranslationRecord`-like tree) and `MessageCatalog<TLocale, TMessages>` MUST narrow `t`/`get` to `K extends DotPaths<TMessages> | (string & {})`. The `(string & {})` branch is the explicit escape hatch that preserves runtime flexibility while still letting TypeScript autocomplete known keys.
### I18N-18 — `Intl.PluralRules` fallback
`PluralizationEngine` MUST consult `Intl.PluralRules(locale).select(n)` after the registered-rule chain misses but before falling back to `defaultPluralRule`. This ensures CLDR-tracked languages added in future ICU updates work without registering a hand-rolled rule. In strict mode, if `Intl.PluralRules` itself throws on construction (invalid locale), the engine MUST throw `I18nError(I18N.PLURAL_RULE_NOT_FOUND)`.
### Requirement: Backend interface contract
The package SHALL expose a `Backend` interface that any external translation source can implement. The interface SHALL declare a stable `name: string`, a required `read(locale, namespace) → Promise<TranslationRecord>` method, and four optional methods: `readMulti?(locale, namespaces) → Promise<Record<string, TranslationRecord>>`, `save?(locale, namespace, record) → Promise<void>`, `lastModified?(locale, namespace) → Promise<number | undefined>`, and lifecycle hooks `init?(catalog)` / `dispose?()`. Implementations SHALL surface transport / parse failures as `I18nError(I18N.BACKEND_READ_FAILED)` (read path) or `I18nError(I18N.BACKEND_WRITE_FAILED)` (write path) with the underlying error attached as `cause`.
#### Scenario: read returns a TranslationRecord
- **WHEN** a registered `Backend.read('en', 'auth')` resolves with `{ login: { title: 'Sign in' } }`
- **THEN** the catalog stores it under `(en, auth)` and a subsequent `t('auth:login.title')` returns `'Sign in'`
#### Scenario: read failure maps to I18N.BACKEND_READ_FAILED
- **WHEN** a backend's `read` rejects with a `TypeError`
- **THEN** the catalog throws `I18nError` with code `I18N.BACKEND_READ_FAILED` and `error.cause === TypeError`
#### Scenario: missing readMulti falls back to N reads
- **WHEN** `MessageCatalog.preload(['en'], ['auth', 'common'])` is called against a backend that omits `readMulti`
- **THEN** `read` is invoked once for each `(locale, namespace)` pair concurrently and the catalog merges the results
**Verified by:** `packages/i18n/i18n/src/core/backend/backend.test.ts::"read populates catalog"` AND `packages/i18n/i18n/src/core/backend/backend.test.ts::"read failure surfaces BACKEND_READ_FAILED"` AND `packages/i18n/i18n/src/core/backend/backend.test.ts::"falls back to per-namespace read when readMulti is absent"`
---
### Requirement: MemoryBackend reference implementation
The package SHALL ship a `MemoryBackend` implementation backed by `Map<locale, Map<namespace, TranslationRecord>>`. It SHALL implement `read`, `readMulti`, `save`, and `lastModified`. Construction SHALL accept an optional `seed: Record<locale, Record<namespace, TranslationRecord>>` for tests and seeded defaults.
#### Scenario: seed is read on first request
- **WHEN** `new MemoryBackend({ seed: { en: { auth: { login: { title: 'Sign in' } } } } })` then `read('en', 'auth')`
- **THEN** the resolved record deep-equals the seeded value
#### Scenario: save then read round-trips
- **WHEN** `backend.save('en', 'auth', { login: { title: 'Sign in' } })` is called, then `backend.read('en', 'auth')`
- **THEN** the resolved record deep-equals the saved value AND `lastModified('en', 'auth')` returns a number ≥ the timestamp before `save`
**Verified by:** `packages/i18n/i18n/src/core/backend/memory/memory-backend.test.ts::"reads seeded values"` AND `packages/i18n/i18n/src/core/backend/memory/memory-backend.test.ts::"save then read round-trips with lastModified"`
---
### Requirement: HttpBackend with templated load path
The package SHALL ship an `HttpBackend` that fetches translations from a URL constructed from a `loadPath` template containing `{locale}` and `{namespace}` placeholders. Defaults: `fetchFn = globalThis.fetch`, `parsePayload = (res) => res.json()`. A non-2xx HTTP response SHALL surface as `I18nError(I18N.BACKEND_READ_FAILED)` with `context.status` set to the HTTP status code. A 404 SHALL surface as `I18nError(I18N.BACKEND_LOCALE_NOT_AVAILABLE)` so chained backends can fall through.
#### Scenario: GET /locales/en/auth.json returns translations
- **WHEN** `new HttpBackend({ loadPath: '/locales/{locale}/{namespace}.json', fetchFn })` then `read('en', 'auth')` with `fetchFn` resolving to `Response.json({ login: { title: 'Sign in' } })`
- **THEN** the resolved record equals `{ login: { title: 'Sign in' } }` AND `fetchFn` was called with `'/locales/en/auth.json'`
#### Scenario: HTTP 404 surfaces LOCALE_NOT_AVAILABLE
- **WHEN** `fetchFn` resolves with status 404
- **THEN** the call rejects with `I18nError(I18N.BACKEND_LOCALE_NOT_AVAILABLE)` and `context.status === 404`
#### Scenario: HTTP 500 surfaces BACKEND_READ_FAILED
- **WHEN** `fetchFn` resolves with status 500
- **THEN** the call rejects with `I18nError(I18N.BACKEND_READ_FAILED)` and `context.status === 500`
**Verified by:** `packages/i18n/i18n/src/core/backend/http/http-backend.test.ts::"fetches templated URL"` AND `packages/i18n/i18n/src/core/backend/http/http-backend.test.ts::"404 surfaces LOCALE_NOT_AVAILABLE"` AND `packages/i18n/i18n/src/core/backend/http/http-backend.test.ts::"5xx surfaces BACKEND_READ_FAILED"`
---
### Requirement: StorageBackend wraps any AbstractStorage
The package SHALL ship a `StorageBackend` that adapts any `AbstractStorage` instance from `@suaveplan/storage` into a `Backend`. It SHALL support three storage layouts via a `layout` option:
- `single-key-per-namespace` (default): key = `${prefix}/${locale}/${namespace}.json`, value = JSON-encoded `TranslationRecord`
- `single-key-per-locale`: key = `${prefix}/${locale}.json`, value = `Record<namespace, TranslationRecord>`
- `flat-keys`: key = `${prefix}:${locale}:${namespace}:${dotPath}`, value = the leaf string
Codec SHALL be pluggable via `encode(record): Uint8Array` / `decode(bytes): TranslationRecord` (default: `JSON.stringify` + `TextEncoder` / `TextDecoder` + `JSON.parse`).
#### Scenario: single-key-per-namespace round-trips through MemoryStorage
- **WHEN** `new StorageBackend({ storage: new MemoryStorage(), layout: 'single-key-per-namespace', prefix: 'i18n' })` and `backend.save('en', 'auth', { login: { title: 'Sign in' } })`
- **THEN** `MemoryStorage.get('i18n/en/auth.json')` decodes to `{ login: { title: 'Sign in' } }` AND `backend.read('en', 'auth')` returns the same record
#### Scenario: single-key-per-locale stores all namespaces under one key
- **WHEN** `layout: 'single-key-per-locale'` and `backend.save('en', 'auth', X)` then `backend.save('en', 'common', Y)`
- **THEN** `MemoryStorage.get('i18n/en.json')` decodes to `{ auth: X, common: Y }`
#### Scenario: flat-keys writes one storage key per leaf
- **WHEN** `layout: 'flat-keys'` and `backend.save('en', 'auth', { login: { title: 'Sign in' } })`
- **THEN** `MemoryStorage.get('i18n:en:auth:login.title')` decodes to `'Sign in'`
#### Scenario: codec swap to MessagePack
- **WHEN** `new StorageBackend({ storage, encode: msgpackEncode, decode: msgpackDecode })` is constructed
- **THEN** `save` calls `msgpackEncode` for serialisation and `read` calls `msgpackDecode`
**Verified by:** `packages/i18n/i18n/src/core/backend/storage/storage-backend.test.ts::"single-key-per-namespace round-trip"` AND `packages/i18n/i18n/src/core/backend/storage/storage-backend.test.ts::"single-key-per-locale shape"` AND `packages/i18n/i18n/src/core/backend/storage/storage-backend.test.ts::"flat-keys shape"` AND `packages/i18n/i18n/src/core/backend/storage/storage-backend.spec-conformance.test.ts::"codec swap to MessagePack"`
---
### Requirement: MultiFileFsBackend reads JSON files in a per-namespace tree
The package SHALL ship a `MultiFileFsBackend` (Node/Bun-only; pure ESM dynamic-import of `node:fs/promises`) that loads `<root>/<locale>/<namespace>.json` from disk. It SHALL support three layouts:
- `nested` (default): `<root>/<locale>/<namespace>.json` (e.g. `locales/en/auth.json`)
- `flat`: `<root>/<locale>.<namespace>.json` (e.g. `locales/en.auth.json`)
- `glob`: caller-supplied `globPattern` with `{locale}` and `{namespace}` placeholders
When `watch: true`, it SHALL use `node:fs/promises.watch` to detect changes and emit a `loaded` event via the catalog event bus on each successful reload. Optional peer-dependency parsers SHALL be supported when present: `yaml` (for `.yaml`/`.yml`), `gettext-parser` (for `.po`), `json5` (for `.json5`).
#### Scenario: nested layout loads namespace files
- **WHEN** `<root>/en/auth.json` contains `{ "login": { "title": "Sign in" } }` and `backend.read('en', 'auth')` is called
- **THEN** the resolved record equals the file's parsed content
#### Scenario: missing file maps to LOCALE_NOT_AVAILABLE
- **WHEN** `<root>/de/auth.json` does not exist and `backend.read('de', 'auth')` is called
- **THEN** the call rejects with `I18nError(I18N.BACKEND_LOCALE_NOT_AVAILABLE)` AND `context.path` is the resolved file path
#### Scenario: watch emits loaded event on file change
- **WHEN** `watch: true` and an external process writes a new value to `<root>/en/auth.json`
- **THEN** the catalog emits `loaded` with `{ locale: 'en', namespace: 'auth', backend: 'multi-file-fs' }` within 500 ms
**Verified by:** `packages/i18n/i18n/src/core/backend/multi-file-fs/multi-file-fs-backend.test.ts::"nested layout loads namespace files"` AND `packages/i18n/i18n/src/core/backend/multi-file-fs/multi-file-fs-backend.test.ts::"missing file maps to LOCALE_NOT_AVAILABLE"` AND `packages/i18n/i18n/src/core/backend/multi-file-fs/multi-file-fs-backend.test.ts::"watch emits loaded event on file change"`
---
### Requirement: ChainedBackend composes N backends with write-through cache
The package SHALL ship a `ChainedBackend` that composes a non-empty ordered list of backends. `read(locale, ns)` SHALL walk the list in order; the first backend whose `read` resolves wins. After a successful read, the resolved record SHALL be written through to every preceding backend whose `save` is implemented (the standard "cache from IndexedDB, fall through to HTTPS" pattern). `readMulti` SHALL fan out concurrently and merge per the configured `mergeStrategy: 'first-wins' | 'last-wins'` (default `'first-wins'`).
#### Scenario: cache hit short-circuits the chain
- **WHEN** `Chained([memory, http])` and `memory.read('en', 'auth')` resolves
- **THEN** `http.read` is never called
#### Scenario: cache miss writes through to earlier backends
- **WHEN** `Chained([memory, http])`, `memory.read` rejects with `BACKEND_LOCALE_NOT_AVAILABLE`, and `http.read` resolves with `R`
- **THEN** the chained `read` resolves with `R` AND `memory.save('en', 'auth', R)` is called exactly once
#### Scenario: BACKEND_READ_FAILED does not fall through
- **WHEN** the first backend rejects with `BACKEND_READ_FAILED` (a transport / parse error, not a 404-equivalent)
- **THEN** the chained `read` propagates the error without calling subsequent backends
**Verified by:** `packages/i18n/i18n/src/core/backend/chained/chained-backend.test.ts::"cache hit short-circuits"` AND `packages/i18n/i18n/src/core/backend/chained/chained-backend.spec-conformance.test.ts::"cache miss writes through"` AND `packages/i18n/i18n/src/core/backend/chained/chained-backend.test.ts::"BACKEND_READ_FAILED does not fall through"`
---
### Requirement: MessageCatalog accepts and routes through registered backends
`MessageCatalog` SHALL accept `backend?: Backend` (single) or `backends?: Backend[]` (auto-wrapped in `ChainedBackend`) at construction. When a backend is registered, `MessageCatalog.preload(locales, namespaces?) → Promise<void>` SHALL bulk-load via `readMulti` when supported and via concurrent `read` calls otherwise. Repeated `preload` calls for the same `(locale, namespace)` pair SHALL be deduplicated via the existing in-flight Promise map. `MessageCatalog.invalidate(locale?, namespace?) → void` SHALL drop the in-memory cache for the affected pairs so the next read re-fetches.
#### Scenario: preload populates catalog via readMulti
- **WHEN** a `MemoryBackend` carries seeds for `en/auth` and `en/common` and `catalog.preload(['en'], ['auth', 'common'])` is called
- **THEN** `t('auth:login.title')` and `t('common:cancel')` resolve from the catalog without further backend calls
#### Scenario: invalidate forces re-read
- **WHEN** `catalog.preload(['en'], ['auth'])` succeeds, then `catalog.invalidate('en', 'auth')`, then `catalog.preload(['en'], ['auth'])` is called again
- **THEN** the backend's `read` was called twice for the `(en, auth)` pair
#### Scenario: concurrent preload calls dedup
- **WHEN** `catalog.preload(['en'], ['auth'])` is called twice in immediate succession (no `await` between them)
- **THEN** the backend's `read` is called exactly once
**Verified by:** `packages/i18n/i18n/src/core/catalog/catalog-backend.test.ts::"preload populates via readMulti"` AND `packages/i18n/i18n/src/core/catalog/catalog-backend.test.ts::"invalidate forces re-read"` AND `packages/i18n/i18n/src/core/catalog/catalog-backend.test.ts::"concurrent preload calls dedup"`
---
### Requirement: getAsync triggers in-flight backend load for missing namespace
When `lazyLoad: true` is set (default when a backend is registered) and `MessageCatalog.getAsync(key, locale?)` is called for a key in a namespace not yet present, the catalog SHALL trigger a backend `read` for that `(locale, namespace)` and resolve the returned Promise once the read completes (success or fallback). The synchronous `get(key)` and `t(key)` SHALL continue to behave as today: return `undefined` / the key string when the namespace is absent, even with `lazyLoad: true`. This separation SHALL be enforced so existing synchronous call sites are unchanged.
#### Scenario: getAsync resolves to backend value
- **WHEN** the catalog has no `auth` namespace loaded and `catalog.getAsync('auth:login.title')` is called
- **THEN** the backend's `read('en', 'auth')` is invoked AND the returned Promise resolves to `{ value: 'Sign in', source: 'catalog', ... }`
#### Scenario: getAsync surfaces backend errors
- **WHEN** the backend's `read` rejects with `BACKEND_READ_FAILED`
- **THEN** `getAsync` rejects with the same `I18nError`
#### Scenario: synchronous t remains unchanged under lazyLoad
- **WHEN** `lazyLoad: true` and `t('auth:login.title')` is called for a not-yet-loaded namespace
- **THEN** the synchronous return value is `'auth:login.title'` (the key) AND the backend `read` was NOT triggered (only `getAsync` triggers backend calls)
**Verified by:** `packages/i18n/i18n/src/core/catalog/catalog-backend.test.ts::"getAsync resolves to backend value"` AND `packages/i18n/i18n/src/core/catalog/catalog-backend.test.ts::"getAsync surfaces backend errors"` AND `packages/i18n/i18n/src/core/catalog/catalog-backend.test.ts::"synchronous t never triggers backend reads"`
---
### Requirement: saveMissing writes back through registered backend
When `saveMissing: true` is passed in catalog options and a backend with a `save` method is registered, `MessageCatalog` SHALL invoke `backend.save(locale, namespace, { ...existing, [key]: defaultValue })` whenever a key miss occurs. If the backend lacks `save` or the call rejects, `MessageCatalog` SHALL emit an OTel counter sample (`i18n.backend.write.total{outcome=error}`) but SHALL NOT throw — saveMissing is best-effort by design.
#### Scenario: missing key triggers save
- **WHEN** `saveMissing: true` and `t('auth:new.unknown.key', { defaultValue: 'Default' })` is called for a key absent from the catalog
- **THEN** the backend's `save('en', 'auth', record)` is called once with the merged record containing the new key
#### Scenario: save rejection does not throw
- **WHEN** `backend.save` rejects
- **THEN** `t()` returns the default value AND no exception is thrown AND the OTel counter `i18n.backend.write.total{outcome=error}` increments by 1
**Verified by:** `packages/i18n/i18n/src/core/catalog/catalog-backend.test.ts::"saveMissing triggers backend.save"` AND `packages/i18n/i18n/src/core/catalog/catalog-backend.test.ts::"save rejection does not throw"`
---
### Requirement: Backend OTel instrumentation
All backend operations SHALL emit OTel telemetry built via `getMeter('suaveplan.i18n')`:
- Histogram `i18n.backend.read.duration_ms` — labels: `backend.name`, `locale`, `namespace`, `outcome` (`hit | miss | error`)
- Counter `i18n.backend.read.total` — labels: `backend.name`, `outcome`
- Counter `i18n.backend.write.total` — labels: `backend.name`, `outcome`
- Gauge `i18n.backend.cache.size` — observable callback returning the count of `(locale, namespace)` pairs currently held in `MessageCatalog`'s in-memory store
#### Scenario: read records duration sample
- **WHEN** `StorageBackend.read('en', 'auth')` is called once with an in-memory `MeterProvider`
- **THEN** exactly one sample is recorded under `i18n.backend.read.duration_ms` with attributes `{ backend.name: 'storage', locale: 'en', namespace: 'auth', outcome: 'hit' }`
#### Scenario: cache size gauge reflects catalog state
- **WHEN** `catalog.preload(['en', 'fr'], ['auth'])` succeeds, then the OTel gauge is observed
- **THEN** the observed value is `2` (one pair per locale × namespace)
**Verified by:** `packages/i18n/i18n/src/core/backend/storage/storage-backend.spec-conformance.test.ts::"records read.duration_ms sample"` AND `packages/i18n/i18n/src/core/catalog/catalog-backend.test.ts::"cache.size gauge reflects state"`
### Requirement: Number formatting with style and currency support
The package SHALL expose a `NumberFormatter` class and a `formatNumber(value, locale, options?)` standalone helper that wrap `Intl.NumberFormat`. The formatter SHALL support `style: 'decimal' | 'percent' | 'currency'`, `currency` (ISO 4217 code), `currencyDisplay` (`'symbol' | 'code' | 'name' | 'narrowSymbol'`), `minimumFractionDigits`, `maximumFractionDigits`, `useGrouping`, `notation` (`'standard' | 'scientific' | 'engineering' | 'compact'`), and `signDisplay`. Options SHALL be validated through `NumberFormatterOptionsSchema` at construction.
#### Scenario: decimal style produces a grouped number
- **WHEN** `formatNumber(1234567.89, 'en-US', { style: 'decimal', maximumFractionDigits: 2 })` is called
- **THEN** the result equals `"1,234,567.89"`
#### Scenario: currency style honours `currencyDisplay`
- **WHEN** `formatNumber(42, 'en-GB', { style: 'currency', currency: 'GBP', currencyDisplay: 'code' })` is called
- **THEN** the result contains the substring `"GBP"` (not the `£` glyph)
#### Scenario: percent style multiplies by 100
- **WHEN** `formatNumber(0.255, 'en-US', { style: 'percent', maximumFractionDigits: 1 })` is called
- **THEN** the result equals `"25.5%"`
**Verified by:** `packages/i18n/i18n/src/formatting/number/number.test.ts::"formats decimals with grouping in en-US"` AND `packages/i18n/i18n/src/formatting/number/number.test.ts::"formats currency with currencyDisplay code"` AND `packages/i18n/i18n/src/formatting/number/number.test.ts::"formats percent by multiplying by 100"`
---
### Requirement: NumberFormatter validates options via Zod
The package SHALL validate every `NumberFormatterOptions` object through `NumberFormatterOptionsSchema` (Zod). Unknown styles, negative fraction digits, fraction-digit inversions (`min > max`), and unsupported locale strings SHALL be rejected with `I18nError`.
#### Scenario: unknown style rejected
- **WHEN** `new NumberFormatter({ style: 'magic' } as never)` is constructed
- **THEN** it throws `I18nError` with code `I18N.INVALID_FORMATTER_OPTIONS` and `context.field === "style"`
#### Scenario: unsupported locale surfaces structured error
- **WHEN** `formatNumber(1, 'xx-INVALID-LOCALE-1234', {})` is called
- **THEN** it throws `I18nError` with code `I18N.UNSUPPORTED_LOCALE` (the underlying `Intl.NumberFormat` `RangeError` is mapped, not propagated)
#### Scenario: fraction-digit inversion rejected
- **WHEN** options carry `{ minimumFractionDigits: 4, maximumFractionDigits: 2 }`
- **THEN** Zod rejection produces `I18N.INVALID_FORMATTER_OPTIONS` with `context.field === "fractionDigits"`
**Verified by:** `packages/i18n/i18n/src/formatting/number/number.spec-conformance.test.ts::"rejects unknown style with INVALID_FORMATTER_OPTIONS"` AND `packages/i18n/i18n/src/formatting/number/number.spec-conformance.test.ts::"maps Intl RangeError to UNSUPPORTED_LOCALE"` AND `packages/i18n/i18n/src/formatting/number/number.spec-conformance.test.ts::"rejects fraction-digit inversion"`
---
### Requirement: NumberFormatter caches `Intl.NumberFormat` instances
The package SHALL cache the underlying `Intl.NumberFormat` keyed by (locale, canonicalised options hash). Repeat calls with the same key SHALL reuse the cached instance and emit the OTel counter `i18n.format.cache_hit` with attribute `formatter="number"`.
#### Scenario: cache reused on identical key
- **WHEN** `formatNumber(1, 'en-US', { style: 'decimal' })` is called twice
- **THEN** the second call increments `i18n.format.cache_hit` by exactly 1
#### Scenario: distinct options produce distinct cache entries
- **WHEN** the same locale is called with `{ style: 'decimal' }` then `{ style: 'percent' }`
- **THEN** both calls miss the cache; `i18n.format.cache_hit` does not increment
**Verified by:** `packages/i18n/i18n/src/formatting/number/number.spec-conformance.test.ts::"reuses cached Intl.NumberFormat on identical key"` AND `packages/i18n/i18n/src/formatting/number/number.spec-conformance.test.ts::"does not reuse cache across distinct options"`
---
### Requirement: Relative-time formatting with auto-unit selection
The package SHALL expose a `RelativeTimeFormatter` class and a `formatRelativeTime(deltaMs, locale, options?)` helper that wrap `Intl.RelativeTimeFormat`. When `options.unit` is omitted, the formatter SHALL auto-select the largest unit whose absolute value is ≥ 1 from the ordered set `seconds`, `minutes`, `hours`, `days`, `weeks`, `months`, `years`. Threshold boundaries match upstream `relative-time.ts` L40-L96 (60s, 60m, 24h, 7d, 4.345w, 12mo).
#### Scenario: 30 seconds selects "seconds" unit
- **WHEN** `formatRelativeTime(30_000, 'en-US')` is called
- **THEN** the result contains `"second"` (e.g. `"in 30 seconds"`) AND not `"minute"`
#### Scenario: 90 seconds rolls over to "minutes" unit
- **WHEN** `formatRelativeTime(90_000, 'en-US')` is called
- **THEN** the selected unit is `"minutes"` and the value is `2` (rounded)
#### Scenario: negative delta produces past-tense phrasing
- **WHEN** `formatRelativeTime(-3_600_000, 'en-US', { numeric: 'auto' })` is called
- **THEN** the result reads `"1 hour ago"` (past), not `"in 1 hour"` (future)
#### Scenario: explicit unit override skips auto-selection
- **WHEN** `formatRelativeTime(90_000, 'en-US', { unit: 'seconds' })` is called
- **THEN** the formatter renders the value in seconds (`"in 90 seconds"`), not minutes
**Verified by:** `packages/i18n/i18n/src/formatting/relative-time/relative-time.test.ts::"auto-selects seconds for sub-minute deltas"` AND `packages/i18n/i18n/src/formatting/relative-time/relative-time.test.ts::"rolls over to minutes at the 60s boundary"` AND `packages/i18n/i18n/src/formatting/relative-time/relative-time.test.ts::"renders past-tense phrasing for negative deltas"` AND `packages/i18n/i18n/src/formatting/relative-time/relative-time.test.ts::"honours explicit unit override"`
---
### Requirement: RelativeTimeFormatter style and numeric overrides
The package SHALL accept `style: 'long' | 'short' | 'narrow'` (default `'long'`) and `numeric: 'always' | 'auto'` (default `'always'`) on `RelativeTimeFormatter`. Both fields SHALL be validated through `RelativeTimeFormatterOptionsSchema`. Unknown values SHALL throw `I18nError` with code `I18N.INVALID_FORMATTER_OPTIONS`.
#### Scenario: short style produces abbreviated unit
- **WHEN** `formatRelativeTime(86_400_000, 'en-US', { style: 'short' })` is called
- **THEN** the result contains `"day"` or `"days"` rendered in short form (e.g. `"in 1 day"` vs the long-style `"in 1 day"`; the test asserts the `Intl.RelativeTimeFormat({ style: 'short' })` output exactly)
#### Scenario: numeric `'auto'` produces phrases like "yesterday"
- **WHEN** `formatRelativeTime(-86_400_000, 'en-US', { numeric: 'auto' })` is called
- **THEN** the result equals `"yesterday"` (the platform-defined phrase for –1 day)
#### Scenario: invalid style rejected
- **WHEN** `new RelativeTimeFormatter({ style: 'rad' } as never)` is constructed
- **THEN** it throws `I18nError` with code `I18N.INVALID_FORMATTER_OPTIONS` and `context.field === "style"`
**Verified by:** `packages/i18n/i18n/src/formatting/relative-time/relative-time.test.ts::"renders short style"` AND `packages/i18n/i18n/src/formatting/relative-time/relative-time.test.ts::"renders 'yesterday' under numeric: auto"` AND `packages/i18n/i18n/src/formatting/relative-time/relative-time.spec-conformance.test.ts::"rejects unknown style with INVALID_FORMATTER_OPTIONS"`
---
### Requirement: Both formatters emit OTel duration histograms
The package SHALL emit `i18n.format.number.duration_ms` and `i18n.format.relative_time.duration_ms` histograms recording the wall-clock duration of each `format()` call. Histograms SHALL be tagged with `locale` and `style` attributes.
#### Scenario: histogram observes a sample per format call
- **WHEN** `formatNumber(1, 'en-US', { style: 'decimal' })` is called once
- **THEN** the in-memory `MeterProvider` records exactly one sample under `i18n.format.number.duration_ms` with attributes `{ locale: 'en-US', style: 'decimal' }`
#### Scenario: relative-time histogram tags include selected unit
- **WHEN** `formatRelativeTime(90_000, 'en-US')` is called
- **THEN** the histogram sample carries attribute `unit: 'minutes'` (the auto-selected unit)
**Verified by:** `packages/i18n/i18n/src/formatting/number/number.spec-conformance.test.ts::"records a duration_ms sample per call"` AND `packages/i18n/i18n/src/formatting/relative-time/relative-time.spec-conformance.test.ts::"tags histogram with auto-selected unit"`
### Requirement: Ergonomic translate options bag
`MessageCatalog.t(key, options?: TranslateOptions)` SHALL accept an optional second argument that bundles the most common translation concerns into a single call. `options` SHALL support `count: number`, `context: string`, `defaultValue: string`, `values: Record<string, unknown>`, `escapeHtml?: boolean`, and `formatters?: Record<string, ValueFormatter>`. When `count` is present, the catalog SHALL invoke `PluralizationEngine.resolve` and select the matching plural branch; when `context` is present, the catalog SHALL look up `${key}_${context}` first, falling back to `key`. The `count` value SHALL be auto-injected into `options.values` so plural strings can use `{count}` directly.
#### Scenario: count selects plural branch
- **GIVEN** catalog `{ en: { item: { one: '{count} item', other: '{count} items' } } }`
- **WHEN** `t('item', { count: 3 })` is called
- **THEN** the result equals `'3 items'`
#### Scenario: context selector falls back to base key
- **GIVEN** catalog `{ en: { greeting: 'Hi', greeting_male: 'Hello sir' } }`
- **WHEN** `t('greeting', { context: 'female' })` is called
- **THEN** the result equals `'Hi'` (greeting_female absent → falls back to base)
#### Scenario: defaultValue used on miss
- **GIVEN** catalog without key `'foo'`
- **WHEN** `t('foo', { defaultValue: 'Bar' })` is called
- **THEN** the result equals `'Bar'`
#### Scenario: values interpolated alongside count
- **GIVEN** catalog `{ en: { item: { one: '{name} has {count} item', other: '{name} has {count} items' } } }`
- **WHEN** `t('item', { count: 2, values: { name: 'Alice' } })` is called
- **THEN** the result equals `'Alice has 2 items'`
**Verified by:** `packages/i18n/i18n/src/core/catalog/translate.test.ts::"count selects plural branch"` AND `packages/i18n/i18n/src/core/catalog/translate.test.ts::"context selector falls back to base key"` AND `packages/i18n/i18n/src/core/catalog/translate.test.ts::"defaultValue used on miss"` AND `packages/i18n/i18n/src/core/catalog/translate.test.ts::"values interpolated alongside count"`
---
### Requirement: Nested-key references in source strings
When a source string contains `$t(other.key)` or `$t(ns:other.key)`, the catalog SHALL recursively resolve the referenced key and substitute its value in place. Cycles SHALL be detected via a maximum nesting depth of 4 (configurable via `nestingDepth` option). Exceeding the depth SHALL throw `I18nError(I18N.NESTING_DEPTH_EXCEEDED)` with `context.path` listing the chain.
#### Scenario: nested reference resolves
- **GIVEN** catalog `{ en: { common: { cancel: 'Cancel' }, dialog: { close: 'Close — $t(common:cancel)' } } }`
- **WHEN** `t('dialog:close')` is called
- **THEN** the result equals `'Close — Cancel'`
#### Scenario: cycle throws NESTING_DEPTH_EXCEEDED
- **GIVEN** catalog `{ en: { a: '$t(b)', b: '$t(a)' } }`
- **WHEN** `t('a')` is called
- **THEN** it throws `I18nError(I18N.NESTING_DEPTH_EXCEEDED)` with `context.path` containing `['a', 'b', 'a', 'b', 'a']`
**Verified by:** `packages/i18n/i18n/src/core/catalog/translate.test.ts::"nested reference resolves"` AND `packages/i18n/i18n/src/core/catalog/translate.test.ts::"cycle throws NESTING_DEPTH_EXCEEDED"`
---
### Requirement: ICU selectordinal node
`MessageParser` SHALL recognise `{var, selectordinal, one {…} two {…} few {…} other {…}}` as a node type distinct from `plural`. At render time, the renderer SHALL invoke the supplied `pluralSelect(count, 'ordinal')` callback (or `PluralizationEngine.getCategory(count, locale, true)` when wired through `MessageCatalog.t`).
#### Scenario: selectordinal selects 'few' for English '3'
- **GIVEN** message `'{place, selectordinal, one {#st} two {#nd} few {#rd} other {#th}} place'`
- **WHEN** parsed and rendered with `{ place: 3 }` under `en` locale
- **THEN** the result equals `'3rd place'`
**Verified by:** `packages/i18n/i18n/src/parser/message/message.test.ts::"selectordinal selects 'few' for English '3'"`
---
### Requirement: ICU typed-argument support
`MessageParser` SHALL recognise typed-argument forms `{name, type[, style]}` where `type` is one of `date`, `time`, `number`. At render time, the renderer SHALL route through the canonical `formatDate` / `formatTime` / `formatNumber` helpers from `@suaveplan/i18n/formatting/*`, threading the active locale. Style strings SHALL accept the standard ICU subset:
- `date`: `short`, `medium`, `long`, `full`
- `time`: `short`, `medium`, `long`, `full`
- `number`: `decimal`, `percent`, `currency/<ISO 4217 code>`, `compact`, `scientific`, `engineering`
#### Scenario: typed currency argument formats per locale
- **GIVEN** message `'{amount, number, currency/USD}'`
- **WHEN** parsed and rendered with `{ amount: 1234.5 }` under `en-US` locale
- **THEN** the result equals `'$1,234.50'`
#### Scenario: typed date argument formats per style
- **GIVEN** message `'{at, date, medium}'`
- **WHEN** parsed and rendered with `{ at: new Date(Date.UTC(2026, 4, 4)) }` under `en-GB` locale
- **THEN** the result equals `'4 May 2026'`
#### Scenario: unknown type rejects at parse time
- **GIVEN** message `'{x, magic, foo}'`
- **WHEN** parsed
- **THEN** `MessageParser.validate(message)` returns an error with code `'UNKNOWN_ARG_TYPE'` and `field === 'magic'`
**Verified by:** `packages/i18n/i18n/src/parser/message/message.test.ts::"typed currency argument formats per locale"` AND `packages/i18n/i18n/src/parser/message/message.test.ts::"typed date argument formats per style"` AND `packages/i18n/i18n/src/parser/message/message-validate.test.ts::"unknown type rejects at validate"`
---
### Requirement: ICU apostrophe quoting
`MessageParser` SHALL implement the ICU MessageFormat apostrophe quoting rule:
- A standalone apostrophe followed by a `{`, `}`, or another apostrophe begins a quoted run; the quoted run ends at the next single apostrophe.
- Two consecutive apostrophes (`''`) render as a single literal apostrophe.
- Apostrophes outside this rule render literally.
#### Scenario: doubled apostrophe renders as single
- **GIVEN** message `"it''s here"`
- **WHEN** parsed and rendered with `{}` values
- **THEN** the result equals `"it's here"`
#### Scenario: quoted run preserves braces
- **GIVEN** message `"'{literal}' is {actual}"`
- **WHEN** parsed and rendered with `{ actual: 'X' }`
- **THEN** the result equals `'{literal} is X'`
#### Scenario: standalone apostrophe is literal
- **GIVEN** message `"don't"`
- **WHEN** parsed and rendered
- **THEN** the result equals `"don't"`
**Verified by:** `packages/i18n/i18n/src/parser/message/message.test.ts::"doubled apostrophe renders as single"` AND `packages/i18n/i18n/src/parser/message/message.test.ts::"quoted run preserves braces"` AND `packages/i18n/i18n/src/parser/message/message.test.ts::"standalone apostrophe is literal"`
---
### Requirement: Rich-text / tag interpolation
`MessageParser` SHALL recognise XML-style open/close tag pairs (`<tag>...</tag>`) inside source messages and emit a `TagNode` with `name: string` and `children: MessageNode[]`. `MessageParser.format(message, values)` SHALL accept a value `name: (chunks: string) => string` to render the tag content inline. `MessageParser.formatChunks<T>(message, values, chunkValues)` SHALL return `Array<string | T>` for framework consumers, where `chunkValues[name]: (chunks: T[]) => T` (e.g. `{ bold: (chunks) => <b>{chunks}</b> }`).
#### Scenario: tag formats inline as string
- **GIVEN** message `'Hello <bold>world</bold>!'`
- **WHEN** `format(message, { bold: (chunks) =>`**${chunks}**`})`
- **THEN** the result equals `'Hello **world**!'`
#### Scenario: nested tag formats correctly
- **GIVEN** message `'<a>read <b>more</b> here</a>'`
- **WHEN** `format(message, { a: (c) =>`[${c}]`, b: (c) => `*${c}*`})`
- **THEN** the result equals `'[read *more* here]'`
#### Scenario: formatChunks returns mixed string + framework-node array
- **GIVEN** message `'Hello <bold>world</bold>!'`
- **WHEN** `formatChunks(message, {}, { bold: (c) => ({ tag: 'b', children: c }) })`
- **THEN** the result deep-equals `['Hello ', { tag: 'b', children: ['world'] }, '!']`
**Verified by:** `packages/i18n/i18n/src/parser/message/message.test.ts::"tag formats inline as string"` AND `packages/i18n/i18n/src/parser/message/message.test.ts::"nested tag formats correctly"` AND `packages/i18n/i18n/src/parser/message/message.test.ts::"formatChunks returns mixed array"`
---
### Requirement: I18nInstance facade and plugin lifecycle
The package SHALL expose `createI18n(options) → I18nInstance` that composes `MessageCatalog`, `InterpolationEngine`, `PluralizationEngine`, `MessageParser`, `LocaleResolver`, and a plugin registry. `I18nInstance.use(plugin: I18nPlugin)` SHALL register the plugin (calling `install(i18n)` synchronously or awaiting it when async). `I18nInstance.dispose()` SHALL call `dispose()` on every registered plugin in reverse-registration order.
#### Scenario: plugin install runs once on registration
- **GIVEN** an `I18nInstance` and a plugin whose `install` increments a counter
- **WHEN** `i18n.use(plugin)` is called once
- **THEN** the counter is 1 (not 0, not 2)
#### Scenario: dispose runs in reverse order
- **GIVEN** plugins A, B, C registered in that order, each pushing their name into a shared array on `dispose`
- **WHEN** `i18n.dispose()` is called
- **THEN** the shared array equals `['C', 'B', 'A']`
#### Scenario: duplicate plugin names rejected
- **GIVEN** an `I18nInstance` with a plugin named `'http-backend'` already registered
- **WHEN** another plugin with the same name is registered
- **THEN** it throws `I18nError(I18N.PLUGIN_ALREADY_INSTALLED)` with `context.name === 'http-backend'`
**Verified by:** `packages/i18n/i18n/src/core/plugin/plugin.test.ts::"install runs once"` AND `packages/i18n/i18n/src/core/plugin/plugin.test.ts::"dispose runs in reverse order"` AND `packages/i18n/i18n/src/core/plugin/plugin.test.ts::"duplicate plugin name rejected"`
---
### Requirement: Event bus topics
`I18nInstance` SHALL emit five canonical event topics via an internal `@suaveplan/events` bus exposed as `i18n.events`:
- `languageChanged` — `{ from: string; to: string }` — emitted by `i18n.changeLanguage(locale)`
- `loaded` — `{ locale: string; namespace: string; backend?: string }` — emitted on every catalog `set` / `mergeMessages` / backend read
- `missingKey` — `{ locale: string; namespace: string; key: string; defaultValue?: string }` — emitted on every miss before `MissingKeyHandler` is invoked
- `keyAdded` — `{ locale: string; namespace: string; key: string; value: string }` — emitted on `saveMissing` / HMR / backend write-through
- `pluralRuleAdded` — `{ locale: string }` — emitted by `PluralizationEngine.registerRule`
#### Scenario: changeLanguage emits languageChanged
- **WHEN** `i18n.changeLanguage('fr')` is called from an `en` locale
- **THEN** the bus emits `languageChanged` with `{ from: 'en', to: 'fr' }` exactly once
#### Scenario: missing key emits missingKey before handler
- **WHEN** `t('absent.key')` is called and a `MissingKeyHandler` is registered
- **THEN** the bus emits `missingKey` with the lookup payload AND the handler is invoked AFTER the emission
**Verified by:** `packages/i18n/i18n/src/core/plugin/events.test.ts::"changeLanguage emits languageChanged"` AND `packages/i18n/i18n/src/core/plugin/events.test.ts::"missingKey emitted before handler"`
---
### Requirement: List, display-name, and number-helper formatters
The package SHALL expose:
- `formatList(values: string[], locale: string, options?: ListFormatOptions)` wrapping `Intl.ListFormat` with cached instance per `(locale, options-hash)`
- `formatDisplayName(code: string, locale: string, options: DisplayNameOptions)` wrapping `Intl.DisplayNames`
- `formatPercent(value: number, locale: string, options?)` — pre-bound `formatNumber({ style: 'percent' })`
- `formatCompact(value: number, locale: string, options?)` — pre-bound `formatNumber({ notation: 'compact' })`
- `formatUnit(value: number, unit: string, locale: string, options?)` — pre-bound `formatNumber({ style: 'unit', unit })`
Each function SHALL emit a sample on the relevant `i18n.format.<name>.duration_ms` histogram.
#### Scenario: list conjunction joins with locale-specific connector
- **WHEN** `formatList(['apples', 'oranges', 'bananas'], 'en-US', { type: 'conjunction' })` is called
- **THEN** the result equals `'apples, oranges, and bananas'`
#### Scenario: display-name returns localised region name
- **WHEN** `formatDisplayName('GB', 'en-US', { type: 'region' })` is called
- **THEN** the result equals `'United Kingdom'`
#### Scenario: compact notation for thousands
- **WHEN** `formatCompact(1234, 'en-US')` is called
- **THEN** the result equals `'1.2K'`
#### Scenario: unit formatting
- **WHEN** `formatUnit(12, 'kilometer-per-hour', 'en-US')` is called
- **THEN** the result equals `'12 km/h'`
**Verified by:** `packages/i18n/i18n/src/formatting/list/list.test.ts::"list conjunction joins"` AND `packages/i18n/i18n/src/formatting/display-name/display-name.test.ts::"region name localised"` AND `packages/i18n/i18n/src/formatting/number/number-helpers.test.ts::"compact notation for thousands"` AND `packages/i18n/i18n/src/formatting/number/number-helpers.test.ts::"unit formatting"`
---
### Requirement: Pluralization engine prefers Intl.PluralRules
`PluralizationEngine.getRule(locale)` SHALL select rules in this order: (1) explicitly registered override rule, (2) `Intl.PluralRules(locale)` when supported by the runtime, (3) hand-written rule registry, (4) `defaultPluralRule`. The hand-written rules SHALL remain registered and SHALL be used when `Intl.PluralRules` is unavailable. This decouples the package from CLDR drift.
#### Scenario: registered override wins over Intl
- **GIVEN** a custom rule registered for `en` that always returns `'few'`
- **WHEN** `getRule('en')(5)` is called
- **THEN** the result equals `'few'` (override wins, Intl skipped)
#### Scenario: Intl.PluralRules used when no override
- **GIVEN** no override registered for `pl`
- **WHEN** `getCategory(2, 'pl')` is called on a runtime where `Intl.PluralRules` is supported
- **THEN** the result equals `Intl.PluralRules('pl').select(2)` (i.e. `'few'`)
**Verified by:** `packages/i18n/i18n/src/core/pluralization/pluralization.test.ts::"override wins over Intl"` AND `packages/i18n/i18n/src/core/pluralization/pluralization.test.ts::"Intl used when no override"`
---
### Requirement: Locale-aware first-day-of-week
`getDayNames(locale, style?)` SHALL consult `new Intl.Locale(locale).weekInfo.firstDay` (1 = Monday, 7 = Sunday) to determine the array's starting weekday. When `weekInfo` is unavailable on the runtime (older Node, older browsers), the function SHALL fall back to Monday-start (`firstDay: 1`).
#### Scenario: en-US starts on Sunday
- **WHEN** `getDayNames('en-US', 'short')` is called on a runtime where `weekInfo` is available
- **THEN** the first element equals `'Sun'`
#### Scenario: en-GB starts on Monday
- **WHEN** `getDayNames('en-GB', 'short')` is called
- **THEN** the first element equals `'Mon'`
**Verified by:** `packages/i18n/i18n/src/formatting/date/date.test.ts::"en-US starts on Sunday"` AND `packages/i18n/i18n/src/formatting/date/date.test.ts::"en-GB starts on Monday"`
---
### Requirement: Accept-Language preserves script casing
`parseAcceptLanguage(header)` SHALL preserve the BCP-47 case convention of script subtags: language → lowercase, script → title-case, region → uppercase. The current implementation lowercases the entire tag and is a bug.
#### Scenario: zh-Hant-TW round-trips with original casing
- **WHEN** `parseAcceptLanguage('zh-Hant-TW;q=0.9')` is called
- **THEN** the first entry's `code` equals `'zh-Hant-TW'` (script title-case, region upper-case, language lower-case)
**Verified by:** `packages/i18n/i18n/src/locale/negotiation/negotiation.test.ts::"zh-Hant-TW round-trips with original casing"`
---
### Requirement: Pseudo-localization post-processor
The package SHALL expose `pseudoLocalize(text, options?)` and a corresponding `pseudoLocalizationPlugin(options?)` that registers as a post-processor. Three modes SHALL be supported via `mode: 'accent' | 'bracket' | 'expand'`:
- `accent` (default): replaces ASCII letters with diacritic equivalents (`Welcome` → `Wéłçömé`)
- `bracket`: surrounds the string with `[!! ... !!]` markers
- `expand`: pads the string by ~30% with extra characters to test layout overflow
#### Scenario: accent mode produces diacritic equivalents
- **WHEN** `pseudoLocalize('Welcome', { mode: 'accent' })` is called
- **THEN** the result is non-empty AND every original ASCII letter is replaced with a non-ASCII counterpart
#### Scenario: bracket mode surrounds the string
- **WHEN** `pseudoLocalize('Welcome', { mode: 'bracket' })` is called
- **THEN** the result equals `'[!! Welcome !!]'`
#### Scenario: post-processor plugin transforms t() output
- **GIVEN** an `I18nInstance` with `pseudoLocalizationPlugin({ mode: 'bracket' })` registered
- **WHEN** `t('greeting')` returns `'Welcome'` from the catalog
- **THEN** the final return value equals `'[!! Welcome !!]'`
**Verified by:** `packages/i18n/i18n/src/post-processors/pseudo-localize.test.ts::"accent mode produces diacritic equivalents"` AND `packages/i18n/i18n/src/post-processors/pseudo-localize.test.ts::"bracket mode surrounds string"` AND `packages/i18n/i18n/src/post-processors/pseudo-localize.test.ts::"plugin transforms t() output"`
---
### Requirement: Segmenter and collator wrappers
The package SHALL expose `formatSegment(text, locale, granularity?)` returning an array of `{ segment: string; index: number; isWordLike?: boolean }` via `Intl.Segmenter`, and `compareLocale(a, b, locale, options?)` wrapping `Intl.Collator` with cached instance per `(locale, options-hash)`.
#### Scenario: word segmentation in Japanese
- **WHEN** `formatSegment('東京は晴れです', 'ja-JP', 'word')` is called on a runtime with `Intl.Segmenter`
- **THEN** the result has at least one segment with `isWordLike: true` and `segment === '東京'`
#### Scenario: collator sorts case-insensitively
- **WHEN** `['B', 'a', 'C'].sort((a, b) => compareLocale(a, b, 'en-US', { sensitivity: 'base' }))`
- **THEN** the result equals `['a', 'B', 'C']`
**Verified by:** `packages/i18n/i18n/src/locale/segment/segment.test.ts::"word segmentation in Japanese"` AND `packages/i18n/i18n/src/locale/collator/collator.test.ts::"sorts case-insensitively"`
---
### Requirement: OTel meter instruments wired into production paths
Every existing instrument declared in `src/observability/metrics.ts` (`i18n.translate.total`, `i18n.translate.duration_ms`, `i18n.locale.resolve.total`) SHALL be incremented / observed from the relevant production code paths. Two additional instruments SHALL be added:
- Histogram `i18n.interpolation.duration_ms` — emitted from `InterpolationEngine.render` and `MessageParser.format`
- Counter `i18n.missing_key.total` — labels: `locale`, `namespace`; incremented from `MessageCatalog.get` whenever the lookup falls through to the missing-key handler or returns `undefined`
#### Scenario: get records translate.total{outcome=hit}
- **WHEN** `MessageCatalog.get('present.key')` resolves a value with the in-memory `MeterProvider` registered
- **THEN** exactly one sample is recorded under `i18n.translate.total` with attribute `outcome: 'hit'` AND one sample under `i18n.translate.duration_ms`
#### Scenario: locale resolve records strategy
- **WHEN** `LocaleResolver.resolve('fr-FR')` returns via `'language'` strategy
- **THEN** `i18n.locale.resolve.total` records a sample with attribute `strategy: 'language'`
#### Scenario: missing key increments counter
- **WHEN** `MessageCatalog.get('absent.key')` returns `undefined`
- **THEN** `i18n.missing_key.total` records a sample with `{ locale: 'en', namespace: '' }`
**Verified by:** `packages/i18n/i18n/src/observability/metrics.spec-conformance.test.ts::"translate.total records hit"` AND `packages/i18n/i18n/src/observability/metrics.spec-conformance.test.ts::"locale.resolve.total records strategy"` AND `packages/i18n/i18n/src/observability/metrics.spec-conformance.test.ts::"missing_key.total increments on miss"`
---
### Requirement: Missing-key reporter plugin
The package SHALL expose `createMissingKeyReporter(options?)` returning an `I18nPlugin` that subscribes to the `missingKey` event. By default it logs at `warn` via `@suaveplan/logger`. When `options.onMissing` is supplied, the plugin SHALL invoke it with the event payload. When the catalog is configured with `saveMissing: true` and a backend that implements `save`, the reporter MAY (opt-in via `persistMissing: true`) call `catalog.saveMissing(locale, key, defaultValue)`.
#### Scenario: missing key logs warn by default
- **GIVEN** a logger spy and `i18n.use(createMissingKeyReporter({ logger }))`
- **WHEN** `t('absent.key')` is called
- **THEN** the logger received exactly one `warn` call mentioning `'absent.key'`
#### Scenario: persistMissing routes to backend
- **GIVEN** `saveMissing: true`, a `MemoryBackend`, and `createMissingKeyReporter({ persistMissing: true })`
- **WHEN** `t('new.key', { defaultValue: 'New' })` is called for a missing key
- **THEN** the backend's `save` was called once with the merged record containing `'new.key': 'New'`
**Verified by:** `packages/i18n/i18n/src/post-processors/missing-key-reporter.test.ts::"warn logged by default"` AND `packages/i18n/i18n/src/post-processors/missing-key-reporter.test.ts::"persistMissing routes to backend"`
---
## Wave 6 (i18n-core-slim) — capability redistribution
The Wave 6 modular split (archived 2026-05-09) moved every leaf
capability of `@suaveplan/i18n` into a dedicated sibling package and
reduced the slim core's scope to the integration layer (catalog,
plugin, locale negotiation/resolver, server middleware, observability,
schemas, key utilities, default `FormatBridge`). The original
requirements above remain part of the `i18n` family contract, but
many are now hosted by sibling capabilities. The mapping below
records the new home for every redistributed requirement; consumers
SHOULD consult the sibling capability spec for the authoritative
contract.
### REMOVED — capabilities re-hosted in sibling packages
| Requirement (this spec) | Now hosted by | Capability spec |
|---|---|---|
| BCP-47 locale tag map | `@suaveplan/i18n-shared` | `openspec/specs/i18n-shared/spec.md` |
| RTL locale set / direction | `@suaveplan/i18n-shared` | `openspec/specs/i18n-shared/spec.md` |
| Locale descriptors | `@suaveplan/i18n-shared` | `openspec/specs/i18n-shared/spec.md` |
| `I18N_CODES` / `I18nError` registry | `@suaveplan/i18n-shared` | `openspec/specs/i18n-shared/spec.md` |
| ICU MessageFormat parser | `@suaveplan/i18n-icu-parser` | `openspec/specs/i18n-icu-parser/spec.md` |
| `{{var}}` interpolation engine | `@suaveplan/i18n-icu-parser` | `openspec/specs/i18n-icu-parser/spec.md` |
| Pluralization with 9 CLDR rule sets | `@suaveplan/i18n-icu-parser` | `openspec/specs/i18n-icu-parser/spec.md` |
| Number formatting | `@suaveplan/i18n-formatter-number` | `openspec/specs/i18n-formatter-number/spec.md` |
| Relative-time formatting | `@suaveplan/i18n-formatter-relative-time` | `openspec/specs/i18n-formatter-relative-time/spec.md` |
| List formatting | `@suaveplan/i18n-formatter-list` | `openspec/specs/i18n-formatter-list/spec.md` |
| Display-name formatting | `@suaveplan/i18n-formatter-display-name` | `openspec/specs/i18n-formatter-display-name/spec.md` |
| Date / time formatting | `@suaveplan/i18n-formatter-date` | `openspec/specs/i18n-formatter-date/spec.md` |
| Currency formatting | `@suaveplan/i18n-formatter-currency` | `openspec/specs/i18n-formatter-currency/spec.md` |
| Collator wrapper | `@suaveplan/i18n-formatter-collator` | `openspec/specs/i18n-formatter-collator/spec.md` |
| Segmenter wrapper | `@suaveplan/i18n-formatter-segmenter` | `openspec/specs/i18n-formatter-segmenter/spec.md` |
| Locale detection chain | `@suaveplan/i18n-detector` | `openspec/specs/i18n-detector/spec.md` |
| Translation backends (memory, http, storage, fs-multifile, chained) | `@suaveplan/i18n-backend-*` | `openspec/specs/i18n-backend-*/spec.md` |
The slim core's root barrel re-exports every redistributed symbol so existing `import { X } from "@suaveplan/i18n"` lines continue to resolve. The four documented subpaths (`./detector`, `./parser`, `./plugin`, `./errors`) are kept indefinitely for back-compat.
### MODIFIED — capabilities still hosted but reshaped
#### Requirement: ICU MessageFormat render integrates formatters via FormatBridge
Pre-Wave-6, `MessageCatalog` and `I18nInstance` invoked formatters by direct function import. Post-Wave-6, the parser package declares a `FormatBridge` interface and the slim `@suaveplan/i18n` core SHALL construct a `defaultFormatBridge` by importing the formatter packages and wiring them to the bridge methods. Consumers calling `createI18n(...)` MAY pass `{ formatBridge }` via `RenderOptions` to override; otherwise the parser falls back to `String(value)` per the `FormatBridge` contract.
##### Scenario: defaultFormatBridge implements every FormatBridge method
- **WHEN** `defaultFormatBridge` is imported from `@suaveplan/i18n`
- **THEN** `number`, `currency`, `date`, `time`, `relativeTime` are all functions
##### Scenario: defaultFormatBridge.number forwards to i18n-formatter-number
- **WHEN** `defaultFormatBridge.number(1234.5, "en-US")` is called
- **THEN** the result equals `formatNumber(1234.5, "en-US")` from `@suaveplan/i18n-formatter-number`
**Verified by:** `packages/i18n/i18n/src/parser/format-bridge-default/format-bridge-default.test.ts`
---
#### Requirement: Public surface preserved via back-compat root barrel
Pre-Wave-6, `packages/i18n/i18n/src/index.ts` exported 33 statements totalling roughly 80 distinct symbols, all sourced from local files. Post-Wave-6, every one of those 80 symbols SHALL continue to resolve through `import { X } from "@suaveplan/i18n"`, but the *source* of every constant / formatter / parser / backend / detector symbol changes to the relevant Wave 1–5 package. The slim core also adds the four universal-tier backend factories (`MemoryBackend`, `memoryBackend`, `HttpBackend`, `httpBackend`, `StorageBackend`, `storageBackend`, `ChainedBackend`, `chainedBackend`) at the root for parity. The server-tier `MultiFileFsBackend` is NOT re-exported and consumers must import directly from `@suaveplan/i18n-backend-fs-multifile`.
##### Scenario: every documented pre-split export resolves post-slim
- **WHEN** every symbol enumerated in the back-compat re-export map is imported via `from "@suaveplan/i18n"`
- **THEN** every import resolves with `typeof <symbol> !== "undefined"`
##### Scenario: subpath exports continue to resolve
- **WHEN** the four kept subpaths (`@suaveplan/i18n/detector`, `@suaveplan/i18n/plugin`, `@suaveplan/i18n/parser`, `@suaveplan/i18n/errors`) are imported
- **THEN** every import resolves with no `Module not found` errors
##### Scenario: legacy I18nCode name preserved as type alias
- **WHEN** `import type { I18nCode } from "@suaveplan/i18n";`
- **THEN** the type equals `I18nErrorCode` (re-exported from `@suaveplan/i18n-shared`)
**Verified by:** `packages/i18n/i18n/tests/tree-shake.test.ts` AND every conformance test in `packages/i18n/i18n/src/conformance.test.ts` (320 tests across the slim core resolve every re-exported symbol)
---
#### Requirement: Slim-core source inventory enforced
Post-Wave-6 the package SHALL contain ONLY the integration-layer directories enumerated in `openspec/archive/2026-05-09-i18n-core-slim/proposal.md` §"Files that stay" plus the new `src/parser/format-bridge-default/` folder and the four `src/subpaths/<name>.ts` shims. Forbidden directories (`src/constants/`, `src/error-codes.ts`, `src/errors/`, `src/parser/message/`, `src/core/interpolation/`, `src/core/pluralization/`, `src/formatting/`, `src/detector/`, `src/core/backend/`) SHALL NOT exist.
**Verified by:** Hand-inspected at archive time (2026-05-09); enforced going forward by the package's `bunx turbo build` failing if any forbidden import re-appears.