1024 lines
69 KiB
Markdown
1024 lines
69 KiB
Markdown
# 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 `&` → `&`, `<` → `<`, `>` → `>`, `"` → `"`, `'` → `'`.
|
||
- `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.
|