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 noMissingKeyHandlerrecovers a valueI18N.INVALID_LOCALE— locale string fails BCP-47 structural validationI18N.LOADER_FAILED— asyncMessageLoaderrejected or returned a value that does not satisfyTranslationRecordSchemaI18N.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— recursivez.record(z.string(), z.union([z.string(), z.lazy(...)]))for nested translation trees; rejects arrays and non-string leavesCatalogOptionsSchema— validates all fields ofCatalogOptionsincluding optionalmissingKeyHandler,defaultLocale, andstrictflagInterpolationConfigSchema— validatesopenandclosedelimiter strings (non-empty, distinct),escapecharacter (single char or empty), andmaxIterations(positive integer, ≤ 100)ResolverConfigSchema— validatesstrategies(non-empty ordered array ofResolutionStrategy),fallbackLocale(non-empty BCP-47 string), and optionalsupportedLocales
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
TranslationRecordtrees; key lookup MUST use dot-path resolution within the namespace - Expose
.get(key, locale, opts?)returningLookupResult—{ value: string; locale: string; namespace: string; source: 'catalog' | 'fallback' | 'handler' | 'key' } - Support async
MessageLoaderfunctions per locale+namespace pair; concurrent calls for the same locale+namespace MUST be deduplicated (single in-flight Promise) - Surface loader rejection as
I18nErrorwith codeI18N.LOADER_FAILED - Invoke
MissingKeyHandler(if registered) before throwing; if the handler returns a string, surface it withsource: 'handler' - In strict mode (default
false), throwI18nError(codeI18N.MISSING_KEY) when no value is found; in non-strict mode, return the key itself withsource: 'key' deepMerge(target, source)MUST perform a deep structural merge of twoTranslationRecordtrees; source values win on scalar conflicts
I18N-04 — Interpolation engine contract
InterpolationEngine MUST:
- Replace
{variableName}tokens (default delimiters configurable viaInterpolationConfig) with values fromInterpolationValues - Support a
ValueFormattermap:{ [variableName]: (value, locale) => string }for locale-aware per-variable formatting - When
escapeHtml: trueinInterpolationOptions, HTML-escape all substituted values usingescapeHtmlCharsBEFORE insertion; raw template text is NOT escaped - Throw
I18nError(codeI18N.INTERPOLATION_ERROR) on unclosed delimiter or on formatter exception escapeHtmlChars(str)MUST escape&→&,<→<,>→>,"→",'→'escapeRegex(str)MUST escape all regex metacharacters:. * + ? ^ $ { } [ ] | ( ) \
I18N-05 — Pluralization engine contract
PluralizationEngine MUST:
- Accept a
PluralRuleRegistrymapping locale codes toPluralRuleFunction; built-in rules for Arabic, Chinese, English, French, German, Polish, Russian, Spanish, Welsh, and adefaultPluralRule(English-style one/other) MUST be pre-registered .resolve(count, locale, messages, opts?)MUST call the registered rule forlocale, fall back to the base-language rule (strip region), then fall back todefaultPluralRule; returnPluralResolveResult { category: PluralCategory; value: string }PluralCategoryMUST be'zero' | 'one' | 'two' | 'few' | 'many' | 'other'- In strict mode, throw
I18nError(codeI18N.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 segmentVariableNode—{name}— simple variable substitutionPluralNode—{count, plural, one{…} other{…}}— plural form withPluralCategorybranchesSelectNode—{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.strategiesorder; return the first locale that is inconfig.supportedLocales(if provided) and passes BCP-47 validation - Fall back to
config.fallbackLocalewhen 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
ResolvedLocaletorequest.locale - Set the
Content-Languageresponse 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 perDateFormatPresetor customIntl.DateTimeFormatOptionsformatDateOnly(date, locale, opts?)— date portion onlyformatTime(date, locale, opts?)— time portion onlyformatDateRange(start, end, locale, opts?)— usesIntl.DateTimeFormat.prototype.formatRangewhere the runtime supports it; falls back to two separate formatted strings joined with an em-dashgetDayNames(locale, format?)— returns 7-element array viaIntl.DateTimeFormatpart extraction;formatis'long' | 'short' | 'narrow'getMonthNames(locale, format?)— returns 12-element array; sameformatoptions
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 viaIntl.NumberFormatpartsgetCurrencyName(currency, locale)— localized currency name viaIntl.DisplayNameswithtype: 'currency'getCurrencyInfo(currency)— returnsCurrencyInfo { 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 nestedTranslationRecordinto a flatRecord<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 throwcountKeys(record)MUST return the total count of string leaf values in a (nested) recordgetNestedValue(record, key, separator?)MUST return the string at the dot-path orundefinedif absenthasKey(record, key, separator?)MUST returntrueiff the dot-path resolves to a string leafbuildKey(...segments)MUST join segments with'.', filtering empty stringsparseKey(key, separator?)MUST split on separator, returningstring[];buildKey(...parseKey(key)) === keyfor any valid keygetTextDirection(locale)/isRtl(locale)MUST consultRTL_LOCALESsanitize(html, opts?)MUST strip disallowed tags and attributes;SanitizeOptionscontrolsallowedTagsandallowedAttributes; defaultallowedTagsis[](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 everyMessageCatalog.getcall; labels:outcome('hit'|'miss'),locale - Counter
i18n.locale.resolve.total— incremented on everyLocaleResolver.resolvecall; labels:strategy(winning strategy name or'fallback'),locale - Histogram
i18n.translate.duration_ms— records elapsed milliseconds for eachMessageCatalog.getcall; label:locale
I18N-13 — Property-based test requirements
The following properties MUST be verified with fast-check arbitrary inputs:
- flattenRecord/unflattenRecord round-trip — for any depth-≤3 record of string leaves (no key collisions),
unflattenRecord(flattenRecord(r, sep), sep)deep-equalsr - normalizeLocale idempotent —
normalizeLocale(normalizeLocale(s)) === normalizeLocale(s)for any non-empty ASCII strings - HTML escaping completeness — for any string value containing one or more of
<,>,&,",',InterpolationEngine.renderwithescapeHtml: trueMUST NOT emit any of those characters unescaped in the returned string - 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 localesInterpolationEngine.render— 10 000 iterations with a fixed template containing 5 named variable placeholders and a matchingInterpolationValuesmapPluralizationEngine.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": falseinpackage.json; enforced by a tree-shake guard test intests/- 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.onlyleft insrc/ortests/ - Zero browser-specific or Node.js-specific globals in implementation code — pure TypeScript that runs in Bun, Node, Deno, and browser environments;
IntlAPI is universally available and permitted - All internal workspace dependencies use
workspace:^; all external dependencies use Bun catalog entries