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

9.2 KiB

Capability Spec: i18n-shared (i18n-shared)

Package: @suaveplan/i18n-shared Status: PROPOSED — first delta; becomes canonical spec on archive.

Every requirement below carries a **Verified by:** line at authoring time. (See openspec/AGENTS.md Section 13.)

ADDED Requirements

Requirement: BCP-47 locale tag map

The package SHALL export LOCALE_BCP47_MAP: Record<string, string> mapping short ISO 639-1 codes (and common mixed-case forms) to canonical BCP-47 tags. Lookups for unknown codes SHALL pass through unchanged at the consumer level (the constant is a plain Record; consumers handle the miss).

Scenario: short code maps to canonical BCP-47

  • WHEN LOCALE_BCP47_MAP["en"] is looked up
  • THEN the value equals "en-GB"

Scenario: mixed-case code maps to canonical form

  • WHEN LOCALE_BCP47_MAP["zh-cn"] is looked up
  • THEN the value equals "zh-CN"

Verified by: packages/i18n/i18n-shared/src/constants/locale-bcp47-map.test.ts::"short code en maps to en-GB" AND packages/i18n/i18n-shared/src/constants/locale-bcp47-map.test.ts::"mixed-case zh-cn maps to zh-CN"


Requirement: RTL locale set and tuple

The package SHALL export RTL_LOCALES: ReadonlySet<string> containing the seven ISO 639-1 codes for right-to-left scripts: ar, he, fa, ur, ps, sd, yi. The package SHALL also export RTL_LOCALE_LIST as a tree-shake-friendly readonly tuple of the same codes.

Scenario: ar is RTL

  • WHEN RTL_LOCALES.has("ar") is evaluated
  • THEN the result is true

Scenario: en is not RTL

  • WHEN RTL_LOCALES.has("en") is evaluated
  • THEN the result is false

Scenario: list and set agree on size

  • WHEN RTL_LOCALE_LIST.length is compared to RTL_LOCALES.size
  • THEN the values are equal

Verified by: packages/i18n/i18n-shared/src/constants/rtl-locales.test.ts::"ar is RTL" AND packages/i18n/i18n-shared/src/constants/rtl-locales.test.ts::"en is not RTL" AND packages/i18n/i18n-shared/src/constants/rtl-locales.test.ts::"RTL_LOCALE_LIST length matches RTL_LOCALES size"


Requirement: Locale descriptors with name, native name, and direction

The package SHALL export LOCALE_DESCRIPTORS as a Record<string, { name: string; nativeName: string; direction: "ltr" | "rtl" }> covering every entry in DEFAULT_LOCALES. Each descriptor's direction SHALL match the locale's membership in RTL_LOCALES.

Scenario: every default locale has a descriptor

  • WHEN every code in DEFAULT_LOCALES is looked up in LOCALE_DESCRIPTORS
  • THEN every lookup returns a defined descriptor

Scenario: Arabic descriptor is RTL

  • WHEN LOCALE_DESCRIPTORS.ar.direction is read
  • THEN the value equals "rtl"

Scenario: native names are non-empty

  • WHEN every descriptor's nativeName is checked
  • THEN every value is a non-empty string

Verified by: packages/i18n/i18n-shared/src/constants/locale-descriptors.test.ts::"every DEFAULT_LOCALES code has a descriptor" AND packages/i18n/i18n-shared/src/constants/locale-descriptors.test.ts::"ar descriptor direction is rtl" AND packages/i18n/i18n-shared/src/constants/locale-descriptors.test.ts::"every descriptor nativeName is non-empty"


Requirement: Default interpolation defaults

The package SHALL export DEFAULT_INTERPOLATION as a frozen object with prefix: "{{", suffix: "}}", and maxDepth: 10. The shape SHALL be as const so consumers can narrow at the type level.

Scenario: default prefix and suffix

  • WHEN DEFAULT_INTERPOLATION.prefix and DEFAULT_INTERPOLATION.suffix are read
  • THEN the values equal "{{" and "}}" respectively

Scenario: default max depth

  • WHEN DEFAULT_INTERPOLATION.maxDepth is read
  • THEN the value equals 10

Scenario: type is narrowed via as const

  • WHEN expectTypeOf<typeof DEFAULT_INTERPOLATION.prefix>().toEqualTypeOf<"{{">() runs
  • THEN the type-level check passes

Verified by: packages/i18n/i18n-shared/src/constants/interpolation-defaults.test.ts::"default prefix is {{" AND packages/i18n/i18n-shared/src/constants/interpolation-defaults.test.ts::"default suffix is }}" AND packages/i18n/i18n-shared/src/constants/interpolation-defaults.test.ts::"default maxDepth is 10" AND packages/i18n/i18n-shared/src/constants/interpolation-defaults.test.ts::"DEFAULT_INTERPOLATION is narrowed via as const"


Requirement: I18N error code registry

