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,107 @@
# @suaveplan/i18n-formatter-list - Locale-aware list joining backed by Intl.ListFormat. Class wrapper + standalone helper + ergonomic helpers (formatConjunction, formatDisjunction), Zod-validated options, per-(locale, style, type) cache, OTel histogram + cache-hit counter. Wave 3 of the @suaveplan/i18n modular split.
## Summary
Locale-aware list joining backed by `Intl.ListFormat`. Wave 3 of the
14-package i18n modular split. Provides both a class
(`ListFormatter`) and a standalone helper (`formatList`) plus
ergonomic shortcuts (`formatConjunction`, `formatDisjunction`) on
top of a per-`(locale, style, type)` 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 list joining backed by `Intl.ListFormat`. Joins an array of strings with the locale-correct connector (`"a…
- [formatter](./src/formatter/formatter.md) — Locale-aware list joining backed by `Intl.ListFormat`. Joins an array of strings with the locale-correct connector (`"a…
- [helpers](./src/helpers/helpers.md) — Pre-bound shortcuts over `formatList` for the two most common list-formatting shapes: conjunction joining ("a, b, and c…
- [helpers](./src/helpers/helpers.md) — Pre-bound shortcuts over `formatList` for the two most common list-formatting shapes: conjunction joining ("a, b, and c…
- [observability](./src/observability/observability.md) — Per-package OpenTelemetry instruments for `@suaveplan/i18n-formatter-list`. Exposes a histogram (`i18n.format.list.dura…
- [observability](./src/observability/observability.md) — Per-package OpenTelemetry instruments for `@suaveplan/i18n-formatter-list`. Exposes a histogram (`i18n.format.list.dura…
<!-- 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
list-formatter slice of Wave 3. The monolith ships only a thin
standalone helper and per-cache module — this package brings the
canonical Wave 3 shape on top: class wrapper for symmetry with
`NumberFormatter`, Zod schema validating options at the boundary,
mapping of Zod failure to `INVALID_FORMATTER_OPTIONS`, and OTel
emission for parity with the rest of the family.
## Install
```bash
bun add @suaveplan/i18n-formatter-list
```
## Quick Start
```ts
import {
formatConjunction,
formatDisjunction,
formatList,
ListFormatter,
} from "@suaveplan/i18n-formatter-list";
formatList(["apples", "oranges", "bananas"], "en-US");
// → "apples, oranges, and bananas"
formatList(["apples", "oranges"], "en-US", { type: "disjunction" });
// → "apples or oranges"
formatConjunction(["a", "b", "c"], "en-GB"); // → "a, b, and c"
formatDisjunction(["x", "y"], "en-GB"); // → "x or y"
const compact = new ListFormatter({ style: "short", type: "unit" });
compact.format(["mon", "tue", "wed"], "en-US"); // → "mon, tue, wed"
```
## Modules
| Subpath | Description |
|---|---|
| `@suaveplan/i18n-formatter-list` | Root barrel — class + standalone + helpers + observability |
| `@suaveplan/i18n-formatter-list/formatter` | `ListFormatter`, `formatList`, `clearListFormatterCache`, types, schema |
| `@suaveplan/i18n-formatter-list/helpers` | `formatConjunction`, `formatDisjunction` only |
## Modules Documentation
- [formatter.md](src/formatter.md) — `ListFormatter` class + `formatList`
- [helpers.md](src/helpers.md) — `formatConjunction`, `formatDisjunction`
- [observability.md](src/observability.md) — OTel histogram + cache-hit counter
## Public Surface (snapshot)
| Export | Kind | Origin |
|---|---|---|
| `ListFormatter` | class | `formatter.ts` |
| `formatList` | function | `formatter.ts` |
| `clearListFormatterCache` | function | `formatter.ts` |
| `ListFormatterOptionsSchema` | const | `formatter.ts` |
| `ListFormatStyle` | type | `formatter.types.ts` |
| `ListFormatType` | type | `formatter.types.ts` |
| `ListFormatterOptions` | type | `formatter.types.ts` |
| `ListFormatterCacheKey` | type | `formatter.types.ts` |
| `formatConjunction` | function | `helpers.ts` |
| `formatDisjunction` | function | `helpers.ts` |
| `recordListFormat` | function | `observability.ts` |
| `recordListCacheHit` | function | `observability.ts` |
| `getListFormatMetricsState` | function | `observability.ts` |
| `resetListFormatMetricsState` | 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/list/` is preserved
in the monolith until Wave 6 deletes it.

View File

@@ -0,0 +1,82 @@
# ListFormatter
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Locale-aware list joining backed by `Intl.ListFormat`. Joins an array of strings with the locale-correct connector (`"apples, oranges, and bananas"` in `en-US`). Supports conjunction (`and`), disjunction (`or`), and unit list types, plus three styles (`long`, `short`, `narrow`).
This package is the Wave 3 list-formatter slice — the monolith ships only the standalone helper and per-cache module; this package adds the canonical Wave 3 shape on top: a `ListFormatter` class wrapper for symmetry with `NumberFormatter` / `RelativeTimeFormatter` / `DateFormatter`, a Zod schema validating options at the boundary, mapping of `Intl` `RangeError` to `I18N.UNSUPPORTED_LOCALE`, mapping of Zod failure to `I18N.INVALID_FORMATTER_OPTIONS`, and an OTel histogram + cache-hit counter for per-formatter parity.
The package caches `Intl.ListFormat` per `(locale, style, type)` so dashboards rendering many comma-separated lists do not pay the construction cost on every call. Cache reuse is opaque to callers and increments `i18n.format.cache_hit{formatter="list"}`.
## Features
- Class form (`ListFormatter`) for fixed-options reuse + standalone helper (`formatList`) for one-off calls
- `style: 'long' | 'short' | 'narrow'` (default `'long'`)
- `type: 'conjunction' | 'disjunction' | 'unit'` (default `'conjunction'`)
- Zod-validated options at construction; unknown style/type rejected with structured `INVALID_FORMATTER_OPTIONS`
- Stable error codes: `I18N.INVALID_FORMATTER_OPTIONS`, `I18N.UNSUPPORTED_LOCALE`
- Process-wide `Intl.ListFormat` cache; `clearListFormatterCache()` for tests
- OTel instrumentation: `i18n.format.list.duration_ms` histogram, `i18n.format.cache_hit{formatter="list"}` counter
## Usage: Basic Example
```typescript
import { formatList, ListFormatter } from "@suaveplan/i18n-formatter-list";
formatList(["apples", "oranges", "bananas"], "en-US");
// → "apples, oranges, and bananas"
formatList(["apples", "oranges"], "en-US", { type: "disjunction" });
// → "apples or oranges"
const compact = new ListFormatter({ style: "short", type: "unit" });
compact.format(["mon", "tue", "wed"], "en-US");
// → "mon, tue, wed"
```
## Usage: Advanced Example — Building Tag Lists
```typescript
import { ListFormatter } from "@suaveplan/i18n-formatter-list";
const tagList = new ListFormatter({ type: "unit", style: "short" });
function summariseTags(items, locale) {
const tags = items.map((item) => item.label);
return `Tagged: ${tagList.format(tags, locale)}`;
}
```
## API Reference
### `formatList(values, locale, options?): string`
Joins `values` per `locale` and `options`. Caches the underlying `Intl.ListFormat` per `(locale, style, type)`. Validates options through Zod, throws `I18nError(I18N.INVALID_FORMATTER_OPTIONS)` on failure. Maps `Intl.ListFormat`'s `RangeError` for unsupported locales to `I18nError(I18N.UNSUPPORTED_LOCALE)`.
### `class ListFormatter`
- `constructor(options?: ListFormatterOptions)` — validates options at construction
- `format(values: readonly string[], locale: string): string` — formats with stored options
### `clearListFormatterCache(): void`
Test-only.
### `ListFormatterOptionsSchema`
Zod schema validating the `ListFormatterOptions` surface.
### Types
`ListFormatStyle`, `ListFormatType`, `ListFormatterOptions`, `ListFormatterCacheKey`.
## Implementation Notes
`Intl.ListFormat` accepts a readonly array but the type signature on Node 22+ requires a mutable array; we cast at the boundary. The cast is safe because `Intl.ListFormat.format` reads but does not mutate the input.
`type: 'unit'` is intended for units of measurement (e.g. measurement parts in `formatNumber({ style: 'unit' })`). For free-form lists prefer `'conjunction'` or `'disjunction'`.
The cache key is `${locale}|${style}|${type}` rather than a JSON-stringified options bag because `Intl.ListFormat` has only three knobs. This is symmetric with the `RelativeTimeFormatter` cache strategy (and distinct from the `NumberFormatter` cache strategy which uses JSON-stringified options because the option surface is much wider).

View File

@@ -0,0 +1,60 @@
# List Helper Formatters
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Pre-bound shortcuts over `formatList` for the two most common list-formatting shapes: conjunction joining ("a, b, and c") and disjunction joining ("a, b, or c"). These are net-new ergonomic helpers introduced by the Wave-3 split — symmetric with `formatPercent` / `formatCompact` for the number formatter.
The helpers route every call through `formatList`, so they inherit Zod option validation, the per-`(locale, style, type)` cache, and the OTel histogram + cache-hit counter from the formatter module.
## Features
- `formatConjunction(values, locale, style?)` — pre-bound `formatList({ type: 'conjunction', style })`
- `formatDisjunction(values, locale, style?)` — pre-bound `formatList({ type: 'disjunction', style })`
- Style argument defaults to `'long'`; pass `'short'` or `'narrow'` for compact rendering
## Usage: Basic Example
```typescript
import {
formatConjunction,
formatDisjunction,
} from "@suaveplan/i18n-formatter-list/helpers";
formatConjunction(["a", "b", "c"], "en-GB"); // → "a, b, and c"
formatConjunction(["x", "y"], "en-GB", "short"); // → "x, & y" or "x & y" (locale-dependent)
formatDisjunction(["red", "green", "blue"], "en-GB"); // → "red, green, or blue"
```
## Usage: Advanced Example — Permission summary
```typescript
import { formatConjunction } from "@suaveplan/i18n-formatter-list/helpers";
function summarisePermissions(permissions, locale) {
const labels = permissions.map((p) => p.label);
return labels.length === 0
? "No permissions"
: `Permissions: ${formatConjunction(labels, locale)}`;
}
```
## API Reference
### `formatConjunction(values, locale, style?)`
Pre-bound `formatList({ type: 'conjunction', style })`. Style defaults to `'long'`. Inherits all error mapping (`INVALID_FORMATTER_OPTIONS`, `UNSUPPORTED_LOCALE`) and observability (histogram + cache-hit counter) from `formatList`.
### `formatDisjunction(values, locale, style?)`
Pre-bound `formatList({ type: 'disjunction', style })`. Style defaults to `'long'`. Same inheritance as `formatConjunction`.
## Implementation Notes
The helpers take `style` as a positional third argument rather than an options bag because `formatList`'s only meaningful per-call option (apart from `type`, which is fixed by the helper) is `style`. Keeping the helper signature lean makes the call site read as "what kind of join, in which language, in which style" — a natural sentence shape.
The two helpers are intentionally simple wrappers because the slim core (Wave 6) wires `formatList` (not the helpers) into the default `FormatBridge` for ICU `{values, list, type=conjunction}` placeholder rendering. Adding more transformation logic in the helpers would create drift between what the slim core renders and what direct callers see.

View File

@@ -0,0 +1,91 @@
# ListFormatter Observability
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Per-package OpenTelemetry instruments for `@suaveplan/i18n-formatter-list`. Exposes a histogram (`i18n.format.list.duration_ms`) recording wall-clock duration of every `formatList` call (tagged with `locale`, `style`, and `type`) and a counter (`i18n.format.cache_hit`) tagged with `formatter: "list"` that increments on every cache reuse.
The list formatter introduces these instruments as part of the Wave 3 uplift — the monolith currently emits no OTel for list formatting. The two instruments are constructed once via `metrics.getMeter` and are lazily initialised on first record so cold-start latency stays low.
## Features
- Lazy instrument construction
- Stable histogram name `i18n.format.list.duration_ms` with attributes `{ locale, style, type }`
- Stable counter name `i18n.format.cache_hit` with attributes `{ formatter: "list" }`
- In-memory sample ring (`listDurationSamples`, `cacheHitSamples`)
- `resetListFormatMetricsState()` clears both arrays so tests stay isolated
## Usage: Basic Example
```typescript
import {
recordListFormat,
recordListCacheHit,
getListFormatMetricsState,
resetListFormatMetricsState,
} from "@suaveplan/i18n-formatter-list/observability";
resetListFormatMetricsState();
recordListFormat("en-GB", "long", "conjunction", 0.4);
recordListCacheHit();
const state = getListFormatMetricsState();
state.listDurationSamples.length; // 1
state.cacheHitSamples.length; // 1
```
## Usage: Advanced Example — Asserting OTel emission in a test
```typescript
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
formatList,
clearListFormatterCache,
} from "@suaveplan/i18n-formatter-list";
import {
getListFormatMetricsState,
resetListFormatMetricsState,
} from "@suaveplan/i18n-formatter-list/observability";
describe("metrics emission", () => {
it("emits duration histogram and cache-hit counter", { timeout: 5000 }, () => {
clearListFormatterCache();
resetListFormatMetricsState();
formatList(["a", "b"], "en-GB");
formatList(["a", "b"], "en-GB");
const state = getListFormatMetricsState();
expect(state.listDurationSamples).toHaveLength(2);
expect(state.cacheHitSamples).toHaveLength(1);
});
});
```
## API Reference
### `recordListFormat(locale, style, type, durationMs): void`
Records one observation on the `i18n.format.list.duration_ms` histogram and pushes a `{ attributes: { locale, style, type } }` entry into the in-memory sample ring.
### `recordListCacheHit(): void`
Adds 1 to the `i18n.format.cache_hit` counter with `formatter: "list"` and pushes a matching entry into the in-memory sample ring.
### `getListFormatMetricsState(): ListFormatMetricsState`
Returns the in-memory sample ring (live reference — do not mutate).
### `resetListFormatMetricsState(): void`
Empties both sample arrays in place.
### `ListFormatMetricsState`
`{ listDurationSamples: FormatMetricSample[]; cacheHitSamples: FormatMetricSample[] }` where `FormatMetricSample = { attributes: Record<string, string | number> }`.
## Implementation Notes
The instruments are built lazily so consumers who never call `formatList` 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 `resetListFormatMetricsState` calls.
This module is one of two Wave 3 changes that introduce a *new* OTel surface — the monolith's `formatting/list/` module emits nothing, while `formatting/number/` and `formatting/relative-time/` already emit through the shared `observability/format-metrics.ts` module. The Wave 3 split brings every formatter package up to the same canonical observability shape so the slim core (Wave 6) can dashboard the entire family with a single regex on `i18n.format.*`.