Initial Commit
This commit is contained in:
152
openspec/specs/i18n-formatter-display-name/spec.md
Normal file
152
openspec/specs/i18n-formatter-display-name/spec.md
Normal file
@@ -0,0 +1,152 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# Capability Spec: i18n-formatter-display-name (i18n-formatter-display-name)
|
||||
|
||||
**Package**: `@suaveplan/i18n-formatter-display-name`
|
||||
**Status**: ACTIVE
|
||||
|
||||
> Every requirement below carries a `**Verified by:**` line at authoring time. (See `openspec/AGENTS.md` Section 13.)
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: DisplayNameFormatter class wrapping `Intl.DisplayNames`
|
||||
|
||||
The package SHALL export a `DisplayNameFormatter` class that wraps `Intl.DisplayNames` with construction-time Zod validation of options and a `format(code: string, locale: string): string` method. Construction with malformed options SHALL throw `I18nError(I18N.INVALID_FORMATTER_OPTIONS)`. The `type` option is required.
|
||||
|
||||
#### Scenario: format with type: language
|
||||
|
||||
- **GIVEN** `const f = new DisplayNameFormatter({ type: "language" })`
|
||||
- **WHEN** `f.format("fr", "en-GB")` is called
|
||||
- **THEN** the result equals `"French"`
|
||||
|
||||
#### Scenario: missing type throws INVALID_FORMATTER_OPTIONS
|
||||
|
||||
- **WHEN** `new DisplayNameFormatter({} as never)` is called
|
||||
- **THEN** the call throws `I18nError` with code `"I18N.INVALID_FORMATTER_OPTIONS"`
|
||||
|
||||
**Verified by:** `packages/i18n/i18n-formatter-display-name/src/formatter.test.ts::"DisplayNameFormatter formats language"` AND `packages/i18n/i18n-formatter-display-name/src/formatter.test.ts::"DisplayNameFormatter rejects missing type with INVALID_FORMATTER_OPTIONS"`
|
||||
|
||||
### Requirement: `formatDisplayName` standalone helper
|
||||
|
||||
The package SHALL export a `formatDisplayName(code, locale, options)` standalone helper that delivers the same behaviour as `DisplayNameFormatter.format` without requiring class construction. Defaults: `style: "long"`, `fallback: "code"`.
|
||||
|
||||
#### Scenario: standalone helper formats region
|
||||
|
||||
- **WHEN** `formatDisplayName("GB", "en-GB", { type: "region" })` is called
|
||||
- **THEN** the result equals `"United Kingdom"`
|
||||
|
||||
#### Scenario: standalone helper twins class output
|
||||
|
||||
- **WHEN** both `formatDisplayName(code, l, opts)` and `new DisplayNameFormatter(opts).format(code, l)` are called for the same `(code, l, opts)`
|
||||
- **THEN** the results are identical
|
||||
|
||||
**Verified by:** `packages/i18n/i18n-formatter-display-name/src/formatter.test.ts::"formatDisplayName formats region"` AND `packages/i18n/i18n-formatter-display-name/src/formatter.test.ts::"formatDisplayName output twins DisplayNameFormatter.format output"`
|
||||
|
||||
### Requirement: `Intl.DisplayNames` `RangeError` maps to `UNSUPPORTED_LOCALE`
|
||||
|
||||
The package SHALL catch every `RangeError` thrown by the `Intl.DisplayNames` 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** `formatDisplayName("fr", "xx-bogus-tag-!!!", { type: "language" })` 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-display-name/src/formatter.test.ts::"unsupported locale throws UNSUPPORTED_LOCALE with cause"`
|
||||
|
||||
### Requirement: Per-(locale, type, style, fallback) cache
|
||||
|
||||
The package SHALL maintain an internal `Map<string, Intl.DisplayNames>` keyed by `${locale}|${type}|${style}|${fallback}`. Repeated calls with identical key SHALL reuse the same `Intl.DisplayNames` instance. The package SHALL export a `clearDisplayNameCache()` test-only helper.
|
||||
|
||||
#### Scenario: identical key reuses the same Intl.DisplayNames
|
||||
|
||||
- **GIVEN** the cache is empty
|
||||
- **WHEN** `formatDisplayName("fr", "en-GB", { type: "language" })` is called twice in sequence
|
||||
- **THEN** only one `Intl.DisplayNames` constructor invocation occurs
|
||||
|
||||
#### Scenario: distinct type creates distinct cache entries
|
||||
|
||||
- **WHEN** `formatDisplayName("fr", "en-GB", { type: "language" })` and `formatDisplayName("fr", "en-GB", { type: "region" })` are called
|
||||
- **THEN** two distinct `Intl.DisplayNames` instances are created and cached
|
||||
|
||||
**Verified by:** `packages/i18n/i18n-formatter-display-name/src/formatter.test.ts::"identical key reuses cached Intl.DisplayNames"` AND `packages/i18n/i18n-formatter-display-name/src/formatter.test.ts::"distinct type creates distinct cache entries"`
|
||||
|
||||
### Requirement: OTel histogram `i18n.format.display_name.duration_ms`
|
||||
|
||||
The package SHALL emit an OTel histogram named exactly `i18n.format.display_name.duration_ms` (unit `ms`) on every successful `formatDisplayName` call, tagged with `locale`, `type`, and `style`.
|
||||
|
||||
#### Scenario: histogram emitted on every call
|
||||
|
||||
- **GIVEN** an OTel test exporter capturing histogram observations
|
||||
- **WHEN** `formatDisplayName("fr", "en-GB", { type: "language", style: "long" })` is called
|
||||
- **THEN** exactly one observation appears under `i18n.format.display_name.duration_ms` with `locale`, `type`, and `style` attributes
|
||||
|
||||
**Verified by:** `packages/i18n/i18n-formatter-display-name/src/observability.test.ts::"emits i18n.format.display_name.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: "display_name"` 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** `formatDisplayName("fr", "en-GB", { type: "language" })` is called twice in sequence
|
||||
- **THEN** the counter `i18n.format.cache_hit{formatter="display_name"}` shows exactly one increment
|
||||
|
||||
**Verified by:** `packages/i18n/i18n-formatter-display-name/src/observability.test.ts::"cache-hit counter increments only on second call with same options"`
|
||||
|
||||
### Requirement: Ergonomic helpers — language, region, currency, script, calendar
|
||||
|
||||
The package SHALL export `formatLanguageName`, `formatRegionName`, `formatCurrencyName`, `formatScriptName`, and `formatCalendarName` ergonomic shortcuts pre-binding `formatDisplayName` with the corresponding `type`.
|
||||
|
||||
#### Scenario: formatLanguageName renders French
|
||||
|
||||
- **WHEN** `formatLanguageName("fr", "en-GB")` is called
|
||||
- **THEN** the result equals `"French"`
|
||||
|
||||
#### Scenario: formatRegionName renders United Kingdom
|
||||
|
||||
- **WHEN** `formatRegionName("GB", "en-GB")` is called
|
||||
- **THEN** the result equals `"United Kingdom"`
|
||||
|
||||
#### Scenario: formatCurrencyName renders US Dollar
|
||||
|
||||
- **WHEN** `formatCurrencyName("USD", "en-GB")` is called
|
||||
- **THEN** the result equals `"US Dollar"`
|
||||
|
||||
#### Scenario: formatScriptName renders Latin
|
||||
|
||||
- **WHEN** `formatScriptName("Latn", "en-GB")` is called
|
||||
- **THEN** the result equals `"Latin"`
|
||||
|
||||
#### Scenario: formatCalendarName renders gregorian
|
||||
|
||||
- **WHEN** `formatCalendarName("gregory", "en-GB")` is called
|
||||
- **THEN** the result is the locale-correct gregorian display name (non-empty)
|
||||
|
||||
**Verified by:** `packages/i18n/i18n-formatter-display-name/src/helpers.test.ts::"formatLanguageName renders French"` AND `packages/i18n/i18n-formatter-display-name/src/helpers.test.ts::"formatRegionName renders United Kingdom"` AND `packages/i18n/i18n-formatter-display-name/src/helpers.test.ts::"formatCurrencyName renders US Dollar"` AND `packages/i18n/i18n-formatter-display-name/src/helpers.test.ts::"formatScriptName renders Latin"` AND `packages/i18n/i18n-formatter-display-name/src/helpers.test.ts::"formatCalendarName renders gregorian display name"`
|
||||
|
||||
### Requirement: Subpath exports
|
||||
|
||||
The package SHALL declare subpath exports in `package.json`:
|
||||
|
||||
- `@suaveplan/i18n-formatter-display-name` — root barrel
|
||||
- `@suaveplan/i18n-formatter-display-name/formatter` — `DisplayNameFormatter` class + `formatDisplayName` + types + schema only
|
||||
- `@suaveplan/i18n-formatter-display-name/helpers` — five 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-display-name/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-display-name/src/index.test.ts::"package.json declares sideEffects false"`
|
||||
Reference in New Issue
Block a user