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

107 lines
9.2 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# `@suaveplan/i18n-formatter-number` — `Intl.NumberFormat` wrapper
**Package**: `@suaveplan/i18n-formatter-number` (NEW)
**Status**: PROPOSED
**Type**: feature (new universal-tier package; verbatim port of `formatting/number/`)
**Date**: 2026-05-05
**Wave**: 3 (depends on Wave 1 foundation; required by Wave 6 core slim and Wave 3 currency formatter sibling)
**Design doc**: `openspec/cross-cutting-designs/2026-05-05-i18n-modular-split.md` (especially §5 formatter package shape, §5b FormatBridge, §7.1)
**Path**: `packages/i18n/i18n-formatter-number/`
**Tier**: universal
## Why
The 14-package i18n modular split lifts each `Intl.*` wrapper out of the monolith into its own sibling package so consumers can pull only the formatters they actually use. `@suaveplan/i18n-formatter-number` is the canonical Wave-3 template: every other formatter package follows its shape (per-(locale, options-hash) cache, Zod-validated options, OTel histogram + cache-hit counter, helper + class twin surface).
Today `formatting/number/` ships:
- `NumberFormatter` class wrapping `Intl.NumberFormat` (`packages/i18n/i18n/src/formatting/number/number.ts` L89–L100)
- `formatNumber` standalone helper (`number.ts` L67–L83)
- `formatPercent`, `formatCompact`, `formatUnit` ergonomic helpers (`number-helpers.ts` L8–L48)
- Zod option schema (`number.schemas.ts` L1–L41)
- Per-(locale, options-hash) cache via `JSON.stringify` keying (`number.ts` L13–L20)
- Structured error mapping: `INVALID_FORMATTER_OPTIONS` on Zod failure (`number.ts` L22–L31), `UNSUPPORTED_LOCALE` on `Intl.NumberFormat` `RangeError` (`number.ts` L33–L48)
- OTel histogram `i18n.format.number.duration_ms` and `i18n.format.cache_hit{formatter="number"}` counter via `observability/format-metrics.ts`
Splitting this into its own package is the lowest-risk Wave-3 step: the surface is small, the test suite is comprehensive (123-line `number.test.ts` + 123-line `number.spec-conformance.test.ts`), and downstream consumers (currency formatter; ICU `{value, number}` placeholder resolution via `FormatBridge`) need a stable target before Wave 6.
## What changes
### New package: `@suaveplan/i18n-formatter-number`
- ADDED: `packages/i18n/i18n-formatter-number/` package scaffold (`package.json`, `tsconfig.json`, `vite.config.ts`, `README.md`).
- ADDED: `src/formatter.ts` — `NumberFormatter` class + `formatNumber` standalone + `clearNumberFormatterCache` test-only helper. Verbatim port of monolith `number.ts` (L1–L106) with import rewires (`@suaveplan/i18n-shared` instead of `../../error-codes.js` / `../../errors/i18n-error.js`; in-package `observability` module instead of `../../observability/format-metrics.js`).
- ADDED: `src/formatter.types.ts` — `NumberFormatterStyle`, `NumberFormatterNotation`, `NumberFormatterCurrencyDisplay`, `NumberFormatterSignDisplay`, `NumberFormatterOptions`, `NumberFormatterCacheKey`. Verbatim port of monolith `number.types.ts` (L1–L40).
- ADDED: `src/formatter.schemas.ts` — `NumberFormatterOptionsSchema` Zod schema. Verbatim port of monolith `number.schemas.ts` (L1–L41).
- ADDED: `src/helpers.ts` — `formatPercent`, `formatCompact`, `formatUnit`. Verbatim port of monolith `number-helpers.ts` (L1–L48).
- ADDED: `src/observability.ts` — local OTel histogram + counter emitters (`recordNumberFormat`, `recordCacheHit`). Sourced from monolith `observability/format-metrics.ts` (number-only slice).
- ADDED: `src/formatter.test.ts` — verbatim port of monolith `number.test.ts` (123 lines) with runner imports through `@suaveplan/testing/runner`.
- ADDED: `src/formatter.spec-conformance.test.ts` — verbatim port of monolith `number.spec-conformance.test.ts` (123 lines).
- ADDED: `src/helpers.test.ts` — verbatim port of monolith `number-helpers.test.ts` (41 lines).
- ADDED: `src/index.ts` — explicit named re-exports (no `export *` per CLAUDE.md §10).
- ADDED: `src/formatter.md`, `src/helpers.md` — sibling docs.
### Subpath exports
- `.` — root barrel (`NumberFormatter`, `formatNumber`, `formatPercent`, `formatCompact`, `formatUnit`, types, `NumberFormatterOptionsSchema`)
- `./formatter` — `NumberFormatter` class + `formatNumber` + types + schema only
- `./helpers` — `formatPercent`, `formatCompact`, `formatUnit` only
### Dependencies
- `@suaveplan/i18n-shared workspace:^` — `I18N_CODES`, `I18nError`
- `@suaveplan/types workspace:^`
- `@suaveplan/error workspace:^`
- `@opentelemetry/api catalog:observability`
- `tslib catalog:build`
- `zod catalog:forms`
- **No** dep on any other formatter package, on `@suaveplan/i18n` core, or on any backend package.
## Out of scope
- **Removing `formatting/number/` from `packages/i18n/i18n/src/`** — happens in Wave 6 (`i18n-core-slim`). For Waves 3–5, both copies coexist and both pass their tests.
- **Currency-style support** — handled by sibling package `@suaveplan/i18n-formatter-currency`, which depends on this package.
- **Switching to a different number-format engine** (e.g. `globalize` or CLDR via `cldrjs`) — explicit non-goal; `Intl.NumberFormat` is the cross-runtime baseline.
- **Adding `format-to-parts` / `format-range` / `format-range-to-parts`** — follow-up; current monolith does not expose them.
## Source mapping
| Requirement | Source file | Source lines | Notes |
|---|---|---|---|
| `NumberFormatter` class | `packages/i18n/i18n/src/formatting/number/number.ts` | L89–L100 | Verbatim port; rewire imports |
| `formatNumber` standalone | `packages/i18n/i18n/src/formatting/number/number.ts` | L67–L83 | Verbatim port; rewire imports |
| `clearNumberFormatterCache` | `packages/i18n/i18n/src/formatting/number/number.ts` | L102–L105 | Verbatim port |
| Per-(locale, options-hash) cache | `packages/i18n/i18n/src/formatting/number/number.ts` | L13–L20, L50–L60 | Verbatim port |
| `INVALID_FORMATTER_OPTIONS` mapping | `packages/i18n/i18n/src/formatting/number/number.ts` | L22–L31 | Verbatim port; throws `I18nError` from `@suaveplan/i18n-shared` |
| `UNSUPPORTED_LOCALE` mapping | `packages/i18n/i18n/src/formatting/number/number.ts` | L33–L48 | Verbatim port |
| Type definitions | `packages/i18n/i18n/src/formatting/number/number.types.ts` | L1–L40 | Verbatim port |
| Zod schema | `packages/i18n/i18n/src/formatting/number/number.schemas.ts` | L1–L41 | Verbatim port |
| `formatPercent`, `formatCompact`, `formatUnit` helpers | `packages/i18n/i18n/src/formatting/number/number-helpers.ts` | L1–L48 | Verbatim port |
| OTel histogram + cache-hit counter | `packages/i18n/i18n/src/observability/format-metrics.ts` | number-only slice | Local re-implementation in `src/observability.ts` |
### Requirements with no source line
| Requirement | Justification |
|---|---|
| Subpath export `./formatter` | Tree-shaking — slim-core `FormatBridge` wiring imports only the standalone `formatNumber`, not the `formatPercent` / `formatCompact` / `formatUnit` helpers |
| Subpath export `./helpers` | Symmetric — consumers wanting only the percent/compact/unit shortcuts can avoid loading the class wrapper |
| `FORMATTER_CACHE_OVERFLOW` (declared in `@suaveplan/i18n-shared`) | The current monolith's cache is unbounded; the per-package split is the right time to bound it. Implementation may emit this code if and when a `maxCacheEntries` option is added (follow-up). For Wave 3, the code is imported but only thrown from the shared registry — this package exercises declaration but not enforcement. |
## Verification approach
Each requirement carries a `**Verified by:**` line in the spec delta. Concrete test files:
- `packages/i18n/i18n-formatter-number/src/formatter.test.ts` — every option permutation (`style`, `notation`, `currency`, `currencyDisplay`, `signDisplay`, `useGrouping`, `compactDisplay`, fraction-digit caps); class wrapper twins helper output; `RangeError` from `Intl.NumberFormat` mapped to `UNSUPPORTED_LOCALE`; Zod failure mapped to `INVALID_FORMATTER_OPTIONS`
- `packages/i18n/i18n-formatter-number/src/formatter.spec-conformance.test.ts` — verbatim port of monolith spec-conformance suite (asserts every code path documented in `openspec/specs/i18n/spec.md`'s number-formatter requirements still passes)
- `packages/i18n/i18n-formatter-number/src/helpers.test.ts` — `formatPercent` defaults to no fraction digits; `formatCompact` produces `"1.2K"` / `"1.2M"`; `formatUnit` accepts unit identifiers
- `packages/i18n/i18n-formatter-number/src/observability.test.ts` — histogram observed on first call; counter incremented only on second call with same `(locale, options)`; both metric names match the design-doc-mandated identifiers
- `packages/i18n/i18n-formatter-number/src/index.test.ts` — every documented public export resolves through the root barrel; every documented subpath resolves
## Impact
- New universal-tier package — joins `@suaveplan/i18n-shared` and `@suaveplan/i18n-icu-parser` under `packages/i18n/`.
- Direct consumer in Wave 3: `@suaveplan/i18n-formatter-currency` (currency wraps `Intl.NumberFormat` with `style: "currency"`; depends on this package's helper for delegation).
- Indirect consumer in Wave 6: `@suaveplan/i18n` slim core wires `formatNumber` into the default `FormatBridge` for ICU `{value, number}` placeholder rendering.
- No downstream consumer changes needed — the slim core re-export bridge surfaces every public symbol from this package under `@suaveplan/i18n`.
- `MANIFEST.md` and `CLAUDE.md` updated post-merge.