7.9 KiB
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:
InterpolationConfigSchemavalidates{ open: string (≥1, ≠close), close: string (≥1), escape: string (length 0 or 1), maxIterations: int 1..100 }.ResolverConfigSchemavalidates{ strategies: ResolutionStrategy[] (non-empty), fallbackLocale: string (≥1), supportedLocales?: string[] }.CatalogOptionsSchemaaddsstrict?: boolean(defaultfalse).
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 legacyload(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 → sameI18N.LOADER_FAILED. - Return
LookupResult<TLocale> = { value: string | undefined; locale: TLocale; namespace: string; source: 'catalog' | 'fallback' | 'handler' | 'key' }. - In strict mode (default
false), throwI18nError(I18N.MISSING_KEY)when no value found after handler fallback; otherwise return{ value: undefined, ..., source: 'key' }(andt()returns the key string). - Provide typed key inference:
t<K extends DotPaths<TMessages> | (string & {})>(key: K)andget<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
ValueFormatterasI18nError(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 insideMessageCatalog.getwith attributes{ outcome: 'hit' | 'miss', locale }.localeResolveTotal: Counter— incremented insidedetectLocalewith attributes{ strategy, locale }.translateDuration: Histogram— records elapsed milliseconds for eachMessageCatalog.getcall 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"