Files
2026-05-27 23:29:53 +01:00

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:

  • 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"