69 KiB
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 noMissingKeyHandlerrecovers a valueI18N.INVALID_LOCALE— locale string fails BCP-47 structural validationI18N.LOADER_FAILED— asyncMessageLoaderrejected or returned a value that does not satisfyTranslationRecordSchemaI18N.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 enabledI18N.INVALID_OPTIONS— engine constructor option schema (CatalogOptionsSchema,InterpolationConfigSchema,ResolverConfigSchema, formatter option schemas) failed Zod parse;context.zodIssuescarries 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— recursivez.record(z.string(), z.union([z.string(), z.lazy(...)]))for nested translation trees; rejects arrays and non-string leavesCatalogOptionsSchema— validates all fields ofCatalogOptionsincluding optionalmissingKeyHandler,defaultLocale, andstrict?: boolean(defaultfalse)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
TranslationRecordtrees; key lookup MUST use dot-path resolution within the namespace. - Expose
set(locale, namespace, record),load(locale, namespaceOrLoader, loader?)(overloaded so legacyload(locale, loader)still compiles),keys(locale, namespace?)(scoped to namespace when provided),has(key, locale?),get(key, locale?), andt(key, locale?). - Return
LookupResult<TLocale> = { value: string | undefined; locale: TLocale; namespace: string; source: 'catalog' | 'fallback' | 'handler' | 'key' }fromget. - Support async
MessageLoaderper(locale, namespace)pair; concurrent calls for the same${locale}::${namespace ?? ''}key MUST be deduplicated (single in-flight Promise) via an internalMap<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 sameI18N.LOADER_FAILEDcode. - Invoke
MissingKeyHandler(if registered) before throwing; if the handler returns a string, surface it withsource: 'handler'. - In strict mode (default
false), throwI18nError(I18N.MISSING_KEY)when no value is found after handler fallback; otherwise return{ value: undefined, ..., source: 'key' }(andt()returns the key string). - Provide typed key inference via
t<K extends DotPaths<TMessages> | (string & {})>(key: K, locale?)andget<K extends DotPaths<TMessages> | (string & {})>(key: K, locale?). deepMerge(target, source)MUST perform a deep structural merge of twoTranslationRecordtrees; source values win on scalar conflicts.
I18N-04 — Interpolation engine contract
InterpolationEngine MUST:
- Replace
{variableName}tokens (default delimiters configurable viaInterpolationConfig) with values fromInterpolationValues. - Support a
ValueFormattermap:{ [variableName]: (value, locale) => string }for locale-aware per-variable formatting. - When
escapeHtml: trueinInterpolationOptions, HTML-escape all substituted values usingescapeHtmlCharsBEFORE 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
ValueFormatterasI18nError(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
PluralRuleRegistrythat callers can write to viaregisterRule(locale, rule). Built-in hand-written rules for Arabic, Chinese, English, French, German, Polish, Russian, Spanish, Welsh, anddefaultPluralRuleSHALL remain registered as overrides. .getRule(locale)selects in this order: (1) explicit override registered viaregisterRule, (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 todefaultPluralRule; returnPluralResolveResult { category: PluralCategory; value: string }.PluralCategoryMUST 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 andIntl.PluralRulesis 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 segmentVariableNode—{name}— simple variable substitutionPluralNode—{count, plural, one{…} other{…}}— plural form withPluralCategorybranchesSelectNode—{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.strategiesorder; return the first locale that is inconfig.supportedLocales(if provided) and passes BCP-47 validation. - Fall back to
config.fallbackLocalewhen 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
ResolvedLocaletorequest.locale(withdirection: TextDirection). - When an optional
setHeader(name, value)callback is supplied inExtendedMiddlewareConfig, invokesetHeader('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 perDateFormatPresetor customIntl.DateTimeFormatOptionsformatDateOnly(date, locale, opts?)— date portion onlyformatTime(date, locale, opts?)— time portion onlyformatDateRange(start, end, locale, opts?)— usesIntl.DateTimeFormat.prototype.formatRangewhere the runtime supports it; falls back to two separate formatted strings joined with an em-dashgetDayNames(locale, format?)— returns 7-element array viaIntl.DateTimeFormatpart extraction;formatis'long' | 'short' | 'narrow'getMonthNames(locale, format?)— returns 12-element array; sameformatoptions
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 viaIntl.NumberFormatpartsgetCurrencyName(currency, locale)— localized currency name viaIntl.DisplayNameswithtype: 'currency'getCurrencyInfo(currency)— returnsCurrencyInfo { 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 nestedTranslationRecordinto a flatRecord<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 throwcountKeys(record)MUST return the total count of string leaf values in a (nested) recordgetNestedValue(record, key, separator?)MUST return the string at the dot-path orundefinedif absenthasKey(record, key, separator?)MUST returntrueiff the dot-path resolves to a string leafbuildKey(...segments)MUST join segments with'.', filtering empty stringsparseKey(key, separator?)MUST split on separator, returningstring[];buildKey(...parseKey(key)) === keyfor any valid keygetTextDirection(locale)/isRtl(locale)MUST consultRTL_LOCALESsanitize(html, opts?)MUST strip disallowed tags and attributes;SanitizeOptionscontrolsallowedTagsandallowedAttributes; defaultallowedTagsis[](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 insideMessageCatalog.getwith attributes{ outcome: 'hit' | 'miss', locale }.localeResolveTotal: Counter—i18n.locale.resolve.total, incremented insidedetectLocalewith attributes{ strategy, locale }(winning strategy name or'fallback').translateDuration: Histogram—i18n.translate.duration_ms, records elapsed milliseconds for eachMessageCatalog.getcall with attribute{ locale }.
I18N-13 — Property-based test requirements
The following properties MUST be verified with fast-check arbitrary inputs:
- flattenRecord/unflattenRecord round-trip — for any depth-≤3 record of string leaves (no key collisions),
unflattenRecord(flattenRecord(r, sep), sep)deep-equalsr - normalizeLocale idempotent —
normalizeLocale(normalizeLocale(s)) === normalizeLocale(s)for any non-empty ASCII strings - HTML escaping completeness — for any string value containing one or more of
<,>,&,",',InterpolationEngine.renderwithescapeHtml: trueMUST NOT emit any of those characters unescaped in the returned string - 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 localesInterpolationEngine.render— 10 000 iterations with a fixed template containing 5 named variable placeholders and a matchingInterpolationValuesmapPluralizationEngine.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": falseinpackage.json; enforced by a tree-shake guard test intests/- 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.onlyleft insrc/ortests/ - Zero browser-specific or Node.js-specific globals in implementation code — pure TypeScript that runs in Bun, Node, Deno, and browser environments;
IntlAPI 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 subsequentt('auth:login.title')returns'Sign in'
Scenario: read failure maps to I18N.BACKEND_READ_FAILED
- WHEN a backend's
readrejects with aTypeError - THEN the catalog throws
I18nErrorwith codeI18N.BACKEND_READ_FAILEDanderror.cause === TypeError
Scenario: missing readMulti falls back to N reads
- WHEN
MessageCatalog.preload(['en'], ['auth', 'common'])is called against a backend that omitsreadMulti - THEN
readis 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' } } } } })thenread('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, thenbackend.read('en', 'auth') - THEN the resolved record deep-equals the saved value AND
lastModified('en', 'auth')returns a number ≥ the timestamp beforesave
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 })thenread('en', 'auth')withfetchFnresolving toResponse.json({ login: { title: 'Sign in' } }) - THEN the resolved record equals
{ login: { title: 'Sign in' } }ANDfetchFnwas called with'/locales/en/auth.json'
Scenario: HTTP 404 surfaces LOCALE_NOT_AVAILABLE
- WHEN
fetchFnresolves with status 404 - THEN the call rejects with
I18nError(I18N.BACKEND_LOCALE_NOT_AVAILABLE)andcontext.status === 404
Scenario: HTTP 500 surfaces BACKEND_READ_FAILED
- WHEN
fetchFnresolves with status 500 - THEN the call rejects with
I18nError(I18N.BACKEND_READ_FAILED)andcontext.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-encodedTranslationRecordsingle-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' })andbackend.save('en', 'auth', { login: { title: 'Sign in' } }) - THEN
MemoryStorage.get('i18n/en/auth.json')decodes to{ login: { title: 'Sign in' } }ANDbackend.read('en', 'auth')returns the same record
Scenario: single-key-per-locale stores all namespaces under one key
- WHEN
layout: 'single-key-per-locale'andbackend.save('en', 'auth', X)thenbackend.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'andbackend.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
savecallsmsgpackEncodefor serialisation andreadcallsmsgpackDecode
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-suppliedglobPatternwith{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.jsoncontains{ "login": { "title": "Sign in" } }andbackend.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.jsondoes not exist andbackend.read('de', 'auth')is called - THEN the call rejects with
I18nError(I18N.BACKEND_LOCALE_NOT_AVAILABLE)ANDcontext.pathis the resolved file path
Scenario: watch emits loaded event on file change
- WHEN
watch: trueand an external process writes a new value to<root>/en/auth.json - THEN the catalog emits
loadedwith{ 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])andmemory.read('en', 'auth')resolves - THEN
http.readis never called
Scenario: cache miss writes through to earlier backends
- WHEN
Chained([memory, http]),memory.readrejects withBACKEND_LOCALE_NOT_AVAILABLE, andhttp.readresolves withR - THEN the chained
readresolves withRANDmemory.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
readpropagates 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
MemoryBackendcarries seeds foren/authanden/commonandcatalog.preload(['en'], ['auth', 'common'])is called - THEN
t('auth:login.title')andt('common:cancel')resolve from the catalog without further backend calls
Scenario: invalidate forces re-read
- WHEN
catalog.preload(['en'], ['auth'])succeeds, thencatalog.invalidate('en', 'auth'), thencatalog.preload(['en'], ['auth'])is called again - THEN the backend's
readwas called twice for the(en, auth)pair
Scenario: concurrent preload calls dedup
- WHEN
catalog.preload(['en'], ['auth'])is called twice in immediate succession (noawaitbetween them) - THEN the backend's
readis 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
authnamespace loaded andcatalog.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
readrejects withBACKEND_READ_FAILED - THEN
getAsyncrejects with the sameI18nError
Scenario: synchronous t remains unchanged under lazyLoad
- WHEN
lazyLoad: trueandt('auth:login.title')is called for a not-yet-loaded namespace - THEN the synchronous return value is
'auth:login.title'(the key) AND the backendreadwas NOT triggered (onlygetAsynctriggers 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: trueandt('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.saverejects - THEN
t()returns the default value AND no exception is thrown AND the OTel counteri18n.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 inMessageCatalog's in-memory store
Scenario: read records duration sample
- WHEN
StorageBackend.read('en', 'auth')is called once with an in-memoryMeterProvider - THEN exactly one sample is recorded under
i18n.backend.read.duration_mswith 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
I18nErrorwith codeI18N.INVALID_FORMATTER_OPTIONSandcontext.field === "style"
Scenario: unsupported locale surfaces structured error
- WHEN
formatNumber(1, 'xx-INVALID-LOCALE-1234', {})is called - THEN it throws
I18nErrorwith codeI18N.UNSUPPORTED_LOCALE(the underlyingIntl.NumberFormatRangeErroris mapped, not propagated)
Scenario: fraction-digit inversion rejected
- WHEN options carry
{ minimumFractionDigits: 4, maximumFractionDigits: 2 } - THEN Zod rejection produces
I18N.INVALID_FORMATTER_OPTIONSwithcontext.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_hitby 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_hitdoes 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 is2(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 theIntl.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
I18nErrorwith codeI18N.INVALID_FORMATTER_OPTIONSandcontext.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
MeterProviderrecords exactly one sample underi18n.format.number.duration_mswith 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)withcontext.pathcontaining['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 }underenlocale - 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,fulltime:short,medium,long,fullnumber: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 }underen-USlocale - 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)) }underen-GBlocale - 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'andfield === '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
I18nInstanceand a plugin whoseinstallincrements 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
I18nInstancewith a plugin named'http-backend'already registered - WHEN another plugin with the same name is registered
- THEN it throws
I18nError(I18N.PLUGIN_ALREADY_INSTALLED)withcontext.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 byi18n.changeLanguage(locale)loaded—{ locale: string; namespace: string; backend?: string }— emitted on every catalogset/mergeMessages/ backend readmissingKey—{ locale: string; namespace: string; key: string; defaultValue?: string }— emitted on every miss beforeMissingKeyHandleris invokedkeyAdded—{ locale: string; namespace: string; key: string; value: string }— emitted onsaveMissing/ HMR / backend write-throughpluralRuleAdded—{ locale: string }— emitted byPluralizationEngine.registerRule
Scenario: changeLanguage emits languageChanged
- WHEN
i18n.changeLanguage('fr')is called from anenlocale - THEN the bus emits
languageChangedwith{ from: 'en', to: 'fr' }exactly once
Scenario: missing key emits missingKey before handler
- WHEN
t('absent.key')is called and aMissingKeyHandleris registered - THEN the bus emits
missingKeywith 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)wrappingIntl.ListFormatwith cached instance per(locale, options-hash)formatDisplayName(code: string, locale: string, options: DisplayNameOptions)wrappingIntl.DisplayNamesformatPercent(value: number, locale: string, options?)— pre-boundformatNumber({ style: 'percent' })formatCompact(value: number, locale: string, options?)— pre-boundformatNumber({ notation: 'compact' })formatUnit(value: number, unit: string, locale: string, options?)— pre-boundformatNumber({ 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
enthat 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 whereIntl.PluralRulesis 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 whereweekInfois 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
codeequals'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[!! ... !!]markersexpand: 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
I18nInstancewithpseudoLocalizationPlugin({ 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 withIntl.Segmenter - THEN the result has at least one segment with
isWordLike: trueandsegment === '東京'
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 fromInterpolationEngine.renderandMessageParser.format - Counter
i18n.missing_key.total— labels:locale,namespace; incremented fromMessageCatalog.getwhenever the lookup falls through to the missing-key handler or returnsundefined
Scenario: get records translate.total
- WHEN
MessageCatalog.get('present.key')resolves a value with the in-memoryMeterProviderregistered - THEN exactly one sample is recorded under
i18n.translate.totalwith attributeoutcome: 'hit'AND one sample underi18n.translate.duration_ms
Scenario: locale resolve records strategy
- WHEN
LocaleResolver.resolve('fr-FR')returns via'language'strategy - THEN
i18n.locale.resolve.totalrecords a sample with attributestrategy: 'language'
Scenario: missing key increments counter
- WHEN
MessageCatalog.get('absent.key')returnsundefined - THEN
i18n.missing_key.totalrecords 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
warncall mentioning'absent.key'
Scenario: persistMissing routes to backend
- GIVEN
saveMissing: true, aMemoryBackend, andcreateMissingKeyReporter({ persistMissing: true }) - WHEN
t('new.key', { defaultValue: 'New' })is called for a missing key - THEN the backend's
savewas 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
defaultFormatBridgeis imported from@suaveplan/i18n - THEN
number,currency,date,time,relativeTimeare 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 founderrors
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.