first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,113 @@
# @suaveplan/i18n-formatter-number - Locale-aware number formatting backed by Intl.NumberFormat. Class wrapper + standalone helper + ergonomic helpers (formatPercent, formatCompact, formatUnit), Zod-validated options, per-(locale, options-hash) cache, OTel histogram + cache-hit counter. Wave 3 of the @suaveplan/i18n modular split.
## Summary
Locale-aware number formatting backed by `Intl.NumberFormat`. Wave 3
of the 14-package i18n modular split. Provides both a class
(`NumberFormatter`) and a standalone helper (`formatNumber`) plus
ergonomic shortcuts (`formatPercent`, `formatCompact`, `formatUnit`)
on top of a per-`(locale, options-hash)` cache, Zod-validated options,
and the canonical OTel histogram + cache-hit counter that every Wave 3
formatter package emits.
<!-- begin:auto-docs -->
## Documentation tree
- [formatter](./src/formatter/formatter.md) — Locale-aware number formatting backed by `Intl.NumberFormat`. The package exposes both a class (`NumberFormatter` — con…
- [formatter](./src/formatter/formatter.md) — Locale-aware number formatting backed by `Intl.NumberFormat`. The package exposes both a class (`NumberFormatter` — con…
- [helpers](./src/helpers/helpers.md) — Pre-bound shortcuts over `formatNumber` and `Intl.NumberFormat` for the three most-asked number-formatting shapes: perc…
- [helpers](./src/helpers/helpers.md) — Pre-bound shortcuts over `formatNumber` and `Intl.NumberFormat` for the three most-asked number-formatting shapes: perc…
- [observability](./src/observability/observability.md) — Per-package OpenTelemetry instruments for `@suaveplan/i18n-formatter-number`. Exposes a histogram (`i18n.format.number.…
- [observability](./src/observability/observability.md) — Per-package OpenTelemetry instruments for `@suaveplan/i18n-formatter-number`. Exposes a histogram (`i18n.format.number.…
<!-- end:auto-docs -->
## Why this exists
Each `Intl.*` wrapper lives in its own sibling package so consumers
can pull only the formatters they actually use. This package is the
canonical Wave 3 template — every other formatter package mirrors its
layout (per-(locale, options-hash) cache, Zod schema, OTel metrics,
helper + class twin surface). `@suaveplan/i18n-formatter-currency`
declares a workspace dep on this package because currency formatting
is `Intl.NumberFormat({ style: "currency", ... })`.
## Install
```bash
bun add @suaveplan/i18n-formatter-number
```
## Quick Start
```ts
import {
formatCompact,
formatNumber,
formatPercent,
NumberFormatter,
} from "@suaveplan/i18n-formatter-number";
formatNumber(1234567.89, "en-GB", {
style: "decimal",
maximumFractionDigits: 2,
}); // → "1,234,567.89"
formatPercent(0.5, "en-GB"); // → "50%"
formatCompact(1500, "en-US"); // → "1.5K"
const usd = new NumberFormatter({
style: "currency",
currency: "USD",
});
usd.format(42.5, "en-US"); // → "$42.50"
```
## Modules
| Subpath | Description |
|---|---|
| `@suaveplan/i18n-formatter-number` | Root barrel — class + standalone + helpers + observability |
| `@suaveplan/i18n-formatter-number/formatter` | `NumberFormatter`, `formatNumber`, `clearNumberFormatterCache`, types, schema |
| `@suaveplan/i18n-formatter-number/helpers` | `formatPercent`, `formatCompact`, `formatUnit` only |
## Modules Documentation
- [formatter.md](src/formatter.md) — `NumberFormatter` class + `formatNumber`
- [helpers.md](src/helpers.md) — `formatPercent`, `formatCompact`, `formatUnit`
- [observability.md](src/observability.md) — OTel histogram + cache-hit counter
## Public Surface (snapshot)
| Export | Kind | Origin |
|---|---|---|
| `NumberFormatter` | class | `formatter.ts` |
| `formatNumber` | function | `formatter.ts` |
| `clearNumberFormatterCache` | function | `formatter.ts` |
| `NumberFormatterOptionsSchema` | const | `formatter.ts` |
| `NumberFormatterStyle` | type | `formatter.types.ts` |
| `NumberFormatterNotation` | type | `formatter.types.ts` |
| `NumberFormatterCurrencyDisplay` | type | `formatter.types.ts` |
| `NumberFormatterSignDisplay` | type | `formatter.types.ts` |
| `NumberFormatterOptions` | type | `formatter.types.ts` |
| `NumberFormatterCacheKey` | type | `formatter.types.ts` |
| `formatPercent` | function | `helpers.ts` |
| `formatCompact` | function | `helpers.ts` |
| `formatUnit` | function | `helpers.ts` |
| `recordNumberFormat` | function | `observability.ts` |
| `recordNumberCacheHit` | function | `observability.ts` |
| `getNumberFormatMetricsState` | function | `observability.ts` |
| `resetNumberFormatMetricsState` | function | `observability.ts` |
## Versioning
This package is part of the i18n modular-split rollout. Stays at
`0.1.0` until the slim core (Wave 6) cuts the family `1.0.0` release.
## Wave 3 Scope
This package is one of eight Wave 3 formatter packages. Pure
addition — `packages/i18n/i18n/src/formatting/number/` is preserved
in the monolith until Wave 6 deletes it. Both copies coexist during
Waves 3–5; downstream consumers continue to import from the monolith
and observe no behaviour change.

View File

@@ -0,0 +1,86 @@
# NumberFormatter
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Locale-aware number formatting backed by `Intl.NumberFormat`. The package exposes both a class (`NumberFormatter` — construct once with stable options, reuse) and a standalone helper (`formatNumber(value, locale, options?)`). Every call validates options through `NumberFormatterOptionsSchema` (Zod) at the boundary, maps `Intl` `RangeError`s for unsupported locales to `I18N.UNSUPPORTED_LOCALE`, and emits a wall-clock duration sample on the `i18n.format.number.duration_ms` histogram.
A per-`(locale, options-hash)` cache for `Intl.NumberFormat` instances amortises construction cost — important on dashboards rendering hundreds of formatted values per second. Cache reuse increments the `i18n.format.cache_hit{formatter="number"}` counter so consumers can measure cache effectiveness.
## Features
- Class form (`NumberFormatter`) for fixed-options reuse + standalone helper (`formatNumber`) for one-off calls
- Full `Intl.NumberFormat` option surface: `style: 'decimal' | 'percent' | 'currency'`, `currency`, `currencyDisplay`, fraction digits, integer digits, significant digits, `useGrouping`, `notation` (`standard | scientific | engineering | compact`), `compactDisplay`, `signDisplay`
- Zod-validated options at construction; fraction-digit inversion (`min > max`) rejected with `field === 'fractionDigits'` context
- Stable error codes: `I18N.INVALID_FORMATTER_OPTIONS`, `I18N.UNSUPPORTED_LOCALE`
- Process-wide `Intl.NumberFormat` cache keyed on `(locale, options-hash)`; `clearNumberFormatterCache()` for tests
- OTel instrumentation: `i18n.format.number.duration_ms` histogram, `i18n.format.cache_hit{formatter="number"}` counter
## Usage: Basic Example
```typescript
import { formatNumber } from "@suaveplan/i18n-formatter-number";
formatNumber(1234567.89, "en-US", { style: "decimal", maximumFractionDigits: 2 });
// → "1,234,567.89"
formatNumber(0.255, "en-US", { style: "percent", maximumFractionDigits: 1 });
// → "25.5%"
formatNumber(42, "en-GB", {
style: "currency",
currency: "GBP",
currencyDisplay: "code",
});
// → "GBP 42.00"
```
## Usage: Advanced Example — Class Form for Repeated Use
```typescript
import { NumberFormatter } from "@suaveplan/i18n-formatter-number";
const usd = new NumberFormatter({
style: "currency",
currency: "USD",
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
// Many call sites, single options validation:
const out = prices.map((price) => usd.format(price, "en-US"));
```
## API Reference
### `formatNumber(value, locale, options?): string`
Formats `value` per `locale` and `options`. Validates options through Zod, throws `I18nError(I18N.INVALID_FORMATTER_OPTIONS)` on failure. Maps `Intl.NumberFormat`'s `RangeError` for unsupported locales to `I18nError(I18N.UNSUPPORTED_LOCALE)`.
### `class NumberFormatter`
- `constructor(options?: NumberFormatterOptions)` — validates options at construction
- `format(value: number, locale: string): string` — formats with stored options
### `clearNumberFormatterCache(): void`
Test-only. Drops the process-wide cache so subsequent calls re-construct `Intl.NumberFormat`.
### Types
`NumberFormatterStyle`, `NumberFormatterNotation`, `NumberFormatterCurrencyDisplay`, `NumberFormatterSignDisplay`, `NumberFormatterOptions`, `NumberFormatterCacheKey`.
### `NumberFormatterOptionsSchema`
Zod schema validating the `NumberFormatterOptions` surface. Rejects unknown enum values, out-of-range fraction-digit counts, and the inverted-fraction-digit case (`minimumFractionDigits > maximumFractionDigits`).
## Implementation Notes
The cache key is `${locale}|${JSON.stringify(options)}`. JSON.stringify sorts keys alphabetically when given an object literal in modern engines, so two callers passing the same options in different order share a cache entry. Order-sensitive object construction would defeat the cache; if a consumer needs deterministic ordering, normalise the options shape in the call site.
`style: 'currency'` requires `currency` to be a 3-letter ISO 4217 code. The Zod schema enforces this; bad currency codes either fail Zod (`field === 'currency'`) or fall through to `Intl.NumberFormat` which throws `RangeError` mapped to `UNSUPPORTED_LOCALE`. Most production usage hits the Zod path because callers know their currency codes at compile time.
The `signDisplay: 'negative'` option is part of the ECMA-402 spec and Node 19+ / Bun runtime; older runtimes may reject it via `RangeError`. The error mapping path produces `UNSUPPORTED_LOCALE` even though the failure is option-related — this is the correct user-facing signal because the runtime is what's missing the support.

View File

@@ -0,0 +1,70 @@
# Number Helper Formatters
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Pre-bound shortcuts over `formatNumber` and `Intl.NumberFormat` for the three most-asked number-formatting shapes: percentages, compact notation ("1.2K"), and units of measurement ("12 km/h"). Saves callers from spelling out the full options bag every time.
The percent and compact helpers route through `formatNumber`, so they benefit from its Zod validation and per-(locale, options-hash) cache. `formatUnit` calls `Intl.NumberFormat` directly because `style: 'unit'` is intentionally outside the narrow `NumberFormatterStyle` union.
## Features
- `formatPercent(value, locale, options?)` — `style: 'percent'` shortcut
- `formatCompact(value, locale, options?)` — `notation: 'compact'` shortcut ("1.2K", "1.2M")
- `formatUnit(value, unit, locale, options?)` — `style: 'unit'` shortcut for measurement units
- `unit` is any `Intl.NumberFormat` unit identifier (`'kilometer-per-hour'`, `'celsius'`, `'megabyte'`, ...)
- `formatUnit` style is `'short' | 'long' | 'narrow'` (default `'short'`)
## Usage: Basic Example
```typescript
import {
formatCompact,
formatPercent,
formatUnit,
} from "@suaveplan/i18n-formatter-number/helpers";
formatPercent(0.255, "en-US", { maximumFractionDigits: 1 }); // → "25.5%"
formatCompact(1234, "en-US"); // → "1.2K"
formatUnit(12, "kilometer-per-hour", "en-US"); // → "12 km/h"
formatUnit(20, "celsius", "en-US", { style: "long" }); // → "20 degrees Celsius"
```
## Usage: Advanced Example — Stat Block
```typescript
import {
formatCompact,
formatPercent,
formatUnit,
} from "@suaveplan/i18n-formatter-number/helpers";
const stats = {
cpuLoad: formatPercent(load, locale),
memUsed: formatUnit(memBytes, "megabyte", locale, { style: "long" }),
reqsPerMinute: formatCompact(reqs, locale),
};
```
## API Reference
### `formatPercent(value, locale, options?)`
Pre-bound `formatNumber({ style: 'percent', ...options })`. Note that `value` is the proportion (0.25 → "25%"), not the percentage (25).
### `formatCompact(value, locale, options?)`
Pre-bound `formatNumber({ notation: 'compact', ...options })`. Defaults to `compactDisplay: 'short'` from `Intl.NumberFormat`. Override via `options.compactDisplay`.
### `formatUnit(value, unit, locale, options?)`
Locale-aware unit formatting. The `unit` is one of `Intl.NumberFormat`'s sanctioned units; the [Intl.NumberFormat units list](https://tc39.es/ecma402/#table-sanctioned-single-unit-identifiers) is authoritative. Composite units use `-per-` joiner (`'meter-per-second'`).
## Implementation Notes
`formatUnit` does NOT route through `formatNumber` because adding `style: 'unit'` would require widening `NumberFormatterStyle` and `NumberFormatterOptions` to include the unit-specific options. Keeping `formatUnit` as a thin direct wrapper preserves the `formatNumber` surface's stability.
Compact notation uses the runtime's locale-specific compact patterns. For en-US that's `'1.2K'`, `'1.2M'`, `'1.2B'`; other locales use locale-correct abbreviations (e.g. `'1,2 万'` for ja-JP).

View File

@@ -0,0 +1,88 @@
# NumberFormatter Observability
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Per-package OpenTelemetry instruments for `@suaveplan/i18n-formatter-number`. Exposes a histogram (`i18n.format.number.duration_ms`) recording wall-clock duration of every `formatNumber` call and a counter (`i18n.format.cache_hit`) tagged with `formatter: "number"` that increments on every cache reuse. The two instruments are constructed once via `metrics.getMeter` and are lazily initialised on first record so cold-start latency stays low.
The module also retains an in-memory sample ring (`getNumberFormatMetricsState()` and `resetNumberFormatMetricsState()`) so spec-conformance tests can assert the exact attribute payloads produced by `recordNumberFormat` and `recordNumberCacheHit` without needing to wire a full SDK exporter inside every test file.
## Features
- Lazy instrument construction: the first call to `recordNumberFormat` or `recordNumberCacheHit` builds both instruments via the OTel global meter
- Stable histogram name `i18n.format.number.duration_ms` with attributes `{ locale, style }`
- Stable counter name `i18n.format.cache_hit` with attributes `{ formatter: "number" }`
- In-memory sample ring (`numberDurationSamples`, `cacheHitSamples`) for spec-conformance assertions
- `resetNumberFormatMetricsState()` clears both arrays so tests stay isolated
## Usage: Basic Example
```typescript
import {
recordNumberFormat,
recordNumberCacheHit,
getNumberFormatMetricsState,
resetNumberFormatMetricsState,
} from "@suaveplan/i18n-formatter-number/observability";
resetNumberFormatMetricsState();
recordNumberFormat("en-GB", "decimal", 1.23);
recordNumberCacheHit();
const state = getNumberFormatMetricsState();
state.numberDurationSamples; // [{ attributes: { locale: "en-GB", style: "decimal" } }]
state.cacheHitSamples; // [{ attributes: { formatter: "number" } }]
```
## Usage: Advanced Example — Asserting OTel emission in a test
```typescript
import { describe, expect, it } from "@suaveplan/testing/runner";
import { formatNumber, clearNumberFormatterCache } from "@suaveplan/i18n-formatter-number";
import {
getNumberFormatMetricsState,
resetNumberFormatMetricsState,
} from "@suaveplan/i18n-formatter-number/observability";
describe("metrics emission", () => {
it("emits duration histogram and cache-hit counter", { timeout: 5000 }, () => {
clearNumberFormatterCache();
resetNumberFormatMetricsState();
formatNumber(1, "en-GB", { style: "decimal" });
formatNumber(1, "en-GB", { style: "decimal" });
const state = getNumberFormatMetricsState();
expect(state.numberDurationSamples).toHaveLength(2);
expect(state.cacheHitSamples).toHaveLength(1);
});
});
```
## API Reference
### `recordNumberFormat(locale: string, style: string, durationMs: number): void`
Records one observation on the `i18n.format.number.duration_ms` histogram and pushes a `{ attributes: { locale, style } }` entry into the in-memory sample ring.
### `recordNumberCacheHit(): void`
Adds 1 to the `i18n.format.cache_hit` counter with `formatter: "number"` and pushes a matching entry into the in-memory sample ring.
### `getNumberFormatMetricsState(): NumberFormatMetricsState`
Returns the in-memory sample ring (live reference — do not mutate). Use `resetNumberFormatMetricsState` between tests to keep state isolated.
### `resetNumberFormatMetricsState(): void`
Empties both sample arrays in place.
### `NumberFormatMetricsState`
`{ numberDurationSamples: FormatMetricSample[]; cacheHitSamples: FormatMetricSample[] }` where `FormatMetricSample = { attributes: Record<string, string | number> }`.
## Implementation Notes
The instruments are built lazily so that consumers who never call `formatNumber` (e.g. tree-shaken builds that import only types) do not pay the OTel meter-lookup cost. The cache check is a strict identity check against the closure-scoped `cached` slot — once built, the instruments are never rebuilt, even across `resetNumberFormatMetricsState` calls (resetting state does not invalidate the meter).
The module deliberately keeps the in-memory sample ring unbounded. Tests should call `resetNumberFormatMetricsState()` between cases. Long-running production processes do not retain the ring because the slim-core wave swaps the in-package observability module for the unified one in `@suaveplan/i18n` core; for now, every leaf package owns its own ring per the design doc's per-package shape.