167 lines
8.7 KiB
Markdown
167 lines
8.7 KiB
Markdown
<!-- markdownlint-disable MD024 -->
|
|
|
|
# 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"`
|