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

69 KiB
Raw Permalink Blame History

i18n

Purpose

Internationalization core for the Suaveplan monorepo — namespaced message catalog with lazy loaders and missing-key hooks, ICU-like interpolation engine, pluralization rules for nine languages, BCP-47 locale resolver with cookie/query/header strategies, locale middleware, and date/currency formatting via Intl. Errors flow through I18nError extending SuaveplanError, with all options Zod-validated at the boundary.

Requirements

I18N-01 — Structured error hierarchy

An I18nError base class MUST extend SuaveplanError from @suaveplan/error. All errors thrown by this package MUST extend I18nError. Six error codes MUST be registered via defineCodes under the I18N.* namespace:

  • I18N.MISSING_KEY — key not found in the catalog when strict mode is enabled and no MissingKeyHandler recovers a value
  • I18N.INVALID_LOCALE — locale string fails BCP-47 structural validation
  • I18N.LOADER_FAILED — async MessageLoader rejected or returned a value that does not satisfy TranslationRecordSchema
  • I18N.INTERPOLATION_ERROR — template rendering failed (e.g. unclosed variable delimiter, formatter threw)
  • I18N.PLURAL_RULE_NOT_FOUND — no plural rule is registered for the locale and strict mode is enabled
  • I18N.INVALID_OPTIONS — engine constructor option schema (CatalogOptionsSchema, InterpolationConfigSchema, ResolverConfigSchema, formatter option schemas) failed Zod parse; context.zodIssues carries the issue array

Error codes MUST be auto-registered via a side-effect import in src/index.ts (same pattern as @suaveplan/error).

I18N-02 — Zod schemas at the public boundary

The following Zod schemas MUST be exported and match the spec field names exactly:

  • TranslationRecordSchema — recursive z.record(z.string(), z.union([z.string(), z.lazy(...)])) for nested translation trees; rejects arrays and non-string leaves
  • CatalogOptionsSchema — validates all fields of CatalogOptions including optional missingKeyHandler, defaultLocale, and strict?: boolean (default false)
  • InterpolationConfigSchema — validates { open: string (≥1, ≠ close), close: string (≥1), escape: string (length 0 or 1), maxIterations: int 1..100 }
  • ResolverConfigSchema — validates { strategies: ResolutionStrategy[] (non-empty), fallbackLocale: string (≥1), supportedLocales?: string[] }

MessageCatalog, InterpolationEngine, and LocaleResolver constructors MUST call .parse(options) and re-throw failures as I18nError with the relevant code (I18N.INVALID_LOCALE for resolver, I18N.INTERPOLATION_ERROR for interpolation, I18N.INVALID_OPTIONS for catalog and any other engine schema mismatch) and context.zodIssues populated with the issue array.

I18N-03 — Message catalog contract

MessageCatalog<TLocale, TMessages> MUST:

  • Accept namespaced TranslationRecord trees; key lookup MUST use dot-path resolution within the namespace.
  • Expose set(locale, namespace, record), load(locale, namespaceOrLoader, loader?) (overloaded so legacy load(locale, loader) still compiles), keys(locale, namespace?) (scoped to namespace when provided), has(key, locale?), get(key, locale?), and t(key, locale?).
  • Return LookupResult<TLocale> = { value: string | undefined; locale: TLocale; namespace: string; source: 'catalog' | 'fallback' | 'handler' | 'key' } from get.
  • Support async MessageLoader per (locale, namespace) pair; concurrent calls for the same ${locale}::${namespace ?? ''} key MUST be deduplicated (single in-flight Promise) via an internal Map<string, Promise<void>>.
  • Wrap loader rejection as I18nError(I18N.LOADER_FAILED, 'i18n.loader.failed', { cause, context: { locale, namespace } }). Loaded records MUST be structurally validated; failure surfaces the same I18N.LOADER_FAILED code.
  • Invoke MissingKeyHandler (if registered) before throwing; if the handler returns a string, surface it with source: 'handler'.
  • In strict mode (default false), throw I18nError(I18N.MISSING_KEY) when no value is found after handler fallback; otherwise return { value: undefined, ..., source: 'key' } (and t() returns the key string).
  • Provide typed key inference via t<K extends DotPaths<TMessages> | (string & {})>(key: K, locale?) and get<K extends DotPaths<TMessages> | (string & {})>(key: K, locale?).
  • deepMerge(target, source) MUST perform a deep structural merge of two TranslationRecord trees; source values win on scalar conflicts.

I18N-04 — Interpolation engine contract

InterpolationEngine MUST:

  • Replace {variableName} tokens (default delimiters configurable via InterpolationConfig) with values from InterpolationValues.
  • Support a ValueFormatter map: { [variableName]: (value, locale) => string } for locale-aware per-variable formatting.
  • When escapeHtml: true in InterpolationOptions, HTML-escape all substituted values using escapeHtmlChars BEFORE insertion; raw template text is NOT escaped.
  • Throw I18nError(I18N.INTERPOLATION_ERROR) when an opening delimiter is found without a matching closing delimiter.
  • Wrap any thrown value from a ValueFormatter as I18nError(I18N.INTERPOLATION_ERROR, 'i18n.interpolation.formatter_failed', { cause, context: { variable } }).
  • Accept canonical option keys { open, close, escape, maxIterations }. Transitional aliases { prefix, suffix, maxDepth } MUST be accepted and mapped to the canonical keys; mixing canonical and alias keys in the same options object MUST throw.
  • escapeHtmlChars(str) MUST escape & → &amp;, < → &lt;, > → &gt;, " → &quot;, ' → &#39;.
  • escapeRegex(str) MUST escape all regex metacharacters: . * + ? ^ $ { } [ ] | ( ) \.

I18N-05 — Pluralization engine contract

PluralizationEngine MUST:

  • Maintain a PluralRuleRegistry that callers can write to via registerRule(locale, rule). Built-in hand-written rules for Arabic, Chinese, English, French, German, Polish, Russian, Spanish, Welsh, and defaultPluralRule SHALL remain registered as overrides.
  • .getRule(locale) selects in this order: (1) explicit override registered via registerRule, (2) Intl.PluralRules(locale) when the runtime supports it, (3) the hand-written rule registry, (4) defaultPluralRule.
  • .resolve(count, locale, messages, opts?) MUST call the resolved rule, fall back to the base-language rule (strip region), then fall back to defaultPluralRule; return PluralResolveResult { category: PluralCategory; value: string }.
  • PluralCategory MUST be 'zero' | 'one' | 'two' | 'few' | 'many' | 'other'.
  • In strict mode, throw I18nError(I18N.PLURAL_RULE_NOT_FOUND) when no rule exists for the locale and its base language and Intl.PluralRules is unavailable.
  • getBaseLocale(locale) strips the region subtag (en-US → en).
  • normalizeLocale(locale) normalizes to lowercase language + uppercase region (EN-us → en-US).

I18N-06 — Message parser contract

MessageParser MUST parse ICU-like message strings into an AST of MessageNode values:

  • TextNode — literal text segment
  • VariableNode — {name} — simple variable substitution
  • PluralNode — {count, plural, one{…} other{…}} — plural form with PluralCategory branches
  • SelectNode — {gender, select, male{…} female{…} other{…}} — arbitrary select form

renderNodes(nodes, values, opts?) MUST render an AST to a string by substituting values; unknown VariableNode references fall back to the variable name in braces.

extractVariables(message) MUST return the deduplicated list of variable names referenced in a raw message string without fully parsing the AST.

