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

12 KiB

Capability Spec: i18n

Package: @suaveplan/i18n Version: 0.0.0 (genesis) Status: ADDED


ADDED Requirements

I18N-01 — Structured error hierarchy

An I18nError base class MUST extend SuaveplanError from @suaveplan/error. All errors thrown by this package MUST extend I18nError. Five error codes MUST be registered via defineCodes under the I18N.* namespace:

  • I18N.MISSING_KEY — key not found in the catalog when strict mode is enabled and no MissingKeyHandler recovers a value
  • I18N.INVALID_LOCALE — locale string fails BCP-47 structural validation
  • I18N.LOADER_FAILED — async MessageLoader rejected or returned a value that does not satisfy TranslationRecordSchema
  • I18N.INTERPOLATION_ERROR — template rendering failed (e.g. unclosed variable delimiter, formatter threw)
  • I18N.PLURAL_RULE_NOT_FOUND — no plural rule is registered for the locale and strict mode is enabled

Error codes MUST be auto-registered via a side-effect import in src/index.ts (same pattern as @suaveplan/error).

I18N-02 — Zod schemas at the public boundary

The following Zod schemas MUST be exported and used to validate options at construction / call sites:

  • TranslationRecordSchema — recursive z.record(z.string(), z.union([z.string(), z.lazy(...)])) for nested translation trees; rejects arrays and non-string leaves
  • CatalogOptionsSchema — validates all fields of CatalogOptions including optional missingKeyHandler, defaultLocale, and strict flag
  • InterpolationConfigSchema — validates open and close delimiter strings (non-empty, distinct), escape character (single char or empty), and maxIterations (positive integer, ≤ 100)
  • ResolverConfigSchema — validates strategies (non-empty ordered array of ResolutionStrategy), fallbackLocale (non-empty BCP-47 string), and optional supportedLocales

Invalid options MUST throw I18nError with the relevant code and a Zod-formatted message attached as context.

I18N-03 — Message catalog contract

MessageCatalog MUST:

  • Accept namespaced TranslationRecord trees; key lookup MUST use dot-path resolution within the namespace
  • Expose .get(key, locale, opts?) returning LookupResult — { value: string; locale: string; namespace: string; source: 'catalog' | 'fallback' | 'handler' | 'key' }
  • Support async MessageLoader functions per locale+namespace pair; concurrent calls for the same locale+namespace MUST be deduplicated (single in-flight Promise)
  • Surface loader rejection as I18nError with code I18N.LOADER_FAILED
  • Invoke MissingKeyHandler (if registered) before throwing; if the handler returns a string, surface it with source: 'handler'
  • In strict mode (default false), throw I18nError (code I18N.MISSING_KEY) when no value is found; in non-strict mode, return the key itself with source: 'key'
  • deepMerge(target, source) MUST perform a deep structural merge of two TranslationRecord trees; source values win on scalar conflicts

I18N-04 — Interpolation engine contract

InterpolationEngine MUST:

  • Replace {variableName} tokens (default delimiters configurable via InterpolationConfig) with values from InterpolationValues
  • Support a ValueFormatter map: { [variableName]: (value, locale) => string } for locale-aware per-variable formatting
  • When escapeHtml: true in InterpolationOptions, HTML-escape all substituted values using escapeHtmlChars BEFORE insertion; raw template text is NOT escaped
  • Throw I18nError (code I18N.INTERPOLATION_ERROR) on unclosed delimiter or on formatter exception
  • escapeHtmlChars(str) MUST escape & → &amp;, < → &lt;, > → &gt;, " → &quot;, ' → &#39;
  • escapeRegex(str) MUST escape all regex metacharacters: . * + ? ^ $ { } [ ] | ( ) \

I18N-05 — Pluralization engine contract

