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

9.2 KiB

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