9.2 KiB
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 typedCommonCurrencyregistry - 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:observabilitytslib→catalog:build
Dev deps (via catalog):
vitest,@vitest/coverage-istanbul→catalog:testingfast-check→catalog:testingmitata→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 localesLOCALE_BCP47_MAP— map from internal locale identifiers to BCP-47 tagsLOCALE_DESCRIPTORS— per-locale metadata (name, nativeName, direction, pluralRules)RTL_LOCALES— set of locales that require right-to-left layoutDefaultLocale— type alias for the default locale string literal
Core: catalog
deepMerge(target, source)— deep merge of translation record treesMessageCatalogclass —.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 metacharactersInterpolationEngineclass —.render(template, values, opts?)→InterpolationResultInterpolationConfig,InterpolationOptions,InterpolationResult,InterpolationValues,ValueFormatter
Core: pluralization
arabicPlural,chinesePlural,defaultPluralRule,englishPlural,frenchPlural,germanPlural,polishPlural,russianPlural,spanishPlural,welshPlural—PluralRuleFunctionimplementationsgetBaseLocale(locale)— strips region subtagnormalizeLocale(locale)— lowercase language, uppercase regionPluralizationEngineclass —.resolve(count, locale, messages, opts?)→PluralResolveResultPluralCategory,PluralMessages,PluralResolveOptions,PluralResolveResult,PluralRuleFunction,PluralRuleRegistry
Formatting: currency
formatCurrency(amount, currency, locale, opts?)→stringformatCurrencyValue(amount, currency, locale, opts?)→string(value only, no symbol)getCurrencyInfo(currency)→CurrencyInfogetCurrencyName(currency, locale)→stringgetCurrencySymbol(currency, locale)→stringCommonCurrency,CurrencyFormatOptions,CurrencyInfo
Formatting: date
formatDate(date, locale, opts?)→stringformatDateOnly(date, locale, opts?)→stringformatDateRange(start, end, locale, opts?)→stringformatTime(date, locale, opts?)→stringgetDayNames(locale, format?)→string[]getMonthNames(locale, format?)→string[]DateFormatOptions,DateFormatPreset
Locale
extractLanguage(locale)→stringextractRegion(locale)→string | undefinedLocaleResolverclass —.resolve(request, config)→Promise<ResolvedLocale>normalizeLocaleCode(code)→stringExtendedResolverConfig,ResolutionStrategy,ResolvedLocale,ResolverConfig
Middleware
createLocaleMiddleware(config)→ middleware handlerdetectLocale(request, config)→Promise<ResolvedLocale>getDirection(locale)→TextDirectionExtendedMiddlewareConfig,I18nRequest,LocaleLookupSource,MiddlewareConfig,MiddlewareResult,TextDirection
Parser
extractVariables(message)→string[]MessageParserclass —.parse(message, opts?)→ParsedMessagerenderNodes(nodes, values, opts?)→stringMessageNode,ParsedMessage,ParserOptions,PluralNode,SelectNode,TextNode,VariableNode
Utils
buildKey(...segments)→stringcountKeys(record)→numberflattenRecord(record, separator?)→Record<string, string>getNestedValue(record, key, separator?)→string | undefinedgetTextDirection(locale)→TextDirectionhasKey(record, key, separator?)→booleanisRtl(locale)→booleanparseKey(key, separator?)→string[]sanitize(html, opts?)→stringunflattenRecord(flat, separator?)→Record<string, unknown>KeyPathResult,SanitizeOptions
New additions for genesis
Structured errors
I18nErrorbase class extendingSuaveplanErrorwith error codes under theI18N.*namespace- Codes via
defineCodes:I18N.MISSING_KEY— key not found in catalog and no fallback availableI18N.INVALID_LOCALE— locale string fails BCP-47 validationI18N.LOADER_FAILED— asyncMessageLoaderrejected or returned invalid shapeI18N.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 recordsCatalogOptionsSchema— validatesCatalogOptionsat catalog-construction boundaryInterpolationConfigSchema— validates delimiter pairs, escape character, and max iterationsResolverConfigSchema— validates resolution strategy order and fallback locale
OTel instrumentation
Emitted via @opentelemetry/api; all instruments are no-ops without an exporter:
- Counter
i18n.translate.totalwithoutcomelabel (hit|miss) andlocalelabel - Counter
i18n.locale.resolve.totalwithstrategylabel andlocalelabel - Histogram
i18n.translate.duration_ms— time from.get()call to resolved string, by locale
Property-based tests (fast-check)
flattenRecord→unflattenRecordround-trip: for any depth-3 nested record of string leaves,unflattenRecord(flattenRecord(r)) ≅ rnormalizeLocaleidempotent:normalizeLocale(normalizeLocale(s)) === normalizeLocale(s)for any strings- Interpolation escaping never leaks raw HTML: for any user-supplied variable value containing
<,>,&,",',InterpolationEngine.renderwithescapeHtml: truemust not emit unescaped characters in the rendered output buildKey/parseKeyround-trip:buildKey(...parseKey(key)) === keyfor 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 namespacesInterpolationEngine.render— 10 000 iterations with a template containing 5 named variablesPluralizationEngine.resolve— 10 000 iterations cycling throughone,few,many,othercounts 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