I18N-07 — Locale resolver contract

LocaleResolver MUST implement a pluggable strategy chain. Built-in ResolutionStrategy sources: 'cookie', 'query', 'header' (Accept-Language), 'default'. .resolve(request, config) MUST:

  • Try each strategy in config.strategies order; return the first locale that is in config.supportedLocales (if provided) and passes BCP-47 validation.
  • Fall back to config.fallbackLocale when no strategy yields a valid supported locale.
  • Return ResolvedLocale { locale: string; source: ResolutionStrategy | 'fallback'; direction: TextDirection }.

The LocaleResolver constructor MUST validate options via ResolverConfigSchema (where strategies is the pluggable-chain field; the existing exact/alias/language/region matcher remains unchanged but now lives behind a strategies: ['exact', 'alias', 'language', 'region', 'default'] default).

normalizeLocaleCode(code) MUST validate input against /^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})*$/ and throw I18nError(I18N.INVALID_LOCALE) for non-matching strings (including empty).

I18N-08 — Locale middleware contract

createLocaleMiddleware(config) MUST return a middleware function compatible with standard Node.js (req, res, next) signatures where req is I18nRequest (extends a plain object with locale?: ResolvedLocale). The middleware MUST:

  • Invoke detectLocale(request, config) to resolve the locale.
  • Attach ResolvedLocale to request.locale (with direction: TextDirection).
  • When an optional setHeader(name, value) callback is supplied in ExtendedMiddlewareConfig, invoke setHeader('Content-Language', resolved.locale) after the lookup completes.
  • Call next() unconditionally after attachment.

getDirection(locale) MUST return 'rtl' for locales in RTL_LOCALES, otherwise 'ltr'.

I18N-09 — Date formatting contract

All date formatting functions MUST be backed by Intl.DateTimeFormat with no hard-coded locale-specific strings in implementation code.

  • formatDate(date, locale, opts?) — full date + time per DateFormatPreset or custom Intl.DateTimeFormatOptions
  • formatDateOnly(date, locale, opts?) — date portion only
  • formatTime(date, locale, opts?) — time portion only
  • formatDateRange(start, end, locale, opts?) — uses Intl.DateTimeFormat.prototype.formatRange where the runtime supports it; falls back to two separate formatted strings joined with an em-dash
  • getDayNames(locale, format?) — returns 7-element array via Intl.DateTimeFormat part extraction; format is 'long' | 'short' | 'narrow'
  • getMonthNames(locale, format?) — returns 12-element array; same format options

DateFormatPreset MUST include at minimum: 'short', 'medium', 'long', 'full', 'relative'.

I18N-10 — Currency formatting contract

All currency formatting MUST be backed by Intl.NumberFormat with style: 'currency'.

  • formatCurrency(amount, currency, locale, opts?) — full formatted string including symbol (e.g. $1,234.56)
  • formatCurrencyValue(amount, currency, locale, opts?) — numeric value only, no symbol (e.g. 1,234.56)
  • getCurrencySymbol(currency, locale) — extracts the currency symbol via Intl.NumberFormat parts
  • getCurrencyName(currency, locale) — localized currency name via Intl.DisplayNames with type: 'currency'
  • getCurrencyInfo(currency) — returns CurrencyInfo { code: CommonCurrency; numericCode: number; decimalDigits: number; name: string }

CommonCurrency MUST be a typed string-literal union covering at minimum: 'USD', 'EUR', 'GBP', 'JPY', 'CNY', 'CAD', 'AUD', 'CHF', 'SEK', 'NOK', 'DKK', 'SGD', 'HKD', 'NZD', 'KRW', 'INR', 'BRL', 'MXN', 'ZAR', 'RUB'.

I18N-11 — Utility functions contract

  • flattenRecord(record, separator?) MUST convert a nested TranslationRecord into a flat Record<string, string> using dot-separated paths; separator defaults to '.'
  • unflattenRecord(flat, separator?) MUST reconstruct a nested object from flat dot-path keys; conflicts (key is both a leaf and a node prefix) MUST throw
  • countKeys(record) MUST return the total count of string leaf values in a (nested) record
  • getNestedValue(record, key, separator?) MUST return the string at the dot-path or undefined if absent
  • hasKey(record, key, separator?) MUST return true iff the dot-path resolves to a string leaf
  • buildKey(...segments) MUST join segments with '.', filtering empty strings
  • parseKey(key, separator?) MUST split on separator, returning string[]; buildKey(...parseKey(key)) === key for any valid key
  • getTextDirection(locale) / isRtl(locale) MUST consult RTL_LOCALES
  • sanitize(html, opts?) MUST strip disallowed tags and attributes; SanitizeOptions controls allowedTags and allowedAttributes; default allowedTags is [] (strip all tags)

I18N-12 — OTel instrumentation

src/observability/metrics.ts MUST export an i18nMetrics singleton built from @opentelemetry/api getMeter('suaveplan.i18n'). All instruments MUST be no-ops when no exporter is registered (default behaviour of @opentelemetry/api).

  • translateTotal: Counter — i18n.translate.total, incremented inside MessageCatalog.get with attributes { outcome: 'hit' | 'miss', locale }.
  • localeResolveTotal: Counter — i18n.locale.resolve.total, incremented inside detectLocale with attributes { strategy, locale } (winning strategy name or 'fallback').
  • translateDuration: Histogram — i18n.translate.duration_ms, records elapsed milliseconds for each MessageCatalog.get call with attribute { locale }.

I18N-13 — Property-based test requirements

The following properties MUST be verified with fast-check arbitrary inputs:

  1. flattenRecord/unflattenRecord round-trip — for any depth-≤3 record of string leaves (no key collisions), unflattenRecord(flattenRecord(r, sep), sep) deep-equals r
  2. normalizeLocale idempotent — normalizeLocale(normalizeLocale(s)) === normalizeLocale(s) for any non-empty ASCII string s
  3. HTML escaping completeness — for any string value containing one or more of <, >, &, ", ', InterpolationEngine.render with escapeHtml: true MUST NOT emit any of those characters unescaped in the returned string
  4. buildKey/parseKey round-trip — for any non-empty array of non-empty, dot-free strings segs, parseKey(buildKey(...segs)) deep-equals segs

I18N-14 — Benchmark requirements

Benchmarks MUST be implemented with mitata and MUST exercise the following hot paths:

  • MessageCatalog.get — 100 000 iterations on a pre-loaded catalog containing 1 000 keys spread across 3 namespaces and 2 locales
  • InterpolationEngine.render — 10 000 iterations with a fixed template containing 5 named variable placeholders and a matching InterpolationValues map
  • PluralizationEngine.resolve — 10 000 iterations cycling through counts [0, 1, 2, 5, 11, 100] for English, Polish, and Arabic rules

Baseline numbers MUST be published in README.md after the first green run.

I18N-15 — Package hygiene

  • "sideEffects": false in package.json; enforced by a tree-shake guard test in tests/
  • Vitest coverage thresholds MUST be set to 100/100/100/100 (statements/branches/functions/lines); provider MUST be istanbul
  • No it.skip, test.skip, describe.skip, or .only left in src/ or tests/
  • Zero browser-specific or Node.js-specific globals in implementation code — pure TypeScript that runs in Bun, Node, Deno, and browser environments; Intl API is universally available and permitted
  • All internal workspace dependencies use workspace:^; all external dependencies use Bun catalog entries

I18N-16 — INVALID_OPTIONS error code

