Files
suaveplan/openspec/archive/2026-04-19-enterprise-i18n/proposal.md
2026-05-27 23:29:53 +01:00

186 lines
9.2 KiB
Markdown

# 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<ResolvedLocale>`
- `normalizeLocaleCode(code)` → `string`
- `ExtendedResolverConfig`, `ResolutionStrategy`, `ResolvedLocale`, `ResolverConfig`
### Middleware
- `createLocaleMiddleware(config)` → middleware handler
- `detectLocale(request, config)` → `Promise<ResolvedLocale>`
- `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<string, string>`
- `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<string, unknown>`
- `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