PluralizationEngine MUST:

  • Accept a PluralRuleRegistry mapping locale codes to PluralRuleFunction; built-in rules for Arabic, Chinese, English, French, German, Polish, Russian, Spanish, Welsh, and a defaultPluralRule (English-style one/other) MUST be pre-registered
  • .resolve(count, locale, messages, opts?) MUST call the registered rule for locale, fall back to the base-language rule (strip region), then fall back to defaultPluralRule; return PluralResolveResult { category: PluralCategory; value: string }
  • PluralCategory MUST be 'zero' | 'one' | 'two' | 'few' | 'many' | 'other'
  • In strict mode, throw I18nError (code I18N.PLURAL_RULE_NOT_FOUND) when no rule exists for the locale and its base language
  • getBaseLocale(locale) strips the region subtag (en-US → en)
  • normalizeLocale(locale) normalizes to lowercase language + uppercase region (EN-us → en-US)

I18N-06 — Message parser contract

MessageParser MUST parse ICU-like message strings into an AST of MessageNode values:

  • TextNode — literal text segment
  • VariableNode — {name} — simple variable substitution
  • PluralNode — {count, plural, one{…} other{…}} — plural form with PluralCategory branches
  • SelectNode — {gender, select, male{…} female{…} other{…}} — arbitrary select form

renderNodes(nodes, values, opts?) MUST render an AST to a string by substituting values; unknown VariableNode references fall back to the variable name in braces.

extractVariables(message) MUST return the deduplicated list of variable names referenced in a raw message string without fully parsing the AST.

I18N-07 — Locale resolver contract

LocaleResolver MUST implement a pluggable strategy chain. Built-in ResolutionStrategy sources: 'cookie', 'query', 'header' (Accept-Language), 'default'. .resolve(request, config) MUST:

  • Try each strategy in config.strategies order; return the first locale that is in config.supportedLocales (if provided) and passes BCP-47 validation
  • Fall back to config.fallbackLocale when no strategy yields a valid supported locale
  • Return ResolvedLocale { locale: string; source: ResolutionStrategy | 'fallback'; direction: TextDirection }

normalizeLocaleCode(code) MUST throw I18nError (code I18N.INVALID_LOCALE) for strings that cannot be parsed as a valid BCP-47 language tag.

I18N-08 — Locale middleware contract

createLocaleMiddleware(config) MUST return a middleware function compatible with standard Node.js (req, res, next) signatures where req is I18nRequest (extends a plain object with locale?: ResolvedLocale). The middleware MUST:

  • Invoke detectLocale(request, config) to resolve the locale
  • Attach ResolvedLocale to request.locale
  • Set the Content-Language response header to the resolved locale string
  • Call next() unconditionally after attachment

getDirection(locale) MUST return 'rtl' for locales in RTL_LOCALES, otherwise 'ltr'.

I18N-09 — Date formatting contract

All date formatting functions MUST be backed by Intl.DateTimeFormat with no hard-coded locale-specific strings in implementation code.

  • formatDate(date, locale, opts?) — full date + time per DateFormatPreset or custom Intl.DateTimeFormatOptions
  • formatDateOnly(date, locale, opts?) — date portion only
  • formatTime(date, locale, opts?) — time portion only
  • formatDateRange(start, end, locale, opts?) — uses Intl.DateTimeFormat.prototype.formatRange where the runtime supports it; falls back to two separate formatted strings joined with an em-dash
  • getDayNames(locale, format?) — returns 7-element array via Intl.DateTimeFormat part extraction; format is 'long' | 'short' | 'narrow'
  • getMonthNames(locale, format?) — returns 12-element array; same format options

DateFormatPreset MUST include at minimum: 'short', 'medium', 'long', 'full', 'relative'.

I18N-10 — Currency formatting contract

All currency formatting MUST be backed by Intl.NumberFormat with style: 'currency'.

  • formatCurrency(amount, currency, locale, opts?) — full formatted string including symbol (e.g. $1,234.56)
  • formatCurrencyValue(amount, currency, locale, opts?) — numeric value only, no symbol (e.g. 1,234.56)
  • getCurrencySymbol(currency, locale) — extracts the currency symbol via Intl.NumberFormat parts
  • getCurrencyName(currency, locale) — localized currency name via Intl.DisplayNames with type: 'currency'
  • getCurrencyInfo(currency) — returns CurrencyInfo { code: CommonCurrency; numericCode: number; decimalDigits: number; name: string }