The package SHALL export I18N_CODES constructed via defineCodes("I18N", [...] as const) from @suaveplan/error, containing the following 18 codes:

  • Parser / interpolation / pluralization: MISSING_KEY, INVALID_LOCALE, INVALID_OPTIONS, LOADER_FAILED, INTERPOLATION_ERROR, PLURAL_RULE_NOT_FOUND, NESTING_DEPTH_EXCEEDED
  • Backends: BACKEND_READ_FAILED, BACKEND_WRITE_FAILED, BACKEND_NOT_REGISTERED, BACKEND_LOCALE_NOT_AVAILABLE, BACKEND_TIMEOUT, CHAINED_BACKEND_ALL_FAILED
  • Formatters: INVALID_FORMATTER_OPTIONS, UNSUPPORTED_LOCALE, FORMATTER_CACHE_OVERFLOW
  • Detector: DETECTOR_CHAIN_EXHAUSTED
  • Plugin / core: PLUGIN_ALREADY_INSTALLED

Every code SHALL be addressable as I18N_CODES.<NAME> and SHALL produce a string literal of the form "I18N.<NAME>". The package SHALL also export I18nErrorCode as the discriminant union of all 18 string literals.

Scenario: every documented code is in the registry

  • WHEN every name listed in this requirement is read from I18N_CODES
  • THEN every read returns a defined value

Scenario: code values follow the I18N. prefix

  • WHEN I18N_CODES.MISSING_KEY is read
  • THEN the value equals "I18N.MISSING_KEY"

Scenario: I18nErrorCode union has cardinality 18

  • WHEN the type-level cardinality of I18nErrorCode is asserted
  • THEN the union covers exactly 18 string literals

Verified by: packages/i18n/i18n-shared/src/errors/error-codes.test.ts::"every documented code is in I18N_CODES" AND packages/i18n/i18n-shared/src/errors/error-codes.test.ts::"code values are namespaced under I18N." AND packages/i18n/i18n-shared/src/errors/error-codes.test.ts::"I18nErrorCode union has cardinality 18"


Requirement: I18nError class

The package SHALL export I18nError extends SuaveplanError<I18nErrorCode> from @suaveplan/error. The class SHALL be constructible from any code in I18N_CODES and SHALL satisfy instanceof I18nError and instanceof SuaveplanError. The class SHALL preserve the cause chain when constructed with { cause }. The class SHALL NOT carry any biome / linter suppression comments per CLAUDE.md §9.

Scenario: construction with each code

  • WHEN new I18nError(code, "test") is called for every code in I18N_CODES
  • THEN every construction succeeds

Scenario: instanceof discrimination

  • WHEN an I18nError is caught
  • THEN instanceof I18nError returns true AND instanceof SuaveplanError returns true

Scenario: cause preservation

  • WHEN new I18nError(I18N_CODES.LOADER_FAILED, "k", { cause: new Error("network") }) is constructed
  • THEN the resulting error's .cause.message equals "network"

Scenario: no biome suppressions in source

  • WHEN the file src/errors/i18n-error.ts is grepped for biome-ignore
  • THEN zero matches are found

Verified by: packages/i18n/i18n-shared/src/errors/i18n-error.test.ts::"constructs with every code in I18N_CODES" AND packages/i18n/i18n-shared/src/errors/i18n-error.test.ts::"instanceof I18nError and SuaveplanError" AND packages/i18n/i18n-shared/src/errors/i18n-error.test.ts::"preserves cause chain" AND packages/i18n/i18n-shared/src/errors/i18n-error.test.ts::"source contains no biome-ignore suppressions"


Requirement: Subpath exports for tree-shaking

The package SHALL declare subpath exports in package.json so consumers can import individual constants without pulling the entire bundle. The following subpaths SHALL resolve:

  • @suaveplan/i18n-shared — root barrel
  • @suaveplan/i18n-shared/constants — all constants
  • @suaveplan/i18n-shared/constants/locale-bcp47-map — LOCALE_BCP47_MAP only
  • @suaveplan/i18n-shared/constants/rtl-locales — RTL_LOCALES and RTL_LOCALE_LIST only
  • @suaveplan/i18n-shared/constants/locale-descriptors — LOCALE_DESCRIPTORS, DEFAULT_LOCALES, DefaultLocale only
  • @suaveplan/i18n-shared/constants/interpolation-defaults — DEFAULT_INTERPOLATION only
  • @suaveplan/i18n-shared/errors — I18N_CODES, I18nError, I18nErrorCode

Scenario: every subpath resolves at type-check time

  • WHEN every documented subpath is imported in a consumer test fixture
  • THEN every import resolves with no Module not found errors

Verified by: packages/i18n/i18n-shared/src/index.test.ts::"every documented subpath export resolves"


Requirement: Side-effect-free package

The package SHALL declare "sideEffects": false in package.json so bundlers can tree-shake unused exports.

Scenario: package.json declares sideEffects false

  • WHEN package.json is parsed
  • THEN the sideEffects field equals false

Verified by: packages/i18n/i18n-shared/src/byte-size.test.ts::"package.json declares sideEffects false"