Initial Commit
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
# Capability Spec Delta: i18n (spec-conformance)
|
||||
|
||||
**Package**: `@suaveplan/i18n`
|
||||
**Status**: PROPOSED
|
||||
|
||||
---
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### I18N-02 — Zod schemas at the public boundary
|
||||
|
||||
`InterpolationConfigSchema`, `ResolverConfigSchema`, and `CatalogOptionsSchema` MUST match the spec field names and MUST be invoked at construction time:
|
||||
|
||||
- `InterpolationConfigSchema` validates `{ open: string (≥1, ≠close), close: string (≥1), escape: string (length 0 or 1), maxIterations: int 1..100 }`.
|
||||
- `ResolverConfigSchema` validates `{ strategies: ResolutionStrategy[] (non-empty), fallbackLocale: string (≥1), supportedLocales?: string[] }`.
|
||||
- `CatalogOptionsSchema` adds `strict?: boolean` (default `false`).
|
||||
|
||||
`MessageCatalog`, `InterpolationEngine`, and `LocaleResolver` constructors MUST call `.parse(options)` and re-throw failures as `I18nError` with the relevant code (`I18N.INVALID_LOCALE` for resolver, `I18N.INTERPOLATION_ERROR` for interpolation, `I18N.MISSING_KEY` is reserved; for catalog use a generic `I18N.INVALID_OPTIONS` — see ADDED below) and `context.zodIssues` populated with the issue array.
|
||||
|
||||
**Verified by:** `src/schemas/i18n.schemas.test.ts::"applies defaults on empty object"` AND `src/schemas/i18n.schemas.test.ts::"accepts custom values"` AND `src/conformance.test.ts::"constructor throws INVALID_OPTIONS on bad input"`
|
||||
|
||||
### I18N-03 — Message catalog contract
|
||||
|
||||
`MessageCatalog<TLocale, TMessages>` MUST:
|
||||
|
||||
- Expose `set(locale, namespace, record)`, `load(locale, namespaceOrLoader, loader?)` (overloaded so legacy `load(locale, loader)` still compiles), `keys(locale, namespace?)`, `has(key, locale?)`, `get(key, locale?)`, `t(key, locale?)`.
|
||||
- Deduplicate concurrent `load(locale, namespace, loader)` calls keyed by `${locale}::${namespace ?? ''}` — only one in-flight Promise per pair; subsequent callers await the same Promise.
|
||||
- Wrap loader rejection as `I18nError(I18N.LOADER_FAILED, 'i18n.loader.failed', { cause, context: { locale, namespace } })`.
|
||||
- Validate loader return value against `TranslationRecordSchema`; failure → same `I18N.LOADER_FAILED`.
|
||||
- Return `LookupResult<TLocale> = { value: string | undefined; locale: TLocale; namespace: string; source: 'catalog' | 'fallback' | 'handler' | 'key' }`.
|
||||
- In strict mode (default `false`), throw `I18nError(I18N.MISSING_KEY)` when no value found after handler fallback; otherwise return `{ value: undefined, ..., source: 'key' }` (and `t()` returns the key string).
|
||||
- Provide typed key inference: `t<K extends DotPaths<TMessages> | (string & {})>(key: K)` and `get<K extends DotPaths<TMessages> | (string & {})>(key: K, locale?)`.
|
||||
|
||||
**Verified by:** `src/conformance.test.ts::"strict mode throws I18N.MISSING_KEY on miss"` AND `src/conformance.test.ts::"load() wraps loader rejection as I18N.LOADER_FAILED"` AND `src/core/catalog/catalog.test.ts::"calls missingKeyHandler when key not found"` AND `src/core/catalog/catalog.test.ts::"falls back to default locale"`
|
||||
|
||||
### I18N-04 — Interpolation engine contract
|
||||
|
||||
`InterpolationEngine` MUST:
|
||||
|
||||
- Throw `I18nError(I18N.INTERPOLATION_ERROR)` when an opening delimiter is found without a matching closing delimiter.
|
||||
- Wrap any thrown value from a `ValueFormatter` as `I18nError(I18N.INTERPOLATION_ERROR, 'i18n.interpolation.formatter_failed', { cause, context: { variable } })`.
|
||||
- Accept canonical option keys `{ open, close, escape, maxIterations }`. Transitional aliases `{ prefix, suffix, maxDepth }` MUST be accepted and mapped to the canonical keys; mixing both throws.
|
||||
|
||||
**Verified by:** `src/conformance.test.ts::"constructor throws INVALID_OPTIONS on bad input"` AND `src/conformance.test.ts::"constructor rejects identical open and close"` AND `src/core/interpolation/interpolation.test.ts::"format() renders in one step"`
|
||||
|
||||
### I18N-05 — Pluralization engine contract
|
||||
|
||||
Rule lookup chain MUST be: registered locale rule → registered base-language rule (`getBaseLocale`) → `Intl.PluralRules(locale).select(n)` mapped to `PluralCategory` → `defaultPluralRule`. In strict mode, if `Intl.PluralRules` itself throws (invalid locale), `PluralizationEngine` MUST throw `I18nError(I18N.PLURAL_RULE_NOT_FOUND)`.
|
||||
|
||||
**Verified by:** `src/core/pluralization/pluralization.test.ts::"falls through to base-language registered rule"` AND `src/core/pluralization/pluralization.test.ts::"customExtractor unsupported locale falls through"` AND `src/core/pluralization/pluralization.test.ts::"accepts options-shape constructor argument"`
|
||||
|
||||
### I18N-07 — Locale resolver contract
|
||||
|
||||
`normalizeLocaleCode(code)` MUST validate input against `/^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})*$/` and throw `I18nError(I18N.INVALID_LOCALE)` for non-matching strings (including empty).
|
||||
|
||||
`LocaleResolver` constructor MUST validate options via `ResolverConfigSchema` (where `strategies` is the new pluggable-chain field; the existing exact/alias/language/region matcher remains unchanged but now lives behind a `strategies: ['exact', 'alias', 'language', 'region', 'default']` default).
|
||||
|
||||
**Verified by:** `src/conformance.test.ts::"normalizeLocaleCode throws I18N.INVALID_LOCALE on garbage"` AND `src/locale/resolver/resolver.test.ts::"exact match"` AND `src/locale/resolver/resolver.test.ts::"falls back to default when no match"`
|
||||
|
||||
### I18N-08 — Locale middleware
|
||||
|
||||
The middleware result MUST attach `direction: TextDirection` (already implemented) AND, when an optional `setHeader(name, value)` callback is supplied in `ExtendedMiddlewareConfig`, MUST invoke `setHeader('Content-Language', resolved.locale)` after the lookup completes.
|
||||
|
||||
**Verified by:** `src/middleware/server/server.test.ts::"calls setHeader('Content-Language', locale) when configured"` AND `src/middleware/server/server.test.ts::"detects from cookie"` AND `src/middleware/server/server.test.ts::"detects from query param"`
|
||||
|
||||
### I18N-12 — OTel instrumentation
|
||||
|
||||
`src/observability/metrics.ts` MUST export an `i18nMetrics` singleton built from `getMeter('suaveplan.i18n')`:
|
||||
|
||||
- `translateTotal: Counter` — incremented inside `MessageCatalog.get` with attributes `{ outcome: 'hit' | 'miss', locale }`.
|
||||
- `localeResolveTotal: Counter` — incremented inside `detectLocale` with attributes `{ strategy, locale }`.
|
||||
- `translateDuration: Histogram` — records elapsed milliseconds for each `MessageCatalog.get` call with attribute `{ locale }`.
|
||||
|
||||
All instruments MUST be no-ops when no exporter is registered (default behaviour of `@opentelemetry/api`).
|
||||
|
||||
**Verified by:** `src/conformance.test.ts` (OTel instrument smoke tests) AND `tests/no-disallowed.test.ts::"found source files to check"`
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### I18N-16 — `INVALID_OPTIONS` error code
|
||||
|
||||
A new code `I18N.INVALID_OPTIONS` MUST be registered alongside the existing five and used by all engines when their constructor option schema fails.
|
||||
|
||||
**Verified by:** `src/errors/i18n-error.test.ts::"accepts all five error codes"` AND `src/conformance.test.ts::"constructor throws INVALID_OPTIONS on bad input"` AND `src/conformance.test.ts::"constructor throws INVALID_OPTIONS on missing fallback"`
|
||||
|
||||
### I18N-17 — Typed key inference
|
||||
|
||||
`@suaveplan/i18n` MUST export `DotPaths<T>` (template-literal recursion over a `TranslationRecord`-like tree) and `MessageCatalog<TLocale, TMessages>` MUST narrow `t`/`get` to `K extends DotPaths<TMessages> | (string & {})`. The `(string & {})` branch is the explicit escape hatch that preserves runtime flexibility while still letting TypeScript autocomplete known keys.
|
||||
|
||||
**Verified by:** `src/conformance.test.ts` (DotPaths typed-inference smoke test)
|
||||
|
||||
### I18N-18 — `Intl.PluralRules` fallback
|
||||
|
||||
`PluralizationEngine` MUST consult `Intl.PluralRules(locale).select(n)` after the registered-rule chain misses but before falling back to `defaultPluralRule`. This ensures CLDR-tracked languages added in future ICU updates work without registering a hand-rolled rule.
|
||||
|
||||
**Verified by:** `src/core/pluralization/pluralization.test.ts::"falls through to base-language registered rule"` AND `src/core/pluralization/pluralization.test.ts::"customExtractor unsupported locale falls through"`
|
||||
Reference in New Issue
Block a user