Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View 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

View 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 `&` → `&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

View 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/`