A new code I18N.INVALID_OPTIONS MUST be registered alongside the other I18N.* codes (see I18N-01) and used by all engines when their constructor option schema fails Zod parse. The thrown I18nError MUST carry context.zodIssues populated with the issue array from the Zod failure.

I18N-17 — Typed key inference

@suaveplan/i18n MUST export DotPaths<T> (template-literal recursion over a TranslationRecord-like tree) and MessageCatalog<TLocale, TMessages> MUST narrow t/get to K extends DotPaths<TMessages> | (string & {}). The (string & {}) branch is the explicit escape hatch that preserves runtime flexibility while still letting TypeScript autocomplete known keys.

I18N-18 — Intl.PluralRules fallback

PluralizationEngine MUST consult Intl.PluralRules(locale).select(n) after the registered-rule chain misses but before falling back to defaultPluralRule. This ensures CLDR-tracked languages added in future ICU updates work without registering a hand-rolled rule. In strict mode, if Intl.PluralRules itself throws on construction (invalid locale), the engine MUST throw I18nError(I18N.PLURAL_RULE_NOT_FOUND).

Requirement: Backend interface contract

The package SHALL expose a Backend interface that any external translation source can implement. The interface SHALL declare a stable name: string, a required read(locale, namespace) → Promise<TranslationRecord> method, and four optional methods: readMulti?(locale, namespaces) → Promise<Record<string, TranslationRecord>>, save?(locale, namespace, record) → Promise<void>, lastModified?(locale, namespace) → Promise<number | undefined>, and lifecycle hooks init?(catalog) / dispose?(). Implementations SHALL surface transport / parse failures as I18nError(I18N.BACKEND_READ_FAILED) (read path) or I18nError(I18N.BACKEND_WRITE_FAILED) (write path) with the underlying error attached as cause.

Scenario: read returns a TranslationRecord

  • WHEN a registered Backend.read('en', 'auth') resolves with { login: { title: 'Sign in' } }
  • THEN the catalog stores it under (en, auth) and a subsequent t('auth:login.title') returns 'Sign in'

Scenario: read failure maps to I18N.BACKEND_READ_FAILED

  • WHEN a backend's read rejects with a TypeError
  • THEN the catalog throws I18nError with code I18N.BACKEND_READ_FAILED and error.cause === TypeError

Scenario: missing readMulti falls back to N reads

  • WHEN MessageCatalog.preload(['en'], ['auth', 'common']) is called against a backend that omits readMulti
  • THEN read is invoked once for each (locale, namespace) pair concurrently and the catalog merges the results

Verified by: packages/i18n/i18n/src/core/backend/backend.test.ts::"read populates catalog" AND packages/i18n/i18n/src/core/backend/backend.test.ts::"read failure surfaces BACKEND_READ_FAILED" AND packages/i18n/i18n/src/core/backend/backend.test.ts::"falls back to per-namespace read when readMulti is absent"


Requirement: MemoryBackend reference implementation

The package SHALL ship a MemoryBackend implementation backed by Map<locale, Map<namespace, TranslationRecord>>. It SHALL implement read, readMulti, save, and lastModified. Construction SHALL accept an optional seed: Record<locale, Record<namespace, TranslationRecord>> for tests and seeded defaults.

Scenario: seed is read on first request

  • WHEN new MemoryBackend({ seed: { en: { auth: { login: { title: 'Sign in' } } } } }) then read('en', 'auth')
  • THEN the resolved record deep-equals the seeded value

Scenario: save then read round-trips

  • WHEN backend.save('en', 'auth', { login: { title: 'Sign in' } }) is called, then backend.read('en', 'auth')
  • THEN the resolved record deep-equals the saved value AND lastModified('en', 'auth') returns a number ≥ the timestamp before save

Verified by: packages/i18n/i18n/src/core/backend/memory/memory-backend.test.ts::"reads seeded values" AND packages/i18n/i18n/src/core/backend/memory/memory-backend.test.ts::"save then read round-trips with lastModified"


Requirement: HttpBackend with templated load path

The package SHALL ship an HttpBackend that fetches translations from a URL constructed from a loadPath template containing {locale} and {namespace} placeholders. Defaults: fetchFn = globalThis.fetch, parsePayload = (res) => res.json(). A non-2xx HTTP response SHALL surface as I18nError(I18N.BACKEND_READ_FAILED) with context.status set to the HTTP status code. A 404 SHALL surface as I18nError(I18N.BACKEND_LOCALE_NOT_AVAILABLE) so chained backends can fall through.

Scenario: GET /locales/en/auth.json returns translations

  • WHEN new HttpBackend({ loadPath: '/locales/{locale}/{namespace}.json', fetchFn }) then read('en', 'auth') with fetchFn resolving to Response.json({ login: { title: 'Sign in' } })
  • THEN the resolved record equals { login: { title: 'Sign in' } } AND fetchFn was called with '/locales/en/auth.json'

Scenario: HTTP 404 surfaces LOCALE_NOT_AVAILABLE

  • WHEN fetchFn resolves with status 404
  • THEN the call rejects with I18nError(I18N.BACKEND_LOCALE_NOT_AVAILABLE) and context.status === 404

Scenario: HTTP 500 surfaces BACKEND_READ_FAILED

  • WHEN fetchFn resolves with status 500
  • THEN the call rejects with I18nError(I18N.BACKEND_READ_FAILED) and context.status === 500

Verified by: packages/i18n/i18n/src/core/backend/http/http-backend.test.ts::"fetches templated URL" AND packages/i18n/i18n/src/core/backend/http/http-backend.test.ts::"404 surfaces LOCALE_NOT_AVAILABLE" AND packages/i18n/i18n/src/core/backend/http/http-backend.test.ts::"5xx surfaces BACKEND_READ_FAILED"


Requirement: StorageBackend wraps any AbstractStorage

The package SHALL ship a StorageBackend that adapts any AbstractStorage instance from @suaveplan/storage into a Backend. It SHALL support three storage layouts via a layout option:

  • single-key-per-namespace (default): key = ${prefix}/${locale}/${namespace}.json, value = JSON-encoded TranslationRecord
  • single-key-per-locale: key = ${prefix}/${locale}.json, value = Record<namespace, TranslationRecord>
  • flat-keys: key = ${prefix}:${locale}:${namespace}:${dotPath}, value = the leaf string

Codec SHALL be pluggable via encode(record): Uint8Array / decode(bytes): TranslationRecord (default: JSON.stringify + TextEncoder / TextDecoder + JSON.parse).

Scenario: single-key-per-namespace round-trips through MemoryStorage

  • WHEN new StorageBackend({ storage: new MemoryStorage(), layout: 'single-key-per-namespace', prefix: 'i18n' }) and backend.save('en', 'auth', { login: { title: 'Sign in' } })
  • THEN MemoryStorage.get('i18n/en/auth.json') decodes to { login: { title: 'Sign in' } } AND backend.read('en', 'auth') returns the same record

Scenario: single-key-per-locale stores all namespaces under one key

  • WHEN layout: 'single-key-per-locale' and backend.save('en', 'auth', X) then backend.save('en', 'common', Y)
  • THEN MemoryStorage.get('i18n/en.json') decodes to { auth: X, common: Y }

Scenario: flat-keys writes one storage key per leaf

  • WHEN layout: 'flat-keys' and backend.save('en', 'auth', { login: { title: 'Sign in' } })
  • THEN MemoryStorage.get('i18n:en:auth:login.title') decodes to 'Sign in'

