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

8.7 KiB

Capability Spec: i18n-formatter-collator (i18n-formatter-collator)

Package: @suaveplan/i18n-formatter-collator Status: ACTIVE

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

NEW formatter — no source in current monolith. Closes the gap from the original i18n-formatters change scope.

Requirements

Requirement: CollatorFormatter class wrapping Intl.Collator

The package SHALL export a CollatorFormatter class that wraps Intl.Collator with construction-time Zod validation of options and compare(a: string, b: string, locale: string): -1 | 0 | 1 and sort(values: readonly string[], locale: string): string[] methods. Construction with malformed options SHALL throw I18nError(I18N.INVALID_FORMATTER_OPTIONS).

Scenario: compare with default options

  • GIVEN const f = new CollatorFormatter()
  • WHEN f.compare("a", "b", "en-GB") is called
  • THEN the result equals -1

Scenario: invalid options throw INVALID_FORMATTER_OPTIONS

  • WHEN new CollatorFormatter({ usage: "invalid" as never }) is called
  • THEN the call throws I18nError with code "I18N.INVALID_FORMATTER_OPTIONS"

Verified by: packages/i18n/i18n-formatter-collator/src/formatter.test.ts::"CollatorFormatter compares with default options" AND packages/i18n/i18n-formatter-collator/src/formatter.test.ts::"CollatorFormatter rejects invalid options with INVALID_FORMATTER_OPTIONS"

Requirement: compareStrings standalone helper

The package SHALL export a compareStrings(a, b, locale, options?) standalone helper that returns -1, 0, or 1 per Intl.Collator.compare semantics.

Scenario: standalone compares with default options

  • WHEN compareStrings("apple", "banana", "en-GB") is called
  • THEN the result equals -1

Scenario: standalone twins class output

  • WHEN both compareStrings(a, b, l, opts) and new CollatorFormatter(opts).compare(a, b, l) are called for the same (a, b, l, opts)
  • THEN the results are identical

Verified by: packages/i18n/i18n-formatter-collator/src/formatter.test.ts::"compareStrings compares apple and banana" AND packages/i18n/i18n-formatter-collator/src/formatter.test.ts::"compareStrings output twins CollatorFormatter.compare output"

Requirement: formatCollation standalone helper returns sorted array

The package SHALL export a formatCollation(values, locale, options?) standalone helper that returns a sorted (immutable copy) array per the configured collator. The input array SHALL NOT be mutated.

Scenario: formatCollation sorts ascending

  • WHEN formatCollation(["c", "a", "b"], "en-GB") is called
  • THEN the result equals ["a", "b", "c"]

Scenario: formatCollation does not mutate the input

  • GIVEN const input = ["c", "a", "b"]
  • WHEN formatCollation(input, "en-GB") is called
  • THEN input still equals ["c", "a", "b"]

Verified by: packages/i18n/i18n-formatter-collator/src/formatter.test.ts::"formatCollation sorts ascending" AND packages/i18n/i18n-formatter-collator/src/formatter.test.ts::"formatCollation does not mutate input array"

Requirement: Intl.Collator RangeError maps to UNSUPPORTED_LOCALE

The package SHALL catch every RangeError thrown by the Intl.Collator constructor and re-throw it as I18nError(I18N.UNSUPPORTED_LOCALE) with the original error attached as cause and the locale string in context.

Scenario: bogus locale throws UNSUPPORTED_LOCALE

  • WHEN compareStrings("a", "b", "xx-bogus-tag-!!!") is called
  • THEN the call throws I18nError with code "I18N.UNSUPPORTED_LOCALE" AND the error's cause is the original RangeError

Verified by: packages/i18n/i18n-formatter-collator/src/formatter.test.ts::"unsupported locale throws UNSUPPORTED_LOCALE with cause"

Requirement: Per-(locale, options-hash) cache

The package SHALL maintain an internal Map<string, Intl.Collator> keyed by ${locale}|${JSON.stringify(options)}. Repeated calls with identical (locale, options) SHALL reuse the same Intl.Collator instance. The package SHALL export a clearCollatorCache() test-only helper.

