# Capability Spec: i18n **Package**: `@suaveplan/i18n` **Version**: 0.0.0 (genesis) **Status**: ADDED --- ## ADDED 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`. Five 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 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 used to validate options at construction / call sites: - `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` flag - `InterpolationConfigSchema` — validates `open` and `close` delimiter strings (non-empty, distinct), `escape` character (single char or empty), and `maxIterations` (positive integer, ≤ 100) - `ResolverConfigSchema` — validates `strategies` (non-empty ordered array of `ResolutionStrategy`), `fallbackLocale` (non-empty BCP-47 string), and optional `supportedLocales` Invalid options MUST throw `I18nError` with the relevant code and a Zod-formatted message attached as `context`. ### I18N-03 — Message catalog contract `MessageCatalog` MUST: - Accept namespaced `TranslationRecord` trees; key lookup MUST use dot-path resolution within the namespace - Expose `.get(key, locale, opts?)` returning `LookupResult` — `{ value: string; locale: string; namespace: string; source: 'catalog' | 'fallback' | 'handler' | 'key' }` - Support async `MessageLoader` functions per locale+namespace pair; concurrent calls for the same locale+namespace MUST be deduplicated (single in-flight Promise) - Surface loader rejection as `I18nError` with code `I18N.LOADER_FAILED` - Invoke `MissingKeyHandler` (if registered) before throwing; if the handler returns a string, surface it with `source: 'handler'` - In strict mode (default `false`), throw `I18nError` (code `I18N.MISSING_KEY`) when no value is found; in non-strict mode, return the key itself with `source: 'key'` - `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` (code `I18N.INTERPOLATION_ERROR`) on unclosed delimiter or on formatter exception - `escapeHtmlChars(str)` MUST escape `&` → `&`, `<` → `<`, `>` → `>`, `"` → `"`, `'` → `'` - `escapeRegex(str)` MUST escape all regex metacharacters: `. * + ? ^ $ { } [ ] | ( ) \` ### I18N-05 — Pluralization engine contract `PluralizationEngine` MUST: - Accept a `PluralRuleRegistry` mapping locale codes to `PluralRuleFunction`; built-in rules for Arabic, Chinese, English, French, German, Polish, Russian, Spanish, Welsh, and a `defaultPluralRule` (English-style one/other) MUST be pre-registered - `.resolve(count, locale, messages, opts?)` MUST call the registered rule for `locale`, 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` (code `I18N.PLURAL_RULE_NOT_FOUND`) when no rule exists for the locale and its base language - `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 }` `normalizeLocaleCode(code)` MUST throw `I18nError` (code `I18N.INVALID_LOCALE`) for strings that cannot be parsed as a valid BCP-47 language tag. ### 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` - Set the `Content-Language` response header to the resolved locale string - Call `next()` unconditionally after attachment `getDirection(locale)` MUST return `'rtl'` for locales in `RTL_LOCALES`, otherwise `'ltr'`. ### I18N-09 — Date formatting contract All date formatting functions MUST be backed by `Intl.DateTimeFormat` with no hard-coded locale-specific strings in implementation code. - `formatDate(date, locale, opts?)` — full date + time per `DateFormatPreset` or custom `Intl.DateTimeFormatOptions` - `formatDateOnly(date, locale, opts?)` — date portion only - `formatTime(date, locale, opts?)` — time portion only - `formatDateRange(start, end, locale, opts?)` — uses `Intl.DateTimeFormat.prototype.formatRange` where the runtime supports it; falls back to two separate formatted strings joined with an em-dash - `getDayNames(locale, format?)` — returns 7-element array via `Intl.DateTimeFormat` part extraction; `format` is `'long' | 'short' | 'narrow'` - `getMonthNames(locale, format?)` — returns 12-element array; same `format` options `DateFormatPreset` MUST include at minimum: `'short'`, `'medium'`, `'long'`, `'full'`, `'relative'`. ### I18N-10 — Currency formatting contract All currency formatting MUST be backed by `Intl.NumberFormat` with `style: 'currency'`. - `formatCurrency(amount, currency, locale, opts?)` — full formatted string including symbol (e.g. `$1,234.56`) - `formatCurrencyValue(amount, currency, locale, opts?)` — numeric value only, no symbol (e.g. `1,234.56`) - `getCurrencySymbol(currency, locale)` — extracts the currency symbol via `Intl.NumberFormat` parts - `getCurrencyName(currency, locale)` — localized currency name via `Intl.DisplayNames` with `type: 'currency'` - `getCurrencyInfo(currency)` — returns `CurrencyInfo { code: CommonCurrency; numericCode: number; decimalDigits: number; name: string }` `CommonCurrency` MUST be a typed string-literal union covering at minimum: `'USD'`, `'EUR'`, `'GBP'`, `'JPY'`, `'CNY'`, `'CAD'`, `'AUD'`, `'CHF'`, `'SEK'`, `'NOK'`, `'DKK'`, `'SGD'`, `'HKD'`, `'NZD'`, `'KRW'`, `'INR'`, `'BRL'`, `'MXN'`, `'ZAR'`, `'RUB'`. ### I18N-11 — Utility functions contract - `flattenRecord(record, separator?)` MUST convert a nested `TranslationRecord` into a flat `Record` using dot-separated paths; separator defaults to `'.'` - `unflattenRecord(flat, separator?)` MUST reconstruct a nested object from flat dot-path keys; conflicts (key is both a leaf and a node prefix) MUST throw - `countKeys(record)` MUST return the total count of string leaf values in a (nested) record - `getNestedValue(record, key, separator?)` MUST return the string at the dot-path or `undefined` if absent - `hasKey(record, key, separator?)` MUST return `true` iff the dot-path resolves to a string leaf - `buildKey(...segments)` MUST join segments with `'.'`, filtering empty strings - `parseKey(key, separator?)` MUST split on separator, returning `string[]`; `buildKey(...parseKey(key)) === key` for any valid key - `getTextDirection(locale)` / `isRtl(locale)` MUST consult `RTL_LOCALES` - `sanitize(html, opts?)` MUST strip disallowed tags and attributes; `SanitizeOptions` controls `allowedTags` and `allowedAttributes`; default `allowedTags` is `[]` (strip all tags) ### I18N-12 — OTel instrumentation All instruments MUST be created via `@opentelemetry/api` `getMeter('suaveplan.i18n')` and MUST be no-ops when no exporter is registered. - Counter `i18n.translate.total` — incremented on every `MessageCatalog.get` call; labels: `outcome` (`'hit'` | `'miss'`), `locale` - Counter `i18n.locale.resolve.total` — incremented on every `LocaleResolver.resolve` call; labels: `strategy` (winning strategy name or `'fallback'`), `locale` - Histogram `i18n.translate.duration_ms` — records elapsed milliseconds for each `MessageCatalog.get` call; label: `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