Scenario: codec swap to MessagePack

  • WHEN new StorageBackend({ storage, encode: msgpackEncode, decode: msgpackDecode }) is constructed
  • THEN save calls msgpackEncode for serialisation and read calls msgpackDecode

Verified by: packages/i18n/i18n/src/core/backend/storage/storage-backend.test.ts::"single-key-per-namespace round-trip" AND packages/i18n/i18n/src/core/backend/storage/storage-backend.test.ts::"single-key-per-locale shape" AND packages/i18n/i18n/src/core/backend/storage/storage-backend.test.ts::"flat-keys shape" AND packages/i18n/i18n/src/core/backend/storage/storage-backend.spec-conformance.test.ts::"codec swap to MessagePack"


Requirement: MultiFileFsBackend reads JSON files in a per-namespace tree

The package SHALL ship a MultiFileFsBackend (Node/Bun-only; pure ESM dynamic-import of node:fs/promises) that loads <root>/<locale>/<namespace>.json from disk. It SHALL support three layouts:

  • nested (default): <root>/<locale>/<namespace>.json (e.g. locales/en/auth.json)
  • flat: <root>/<locale>.<namespace>.json (e.g. locales/en.auth.json)
  • glob: caller-supplied globPattern with {locale} and {namespace} placeholders

When watch: true, it SHALL use node:fs/promises.watch to detect changes and emit a loaded event via the catalog event bus on each successful reload. Optional peer-dependency parsers SHALL be supported when present: yaml (for .yaml/.yml), gettext-parser (for .po), json5 (for .json5).

Scenario: nested layout loads namespace files

  • WHEN <root>/en/auth.json contains { "login": { "title": "Sign in" } } and backend.read('en', 'auth') is called
  • THEN the resolved record equals the file's parsed content

Scenario: missing file maps to LOCALE_NOT_AVAILABLE

  • WHEN <root>/de/auth.json does not exist and backend.read('de', 'auth') is called
  • THEN the call rejects with I18nError(I18N.BACKEND_LOCALE_NOT_AVAILABLE) AND context.path is the resolved file path

Scenario: watch emits loaded event on file change

  • WHEN watch: true and an external process writes a new value to <root>/en/auth.json
  • THEN the catalog emits loaded with { locale: 'en', namespace: 'auth', backend: 'multi-file-fs' } within 500 ms

Verified by: packages/i18n/i18n/src/core/backend/multi-file-fs/multi-file-fs-backend.test.ts::"nested layout loads namespace files" AND packages/i18n/i18n/src/core/backend/multi-file-fs/multi-file-fs-backend.test.ts::"missing file maps to LOCALE_NOT_AVAILABLE" AND packages/i18n/i18n/src/core/backend/multi-file-fs/multi-file-fs-backend.test.ts::"watch emits loaded event on file change"


Requirement: ChainedBackend composes N backends with write-through cache

The package SHALL ship a ChainedBackend that composes a non-empty ordered list of backends. read(locale, ns) SHALL walk the list in order; the first backend whose read resolves wins. After a successful read, the resolved record SHALL be written through to every preceding backend whose save is implemented (the standard "cache from IndexedDB, fall through to HTTPS" pattern). readMulti SHALL fan out concurrently and merge per the configured mergeStrategy: 'first-wins' | 'last-wins' (default 'first-wins').

Scenario: cache hit short-circuits the chain

  • WHEN Chained([memory, http]) and memory.read('en', 'auth') resolves
  • THEN http.read is never called

Scenario: cache miss writes through to earlier backends

  • WHEN Chained([memory, http]), memory.read rejects with BACKEND_LOCALE_NOT_AVAILABLE, and http.read resolves with R
  • THEN the chained read resolves with R AND memory.save('en', 'auth', R) is called exactly once

Scenario: BACKEND_READ_FAILED does not fall through

  • WHEN the first backend rejects with BACKEND_READ_FAILED (a transport / parse error, not a 404-equivalent)
  • THEN the chained read propagates the error without calling subsequent backends

Verified by: packages/i18n/i18n/src/core/backend/chained/chained-backend.test.ts::"cache hit short-circuits" AND packages/i18n/i18n/src/core/backend/chained/chained-backend.spec-conformance.test.ts::"cache miss writes through" AND packages/i18n/i18n/src/core/backend/chained/chained-backend.test.ts::"BACKEND_READ_FAILED does not fall through"


Requirement: MessageCatalog accepts and routes through registered backends

MessageCatalog SHALL accept backend?: Backend (single) or backends?: Backend[] (auto-wrapped in ChainedBackend) at construction. When a backend is registered, MessageCatalog.preload(locales, namespaces?) → Promise<void> SHALL bulk-load via readMulti when supported and via concurrent read calls otherwise. Repeated preload calls for the same (locale, namespace) pair SHALL be deduplicated via the existing in-flight Promise map. MessageCatalog.invalidate(locale?, namespace?) → void SHALL drop the in-memory cache for the affected pairs so the next read re-fetches.

Scenario: preload populates catalog via readMulti

  • WHEN a MemoryBackend carries seeds for en/auth and en/common and catalog.preload(['en'], ['auth', 'common']) is called
  • THEN t('auth:login.title') and t('common:cancel') resolve from the catalog without further backend calls

Scenario: invalidate forces re-read

  • WHEN catalog.preload(['en'], ['auth']) succeeds, then catalog.invalidate('en', 'auth'), then catalog.preload(['en'], ['auth']) is called again
  • THEN the backend's read was called twice for the (en, auth) pair

Scenario: concurrent preload calls dedup

  • WHEN catalog.preload(['en'], ['auth']) is called twice in immediate succession (no await between them)
  • THEN the backend's read is called exactly once

Verified by: packages/i18n/i18n/src/core/catalog/catalog-backend.test.ts::"preload populates via readMulti" AND packages/i18n/i18n/src/core/catalog/catalog-backend.test.ts::"invalidate forces re-read" AND packages/i18n/i18n/src/core/catalog/catalog-backend.test.ts::"concurrent preload calls dedup"


Requirement: getAsync triggers in-flight backend load for missing namespace

When lazyLoad: true is set (default when a backend is registered) and MessageCatalog.getAsync(key, locale?) is called for a key in a namespace not yet present, the catalog SHALL trigger a backend read for that (locale, namespace) and resolve the returned Promise once the read completes (success or fallback). The synchronous get(key) and t(key) SHALL continue to behave as today: return undefined / the key string when the namespace is absent, even with lazyLoad: true. This separation SHALL be enforced so existing synchronous call sites are unchanged.

Scenario: getAsync resolves to backend value

  • WHEN the catalog has no auth namespace loaded and catalog.getAsync('auth:login.title') is called
  • THEN the backend's read('en', 'auth') is invoked AND the returned Promise resolves to { value: 'Sign in', source: 'catalog', ... }

Scenario: getAsync surfaces backend errors

  • WHEN the backend's read rejects with BACKEND_READ_FAILED
  • THEN getAsync rejects with the same I18nError

Scenario: synchronous t remains unchanged under lazyLoad

  • WHEN lazyLoad: true and t('auth:login.title') is called for a not-yet-loaded namespace
  • THEN the synchronous return value is 'auth:login.title' (the key) AND the backend read was NOT triggered (only getAsync triggers backend calls)

Verified by: packages/i18n/i18n/src/core/catalog/catalog-backend.test.ts::"getAsync resolves to backend value" AND packages/i18n/i18n/src/core/catalog/catalog-backend.test.ts::"getAsync surfaces backend errors" AND packages/i18n/i18n/src/core/catalog/catalog-backend.test.ts::"synchronous t never triggers backend reads"


