# OpenSpec Change: enterprise-i18n - **Change ID**: enterprise-i18n - **Package**: `@suaveplan/i18n` - **Category**: universal - **Target path**: `packages/universal/i18n` - **Status**: PROPOSED - **Date**: 2026-04-19 - **Reference spec**: `/mnt/s/web/suaveplan/enterprise/i18n.md` ## Summary Greenfield implementation of `@suaveplan/i18n` for the genesis branch. The existing main-branch i18n package provides the baseline public-API surface to preserve; this change delivers a complete internationalization core encompassing: - **Message catalog** — namespace support, lazy loaders (async MessageLoader), missing-key hooks, deep-merge of translation records, and typed lookup results - **ICU-like interpolation engine** — variable substitution, pluggable value formatters, HTML escaping, regex escaping - **Pluralization engine** — PluralRuleRegistry with language rules for Arabic, Chinese, English, French, German, Polish, Russian, Spanish, and Welsh; select-form resolution - **Message parser** — full AST (TextNode, VariableNode, PluralNode, SelectNode), `extractVariables`, `renderNodes` - **Locale resolution** — BCP-47 normalization, RTL detection, region/language extraction, pluggable resolution strategies (cookie, header, query-param, accept-language, default) - **Locale middleware** — server-side locale negotiation, request augmentation (`I18nRequest`), direction detection - **Date formatting** — `Intl.DateTimeFormat`-backed preset-based and custom formatting, day/month name helpers, date-range formatting - **Currency formatting** — `Intl.NumberFormat`-backed formatting, currency symbol/name lookups, a typed `CommonCurrency` registry - **Utility layer** — record flattening/unflattening, key building/parsing, HTML sanitization, RTL helpers, nested value access - **Structured errors** (`I18nError` + five typed codes), Zod schemas, OTel instrumentation, property-based tests, and benchmarks ## Current-state audit (genesis) The package does not exist on genesis yet — fresh build. ## Available workspace deps on genesis | Package | Available | |---------|-----------| | `@suaveplan/error` | ✅ | | `@suaveplan/types` | ✅ | | `@suaveplan/events` | ✅ | | `@suaveplan/logger` | ✅ | | `@suaveplan/opentelemetry` | ❌ not yet built — use `@opentelemetry/api` directly | | `@suaveplan/schema-registry` | ❌ not yet built — export Zod schemas directly | | `@suaveplan/templates` | ❌ not yet built — implement interpolation engine inline | External deps (via catalog): - `zod` → `catalog:forms` - `@opentelemetry/api` → `catalog:observability` - `tslib` → `catalog:build` Dev deps (via catalog): - `vitest`, `@vitest/coverage-istanbul` → `catalog:testing` - `fast-check` → `catalog:testing` - `mitata` → `catalog:testing` ## Preserved public API surface (from main branch) ### Constants - `DEFAULT_INTERPOLATION` — default interpolation config (delimiters, escape char) - `DEFAULT_LOCALES` — ordered list of built-in supported locales - `LOCALE_BCP47_MAP` — map from internal locale identifiers to BCP-47 tags - `LOCALE_DESCRIPTORS` — per-locale metadata (name, nativeName, direction, pluralRules) - `RTL_LOCALES` — set of locales that require right-to-left layout - `DefaultLocale` — type alias for the default locale string literal ### Core: catalog - `deepMerge(target, source)` — deep merge of translation record trees - `MessageCatalog` class — `.get(key, locale, opts?)`, `.set(locale, ns, record)`, `.load(locale, ns, loader)`, `.has(key, locale)`, `.keys(locale, ns?)` - `CatalogOptions`, `ExtendedCatalogOptions`, `LookupResult`, `MessageLoader`, `MissingKeyHandler`, `NamespacedCatalogMap`, `TranslationCatalogMap`, `TranslationRecord` ### Core: interpolation - `escapeHtmlChars(str)` — escapes `&`, `<`, `>`, `"`, `'` - `escapeRegex(str)` — escapes all regex metacharacters - `InterpolationEngine` class — `.render(template, values, opts?)` → `InterpolationResult` - `InterpolationConfig`, `InterpolationOptions`, `InterpolationResult`, `InterpolationValues`, `ValueFormatter` ### Core: pluralization - `arabicPlural`, `chinesePlural`, `defaultPluralRule`, `englishPlural`, `frenchPlural`, `germanPlural`, `polishPlural`, `russianPlural`, `spanishPlural`, `welshPlural` — `PluralRuleFunction` implementations - `getBaseLocale(locale)` — strips region subtag - `normalizeLocale(locale)` — lowercase language, uppercase region - `PluralizationEngine` class — `.resolve(count, locale, messages, opts?)` → `PluralResolveResult` - `PluralCategory`, `PluralMessages`, `PluralResolveOptions`, `PluralResolveResult`, `PluralRuleFunction`, `PluralRuleRegistry` ### Formatting: currency - `formatCurrency(amount, currency, locale, opts?)` → `string` - `formatCurrencyValue(amount, currency, locale, opts?)` → `string` (value only, no symbol) - `getCurrencyInfo(currency)` → `CurrencyInfo` - `getCurrencyName(currency, locale)` → `string` - `getCurrencySymbol(currency, locale)` → `string` - `CommonCurrency`, `CurrencyFormatOptions`, `CurrencyInfo` ### Formatting: date - `formatDate(date, locale, opts?)` → `string` - `formatDateOnly(date, locale, opts?)` → `string` - `formatDateRange(start, end, locale, opts?)` → `string` - `formatTime(date, locale, opts?)` → `string` - `getDayNames(locale, format?)` → `string[]` - `getMonthNames(locale, format?)` → `string[]` - `DateFormatOptions`, `DateFormatPreset` ### Locale - `extractLanguage(locale)` → `string` - `extractRegion(locale)` → `string | undefined` - `LocaleResolver` class — `.resolve(request, config)` → `Promise` - `normalizeLocaleCode(code)` → `string` - `ExtendedResolverConfig`, `ResolutionStrategy`, `ResolvedLocale`, `ResolverConfig` ### Middleware - `createLocaleMiddleware(config)` → middleware handler - `detectLocale(request, config)` → `Promise` - `getDirection(locale)` → `TextDirection` - `ExtendedMiddlewareConfig`, `I18nRequest`, `LocaleLookupSource`, `MiddlewareConfig`, `MiddlewareResult`, `TextDirection` ### Parser - `extractVariables(message)` → `string[]` - `MessageParser` class — `.parse(message, opts?)` → `ParsedMessage` - `renderNodes(nodes, values, opts?)` → `string` - `MessageNode`, `ParsedMessage`, `ParserOptions`, `PluralNode`, `SelectNode`, `TextNode`, `VariableNode` ### Utils - `buildKey(...segments)` → `string` - `countKeys(record)` → `number` - `flattenRecord(record, separator?)` → `Record` - `getNestedValue(record, key, separator?)` → `string | undefined` - `getTextDirection(locale)` → `TextDirection` - `hasKey(record, key, separator?)` → `boolean` - `isRtl(locale)` → `boolean` - `parseKey(key, separator?)` → `string[]` - `sanitize(html, opts?)` → `string` - `unflattenRecord(flat, separator?)` → `Record` - `KeyPathResult`, `SanitizeOptions` ## New additions for genesis ### Structured errors - `I18nError` base class extending `SuaveplanError` with error codes under the `I18N.*` namespace - Codes via `defineCodes`: - `I18N.MISSING_KEY` — key not found in catalog and no fallback available - `I18N.INVALID_LOCALE` — locale string fails BCP-47 validation - `I18N.LOADER_FAILED` — async `MessageLoader` rejected or returned invalid shape - `I18N.INTERPOLATION_ERROR` — template rendering failed (unclosed variable, bad formatter output) - `I18N.PLURAL_RULE_NOT_FOUND` — no plural rule registered for the requested locale ### Zod schemas - `TranslationRecordSchema` — recursive schema for nested translation records - `CatalogOptionsSchema` — validates `CatalogOptions` at catalog-construction boundary - `InterpolationConfigSchema` — validates delimiter pairs, escape character, and max iterations - `ResolverConfigSchema` — validates resolution strategy order and fallback locale ### OTel instrumentation Emitted via `@opentelemetry/api`; all instruments are no-ops without an exporter: - Counter `i18n.translate.total` with `outcome` label (`hit` | `miss`) and `locale` label - Counter `i18n.locale.resolve.total` with `strategy` label and `locale` label - Histogram `i18n.translate.duration_ms` — time from `.get()` call to resolved string, by locale ### Property-based tests (fast-check) - `flattenRecord` → `unflattenRecord` round-trip: for any depth-3 nested record of string leaves, `unflattenRecord(flattenRecord(r)) ≅ r` - `normalizeLocale` idempotent: `normalizeLocale(normalizeLocale(s)) === normalizeLocale(s)` for any string `s` - Interpolation escaping never leaks raw HTML: for any user-supplied variable value containing `<`, `>`, `&`, `"`, `'`, `InterpolationEngine.render` with `escapeHtml: true` must not emit unescaped characters in the rendered output - `buildKey` / `parseKey` round-trip: `buildKey(...parseKey(key)) === key` for any non-empty dot-separated key string ### Benchmarks (mitata) - `MessageCatalog.get` — 100 000 iterations on a pre-loaded catalog with 1 000 keys across 3 namespaces - `InterpolationEngine.render` — 10 000 iterations with a template containing 5 named variables - `PluralizationEngine.resolve` — 10 000 iterations cycling through `one`, `few`, `many`, `other` counts for English, Polish, and Arabic rules ## Impact - Adds one new package: `packages/universal/i18n` - Adds one new spec capability: `i18n` - No existing package consumers on genesis yet