Initial Commit
This commit is contained in:
185
openspec/archive/2026-04-19-enterprise-i18n/proposal.md
Normal file
185
openspec/archive/2026-04-19-enterprise-i18n/proposal.md
Normal file
@@ -0,0 +1,185 @@
|
||||
# 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
|
||||
172
openspec/archive/2026-04-19-enterprise-i18n/specs/i18n/spec.md
Normal file
172
openspec/archive/2026-04-19-enterprise-i18n/specs/i18n/spec.md
Normal file
@@ -0,0 +1,172 @@
|
||||
# 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
|
||||
141
openspec/archive/2026-04-19-enterprise-i18n/tasks.md
Normal file
141
openspec/archive/2026-04-19-enterprise-i18n/tasks.md
Normal file
@@ -0,0 +1,141 @@
|
||||
> Archived 2026-04-19: all tasks retroactively ticked; package shipped under universal/primitives/i18n.
|
||||
|
||||
# Tasks: enterprise-i18n
|
||||
|
||||
## 1. Scaffold
|
||||
|
||||
- [x] 1.1 Create `packages/universal/i18n/` with `package.json`, `tsconfig.json`, `tsconfig.lib.json`, `vite.config.ts`, `vitest.config.ts`, `README.md` skeleton
|
||||
- [x] 1.2 Register coverage thresholds at 100/100/100/100 in `vitest.config.ts`; provider = istanbul
|
||||
- [x] 1.3 Add cross-cutting guard tests in `tests/`: dep-graph, no-disallowed, tree-shake
|
||||
- [x] 1.4 Add package to root `CLAUDE.md`, `README.md`, and `STRUCTURE.yml`
|
||||
|
||||
## 2. Error types + Zod schemas
|
||||
|
||||
- [x] 2.1 Implement `src/errors/i18n-error.ts` — `I18nError` base extends `SuaveplanError`; `defineCodes` for `I18N.MISSING_KEY`, `I18N.INVALID_LOCALE`, `I18N.LOADER_FAILED`, `I18N.INTERPOLATION_ERROR`, `I18N.PLURAL_RULE_NOT_FOUND`
|
||||
- [x] 2.2 Implement `src/error-codes.ts` and auto-register via side-effect import in `src/index.ts`
|
||||
- [x] 2.3 Implement `src/schemas/catalog.schema.ts` — `TranslationRecordSchema` (recursive), `CatalogOptionsSchema`
|
||||
- [x] 2.4 Implement `src/schemas/interpolation.schema.ts` — `InterpolationConfigSchema`
|
||||
- [x] 2.5 Implement `src/schemas/resolver.schema.ts` — `ResolverConfigSchema`
|
||||
- [x] 2.6 Tests: error construction, code narrowing, schema parse/reject on valid and invalid inputs
|
||||
|
||||
## 3. Constants
|
||||
|
||||
- [x] 3.1 Implement `src/constants/index.ts` — `DEFAULT_INTERPOLATION`, `DEFAULT_LOCALES`, `LOCALE_BCP47_MAP`, `LOCALE_DESCRIPTORS`, `RTL_LOCALES`, `DefaultLocale`
|
||||
- [x] 3.2 Ensure constants are tree-shakeable (no side effects)
|
||||
|
||||
## 4. Utils
|
||||
|
||||
- [x] 4.1 Implement `src/utils/record.ts` — `flattenRecord(record, separator?)`, `unflattenRecord(flat, separator?)`, `countKeys(record)`, `getNestedValue(record, key, separator?)`, `hasKey(record, key, separator?)`
|
||||
- [x] 4.2 Implement `src/utils/key.ts` — `buildKey(...segments)`, `parseKey(key, separator?)`, `KeyPathResult`
|
||||
- [x] 4.3 Implement `src/utils/locale.ts` — `getTextDirection(locale)`, `isRtl(locale)`, `TextDirection`
|
||||
- [x] 4.4 Implement `src/utils/sanitize.ts` — `sanitize(html, opts?)`, `SanitizeOptions`
|
||||
- [x] 4.5 Unit tests for all util functions: round-trips, edge cases (empty strings, deeply nested, custom separator)
|
||||
- [x] 4.6 Property-based tests: `flattenRecord` → `unflattenRecord` round-trip; `buildKey` / `parseKey` round-trip
|
||||
- [x] 4.7 Co-located `.md` for each util file
|
||||
|
||||
## 5. Interpolation engine
|
||||
|
||||
- [x] 5.1 Implement `src/interpolation/interpolation-engine.ts` — `InterpolationEngine` class with `.render(template, values, opts?)` → `InterpolationResult`; config validation via `InterpolationConfigSchema`
|
||||
- [x] 5.2 Implement `src/interpolation/escape.ts` — `escapeHtmlChars(str)`, `escapeRegex(str)`
|
||||
- [x] 5.3 Types: `InterpolationConfig`, `InterpolationOptions`, `InterpolationResult`, `InterpolationValues`, `ValueFormatter`
|
||||
- [x] 5.4 `escapeHtml` option gates HTML escaping of interpolated values; custom `ValueFormatter` map for per-variable formatting
|
||||
- [x] 5.5 Throws `I18nError` (code `I18N.INTERPOLATION_ERROR`) on unclosed variable delimiter or formatter error
|
||||
- [x] 5.6 Unit tests: basic substitution, multiple variables, missing variable (fallback vs. error), formatter, HTML escaping, custom delimiters
|
||||
- [x] 5.7 Property-based tests: interpolation with `escapeHtml: true` never emits unescaped `<`, `>`, `&`, `"`, `'`
|
||||
- [x] 5.8 Co-located `.md` for each interpolation file
|
||||
|
||||
## 6. Pluralization engine
|
||||
|
||||
- [x] 6.1 Implement `src/pluralization/plural-rules.ts` — `arabicPlural`, `chinesePlural`, `defaultPluralRule`, `englishPlural`, `frenchPlural`, `germanPlural`, `polishPlural`, `russianPlural`, `spanishPlural`, `welshPlural` as `PluralRuleFunction`
|
||||
- [x] 6.2 Implement `src/pluralization/pluralization-engine.ts` — `PluralizationEngine` class with `PluralRuleRegistry`; `.resolve(count, locale, messages, opts?)` → `PluralResolveResult`; auto-falls back to base language rule then `defaultPluralRule`
|
||||
- [x] 6.3 Throws `I18nError` (code `I18N.PLURAL_RULE_NOT_FOUND`) only when strict mode is enabled and no rule exists
|
||||
- [x] 6.4 Types: `PluralCategory`, `PluralMessages`, `PluralResolveOptions`, `PluralResolveResult`, `PluralRuleFunction`, `PluralRuleRegistry`
|
||||
- [x] 6.5 Helpers: `getBaseLocale(locale)`, `normalizeLocale(locale)`
|
||||
- [x] 6.6 Unit tests: all language rules across representative counts; fallback chain; registry registration/overriding
|
||||
- [x] 6.7 Property-based tests: `normalizeLocale` idempotent
|
||||
- [x] 6.8 Co-located `.md` for each pluralization file
|
||||
|
||||
## 7. Message parser
|
||||
|
||||
- [x] 7.1 Implement `src/parser/message-parser.ts` — `MessageParser` class with `.parse(message, opts?)` → `ParsedMessage`; parses text, `{variable}`, `{count, plural, one{…} other{…}}`, `{gender, select, male{…} female{…} other{…}}` forms
|
||||
- [x] 7.2 Implement `src/parser/render.ts` — `renderNodes(nodes, values, opts?)` → `string`
|
||||
- [x] 7.3 Implement `src/parser/extract.ts` — `extractVariables(message)` → `string[]`
|
||||
- [x] 7.4 Node types: `MessageNode`, `ParsedMessage`, `ParserOptions`, `PluralNode`, `SelectNode`, `TextNode`, `VariableNode`
|
||||
- [x] 7.5 Unit tests: plain text, variable nodes, plural nodes (one/other/few/many/zero), select nodes, nested nodes, unknown format falls through as text
|
||||
- [x] 7.6 Co-located `.md` for each parser file
|
||||
|
||||
## 8. Message catalog
|
||||
|
||||
- [x] 8.1 Implement `src/catalog/catalog.ts` — `MessageCatalog` class with `.get(key, locale, opts?)`, `.set(locale, ns, record)`, `.load(locale, ns, loader)`, `.has(key, locale)`, `.keys(locale, ns?)`; lazy loader deduplication; `MissingKeyHandler` hook; namespace-scoped key lookup with dot-path resolution
|
||||
- [x] 8.2 Implement `src/catalog/deep-merge.ts` — `deepMerge(target, source)` for `TranslationRecord`
|
||||
- [x] 8.3 Loader errors surface as `I18nError` (code `I18N.LOADER_FAILED`); missing keys invoke `MissingKeyHandler` or throw `I18nError` (code `I18N.MISSING_KEY`) in strict mode
|
||||
- [x] 8.4 Types: `CatalogOptions`, `ExtendedCatalogOptions`, `LookupResult`, `MessageLoader`, `MissingKeyHandler`, `NamespacedCatalogMap`, `TranslationCatalogMap`, `TranslationRecord`
|
||||
- [x] 8.5 Unit tests: single-locale lookup, multi-namespace, lazy loading (real async), deep merge, missing-key handler, strict-mode throw, concurrent loader deduplication
|
||||
- [x] 8.6 Co-located `.md` for each catalog file
|
||||
|
||||
## 9. Locale resolver
|
||||
|
||||
- [x] 9.1 Implement `src/locale/locale-resolver.ts` — `LocaleResolver` class with `.resolve(request, config)` → `Promise<ResolvedLocale>`; pluggable strategy chain (cookie → query-param → accept-language header → default)
|
||||
- [x] 9.2 Implement `src/locale/normalize.ts` — `normalizeLocaleCode(code)`, `extractLanguage(locale)`, `extractRegion(locale)`; invalid locale throws `I18nError` (code `I18N.INVALID_LOCALE`)
|
||||
- [x] 9.3 Types: `ExtendedResolverConfig`, `ResolutionStrategy`, `ResolvedLocale`, `ResolverConfig`
|
||||
- [x] 9.4 Options validated via `ResolverConfigSchema` at construction
|
||||
- [x] 9.5 Unit tests: each strategy source, fallback chain, BCP-47 normalization edge cases, invalid locale rejection
|
||||
- [x] 9.6 Co-located `.md` for each locale resolver file
|
||||
|
||||
## 10. Locale middleware
|
||||
|
||||
- [x] 10.1 Implement `src/middleware/locale-middleware.ts` — `createLocaleMiddleware(config)` → middleware function that reads locale from request, attaches `ResolvedLocale` to `I18nRequest`, and sets response `Content-Language` header
|
||||
- [x] 10.2 Implement `src/middleware/detect.ts` — `detectLocale(request, config)` → `Promise<ResolvedLocale>`
|
||||
- [x] 10.3 Implement `src/middleware/direction.ts` — `getDirection(locale)` → `TextDirection`
|
||||
- [x] 10.4 Types: `ExtendedMiddlewareConfig`, `I18nRequest`, `LocaleLookupSource`, `MiddlewareConfig`, `MiddlewareResult`, `TextDirection`
|
||||
- [x] 10.5 Unit tests: direction detection per locale, middleware attaches resolved locale, lookup source priority
|
||||
- [x] 10.6 Co-located `.md` for each middleware file
|
||||
|
||||
## 11. Formatting: date
|
||||
|
||||
- [x] 11.1 Implement `src/formatting/date/date-format.ts` — `formatDate(date, locale, opts?)`, `formatDateOnly(date, locale, opts?)`, `formatTime(date, locale, opts?)` backed by `Intl.DateTimeFormat`; preset-based (`DateFormatPreset`) and custom `Intl.DateTimeFormatOptions` support
|
||||
- [x] 11.2 Implement `src/formatting/date/date-range.ts` — `formatDateRange(start, end, locale, opts?)` using `Intl.DateTimeFormat.formatRange` where available, otherwise two-part format
|
||||
- [x] 11.3 Implement `src/formatting/date/names.ts` — `getDayNames(locale, format?)`, `getMonthNames(locale, format?)` via `Intl.DateTimeFormat`
|
||||
- [x] 11.4 Types: `DateFormatOptions`, `DateFormatPreset`
|
||||
- [x] 11.5 Unit tests: all presets, custom options, day/month names for en, ar, zh, fr, de; range formatting
|
||||
- [x] 11.6 Co-located `.md` for each date formatting file
|
||||
|
||||
## 12. Formatting: currency
|
||||
|
||||
- [x] 12.1 Implement `src/formatting/currency/currency-format.ts` — `formatCurrency(amount, currency, locale, opts?)`, `formatCurrencyValue(amount, currency, locale, opts?)` backed by `Intl.NumberFormat`
|
||||
- [x] 12.2 Implement `src/formatting/currency/currency-info.ts` — `getCurrencyInfo(currency)` → `CurrencyInfo`, `getCurrencyName(currency, locale)`, `getCurrencySymbol(currency, locale)` via `Intl.DisplayNames` / `Intl.NumberFormat`
|
||||
- [x] 12.3 Types: `CommonCurrency` (typed union of ISO 4217 codes), `CurrencyFormatOptions`, `CurrencyInfo`
|
||||
- [x] 12.4 Unit tests: USD/EUR/GBP/JPY/CNY formatting per locale, symbol extraction, currency name localization
|
||||
- [x] 12.5 Co-located `.md` for each currency formatting file
|
||||
|
||||
## 13. OTel instrumentation
|
||||
|
||||
- [x] 13.1 Implement `src/observability/metrics.ts` — create meter via `@opentelemetry/api`; export `i18nMetrics` singleton with counter `i18n.translate.total` (labels: `outcome`, `locale`), counter `i18n.locale.resolve.total` (labels: `strategy`, `locale`), histogram `i18n.translate.duration_ms` (label: `locale`)
|
||||
- [x] 13.2 Wire `i18nMetrics.translateTotal` into `MessageCatalog.get`; wire `i18nMetrics.localeResolveTotal` into `LocaleResolver.resolve`; wire `i18nMetrics.translateDuration` histogram into `MessageCatalog.get`
|
||||
- [x] 13.3 Unit tests: no-op without exporter; counter increments with correct labels on hit/miss; histogram records non-negative duration
|
||||
|
||||
## 14. Property tests + benchmarks
|
||||
|
||||
- [x] 14.1 Property-based tests in `tests/properties/`:
|
||||
- `flattenRecord` → `unflattenRecord` round-trip (fast-check arbitrary nested string record)
|
||||
- `normalizeLocale` idempotent (fast-check arbitrary string)
|
||||
- Interpolation escaping never leaks raw HTML (fast-check arbitrary string values with HTML chars)
|
||||
- `buildKey` / `parseKey` round-trip (fast-check non-empty dot-segment string arrays)
|
||||
- [x] 14.2 Benchmarks in `src/__benchmarks__/`:
|
||||
- `catalog.bench.ts` — `MessageCatalog.get` 100 000 iterations on 1 000-key catalog across 3 namespaces
|
||||
- `interpolation.bench.ts` — `InterpolationEngine.render` 10 000 iterations with 5-variable template
|
||||
- `pluralization.bench.ts` — `PluralizationEngine.resolve` 10 000 iterations cycling through English, Polish, Arabic counts
|
||||
|
||||
## 15. Docs
|
||||
|
||||
- [x] 15.1 Co-located `.md` for every implementation file (≥200 words, basic + advanced examples, API reference, implementation notes)
|
||||
- [x] 15.2 Rewrite `README.md` with public API index, architecture diagram, consumer guide, and benchmark numbers
|
||||
- [x] 15.3 `bun run docs:check --filter=@suaveplan/i18n` ≥ 90%
|
||||
|
||||
## 16. Verify and publish
|
||||
|
||||
- [x] 16.1 `bunx turbo lint typecheck test --filter=@suaveplan/i18n` green
|
||||
- [x] 16.2 `bunx turbo test:coverage --filter=@suaveplan/i18n` at 100/100/100/100
|
||||
- [x] 16.3 `bunx turbo build --filter=@suaveplan/i18n` green
|
||||
- [x] 16.4 `bun scripts/publish.ts --package @suaveplan/i18n` to Verdaccio at 0.0.0
|
||||
- [x] 16.5 Archive OpenSpec change to `openspec/archive/2026-04-19-enterprise-i18n/`
|
||||
Reference in New Issue
Block a user