Scenario: identical options reuse the same Intl.Collator

  • GIVEN the cache is empty
  • WHEN compareStrings("a", "b", "en-GB") is called twice in sequence
  • THEN only one Intl.Collator constructor invocation occurs

Scenario: distinct options create distinct cache entries

  • WHEN compareStrings("a", "b", "en-GB", { sensitivity: "base" }) and compareStrings("a", "b", "en-GB", { sensitivity: "accent" }) are called
  • THEN two distinct Intl.Collator instances are created and cached

Verified by: packages/i18n/i18n-formatter-collator/src/formatter.test.ts::"identical options reuse cached Intl.Collator" AND packages/i18n/i18n-formatter-collator/src/formatter.test.ts::"distinct options create distinct cache entries"

Requirement: OTel histogram i18n.format.collator.duration_ms

The package SHALL emit an OTel histogram named exactly i18n.format.collator.duration_ms (unit ms) on every successful compareStrings or formatCollation call, tagged with locale, usage, and sensitivity.

Scenario: histogram emitted on every call

  • GIVEN an OTel test exporter capturing histogram observations
  • WHEN compareStrings("a", "b", "en-GB", { sensitivity: "base", usage: "sort" }) is called
  • THEN exactly one observation appears under i18n.format.collator.duration_ms with locale, usage, and sensitivity attributes

Verified by: packages/i18n/i18n-formatter-collator/src/observability.test.ts::"emits i18n.format.collator.duration_ms histogram on every call"

Requirement: OTel cache-hit counter i18n.format.cache_hit

The package SHALL emit an OTel counter i18n.format.cache_hit tagged with formatter: "collator" on every cache hit. The counter SHALL NOT increment on the first (cache-miss) call.

Scenario: cache-hit counter increments only on second call

  • GIVEN an OTel test exporter capturing counter observations and an empty cache
  • WHEN compareStrings("a", "b", "en-GB") is called twice in sequence
  • THEN the counter i18n.format.cache_hit{formatter="collator"} shows exactly one increment

Verified by: packages/i18n/i18n-formatter-collator/src/observability.test.ts::"cache-hit counter increments only on second call with same options"

Requirement: Ergonomic helpers — case-insensitive, numeric-aware, sort

The package SHALL export three ergonomic helpers:

  • compareCaseInsensitive(a, b, locale) — pre-binds sensitivity: "accent"
  • compareNumericAware(a, b, locale) — pre-binds numeric: true for natural sort ("file2" < "file10")
  • sortStrings(values, locale, options?) — returns sorted immutable copy

Scenario: compareCaseInsensitive treats a and A as equal

  • WHEN compareCaseInsensitive("a", "A", "en-GB") is called
  • THEN the result equals 0

Scenario: compareNumericAware orders file2 before file10

  • WHEN compareNumericAware("file2", "file10", "en-GB") is called
  • THEN the result equals -1

Scenario: sortStrings sorts and returns immutable copy

  • GIVEN const input = ["c", "a", "b"]
  • WHEN const out = sortStrings(input, "en-GB") is called
  • THEN out equals ["a", "b", "c"] AND input still equals ["c", "a", "b"]

Verified by: packages/i18n/i18n-formatter-collator/src/helpers.test.ts::"compareCaseInsensitive treats a and A as equal" AND packages/i18n/i18n-formatter-collator/src/helpers.test.ts::"compareNumericAware orders file2 before file10" AND packages/i18n/i18n-formatter-collator/src/helpers.test.ts::"sortStrings sorts and returns immutable copy"

Requirement: Subpath exports

The package SHALL declare subpath exports in package.json:

  • @suaveplan/i18n-formatter-collator — root barrel
  • @suaveplan/i18n-formatter-collator/formatter — CollatorFormatter class + compareStrings + formatCollation + types + schema only
  • @suaveplan/i18n-formatter-collator/helpers — three ergonomic helpers only

Scenario: every documented subpath resolves

  • 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-formatter-collator/src/index.test.ts::"every documented subpath export resolves"

Requirement: Side-effect-free package

The package SHALL declare "sideEffects": false in package.json.

Scenario: package.json declares sideEffects false

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

Verified by: packages/i18n/i18n-formatter-collator/src/index.test.ts::"package.json declares sideEffects false"