Requirement: saveMissing writes back through registered backend

When saveMissing: true is passed in catalog options and a backend with a save method is registered, MessageCatalog SHALL invoke backend.save(locale, namespace, { ...existing, [key]: defaultValue }) whenever a key miss occurs. If the backend lacks save or the call rejects, MessageCatalog SHALL emit an OTel counter sample (i18n.backend.write.total{outcome=error}) but SHALL NOT throw — saveMissing is best-effort by design.

Scenario: missing key triggers save

  • WHEN saveMissing: true and t('auth:new.unknown.key', { defaultValue: 'Default' }) is called for a key absent from the catalog
  • THEN the backend's save('en', 'auth', record) is called once with the merged record containing the new key

Scenario: save rejection does not throw

  • WHEN backend.save rejects
  • THEN t() returns the default value AND no exception is thrown AND the OTel counter i18n.backend.write.total{outcome=error} increments by 1

Verified by: packages/i18n/i18n/src/core/catalog/catalog-backend.test.ts::"saveMissing triggers backend.save" AND packages/i18n/i18n/src/core/catalog/catalog-backend.test.ts::"save rejection does not throw"


Requirement: Backend OTel instrumentation

All backend operations SHALL emit OTel telemetry built via getMeter('suaveplan.i18n'):

  • Histogram i18n.backend.read.duration_ms — labels: backend.name, locale, namespace, outcome (hit | miss | error)
  • Counter i18n.backend.read.total — labels: backend.name, outcome
  • Counter i18n.backend.write.total — labels: backend.name, outcome
  • Gauge i18n.backend.cache.size — observable callback returning the count of (locale, namespace) pairs currently held in MessageCatalog's in-memory store

Scenario: read records duration sample

  • WHEN StorageBackend.read('en', 'auth') is called once with an in-memory MeterProvider
  • THEN exactly one sample is recorded under i18n.backend.read.duration_ms with attributes { backend.name: 'storage', locale: 'en', namespace: 'auth', outcome: 'hit' }

Scenario: cache size gauge reflects catalog state

  • WHEN catalog.preload(['en', 'fr'], ['auth']) succeeds, then the OTel gauge is observed
  • THEN the observed value is 2 (one pair per locale × namespace)

Verified by: packages/i18n/i18n/src/core/backend/storage/storage-backend.spec-conformance.test.ts::"records read.duration_ms sample" AND packages/i18n/i18n/src/core/catalog/catalog-backend.test.ts::"cache.size gauge reflects state"

Requirement: Number formatting with style and currency support

The package SHALL expose a NumberFormatter class and a formatNumber(value, locale, options?) standalone helper that wrap Intl.NumberFormat. The formatter SHALL support style: 'decimal' | 'percent' | 'currency', currency (ISO 4217 code), currencyDisplay ('symbol' | 'code' | 'name' | 'narrowSymbol'), minimumFractionDigits, maximumFractionDigits, useGrouping, notation ('standard' | 'scientific' | 'engineering' | 'compact'), and signDisplay. Options SHALL be validated through NumberFormatterOptionsSchema at construction.

Scenario: decimal style produces a grouped number

  • WHEN formatNumber(1234567.89, 'en-US', { style: 'decimal', maximumFractionDigits: 2 }) is called
  • THEN the result equals "1,234,567.89"

Scenario: currency style honours currencyDisplay

  • WHEN formatNumber(42, 'en-GB', { style: 'currency', currency: 'GBP', currencyDisplay: 'code' }) is called
  • THEN the result contains the substring "GBP" (not the £ glyph)

Scenario: percent style multiplies by 100

  • WHEN formatNumber(0.255, 'en-US', { style: 'percent', maximumFractionDigits: 1 }) is called
  • THEN the result equals "25.5%"

Verified by: packages/i18n/i18n/src/formatting/number/number.test.ts::"formats decimals with grouping in en-US" AND packages/i18n/i18n/src/formatting/number/number.test.ts::"formats currency with currencyDisplay code" AND packages/i18n/i18n/src/formatting/number/number.test.ts::"formats percent by multiplying by 100"


Requirement: NumberFormatter validates options via Zod

The package SHALL validate every NumberFormatterOptions object through NumberFormatterOptionsSchema (Zod). Unknown styles, negative fraction digits, fraction-digit inversions (min > max), and unsupported locale strings SHALL be rejected with I18nError.

Scenario: unknown style rejected

  • WHEN new NumberFormatter({ style: 'magic' } as never) is constructed
  • THEN it throws I18nError with code I18N.INVALID_FORMATTER_OPTIONS and context.field === "style"

Scenario: unsupported locale surfaces structured error

  • WHEN formatNumber(1, 'xx-INVALID-LOCALE-1234', {}) is called
  • THEN it throws I18nError with code I18N.UNSUPPORTED_LOCALE (the underlying Intl.NumberFormat RangeError is mapped, not propagated)

Scenario: fraction-digit inversion rejected

  • WHEN options carry { minimumFractionDigits: 4, maximumFractionDigits: 2 }
  • THEN Zod rejection produces I18N.INVALID_FORMATTER_OPTIONS with context.field === "fractionDigits"

Verified by: packages/i18n/i18n/src/formatting/number/number.spec-conformance.test.ts::"rejects unknown style with INVALID_FORMATTER_OPTIONS" AND packages/i18n/i18n/src/formatting/number/number.spec-conformance.test.ts::"maps Intl RangeError to UNSUPPORTED_LOCALE" AND packages/i18n/i18n/src/formatting/number/number.spec-conformance.test.ts::"rejects fraction-digit inversion"


Requirement: NumberFormatter caches Intl.NumberFormat instances

The package SHALL cache the underlying Intl.NumberFormat keyed by (locale, canonicalised options hash). Repeat calls with the same key SHALL reuse the cached instance and emit the OTel counter i18n.format.cache_hit with attribute formatter="number".

Scenario: cache reused on identical key

  • WHEN formatNumber(1, 'en-US', { style: 'decimal' }) is called twice
  • THEN the second call increments i18n.format.cache_hit by exactly 1

Scenario: distinct options produce distinct cache entries

  • WHEN the same locale is called with { style: 'decimal' } then { style: 'percent' }
  • THEN both calls miss the cache; i18n.format.cache_hit does not increment

Verified by: packages/i18n/i18n/src/formatting/number/number.spec-conformance.test.ts::"reuses cached Intl.NumberFormat on identical key" AND packages/i18n/i18n/src/formatting/number/number.spec-conformance.test.ts::"does not reuse cache across distinct options"


Requirement: Relative-time formatting with auto-unit selection

The package SHALL expose a RelativeTimeFormatter class and a formatRelativeTime(deltaMs, locale, options?) helper that wrap Intl.RelativeTimeFormat. When options.unit is omitted, the formatter SHALL auto-select the largest unit whose absolute value is ≥ 1 from the ordered set seconds, minutes, hours, days, weeks, months, years. Threshold boundaries match upstream relative-time.ts L40-L96 (60s, 60m, 24h, 7d, 4.345w, 12mo).

Scenario: 30 seconds selects "seconds" unit

  • WHEN formatRelativeTime(30_000, 'en-US') is called
  • THEN the result contains "second" (e.g. "in 30 seconds") AND not "minute"

Scenario: 90 seconds rolls over to "minutes" unit

  • WHEN formatRelativeTime(90_000, 'en-US') is called
  • THEN the selected unit is "minutes" and the value is 2 (rounded)