CommonCurrency MUST be a typed string-literal union covering at minimum: 'USD', 'EUR', 'GBP', 'JPY', 'CNY', 'CAD', 'AUD', 'CHF', 'SEK', 'NOK', 'DKK', 'SGD', 'HKD', 'NZD', 'KRW', 'INR', 'BRL', 'MXN', 'ZAR', 'RUB'.

I18N-11 — Utility functions contract

  • flattenRecord(record, separator?) MUST convert a nested TranslationRecord into a flat Record<string, string> using dot-separated paths; separator defaults to '.'
  • unflattenRecord(flat, separator?) MUST reconstruct a nested object from flat dot-path keys; conflicts (key is both a leaf and a node prefix) MUST throw
  • countKeys(record) MUST return the total count of string leaf values in a (nested) record
  • getNestedValue(record, key, separator?) MUST return the string at the dot-path or undefined if absent
  • hasKey(record, key, separator?) MUST return true iff the dot-path resolves to a string leaf
  • buildKey(...segments) MUST join segments with '.', filtering empty strings
  • parseKey(key, separator?) MUST split on separator, returning string[]; buildKey(...parseKey(key)) === key for any valid key
  • getTextDirection(locale) / isRtl(locale) MUST consult RTL_LOCALES
  • sanitize(html, opts?) MUST strip disallowed tags and attributes; SanitizeOptions controls allowedTags and allowedAttributes; default allowedTags is [] (strip all tags)

I18N-12 — OTel instrumentation

All instruments MUST be created via @opentelemetry/api getMeter('suaveplan.i18n') and MUST be no-ops when no exporter is registered.

  • Counter i18n.translate.total — incremented on every MessageCatalog.get call; labels: outcome ('hit' | 'miss'), locale
  • Counter i18n.locale.resolve.total — incremented on every LocaleResolver.resolve call; labels: strategy (winning strategy name or 'fallback'), locale
  • Histogram i18n.translate.duration_ms — records elapsed milliseconds for each MessageCatalog.get call; label: locale

I18N-13 — Property-based test requirements

The following properties MUST be verified with fast-check arbitrary inputs:

  1. flattenRecord/unflattenRecord round-trip — for any depth-≤3 record of string leaves (no key collisions), unflattenRecord(flattenRecord(r, sep), sep) deep-equals r
  2. normalizeLocale idempotent — normalizeLocale(normalizeLocale(s)) === normalizeLocale(s) for any non-empty ASCII string s
  3. HTML escaping completeness — for any string value containing one or more of <, >, &, ", ', InterpolationEngine.render with escapeHtml: true MUST NOT emit any of those characters unescaped in the returned string
  4. buildKey/parseKey round-trip — for any non-empty array of non-empty, dot-free strings segs, parseKey(buildKey(...segs)) deep-equals segs

I18N-14 — Benchmark requirements

Benchmarks MUST be implemented with mitata and MUST exercise the following hot paths:

  • MessageCatalog.get — 100 000 iterations on a pre-loaded catalog containing 1 000 keys spread across 3 namespaces and 2 locales
  • InterpolationEngine.render — 10 000 iterations with a fixed template containing 5 named variable placeholders and a matching InterpolationValues map
  • PluralizationEngine.resolve — 10 000 iterations cycling through counts [0, 1, 2, 5, 11, 100] for English, Polish, and Arabic rules

Baseline numbers MUST be published in README.md after the first green run.

I18N-15 — Package hygiene

  • "sideEffects": false in package.json; enforced by a tree-shake guard test in tests/
  • Vitest coverage thresholds MUST be set to 100/100/100/100 (statements/branches/functions/lines); provider MUST be istanbul
  • No it.skip, test.skip, describe.skip, or .only left in src/ or tests/
  • Zero browser-specific or Node.js-specific globals in implementation code — pure TypeScript that runs in Bun, Node, Deno, and browser environments; Intl API is universally available and permitted
  • All internal workspace dependencies use workspace:^; all external dependencies use Bun catalog entries