# 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` 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 = { 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>`. - 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 | (string & {})>(key: K, locale?)` and `get | (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 `&` → `&`, `<` → `<`, `>` → `>`, `"` → `"`, `'` → `'`. - `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` 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` (template-literal recursion over a `TranslationRecord`-like tree) and `MessageCatalog` MUST narrow `t`/`get` to `K extends DotPaths | (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` method, and four optional methods: `readMulti?(locale, namespaces) → Promise>`, `save?(locale, namespace, record) → Promise`, `lastModified?(locale, namespace) → Promise`, 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>`. It SHALL implement `read`, `readMulti`, `save`, and `lastModified`. Construction SHALL accept an optional `seed: Record>` 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` - `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 `//.json` from disk. It SHALL support three layouts: - `nested` (default): `//.json` (e.g. `locales/en/auth.json`) - `flat`: `/..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** `/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** `/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 `/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` 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`, `escapeHtml?: boolean`, and `formatters?: Record`. 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/`, `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 (`...`) 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(message, values, chunkValues)` SHALL return `Array` for framework consumers, where `chunkValues[name]: (chunks: T[]) => T` (e.g. `{ bold: (chunks) => {chunks} }`). #### Scenario: tag formats inline as string - **GIVEN** message `'Hello world!'` - **WHEN** `format(message, { bold: (chunks) =>`**${chunks}**`})` - **THEN** the result equals `'Hello **world**!'` #### Scenario: nested tag formats correctly - **GIVEN** message `'read more here'` - **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 world!'` - **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..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 !== "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/.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.