Scenario: negative delta produces past-tense phrasing

  • WHEN formatRelativeTime(-3_600_000, 'en-US', { numeric: 'auto' }) is called
  • THEN the result reads "1 hour ago" (past), not "in 1 hour" (future)

Scenario: explicit unit override skips auto-selection

  • WHEN formatRelativeTime(90_000, 'en-US', { unit: 'seconds' }) is called
  • THEN the formatter renders the value in seconds ("in 90 seconds"), not minutes

Verified by: packages/i18n/i18n/src/formatting/relative-time/relative-time.test.ts::"auto-selects seconds for sub-minute deltas" AND packages/i18n/i18n/src/formatting/relative-time/relative-time.test.ts::"rolls over to minutes at the 60s boundary" AND packages/i18n/i18n/src/formatting/relative-time/relative-time.test.ts::"renders past-tense phrasing for negative deltas" AND packages/i18n/i18n/src/formatting/relative-time/relative-time.test.ts::"honours explicit unit override"


Requirement: RelativeTimeFormatter style and numeric overrides

The package SHALL accept style: 'long' | 'short' | 'narrow' (default 'long') and numeric: 'always' | 'auto' (default 'always') on RelativeTimeFormatter. Both fields SHALL be validated through RelativeTimeFormatterOptionsSchema. Unknown values SHALL throw I18nError with code I18N.INVALID_FORMATTER_OPTIONS.

Scenario: short style produces abbreviated unit

  • WHEN formatRelativeTime(86_400_000, 'en-US', { style: 'short' }) is called
  • THEN the result contains "day" or "days" rendered in short form (e.g. "in 1 day" vs the long-style "in 1 day"; the test asserts the Intl.RelativeTimeFormat({ style: 'short' }) output exactly)

Scenario: numeric 'auto' produces phrases like "yesterday"

  • WHEN formatRelativeTime(-86_400_000, 'en-US', { numeric: 'auto' }) is called
  • THEN the result equals "yesterday" (the platform-defined phrase for –1 day)

Scenario: invalid style rejected

  • WHEN new RelativeTimeFormatter({ style: 'rad' } as never) is constructed
  • THEN it throws I18nError with code I18N.INVALID_FORMATTER_OPTIONS and context.field === "style"

Verified by: packages/i18n/i18n/src/formatting/relative-time/relative-time.test.ts::"renders short style" AND packages/i18n/i18n/src/formatting/relative-time/relative-time.test.ts::"renders 'yesterday' under numeric: auto" AND packages/i18n/i18n/src/formatting/relative-time/relative-time.spec-conformance.test.ts::"rejects unknown style with INVALID_FORMATTER_OPTIONS"


Requirement: Both formatters emit OTel duration histograms

The package SHALL emit i18n.format.number.duration_ms and i18n.format.relative_time.duration_ms histograms recording the wall-clock duration of each format() call. Histograms SHALL be tagged with locale and style attributes.

Scenario: histogram observes a sample per format call

  • WHEN formatNumber(1, 'en-US', { style: 'decimal' }) is called once
  • THEN the in-memory MeterProvider records exactly one sample under i18n.format.number.duration_ms with attributes { locale: 'en-US', style: 'decimal' }

Scenario: relative-time histogram tags include selected unit

  • WHEN formatRelativeTime(90_000, 'en-US') is called
  • THEN the histogram sample carries attribute unit: 'minutes' (the auto-selected unit)

Verified by: packages/i18n/i18n/src/formatting/number/number.spec-conformance.test.ts::"records a duration_ms sample per call" AND packages/i18n/i18n/src/formatting/relative-time/relative-time.spec-conformance.test.ts::"tags histogram with auto-selected unit"

Requirement: Ergonomic translate options bag

MessageCatalog.t(key, options?: TranslateOptions) SHALL accept an optional second argument that bundles the most common translation concerns into a single call. options SHALL support count: number, context: string, defaultValue: string, values: Record<string, unknown>, escapeHtml?: boolean, and formatters?: Record<string, ValueFormatter>. When count is present, the catalog SHALL invoke PluralizationEngine.resolve and select the matching plural branch; when context is present, the catalog SHALL look up ${key}_${context} first, falling back to key. The count value SHALL be auto-injected into options.values so plural strings can use {count} directly.

Scenario: count selects plural branch

  • GIVEN catalog { en: { item: { one: '{count} item', other: '{count} items' } } }
  • WHEN t('item', { count: 3 }) is called
  • THEN the result equals '3 items'

Scenario: context selector falls back to base key

  • GIVEN catalog { en: { greeting: 'Hi', greeting_male: 'Hello sir' } }
  • WHEN t('greeting', { context: 'female' }) is called
  • THEN the result equals 'Hi' (greeting_female absent → falls back to base)

Scenario: defaultValue used on miss

  • GIVEN catalog without key 'foo'
  • WHEN t('foo', { defaultValue: 'Bar' }) is called
  • THEN the result equals 'Bar'

Scenario: values interpolated alongside count

  • GIVEN catalog { en: { item: { one: '{name} has {count} item', other: '{name} has {count} items' } } }
  • WHEN t('item', { count: 2, values: { name: 'Alice' } }) is called
  • THEN the result equals 'Alice has 2 items'

Verified by: packages/i18n/i18n/src/core/catalog/translate.test.ts::"count selects plural branch" AND packages/i18n/i18n/src/core/catalog/translate.test.ts::"context selector falls back to base key" AND packages/i18n/i18n/src/core/catalog/translate.test.ts::"defaultValue used on miss" AND packages/i18n/i18n/src/core/catalog/translate.test.ts::"values interpolated alongside count"


Requirement: Nested-key references in source strings

When a source string contains $t(other.key) or $t(ns:other.key), the catalog SHALL recursively resolve the referenced key and substitute its value in place. Cycles SHALL be detected via a maximum nesting depth of 4 (configurable via nestingDepth option). Exceeding the depth SHALL throw I18nError(I18N.NESTING_DEPTH_EXCEEDED) with context.path listing the chain.

Scenario: nested reference resolves

  • GIVEN catalog { en: { common: { cancel: 'Cancel' }, dialog: { close: 'Close — $t(common:cancel)' } } }
  • WHEN t('dialog:close') is called
  • THEN the result equals 'Close — Cancel'

Scenario: cycle throws NESTING_DEPTH_EXCEEDED

  • GIVEN catalog { en: { a: '$t(b)', b: '$t(a)' } }
  • WHEN t('a') is called
  • THEN it throws I18nError(I18N.NESTING_DEPTH_EXCEEDED) with context.path containing ['a', 'b', 'a', 'b', 'a']

Verified by: packages/i18n/i18n/src/core/catalog/translate.test.ts::"nested reference resolves" AND packages/i18n/i18n/src/core/catalog/translate.test.ts::"cycle throws NESTING_DEPTH_EXCEEDED"


Requirement: ICU selectordinal node

MessageParser SHALL recognise {var, selectordinal, one {…} two {…} few {…} other {…}} as a node type distinct from plural. At render time, the renderer SHALL invoke the supplied pluralSelect(count, 'ordinal') callback (or PluralizationEngine.getCategory(count, locale, true) when wired through MessageCatalog.t).

Scenario: selectordinal selects 'few' for English '3'

  • GIVEN message '{place, selectordinal, one {#st} two {#nd} few {#rd} other {#th}} place'
  • WHEN parsed and rendered with { place: 3 } under en locale
  • THEN the result equals '3rd place'

