186 lines
9.2 KiB
Markdown
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
|