173 lines
12 KiB
Markdown
173 lines
12 KiB
Markdown
# 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 `&` → `&`, `<` → `<`, `>` → `>`, `"` → `"`, `'` → `'`
|
|
- `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
|