Verified by: packages/i18n/i18n/src/parser/message/message.test.ts::"selectordinal selects 'few' for English '3'"


Requirement: ICU typed-argument support

MessageParser SHALL recognise typed-argument forms {name, type[, style]} where type is one of date, time, number. At render time, the renderer SHALL route through the canonical formatDate / formatTime / formatNumber helpers from @suaveplan/i18n/formatting/*, threading the active locale. Style strings SHALL accept the standard ICU subset:

  • date: short, medium, long, full
  • time: short, medium, long, full
  • number: decimal, percent, currency/<ISO 4217 code>, compact, scientific, engineering

Scenario: typed currency argument formats per locale

  • GIVEN message '{amount, number, currency/USD}'
  • WHEN parsed and rendered with { amount: 1234.5 } under en-US locale
  • THEN the result equals '$1,234.50'

Scenario: typed date argument formats per style

  • GIVEN message '{at, date, medium}'
  • WHEN parsed and rendered with { at: new Date(Date.UTC(2026, 4, 4)) } under en-GB locale
  • THEN the result equals '4 May 2026'

Scenario: unknown type rejects at parse time

  • GIVEN message '{x, magic, foo}'
  • WHEN parsed
  • THEN MessageParser.validate(message) returns an error with code 'UNKNOWN_ARG_TYPE' and field === 'magic'

Verified by: packages/i18n/i18n/src/parser/message/message.test.ts::"typed currency argument formats per locale" AND packages/i18n/i18n/src/parser/message/message.test.ts::"typed date argument formats per style" AND packages/i18n/i18n/src/parser/message/message-validate.test.ts::"unknown type rejects at validate"


Requirement: ICU apostrophe quoting

MessageParser SHALL implement the ICU MessageFormat apostrophe quoting rule:

  • A standalone apostrophe followed by a {, }, or another apostrophe begins a quoted run; the quoted run ends at the next single apostrophe.
  • Two consecutive apostrophes ('') render as a single literal apostrophe.
  • Apostrophes outside this rule render literally.

Scenario: doubled apostrophe renders as single

  • GIVEN message "it''s here"
  • WHEN parsed and rendered with {} values
  • THEN the result equals "it's here"

Scenario: quoted run preserves braces

  • GIVEN message "'{literal}' is {actual}"
  • WHEN parsed and rendered with { actual: 'X' }
  • THEN the result equals '{literal} is X'

Scenario: standalone apostrophe is literal

  • GIVEN message "don't"
  • WHEN parsed and rendered
  • THEN the result equals "don't"

Verified by: packages/i18n/i18n/src/parser/message/message.test.ts::"doubled apostrophe renders as single" AND packages/i18n/i18n/src/parser/message/message.test.ts::"quoted run preserves braces" AND packages/i18n/i18n/src/parser/message/message.test.ts::"standalone apostrophe is literal"


Requirement: Rich-text / tag interpolation

MessageParser SHALL recognise XML-style open/close tag pairs (<tag>...</tag>) inside source messages and emit a TagNode with name: string and children: MessageNode[]. MessageParser.format(message, values) SHALL accept a value name: (chunks: string) => string to render the tag content inline. MessageParser.formatChunks<T>(message, values, chunkValues) SHALL return Array<string | T> for framework consumers, where chunkValues[name]: (chunks: T[]) => T (e.g. { bold: (chunks) => <b>{chunks}</b> }).

Scenario: tag formats inline as string

  • GIVEN message 'Hello <bold>world</bold>!'
  • WHEN format(message, { bold: (chunks) =>${chunks}})
  • THEN the result equals 'Hello **world**!'

Scenario: nested tag formats correctly

  • GIVEN message '<a>read <b>more</b> here</a>'
  • WHEN format(message, { a: (c) =>[${c}], b: (c) => ${c}})
  • THEN the result equals '[read *more* here]'

Scenario: formatChunks returns mixed string + framework-node array

  • GIVEN message 'Hello <bold>world</bold>!'
  • WHEN formatChunks(message, {}, { bold: (c) => ({ tag: 'b', children: c }) })
  • THEN the result deep-equals ['Hello ', { tag: 'b', children: ['world'] }, '!']

Verified by: packages/i18n/i18n/src/parser/message/message.test.ts::"tag formats inline as string" AND packages/i18n/i18n/src/parser/message/message.test.ts::"nested tag formats correctly" AND packages/i18n/i18n/src/parser/message/message.test.ts::"formatChunks returns mixed array"


Requirement: I18nInstance facade and plugin lifecycle

The package SHALL expose createI18n(options) → I18nInstance that composes MessageCatalog, InterpolationEngine, PluralizationEngine, MessageParser, LocaleResolver, and a plugin registry. I18nInstance.use(plugin: I18nPlugin) SHALL register the plugin (calling install(i18n) synchronously or awaiting it when async). I18nInstance.dispose() SHALL call dispose() on every registered plugin in reverse-registration order.

Scenario: plugin install runs once on registration

  • GIVEN an I18nInstance and a plugin whose install increments a counter
  • WHEN i18n.use(plugin) is called once
  • THEN the counter is 1 (not 0, not 2)

Scenario: dispose runs in reverse order

  • GIVEN plugins A, B, C registered in that order, each pushing their name into a shared array on dispose
  • WHEN i18n.dispose() is called
  • THEN the shared array equals ['C', 'B', 'A']

Scenario: duplicate plugin names rejected

  • GIVEN an I18nInstance with a plugin named 'http-backend' already registered
  • WHEN another plugin with the same name is registered
  • THEN it throws I18nError(I18N.PLUGIN_ALREADY_INSTALLED) with context.name === 'http-backend'

Verified by: packages/i18n/i18n/src/core/plugin/plugin.test.ts::"install runs once" AND packages/i18n/i18n/src/core/plugin/plugin.test.ts::"dispose runs in reverse order" AND packages/i18n/i18n/src/core/plugin/plugin.test.ts::"duplicate plugin name rejected"


Requirement: Event bus topics

I18nInstance SHALL emit five canonical event topics via an internal @suaveplan/events bus exposed as i18n.events:

  • languageChanged — { from: string; to: string } — emitted by i18n.changeLanguage(locale)
  • loaded — { locale: string; namespace: string; backend?: string } — emitted on every catalog set / mergeMessages / backend read
  • missingKey — { locale: string; namespace: string; key: string; defaultValue?: string } — emitted on every miss before MissingKeyHandler is invoked
  • keyAdded — { locale: string; namespace: string; key: string; value: string } — emitted on saveMissing / HMR / backend write-through
  • pluralRuleAdded — { locale: string } — emitted by PluralizationEngine.registerRule

Scenario: changeLanguage emits languageChanged

  • WHEN i18n.changeLanguage('fr') is called from an en locale
  • THEN the bus emits languageChanged with { from: 'en', to: 'fr' } exactly once

Scenario: missing key emits missingKey before handler

  • WHEN t('absent.key') is called and a MissingKeyHandler is registered
  • THEN the bus emits missingKey with the lookup payload AND the handler is invoked AFTER the emission

Verified by: packages/i18n/i18n/src/core/plugin/events.test.ts::"changeLanguage emits languageChanged" AND packages/i18n/i18n/src/core/plugin/events.test.ts::"missingKey emitted before handler"


Requirement: List, display-name, and number-helper formatters

The package SHALL expose:

  • formatList(values: string[], locale: string, options?: ListFormatOptions) wrapping Intl.ListFormat with cached instance per (locale, options-hash)
  • formatDisplayName(code: string, locale: string, options: DisplayNameOptions) wrapping Intl.DisplayNames
  • formatPercent(value: number, locale: string, options?) — pre-bound formatNumber({ style: 'percent' })
  • formatCompact(value: number, locale: string, options?) — pre-bound formatNumber({ notation: 'compact' })
  • formatUnit(value: number, unit: string, locale: string, options?) — pre-bound formatNumber({ style: 'unit', unit })

Each function SHALL emit a sample on the relevant i18n.format.<name>.duration_ms histogram.

Scenario: list conjunction joins with locale-specific connector

  • WHEN formatList(['apples', 'oranges', 'bananas'], 'en-US', { type: 'conjunction' }) is called
  • THEN the result equals 'apples, oranges, and bananas'

Scenario: display-name returns localised region name

  • WHEN formatDisplayName('GB', 'en-US', { type: 'region' }) is called
  • THEN the result equals 'United Kingdom'

Scenario: compact notation for thousands

  • WHEN formatCompact(1234, 'en-US') is called
  • THEN the result equals '1.2K'

Scenario: unit formatting

  • WHEN formatUnit(12, 'kilometer-per-hour', 'en-US') is called
  • THEN the result equals '12 km/h'

Verified by: packages/i18n/i18n/src/formatting/list/list.test.ts::"list conjunction joins" AND packages/i18n/i18n/src/formatting/display-name/display-name.test.ts::"region name localised" AND packages/i18n/i18n/src/formatting/number/number-helpers.test.ts::"compact notation for thousands" AND packages/i18n/i18n/src/formatting/number/number-helpers.test.ts::"unit formatting"


Requirement: Pluralization engine prefers Intl.PluralRules

PluralizationEngine.getRule(locale) SHALL select rules in this order: (1) explicitly registered override rule, (2) Intl.PluralRules(locale) when supported by the runtime, (3) hand-written rule registry, (4) defaultPluralRule. The hand-written rules SHALL remain registered and SHALL be used when Intl.PluralRules is unavailable. This decouples the package from CLDR drift.

Scenario: registered override wins over Intl

  • GIVEN a custom rule registered for en that always returns 'few'
  • WHEN getRule('en')(5) is called
  • THEN the result equals 'few' (override wins, Intl skipped)

Scenario: Intl.PluralRules used when no override

  • GIVEN no override registered for pl
  • WHEN getCategory(2, 'pl') is called on a runtime where Intl.PluralRules is supported
  • THEN the result equals Intl.PluralRules('pl').select(2) (i.e. 'few')

Verified by: packages/i18n/i18n/src/core/pluralization/pluralization.test.ts::"override wins over Intl" AND packages/i18n/i18n/src/core/pluralization/pluralization.test.ts::"Intl used when no override"


Requirement: Locale-aware first-day-of-week

getDayNames(locale, style?) SHALL consult new Intl.Locale(locale).weekInfo.firstDay (1 = Monday, 7 = Sunday) to determine the array's starting weekday. When weekInfo is unavailable on the runtime (older Node, older browsers), the function SHALL fall back to Monday-start (firstDay: 1).

Scenario: en-US starts on Sunday

  • WHEN getDayNames('en-US', 'short') is called on a runtime where weekInfo is available
  • THEN the first element equals 'Sun'

Scenario: en-GB starts on Monday

  • WHEN getDayNames('en-GB', 'short') is called
  • THEN the first element equals 'Mon'

Verified by: packages/i18n/i18n/src/formatting/date/date.test.ts::"en-US starts on Sunday" AND packages/i18n/i18n/src/formatting/date/date.test.ts::"en-GB starts on Monday"


Requirement: Accept-Language preserves script casing

parseAcceptLanguage(header) SHALL preserve the BCP-47 case convention of script subtags: language → lowercase, script → title-case, region → uppercase. The current implementation lowercases the entire tag and is a bug.

Scenario: zh-Hant-TW round-trips with original casing

  • WHEN parseAcceptLanguage('zh-Hant-TW;q=0.9') is called
  • THEN the first entry's code equals 'zh-Hant-TW' (script title-case, region upper-case, language lower-case)

Verified by: packages/i18n/i18n/src/locale/negotiation/negotiation.test.ts::"zh-Hant-TW round-trips with original casing"


Requirement: Pseudo-localization post-processor

The package SHALL expose pseudoLocalize(text, options?) and a corresponding pseudoLocalizationPlugin(options?) that registers as a post-processor. Three modes SHALL be supported via mode: 'accent' | 'bracket' | 'expand':

  • accent (default): replaces ASCII letters with diacritic equivalents (Welcome → Wéłçömé)
  • bracket: surrounds the string with [!! ... !!] markers
  • expand: pads the string by ~30% with extra characters to test layout overflow

Scenario: accent mode produces diacritic equivalents

  • WHEN pseudoLocalize('Welcome', { mode: 'accent' }) is called
  • THEN the result is non-empty AND every original ASCII letter is replaced with a non-ASCII counterpart

Scenario: bracket mode surrounds the string

  • WHEN pseudoLocalize('Welcome', { mode: 'bracket' }) is called
  • THEN the result equals '[!! Welcome !!]'

Scenario: post-processor plugin transforms t() output

  • GIVEN an I18nInstance with pseudoLocalizationPlugin({ mode: 'bracket' }) registered
  • WHEN t('greeting') returns 'Welcome' from the catalog
  • THEN the final return value equals '[!! Welcome !!]'

Verified by: packages/i18n/i18n/src/post-processors/pseudo-localize.test.ts::"accent mode produces diacritic equivalents" AND packages/i18n/i18n/src/post-processors/pseudo-localize.test.ts::"bracket mode surrounds string" AND packages/i18n/i18n/src/post-processors/pseudo-localize.test.ts::"plugin transforms t() output"


Requirement: Segmenter and collator wrappers

The package SHALL expose formatSegment(text, locale, granularity?) returning an array of { segment: string; index: number; isWordLike?: boolean } via Intl.Segmenter, and compareLocale(a, b, locale, options?) wrapping Intl.Collator with cached instance per (locale, options-hash).

Scenario: word segmentation in Japanese

  • WHEN formatSegment('東京は晴れです', 'ja-JP', 'word') is called on a runtime with Intl.Segmenter
  • THEN the result has at least one segment with isWordLike: true and segment === '東京'

Scenario: collator sorts case-insensitively

  • WHEN ['B', 'a', 'C'].sort((a, b) => compareLocale(a, b, 'en-US', { sensitivity: 'base' }))
  • THEN the result equals ['a', 'B', 'C']

Verified by: packages/i18n/i18n/src/locale/segment/segment.test.ts::"word segmentation in Japanese" AND packages/i18n/i18n/src/locale/collator/collator.test.ts::"sorts case-insensitively"


Requirement: OTel meter instruments wired into production paths

Every existing instrument declared in src/observability/metrics.ts (i18n.translate.total, i18n.translate.duration_ms, i18n.locale.resolve.total) SHALL be incremented / observed from the relevant production code paths. Two additional instruments SHALL be added:

  • Histogram i18n.interpolation.duration_ms — emitted from InterpolationEngine.render and MessageParser.format
  • Counter i18n.missing_key.total — labels: locale, namespace; incremented from MessageCatalog.get whenever the lookup falls through to the missing-key handler or returns undefined

Scenario: get records translate.total

  • 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.