Move docs/ package mirror into document-navigator plugin

Relocate the ~6k-file docs tree from the repo root into
plugins/document-navigator/docs/ so the plugin is self-contained and
portable. docs/superpowers/ (specs and plans) stays at the repo root.

Moved via cp+rm rather than git mv because directory rename() fails with
EPERM on this WSL /mnt DrvFs mount; git rename-detection pairs the
identical-content files at commit time.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-30 01:10:00 +01:00
parent fe3fedf42e
commit 9bf56a806b
6022 changed files with 12437 additions and 0 deletions

View File

@@ -0,0 +1,31 @@
# `i18n`
Internationalization core, backends, detectors, and locale resolution.
<!-- begin:auto-docs -->
## Packages in `i18n`
19 packages.
| Package | Description |
|---|---|
| [`@suaveplan/i18n`](./i18n/README.md) | Slim integration core for the i18n family. After the Wave 6 modular split, this package owns the `MessageCatalog`, the `createI18n` / `I18nInstance` plugin slo… |
| [`@suaveplan/i18n-backend-chained`](./i18n-backend-chained/README.md) | Universal chained backend orchestrator for the `@suaveplan/i18n` family. Composes N backends into a single `Backend` with read-fallthrough on `BACKEND_LOCALE_N… |
| [`@suaveplan/i18n-backend-fs-multifile`](./i18n-backend-fs-multifile/README.md) | Server-tier multi-file Node fs/promises translation backend for the @suaveplan/i18n family. Reads <root>/<locale>/<namespace>.<ext> (nested), <root>/<locale>.<namespace>.<ext> (flat), or a caller-supplied glob pattern. JSON built-in; YAML and JSON5 auto-detected via dynamic import. Optional fs.watch loop emits loaded events through the catalog event bus. AbortSignal cancellation, OpenTelemetry instrumentation under i18n.backend.fs_multifile.*. Server-only — uses node:fs/promises; browser consumers should use @suaveplan/i18n-backend-http or @suaveplan/i18n-backend-storage. |
| [`@suaveplan/i18n-backend-http`](./i18n-backend-http/README.md) | Universal translation backend for the `@suaveplan/i18n` family, backed by an injectable `fetch`. Read-only by design: maps HTTP 404 to `BACKEND_LOCALE_NOT_AVAI… |
| [`@suaveplan/i18n-backend-memory`](./i18n-backend-memory/README.md) | Universal in-memory translation backend for the `@suaveplan/i18n` family. Backed by `Map<locale, Map<namespace, TranslationRecord>>` plus a modification ledger… |
| [`@suaveplan/i18n-backend-storage`](./i18n-backend-storage/README.md) | Universal translation backend for the `@suaveplan/i18n` family, backed by any `AbstractStorage` implementation from `@suaveplan/storage`. Implements the full `… |
| [`@suaveplan/i18n-detector`](./i18n-detector/README.md) | Universal locale-detection chain for the `@suaveplan/i18n` family. Ships the chain orchestrator, the BCP-47 default negotiator, the `getTextDirection` / `isRtl… |
| [`@suaveplan/i18n-formatter-collator`](./i18n-formatter-collator/README.md) | Wrapper around `Intl.Collator` for the `@suaveplan/i18n` family — locale-aware string comparison and sorting (sensitivity, numeric-aware natural sort, case-fir… |
| [`@suaveplan/i18n-formatter-currency`](./i18n-formatter-currency/README.md) | Locale-aware currency formatting backed by `Intl.NumberFormat` with `style: "currency"`. Wave 3 of the 14-package i18n modular split. Provides both a class (`C… |
| [`@suaveplan/i18n-formatter-date`](./i18n-formatter-date/README.md) | Wrapper around `Intl.DateTimeFormat` for the `@suaveplan/i18n` family — 8-preset table (short/medium/long/full × date+time/time-only/date-only), range renderin… |
| [`@suaveplan/i18n-formatter-display-name`](./i18n-formatter-display-name/README.md) | Wrapper around `Intl.DisplayNames` for the `@suaveplan/i18n` family — locale-aware language, region, script, currency, and calendar display names. Universal le… |
| [`@suaveplan/i18n-formatter-list`](./i18n-formatter-list/README.md) | 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 he… |
| [`@suaveplan/i18n-formatter-number`](./i18n-formatter-number/README.md) | Locale-aware number formatting backed by `Intl.NumberFormat`. Wave 3 of the 14-package i18n modular split. Provides both a class (`NumberFormatter`) and a stan… |
| [`@suaveplan/i18n-formatter-relative-time`](./i18n-formatter-relative-time/README.md) | Locale-aware relative-time formatting backed by `Intl.RelativeTimeFormat`. Wave 3 of the 14-package i18n modular split. Provides both a class (`RelativeTimeFor… |
| [`@suaveplan/i18n-formatter-segmenter`](./i18n-formatter-segmenter/README.md) | Wrapper around `Intl.Segmenter` for the `@suaveplan/i18n` family — locale-aware text segmentation by grapheme, word, or sentence. Compound emoji count as a sin… |
| [`@suaveplan/i18n-icu-parser`](./i18n-icu-parser/README.md) | ICU MessageFormat parser, `{{var}}` interpolation engine, and 9-language CLDR pluralization for the `@suaveplan/i18n` family. Universal leaf package — depends… |
| [`@suaveplan/i18n-shared`](./i18n-shared/README.md) | Shared runtime atoms for the entire `@suaveplan/i18n*` package family — locale constants, BCP-47 normalisation map, RTL set, locale descriptors, interpolation… |
| [`@suaveplan/locale-data`](./locale-data/README.md) | Static ISO 3166-1 country data and ISO 639-1 language data with typed lookup functions, OTel instrumentation, and Zod schemas. |
| [`@suaveplan/react-i18n`](./react-i18n/README.md) | React bindings for `@suaveplan/i18n`. Ships the `<TranslationProvider>`, twelve hooks, four headless components, the class-component HOC, the nine built-in loc… |
<!-- end:auto-docs -->

View File

@@ -0,0 +1 @@
Internationalization core, backends, detectors, and locale resolution.

View File

@@ -0,0 +1,45 @@
# @suaveplan/i18n-backend-chained - Universal chained backend orchestrator for the @suaveplan/i18n family. Composes N backends into a single Backend with read-fallthrough on BACKEND_LOCALE_NOT_AVAILABLE, write-through cache semantics after a successful read, and concurrent fan-out for readMulti with first-wins / last-wins merge. Transport failures (BACKEND_READ_FAILED) propagate immediately to stop the chain. Emits OpenTelemetry instrumentation under i18n.backend.chained.* including a cache_hit/cache_miss/primary outcome attribute on the read counter.
## Summary
Universal chained backend orchestrator for the `@suaveplan/i18n` family. Composes N backends into a single `Backend` with read-fallthrough on `BACKEND_LOCALE_NOT_AVAILABLE`, write-through cache semantics after a successful read, and concurrent fan-out for `readMulti` with `first-wins` (default) or `last-wins` merge strategy.
The chained backend is the canonical wiring for offline-first applications: register an in-memory or storage cache at index 0 and a network source of truth (`HttpBackend`) at the last index. The chain checks the cache first, falls through on miss, and writes the resolved record back to the cache so subsequent reads avoid the network round-trip. Transport failures (`BACKEND_READ_FAILED`) propagate immediately to stop the chain — silent degradation across an HTTP outage is not a feature.
OpenTelemetry instrumentation is emitted under `i18n.backend.chained.*`. The read counter carries an extended outcome attribute (`cache_hit | cache_miss | primary`) on top of the base `hit | miss | error` set so production teams can size cache layers correctly. A `fallthrough_total{from_backend, to_backend}` counter tracks per-link fallthrough rates.
<!-- begin:auto-docs -->
## Documentation tree
- [backend](./src/backend/backend.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [backend](./src/backend/backend.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
<!-- end:auto-docs --> - [backend](./src/backend/backend.md) — <!-- begin:auto-docs --> _(no submodules)_- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_- [backend](./src/backend/backend.md) — <!-- begin:auto-docs --> _(no submodules)_- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_- [backend](./src/backend/backend.md) — <!-- begin:auto-docs --> _(no submodules)_- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_- [backend](./src/backend/backend.md) — <!-- begin:auto-docs --> _(no submodules)_- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_ - [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_ - [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_
## Modules
| Module | Path | Doc |
|---|---|---|
| `ChainedBackend` class + Zod schema | `src/backend/backend.ts` | [`backend.md`](./src/backend/backend.md) |
| `chainedBackend` factory | `src/factory/factory.ts` | [`factory.md`](./src/factory/factory.md) |
| OTel instruments + state ring | `src/observability/observability.ts` | [`observability.md`](./src/observability/observability.md) |
## Subpath exports
- `@suaveplan/i18n-backend-chained` — root barrel
- `@suaveplan/i18n-backend-chained/backend` — `ChainedBackend` class + types + schema
- `@suaveplan/i18n-backend-chained/factory` — `chainedBackend` factory
- `@suaveplan/i18n-backend-chained/observability` — OTel instruments + state ring
## Install
```sh
bun add @suaveplan/i18n-backend-chained
```
## License
MIT — see repository root.

View File

@@ -0,0 +1,110 @@
# `ChainedBackend`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`ChainedBackend` composes N backends into a single `Backend` with read-fallthrough on `BACKEND_LOCALE_NOT_AVAILABLE` and write-through cache semantics after a successful read. It is the canonical wiring for offline-first applications: register a fast cache (in-memory `MemoryBackend` or `StorageBackend(OPFSStorage)`) at index 0 and a source-of-truth backend (`HttpBackend`) at the last index. The chain checks the cache first, falls through to the next backend on a cache miss, then writes the resolved record back to the cache so subsequent reads avoid the network round-trip.
Transport failures (`BACKEND_READ_FAILED`) propagate immediately to stop the chain — silent degradation across an HTTP outage is a bug, not a feature. Only `BACKEND_LOCALE_NOT_AVAILABLE` (the resource is genuinely missing from a backend) triggers fallthrough.
## Features
- Read-fallthrough on `BACKEND_LOCALE_NOT_AVAILABLE`; immediate propagation on `BACKEND_READ_FAILED`.
- Write-through cache after a successful read from index N to indices 0..N-1 (best-effort; per-backend save failures are swallowed and never block the read return).
- `readMulti` concurrent fan-out across every backend with `first-wins` (default) or `last-wins` merge strategy; gaps are filled from the other side regardless of strategy.
- `save` fan-out to every save-capable child; failures surface as `BACKEND_WRITE_FAILED` with `cause` preserved.
- `lastModified` returns the first defined timestamp across children.
- `init` forwards in registration order; `dispose` runs in reverse order — both safely callable multiple times.
- Capabilities matrix derived from the union of child capabilities (`canRead: true` always; `canSave`, `canTrackModification`, `canWatch` follow the union).
- AbortSignal threading on `read`, `readMulti`, and `save`. A pre-aborted signal throws `BACKEND_TIMEOUT` without calling any child.
- OpenTelemetry instrumentation under `i18n.backend.chained.*`. The read counter carries an outcome attribute of `cache_hit | cache_miss | primary` so production teams can size cache layers correctly. A `fallthrough_total{from_backend, to_backend}` counter tracks per-link fallthrough rates.
- New `CHAINED_BACKEND_ALL_FAILED` error code thrown defensively when the chain is empty at runtime (the constructor's Zod schema already rejects empty arrays at construction time).
## Usage: Basic Example
```ts
import { ChainedBackend } from "@suaveplan/i18n-backend-chained/backend";
import { memoryBackend } from "@suaveplan/i18n-backend-memory";
import { httpBackend } from "@suaveplan/i18n-backend-http";
const chain = new ChainedBackend({
backends: [
memoryBackend(),
httpBackend({ loadPath: "/locales/{locale}/{namespace}.json" }),
],
});
const record = await chain.read("en", "auth");
// On miss the http backend served the request; the memory backend now
// caches the record so subsequent reads hit memory.
```
## Usage: Advanced Example
```ts
import { ChainedBackend } from "@suaveplan/i18n-backend-chained/backend";
import { memoryBackend } from "@suaveplan/i18n-backend-memory";
import { httpBackend } from "@suaveplan/i18n-backend-http";
const controller = new AbortController();
const chain = new ChainedBackend({
backends: [
memoryBackend({ name: "memory-cache" }),
httpBackend({
name: "https-source",
loadPath: "https://i18n.example.com/{locale}/{namespace}.json",
}),
],
name: "offline-first",
mergeStrategy: "first-wins",
});
// AbortSignal threading: the chain throws BACKEND_TIMEOUT immediately
// when the signal is already aborted, and threads the signal into
// every child call when the child accepts it.
const merged = await chain.readMulti(
"en",
["auth", "common", "billing"],
controller.signal
);
// Capabilities come from the union of children — memory has canSave,
// http does not, so the chain reports canSave: true.
chain.capabilities.canSave; // true
```
## API Reference
### `class ChainedBackend implements Backend`
#### Constructor
`new ChainedBackend(options: ChainedBackendOptions)`
- `options.backends: readonly Backend[]` — required, non-empty.
- `options.name?: string` — defaults to `"chained"`.
- `options.mergeStrategy?: "first-wins" | "last-wins"` — defaults to `"first-wins"`.
Throws `I18nError(INVALID_OPTIONS)` when `backends` is empty or any field fails validation.
#### Instance methods
- `read(locale, namespace, signal?): Promise<TranslationRecord>` — sequential fallthrough on `BACKEND_LOCALE_NOT_AVAILABLE`; propagates `BACKEND_READ_FAILED` immediately; throws `BACKEND_TIMEOUT` on a pre-aborted signal; throws `CHAINED_BACKEND_ALL_FAILED` when the chain is empty at runtime.
- `readMulti(locale, namespaces, signal?): Promise<Record<string, TranslationRecord>>` — concurrent fan-out + merge per strategy; per-backend errors are swallowed.
- `save(locale, namespace, record, signal?): Promise<void>` — fan-out to every save-capable child; failures surface as `BACKEND_WRITE_FAILED`.
- `lastModified(locale, namespace): Promise<number | undefined>` — first defined timestamp across children.
- `init(context): Promise<void>` — forwards in registration order.
- `dispose(): Promise<void>` — reverse-order forward.
### `ChainedBackendOptionsSchema`
Zod schema validating the constructor options shape. Rejects empty `backends` arrays.
## Implementation Notes
- The `read` outcome attribute is computed by `#classifyReadOutcome(successIndex)`. Single-backend chains report `primary`; multi-backend chains report `cache_hit` (index 0 served the request) or `cache_miss` (a later backend served the request after fallthrough). On total miss, the outcome is `miss`; on `BACKEND_READ_FAILED` propagation, the outcome is `error`.
- `init` and `dispose` are safe to call multiple times — both methods iterate the children and forward the lifecycle event without keeping internal state.
- `writeThrough` is intentionally best-effort: a failing cache layer should never block the read return. Failures are swallowed silently. Production observability comes from the `i18n.backend.chained.write_total{outcome=error}` counter incremented by the `save` path (not the write-through path).
- The `Backend` interface in `@suaveplan/types` does not declare a `signal` parameter on `read` / `readMulti` / `save`, but concrete backends (HTTP, sodium-backed remote) accept one. The chained backend threads `signal` to children via a structural cast — children that ignore the third argument receive normal positional arguments only.

View File

@@ -0,0 +1,68 @@
# `chainedBackend` factory
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`chainedBackend` is a builder-style alternative to `new ChainedBackend(options)`. It returns a fresh `ChainedBackend` per call with no internal singleton or shared state. The factory accepts either an array shorthand for the common "just give me this list of backends" case or a full options object when callers want to override `name` or `mergeStrategy`.
This separation between class constructor and factory function lets consumers write composable code (`pipe(loadConfig, mapBackends, chainedBackend)`) without sprinkling `new` keywords through their initialisation pipeline. It also pairs naturally with the `httpBackend` / `memoryBackend` / `storageBackend` factories from sibling packages so an entire i18n backend chain can be expressed as a single nested function call.
## Features
- Array shorthand: `chainedBackend([memoryBackend(), httpBackend({...})])` — uses default `name: "chained"` and `mergeStrategy: "first-wins"`.
- Options form: `chainedBackend({ backends, name, mergeStrategy })` — passes options straight through to the constructor.
- No mutation of the input array — the factory wraps the array in a new options object before calling the constructor.
- Returns a fresh `ChainedBackend` per call (no caching, no singleton).
- Throws `I18nError(INVALID_OPTIONS)` if the resulting options fail Zod validation (empty `backends` array, etc.).
## Usage: Basic Example
```ts
import { chainedBackend } from "@suaveplan/i18n-backend-chained/factory";
import { memoryBackend } from "@suaveplan/i18n-backend-memory";
import { httpBackend } from "@suaveplan/i18n-backend-http";
const chain = chainedBackend([
memoryBackend(),
httpBackend({ loadPath: "/locales/{locale}/{namespace}.json" }),
]);
await chain.read("en", "auth");
```
## Usage: Advanced Example
```ts
import { chainedBackend } from "@suaveplan/i18n-backend-chained/factory";
import { memoryBackend } from "@suaveplan/i18n-backend-memory";
import { httpBackend } from "@suaveplan/i18n-backend-http";
const chain = chainedBackend({
backends: [
memoryBackend({ name: "memory-cache" }),
httpBackend({
name: "https-source",
loadPath: "https://i18n.example.com/{locale}/{namespace}.json",
}),
],
name: "production-chain",
mergeStrategy: "last-wins",
});
chain.name; // "production-chain"
```
## API Reference
`chainedBackend(backendsOrOptions: readonly Backend[] | ChainedBackendOptions): ChainedBackend`
- `backendsOrOptions` — either an array of backends (array shorthand) or a full `ChainedBackendOptions` object. The function uses `Array.isArray` to discriminate at runtime.
Returns a fresh `ChainedBackend` instance. Throws `I18nError(INVALID_OPTIONS)` on validation failure.
## Implementation Notes
- The factory is a thin wrapper around `new ChainedBackend(options)`. All validation happens in the constructor's Zod schema; the factory itself adds no logic beyond array-vs-options discrimination.
- The factory is the recommended ergonomic surface for application code; the class is exported for libraries that need to subclass or extend the chain. Both surfaces produce identical `ChainedBackend` instances.

View File

@@ -0,0 +1,73 @@
# Chained-backend observability
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module owns every OpenTelemetry instrument and the in-memory state ring used by `@suaveplan/i18n-backend-chained`. Production code paths in `ChainedBackend` call `recordRead`, `recordWrite`, and `recordFallthrough`; tests reset and inspect the in-memory state ring via `resetChainedBackendMetricsState` and `getChainedBackendMetricsState`. The instruments are built lazily on first call so consumers without an OTel `MeterProvider` registered pay zero overhead.
The emitted metric prefix is `i18n.backend.chained.*`, and every counter and histogram carries a `backend` attribute so multi-chain deployments can disambiguate per-instance metrics. The chained backend's `read_total` counter additionally carries an `outcome` attribute drawn from the extended set `hit | miss | error | cache_hit | cache_miss | primary` — the latter three values are the chained-backend's contribution beyond the base outcome set used by leaf backends.
## Features
- Lazy meter construction; idempotent `getChainedBackendMetrics()`.
- Six instruments: `read_duration_ms` (histogram), `read_total{outcome}` (counter), `read_errors_total{code}` (counter), `write_duration_ms` (histogram), `write_total{outcome}` (counter), `fallthrough_total{from_backend, to_backend}` (counter).
- In-memory state ring for spec-conformance tests — avoids standing up a full SDK pipeline.
- `recordRead` / `recordWrite` / `recordFallthrough` are the only API surface production code calls.
- `resetChainedBackendMetricsState` clears the ring; `resetChainedBackendMetricsCache` drops cached instruments so a fresh meter can be built (test-only).
## Usage: Basic Example
```ts
import {
getChainedBackendMetrics,
recordRead,
} from "@suaveplan/i18n-backend-chained/observability";
// Lazy-build instruments on first use.
const ins = getChainedBackendMetrics();
// Production hook — emit a hit on the cache layer at index 0.
recordRead("chained", "en", "auth", "cache_hit", 0.42);
```
## Usage: Advanced Example
```ts
import {
getChainedBackendMetricsState,
recordFallthrough,
recordWrite,
resetChainedBackendMetricsCache,
resetChainedBackendMetricsState,
} from "@suaveplan/i18n-backend-chained/observability";
resetChainedBackendMetricsCache();
resetChainedBackendMetricsState();
recordWrite("chained", "ok", 1.23);
recordFallthrough("memory-cache", "https-source");
const state = getChainedBackendMetricsState();
state.writeTotal.length; // 1
state.fallthroughTotal[0]?.attributes; // { from_backend: "memory-cache", to_backend: "https-source" }
```
## API Reference
- `getChainedBackendMetrics(): CachedInstruments` — lazy build / cached fetch.
- `recordRead(backend, locale, namespace, outcome, durationMs, errorCode?)` — emit one read sample.
- `recordWrite(backend, outcome, durationMs)` — emit one write sample.
- `recordFallthrough(fromBackend, toBackend)` — emit one fallthrough sample.
- `getChainedBackendMetricsState(): ChainedBackendMetricsState` — read the in-memory state ring.
- `resetChainedBackendMetricsState(): void` — clear the ring.
- `resetChainedBackendMetricsCache(): void` — drop cached instruments (test-only).
- `ChainedReadOutcome` — `"hit" | "miss" | "error" | "cache_hit" | "cache_miss" | "primary"`.
- `ChainedWriteOutcome` — `"ok" | "error"`.
## Implementation Notes
- The `fallthrough_total` counter is bounded in cardinality by the number of unique chain links observed in production — typically O(N) for a chain of N backends, never proliferating beyond the registered set. Consumers configuring per-tenant chains should keep `from_backend` / `to_backend` low-cardinality (avoid embedding tenant ids in backend names).
- The in-memory state ring grows unbounded in long-running tests. Always call `resetChainedBackendMetricsState()` between cases.
- The OTel `Counter` and `Histogram` instruments are no-ops when no `MeterProvider` is registered — the default. Production wiring registers a SDK + exporter at process start.

View File

@@ -0,0 +1,57 @@
# @suaveplan/i18n-backend-fs-multifile - Server-tier multi-file Node fs/promises translation backend for the @suaveplan/i18n family. Reads <root>/<locale>/<namespace>.<ext> (nested), <root>/<locale>.<namespace>.<ext> (flat), or a caller-supplied glob pattern. JSON built-in; YAML and JSON5 auto-detected via dynamic import. Optional fs.watch loop emits loaded events through the catalog event bus. AbortSignal cancellation, OpenTelemetry instrumentation under i18n.backend.fs_multifile.*. Server-only — uses node:fs/promises; browser consumers should use @suaveplan/i18n-backend-http or @suaveplan/i18n-backend-storage.
## Summary
> **Server-tier package** — uses `node:fs/promises`. For browser consumers, use
> [`@suaveplan/i18n-backend-http`](../i18n-backend-http) or
> [`@suaveplan/i18n-backend-storage`](../i18n-backend-storage).
Server-tier translation backend for the `@suaveplan/i18n` family. Reads
locale + namespace catalogs from the local filesystem in three wire layouts,
optionally watches the root directory for changes, and emits `loaded` events
into the catalog event bus so React Suspense / HMR consumers refresh without
polling. JSON is built in; YAML and JSON5 are auto-detected via dynamic
`import()` so the package stays lean and the consumer picks the parser.
The backend implements the universal `Backend` contract from
`@suaveplan/types` — capabilities matrix `{ canRead, canReadMulti: false,
canSave, canTrackModification, canWatch }` — and conforms to the
`runBackendConformance` harness shipped from
`@suaveplan/i18n-shared/testing`. Errors are surfaced via `I18nError` from
`@suaveplan/i18n-shared`: `ENOENT`/`ENOTDIR` map to
`BACKEND_LOCALE_NOT_AVAILABLE` (so a chained-backend can fall through to
the next layer), other errno codes map to `BACKEND_READ_FAILED`, and a
pre-aborted `AbortSignal` short-circuits with `BACKEND_TIMEOUT` without
touching the filesystem. OpenTelemetry instruments are emitted under the
`i18n.backend.fs_multifile.*` prefix with a `layout` attribute.
<!-- begin:auto-docs -->
## Documentation tree
- [backend](./src/backend/backend.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [backend](./src/backend/backend.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
<!-- end:auto-docs --> - [backend](./src/backend/backend.md) — <!-- begin:auto-docs --> _(no submodules)_- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_- [backend](./src/backend/backend.md) — <!-- begin:auto-docs --> _(no submodules)_- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_- [backend](./src/backend/backend.md) — <!-- begin:auto-docs --> _(no submodules)_- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_- [backend](./src/backend/backend.md) — <!-- begin:auto-docs --> _(no submodules)_- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_ - [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_ - [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_
## Modules
| Module | Doc |
|---|---|
| `MultiFileFsBackend` class + Zod options schema | [`src/backend/backend.md`](./src/backend/backend.md) |
| `multiFileFsBackend()` factory | [`src/factory/factory.md`](./src/factory/factory.md) |
| OTel instruments + state ring | [`src/observability/observability.md`](./src/observability/observability.md) |
## Quick example
```ts
import { multiFileFsBackend } from "@suaveplan/i18n-backend-fs-multifile";
const backend = multiFileFsBackend({ root: "./locales", layout: "nested" });
const record = await backend.read("en", "auth");
```
See the linked module docs for the advanced examples (watch mode, glob
layout, YAML peer detection, AbortSignal cancellation).

View File

@@ -0,0 +1,125 @@
# `backend.ts` — `MultiFileFsBackend`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Server-tier translation `Backend` that reads and writes
`<root>/<locale>/<namespace>.<ext>` (or one of the alternate `flat` /
`glob` layouts) on the local filesystem via `node:fs/promises`. The
class implements the universal `Backend` contract from
`@suaveplan/types` and conforms to the
`runBackendConformance` harness shipped from
`@suaveplan/i18n-shared/testing`. It exists as its own package — and
not part of the universal `@suaveplan/i18n` core — because the
default code path performs `await import("node:fs/promises")` which
fails at module evaluation in browsers and non-Node runtimes that do
not expose Node built-ins.
## Features
- Three wire layouts: `nested` (default), `flat`, `glob` (with
`{locale}` / `{namespace}` substitution into a caller-supplied
pattern).
- `read` / `save` / `lastModified` over a `MultiFileFsLike` shim that
defaults to `node:fs/promises` and is fully injectable for tests.
- JSON built in. YAML and JSON5 are auto-detected via dynamic
`import()` so the package stays free of mandatory parser deps.
- Optional `fs.watch` loop emits `loaded` events into the
`BackendInitContext.emitLoaded` channel for HMR / Suspense refresh.
- Pre-aborted `AbortSignal` short-circuits with `BACKEND_TIMEOUT`
without touching the filesystem. ENOENT / ENOTDIR fall through as
`BACKEND_LOCALE_NOT_AVAILABLE`; other errno codes surface as
`BACKEND_READ_FAILED`.
- Zod-validated options at the public boundary — bad `layout` /
missing `globPattern` / non-string `name` rejected as
`INVALID_OPTIONS`.
## Usage: Basic Example
```ts
import { MultiFileFsBackend } from "@suaveplan/i18n-backend-fs-multifile";
const backend = new MultiFileFsBackend({ root: "./locales" });
const record = await backend.read("en", "auth");
// → { login: { title: "Sign in" } }
```
## Usage: Advanced Example
```ts
import { MultiFileFsBackend } from "@suaveplan/i18n-backend-fs-multifile";
const backend = new MultiFileFsBackend({
root: "./locales",
layout: "glob",
globPattern: "{locale}/{namespace}.json",
extension: "json",
watch: true,
});
await backend.init({
catalogName: "app",
emitLoaded: (locale, ns) => {
console.log(`reloaded ${locale}/${ns}`);
},
});
// AbortSignal short-circuits with BACKEND_TIMEOUT.
const ctrl = new AbortController();
ctrl.abort();
await backend.read("en", "auth", ctrl.signal); // throws I18N.BACKEND_TIMEOUT
// YAML peer auto-detection — install `yaml` to enable.
const yamlBackend = new MultiFileFsBackend({
root: "./locales",
extension: "yaml",
});
const fr = await yamlBackend.read("fr", "auth");
await backend.dispose();
```
## API Reference
- `new MultiFileFsBackend(options: MultiFileFsBackendOptions)`:
Constructs and validates options. Throws
`I18nError(I18N.INVALID_OPTIONS)` on Zod failure or when
`layout: "glob"` is supplied without `globPattern`.
- `read(locale, namespace, signal?)`: returns the parsed
`TranslationRecord`. Throws
`BACKEND_LOCALE_NOT_AVAILABLE` on ENOENT/ENOTDIR,
`BACKEND_READ_FAILED` on other fs / parse / unknown-extension /
missing-peer failures, `BACKEND_TIMEOUT` on a pre-aborted signal.
- `save(locale, namespace, record, signal?)`: ensures the parent
directory and writes the encoded payload. Throws
`BACKEND_WRITE_FAILED` on writeFile failure or unsupported
extension; `BACKEND_TIMEOUT` on pre-aborted signal.
- `lastModified(locale, namespace)`: returns `mtimeMs` or `undefined`
(never throws).
- `init(context)`: stores the `BackendInitContext` and starts the
`fs.watch` loop when `watch: true`.
- `dispose()`: aborts the watcher; safe to call multiple times.
- `MultiFileFsBackendOptionsSchema`: exported Zod schema for the
options bag.
## Implementation Notes
- A leading source-comment marks the package as server-tier so
consumers do not accidentally land it in a browser bundle. Wave 6's
slim universal `@suaveplan/i18n` core does NOT re-export this
package.
- The `#getFs` method dynamically imports `node:fs/promises` only
when no `fs` shim was injected, keeping injected-fs tests
hermetic and avoiding the cost when consumers supply their own
filesystem.
- `parsePayload` routes JSON through built-in `JSON.parse`. Unknown
extensions throw `BACKEND_READ_FAILED` with the extension name in
the error message and context. Peer parsers (`yaml`, `json5`) are
loaded via dynamic `import()`; missing peers surface as
`BACKEND_READ_FAILED` with `{ peer }` in the context so consumers
can install the right package.
- `joinPath` collapses leading and trailing slashes so
`root: ""` (relative to cwd) and `root: "/abs"` both produce well-formed
paths.

View File

@@ -0,0 +1,72 @@
# `factory.ts` — `multiFileFsBackend()`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Builder-style entry point that returns a fresh `MultiFileFsBackend`
per call. Functionally identical to `new MultiFileFsBackend(options)`,
but reads naturally in pipelines and configuration objects where the
caller is composing a backend chain alongside `memoryBackend()`,
`storageBackend()`, `httpBackend()`, and `chainedBackend()` — every
backend in the family ships a matching factory function for symmetry.
The factory has no internal state — every invocation returns a brand-new
instance. Options are validated by the same Zod schema as the
constructor; bad options throw `I18nError(I18N.INVALID_OPTIONS)`. There
is no caching, no singleton, and no hidden configuration: the factory
exists purely to flip the calling style from `new C(opts)` to
`factory(opts)` for ergonomic chain composition.
## Features
- Returns a fresh `MultiFileFsBackend` on every call.
- Forwards all options unchanged into the class constructor.
- Same validation surface — bad options throw `INVALID_OPTIONS`.
- Zero shared state, zero side effects.
## Usage: Basic Example
```ts
import { multiFileFsBackend } from "@suaveplan/i18n-backend-fs-multifile";
const backend = multiFileFsBackend({ root: "./locales" });
```
## Usage: Advanced Example
```ts
import { chainedBackend } from "@suaveplan/i18n-backend-chained";
import { multiFileFsBackend } from "@suaveplan/i18n-backend-fs-multifile";
import { memoryBackend } from "@suaveplan/i18n-backend-memory";
// Server-side: hot in-memory cache → fall through to disk on miss.
const chain = chainedBackend([
memoryBackend(),
multiFileFsBackend({ root: "./locales", watch: true }),
]);
await chain.init({
catalogName: "app",
emitLoaded: () => {},
});
const record = await chain.read("en", "auth");
```
## API Reference
- `multiFileFsBackend(options: MultiFileFsBackendOptions): MultiFileFsBackend`:
returns a new `MultiFileFsBackend` instance. Throws
`I18nError(I18N.INVALID_OPTIONS)` on schema failure or when
`layout: "glob"` is supplied without `globPattern`.
## Implementation Notes
- The factory is a thin wrapper — the line count is intentional. The
factory exists for symmetry with the rest of the i18n backend
family, where every concrete backend ships both a class export and
a `<thing>Backend()` factory function.
- The factory is server-tier — declaring this package's `engines.node`
at the package boundary covers the factory and the class equally.

View File

@@ -0,0 +1,92 @@
# `observability.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Self-contained OpenTelemetry instrument set for the
`@suaveplan/i18n-backend-fs-multifile` package. The module wires every
public counter and histogram emitted by `MultiFileFsBackend` and exposes
an in-memory state ring used by spec-conformance tests so production
code paths can be exercised without standing up a full OTel SDK.
The module is loaded lazily by `getMultiFileFsBackendMetrics()` — the
first call to any production path (`recordRead`, `recordWrite`) builds a
single shared `Meter` and caches the resulting `Histogram`/`Counter`
instances. Subsequent calls hit the cache. When no `MeterProvider` is
registered (the default in production), every instrument is a no-op,
keeping the cost path-neutral when telemetry is off.
## Features
- Lazily-built histograms and counters under the
`i18n.backend.fs_multifile.*` prefix.
- Five instruments: `read_duration_ms`, `read_total`,
`read_errors_total`, `write_duration_ms`, `write_total`.
- Every emission carries a `backend` label plus a `layout` label
(`nested` / `flat` / `glob`) so consumers can size watchers per
layout.
- In-memory state ring (`getMultiFileFsBackendMetricsState`,
`resetMultiFileFsBackendMetricsState`) so spec-conformance tests can
assert exact attribute payloads without an exporter.
- `resetMultiFileFsBackendMetricsCache` test hook to drop the cached
instruments between cases when a different MeterProvider is required.
## Usage: Basic Example
```ts
import {
recordRead,
recordWrite,
} from "@suaveplan/i18n-backend-fs-multifile/observability";
recordRead("multi-file-fs", "en", "auth", "nested", "hit", 0.42);
recordWrite("multi-file-fs", "nested", "ok", 0.31);
```
## Usage: Advanced Example
```ts
import {
getMultiFileFsBackendMetricsState,
resetMultiFileFsBackendMetricsCache,
resetMultiFileFsBackendMetricsState,
} from "@suaveplan/i18n-backend-fs-multifile/observability";
resetMultiFileFsBackendMetricsCache();
resetMultiFileFsBackendMetricsState();
// ... run code that hits the backend ...
const state = getMultiFileFsBackendMetricsState();
const outcomes = state.readTotal.map((s) => s.attributes["outcome"]);
expect(outcomes).toContain("hit");
```
## API Reference
- `recordRead(backend, locale, namespace, layout, outcome, durationMs, errorCode?)`:
emits the `read_duration_ms` histogram and `read_total` counter; if
`errorCode` is supplied, also increments `read_errors_total{code,layout}`.
- `recordWrite(backend, layout, outcome, durationMs)`: emits the
`write_duration_ms` histogram and `write_total` counter.
- `getMultiFileFsBackendMetrics()`: lazy build / return cached
instruments — idempotent.
- `getMultiFileFsBackendMetricsState()`: return the in-memory state
ring snapshot.
- `resetMultiFileFsBackendMetricsState()`: clear the in-memory state
ring.
- `resetMultiFileFsBackendMetricsCache()`: drop the cached instruments
so a fresh MeterProvider can take over (test-only escape hatch).
## Implementation Notes
- The `layout` attribute is mandatory on every counter and histogram so
operators can split SLOs per wire layout — `glob` patterns tend to
hide expensive resolutions behind dynamic substitution.
- The state ring is unbounded by design and is the test-only path. The
`resetMultiFileFsBackendMetricsState` helper is the supported way to
prevent ring growth across tests.
- The OTel meter is named `suaveplan.i18n-backend-fs-multifile` so
consumers can apply per-package views.

View File

@@ -0,0 +1,96 @@
# @suaveplan/i18n-backend-http - Universal translation backend backed by injectable fetch. Read-only; maps HTTP 404 to BACKEND_LOCALE_NOT_AVAILABLE (chain fallthrough) and HTTP 5xx / network / parse failures to BACKEND_READ_FAILED (chain stop). Pluggable parsePayload + customHeaders + retry policy + AbortSignal cancellation, OpenTelemetry instrumentation under i18n.backend.http.*. Zero runtime dependencies on @suaveplan/i18n core.
## Summary
Universal translation backend for the `@suaveplan/i18n` family,
backed by an injectable `fetch`. Read-only by design: maps HTTP 404 to
`BACKEND_LOCALE_NOT_AVAILABLE` (the chained-backend fallthrough signal)
and HTTP 5xx, network failures, and parse failures to
`BACKEND_READ_FAILED` (a chain-stopping signal that prevents silent
degradation when the transport is broken). Pluggable `parsePayload`,
`customHeaders`, `requestOptions`, `crossDomain` shorthand, retry-policy
injection (structurally compatible with `@suaveplan/retry`'s
`RetryPolicy`), `AbortSignal` cancellation, and OpenTelemetry
instrumentation under `i18n.backend.http.*`. Zero runtime dependency on
`@suaveplan/i18n` core.
<!-- begin:auto-docs -->
## Documentation tree
- [backend](./src/backend/backend.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [backend](./src/backend/backend.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
<!-- end:auto-docs --> - [backend](./src/backend/backend.md) — <!-- begin:auto-docs --> _(no submodules)_- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_- [backend](./src/backend/backend.md) — <!-- begin:auto-docs --> _(no submodules)_- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_- [backend](./src/backend/backend.md) — <!-- begin:auto-docs --> _(no submodules)_- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_- [backend](./src/backend/backend.md) — <!-- begin:auto-docs --> _(no submodules)_- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_ - [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_ - [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_
## Why this exists
The Wave 5 modular split lifts each backend out of the
`@suaveplan/i18n` monolith into its own sibling so consumers can install
only the backends they need. The HTTP backend is the canonical
production source for browser-tier and edge consumers fetching
translations from a CDN, an internal i18n service, or a pre-signed
storage URL. Keeping it in its own package avoids dragging
`@suaveplan/storage` (storage backend) or `node:fs` (multifile-fs
backend) into browser bundles.
## Install
```bash
bun add @suaveplan/i18n-backend-http
```
## Quick Start
```ts
import { httpBackend } from "@suaveplan/i18n-backend-http";
const backend = httpBackend({
loadPath: "/i18n/{locale}/{namespace}.json",
});
const record = await backend.read("en", "auth");
```
## Modules
| Subpath | Module |
| --- | --- |
| `@suaveplan/i18n-backend-http/backend` | [`HttpBackend`](./src/backend/backend.md) |
| `@suaveplan/i18n-backend-http/factory` | [`httpBackend`](./src/factory/factory.md) |
| `@suaveplan/i18n-backend-http/observability` | [OTel instruments](./src/observability/observability.md) |
## Capabilities
```ts
{
canRead: true,
canReadMulti: false,
canSave: false,
canTrackModification: false,
canWatch: false,
}
```
## Error semantics
- HTTP 404 → `I18nError(I18N.BACKEND_LOCALE_NOT_AVAILABLE)` — chained
backend SHOULD fall through.
- HTTP 5xx / non-2xx non-404 → `I18nError(I18N.BACKEND_READ_FAILED)` —
chained backend MUST propagate.
- Network failure (`TypeError` from `fetch`) →
`I18nError(I18N.BACKEND_READ_FAILED)`.
- AbortError mid-flight → `I18nError(I18N.BACKEND_TIMEOUT)`.
- Pre-aborted `signal` → `I18nError(I18N.BACKEND_TIMEOUT)` (no
network request issued).
- Parse failure → `I18nError(I18N.BACKEND_READ_FAILED)`.
- Invalid options → `I18nError(I18N.INVALID_OPTIONS)` at construction.
## Tier
Universal — runs in browsers, Bun, and Node.js >= 18. On older Node
runtimes lacking `globalThis.fetch`, `defaultFetch()` returns a stub
that throws `BACKEND_READ_FAILED` with an explicit message instructing
consumers to inject `fetchFn`.

View File

@@ -0,0 +1,119 @@
# `HttpBackend`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`HttpBackend` is the canonical browser-tier translation source for the
`@suaveplan/i18n` family. It fetches `<loadPath>` per `(locale,
namespace)` via an injectable `fetchFn` (defaulting to
`globalThis.fetch`) and parses the response body via an injectable
`parsePayload` (defaulting to a JSON-object parser that rejects arrays,
null, and primitive payloads). HTTP 404 maps to
`BACKEND_LOCALE_NOT_AVAILABLE` — the chained-backend fallthrough signal
— while HTTP 5xx, network failures, and parse failures map to
`BACKEND_READ_FAILED`, the chain-stopping signal that prevents silent
degradation when the transport itself is broken.
The backend is read-only by design (`canSave: false`,
`canTrackModification: false`, `canWatch: false`). Consumers needing
write-back wire `MultiFileFsBackend` or `StorageBackend` behind the
catalog instead. Universal — runs identically in browsers, Bun,
and Node.js >= 18; older Node runtimes get a clear
`BACKEND_READ_FAILED` from `defaultFetch()` instructing them to inject
`fetchFn`.
## Features
- 5-flag capability matrix `{ canRead: true, canReadMulti: false,
canSave: false, canTrackModification: false, canWatch: false }`.
- URL templating via `{locale}` / `{namespace}` placeholders with
`encodeURIComponent` per segment.
- Pluggable `parsePayload` accepts non-JSON bodies (YAML, MessagePack,
schema-validated JSON).
- Pluggable `customHeaders` builder per request.
- `crossDomain: true` shorthand for `credentials: "include"` +
`mode: "cors"` (preserves explicit overrides in `requestOptions`).
- Optional `retry?: HttpBackendRetry` (structurally compatible with
`@suaveplan/retry`'s `RetryPolicy`) wraps every fetch.
- Optional `signal?: AbortSignal` — pre-aborted signals throw
`BACKEND_TIMEOUT` without issuing a network request; mid-flight
AbortError surfaces as `BACKEND_TIMEOUT`; the signal is threaded into
the underlying `RequestInit` so `fetch` can cancel mid-flight.
- Zod boundary validation throws `INVALID_OPTIONS` at construction.
- Per-call OTel instrumentation under `i18n.backend.http.*`.
## Usage: Basic Example
```ts
import { HttpBackend } from "@suaveplan/i18n-backend-http";
const backend = new HttpBackend({
loadPath: "/i18n/{locale}/{namespace}.json",
});
const record = await backend.read("en", "auth");
```
## Usage: Advanced Example
```ts
import { HttpBackend } from "@suaveplan/i18n-backend-http";
const backend = new HttpBackend({
loadPath: "/i18n/{locale}/{namespace}.yaml",
crossDomain: true,
customHeaders: (locale) => ({ "x-locale": locale }),
parsePayload: async (res) => parseYaml(await res.text()),
retry: {
run: async (op) => {
for (let i = 0; i < 3; i++) {
try {
return await op();
} catch (err) {
if (i === 2) throw err;
}
}
throw new Error("unreachable");
},
},
});
const ctrl = new AbortController();
setTimeout(() => ctrl.abort(), 2_000);
const record = await backend.read("en", "auth", ctrl.signal);
```
## API Reference
### `class HttpBackend implements Backend`
- `new HttpBackend(options: HttpBackendOptions)` — Zod-validated;
throws `I18nError(I18N.INVALID_OPTIONS)` on malformed input.
- `read(locale, namespace, signal?): Promise<TranslationRecord>` —
fetches and parses the URL. Throws `BACKEND_LOCALE_NOT_AVAILABLE` on
404; `BACKEND_READ_FAILED` on 5xx / network / parse failures;
`BACKEND_TIMEOUT` on signal abort.
### Helpers
- `buildUrl(loadPath, locale, namespace): string` — substitutes
`{locale}` and `{namespace}` with `encodeURIComponent`-applied
values.
- `defaultParsePayload: ParsePayloadFn` — JSON object parser. Rejects
arrays, null, and primitives as `BACKEND_READ_FAILED`.
- `HttpBackendOptionsSchema: z.ZodObject` — Zod boundary schema.
## Implementation Notes
The class delegates to a private `#fetchOnce` helper that classifies
network failures: `AbortError` / `ABORT_ERR` codes surface as
`BACKEND_TIMEOUT` (so consumers can distinguish cancellation from
transport faults), and `I18nError(I18N.BACKEND_TIMEOUT)` thrown from
inside `fetchFn` (e.g. an injected fake) propagates unchanged.
Everything else from the network surface — `TypeError`, refused
connections, DNS failures, CORS preflights — maps to
`BACKEND_READ_FAILED`. Capability flags stay `false` for write paths
because the HTTP transport has no canonical `PUT` semantics across
CDNs; consumers needing write-back wire a different backend.

View File

@@ -0,0 +1,76 @@
# `httpBackend(options)`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`httpBackend(options)` is the builder-style factory for
[`HttpBackend`](../backend/backend.md). It constructs a fresh
`HttpBackend` instance per call — no singletons, no shared state, no
caching of options or fetch bindings — and surfaces the same Zod
boundary validation and capability matrix as the constructor.
Consumers who prefer composition over `new` reach for this factory; the
two forms are interchangeable. Both exist because i18n-family packages
historically preferred named factories for ergonomic test fixtures and
dependency-injection setups.
## Features
- Zero-argument-overhead alias for `new HttpBackend(options)`.
- Returns a fresh `HttpBackend` per call (no implicit caching).
- Throws `I18nError(I18N.INVALID_OPTIONS)` on malformed input (Zod
boundary). Throws nothing else at construction time.
- Threads every option (`loadPath`, `fetchFn`, `parsePayload`,
`customHeaders`, `requestOptions`, `crossDomain`, `retry`, `name`)
unchanged into the constructor.
## Usage: Basic Example
```ts
import { httpBackend } from "@suaveplan/i18n-backend-http";
const backend = httpBackend({
loadPath: "/i18n/{locale}/{namespace}.json",
});
```
## Usage: Advanced Example
```ts
import { httpBackend } from "@suaveplan/i18n-backend-http";
const backend = httpBackend({
name: "cdn",
loadPath: "https://cdn.example/i18n/{locale}/{namespace}.json",
crossDomain: true,
customHeaders: () => ({ "x-tenant": "acme" }),
retry: {
run: async (op, _opts) => op(),
},
});
const ctrl = new AbortController();
queueMicrotask(() => ctrl.abort());
try {
await backend.read("en", "auth", ctrl.signal);
} catch (err) {
// I18nError(I18N.BACKEND_TIMEOUT)
}
```
## API Reference
### `httpBackend(options: HttpBackendOptions): HttpBackend`
Returns a new `HttpBackend`. Every option is passed verbatim. Throws
`I18nError(I18N.INVALID_OPTIONS)` when `options` fails Zod validation.
## Implementation Notes
The factory holds no module-level state. Two calls produce two
distinct `HttpBackend` instances even when the option objects are
referentially identical — this is deliberate so test fixtures can
build many cheaply-isolated backends without aliasing issues. The
factory is defined in a separate file so it can be tree-shaken away by
consumers that import the class directly via the `./backend` subpath.

View File

@@ -0,0 +1,75 @@
# `@suaveplan/i18n-backend-http/observability`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Lazy OpenTelemetry instrument factory and matched in-memory state ring
for `@suaveplan/i18n-backend-http`. Production paths in
[`backend.ts`](../backend/backend.md) call `recordRead(...)` once per
`HttpBackend.read` call; the instruments are no-ops until a
`MeterProvider` is registered against `@opentelemetry/api`, so consumers
pay zero overhead unless they wire up the SDK. The matched state ring
exists so spec-conformance tests can assert exact attribute payloads
without standing up a full SDK + `InMemoryMetricExporter` pipeline.
## Features
- `i18n.backend.http.read_duration_ms` — Histogram, milliseconds.
Attributes: `locale`, `namespace`, `outcome ∈ hit | miss | error`.
- `i18n.backend.http.read_total` — Counter. Attribute: `outcome`.
- `i18n.backend.http.read_errors_total` — Counter. Attributes: `code`
(the `I18N.*` error code), optional `status` (HTTP status code as
string when the error originated from a known HTTP response).
- Zero-cost when no MeterProvider is wired — every instrument is a
cached singleton.
- Test-only state ring + reset helpers
(`getHttpBackendMetricsState`, `resetHttpBackendMetricsState`,
`resetHttpBackendMetricsCache`).
## Usage: Basic Example
```ts
import { recordRead } from "@suaveplan/i18n-backend-http/observability";
recordRead("en", "auth", "hit", 0.42);
```
## Usage: Advanced Example
```ts
import {
getHttpBackendMetricsState,
recordRead,
resetHttpBackendMetricsState,
} from "@suaveplan/i18n-backend-http/observability";
resetHttpBackendMetricsState();
recordRead("xx", "auth", "miss", 1.2, "I18N.BACKEND_LOCALE_NOT_AVAILABLE", 404);
const state = getHttpBackendMetricsState();
console.log(state.readErrorsTotal);
// → [{ attributes: { code: "I18N.BACKEND_LOCALE_NOT_AVAILABLE", status: "404" } }]
```
## API Reference
- `getHttpBackendMetrics(): { readDurationMs, readTotal, readErrorsTotal }`
— idempotent factory.
- `recordRead(locale, namespace, outcome, durationMs, errorCode?, status?)`
— emit + ring-record.
- `getHttpBackendMetricsState(): HttpBackendMetricsState` — frozen
snapshot.
- `resetHttpBackendMetricsState()` / `resetHttpBackendMetricsCache()` —
test helpers.
## Implementation Notes
`status` is encoded as a string attribute so OTel SDK aggregators can
union it with the `code` label without numeric coercion ambiguity. The
counter cardinality is bounded by the small `I18N.*` error-code set
times a typical handful of HTTP statuses (404 / 500 / 502 / 503), which
stays well below downstream backend high-cardinality limits. The cached
instruments survive across `recordRead` calls inside one process — the
`resetHttpBackendMetricsCache` helper exists purely so tests can verify
the cache-hit path is exercised.

View File

@@ -0,0 +1,121 @@
# @suaveplan/i18n-backend-memory - Universal in-memory translation backend for the @suaveplan/i18n family. Backed by Map<locale, Map<namespace, TranslationRecord>> plus a modification ledger; implements the full Backend contract (read, readMulti, save, lastModified). Designed for tests, seeded defaults, and the cache layer of a ChainedBackend. Zero runtime dependencies on @suaveplan/i18n core.
## Summary
Universal in-memory translation backend for the
`@suaveplan/i18n` family. Backed by `Map<locale, Map<namespace,
TranslationRecord>>` plus a modification ledger; implements the full
`Backend` contract (`read`, `readMulti`, `save`, `lastModified`).
Designed for tests, seeded defaults, and the cache layer of a
`ChainedBackend`. Zero runtime dependencies on `@suaveplan/i18n`
core.
<!-- begin:auto-docs -->
## Documentation tree
- [backend](./src/backend/backend.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [backend](./src/backend/backend.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
<!-- end:auto-docs --> - [backend](./src/backend/backend.md) — <!-- begin:auto-docs --> _(no submodules)_- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_- [backend](./src/backend/backend.md) — <!-- begin:auto-docs --> _(no submodules)_- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_- [backend](./src/backend/backend.md) — <!-- begin:auto-docs --> _(no submodules)_- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_- [backend](./src/backend/backend.md) — <!-- begin:auto-docs --> _(no submodules)_- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_ - [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_ - [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_
## Why this exists
The Wave 5 modular split lifts each backend out of the
`@suaveplan/i18n` monolith into its own sibling so consumers can
install only the backends they need (matching the `crypto-*` /
`drive-*` / `network-plugin-*` granularity established elsewhere in
the project). The in-memory backend is the simplest and most
universal of the five — it has zero external runtime deps, runs
identically in browser and server, and is the canonical seed source
for every other backend's tests.
## Install
```bash
bun add @suaveplan/i18n-backend-memory
```
## Quick Start
```ts
import { memoryBackend } from "@suaveplan/i18n-backend-memory";
const backend = memoryBackend({
seed: {
en: { common: { hello: "Hello" } },
fr: { common: { hello: "Bonjour" } },
},
});
const enCommon = await backend.read("en", "common");
// { hello: "Hello" }
await backend.save("en", "auth", { login: { title: "Sign in" } });
const t = await backend.lastModified("en", "auth"); // number (ms)
```
## Modules
| Subpath | Description |
|---|---|
| `@suaveplan/i18n-backend-memory` | Root barrel — class + factory + types + schemas + observability |
| `@suaveplan/i18n-backend-memory/backend` | `MemoryBackend` class only |
| `@suaveplan/i18n-backend-memory/factory` | `memoryBackend` factory function only |
| `@suaveplan/i18n-backend-memory/schemas` | Zod boundary schemas |
| `@suaveplan/i18n-backend-memory/observability` | OTel instruments + in-memory state ring |
## Modules Documentation
- [backend.md](src/backend/backend.md) — `MemoryBackend` class + capability matrix
- [factory.md](src/factory/factory.md) — `memoryBackend` factory function
- [observability.md](src/observability/observability.md) — OTel instruments + metric names + cardinality
## Capabilities
| Flag | Value | Notes |
|---|---|---|
| `canRead` | `true` | always — every backend reads |
| `canReadMulti` | `true` | bulk-load O(N) over the namespace map |
| `canSave` | `true` | persists into the in-memory store |
| `canTrackModification` | `true` | injectable `now()` clock |
| `canWatch` | `false` | no reactive `loaded` events |
## Testing
The package's own tests use the shared `runBackendConformance`
harness from `@suaveplan/i18n-shared/testing` so the universal
`Backend` contract is exercised against a fresh in-memory backend
per assertion:
```ts
import { runBackendConformance } from "@suaveplan/i18n-shared/testing";
import { MemoryBackend } from "@suaveplan/i18n-backend-memory";
runBackendConformance("MemoryBackend", async () => {
const backend = new MemoryBackend();
return {
backend,
async seed(locale, namespace, record) {
await backend.save(locale, namespace, record as never);
},
};
});
```
## 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. The metric names under `i18n.backend.memory.*` are part of
the public API contract and will not change without a major bump.
## Wave 5 Scope
This package is Wave 5a of the 14-package i18n modular split. It
ships pure additions only — `packages/i18n/i18n/src/core/backend/memory/`
retains its copy of the backend until Wave 6 deletes it. Both copies
coexist during Wave 5; downstream consumers continue to import from
the monolith and observe no behaviour change.

View File

@@ -0,0 +1,129 @@
# `MemoryBackend`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`MemoryBackend` is the universal in-memory translation backend
for the `@suaveplan/i18n` family. It implements the full `Backend`
contract from `@suaveplan/types` (read, readMulti, save,
lastModified) backed by `Map<locale, Map<namespace,
TranslationRecord>>` plus a modification ledger keyed by `${locale}
${namespace}`. It is the canonical seed source for tests, the
default cache layer for `ChainedBackend`, and the reference
implementation every other backend's conformance suite is matched
against.
The backend has zero runtime dependencies on `@suaveplan/i18n` core,
so it can be used standalone — for example, a test suite needing
catalog-shaped data can import `MemoryBackend` without paying the
cost of pulling in the full catalog stack.
## Features
- Full 5-flag capability matrix: `canRead: true`, `canReadMulti:
true`, `canSave: true`, `canTrackModification: true`, `canWatch:
false`.
- Optional constructor seed (`{ <locale>: { <namespace>:
TranslationRecord } }`) for pre-populating before first read.
- Injectable `now: () => number` clock for deterministic
`lastModified` testing.
- Optional `signal?: AbortSignal` parameter on `read`, `readMulti`,
and `save`; pre-aborted signals throw `I18nError(I18N.BACKEND_TIMEOUT)`.
- OTel histograms + counters emitted on every read/save:
`i18n.backend.memory.read_duration_ms`, `read_total{outcome}`,
`read_errors_total{code}`, `write_duration_ms`,
`write_total{outcome}`.
- Zod boundary validation: invalid options throw
`I18nError(I18N.INVALID_OPTIONS)` at construction time.
## Usage: Basic Example
```ts
import { MemoryBackend } from "@suaveplan/i18n-backend-memory";
const backend = new MemoryBackend({
seed: {
en: { common: { hello: "Hello" } },
fr: { common: { hello: "Bonjour" } },
},
});
const enCommon = await backend.read("en", "common");
// { hello: "Hello" }
```
## Usage: Advanced Example
The injectable clock makes `lastModified` deterministic so chained-
backend tests can assert exact timestamps without fighting wall-clock
non-determinism.
```ts
import { MemoryBackend } from "@suaveplan/i18n-backend-memory";
let tick = 1000;
const backend = new MemoryBackend({
name: "cache",
now: () => tick++,
});
await backend.save("en", "common", { hello: "Hello" });
await backend.save("en", "auth", { login: "Sign in" });
const tCommon = await backend.lastModified("en", "common"); // 1000
const tAuth = await backend.lastModified("en", "auth"); // 1001
// Cancellable reads play nicely with multi-backend orchestration.
const controller = new AbortController();
controller.abort();
await backend.read("en", "common", controller.signal); // throws BACKEND_TIMEOUT
```
## API Reference
### `class MemoryBackend implements Backend`
- `constructor(options?: MemoryBackendOptions)` — validates `options`
via `MemoryBackendOptionsSchema`; throws `I18nError(I18N.INVALID_OPTIONS)`
on schema mismatch.
- `name: string` — backend label (default `"memory"`).
- `capabilities: BackendCapabilities` — frozen 5-flag matrix.
- `read(locale, namespace, signal?): Promise<TranslationRecord>` —
resolves with the seeded record; throws
`I18nError(I18N.BACKEND_LOCALE_NOT_AVAILABLE)` on miss.
- `readMulti(locale, namespaces, signal?): Promise<Record<string,
TranslationRecord>>` — returns only the namespaces that exist;
missing namespaces are omitted (not undefined).
- `save(locale, namespace, record, signal?): Promise<void>` —
persists the record and stamps the modification ledger via the
injected `now()` function.
- `lastModified(locale, namespace): Promise<number | undefined>` —
returns the timestamp of the last `save` for the slot, or
`undefined` if never written.
- `clear(): void` — test-only helper, drops all in-memory state.
- `snapshot(): MemorySeed` — returns a fresh deep-copy snapshot of
the current store keyed by locale → namespace.
## Implementation Notes
- The `Map<locale, Map<namespace, ...>>` two-level data structure
gives O(1) lookups and O(1) inserts. The modification ledger is a
flat `Map<string, number>` keyed by `"${locale} ${namespace}"`
with a single space separator (BCP-47 tags do not contain spaces,
so the key is unambiguous).
- `signal?.aborted` is checked at the entry of every async method
and the throw surfaces as `I18nError(I18N.BACKEND_TIMEOUT)`. The
`aborted` flag is sampled exactly once at the top — the backend
performs no further async work, so post-yield abort is not
meaningful.
- OTel `recordRead` / `recordWrite` calls emit zero overhead unless
a `MeterProvider` is registered. Test suites use the in-memory
metrics state ring exposed by `./observability` to assert
counter/histogram payloads without standing up the full SDK.
- Seeded options call the same private `#put` path as `save`, so
the modification ledger is populated for every seeded slot using
the injected `now()` function. The seed is consumed
synchronously at construction; consumers needing async seeding
call `save()` post-construction.

View File

@@ -0,0 +1,84 @@
# `memoryBackend(options?)`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Factory wrapper around `new MemoryBackend(options)` for callers that
prefer fluent builder-style composition over `new`. Returns a fresh
backend per call — no internal singleton, no shared state, no
caching. The factory exists primarily so chained-backend
configurations can be expressed declaratively without the visual
weight of repeated `new` keywords.
The factory is the recommended public entry point for routine use
because it composes naturally with the rest of the i18n family
(every backend ships a `<name>Backend` factory mirroring its class
constructor). Direct `new MemoryBackend(...)` remains supported and
is exactly equivalent.
## Features
- Returns a fresh `MemoryBackend` per call.
- Forwards every constructor option (`seed`, `name`, `now`)
unchanged.
- Same Zod boundary validation as the class constructor — invalid
options throw `I18nError(I18N.INVALID_OPTIONS)`.
## Usage: Basic Example
```ts
import { memoryBackend } from "@suaveplan/i18n-backend-memory";
const backend = memoryBackend();
await backend.save("en", "common", { hello: "Hello" });
const record = await backend.read("en", "common");
// { hello: "Hello" }
```
## Usage: Advanced Example
The factory shines in chained-backend configurations where multiple
in-memory backends (e.g. a cache layer + a write-through layer)
need to be expressed inline.
```ts
import { memoryBackend } from "@suaveplan/i18n-backend-memory";
const cache = memoryBackend({ name: "cache" });
const seed = memoryBackend({
name: "seed",
seed: {
en: { common: { hello: "Hello" } },
},
});
// Hand both to a ChainedBackend (Wave 5e).
// const chain = chainedBackend({ backends: [cache, seed] });
```
## API Reference
### `memoryBackend(options?: MemoryBackendOptions): MemoryBackend`
- `options.seed?: MemorySeed` — optional pre-population (`{ <locale>:
{ <namespace>: TranslationRecord } }`).
- `options.name?: string` — optional name override (default
`"memory"`).
- `options.now?: () => number` — injectable clock for deterministic
`lastModified` testing.
- Returns: a fresh `MemoryBackend` instance.
## Implementation Notes
- The factory is a one-liner: `return new MemoryBackend(options)`.
It exists for ergonomic parity with the rest of the
`<name>Backend` factory pattern across the i18n backend family
and adds no behaviour beyond instantiation.
- The factory does NOT memoise — every call returns a distinct
backend instance. Callers needing shared state pass the same
backend reference to multiple call sites; do not call the
factory multiple times expecting the same store.
- For consumers who need polymorphism over `BackendFactory<TOptions>`
from `@suaveplan/types`, this factory satisfies that signature.

View File

@@ -0,0 +1,113 @@
# `MemoryBackend` Observability
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module owns the OpenTelemetry instrumentation for
`@suaveplan/i18n-backend-memory`. It ships per-call metric emitters
(`recordRead`, `recordWrite`) that the backend invokes around every
`read` / `save` operation, plus a small in-memory state ring so spec-
conformance tests can assert exact attribute payloads without
standing up a full SDK + InMemoryMetricExporter pipeline. Every
instrument is built lazily and is a no-op when no MeterProvider is
registered — consumers pay zero overhead unless they wire up the
SDK.
The metric naming follows the per-backend prefix convention agreed
in the Wave 5 design: `i18n.backend.memory.*`. Sibling backend
packages (`i18n-backend-http`, `i18n-backend-storage`, etc.) emit
under their own prefixes so consumers can group dashboard panels
per backend without shared-namespace collisions.
## Features
- Five instruments — two histograms, three counters — covering both
read and write paths.
- In-memory state ring for test assertions (no exporter required).
- Lazy meter construction; idempotent `getMemoryBackendMetrics()`.
- Test-only `resetMemoryBackendMetricsCache()` so suites can
rebuild the meter under a fresh provider.
## Usage: Basic Example
```ts
import {
getMemoryBackendMetricsState,
resetMemoryBackendMetricsState,
} from "@suaveplan/i18n-backend-memory/observability";
import { MemoryBackend } from "@suaveplan/i18n-backend-memory";
resetMemoryBackendMetricsState();
const backend = new MemoryBackend({ seed: { en: { common: { hi: "Hi" } } } });
await backend.read("en", "common");
const state = getMemoryBackendMetricsState();
expect(state.readTotal).toEqual([{ attributes: { outcome: "hit" } }]);
```
## Usage: Advanced Example
Mismatched reads emit BOTH an outcome counter AND an
`read_errors_total` counter labelled by error code. Tests can assert
the full payload to detect regressions in error classification.
```ts
import {
getMemoryBackendMetricsState,
resetMemoryBackendMetricsState,
} from "@suaveplan/i18n-backend-memory/observability";
import { MemoryBackend } from "@suaveplan/i18n-backend-memory";
resetMemoryBackendMetricsState();
const backend = new MemoryBackend();
await backend.read("en", "missing").catch(() => undefined);
const state = getMemoryBackendMetricsState();
expect(state.readTotal).toEqual([{ attributes: { outcome: "miss" } }]);
expect(state.readErrorsTotal).toEqual([
{ attributes: { code: "I18N.BACKEND_LOCALE_NOT_AVAILABLE" } },
]);
```
## API Reference
### Emitted instruments
| Instrument | Kind | Labels | Unit |
|---|---|---|---|
| `i18n.backend.memory.read_duration_ms` | Histogram | `locale`, `namespace`, `outcome` | ms |
| `i18n.backend.memory.read_total` | Counter | `outcome` (hit \| miss) | count |
| `i18n.backend.memory.read_errors_total` | Counter | `code` (I18N error code) | count |
| `i18n.backend.memory.write_duration_ms` | Histogram | `locale`, `namespace`, `outcome` | ms |
| `i18n.backend.memory.write_total` | Counter | `outcome` (ok \| error) | count |
### Functions
- `getMemoryBackendMetrics()` — returns cached instruments; builds
them on first call.
- `recordRead(locale, namespace, outcome, durationMs, errorCode?)`
— emit a read sample.
- `recordWrite(locale, namespace, outcome, durationMs)` — emit a
write sample.
- `getMemoryBackendMetricsState()` — frozen-shape snapshot of every
emitted sample since last reset.
- `resetMemoryBackendMetricsState()` — clear the in-memory state
ring (test fixture).
- `resetMemoryBackendMetricsCache()` — drop the cached instruments
so a fresh `MeterProvider` can be wired (test fixture).
## Implementation Notes
- The state ring is intentionally cardinality-bounded by the
caller's test cases. It exists for assertion ergonomics, not for
production telemetry — production telemetry flows through the
registered OTel `MeterProvider`.
- Cardinality safety: aggregate counters use only `outcome` (hit /
miss / ok / error); per-error-code attribution lives on a
separate `read_errors_total` counter so consumers can drop the
high-cardinality counter without losing aggregate visibility.
- Metric names are stable: every `i18n.backend.memory.*` name is
part of the package's public API contract and will not change
without a major bump.

View File

@@ -0,0 +1,130 @@
# @suaveplan/i18n-backend-storage - Universal translation backend backed by any AbstractStorage from @suaveplan/storage. Three wire layouts: single-key-per-namespace (default), single-key-per-locale, flat-keys. Pluggable codec, AbortSignal cancellation, OpenTelemetry instrumentation under i18n.backend.storage.*. Zero runtime dependencies on @suaveplan/i18n core.
## Summary
Universal translation backend for the `@suaveplan/i18n` family,
backed by any `AbstractStorage` implementation from
`@suaveplan/storage`. Implements the full `Backend` contract
(`read`, `readMulti`, `save`, `lastModified`) over three wire
layouts: `single-key-per-namespace` (default),
`single-key-per-locale`, and `flat-keys`. Pluggable codec, optional
`AbortSignal` cancellation, OpenTelemetry instrumentation under the
`i18n.backend.storage.*` prefix. Zero runtime dependencies on
`@suaveplan/i18n` core.
<!-- begin:auto-docs -->
## Documentation tree
- [backend](./src/backend/backend.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [backend](./src/backend/backend.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
<!-- end:auto-docs --> - [backend](./src/backend/backend.md) — <!-- begin:auto-docs --> _(no submodules)_- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_- [backend](./src/backend/backend.md) — <!-- begin:auto-docs --> _(no submodules)_- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_- [backend](./src/backend/backend.md) — <!-- begin:auto-docs --> _(no submodules)_- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_- [backend](./src/backend/backend.md) — <!-- begin:auto-docs --> _(no submodules)_- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_ - [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_ - [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_
## Why this exists
The Wave 5 modular split lifts each backend out of the
`@suaveplan/i18n` monolith into its own sibling so consumers can
install only the backends they need. The storage backend is the
canonical persistence layer: pair it with `MemoryStorage` for tests,
`OpfsStorage` / `IndexedDBStorage` for offline-first browser apps,
or `S3Storage` / `GarageStorage` / `NodeFsStorage` /
`SqliteStorage` for server-tier deployments. Splitting it out also
isolates `@suaveplan/storage` as a transitive dep — consumers using
only the HTTP or memory backends no longer pull it in.
## Install
```bash
bun add @suaveplan/i18n-backend-storage @suaveplan/storage
```
## Quick Start
```ts
import { storageBackend } from "@suaveplan/i18n-backend-storage";
import { MemoryStorage } from "@suaveplan/storage";
const backend = storageBackend({ storage: new MemoryStorage() });
await backend.save("en", "auth", { login: { title: "Sign in" } });
const record = await backend.read("en", "auth");
// → { login: { title: "Sign in" } }
```
## Modules
| Subpath | Description |
|---|---|
| `@suaveplan/i18n-backend-storage` | Root barrel — class + factory + types + schemas + observability |
| `@suaveplan/i18n-backend-storage/backend` | `StorageBackend` class + `defaultCodec` |
| `@suaveplan/i18n-backend-storage/factory` | `storageBackend` factory function only |
| `@suaveplan/i18n-backend-storage/schemas` | Zod boundary schemas |
| `@suaveplan/i18n-backend-storage/observability` | OTel instruments + in-memory state ring |
## Modules Documentation
- [backend.md](src/backend/backend.md) — `StorageBackend` class, layouts, codec
- [factory.md](src/factory/factory.md) — `storageBackend` factory function
- [observability.md](src/observability/observability.md) — OTel instruments + cardinality
## Capabilities
| Flag | Value | Notes |
|---|---|---|
| `canRead` | `true` | always |
| `canReadMulti` | `true` | per-layout dispatch |
| `canSave` | `true` | persists into the wrapped storage |
| `canTrackModification` | `true` | consults `getMetadata().modifiedAt` |
| `canWatch` | `false` | the storage contract has no change feed |
## Layouts
- `single-key-per-namespace` (default) — one key per `(locale, ns)`
at `<prefix>/<locale>/<namespace>.json`. Best for HTTP-CDN-style
storages.
- `single-key-per-locale` — one key per locale holding the whole
namespace map. Best when namespaces are small and read together.
- `flat-keys` — one key per leaf string at
`<prefix>:<locale>:<namespace>:<dotPath>`. Best for content-
addressed storages with cheap per-leaf delta shipping.
## Testing
The package's own tests use the shared `runBackendConformance`
harness from `@suaveplan/i18n-shared/testing` so the universal
`Backend` contract is exercised against a fresh storage backend
per assertion (against all three layouts):
```ts
import { runBackendConformance } from "@suaveplan/i18n-shared/testing";
import { StorageBackend } from "@suaveplan/i18n-backend-storage";
import { MemoryStorage } from "@suaveplan/storage";
runBackendConformance("StorageBackend", async () => {
const backend = new StorageBackend({ storage: new MemoryStorage() });
return {
backend,
async seed(locale, namespace, record) {
await backend.save(locale, namespace, record as never);
},
};
});
```
## 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. The metric names under `i18n.backend.storage.*` are part of
the public API contract and will not change without a major bump.
## Wave 5 Scope
This package is Wave 5b of the 14-package i18n modular split. It
ships pure additions only — `packages/i18n/i18n/src/core/backend/storage/`
retains its copy of the backend until Wave 6 deletes it. Both
copies coexist during Wave 5; downstream consumers continue to
import from the monolith and observe no behaviour change.

View File

@@ -0,0 +1,167 @@
# `StorageBackend`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`StorageBackend` adapts any `AbstractStorage` instance from
`@suaveplan/storage` into a translation `Backend` for the
`@suaveplan/i18n` family. This is the headline persistence
integration that lets translations live in S3, OPFS, IndexedDB,
SQLite, Garage, or any other storage `@suaveplan/storage` supports
— without each consumer integrating once per source. The user-
facing pitch is "translations in OPFS for offline" or "translations
on a content-addressed CDN" become one config line.
The backend has zero runtime dependencies on `@suaveplan/i18n` core.
It implements the full `Backend` contract from `@suaveplan/types`
(`read`, `readMulti`, `save`, `lastModified`) and emits
OpenTelemetry instruments under the `i18n.backend.storage.*` prefix
so production teams can dashboard latency and miss-rate per layout.
## Features
- Full 5-flag capability matrix: `canRead`, `canReadMulti`,
`canSave`, `canTrackModification` all `true`; `canWatch: false`.
- Three wire layouts: `single-key-per-namespace` (default),
`single-key-per-locale`, `flat-keys`.
- Pluggable codec — JSON by default; swap for MessagePack, CBOR, or
schema-validated JSON.
- Optional `signal?: AbortSignal` parameter on `read`, `readMulti`,
and `save`; pre-aborted signals throw
`I18nError(I18N.BACKEND_TIMEOUT)` without touching the underlying
storage.
- OTel histograms + counters emitted on every read/save with a
`layout` attribute so per-layout latency can be charted.
- Zod boundary validation: invalid options throw
`I18nError(I18N.INVALID_OPTIONS)` at construction time.
- `BACKEND_LOCALE_NOT_AVAILABLE` on miss (chained backends fall
through); `BACKEND_READ_FAILED` / `BACKEND_WRITE_FAILED` on
transport / codec errors (chained backends propagate without
fallthrough).
## Layouts
### `single-key-per-namespace` (default)
```text
i18n/en/auth.json → {"login":{"title":"Sign in"}}
i18n/en/common.json → {"cancel":"Cancel"}
```
One storage key per `(locale, namespace)`. Reads are exactly one
storage `get` per `read(locale, ns)` call. Best for HTTP-CDN-style
storages where each key is independently cacheable.
### `single-key-per-locale`
```text
i18n/en.json → {"auth":{...},"common":{...}}
```
One storage key per locale, holding the entire
`Record<namespace, TranslationRecord>` map. Best when namespaces are
small and read together. Saving a single namespace requires a
read-modify-write of the whole locale map.
### `flat-keys`
```text
i18n:en:auth:login.title → Sign in
i18n:en:common:cancel → Cancel
```
One storage key per leaf string. Best for content-addressed storages
where deltas are expensive at namespace granularity but cheap at
leaf granularity.
## Usage: Basic Example
```ts
import { StorageBackend } from "@suaveplan/i18n-backend-storage";
import { MemoryStorage } from "@suaveplan/storage";
const backend = new StorageBackend({ storage: new MemoryStorage() });
await backend.save("en", "auth", { login: { title: "Sign in" } });
const record = await backend.read("en", "auth");
// → { login: { title: "Sign in" } }
```
## Usage: Advanced Example — OPFS + custom binary codec
```ts
import { StorageBackend } from "@suaveplan/i18n-backend-storage";
import { OpfsStorage } from "@suaveplan/storage-browser-opfs";
import { encode, decode } from "@msgpack/msgpack";
const backend = new StorageBackend({
storage: new OpfsStorage({ rootDir: "i18n" }),
layout: "single-key-per-locale",
encode: (record) => encode(record),
decode: (bytes) => decode(bytes) as Record<string, unknown>,
});
// Cancellable reads play nicely with multi-backend orchestration.
const controller = new AbortController();
controller.abort();
await backend.read("en", "auth", controller.signal); // throws BACKEND_TIMEOUT
```
## API Reference
### `class StorageBackend implements Backend`
- `constructor(options: StorageBackendOptions)` — validates options
via `StorageBackendOptionsSchema`; throws
`I18nError(I18N.INVALID_OPTIONS)` on schema mismatch or missing
`storage`.
- `name: string` — backend label (default `"storage"`).
- `capabilities: BackendCapabilities` — frozen 5-flag matrix.
- `read(locale, namespace, signal?): Promise<TranslationRecord>` —
resolves with the stored record; throws
`I18nError(I18N.BACKEND_LOCALE_NOT_AVAILABLE)` on miss.
- `readMulti(locale, namespaces, signal?):
Promise<Record<string, TranslationRecord>>` — returns only the
namespaces that exist; missing namespaces are omitted.
- `save(locale, namespace, record, signal?): Promise<void>` —
persists the record; failure surfaces as
`BACKEND_WRITE_FAILED`.
- `lastModified(locale, namespace): Promise<number | undefined>` —
returns the underlying storage's `modifiedAt` field, or
`undefined` if metadata is unavailable.
### `defaultCodec: StorageBackendCodec`
JSON-based codec (`TextEncoder` / `JSON.stringify`). The decode path
rejects non-object payloads (strings, arrays, null) as
`BACKEND_READ_FAILED`.
## Implementation Notes
- The default JSON codec uses `TextEncoder` / `TextDecoder` and
`JSON.stringify` / `JSON.parse`. The decode path rejects non-
object payloads because translation records are always objects in
the contract; custom codecs MUST also reject non-objects or
document the relaxation explicitly.
- `flat-keys` reads via `AbstractStorage.list({ prefix })`. List is
required by every concrete storage, so the layout works against
all of them. Saves write each leaf with its own `set` call — for
backends that batch writes, prefer `single-key-per-namespace` to
amortise the batch.
- `lastModified` for `flat-keys` takes the maximum `modifiedAt`
across leaves. For other layouts it consults the namespace or
locale key directly. All paths swallow storage exceptions and
return `undefined` rather than throw, mirroring the contract that
`lastModified` is a hint, not a guarantee.
- `signal?.aborted` is checked at the entry of every async method.
The throw surfaces as `I18nError(I18N.BACKEND_TIMEOUT)`. The flag
is sampled exactly once at the top — once the underlying
`AbstractStorage` operation begins, abort propagation is the
storage's responsibility.
- OTel `recordRead` / `recordWrite` calls emit zero overhead unless
a `MeterProvider` is registered. Tests use the in-memory metrics
state ring exposed by `./observability` to assert payloads
without standing up the full SDK. The `layout` attribute is a
three-value low-cardinality enum.

View File

@@ -0,0 +1,94 @@
# `storageBackend(options)`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Factory wrapper around `new StorageBackend(options)` for callers
that prefer fluent builder-style composition over `new`. Returns a
fresh backend per call — no internal singleton, no shared state, no
caching. The factory exists primarily so chained-backend
configurations can be expressed declaratively without the visual
weight of repeated `new` keywords.
The factory is the recommended public entry point for routine use
because it composes naturally with the rest of the i18n family
(every backend ships a `<name>Backend` factory mirroring its class
constructor). Direct `new StorageBackend(...)` remains supported and
is exactly equivalent.
## Features
- Returns a fresh `StorageBackend` per call.
- Forwards every constructor option (`storage`, `layout`, `prefix`,
`name`, `encode`, `decode`) unchanged.
- Same Zod boundary validation as the class constructor — invalid
options throw `I18nError(I18N.INVALID_OPTIONS)`.
## Usage: Basic Example
```ts
import { storageBackend } from "@suaveplan/i18n-backend-storage";
import { MemoryStorage } from "@suaveplan/storage";
const backend = storageBackend({ storage: new MemoryStorage() });
await backend.save("en", "common", { hello: "Hello" });
const record = await backend.read("en", "common");
// → { hello: "Hello" }
```
## Usage: Advanced Example
The factory composes naturally with chained-backend configurations
where multiple storages (e.g. an OPFS write-through cache plus an
S3 origin) are wired declaratively.
```ts
import { storageBackend } from "@suaveplan/i18n-backend-storage";
import { MemoryStorage } from "@suaveplan/storage";
const cache = storageBackend({
storage: new MemoryStorage(),
layout: "single-key-per-locale",
name: "cache",
});
const origin = storageBackend({
storage: new MemoryStorage(),
layout: "flat-keys",
name: "origin",
prefix: "translations",
});
// Hand both to a ChainedBackend (Wave 5e).
// const chain = chainedBackend({ backends: [cache, origin] });
```
## API Reference
### `storageBackend(options: StorageBackendOptions): StorageBackend`
- `options.storage: AbstractStorage` — required.
- `options.layout?: StorageBackendLayout` — wire layout (default
`"single-key-per-namespace"`).
- `options.prefix?: string` — key prefix (default `"i18n"`).
- `options.name?: string` — backend label (default `"storage"`).
- `options.encode?: (record) => Uint8Array` — custom encoder.
- `options.decode?: (bytes) => TranslationRecord` — custom decoder.
- Returns: a fresh `StorageBackend` instance.
## Implementation Notes
- The factory is a one-liner: `return new StorageBackend(options)`.
It exists for ergonomic parity with the rest of the
`<name>Backend` factory pattern across the i18n backend family
and adds no behaviour beyond instantiation.
- The factory does NOT memoise — every call returns a distinct
backend instance. Callers needing shared state pass the same
backend reference to multiple call sites; do not call the
factory multiple times expecting the same store.
- For consumers who need polymorphism over
`BackendFactory<TOptions>` from `@suaveplan/types`, this factory
satisfies that signature.

View File

@@ -0,0 +1,132 @@
# `StorageBackend` Observability
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module owns the OpenTelemetry instrumentation for
`@suaveplan/i18n-backend-storage`. It ships per-call metric emitters
(`recordRead`, `recordWrite`) that the backend invokes around every
`read` / `save` operation, plus a small in-memory state ring so
spec-conformance tests can assert exact attribute payloads without
standing up a full SDK + InMemoryMetricExporter pipeline. Every
instrument is built lazily and is a no-op when no MeterProvider is
registered — consumers pay zero overhead unless they wire up the
SDK.
The metric naming follows the per-backend prefix convention agreed
in the Wave 5 design: `i18n.backend.storage.*`. Sibling backend
packages emit under their own prefixes so consumers can group
dashboard panels per backend without shared-namespace collisions.
Every counter and histogram carries a low-cardinality `layout`
attribute (`single-key-per-namespace` | `single-key-per-locale` |
`flat-keys`) so production teams can drill into per-layout latency
or miss-rate.
## Features
- Five instruments — two histograms, three counters — covering both
read and write paths.
- `layout` attribute on every aggregate so per-layout dashboards
work without parsing namespaces.
- In-memory state ring for test assertions (no exporter required).
- Lazy meter construction; idempotent
`getStorageBackendMetrics()`.
- Test-only `resetStorageBackendMetricsCache()` so suites can
rebuild the meter under a fresh provider.
## Usage: Basic Example
```ts
import {
getStorageBackendMetricsState,
resetStorageBackendMetricsState,
} from "@suaveplan/i18n-backend-storage/observability";
import { StorageBackend } from "@suaveplan/i18n-backend-storage";
import { MemoryStorage } from "@suaveplan/storage";
resetStorageBackendMetricsState();
const backend = new StorageBackend({ storage: new MemoryStorage() });
await backend.save("en", "auth", { ok: "Ok" });
await backend.read("en", "auth");
const state = getStorageBackendMetricsState();
expect(state.readTotal).toEqual([
{ attributes: { outcome: "hit", layout: "single-key-per-namespace" } },
]);
```
## Usage: Advanced Example
A read miss emits both an outcome counter AND a
`read_errors_total` counter labelled by error code. Both carry the
`layout` attribute so per-layout error rates are charted directly.
```ts
import {
getStorageBackendMetricsState,
resetStorageBackendMetricsState,
} from "@suaveplan/i18n-backend-storage/observability";
import { StorageBackend } from "@suaveplan/i18n-backend-storage";
import { MemoryStorage } from "@suaveplan/storage";
resetStorageBackendMetricsState();
const backend = new StorageBackend({
storage: new MemoryStorage(),
layout: "flat-keys",
});
await backend.read("en", "missing").catch(() => undefined);
const state = getStorageBackendMetricsState();
expect(state.readErrorsTotal).toEqual([
{
attributes: {
code: "I18N.BACKEND_LOCALE_NOT_AVAILABLE",
layout: "flat-keys",
},
},
]);
```
## API Reference
### Emitted instruments
| Instrument | Kind | Labels | Unit |
|---|---|---|---|
| `i18n.backend.storage.read_duration_ms` | Histogram | `locale`, `namespace`, `outcome`, `layout` | ms |
| `i18n.backend.storage.read_total` | Counter | `outcome` (hit \| miss \| error), `layout` | count |
| `i18n.backend.storage.read_errors_total` | Counter | `code` (I18N error code), `layout` | count |
| `i18n.backend.storage.write_duration_ms` | Histogram | `locale`, `namespace`, `outcome`, `layout` | ms |
| `i18n.backend.storage.write_total` | Counter | `outcome` (ok \| error), `layout` | count |
### Functions
- `getStorageBackendMetrics()` — returns cached instruments; builds
them on first call.
- `recordRead(locale, namespace, layout, outcome, durationMs, errorCode?)`
— emit a read sample.
- `recordWrite(locale, namespace, layout, outcome, durationMs)` —
emit a write sample.
- `getStorageBackendMetricsState()` — frozen-shape snapshot of every
emitted sample since last reset.
- `resetStorageBackendMetricsState()` — clear the in-memory state
ring (test fixture).
- `resetStorageBackendMetricsCache()` — drop the cached instruments
so a fresh `MeterProvider` can be wired (test fixture).
## Implementation Notes
- The state ring is intentionally cardinality-bounded by the
caller's test cases. It exists for assertion ergonomics, not for
production telemetry — production telemetry flows through the
registered OTel `MeterProvider`.
- Cardinality safety: aggregate counters use only `outcome` +
`layout` (each three or fewer values); per-error-code attribution
lives on a separate `read_errors_total` counter so consumers can
drop the high-cardinality counter without losing aggregate
visibility.
- Metric names are stable: every `i18n.backend.storage.*` name is
part of the package's public API contract and will not change
without a major bump.

View File

@@ -0,0 +1,154 @@
# @suaveplan/i18n-detector - Universal locale-detection chain for the @suaveplan/i18n family. Ships the chain orchestrator, the BCP-47 default negotiator, the getTextDirection / isRtl helpers, and 9 detector implementations (querystring, user-preference, cookie, localStorage, html-lang, Accept-Language, navigator, Cloudflare IP-geo, fallback). Browser-only and server-only detectors gate on runtime probes so the same package runs in either tier without conditional package.json exports.
## Summary
Universal locale-detection chain for the `@suaveplan/i18n`
family. Ships the chain orchestrator, the BCP-47 default
negotiator, the `getTextDirection` / `isRtl` direction helpers, and
nine detector implementations (`querystring`, `userPreference`,
`cookie`, `localStorage`, `htmlLang`, `acceptLanguage`,
`navigator`, `cloudflareIpGeo`, `fallback`) plus a canonical
`defaultDetectorChain` factory.
<!-- begin:auto-docs -->
## Documentation tree
- [chain](./src/chain/chain.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [bcp47-negotiate](./src/chain/bcp47-negotiate.md) — `bcp47Negotiate(raw, supported, fallback)` is the default negotiator wired into `createDetectorChain` when no `options.…
- [chain](./src/chain/chain.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [default-chain](./src/default-chain/default-chain.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [default-chain](./src/default-chain/default-chain.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [accept-language](./src/detectors/accept-language/accept-language.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [accept-language](./src/detectors/accept-language/accept-language.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [cloudflare-ip-geo](./src/detectors/cloudflare-ip-geo/cloudflare-ip-geo.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [cloudflare-ip-geo](./src/detectors/cloudflare-ip-geo/cloudflare-ip-geo.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [cookie](./src/detectors/cookie/cookie.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [cookie](./src/detectors/cookie/cookie.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [fallback](./src/detectors/fallback/fallback.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [fallback](./src/detectors/fallback/fallback.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [html-lang](./src/detectors/html-lang/html-lang.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [html-lang](./src/detectors/html-lang/html-lang.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [local-storage](./src/detectors/local-storage/local-storage.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [local-storage](./src/detectors/local-storage/local-storage.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [navigator](./src/detectors/navigator/navigator.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [navigator](./src/detectors/navigator/navigator.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [querystring](./src/detectors/querystring/querystring.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [querystring](./src/detectors/querystring/querystring.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [user-preference](./src/detectors/user-preference/user-preference.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [user-preference](./src/detectors/user-preference/user-preference.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [direction](./src/direction/direction.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [direction](./src/direction/direction.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [observability](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [metrics](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
<!-- end:auto-docs --> - [bcp47-negotiate](./src/chain/bcp47-negotiate.md) — `bcp47Negotiate(raw, supported, fallback)` is the default negotiator wired into `createDetectorChain` when no `options.…
- [chain](./src/chain/chain.md) — <!-- begin:auto-docs --> _(no submodules)_- [default-chain](./src/default-chain/default-chain.md) — <!-- begin:auto-docs --> _(no submodules)_- [default-chain](./src/default-chain/default-chain.md) — <!-- begin:auto-docs --> _(no submodules)_- [accept-language](./src/detectors/accept-language/accept-language.md) — <!-- begin:auto-docs --> _(no submodules)_- [accept-language](./src/detectors/accept-language/accept-language.md) — <!-- begin:auto-docs --> _(no submodules)_- [cloudflare-ip-geo](./src/detectors/cloudflare-ip-geo/cloudflare-ip-geo.md) — <!-- begin:auto-docs --> _(no submodules)_- [cloudflare-ip-geo](./src/detectors/cloudflare-ip-geo/cloudflare-ip-geo.md) — <!-- begin:auto-docs --> _(no submodules)_- [cookie](./src/detectors/cookie/cookie.md) — <!-- begin:auto-docs --> _(no submodules)_- [cookie](./src/detectors/cookie/cookie.md) — <!-- begin:auto-docs --> _(no submodules)_- [fallback](./src/detectors/fallback/fallback.md) — <!-- begin:auto-docs --> _(no submodules)_- [fallback](./src/detectors/fallback/fallback.md) — <!-- begin:auto-docs --> _(no submodules)_- [html-lang](./src/detectors/html-lang/html-lang.md) — <!-- begin:auto-docs --> _(no submodules)_- [html-lang](./src/detectors/html-lang/html-lang.md) — <!-- begin:auto-docs --> _(no submodules)_- [local-storage](./src/detectors/local-storage/local-storage.md) — <!-- begin:auto-docs --> _(no submodules)_- [local-storage](./src/detectors/local-storage/local-storage.md) — <!-- begin:auto-docs --> _(no submodules)_- [navigator](./src/detectors/navigator/navigator.md) — <!-- begin:auto-docs --> _(no submodules)_- [navigator](./src/detectors/navigator/navigator.md) — <!-- begin:auto-docs --> _(no submodules)_- [querystring](./src/detectors/querystring/querystring.md) — <!-- begin:auto-docs --> _(no submodules)_- [querystring](./src/detectors/querystring/querystring.md) — <!-- begin:auto-docs --> _(no submodules)_- [user-preference](./src/detectors/user-preference/user-preference.md) — <!-- begin:auto-docs --> _(no submodules)_- [user-preference](./src/detectors/user-preference/user-preference.md) — <!-- begin:auto-docs --> _(no submodules)_- [direction](./src/direction/direction.md) — <!-- begin:auto-docs --> _(no submodules)_- [direction](./src/direction/direction.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [metrics](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [bcp47-negotiate](./src/chain/bcp47-negotiate.md) — `bcp47Negotiate(raw, supported, fallback)` is the default negotiator wired into `createDetectorChain` when no `options.…
- [chain](./src/chain/chain.md) — <!-- begin:auto-docs --> _(no submodules)_- [default-chain](./src/default-chain/default-chain.md) — <!-- begin:auto-docs --> _(no submodules)_- [default-chain](./src/default-chain/default-chain.md) — <!-- begin:auto-docs --> _(no submodules)_- [accept-language](./src/detectors/accept-language/accept-language.md) — <!-- begin:auto-docs --> _(no submodules)_- [accept-language](./src/detectors/accept-language/accept-language.md) — <!-- begin:auto-docs --> _(no submodules)_- [cloudflare-ip-geo](./src/detectors/cloudflare-ip-geo/cloudflare-ip-geo.md) — <!-- begin:auto-docs --> _(no submodules)_- [cloudflare-ip-geo](./src/detectors/cloudflare-ip-geo/cloudflare-ip-geo.md) — <!-- begin:auto-docs --> _(no submodules)_- [cookie](./src/detectors/cookie/cookie.md) — <!-- begin:auto-docs --> _(no submodules)_- [cookie](./src/detectors/cookie/cookie.md) — <!-- begin:auto-docs --> _(no submodules)_- [fallback](./src/detectors/fallback/fallback.md) — <!-- begin:auto-docs --> _(no submodules)_- [fallback](./src/detectors/fallback/fallback.md) — <!-- begin:auto-docs --> _(no submodules)_- [html-lang](./src/detectors/html-lang/html-lang.md) — <!-- begin:auto-docs --> _(no submodules)_- [html-lang](./src/detectors/html-lang/html-lang.md) — <!-- begin:auto-docs --> _(no submodules)_- [local-storage](./src/detectors/local-storage/local-storage.md) — <!-- begin:auto-docs --> _(no submodules)_- [local-storage](./src/detectors/local-storage/local-storage.md) — <!-- begin:auto-docs --> _(no submodules)_- [navigator](./src/detectors/navigator/navigator.md) — <!-- begin:auto-docs --> _(no submodules)_- [navigator](./src/detectors/navigator/navigator.md) — <!-- begin:auto-docs --> _(no submodules)_- [querystring](./src/detectors/querystring/querystring.md) — <!-- begin:auto-docs --> _(no submodules)_- [querystring](./src/detectors/querystring/querystring.md) — <!-- begin:auto-docs --> _(no submodules)_- [user-preference](./src/detectors/user-preference/user-preference.md) — <!-- begin:auto-docs --> _(no submodules)_- [user-preference](./src/detectors/user-preference/user-preference.md) — <!-- begin:auto-docs --> _(no submodules)_- [direction](./src/direction/direction.md) — <!-- begin:auto-docs --> _(no submodules)_- [direction](./src/direction/direction.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [metrics](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [bcp47-negotiate](./src/chain/bcp47-negotiate.md) — `bcp47Negotiate(raw, supported, fallback)` is the default negotiator wired into `createDetectorChain` when no `options.…
- [chain](./src/chain/chain.md) — <!-- begin:auto-docs --> _(no submodules)_- [default-chain](./src/default-chain/default-chain.md) — <!-- begin:auto-docs --> _(no submodules)_- [default-chain](./src/default-chain/default-chain.md) — <!-- begin:auto-docs --> _(no submodules)_- [accept-language](./src/detectors/accept-language/accept-language.md) — <!-- begin:auto-docs --> _(no submodules)_- [accept-language](./src/detectors/accept-language/accept-language.md) — <!-- begin:auto-docs --> _(no submodules)_- [cloudflare-ip-geo](./src/detectors/cloudflare-ip-geo/cloudflare-ip-geo.md) — <!-- begin:auto-docs --> _(no submodules)_- [cloudflare-ip-geo](./src/detectors/cloudflare-ip-geo/cloudflare-ip-geo.md) — <!-- begin:auto-docs --> _(no submodules)_- [cookie](./src/detectors/cookie/cookie.md) — <!-- begin:auto-docs --> _(no submodules)_- [cookie](./src/detectors/cookie/cookie.md) — <!-- begin:auto-docs --> _(no submodules)_- [fallback](./src/detectors/fallback/fallback.md) — <!-- begin:auto-docs --> _(no submodules)_- [fallback](./src/detectors/fallback/fallback.md) — <!-- begin:auto-docs --> _(no submodules)_- [html-lang](./src/detectors/html-lang/html-lang.md) — <!-- begin:auto-docs --> _(no submodules)_- [html-lang](./src/detectors/html-lang/html-lang.md) — <!-- begin:auto-docs --> _(no submodules)_- [local-storage](./src/detectors/local-storage/local-storage.md) — <!-- begin:auto-docs --> _(no submodules)_- [local-storage](./src/detectors/local-storage/local-storage.md) — <!-- begin:auto-docs --> _(no submodules)_- [navigator](./src/detectors/navigator/navigator.md) — <!-- begin:auto-docs --> _(no submodules)_- [navigator](./src/detectors/navigator/navigator.md) — <!-- begin:auto-docs --> _(no submodules)_- [querystring](./src/detectors/querystring/querystring.md) — <!-- begin:auto-docs --> _(no submodules)_- [querystring](./src/detectors/querystring/querystring.md) — <!-- begin:auto-docs --> _(no submodules)_- [user-preference](./src/detectors/user-preference/user-preference.md) — <!-- begin:auto-docs --> _(no submodules)_- [user-preference](./src/detectors/user-preference/user-preference.md) — <!-- begin:auto-docs --> _(no submodules)_- [direction](./src/direction/direction.md) — <!-- begin:auto-docs --> _(no submodules)_- [direction](./src/direction/direction.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [metrics](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [bcp47-negotiate](./src/chain/bcp47-negotiate.md) — `bcp47Negotiate(raw, supported, fallback)` is the default negotiator wired into `createDetectorChain` when no `options.…
- [chain](./src/chain/chain.md) — <!-- begin:auto-docs --> _(no submodules)_- [default-chain](./src/default-chain/default-chain.md) — <!-- begin:auto-docs --> _(no submodules)_ - [default-chain](./src/default-chain/default-chain.md) — <!-- begin:auto-docs --> _(no submodules)_- [accept-language](./src/detectors/accept-language/accept-language.md) — <!-- begin:auto-docs --> _(no submodules)_ - [accept-language](./src/detectors/accept-language/accept-language.md) — <!-- begin:auto-docs --> _(no submodules)_- [cloudflare-ip-geo](./src/detectors/cloudflare-ip-geo/cloudflare-ip-geo.md) — <!-- begin:auto-docs --> _(no submodules)_ - [cloudflare-ip-geo](./src/detectors/cloudflare-ip-geo/cloudflare-ip-geo.md) — <!-- begin:auto-docs --> _(no submodules)_- [cookie](./src/detectors/cookie/cookie.md) — <!-- begin:auto-docs --> _(no submodules)_ - [cookie](./src/detectors/cookie/cookie.md) — <!-- begin:auto-docs --> _(no submodules)_- [fallback](./src/detectors/fallback/fallback.md) — <!-- begin:auto-docs --> _(no submodules)_ - [fallback](./src/detectors/fallback/fallback.md) — <!-- begin:auto-docs --> _(no submodules)_- [html-lang](./src/detectors/html-lang/html-lang.md) — <!-- begin:auto-docs --> _(no submodules)_ - [html-lang](./src/detectors/html-lang/html-lang.md) — <!-- begin:auto-docs --> _(no submodules)_- [local-storage](./src/detectors/local-storage/local-storage.md) — <!-- begin:auto-docs --> _(no submodules)_ - [local-storage](./src/detectors/local-storage/local-storage.md) — <!-- begin:auto-docs --> _(no submodules)_- [navigator](./src/detectors/navigator/navigator.md) — <!-- begin:auto-docs --> _(no submodules)_ - [navigator](./src/detectors/navigator/navigator.md) — <!-- begin:auto-docs --> _(no submodules)_- [querystring](./src/detectors/querystring/querystring.md) — <!-- begin:auto-docs --> _(no submodules)_ - [querystring](./src/detectors/querystring/querystring.md) — <!-- begin:auto-docs --> _(no submodules)_- [user-preference](./src/detectors/user-preference/user-preference.md) — <!-- begin:auto-docs --> _(no submodules)_ - [user-preference](./src/detectors/user-preference/user-preference.md) — <!-- begin:auto-docs --> _(no submodules)_- [direction](./src/direction/direction.md) — <!-- begin:auto-docs --> _(no submodules)_ - [direction](./src/direction/direction.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_ - [metrics](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_
## Why this package exists
Locale detection is the single most architecturally mis-placed
piece of the i18n stack. The chain orchestrator lived in the
universal `@suaveplan/i18n` monolith, but every detector
implementation lived in `@suaveplan/react-i18n` — a browser-tier
React binding package — even though most detectors are framework-
agnostic and have no DOM dependency at all. Server-only detectors
(`acceptLanguage`, `cloudflareIpGeo`) were unreachable from a
Node-only consumer that didn't want to install React.
This package consolidates every detector implementation, the
chain orchestrator, the standalone `getTextDirection` helper, and
the `defaultDetectorChain` factory into a single universal
package that depends only on `@suaveplan/i18n-shared`,
`@suaveplan/locale-data`, and `@suaveplan/types`.
## Dual-tier strategy
The detector chain runs in both browser and server contexts.
Universal means every detector MUST gracefully short-circuit
when the runtime it expects is absent:
- **Browser-only** (`htmlLang`, `localStorage`, `navigator`)
probe `globalThis.document` / `globalThis.localStorage` /
`globalThis.navigator` and return `null` when the global is
absent. The chain additionally honours `environment: "browser"`
so server runs skip them entirely.
- **Server-only** (`acceptLanguage`, `cloudflareIpGeo`) gate on
`ctx.request !== undefined`. Their `environment: "server"`
causes the chain to skip them on a browser run.
- **Universal** (`querystring`, `cookie`, `userPreference`,
`fallback`) carry `environment: "both"` and fall back through
`ctx.request → globalThis` lookups so they work in either tier.
No conditional `package.json` exports; no `node:` import in any
source file.
## Quick start
```ts
import {
createDetectorChain,
defaultDetectorChain,
} from "@suaveplan/i18n-detector";
const chain = createDetectorChain(
defaultDetectorChain({ fallback: "en-GB" })
);
const result = await chain.detect({
request: {
headers: new Headers({ "accept-language": "fr-FR,en;q=0.5" }),
url: new URL("https://example.com/?lang=fr-FR"),
},
supportedLocales: ["en-GB", "fr-FR"],
fallbackLocale: "en-GB",
});
```
## Modules
| Module | Doc |
| --- | --- |
| `chain/chain.ts` — chain orchestrator | [chain.md](./src/chain/chain.md) |
| `chain/bcp47-negotiate.ts` — default negotiator | [bcp47-negotiate.md](./src/chain/bcp47-negotiate.md) |
| `direction/direction.ts` — direction helpers | [direction.md](./src/direction/direction.md) |
| `detectors/querystring/querystring.ts` | [querystring.md](./src/detectors/querystring/querystring.md) |
| `detectors/user-preference/user-preference.ts` | [user-preference.md](./src/detectors/user-preference/user-preference.md) |
| `detectors/cookie/cookie.ts` | [cookie.md](./src/detectors/cookie/cookie.md) |
| `detectors/local-storage/local-storage.ts` | [local-storage.md](./src/detectors/local-storage/local-storage.md) |
| `detectors/html-lang/html-lang.ts` | [html-lang.md](./src/detectors/html-lang/html-lang.md) |
| `detectors/accept-language/accept-language.ts` | [accept-language.md](./src/detectors/accept-language/accept-language.md) |
| `detectors/navigator/navigator.ts` | [navigator.md](./src/detectors/navigator/navigator.md) |
| `detectors/cloudflare-ip-geo/cloudflare-ip-geo.ts` | [cloudflare-ip-geo.md](./src/detectors/cloudflare-ip-geo/cloudflare-ip-geo.md) |
| `detectors/fallback/fallback.ts` | [fallback.md](./src/detectors/fallback/fallback.md) |
| `default-chain/default-chain.ts` | [default-chain.md](./src/default-chain/default-chain.md) |
| `observability/metrics.ts` | [metrics.md](./src/observability/metrics.md) |
## Subpath exports
Every detector ships under its own subpath so consumers can import
only the surfaces they need (tree-shake-friendly):
- `@suaveplan/i18n-detector` — root barrel
- `@suaveplan/i18n-detector/chain`
- `@suaveplan/i18n-detector/direction`
- `@suaveplan/i18n-detector/default-chain`
- `@suaveplan/i18n-detector/detectors/<name>` for each of the nine
detectors
## Observability
- `i18n.detect` span — wraps every chain run; sets
`resolved_locale` and `winning_detector` attributes.
- `i18n.detector.match_total{detector}` — aggregate match counter.
- `i18n.detector.detected_total{detector,locale}` — per-locale
match counter.
- `i18n.detector.duration_ms{detector}` — async detector
wall-time histogram.
- `i18n.detector.chain.exhausted_total` — chain-exhaustion
counter.
## License
MIT

View File

@@ -0,0 +1,75 @@
# `bcp47-negotiate.ts` — default BCP-47 negotiator
## Purpose
`bcp47Negotiate(raw, supported, fallback)` is the default negotiator
wired into `createDetectorChain` when no `options.negotiate` is
supplied. It maps a detector's raw locale output onto a value the
application actually supports without forcing the configured
fallback prematurely. Returning `null` on miss is intentional — the
chain advances to the next detector instead of committing to the
fallback before every detector has been consulted.
The implementation handles the three cases the upstream
`LocaleResolver.resolve()` happy path covered: exact match,
short-code expansion via `LOCALE_BCP47_MAP` from
`@suaveplan/i18n-shared`, and language-subtag fallback.
## Features
- Exact-match short-circuit: returns the raw input unchanged when it
appears in `supported`.
- Short-code expansion via `LOCALE_BCP47_MAP` (`"en"` → `"en-GB"`).
- Language-subtag matching falls back to the first supported entry
whose primary language matches the raw input's primary language.
- Strict null on miss — the negotiator never invents a value.
- Pure function, no internal state, safe to call from any context.
## Usage: Basic Example
```ts
import { bcp47Negotiate } from "@suaveplan/i18n-detector/chain";
bcp47Negotiate("fr-FR", ["en-GB", "fr-FR"], "en-GB"); // "fr-FR"
bcp47Negotiate("en", ["en-GB", "fr-FR"], "en-GB"); // "en-GB"
bcp47Negotiate("zh-Hant-TW", ["zh-CN", "en-GB"], "en-GB"); // "zh-CN"
bcp47Negotiate("xx-XX", ["en-GB"], "en-GB"); // null
```
## Usage: Advanced Example
```ts
import {
bcp47Negotiate,
createDetectorChain,
type NegotiateFn,
} from "@suaveplan/i18n-detector";
// Wrap the default with a per-request override map.
const negotiate: NegotiateFn = (raw, supported, fallback) => {
if (raw === "zh-Hans") return "zh-CN";
return bcp47Negotiate(raw, supported, fallback);
};
const chain = createDetectorChain(detectors, { negotiate });
```
## API Reference
- `bcp47Negotiate: NegotiateFn` — pure function;
`(raw: string, supported: readonly string[], fallback: string) => string | null`.
- Returns `null` when input is empty, non-string, or no supported
match exists.
- The `fallback` argument is accepted for parity with the
`NegotiateFn` signature but is intentionally unused — the chain
decides the fallback policy.
## Implementation Notes
- Lookup order: exact → lower-cased short-code expansion → original
short-code expansion → language-subtag scan.
- `LOCALE_BCP47_MAP` is consulted twice (`lowered` first, raw form
as a fallback) so mixed-case inputs like `"En-GB"` resolve via the
same lookup path as `"en"`.
- Whitespace-only and empty inputs return `null` before any lookup
runs.

View File

@@ -0,0 +1,105 @@
# `chain.ts` — locale-detector chain orchestrator
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`createDetectorChain(detectors, options?)` composes any number of
`LocaleDetector` instances into a single runner that resolves the
active locale on every request. The orchestrator owns three concerns
the individual detectors deliberately do not: ordering (sync first,
async raced in parallel), environment gating (browser-only and
server-only detectors are skipped on the wrong tier), and BCP-47
negotiation against `ctx.supportedLocales` via an injectable
`negotiate` function.
The chain returns either a `DetectorChainResult` (locale + winning
detector + raw value) or the `DETECTOR_CHAIN_UNRESOLVED` sentinel
when every detector miss. Server-side renderers can opt into
`throwOnExhausted: true` to receive a typed
`I18nError(I18N.DETECTOR_CHAIN_EXHAUSTED)` instead, which maps
cleanly to a 4xx response.
## Features
- Sync chain executes in registration order; first non-null,
negotiable value wins and short-circuits the rest.
- Async chain races every eligible `detectAsync` in parallel; first
resolution to a supported locale wins.
- Environment gating skips `environment: "browser"` detectors on
server runs and `environment: "server"` detectors on browser runs.
- `cache(locale, ctx)` walks every persisting detector and asks each
to write the negotiated locale back; failures are absorbed.
- AbortSignal threading — `ctx.signal` flows unchanged into every
async detector via `RequestInit.signal`.
- OTel: `i18n.detect` span with `resolved_locale` /
`winning_detector` attributes; `i18n.detector.chain.exhausted_total`
counter increments on every sentinel return.
## Usage: Basic Example
```ts
import {
createDetectorChain,
defaultDetectorChain,
} from "@suaveplan/i18n-detector";
const chain = createDetectorChain(
defaultDetectorChain({ fallback: "en-GB" })
);
const result = await chain.detect({
request: new Request("https://example.com/?lang=fr").clone() as never,
supportedLocales: ["en-GB", "fr-FR"],
fallbackLocale: "en-GB",
});
```
## Usage: Advanced Example
```ts
import {
createDetectorChain,
cookieDetector,
acceptLanguageDetector,
fallbackDetector,
} from "@suaveplan/i18n-detector";
const chain = createDetectorChain(
[cookieDetector(), acceptLanguageDetector(), fallbackDetector("en-GB")],
{
throwOnExhausted: true,
negotiate: (raw, supported) =>
supported.find((s) => s.toLowerCase() === raw.toLowerCase()) ??
null,
}
);
await chain.cache("fr-FR", ctx);
```
## API Reference
- `createDetectorChain(detectors, options?)` — returns `DetectorChain`.
- `DETECTOR_CHAIN_UNRESOLVED: unique symbol` — sentinel return value.
- `DetectorChain.detect(ctx)` — async; returns
`DetectorChainResult | DETECTOR_CHAIN_UNRESOLVED`. Throws
`I18nError(I18N.DETECTOR_CHAIN_EXHAUSTED)` when
`throwOnExhausted: true` and no detector matched.
- `DetectorChain.cache(locale, ctx)` — async; persists the locale via
every eligible detector that exposes `cache()`.
- `DetectorChain.detectors` — read-only registration-order view.
## Implementation Notes
- The chain holds a defensive copy of the supplied detectors, so
later mutation of the input array does not affect the chain.
- Async detector failures (rejections, null resolutions, unsupported
locales) decrement an internal `pending` counter; only the last
loser resolves with `null`. A late winner cannot override a settled
result.
- Environment gating uses `ctx.request !== undefined` as the
is-server probe — no runtime feature detection.
- The injected `negotiate` is invoked with `ctx.supportedLocales`
unchanged; the chain itself never inspects locale strings.

View File

@@ -0,0 +1,87 @@
# `default-chain.ts` — canonical detector-chain factory
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`defaultDetectorChain(options)` builds the canonical 8-or-9 entry
detector chain consumed by `<TranslationProvider>` and
`useLocaleDetection` (and any other chain consumer that wants a
sensible default without enumerating every detector). The factory
returns a fresh `LocaleDetector[]` on every call so callers can
mutate or filter the result without affecting subsequent
invocations.
The order is deliberate: explicit user signals (querystring,
backend preference, cookie, localStorage) come first, followed by
shell-rendered hints (htmlLang), browser preferences
(acceptLanguage, navigator), geolocation (cloudflareIpGeo), and
finally the unconditional fallback.
## Features
- 8-entry chain when `userPreferenceEndpoint` is omitted; 9-entry
chain when it is supplied. The user-preference detector slots
in second so authenticated users' saved preferences beat every
device-bound signal.
- Fresh array per call — callers can `.filter` / `.splice` /
`.concat` without affecting subsequent invocations.
- Fallback always last; the factory wires
`fallbackDetector(options.fallback)` as the terminal entry.
- Every detector is constructed with default options — consumers
that need custom names / paths / endpoints assemble their own
chain.
## Usage: Basic Example
```ts
import {
createDetectorChain,
defaultDetectorChain,
} from "@suaveplan/i18n-detector";
const chain = createDetectorChain(
defaultDetectorChain({ fallback: "en-GB" })
);
```
## Usage: Advanced Example
```ts
import {
createDetectorChain,
defaultDetectorChain,
} from "@suaveplan/i18n-detector";
const chain = createDetectorChain(
defaultDetectorChain({
fallback: "en-GB",
userPreferenceEndpoint: "/api/me/locale",
}),
{ throwOnExhausted: true }
);
// Or filter to remove a detector that does not fit the deployment.
const detectors = defaultDetectorChain({ fallback: "en-GB" }).filter(
(d) => d.name !== "cloudflareIpGeo"
);
```
## API Reference
- `defaultDetectorChain(options): LocaleDetector[]` — fresh array
per call.
- `DefaultDetectorChainOptions.fallback: string` — required.
- `DefaultDetectorChainOptions.userPreferenceEndpoint?: string` —
enables the user-preference detector when set.
## Implementation Notes
- The fallback is constructed via `fallbackDetector(...)` which
validates the locale at chain-build time, so a misconfigured
fallback surfaces immediately rather than at the first request.
- The factory makes no assumptions about cookie name, storage
key, querystring param name, or geolocation header. Consumers
that need custom configuration assemble their own chain by
importing each detector directly.

View File

@@ -0,0 +1,86 @@
# `accept-language.ts` — `Accept-Language` HTTP header detector
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`acceptLanguageDetector()` and the standalone
`parseAcceptLanguage(header)` parser implement RFC 9110 §5.3.5
parsing of the inbound `Accept-Language` request header. The
detector returns the highest-q entry; `parseAcceptLanguage` is
exported for advanced consumers building custom detectors layered
on top of this one (e.g. honouring a specific browser's quirks, or
combining the header with a user-agent classifier).
The detector is `environment: "server"` because the header is
only meaningful for the inbound request — SSR and Edge runtimes
consume it; browsers do not see their own outbound headers. Any
detector chain running in either tier benefits from including
this entry behind cookie/localStorage so first-time visitors fall
through to a sensible default without configuration.
## Features
- RFC 9110 §5.3.5 q-value parsing with stable sort (insertion
order preserved on ties).
- Wildcard `*` tokens skipped — they carry no information that
can be negotiated.
- Malformed q values, q values out of range `[0, 1]`, and
unparseable parameters skipped per the RFC's "be liberal in
what you accept" guidance.
- Empty headers and missing `accept-language` return `null`.
- `environment: "server"` — automatic browser skip via the chain
runner.
## Usage: Basic Example
```ts
import { acceptLanguageDetector } from "@suaveplan/i18n-detector/detectors/accept-language";
const detector = acceptLanguageDetector();
const locale = detector.detect?.({
request: {
headers: new Headers({
"accept-language": "en;q=0.5, fr;q=0.9, de",
}),
url: new URL("https://example.com/"),
},
supportedLocales: ["en-GB", "fr-FR", "de-DE"],
fallbackLocale: "en-GB",
});
// locale === "de" (default q=1)
```
## Usage: Advanced Example
```ts
import { parseAcceptLanguage } from "@suaveplan/i18n-detector/detectors/accept-language";
const entries = parseAcceptLanguage(
"fr-FR;q=0.9, fr;q=0.8, en-GB;q=0.7, en;q=0.6"
);
// entries: [
// { locale: "fr-FR", q: 0.9 },
// { locale: "fr", q: 0.8 },
// { locale: "en-GB", q: 0.7 },
// { locale: "en", q: 0.6 },
// ]
```
## API Reference
- `acceptLanguageDetector(): LocaleDetector` — `environment: "server"`.
- `parseAcceptLanguage(header: string): AcceptLanguageEntry[]`.
- `AcceptLanguageEntry = { locale: string; q: number }`.
## Implementation Notes
- The sort is implemented by mapping `(entry, index)` pairs and
comparing `(b.q - a.q) || (a.index - b.index)` so ties preserve
insertion order — `Array.prototype.sort` is not guaranteed
stable on every runtime ECMA spec actively supports.
- Default q value is 1 when no `q=` parameter appears, per the
RFC.
- `q=2.0` and other out-of-range values cause the entire entry to
be discarded (the parser refuses to "fix" the value to 1 or 0).

View File

@@ -0,0 +1,87 @@
# `cloudflare-ip-geo.ts` — Cloudflare IP-geo detector
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`cloudflareIpGeoDetector(options?)` builds a `LocaleDetector` that
maps the Cloudflare `cf-ipcountry` header (ISO 3166-1 alpha-2) to
a BCP-47 locale. It is the lowest-precedence detector in the
canonical chain — only consulted when no querystring, cookie,
storage, or browser preference signal exists — because country
geolocation is at best a coarse approximation of language
preference (the UK and India both speak English; Switzerland has
four official languages).
Resolution order, per ISO code:
1. `options.countryToLocale[code]` if provided.
2. `${language}-${code}` where `language` is the first entry from
the country record's `languages` array in
`@suaveplan/locale-data`.
3. `null` when neither override nor language data exists.
## Features
- Default header `"cf-ipcountry"`, configurable for non-Cloudflare
edge providers.
- Per-country override map wins over the data-derived default.
- `environment: "server"` — automatic browser skip via the chain
runner.
- Lowercased header values are upper-cased before lookup so
`Headers.get` casing quirks don't break resolution.
- Missing header, empty header, and unknown country codes all
return `null`.
## Usage: Basic Example
```ts
import { cloudflareIpGeoDetector } from "@suaveplan/i18n-detector/detectors/cloudflare-ip-geo";
const detector = cloudflareIpGeoDetector();
const locale = detector.detect?.({
request: {
headers: new Headers({ "cf-ipcountry": "AE" }),
url: new URL("https://example.com/"),
},
supportedLocales: ["en-GB", "ar-AE"],
fallbackLocale: "en-GB",
});
// locale === "ar-AE"
```
## Usage: Advanced Example
```ts
import { cloudflareIpGeoDetector } from "@suaveplan/i18n-detector/detectors/cloudflare-ip-geo";
const detector = cloudflareIpGeoDetector({
headerName: "x-vercel-ip-country",
countryToLocale: {
CH: "de-CH", // Swiss German default — overrides Latin order
BE: "nl-BE",
IN: "en-IN",
},
});
```
## API Reference
- `cloudflareIpGeoDetector(options?): LocaleDetector` —
`environment: "server"`, `name: "cloudflareIpGeo"`. Has only
`detect`; no `detectAsync`, no `cache`.
- `CloudflareIpGeoDetectorOptions.headerName?: string` — default
`"cf-ipcountry"`.
- `CloudflareIpGeoDetectorOptions.countryToLocale?: Readonly<Record<string, string>>`.
## Implementation Notes
- `getAllCountries()` is called once at detector-build time and
the result is captured in closure scope; subsequent `detect`
calls reuse the same record. Each detector instance therefore
has O(1) lookup cost.
- The `${primaryLanguage}-${code}` template is correct for the
vast majority of countries. The override map exists precisely
for the multilingual countries where a single primary language
would mis-serve users.

View File

@@ -0,0 +1,88 @@
# `cookie.ts` — cookie-backed locale detector
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`cookieDetector(options?)` builds a `LocaleDetector` that reads and
writes the active locale via a single cookie. On the server it
reads the inbound `Cookie` header from `ctx.request.headers` and
writes `Set-Cookie` to `ctx.responseHeaders`. On the browser it
reads and writes `document.cookie` directly. The detector is the
canonical persisted preference for unauthenticated visitors —
device-local but survives navigation, and works in both tiers
without further plumbing.
Cookie values are URL-encoded on write so locales containing non-
token characters (none in the BCP-47 subset, but a defensive
guard) cannot break the cookie wire format.
## Features
- `environment: "both"` — reads and writes on either tier.
- Default cookie name `"suaveplan_locale"`, configurable.
- Default `Max-Age` of 31_536_000s (one year), configurable.
- Default `SameSite=Lax`, configurable to `Strict` or `None`.
- Default `Path=/`, configurable.
- Server-side write goes through `Headers.append("set-cookie", …)`
so multiple cookies can coexist in one response.
- Missing both `ctx.request` and any `document` global causes
`detect` to return `null` and `cache` to no-op.
## Usage: Basic Example
```ts
import { cookieDetector } from "@suaveplan/i18n-detector/detectors/cookie";
const detector = cookieDetector();
const locale = detector.detect?.({
request: {
headers: new Headers({ cookie: "suaveplan_locale=fr-FR" }),
url: new URL("https://example.com/"),
},
supportedLocales: ["en-GB", "fr-FR"],
fallbackLocale: "en-GB",
});
// locale === "fr-FR"
```
## Usage: Advanced Example
```ts
import { cookieDetector } from "@suaveplan/i18n-detector/detectors/cookie";
const detector = cookieDetector({
name: "ui_locale",
maxAgeSeconds: 60 * 60 * 24 * 30, // 30 days
sameSite: "strict",
path: "/app",
});
await detector.cache?.("zh-Hant-TW", {
responseHeaders: new Headers(),
supportedLocales: ["en-GB", "zh-Hant-TW"],
fallbackLocale: "en-GB",
});
```
## API Reference
- `cookieDetector(options?): LocaleDetector`
- `CookieDetectorOptions.name?: string` — default `"suaveplan_locale"`.
- `CookieDetectorOptions.maxAgeSeconds?: number` — default `31536000`.
- `CookieDetectorOptions.sameSite?: "lax" | "strict" | "none"`.
- `CookieDetectorOptions.path?: string` — default `"/"`.
- `CookieSameSite` — alias for the SameSite union.
## Implementation Notes
- Cookie parsing splits on `;`, trims each segment, and looks up
the configured name only — other cookies in the header are
ignored.
- Decode failures (e.g. percent sequences without two hex digits)
surface the raw value rather than throwing. This matches browser
behaviour and keeps the detector resilient to legacy cookies.
- The `Secure` flag is intentionally NOT auto-added when
`SameSite=None`; that enforcement is tracked as the separate
`cookie-detector-secure-flag` change.

View File

@@ -0,0 +1,75 @@
# `fallback.ts` — unconditional fallback detector
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`fallbackDetector(locale)` builds a `LocaleDetector` that always
returns the configured locale. The detector is the floor of every
chain — when querystring, cookie, storage, header, navigator, and
geo all miss, the fallback guarantees a deterministic response.
Because the detector never returns `null`, it MUST appear last in
any chain — any detector after it would never run. The constructor
validates the supplied locale up-front and throws
`I18nError(I18N.INVALID_LOCALE)` on empty / whitespace-only input
so a misconfiguration surfaces at chain-build time rather than at
the first request.
## Features
- `environment: "both"` — eligible on every chain run.
- Constructor-time validation: empty string / whitespace string /
non-string locale all throw a typed `I18nError(INVALID_LOCALE)`.
- Locale value is trimmed once at construction; subsequent
`detect` calls reuse the cached value.
- Read-only — no `cache()` because the fallback has no source to
persist to.
## Usage: Basic Example
```ts
import { fallbackDetector } from "@suaveplan/i18n-detector/detectors/fallback";
const detector = fallbackDetector("en-GB");
const locale = detector.detect?.({
supportedLocales: ["en-GB", "fr-FR"],
fallbackLocale: "en-GB",
});
// locale === "en-GB"
```
## Usage: Advanced Example
```ts
import { I18nError } from "@suaveplan/i18n-shared";
import { fallbackDetector } from "@suaveplan/i18n-detector/detectors/fallback";
try {
fallbackDetector("");
} catch (err) {
if (err instanceof I18nError) {
console.error("misconfigured fallback:", err.code, err.message);
}
}
```
## API Reference
- `fallbackDetector(locale: string): LocaleDetector` —
`environment: "both"`, `name: "fallback"`. Has only `detect`;
no `detectAsync`, no `cache`.
- Throws `I18nError(I18N.INVALID_LOCALE)` synchronously on
empty / whitespace / non-string input.
## Implementation Notes
- Validation runs once at detector-build time, not on every
`detect` call — the chain pays no per-request cost for the
guard.
- The trimmed locale is captured by closure so the returned
detector is allocation-free per request.
- The fallback's `name` is fixed at `"fallback"` so chain
consumers can identify it deterministically (e.g. to render
a "default locale" warning in dev tools).

View File

@@ -0,0 +1,76 @@
# `html-lang.ts` — `<html lang>` detector
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`htmlLangDetector()` builds a `LocaleDetector` that reads
`document.documentElement.lang`. The detector is
`environment: "browser"` and never runs in SSR. Its niche is the
common case where a static HTML shell already declares
`<html lang="…">` — typically rendered by the SSR layer for
crawlers and accessibility — and the runtime surface wants to
honour that declaration without coupling to a specific framework.
This detector contributes a stable, framework-neutral signal that
sits between cookie/localStorage (device preferences) and
navigator (raw browser preferences). It is a read-only detector —
no `cache()` method — because the SSR layer is the source of
truth for the `<html lang>` attribute.
## Features
- `environment: "browser"` — automatic SSR skip via the chain
runner.
- Reads from `ctx.document` if supplied, else from
`globalThis.document`.
- Empty / whitespace-only `lang` attributes return `null`.
- Missing `documentElement` (impossible in real browsers but a
defensive guard for jsdom shims) returns `null`.
- No persistence — there is no `cache()` method.
## Usage: Basic Example
```ts
import { htmlLangDetector } from "@suaveplan/i18n-detector/detectors/html-lang";
const detector = htmlLangDetector();
const locale = detector.detect?.({
supportedLocales: ["en-GB", "fr-FR"],
fallbackLocale: "en-GB",
});
```
## Usage: Advanced Example
```ts
import { htmlLangDetector } from "@suaveplan/i18n-detector/detectors/html-lang";
const fakeDoc = {
documentElement: { lang: "fr-FR" },
cookie: "",
} as unknown as Document;
const detector = htmlLangDetector();
const locale = detector.detect?.({
document: fakeDoc,
supportedLocales: ["en-GB", "fr-FR"],
fallbackLocale: "en-GB",
});
```
## API Reference
- `htmlLangDetector(): LocaleDetector` — `environment: "browser"`,
`name: "htmlLang"`. Has only `detect`; no `detectAsync`, no
`cache`.
## Implementation Notes
- The detector tolerates a missing `globalThis.document` (e.g.
Workers / Edge runtimes that have a request-bound DOM shim)
without throwing.
- `root.lang` is asserted as `string` defensively — some custom
document polyfills expose `Element` instances whose `lang`
property is undefined.

View File

@@ -0,0 +1,80 @@
# `local-storage.ts` — `localStorage`-backed locale detector
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`localStorageDetector(options?)` builds a `LocaleDetector` that
reads and writes the active locale from a `Storage`-shaped backing
store. The detector is `environment: "browser"` so the chain
runner automatically skips it during SSR. Browser-side persistence
sits between cookies (which round-trip on every request) and
backend-stored user preference (which requires authentication) —
ideal for unauthenticated single-page apps that want fast
client-side reads after the first visit without paying the cookie
header cost on every request.
## Features
- Default backing store is `globalThis.localStorage`.
- `options.storage` injects an alternative `LocalStorageLike`
(e.g. a session-storage handle, or an in-memory test fake).
- Default key `"suaveplan-locale"`, configurable.
- All storage access wrapped in `try/catch` — private mode,
disabled storage, and quota exhaustion all surface as `null` /
silent no-op.
- `environment: "browser"` — server runs skip the detector
entirely, so SSR contexts never need to inject a fake.
## Usage: Basic Example
```ts
import { localStorageDetector } from "@suaveplan/i18n-detector/detectors/local-storage";
const detector = localStorageDetector();
const locale = detector.detect?.({
supportedLocales: ["en-GB", "fr-FR"],
fallbackLocale: "en-GB",
});
```
## Usage: Advanced Example
```ts
import { localStorageDetector } from "@suaveplan/i18n-detector/detectors/local-storage";
const fake = new Map<string, string>();
const detector = localStorageDetector({
key: "app:locale",
storage: {
getItem: (k) => fake.get(k) ?? null,
setItem: (k, v) => {
fake.set(k, v);
},
removeItem: (k) => {
fake.delete(k);
},
},
});
await detector.cache?.("fr-FR", ctx);
```
## API Reference
- `localStorageDetector(options?): LocaleDetector`
- `LocalStorageDetectorOptions.key?: string` — default
`"suaveplan-locale"`.
- `LocalStorageDetectorOptions.storage?: LocalStorageLike`.
- `LocalStorageLike` — `{ getItem, setItem, removeItem }` shape.
## Implementation Notes
- `resolveStorage` checks both `getItem` and `setItem` are
functions before treating the candidate as a `LocalStorageLike`
to defend against custom shims that expose only one half of the
contract.
- Empty-string reads are treated as a miss — Safari has been known
to return `""` rather than `null` for unset keys in private mode.
- Cache failures (quota, private mode, disabled storage) are
swallowed; the detector NEVER throws into the consumer.

View File

@@ -0,0 +1,85 @@
# `navigator.ts` — `navigator.languages` detector
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`navigatorDetector()` builds a `LocaleDetector` that reads the
active locale from the browser's
`globalThis.navigator.languages` (preferred) or
`globalThis.navigator.language` (fallback). The chain runner
negotiates the returned value against `ctx.supportedLocales` so
this detector contributes the user's raw browser preference and
lets the resolver perform BCP-47 fallback (so a user with
`["fr-CA", "en-US"]` browser settings still receives `fr-FR`
when only `fr-FR` is supported).
The detector is `environment: "browser"` and never runs in SSR.
It is the lowest-priority signal in the canonical chain because
browser preferences are coarse — they reflect OS-level locale
rather than per-application choice — but they are the strongest
signal available for first-time, unauthenticated visitors with
no cookies or localStorage.
## Features
- Reads `navigator.languages` array first (preferred — every
modern browser exposes it).
- Falls back to `navigator.language` when `languages` is missing
or empty.
- Skips whitespace-only entries; returns the first non-empty.
- Missing `navigator` global returns `null` (Workers, server
contexts that mistakenly bypass environment gating).
- `environment: "browser"` — server runs skip the detector
entirely.
## Usage: Basic Example
```ts
import { navigatorDetector } from "@suaveplan/i18n-detector/detectors/navigator";
const detector = navigatorDetector();
const locale = detector.detect?.({
supportedLocales: ["en-GB", "fr-FR"],
fallbackLocale: "en-GB",
});
```
## Usage: Advanced Example
```ts
import { navigatorDetector } from "@suaveplan/i18n-detector/detectors/navigator";
// Test fixture: stub navigator.
const original = (globalThis as { navigator?: unknown }).navigator;
(globalThis as { navigator?: unknown }).navigator = {
languages: ["fr-CA", "en-US"],
language: "fr-CA",
};
const detector = navigatorDetector();
const locale = detector.detect?.({
supportedLocales: ["en-GB", "fr-FR"],
fallbackLocale: "en-GB",
});
(globalThis as { navigator?: unknown }).navigator = original;
```
## API Reference
- `navigatorDetector(): LocaleDetector` — `environment: "browser"`,
`name: "navigator"`. Has only `detect`; no `detectAsync`, no
`cache`.
## Implementation Notes
- The detector NEVER throws even when `navigator` is partially
defined (only `language`, only `languages`, or neither). The
chain runner skips browser detectors on server runs anyway, but
the in-detector guards keep the behaviour predictable in
unusual runtime shims.
- Whitespace-only entries are skipped because some Android
browsers historically emitted `" "` as a sentinel for
"preference unset".

View File

@@ -0,0 +1,77 @@
# `querystring.ts` — `?lang=` querystring detector
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`querystringDetector(options?)` builds a `LocaleDetector` that reads
the active locale from a URL search parameter. It runs in both the
browser tier (consulting `globalThis.location.search`) and the
server tier (consulting `ctx.request.url.searchParams`), so the
same detector entry can sit at the top of every chain regardless
of where the chain is executed. Querystring detection is the
highest-precedence signal because it represents an explicit user
override (a tester switching locale via the URL bar, an outbound
link from a translated page) that should beat every persisted
preference.
When neither the request nor the browser global is available the
detector falls through with `null`, letting the chain advance to
the next entry.
## Features
- Default param name `"lang"`, configurable via
`options.paramName`.
- Server-tier read via `ctx.request.url.searchParams.get(...)`.
- Browser-tier read via `URLSearchParams(globalThis.location.search)`.
- Empty values and missing globals return `null`, never throw.
- `environment: "both"` — eligible on every chain run.
## Usage: Basic Example
```ts
import { querystringDetector } from "@suaveplan/i18n-detector/detectors/querystring";
const detector = querystringDetector();
const result = detector.detect?.({
request: {
headers: new Headers(),
url: new URL("https://example.com/?lang=fr"),
},
supportedLocales: ["en-GB", "fr-FR"],
fallbackLocale: "en-GB",
});
// result === "fr"
```
## Usage: Advanced Example
```ts
import {
createDetectorChain,
querystringDetector,
cookieDetector,
} from "@suaveplan/i18n-detector";
const chain = createDetectorChain([
querystringDetector({ paramName: "ui-lang" }),
cookieDetector(),
]);
```
## API Reference
- `querystringDetector(options?: QuerystringDetectorOptions): LocaleDetector`
- `QuerystringDetectorOptions.paramName?: string` — default `"lang"`.
## Implementation Notes
- The browser fallback uses `URLSearchParams` rather than parsing
manually to keep the detector free of edge-case bugs (encoded
characters, repeated keys, etc.).
- The server reader trusts `ctx.request.url` to be a parsed `URL`
instance — request adapters at the edge construct it once and
hand it through every detector, which is significantly faster
than re-parsing per detector call.

View File

@@ -0,0 +1,86 @@
# `user-preference.ts` — backend-stored locale preference detector
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`userPreferenceDetector(options)` builds an async `LocaleDetector`
that calls a backend endpoint to read (and later persist) the
user's stored locale preference. This is the surface authenticated
applications use to honour a user's saved-to-account choice across
devices, ahead of cookie/localStorage which are device-bound.
Unlike most detectors in this package, user-preference is async-
only — it has no `detect` and only contributes during the chain's
async race phase. It runs in both server and browser tiers because
the endpoint is typically a thin JSON API that accepts the
session's auth header from either side.
## Features
- GET `options.endpoint` returning `{ locale }`; non-2xx,
malformed, and rejected responses log a warn and return `null`.
- POST `options.endpoint` with `{ locale }` JSON on
`cache(locale, ctx)`; failures are non-fatal.
- `options.extractAuth(ctx)` injects per-request auth headers
(e.g. session cookie on SSR runs).
- `options.fetch` injectable for tests.
- `options.logger?: LoggerLike` — defaults to a no-op so the
package keeps zero non-shared deps. Production consumers wire
in `@suaveplan/logger`.
- Threads `ctx.signal` into every fetch's `RequestInit.signal`.
## Usage: Basic Example
```ts
import { userPreferenceDetector } from "@suaveplan/i18n-detector/detectors/user-preference";
const detector = userPreferenceDetector({
endpoint: "/api/me/locale",
});
const locale = await detector.detectAsync?.({
supportedLocales: ["en-GB", "fr-FR"],
fallbackLocale: "en-GB",
});
```
## Usage: Advanced Example
```ts
import { userPreferenceDetector } from "@suaveplan/i18n-detector/detectors/user-preference";
import { ConsoleLogger } from "@suaveplan/logger";
const detector = userPreferenceDetector({
endpoint: "/api/me/locale",
logger: new ConsoleLogger({ tags: ["i18n.user-preference"] }),
extractAuth: (ctx) => ({
cookie: ctx.request?.headers.get("cookie") ?? "",
}),
fetch: globalThis.fetch,
});
await detector.cache?.("fr-FR", ctx);
```
## API Reference
- `userPreferenceDetector(options): LocaleDetector` — `environment: "both"`.
- `UserPreferenceDetectorOptions.endpoint: string` — required.
- `UserPreferenceDetectorOptions.extractAuth?: (ctx) => HeadersInit`.
- `UserPreferenceDetectorOptions.cacheKey?: (ctx) => string`.
- `UserPreferenceDetectorOptions.fetch?: typeof fetch`.
- `UserPreferenceDetectorOptions.logger?: LoggerLike`.
- `UserPreferenceResponseBody = { locale: string }` — wire shape.
## Implementation Notes
- A pre-aborted `ctx.signal` typically causes the underlying
`fetch` to reject; the rejection is swallowed and `null` is
returned, never propagated into the chain.
- `Content-Type: application/json` is set automatically on cache
POSTs unless the auth headers already declare one.
- The `cacheKey` field is exposed for consumers that want
request-level dedup but is intentionally not consulted by the
detector itself — caching is the consumer's concern.

View File

@@ -0,0 +1,78 @@
# `direction.ts` — text-direction helpers
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`getTextDirection(locale)` and `isRtl(locale)` resolve a locale's
display direction without instantiating a full locale resolver or
booting the slim core. They are pure functions over `RTL_LOCALES`
from `@suaveplan/i18n-shared` and complete in O(1) — perfect for use
in render hot paths (e.g. `<html dir={getTextDirection(locale)}>`)
where every microsecond saved compounds across SSR fan-out.
The helpers ship in `@suaveplan/i18n-detector` rather than the slim
core because direction lookup is logically part of the locale-
resolution surface — every consumer that detects a locale needs to
know which direction to render — and pulling them here keeps the
slim core lighter.
## Features
- `getTextDirection(locale)` returns `"ltr"` or `"rtl"` for any
BCP-47 locale tag.
- `isRtl(locale)` is sugar for `getTextDirection(locale) === "rtl"`.
- Region subtags are tolerated: `"ar-SA"`, `"he-IL"`, `"fa-IR"` all
resolve to `"rtl"`.
- Lookup is case-insensitive — the language subtag is lower-cased
before the `RTL_LOCALES` set probe.
- Unknown locales default to `"ltr"`, matching browser behaviour.
## Usage: Basic Example
```ts
import { getTextDirection, isRtl } from "@suaveplan/i18n-detector/direction";
getTextDirection("ar"); // "rtl"
getTextDirection("ar-SA"); // "rtl"
getTextDirection("en-GB"); // "ltr"
isRtl("he-IL"); // true
```
## Usage: Advanced Example
```tsx
import { getTextDirection } from "@suaveplan/i18n-detector/direction";
export function HtmlShell({
locale,
children,
}: {
locale: string;
children: React.ReactNode;
}) {
return (
<html lang={locale} dir={getTextDirection(locale)}>
{children}
</html>
);
}
```
## API Reference
- `getTextDirection(locale: string): "ltr" | "rtl"` — pure.
- `isRtl(locale: string): boolean` — pure.
- `type TextDirection = "ltr" | "rtl"` — re-exported for consumer
type-safety.
## Implementation Notes
- The language subtag is everything before the first `-`. Multi-tag
locales (`zh-Hant-TW`) resolve via the first segment only — script
subtags are ignored because every script-bearing RTL locale is
already covered by its language subtag.
- Adding a new RTL language is a one-line change in
`@suaveplan/i18n-shared/constants/rtl-locales.ts` — no detector
code needs to be touched.

View File

@@ -0,0 +1,94 @@
# `metrics.ts` — detector-chain OTel instruments
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`metrics.ts` owns the OTel surface for the entire
`@suaveplan/i18n-detector` package. It exposes lazily-built
instruments (`tracer`, `matchTotal`, `durationMs`,
`detectedTotal`, `chainExhaustedTotal`) plus an in-memory state
ring so spec-conformance tests can assert on exact attribute
payloads without standing up a full `MeterProvider` +
`InMemoryMetricExporter` pipeline.
All instruments are no-ops when no `MeterProvider` /
`TracerProvider` is registered (the default), so consumers pay
zero overhead unless they wire up the SDK. The cached singleton
is reset between tests via `resetDetectorMetricsCache()`.
## Features
- `getDetectorMetrics()` — lazily builds the tracer, counters, and
histograms; caches the result.
- `recordDetectorMatch(detector, locale)` — increments the
aggregate `i18n.detector.match_total{detector}` counter AND
the per-locale `i18n.detector.detected_total{detector,locale}`
counter, mirroring both events into the in-memory state ring.
- `recordDetectorDuration(detector, durationMs)` — records
`i18n.detector.duration_ms{detector}` and pushes the sample
into state.
- `recordChainExhausted()` — increments
`i18n.detector.chain.exhausted_total` on every sentinel return.
- `getDetectorMetricsState()` / `resetDetectorMetricsState()` —
test-only helpers for asserting + clearing the in-memory ring.
- `resetDetectorMetricsCache()` — drops the singleton so a fresh
meter can be built between tests.
## Usage: Basic Example
```ts
import { getDetectorMetrics } from "@suaveplan/i18n-detector";
const m = getDetectorMetrics();
m.matchTotal.add(1, { detector: "cookie" });
```
## Usage: Advanced Example
```ts
import {
getDetectorMetricsState,
recordDetectorMatch,
resetDetectorMetricsState,
} from "@suaveplan/i18n-detector";
beforeEach(() => resetDetectorMetricsState());
it("increments detected_total once per match", () => {
recordDetectorMatch("cookie", "fr-FR");
const state = getDetectorMetricsState();
expect(state.detectedTotal).toEqual([
{ attributes: { detector: "cookie", locale: "fr-FR" } },
]);
});
```
## API Reference
- `getDetectorMetrics()` — returns the cached `CachedInstruments`
object (`tracer`, `matchTotal`, `durationMs`,
`detectedTotal`, `chainExhaustedTotal`).
- `recordDetectorMatch(detector, locale)` — emits both aggregate
and labelled counters.
- `recordDetectorDuration(detector, ms)` — emits the duration
histogram.
- `recordChainExhausted()` — emits the chain-exhausted counter.
- `getDetectorMetricsState()` / `resetDetectorMetricsState()` /
`resetDetectorMetricsCache()` — test plumbing.
- `DetectorMetricSample` / `DetectorMetricsState` — in-memory
ring shape.
## Implementation Notes
- The cached singleton has process-wide lifetime by design —
metrics emitters are not per-chain because the chain is a thin
orchestrator and detector names are globally unique.
- `state.detectedTotal` carries `{detector, locale}` attributes;
`state.matchTotal` carries `{detector}` only. Tests that need
per-locale assertions read `detectedTotal`; tests that only
care about which detector won read `matchTotal`.
- All sample arrays are unbounded — `resetDetectorMetricsState`
is the safety valve. Production code should never call the
state-inspection helpers (they exist for spec conformance).

View File

@@ -0,0 +1,104 @@
# @suaveplan/i18n-formatter-collator - Intl.Collator wrapper for the @suaveplan/i18n family — locale-aware string comparison and sorting (sensitivity, numeric-aware natural sort, case-first, ignorePunctuation, custom collation). Per-(locale, options-hash) cache, Zod-validated options, OTel histogram + cache-hit counter, structured I18nError mapping. Brand-new formatter — closes the gap from the original i18n-formatters change scope. Universal leaf package.
## Summary
Wrapper around `Intl.Collator` for the `@suaveplan/i18n` family —
locale-aware string comparison and sorting (sensitivity, numeric-aware
natural sort, case-first, ignorePunctuation, custom collation).
This is a **brand-new formatter** in the i18n family — `Intl.Collator`
was listed in the original `i18n-formatters` change scope but was
never implemented in the monolith. This package closes that gap.
<!-- begin:auto-docs -->
## Documentation tree
- [formatter](./src/formatter/formatter.md) — `CollatorFormatter` and the `compareStrings` / `formatCollation` standalone helpers expose `Intl.Collator` for the `@su…
- [formatter](./src/formatter/formatter.md) — `CollatorFormatter` and the `compareStrings` / `formatCollation` standalone helpers expose `Intl.Collator` for the `@su…
- [helpers](./src/helpers/helpers.md) — The three ergonomic helpers (`compareCaseInsensitive`, `compareNumericAware`, `sortStrings`) pre-bind `compareStrings`…
- [helpers](./src/helpers/helpers.md) — The three ergonomic helpers (`compareCaseInsensitive`, `compareNumericAware`, `sortStrings`) pre-bind `compareStrings`…
- [observability](./src/observability/observability.md) — The Collator formatter exports two OpenTelemetry instruments and a small in-memory state ring so spec-conformance tests…
- [observability](./src/observability/observability.md) — The Collator formatter exports two OpenTelemetry instruments and a small in-memory state ring so spec-conformance tests…
<!-- end:auto-docs -->
## Why this exists
The 14-package i18n modular split (cross-cutting design
`2026-05-05-i18n-modular-split.md`) lifts each `Intl.*` wrapper into
its own sibling package and adds the two formatters
(`Collator` + `Segmenter`) that the original change scope listed but
never shipped. Locale-aware comparison is the right primitive for
sorting names and substring search, and is required by downstream
consumers like `@suaveplan/dedup` and `@suaveplan/react-data-table`.
## Install
```bash
bun add @suaveplan/i18n-formatter-collator
```
## Quick Start
```ts
import {
CollatorFormatter,
compareStrings,
formatCollation,
sortStrings,
compareCaseInsensitive,
compareNumericAware,
} from "@suaveplan/i18n-formatter-collator";
compareStrings("apple", "banana", "en-GB"); // -1
sortStrings(["c", "a", "b"], "en-GB"); // ["a", "b", "c"]
compareCaseInsensitive("a", "A", "en-GB"); // 0
compareNumericAware("file2", "file10", "en-GB"); // -1
const f = new CollatorFormatter({ numeric: true });
f.sort(["log10.txt", "log2.txt", "log1.txt"], "en-GB");
// ["log1.txt", "log2.txt", "log10.txt"]
```
## Modules
| Subpath | Description |
|---|---|
| `@suaveplan/i18n-formatter-collator` | Root barrel — class + standalones + helpers + observability |
| `@suaveplan/i18n-formatter-collator/formatter` | `CollatorFormatter`, `compareStrings`, `formatCollation`, types, schema |
| `@suaveplan/i18n-formatter-collator/helpers` | `compareCaseInsensitive`, `compareNumericAware`, `sortStrings` |
## Modules Documentation
- [formatter.md](src/formatter.md) — `CollatorFormatter`, `compareStrings`, `formatCollation`, `clearCollatorCache`
- [helpers.md](src/helpers.md) — three ergonomic helpers
- [observability.md](src/observability.md) — OTel histogram + cache-hit counter
## Public Surface (snapshot)
| Export | Kind | Origin |
|---|---|---|
| `CollatorFormatter` | class | `formatter.ts` |
| `compareStrings` | function | `formatter.ts` |
| `formatCollation` | function | `formatter.ts` |
| `clearCollatorCache` | function | `formatter.ts` |
| `CollatorFormatterOptionsSchema` | Zod schema | `formatter.schemas.ts` |
| `CollatorFormatterOptions` | type | `formatter.types.ts` |
| `CollatorUsage` | type | `formatter.types.ts` |
| `CollatorSensitivity` | type | `formatter.types.ts` |
| `CollatorCaseFirst` | type | `formatter.types.ts` |
| `CollatorNumeric` | type | `formatter.types.ts` |
| `CollatorFormatterCacheKey` | type | `formatter.types.ts` |
| `compareCaseInsensitive` | function | `helpers.ts` |
| `compareNumericAware` | function | `helpers.ts` |
| `sortStrings` | function | `helpers.ts` |
| `recordCollatorFormat` | function | `observability.ts` |
| `recordCollatorCacheHit` | function | `observability.ts` |
| `getCollatorMetricsState` | function | `observability.ts` |
| `resetCollatorMetricsState` | function | `observability.ts` |
## Versioning
Part of the 14-package i18n modular-split rollout. Stays at `0.1.0`
until the slim core (Wave 6) cuts the family `1.0.0` release.

View File

@@ -0,0 +1,121 @@
# CollatorFormatter
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`CollatorFormatter` and the `compareStrings` / `formatCollation`
standalone helpers expose `Intl.Collator` for the `@suaveplan/i18n`
family at the canonical Wave-3 formatter surface. Locale-aware string
comparison is the right primitive for everything from sorting names
in a locale-correct order to powering substring search that ignores
diacritics.
This is a brand-new formatter — `Intl.Collator` was listed in the
original `i18n-formatters` change scope but never implemented in the
monolith. The split package closes that gap with the canonical Wave-3
shape.
## Features
- `CollatorFormatter` class with `compare(a, b, locale)` and `sort(values, locale)` methods
- `compareStrings(a, b, locale, options?)` standalone — returns `-1 | 0 | 1`
- `formatCollation(values, locale, options?)` standalone — returns a NEW sorted array (input never mutated)
- All `Intl.Collator` options supported: `usage`, `sensitivity`, `ignorePunctuation`, `numeric`, `caseFirst`, `collation`
- Construction-time Zod validation with `INVALID_FORMATTER_OPTIONS` mapping
- `Intl.Collator` `RangeError` mapped to `UNSUPPORTED_LOCALE` with original error attached as `cause`
- Per-(locale, options-hash) cache — repeated calls reuse the same `Intl.Collator`
- `clearCollatorCache()` test-only helper
- Compare result normalised to `-1 | 0 | 1` (matches familiar `Array.sort` callback shape)
## Usage: Basic Example
```ts
import {
compareStrings,
formatCollation,
} from "@suaveplan/i18n-formatter-collator";
compareStrings("apple", "banana", "en-GB"); // -1
formatCollation(["c", "a", "b"], "en-GB"); // ["a", "b", "c"]
// Numeric-aware natural sort.
formatCollation(
["file10", "file2", "file1"],
"en-GB",
{ numeric: true }
); // ["file1", "file2", "file10"]
```
## Usage: Advanced Example
```ts
import {
CollatorFormatter,
type CollatorFormatterOptions,
} from "@suaveplan/i18n-formatter-collator";
// Substring-search collator — accent- and case-insensitive.
const search: CollatorFormatterOptions = {
usage: "search",
sensitivity: "base",
};
const f = new CollatorFormatter(search);
// Search "café" matches "cafe" / "Cafe" / "CAFE".
function matches(needle: string, haystack: string, locale: string) {
// For substring search the collator's compare function
// returns 0 for matching items.
return f.compare(needle, haystack, locale) === 0;
}
matches("café", "cafe", "en-GB"); // true
// Phonebook-style sort for German.
const phonebook = new CollatorFormatter({ collation: "phonebk" });
phonebook.sort(["Müller", "Schmidt", "Mueller"], "de-DE");
```
## API Reference
### `compareStrings(a, b, locale, options?): -1 | 0 | 1`
Standalone helper. Validates options via Zod, looks up or constructs
the cached `Intl.Collator`, and returns the normalised compare result.
Throws `I18nError(INVALID_FORMATTER_OPTIONS)` on Zod failure or
`I18nError(UNSUPPORTED_LOCALE)` on `Intl.Collator` `RangeError`.
### `formatCollation(values, locale, options?): string[]`
Standalone helper. Returns a NEW array sorted via the configured
collator. Input is never mutated.
### `class CollatorFormatter`
- `constructor(options?: CollatorFormatterOptions)` — Zod-validates options
- `compare(a, b, locale): -1 | 0 | 1`
- `sort(values: readonly string[], locale): string[]` — returns a NEW sorted array
### `clearCollatorCache(): void`
Test-only helper that empties the per-(locale, options-hash) cache.
## Implementation Notes
- The cache key is `${locale}|${JSON.stringify(options)}`. JSON
serialisation is stable for the option shape — every field is a
string, boolean, or undefined.
- `compareStrings` normalises the raw `Intl.Collator.compare` return
value (any signed number) to the `-1 | 0 | 1` triple so callers can
use it directly as an `Array.sort` callback or as a switch
discriminant.
- `formatCollation` uses `[...values].sort(collator.compare)` — the
spread copies the input, then `Array.sort` mutates the copy in
place. Result is referentially distinct from the input.
- `compareStrings` and `formatCollation` BOTH go through the same
cache. A single collator can serve both compare and sort patterns.
- `Intl.Collator` is supported in Node 14+ and every current browser.
The package does not provide a polyfill — Node 14 is below the
family's documented support floor anyway.

View File

@@ -0,0 +1,107 @@
# Collator ergonomic helpers
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The three ergonomic helpers (`compareCaseInsensitive`,
`compareNumericAware`, `sortStrings`) pre-bind `compareStrings` and
`formatCollation` with the most common option configurations so
consumers don't have to remember which `sensitivity` value matches
case-insensitive comparison or which boolean flag enables natural
sort.
## Features
- `compareCaseInsensitive(a, b, locale)` — pre-binds `sensitivity: "accent"` (case-insensitive but accent-aware)
- `compareNumericAware(a, b, locale)` — pre-binds `numeric: true` (natural sort: `"file2" < "file10"`)
- `sortStrings(values, locale, options?)` — alias for `formatCollation` with a more familiar name
## Usage: Basic Example
```ts
import {
compareCaseInsensitive,
compareNumericAware,
sortStrings,
} from "@suaveplan/i18n-formatter-collator";
compareCaseInsensitive("apple", "Apple", "en-GB"); // 0
compareNumericAware("file2", "file10", "en-GB"); // -1
sortStrings(["c", "a", "b"], "en-GB"); // ["a", "b", "c"]
sortStrings(["file10", "file2"], "en-GB", { numeric: true });
// ["file2", "file10"]
```
## Usage: Advanced Example
```ts
import {
compareCaseInsensitive,
compareNumericAware,
sortStrings,
} from "@suaveplan/i18n-formatter-collator";
// Build a case-insensitive search filter.
function findItems(needle: string, items: string[], locale: string) {
return items.filter(
(item) => compareCaseInsensitive(needle, item, locale) === 0
);
}
// Sort a list of file names naturally.
const files = ["log10.txt", "log2.txt", "log1.txt"];
const sorted = sortStrings(files, "en-GB", { numeric: true });
// ["log1.txt", "log2.txt", "log10.txt"]
// Sort then group names case-insensitively.
function groupBy<T>(
items: readonly T[],
keyFn: (item: T) => string,
locale: string
): Record<string, T[]> {
const sortedItems = [...items].sort((x, y) =>
compareCaseInsensitive(keyFn(x), keyFn(y), locale)
);
const out: Record<string, T[]> = {};
for (const item of sortedItems) {
const key = keyFn(item);
const bucket = out[key] ?? [];
bucket.push(item);
out[key] = bucket;
}
return out;
}
```
## API Reference
### `compareCaseInsensitive(a, b, locale): -1 | 0 | 1`
Pre-binds `sensitivity: "accent"`. Treats `"a"` and `"A"` as equal
(case-insensitive) but distinguishes `"a"` from `"á"`
(accent-sensitive). For full base-letter comparison (treating `"a"`
and `"á"` equal as well), call `compareStrings` directly with
`sensitivity: "base"`.
### `compareNumericAware(a, b, locale): -1 | 0 | 1`
Pre-binds `numeric: true`. Embedded numbers sort by their numeric
value, not their lexicographic order: `"file2"` sorts before
`"file10"` (lexicographic would put `"file10"` first).
### `sortStrings(values, locale, options?): string[]`
Alias for `formatCollation` with a familiar name. Returns a NEW
sorted array; the input is never mutated. All options forwarded.
## Implementation Notes
- Each helper is a one-line wrapper around `compareStrings` /
`formatCollation`. The underlying per-(locale, options-hash) cache
applies — calling `compareCaseInsensitive` twice with the same
locale reuses one `Intl.Collator`.
- The helpers are pure functions with no shared state.

View File

@@ -0,0 +1,100 @@
# Collator observability
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The Collator formatter exports two OpenTelemetry instruments and a
small in-memory state ring so spec-conformance tests can assert exact
attribute payloads without standing up a full SDK exporter. The
instrument names match the canonical Wave-3 formatter contract —
every sibling formatter emits the same two metric names so dashboards
and alerts compose cleanly across the family.
## Features
- Histogram `i18n.format.collator.duration_ms` (unit `ms`) — per-compare/-sort wall-clock time
- Counter `i18n.format.cache_hit{formatter="collator"}` — increments on cache reuse only
- In-memory state ring (`getCollatorMetricsState()`) for spec-conformance
- `resetCollatorMetricsState()` test-only helper
## Usage: Basic Example
```ts
import { metrics } from "@opentelemetry/api";
import { MeterProvider } from "@opentelemetry/sdk-metrics";
const provider = new MeterProvider({ readers: [/* reader */] });
metrics.setGlobalMeterProvider(provider);
import { compareStrings } from "@suaveplan/i18n-formatter-collator";
compareStrings("a", "b", "en-GB");
// emits via your reader
```
## Usage: Advanced Example
```ts
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
clearCollatorCache,
compareStrings,
} from "@suaveplan/i18n-formatter-collator";
import {
getCollatorMetricsState,
resetCollatorMetricsState,
} from "@suaveplan/i18n-formatter-collator";
it("emits histogram + cache-hit counter", () => {
clearCollatorCache();
resetCollatorMetricsState();
compareStrings("a", "b", "en-GB", { sensitivity: "base", usage: "sort" });
compareStrings("a", "b", "en-GB", { sensitivity: "base", usage: "sort" });
const state = getCollatorMetricsState();
expect(state.durationSamples).toHaveLength(2);
expect(state.cacheHitSamples).toHaveLength(1);
expect(state.durationSamples[0]?.attributes).toMatchObject({
locale: "en-GB",
usage: "sort",
sensitivity: "base",
});
});
```
## API Reference
### `recordCollatorFormat(locale, usage, sensitivity, durationMs): void`
Records the duration histogram sample. Tags: `locale`, `usage`,
`sensitivity`. Called internally by `compareStrings` /
`formatCollation`.
### `recordCollatorCacheHit(): void`
Increments the cache-hit counter with the constant tag
`{ formatter: "collator" }`.
### `getCollatorMetricsState(): CollatorMetricsState`
Returns the in-memory ring of `durationSamples` and
`cacheHitSamples`. Used by spec-conformance tests.
### `resetCollatorMetricsState(): void`
Clears both ring buffers. Test-only helper.
## Implementation Notes
- The OTel meter is named `suaveplan.i18n.formatter.collator`. Every
formatter in the family has its own meter so OTel resource
attributes can be tied per-package if a consumer chooses.
- Defaults supplied to the histogram tag when caller omits options:
`usage = "sort"`, `sensitivity = "variant"` (matching `Intl.Collator`
defaults). Every sample carries non-null values for all three tags.
- Sample dashboard query (PromQL/Grafana over OTLP):
`histogram_quantile(0.99, sum(rate(i18n_format_collator_duration_ms_bucket[5m])) by (le, locale, usage))`
- The in-memory ring is intentionally unbounded — tests are short and
call `resetCollatorMetricsState()` between cases. A long-running
test loop should call reset to avoid memory growth.

View File

@@ -0,0 +1,119 @@
# @suaveplan/i18n-formatter-currency - Locale-aware currency formatting backed by Intl.NumberFormat({ style: 'currency' }). Class wrapper + standalone helper + currency-info helpers (getCurrencySymbol, getCurrencyName, getCurrencyInfo), Zod-validated ISO 4217 codes, per-(locale, currency, options-hash) cache, OTel histogram + cache-hit counter. Wave 3 of the @suaveplan/i18n modular split.
## Summary
Locale-aware currency formatting backed by `Intl.NumberFormat` with
`style: "currency"`. Wave 3 of the 14-package i18n modular split.
Provides both a class (`CurrencyFormatter`) and a standalone helper
(`formatCurrency`) plus value-only formatting (`formatCurrencyValue`)
and currency-info helpers (`getCurrencySymbol`, `getCurrencyName`,
`getCurrencyInfo`) on top of a per-`(locale, currency, options-hash)`
cache, Zod-validated ISO 4217 codes, 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 currency formatting backed by `Intl.NumberFormat({ style: "currency" })`. The package exposes both a class…
- [formatter](./src/formatter/formatter.md) — Locale-aware currency formatting backed by `Intl.NumberFormat({ style: "currency" })`. The package exposes both a class…
- [helpers](./src/helpers/helpers.md) — Currency-info helpers exposing structured metadata about ISO 4217 codes via `Intl.NumberFormat.formatToParts`. Use thes…
- [helpers](./src/helpers/helpers.md) — Currency-info helpers exposing structured metadata about ISO 4217 codes via `Intl.NumberFormat.formatToParts`. Use thes…
- [observability](./src/observability/observability.md) — Per-package OpenTelemetry instruments for `@suaveplan/i18n-formatter-currency`. Exposes a histogram (`i18n.format.curre…
- [observability](./src/observability/observability.md) — Per-package OpenTelemetry instruments for `@suaveplan/i18n-formatter-currency`. Exposes a histogram (`i18n.format.curre…
<!-- 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
only Wave 3 formatter with an intra-formatter dependency: it depends
on `@suaveplan/i18n-formatter-number workspace:^` because currency
formatting is `Intl.NumberFormat({ style: "currency" })`. The dep is
pinned in `package.json` per the design doc §3 so the slim core
(Wave 6) can wire `formatCurrency` and `formatNumber` consistently
into the default `FormatBridge`.
## Install
```bash
bun add @suaveplan/i18n-formatter-currency
```
## Quick Start
```ts
import {
CurrencyFormatter,
formatCurrency,
formatCurrencyValue,
getCurrencyInfo,
getCurrencyName,
getCurrencySymbol,
} from "@suaveplan/i18n-formatter-currency";
formatCurrency(1234.56, "USD", "en"); // → "$1,234.56"
formatCurrency(1234.56, "EUR", "de"); // → "1.234,56 €"
formatCurrency(100, "CHF", "en", { display: "code" }); // → "CHF 100.00"
formatCurrencyValue(9.99, "USD", "en"); // → "9.99"
getCurrencySymbol("GBP", "en"); // → "£"
getCurrencyName("USD", "fr"); // → "dollar des États-Unis"
getCurrencyInfo("EUR", "en-US");
// → { code: "EUR", symbol: "€", name: "Euro", decimalDigits: 2 }
const usd = new CurrencyFormatter("USD", { minimumFractionDigits: 2 });
usd.format(42, "en-US"); // → "$42.00"
```
## Modules
| Subpath | Description |
|---|---|
| `@suaveplan/i18n-formatter-currency` | Root barrel — class + standalone + helpers + observability |
| `@suaveplan/i18n-formatter-currency/formatter` | `CurrencyFormatter`, `formatCurrency`, `formatCurrencyValue`, types, schemas |
| `@suaveplan/i18n-formatter-currency/helpers` | `getCurrencySymbol`, `getCurrencyName`, `getCurrencyInfo` only |
## Modules Documentation
+ [formatter.md](src/formatter.md) — `CurrencyFormatter`, `formatCurrency`, `formatCurrencyValue`
+ [helpers.md](src/helpers.md) — `getCurrencySymbol`, `getCurrencyName`, `getCurrencyInfo`
+ [observability.md](src/observability.md) — OTel histogram + cache-hit counter
## Public Surface (snapshot)
| Export | Kind | Origin |
|---|---|---|
| `CurrencyFormatter` | class | `formatter.ts` |
| `formatCurrency` | function | `formatter.ts` |
| `formatCurrencyValue` | function | `formatter.ts` |
| `clearCurrencyFormatterCache` | function | `formatter.ts` |
| `toBcp47` | function | `formatter.ts` |
| `CurrencyCodeSchema` | const | `formatter.schemas.ts` |
| `CurrencyFormatterOptionsSchema` | const | `formatter.schemas.ts` |
| `CommonCurrency` | type | `formatter.types.ts` |
| `CurrencyDisplay` | type | `formatter.types.ts` |
| `CurrencyFormatterOptions` | type | `formatter.types.ts` |
| `CurrencyInfo` | type | `formatter.types.ts` |
| `CurrencyFormatterCacheKey` | type | `formatter.types.ts` |
| `getCurrencySymbol` | function | `helpers.ts` |
| `getCurrencyName` | function | `helpers.ts` |
| `getCurrencyInfo` | function | `helpers.ts` |
| `recordCurrencyFormat` | function | `observability.ts` |
| `recordCurrencyCacheHit` | function | `observability.ts` |
| `getCurrencyFormatMetricsState` | function | `observability.ts` |
| `resetCurrencyFormatMetricsState` | 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 and the only
Wave 3 formatter with an intra-formatter dependency. Pure addition —
`packages/i18n/i18n/src/formatting/currency/` is preserved in the
monolith until Wave 6 deletes it.

View File

@@ -0,0 +1,103 @@
# CurrencyFormatter
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Locale-aware currency formatting backed by `Intl.NumberFormat({ style: "currency" })`. The package exposes both a class (`CurrencyFormatter` — construct once with stable currency + options, reuse) and a standalone helper (`formatCurrency(amount, currency, locale, options?)`). Every call validates the currency code through `CurrencyCodeSchema` (Zod) at the boundary (rejecting bogus codes like `"$"`, `"abc"`, `"USDT"` with a structured `INVALID_FORMATTER_OPTIONS` rather than letting them surface as raw `RangeError` from `Intl.NumberFormat`), validates options through `CurrencyFormatterOptionsSchema`, and emits a wall-clock duration sample on the `i18n.format.currency.duration_ms` histogram tagged with `locale`, `currency`, and `display`.
This package is the only Wave 3 formatter that depends on a sibling formatter — it declares `@suaveplan/i18n-formatter-number workspace:^` because currency formatting is `Intl.NumberFormat` with `style: "currency"`. The dep is documentation-grade today (the implementation constructs its own `Intl.NumberFormat`) but pins the architectural relationship the slim core (Wave 6) will exercise when wiring the default `FormatBridge`.
A per-`(locale, currency, options-hash)` cache for `Intl.NumberFormat` instances amortises construction cost across many price renders. Cache reuse increments the `i18n.format.cache_hit{formatter="currency"}` counter.
## Features
- Class form (`CurrencyFormatter`) for fixed-currency reuse + standalone helper (`formatCurrency`) for one-off calls
- `formatCurrencyValue` returns the numeric portion only (no symbol) — useful for custom rendering layouts
- BCP-47 locale resolution via the shared `LOCALE_BCP47_MAP` (re-exported by `@suaveplan/i18n-shared`)
- Display modes: `"symbol"` (default), `"code"`, `"name"`, `"narrowSymbol"`
- ISO 4217 validation enforced by Zod (`/^[A-Z]{3}$/`)
- `minimumFractionDigits` / `maximumFractionDigits` Zod-validated; inverted pair rejected as `field === 'fractionDigits'`
- Stable error codes: `I18N.INVALID_FORMATTER_OPTIONS`, `I18N.UNSUPPORTED_LOCALE`
- Process-wide cache; `clearCurrencyFormatterCache()` for tests
- OTel instrumentation: `i18n.format.currency.duration_ms` histogram, `i18n.format.cache_hit{formatter="currency"}` counter
## Usage: Basic Example
```typescript
import {
CurrencyFormatter,
formatCurrency,
formatCurrencyValue,
} from "@suaveplan/i18n-formatter-currency";
formatCurrency(1234.56, "USD", "en");
// → "$1,234.56"
formatCurrency(1234.56, "EUR", "de");
// → "1.234,56 €"
formatCurrency(100, "CHF", "en", { display: "code" });
// → "CHF 100.00"
formatCurrencyValue(1234.56, "USD", "en");
// → "1,234.56"
```
## Usage: Advanced Example — Class Form for Repeated Use
```typescript
import { CurrencyFormatter } from "@suaveplan/i18n-formatter-currency";
const usd = new CurrencyFormatter("USD", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
// Many call sites, single options + currency validation:
const lines = lineItems.map((item) => ({
label: item.label,
amount: usd.format(item.price, "en-US"),
}));
```
## API Reference
### `formatCurrency(amount, currency, locale, options?): string`
Formats `amount` per `currency`, `locale`, and `options`. Validates currency code through Zod (must match `/^[A-Z]{3}$/`), validates options through Zod, throws `I18nError(I18N.INVALID_FORMATTER_OPTIONS)` on either failure. Maps `Intl.NumberFormat`'s `RangeError` for unsupported locales / currencies to `I18nError(I18N.UNSUPPORTED_LOCALE)`.
### `formatCurrencyValue(amount, currency, locale): string`
Returns the numeric portion of the formatted currency string with the currency symbol and literal whitespace separators stripped via `formatToParts`. Reuses the same per-(locale, currency, options-hash) cache as `formatCurrency`.
### `class CurrencyFormatter`
- `constructor(currency: string, options?: CurrencyFormatterOptions)` — validates currency code + options at construction
- `format(amount: number, locale: string): string` — formats with stored currency + options
### `clearCurrencyFormatterCache(): void`
Test-only.
### `toBcp47(locale): string`
Internal BCP-47 normaliser exposed for re-use; consults `LOCALE_BCP47_MAP` from `@suaveplan/i18n-shared` for known short codes (`"en"` → `"en-GB"`), tries the lowercase fallback, then passes through unchanged.
### `CurrencyCodeSchema` and `CurrencyFormatterOptionsSchema`
Zod schemas validating the ISO 4217 code and options surface respectively.
### Types
`CommonCurrency`, `CurrencyDisplay`, `CurrencyFormatterOptions`, `CurrencyInfo`, `CurrencyFormatterCacheKey`.
## Implementation Notes
The cache key is `${locale}|${currency}|${JSON.stringify(options)}`. Including the currency in the key (rather than rolling it into the options bag) keeps `formatCurrencyValue` cache-coherent with `formatCurrency` for the same `(locale, currency)` pair under default options — both call sites resolve to the same `Intl.NumberFormat` instance.
`formatCurrencyValue` filters `formatToParts` for non-`"currency"` and non-`"literal"` parts. This correctly handles locales that place the symbol after the number (e.g. some European locales) without regex hacks. The `.trim()` at the end normalises any leading/trailing whitespace produced by edge-case locales.
ISO 4217 validation is intentionally strict — `/^[A-Z]{3}$/` rejects every code that is not exactly three uppercase letters. Cryptocurrencies like `BTC` (3 letters, all upper) pass the regex but fail at `Intl.NumberFormat` level if the runtime ICU does not recognise them; both failure modes funnel into structured `I18nError` codes (`INVALID_FORMATTER_OPTIONS` for the regex case, `UNSUPPORTED_LOCALE` for the ICU case). For non-ISO-4217 cryptocurrencies the dedicated `@suaveplan/cryptocurrency` package owns formatting.

View File

@@ -0,0 +1,74 @@
# Currency Helper Functions
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Currency-info helpers exposing structured metadata about ISO 4217 codes via `Intl.NumberFormat.formatToParts`. Use these when you need just the symbol, the localised name, or the full record (`{ code, symbol, name, decimalDigits }`) — without rendering an actual amount.
Each helper validates the currency code through the same `CurrencyCodeSchema` (Zod) used by the main `formatCurrency` standalone, so bogus codes (`"$"`, `"abc"`, `"USDT"`) raise structured `I18nError(INVALID_FORMATTER_OPTIONS)` rather than surfacing as raw `RangeError` from the underlying `Intl.NumberFormat`. Locale resolution goes through `toBcp47` from `formatter.ts` so callers can pass either a short code (`"en"` → `"en-GB"`) or a full BCP-47 tag.
## Features
- `getCurrencySymbol(currency, locale, display?)` — symbol / narrow symbol / code lookup
- `getCurrencyName(currency, locale)` — localised display name
- `getCurrencyInfo(currency, locale)` — aggregate `{ code, symbol, name, decimalDigits }` record
- ISO 4217 validation enforced before any `Intl.NumberFormat` construction
- Locale resolution via `LOCALE_BCP47_MAP` from `@suaveplan/i18n-shared`
## Usage: Basic Example
```typescript
import {
getCurrencyInfo,
getCurrencyName,
getCurrencySymbol,
} from "@suaveplan/i18n-formatter-currency/helpers";
getCurrencySymbol("GBP", "en"); // → "£"
getCurrencySymbol("USD", "en", "code"); // → "USD"
getCurrencyName("USD", "fr"); // → "dollar des États-Unis"
getCurrencyInfo("EUR", "en-US");
// → { code: "EUR", symbol: "€", name: "Euro", decimalDigits: 2 }
```
## Usage: Advanced Example — Currency picker
```typescript
import { getCurrencyInfo } from "@suaveplan/i18n-formatter-currency/helpers";
const supported = ["USD", "EUR", "GBP", "JPY", "CHF"];
function buildCurrencyOptions(locale) {
return supported.map((code) => {
const info = getCurrencyInfo(code, locale);
return {
value: info.code,
label: `${info.symbol} ${info.name} (${info.code})`,
decimals: info.decimalDigits,
};
});
}
```
## API Reference
### `getCurrencySymbol(currency, locale, display?)`
Returns the currency symbol string. The `display` argument restricts to `"symbol"` (default), `"narrowSymbol"`, or `"code"` — the same surface as the corresponding `Intl.NumberFormat` option restricted to the symbol-shaped subset. Throws `I18nError(INVALID_FORMATTER_OPTIONS)` if the currency code does not match `/^[A-Z]{3}$/`.
### `getCurrencyName(currency, locale)`
Returns the localised display name (e.g. `"US dollar"` in `en`, `"dollar américain"` in `fr`). Throws `I18nError(INVALID_FORMATTER_OPTIONS)` for malformed currency codes.
### `getCurrencyInfo(currency, locale)`
Returns an aggregate `CurrencyInfo` record containing the code, symbol, localised name, and decimal-digit count. Decimal digits are resolved from `formatter.resolvedOptions().maximumFractionDigits`, ensuring JPY returns 0, BHD returns 3, and most other currencies return 2 automatically.
## Implementation Notes
The helpers do NOT participate in the `formatCurrency` cache because they ask `Intl.NumberFormat` for a different option set per call (`currencyDisplay: "code"` for symbol-as-code, `currencyDisplay: "name"` for the name). Sharing a cache would require keying on those options, which would defeat the simpler `(locale, currency, options-hash)` shape used by the main formatter.
The `formatToParts(0)` / `formatToParts(1)` calls use a small constant amount because some locales render different symbols at different magnitudes (rare but possible in less-common ICU builds). Using `0` for symbol lookup and `1` for name lookup matches the monolith's behaviour for cross-package compatibility.

View File

@@ -0,0 +1,91 @@
# CurrencyFormatter Observability
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Per-package OpenTelemetry instruments for `@suaveplan/i18n-formatter-currency`. Exposes a histogram (`i18n.format.currency.duration_ms`) recording wall-clock duration of every `formatCurrency` call (tagged with `locale`, `currency`, and `display`) and a counter (`i18n.format.cache_hit`) tagged with `formatter: "currency"` that increments on every cache reuse.
The currency formatter introduces these instruments as part of the Wave 3 uplift — the monolith currently emits no OTel for currency 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.currency.duration_ms` with attributes `{ locale, currency, display }`
- Stable counter name `i18n.format.cache_hit` with attributes `{ formatter: "currency" }`
- In-memory sample ring (`currencyDurationSamples`, `cacheHitSamples`)
- `resetCurrencyFormatMetricsState()` clears both arrays so tests stay isolated
## Usage: Basic Example
```typescript
import {
recordCurrencyFormat,
recordCurrencyCacheHit,
getCurrencyFormatMetricsState,
resetCurrencyFormatMetricsState,
} from "@suaveplan/i18n-formatter-currency/observability";
resetCurrencyFormatMetricsState();
recordCurrencyFormat("en-US", "USD", "symbol", 0.4);
recordCurrencyCacheHit();
const state = getCurrencyFormatMetricsState();
state.currencyDurationSamples.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 {
formatCurrency,
clearCurrencyFormatterCache,
} from "@suaveplan/i18n-formatter-currency";
import {
getCurrencyFormatMetricsState,
resetCurrencyFormatMetricsState,
} from "@suaveplan/i18n-formatter-currency/observability";
describe("metrics emission", () => {
it("emits duration histogram and cache-hit counter", { timeout: 5000 }, () => {
clearCurrencyFormatterCache();
resetCurrencyFormatMetricsState();
formatCurrency(42, "USD", "en-US");
formatCurrency(42, "USD", "en-US");
const state = getCurrencyFormatMetricsState();
expect(state.currencyDurationSamples).toHaveLength(2);
expect(state.cacheHitSamples).toHaveLength(1);
});
});
```
## API Reference
### `recordCurrencyFormat(locale, currency, display, durationMs): void`
Records one observation on the `i18n.format.currency.duration_ms` histogram and pushes a `{ attributes: { locale, currency, display } }` entry into the in-memory sample ring.
### `recordCurrencyCacheHit(): void`
Adds 1 to the `i18n.format.cache_hit` counter with `formatter: "currency"` and pushes a matching entry into the in-memory sample ring.
### `getCurrencyFormatMetricsState(): CurrencyFormatMetricsState`
Returns the in-memory sample ring (live reference — do not mutate).
### `resetCurrencyFormatMetricsState(): void`
Empties both sample arrays in place.
### `CurrencyFormatMetricsState`
`{ currencyDurationSamples: FormatMetricSample[]; cacheHitSamples: FormatMetricSample[] }` where `FormatMetricSample = { attributes: Record<string, string | number> }`.
## Implementation Notes
The instruments are built lazily so consumers who never call `formatCurrency` 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 `resetCurrencyFormatMetricsState` calls.
The `currency` attribute is the ISO 4217 code (`"USD"`, `"EUR"`, `"JPY"`); the `display` attribute is the resolved display mode (`"symbol"`, `"code"`, `"name"`, `"narrowSymbol"`). Both are included in the histogram tags so consumers can dashboard the most-formatted currencies and the most-used display modes per locale.

View File

@@ -0,0 +1,128 @@
# @suaveplan/i18n-formatter-date - Intl.DateTimeFormat wrapper for the @suaveplan/i18n family — 8-preset table (short/medium/long/full/time-short/time-long/date-short/date-long), formatDate / formatTime / formatDateOnly / formatDateRange / getDayNames / getMonthNames. Per-(locale, options-hash) cache, Zod-validated options, OTel histogram + cache-hit counter, structured I18nError mapping. Universal leaf package.
## Summary
Wrapper around `Intl.DateTimeFormat` for the `@suaveplan/i18n` family
— 8-preset table (short/medium/long/full × date+time/time-only/date-only),
range rendering, day/month name lists, and a per-(locale,
options-hash) cache.
<!-- begin:auto-docs -->
## Documentation tree
- [day-month-names](./src/day-month-names/day-month-names.md) — `getDayNames(locale, style?)` and `getMonthNames(locale, style?)` return the locale-correct list of weekday or month na…
- [day-month-names](./src/day-month-names/day-month-names.md) — `getDayNames(locale, style?)` and `getMonthNames(locale, style?)` return the locale-correct list of weekday or month na…
- [formatter](./src/formatter/formatter.md) — `DateFormatter` and the `formatDate` / `formatDateRange` standalone helpers expose `Intl.DateTimeFormat` for the `@suav…
- [formatter](./src/formatter/formatter.md) — `DateFormatter` and the `formatDate` / `formatDateRange` standalone helpers expose `Intl.DateTimeFormat` for the `@suav…
- [helpers](./src/helpers/helpers.md) — `formatTime` and `formatDateOnly` pre-bind `formatDate` with the appropriate preset for time-only or date-only renderin…
- [helpers](./src/helpers/helpers.md) — `formatTime` and `formatDateOnly` pre-bind `formatDate` with the appropriate preset for time-only or date-only renderin…
- [observability](./src/observability/observability.md) — The Date formatter exports two OpenTelemetry instruments and a small in-memory state ring so spec-conformance tests can…
- [observability](./src/observability/observability.md) — The Date formatter exports two OpenTelemetry instruments and a small in-memory state ring so spec-conformance tests can…
- [presets](./src/presets/presets.md) — `PRESET_MAP` is the canonical 8-entry table that maps each `DateFormatPreset` discriminant to a concrete `Intl.DateTime…
- [presets](./src/presets/presets.md) — `PRESET_MAP` is the canonical 8-entry table that maps each `DateFormatPreset` discriminant to a concrete `Intl.DateTime…
<!-- end:auto-docs -->
## Why this exists
The 14-package i18n modular split (cross-cutting design
`2026-05-05-i18n-modular-split.md`) lifts each `Intl.*` wrapper out
of the monolith into its own sibling package. Lifting the date
helper out of `@suaveplan/i18n` lets consumers that only need
date/time rendering (most UI consumers) pull a single small package
instead of the entire i18n core.
The package adds the canonical Wave-3 formatter shape on top of the
monolith's standalone helpers: Zod-validated options, a per-(locale,
options-hash) cache, OTel histogram + cache-hit counter, and the
`DateFormatter` class wrapper for symmetry with `NumberFormatter` /
`RelativeTimeFormatter` / `ListFormatter` / `DisplayNameFormatter`.
## Install
```bash
bun add @suaveplan/i18n-formatter-date
```
## Quick Start
```ts
import {
DateFormatter,
formatDate,
formatDateOnly,
formatDateRange,
formatTime,
getDayNames,
getMonthNames,
} from "@suaveplan/i18n-formatter-date";
const now = new Date(2026, 0, 1, 14, 30, 45);
formatDate(now, "en-GB", { preset: "short" }); // "01/01/2026, 14:30"
formatTime(now, "en-GB", true); // "14:30:45"
formatDateOnly(now, "en-GB", true); // "1 January 2026"
formatDateRange(
new Date(2026, 0, 1),
new Date(2026, 0, 5),
"en-GB",
{ day: "numeric", month: "long" }
); // "1 – 5 January"
getDayNames("en-GB", "short");
// ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
const f = new DateFormatter({ preset: "long", timeZone: "Europe/London" });
f.format(now, "en-GB");
```
## Modules
| Subpath | Description |
|---|---|
| `@suaveplan/i18n-formatter-date` | Root barrel — class + standalones + helpers + presets + day/month names |
| `@suaveplan/i18n-formatter-date/formatter` | `DateFormatter`, `formatDate`, `formatDateRange`, `toBcp47`, types, schema |
| `@suaveplan/i18n-formatter-date/helpers` | `formatTime`, `formatDateOnly` |
| `@suaveplan/i18n-formatter-date/presets` | `PRESET_MAP`, `DATE_FORMAT_PRESETS`, `resolvePreset` |
| `@suaveplan/i18n-formatter-date/day-month-names` | `getDayNames`, `getMonthNames` |
## Modules Documentation
- [formatter.md](src/formatter.md) — `DateFormatter`, `formatDate`, `formatDateRange`, `toBcp47`, `clearDateFormatterCache`
- [helpers.md](src/helpers.md) — `formatTime`, `formatDateOnly`
- [presets.md](src/presets.md) — 8-preset table + `resolvePreset`
- [day-month-names.md](src/day-month-names.md) — `getDayNames`, `getMonthNames`
- [observability.md](src/observability.md) — OTel histogram + cache-hit counter
## Public Surface (snapshot)
| Export | Kind | Origin |
|---|---|---|
| `DateFormatter` | class | `formatter.ts` |
| `formatDate` | function | `formatter.ts` |
| `formatDateRange` | function | `formatter.ts` |
| `toBcp47` | function | `formatter.ts` |
| `clearDateFormatterCache` | function | `formatter.ts` |
| `DateFormatterOptionsSchema` | Zod schema | `formatter.schemas.ts` |
| `DateFormatPreset` | type | `formatter.types.ts` |
| `DateFormatOptions` | type | `formatter.types.ts` |
| `ExtendedDateOptions` | type | `formatter.types.ts` |
| `DateFormatterCacheKey` | type | `formatter.types.ts` |
| `formatTime` | function | `helpers.ts` |
| `formatDateOnly` | function | `helpers.ts` |
| `PRESET_MAP` | const | `presets.ts` |
| `DATE_FORMAT_PRESETS` | const | `presets.ts` |
| `resolvePreset` | function | `presets.ts` |
| `PresetTable` | type | `presets.types.ts` |
| `getDayNames` | function | `day-month-names.ts` |
| `getMonthNames` | function | `day-month-names.ts` |
| `recordDateFormat` | function | `observability.ts` |
| `recordDateCacheHit` | function | `observability.ts` |
| `getDateMetricsState` | function | `observability.ts` |
| `resetDateMetricsState` | function | `observability.ts` |
## Versioning
Part of the 14-package i18n modular-split rollout. Stays at `0.1.0`
until the slim core (Wave 6) cuts the family `1.0.0` release.

View File

@@ -0,0 +1,112 @@
# Day / Month names
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`getDayNames(locale, style?)` and `getMonthNames(locale, style?)`
return the locale-correct list of weekday or month names. They are
the canonical primitives for day-picker / calendar / date-picker UI
components — every locale wants a different starting weekday and a
different rendering style.
`getDayNames` consults `Intl.Locale.getWeekInfo()` (ECMA-402 §10.6) to
discover the locale-defined first day of the week (1 = Monday … 7 =
Sunday). When the host runtime lacks `getWeekInfo` (older Node, older
browsers), the function falls back to Monday-start.
## Features
- `getDayNames(locale, style?)` returns 7 strings starting from the locale's first weekday
- `getMonthNames(locale, style?)` returns 12 strings
- Both accept `"long"` (default), `"short"`, or `"narrow"` style
- BCP-47 normalisation through `LOCALE_BCP47_MAP`
- `getDayNames` falls back to Monday-start when `getWeekInfo` is unavailable or throws
- Pure functions — no shared state, no cache
## Usage: Basic Example
```ts
import {
getDayNames,
getMonthNames,
} from "@suaveplan/i18n-formatter-date";
getDayNames("en-GB");
// ["Monday", "Tuesday", ..., "Sunday"]
getDayNames("en-US", "short");
// ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]
getMonthNames("en-GB");
// ["January", "February", ..., "December"]
getMonthNames("fr-FR", "narrow");
// ["J", "F", "M", "A", "M", "J", "J", "A", "S", "O", "N", "D"]
```
## Usage: Advanced Example
```ts
import {
getDayNames,
getMonthNames,
} from "@suaveplan/i18n-formatter-date";
// Build a date-picker header that respects the user's locale.
function renderCalendarHeader(locale: string) {
const days = getDayNames(locale, "narrow");
const months = getMonthNames(locale, "long");
return { days, months };
}
const header = renderCalendarHeader("ja-JP");
// header.days[0] is the locale-correct first weekday name
// header.months is the 12 long Japanese month names
// Stub Intl.Locale.getWeekInfo to test the Monday-start fallback.
const Original = Intl.Locale;
Intl.Locale = function () {
return { getWeekInfo: undefined } as unknown as Intl.Locale;
} as unknown as typeof Intl.Locale;
try {
const days = getDayNames("en-US"); // falls back to Monday-start
console.log(days[0]); // "Monday"
} finally {
Intl.Locale = Original;
}
```
## API Reference
### `getDayNames(locale, style?): string[]`
- `locale` — BCP-47 locale tag.
- `style` — `"long"` (default), `"short"`, or `"narrow"`.
- Returns 7 strings starting from the locale-defined first weekday.
- Anchors on Monday 2024-01-01 then offsets by `(firstDay - 1)` so
index 0 is the locale's first weekday.
### `getMonthNames(locale, style?): string[]`
- `locale` — BCP-47 locale tag.
- `style` — `"long"` (default), `"short"`, or `"narrow"`.
- Returns 12 strings, one per month.
## Implementation Notes
- The `getWeekInfo()` probe is wrapped in `try/catch` because (a)
older runtimes don't expose the method at all (the access throws
`TypeError`), and (b) some older proposals exposed `weekInfo` as a
property — neither path is hit on current Node/Bun/browser, but the
fallback keeps the function safe across the entire range.
- The constants are computed by iterating dates through
`Intl.DateTimeFormat`, not by hard-coding day/month strings —
that's what makes the helpers correctly return localised names for
every BCP-47 tag without a translation table.
- These helpers do NOT participate in the formatter cache because
they construct ad-hoc `Intl.DateTimeFormat` instances per call.
The cost is negligible (single-digit microseconds) and avoids
mixing weekday-style cache entries with the main formatter cache.

View File

@@ -0,0 +1,133 @@
# DateFormatter
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`DateFormatter` and the `formatDate` / `formatDateRange` standalone
helpers expose `Intl.DateTimeFormat` for the `@suaveplan/i18n` family
at the canonical Wave-3 formatter surface. Consumers format `Date`
instances against an 8-entry preset table or raw
`Intl.DateTimeFormatOptions`, with locale-correct rendering across
every BCP-47 tag.
This module is the broadest formatter in the family — it ships the
class wrapper, the standalone formatters, the preset/raw merge logic,
the BCP-47 normalisation map (via `@suaveplan/i18n-shared`), and the
per-(locale, options-hash) cache.
## Features
- `DateFormatter` class with stored options + `format(date, locale)` and `formatRange(start, end, locale)` methods
- `formatDate(date, locale, options?)` standalone helper
- `formatDateRange(start, end, locale, options?)` standalone helper
- 8-preset table: `short`, `medium`, `long`, `full`, `time-short`, `time-long`, `date-short`, `date-long`
- Raw `Intl.DateTimeFormatOptions` accepted alongside or instead of `preset`
- Construction-time Zod validation with `INVALID_FORMATTER_OPTIONS` mapping
- `Intl.DateTimeFormat` `RangeError` mapped to `UNSUPPORTED_LOCALE` with original error attached as `cause`
- Per-(locale, options-hash) cache — repeated calls with identical key reuse the same `Intl.DateTimeFormat`
- BCP-47 normalisation through `LOCALE_BCP47_MAP` from `@suaveplan/i18n-shared`
- `clearDateFormatterCache()` test-only helper
## Usage: Basic Example
```ts
import {
formatDate,
formatDateRange,
} from "@suaveplan/i18n-formatter-date";
formatDate(new Date(2026, 0, 1), "en-GB", { preset: "short" });
// e.g. "01/01/2026, 00:00"
formatDate(new Date(2026, 0, 1), "en-GB", { preset: "long" });
// e.g. "1 January 2026 at 00:00:00"
formatDateRange(
new Date(2026, 0, 1),
new Date(2026, 0, 5),
"en-GB",
{ day: "numeric", month: "long" }
); // e.g. "1 – 5 January"
```
## Usage: Advanced Example
```ts
import {
DateFormatter,
type ExtendedDateOptions,
} from "@suaveplan/i18n-formatter-date";
const opts: ExtendedDateOptions = {
preset: "short",
// Override the preset's month field — raw options take precedence.
month: "long",
timeZone: "Europe/London",
};
const f = new DateFormatter(opts);
f.format(new Date(2026, 0, 1), "en-GB"); // "1 January 2026, 00:00"
f.format(new Date(2026, 0, 1), "fr-FR"); // "1 janvier 2026 à 00:00"
// Range rendering uses the same configured options.
f.formatRange(
new Date(2026, 0, 1),
new Date(2026, 0, 5),
"en-GB"
);
```
## API Reference
### `formatDate(date, locale, options?): string`
Standalone helper. Validates options via Zod, normalises the locale
through `LOCALE_BCP47_MAP`, resolves `options.preset` against
`PRESET_MAP` (with raw fields overriding preset defaults), looks up
the cached `Intl.DateTimeFormat`, and returns `formatter.format(date)`.
Throws `I18nError(INVALID_FORMATTER_OPTIONS)` on Zod failure; throws
`I18nError(UNSUPPORTED_LOCALE)` on `Intl.DateTimeFormat` `RangeError`
with the original error as `cause`.
### `formatDateRange(start, end, locale, options?): string`
Standalone helper for range rendering. Uses
`Intl.DateTimeFormat.formatRange` under the hood; raw
`Intl.DateTimeFormatOptions` are passed straight through (no preset
resolution).
### `class DateFormatter`
- `constructor(options?: ExtendedDateOptions)` — Zod-validates options at construction
- `format(date: Date, locale: string): string` — same semantics as standalone
- `formatRange(start: Date, end: Date, locale: string): string` — uses the configured options' resolved Intl options for range rendering
### `toBcp47(locale: string): string`
Look up the locale in `LOCALE_BCP47_MAP` (direct first, then via
`toLowerCase` fallback). Returns the input locale unchanged when no
mapping exists. Exported for downstream callers (e.g. day/month name
helpers).
### `clearDateFormatterCache(): void`
Test-only helper that empties the per-(locale, options-hash) cache.
## Implementation Notes
- The cache key is `${locale}|${JSON.stringify(options)}` — JSON
serialisation is stable for objects with no symbol/function keys
and no NaN/Infinity values, which `ExtendedDateOptions` does not
contain.
- `presetTag(options)` returns `options.preset ?? "custom"` so the
OTel histogram tag is always present.
- The `formatRange` method on `Intl.DateTimeFormat` ships in Node 18+
and every current browser. The package does not provide a
fallback — a runtime missing `formatRange` is below the family's
documented support floor.
- `validateOptions(undefined)` is a no-op so consumers can call
`formatDate(d, l)` with no options object.

View File

@@ -0,0 +1,91 @@
# Date helpers — `formatTime`, `formatDateOnly`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`formatTime` and `formatDateOnly` pre-bind `formatDate` with the
appropriate preset for time-only or date-only rendering. They exist
because the most common UI patterns ("show just the time", "show just
the date") shouldn't require the caller to remember the preset
discriminant string.
## Features
- `formatTime(date, locale, long?)` — pre-binds `time-short` (`long === false`) or `time-long` (`long === true`)
- `formatDateOnly(date, locale, long?)` — pre-binds `date-short` (`long === false`) or `date-long` (`long === true`)
- Both default `long` to `false`
- Both go through the cached `formatDate` path — every advantage of the per-(locale, options-hash) cache and OTel emission applies
## Usage: Basic Example
```ts
import {
formatDateOnly,
formatTime,
} from "@suaveplan/i18n-formatter-date";
const now = new Date(2026, 0, 1, 14, 30, 45);
formatTime(now, "en-GB"); // "14:30"
formatTime(now, "en-GB", true); // "14:30:45"
formatDateOnly(now, "en-GB"); // "01/01/2026"
formatDateOnly(now, "en-GB", true); // "1 January 2026"
```
## Usage: Advanced Example
```ts
import {
formatDateOnly,
formatTime,
} from "@suaveplan/i18n-formatter-date";
// Render a chat message timestamp — short form by default,
// long form on hover/expand.
function renderTimestamp(d: Date, locale: string, expanded: boolean) {
const date = formatDateOnly(d, locale, expanded);
const time = formatTime(d, locale, expanded);
return expanded ? `${date} at ${time}` : `${date} ${time}`;
}
renderTimestamp(new Date(2026, 5, 15, 9, 5), "en-GB", false);
// "15/06/2026 09:05"
renderTimestamp(new Date(2026, 5, 15, 9, 5, 30), "en-GB", true);
// "15 June 2026 at 09:05:30"
```
## API Reference
### `formatTime(date, locale, long?): string`
Pre-binds `formatDate` with `preset: long ? "time-long" : "time-short"`.
- `date` — a `Date` instance.
- `locale` — BCP-47 locale tag.
- `long` — when `true`, includes seconds.
Throws every error class `formatDate` throws.
### `formatDateOnly(date, locale, long?): string`
Pre-binds `formatDate` with `preset: long ? "date-long" : "date-short"`.
- `date` — a `Date` instance.
- `locale` — BCP-47 locale tag.
- `long` — when `true`, uses the long month name (e.g. "January"
instead of "01").
Throws every error class `formatDate` throws.
## Implementation Notes
- Both helpers are one-line wrappers around `formatDate`. The
per-(locale, options-hash) cache shared with `formatDate` and
`formatDateRange` covers them automatically.
- Default of `long = false` matches the monolith behaviour to
minimise behavioural drift during the modular split.

View File

@@ -0,0 +1,98 @@
# Date observability
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The Date formatter exports two OpenTelemetry instruments and a small
in-memory state ring so spec-conformance tests can assert exact
attribute payloads without standing up a full SDK exporter. The
instrument names match the canonical Wave-3 formatter contract —
every sibling formatter (number, relative-time, list, display-name,
currency, collator, segmenter) emits the same two metric names so
dashboards and alerts compose cleanly across the family.
## Features
- Histogram `i18n.format.date.duration_ms` (unit `ms`) — per-format wall-clock time
- Counter `i18n.format.cache_hit{formatter="date"}` — increments on cache reuse only
- In-memory state ring (`getDateMetricsState()`) for spec-conformance
- `resetDateMetricsState()` test-only helper to clear the ring
## Usage: Basic Example
```ts
import { metrics } from "@opentelemetry/api";
import { MeterProvider } from "@opentelemetry/sdk-metrics";
const provider = new MeterProvider({ readers: [/* reader */] });
metrics.setGlobalMeterProvider(provider);
import { formatDate } from "@suaveplan/i18n-formatter-date";
formatDate(new Date(2026, 0, 1), "en-GB", { preset: "short" });
// emits via your reader
```
## Usage: Advanced Example
```ts
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
clearDateFormatterCache,
formatDate,
} from "@suaveplan/i18n-formatter-date";
import {
getDateMetricsState,
resetDateMetricsState,
} from "@suaveplan/i18n-formatter-date";
it("emits histogram + cache-hit counter", () => {
clearDateFormatterCache();
resetDateMetricsState();
formatDate(new Date(2026, 0, 1), "en-GB", { preset: "short" });
formatDate(new Date(2026, 0, 1), "en-GB", { preset: "short" });
const state = getDateMetricsState();
expect(state.durationSamples).toHaveLength(2);
expect(state.cacheHitSamples).toHaveLength(1);
expect(state.durationSamples[0]?.attributes).toMatchObject({
locale: "en-GB",
preset: "short",
});
});
```
## API Reference
### `recordDateFormat(locale, preset, durationMs): void`
Records the duration histogram sample. Tags: `locale`, `preset`.
Called internally by `formatDate` / `formatDateRange`.
### `recordDateCacheHit(): void`
Increments the cache-hit counter with the constant tag
`{ formatter: "date" }`.
### `getDateMetricsState(): DateMetricsState`
Returns the in-memory ring of `durationSamples` and
`cacheHitSamples`. Used by spec-conformance tests.
### `resetDateMetricsState(): void`
Clears both ring buffers. Test-only helper.
## Implementation Notes
- The OTel meter is named `suaveplan.i18n.formatter.date`. Every
formatter in the family has its own meter so OTel resource
attributes can be tied per-package if a consumer chooses.
- When the caller omits `preset`, the histogram tag is `"custom"` so
every sample carries a non-null preset attribute.
- Sample dashboard query (PromQL/Grafana over OTLP):
`histogram_quantile(0.99, sum(rate(i18n_format_date_duration_ms_bucket[5m])) by (le, locale, preset))`
- The in-memory ring is intentionally unbounded — tests are short
and call `resetDateMetricsState()` between cases. A long-running
test loop should call reset to avoid memory growth.

View File

@@ -0,0 +1,100 @@
# Date format presets
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`PRESET_MAP` is the canonical 8-entry table that maps each
`DateFormatPreset` discriminant to a concrete
`Intl.DateTimeFormatOptions` payload. `resolvePreset(options)` is the
merge function used inside `formatDate` to combine a preset's
defaults with raw caller-supplied options (raw fields override).
The presets exist so consumers can render dates with ergonomic verbs
(`{ preset: "short" }`) instead of having to memorise the eight or so
field-style enums `Intl.DateTimeFormat` accepts.
## Features
- 8 frozen presets covering every common UI case (date+time / time-only / date-only, plus `full` for legal-style timestamps)
- `DATE_FORMAT_PRESETS` array of all preset names — useful for building UI selects
- `resolvePreset(options?)` merges preset defaults with raw fields; raw fields take precedence
- Pure functions, side-effect-free
## Usage: Basic Example
```ts
import {
DATE_FORMAT_PRESETS,
PRESET_MAP,
resolvePreset,
} from "@suaveplan/i18n-formatter-date";
// Inspect a preset directly.
PRESET_MAP.short;
// { year: "numeric", month: "numeric", day: "numeric",
// hour: "numeric", minute: "numeric" }
// Build a select.
DATE_FORMAT_PRESETS.map((p) => ({ value: p, label: p }));
// Resolve a merged options object.
resolvePreset({ preset: "short", month: "long" });
// { year: "numeric", month: "long", day: "numeric",
// hour: "numeric", minute: "numeric" }
```
## Usage: Advanced Example
```ts
import { resolvePreset } from "@suaveplan/i18n-formatter-date";
// Power a custom Intl wrapper that needs the resolved Intl options
// without going through formatDate.
function customDate(d: Date, locale: string, preset: "short" | "long") {
const opts = resolvePreset({ preset });
return new Intl.DateTimeFormat(locale, opts).format(d);
}
// Override the default fields of a preset.
const fullWithoutWeekday = resolvePreset({
preset: "full",
weekday: undefined, // raw fields override; setting to undefined
// keeps the field in the object but
// Intl.DateTimeFormat ignores `undefined`.
});
```
## API Reference
### `PRESET_MAP: Readonly<Record<DateFormatPreset, Intl.DateTimeFormatOptions>>`
The 8-entry table. Keys: `"short"`, `"medium"`, `"long"`, `"full"`,
`"time-short"`, `"time-long"`, `"date-short"`, `"date-long"`.
### `DATE_FORMAT_PRESETS: readonly DateFormatPreset[]`
Frozen array of every preset name. Useful for select-box population.
### `resolvePreset(options?): Intl.DateTimeFormatOptions | undefined`
- Returns `undefined` when `options` is `undefined`.
- Returns `rest` (with `preset` and `locale` stripped) when no
`preset` is supplied.
- Returns `{ ...PRESET_MAP[preset], ...rest }` when `preset` is
supplied — raw fields override preset defaults.
## Implementation Notes
- `PRESET_MAP` and `DATE_FORMAT_PRESETS` are sourced verbatim from
the monolith `formatting/date/date.ts` L4–L41 (per the source
mapping table in the change folder's proposal).
- `resolvePreset` mirrors the monolith's L51–L58. The function is
small, pure, and exported so consumers can build their own Intl
wrappers without reinventing the merge.
- The `locale` field is stripped because `Intl.DateTimeFormat` does
not accept `locale` inside the options object — it must be passed
as the first constructor argument. Stripping here keeps the
resolved options object passable to `Intl.DateTimeFormat` directly.

View File

@@ -0,0 +1,106 @@
# @suaveplan/i18n-formatter-display-name - Intl.DisplayNames wrapper for the @suaveplan/i18n family — locale-aware language/region/script/currency/calendar display names. Per-(locale, type, style, fallback) cache, Zod-validated options, OTel histogram + cache-hit counter, structured I18nError mapping. Universal leaf package.
## Summary
Wrapper around `Intl.DisplayNames` for the `@suaveplan/i18n` family —
locale-aware language, region, script, currency, and calendar display
names. Universal leaf package; depends only on
`@suaveplan/i18n-shared`, `@suaveplan/types`, `@suaveplan/error`,
`@opentelemetry/api`, and `zod`.
<!-- begin:auto-docs -->
## Documentation tree
- [formatter](./src/formatter/formatter.md) — `DisplayNameFormatter` and the `formatDisplayName` standalone helper expose `Intl.DisplayNames` for the `@suaveplan/i18…
- [formatter](./src/formatter/formatter.md) — `DisplayNameFormatter` and the `formatDisplayName` standalone helper expose `Intl.DisplayNames` for the `@suaveplan/i18…
- [helpers](./src/helpers/helpers.md) — The five ergonomic helpers (`formatLanguageName`, `formatRegionName`, `formatCurrencyName`, `formatScriptName`, `format…
- [helpers](./src/helpers/helpers.md) — The five ergonomic helpers (`formatLanguageName`, `formatRegionName`, `formatCurrencyName`, `formatScriptName`, `format…
- [observability](./src/observability/observability.md) — The DisplayName formatter exports two OpenTelemetry instruments and a small in-memory state ring so spec-conformance te…
- [observability](./src/observability/observability.md) — The DisplayName formatter exports two OpenTelemetry instruments and a small in-memory state ring so spec-conformance te…
<!-- end:auto-docs -->
## Why this exists
The 14-package i18n modular split (cross-cutting design
`2026-05-05-i18n-modular-split.md`) lifts each `Intl.*` wrapper out of
the monolith into its own sibling package. Lifting the display-name
helper out of `@suaveplan/i18n` lets consumers that only need
country-name / currency-name rendering pull a single small package
instead of the entire i18n core.
The package adds the canonical Wave-3 formatter shape on top of the
monolith's standalone helper: Zod-validated options, a per-(locale,
type, style, fallback) cache, OTel histogram + cache-hit counter, the
class wrapper for symmetry with `NumberFormatter` /
`RelativeTimeFormatter` / `ListFormatter` / `DateFormatter`, and five
ergonomic helpers that pre-bind `formatDisplayName` with each
canonical `type` value.
## Install
```bash
bun add @suaveplan/i18n-formatter-display-name
```
## Quick Start
```ts
import {
DisplayNameFormatter,
formatDisplayName,
formatLanguageName,
formatRegionName,
formatCurrencyName,
} from "@suaveplan/i18n-formatter-display-name";
formatDisplayName("GB", "en-GB", { type: "region" }); // "United Kingdom"
formatLanguageName("fr", "en-GB"); // "French"
formatRegionName("US", "en-GB"); // "United States"
formatCurrencyName("USD", "en-GB"); // "US Dollar"
const f = new DisplayNameFormatter({ type: "region", style: "short" });
f.format("DE", "en-GB"); // "Germany"
```
## Modules
| Subpath | Description |
|---|---|
| `@suaveplan/i18n-formatter-display-name` | Root barrel — class + standalones + helpers + observability |
| `@suaveplan/i18n-formatter-display-name/formatter` | `DisplayNameFormatter`, `formatDisplayName`, types, schema |
| `@suaveplan/i18n-formatter-display-name/helpers` | Five ergonomic helpers — `formatLanguageName`, `formatRegionName`, `formatCurrencyName`, `formatScriptName`, `formatCalendarName` |
## Modules Documentation
- [formatter.md](src/formatter.md) — `DisplayNameFormatter` class, `formatDisplayName`, `clearDisplayNameCache`
- [helpers.md](src/helpers.md) — five ergonomic helpers
- [observability.md](src/observability.md) — OTel histogram + cache-hit counter
## Public Surface (snapshot)
| Export | Kind | Origin |
|---|---|---|
| `DisplayNameFormatter` | class | `formatter.ts` |
| `formatDisplayName` | function | `formatter.ts` |
| `clearDisplayNameCache` | function | `formatter.ts` |
| `DisplayNameFormatterOptionsSchema` | Zod schema | `formatter.schemas.ts` |
| `DisplayNameFormatterOptions` | type | `formatter.types.ts` |
| `DisplayNameType` | type | `formatter.types.ts` |
| `DisplayNameStyle` | type | `formatter.types.ts` |
| `DisplayNameFallback` | type | `formatter.types.ts` |
| `DisplayNameFormatterCacheKey` | type | `formatter.types.ts` |
| `formatLanguageName` | function | `helpers.ts` |
| `formatRegionName` | function | `helpers.ts` |
| `formatCurrencyName` | function | `helpers.ts` |
| `formatScriptName` | function | `helpers.ts` |
| `formatCalendarName` | function | `helpers.ts` |
| `recordDisplayNameFormat` | function | `observability.ts` |
| `recordDisplayNameCacheHit` | function | `observability.ts` |
| `getDisplayNameMetricsState` | function | `observability.ts` |
| `resetDisplayNameMetricsState` | function | `observability.ts` |
## Versioning
Part of the 14-package i18n modular-split rollout. Stays at `0.1.0`
until the slim core (Wave 6) cuts the family `1.0.0` release.

View File

@@ -0,0 +1,98 @@
# DisplayNameFormatter
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`DisplayNameFormatter` and the `formatDisplayName` standalone helper
expose `Intl.DisplayNames` for the `@suaveplan/i18n` family at the
canonical Wave-3 formatter surface: Zod-validated options, structured
error mapping, per-(locale, type, style, fallback) cache, and OTel
emission. Consumers convert ISO codes (`"GB"`, `"USD"`, `"fr"`,
`"gregory"`) into their localised display names — the universal way to
render country names, currencies, languages, scripts, and calendar
identifiers in a user's locale.
## Features
- `DisplayNameFormatter` class with stored options + `format(code, locale)` method
- `formatDisplayName(code, locale, options)` standalone helper
- All six `Intl.DisplayNames` `type` values: `language` / `region` / `script` / `currency` / `calendar` / `dateTimeField`
- Three `style` values: `long` / `short` / `narrow`
- Two `fallback` policies: `code` (default) / `none`
- Construction-time Zod validation with `INVALID_FORMATTER_OPTIONS` mapping
- `Intl.DisplayNames` `RangeError` mapped to `UNSUPPORTED_LOCALE` with original error attached as `cause`
- Per-(locale, type, style, fallback) cache — repeated calls reuse the same `Intl.DisplayNames`
- `clearDisplayNameCache()` test-only helper
## Usage: Basic Example
```ts
import { formatDisplayName } from "@suaveplan/i18n-formatter-display-name";
formatDisplayName("GB", "en-GB", { type: "region" }); // "United Kingdom"
formatDisplayName("fr", "en-GB", { type: "language" }); // "French"
formatDisplayName("USD", "en-GB", { type: "currency" }); // "US Dollar"
```
## Usage: Advanced Example
```ts
import {
DisplayNameFormatter,
type DisplayNameFormatterOptions,
} from "@suaveplan/i18n-formatter-display-name";
const opts: DisplayNameFormatterOptions = {
type: "region",
style: "short",
fallback: "none",
};
const f = new DisplayNameFormatter(opts);
// Reuses the same cached Intl.DisplayNames per locale.
const us = f.format("US", "en-GB");
const fr = f.format("FR", "fr-FR");
// Unknown region with fallback: "none" returns the code unchanged.
const unknown = f.format("ZZ", "en-GB"); // "ZZ"
```
## API Reference
### `formatDisplayName(code, locale, options): string`
Standalone helper. Validates `options` via Zod, looks up or constructs
the cached `Intl.DisplayNames`, and returns
`formatter.of(code) ?? code`. Throws `I18nError(INVALID_FORMATTER_OPTIONS)`
on Zod failure or `I18nError(UNSUPPORTED_LOCALE)` on
`Intl.DisplayNames` `RangeError`.
### `class DisplayNameFormatter`
- `constructor(options: DisplayNameFormatterOptions)` — stores options after Zod validation
- `format(code: string, locale: string): string` — same semantics as the standalone
### `clearDisplayNameCache(): void`
Test-only helper that empties the per-(locale, type, style, fallback)
cache.
## Implementation Notes
- The cache is a process-wide `Map<string, Intl.DisplayNames>` keyed
by `${locale}|${type}|${style}|${fallback}`. The single shared
cache is a deliberate choice — `Intl.DisplayNames` instances are
immutable, so cross-call reuse is safe.
- `formatter.of(code)` returns `undefined` when the input is not a
recognised code AND `fallback: "none"` was specified. The `?? code`
fallback returns the input unchanged in that case.
- OTel histogram `i18n.format.display_name.duration_ms` records the
end-to-end format time per call; counter
`i18n.format.cache_hit{formatter="display_name"}` increments on
cache reuse only (not on first miss).
- Construction-time validation ensures the class never wraps invalid
options. The standalone re-validates on every call (cheap — a Zod
`safeParse` over a 3-field object).

View File

@@ -0,0 +1,98 @@
# Display-name ergonomic helpers
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The five ergonomic helpers (`formatLanguageName`, `formatRegionName`,
`formatCurrencyName`, `formatScriptName`, `formatCalendarName`)
pre-bind `formatDisplayName` with the required `type` discriminant so
consumers don't have to remember the discriminant string. Each helper
forwards an optional `style` argument to the underlying call.
The helpers exist because `Intl.DisplayNames` requires the caller to
pass `{ type: "<x>" }` on every call — across a real-world UI codebase
that adds noise and creates a chance to typo a discriminant. The
helpers eliminate both.
## Features
- `formatLanguageName(code, locale, style?)` — pre-binds `type: "language"`
- `formatRegionName(code, locale, style?)` — pre-binds `type: "region"`
- `formatCurrencyName(code, locale, style?)` — pre-binds `type: "currency"`
- `formatScriptName(code, locale, style?)` — pre-binds `type: "script"`
- `formatCalendarName(code, locale, style?)` — pre-binds `type: "calendar"`
- All accept the optional `style` parameter (`"long" | "short" | "narrow"`); when omitted, the underlying `formatDisplayName` defaults to `"long"`.
## Usage: Basic Example
```ts
import {
formatLanguageName,
formatRegionName,
formatCurrencyName,
} from "@suaveplan/i18n-formatter-display-name";
formatLanguageName("fr", "en-GB"); // "French"
formatRegionName("GB", "en-GB"); // "United Kingdom"
formatCurrencyName("USD", "en-GB"); // "US Dollar"
```
## Usage: Advanced Example
```ts
import {
formatLanguageName,
formatRegionName,
formatScriptName,
} from "@suaveplan/i18n-formatter-display-name";
// Build a locale picker — language + region in the user's locale.
const locales = ["fr-FR", "en-GB", "ja-JP", "zh-Hans-CN"];
const userLocale = "en-GB";
const labels = locales.map((tag) => {
const [lang, ...rest] = tag.split("-");
const region = rest[rest.length - 1];
const lname = formatLanguageName(lang, userLocale);
const rname = region ? formatRegionName(region, userLocale) : "";
return rname ? `${lname} (${rname})` : lname;
});
// ["French (France)", "English (United Kingdom)", "Japanese (Japan)", "Chinese (China)"]
// Render script name in narrow style for compact UIs.
const narrow = formatScriptName("Latn", "en-GB", "narrow");
```
## API Reference
Every helper has the signature
`(code: string, locale: string, style?: DisplayNameStyle) => string`.
- **`formatLanguageName(code, locale, style?)`** — Returns the language
display name. `code` is a BCP-47 language subtag (`"fr"`, `"zh"`,
`"en-GB"` is also accepted by the underlying `Intl.DisplayNames`).
- **`formatRegionName(code, locale, style?)`** — Returns the region
display name. `code` is an ISO 3166-1 alpha-2 region code (`"GB"`,
`"US"`).
- **`formatCurrencyName(code, locale, style?)`** — Returns the currency
display name. `code` is an ISO 4217 currency code (`"USD"`,
`"EUR"`).
- **`formatScriptName(code, locale, style?)`** — Returns the script
display name. `code` is an ISO 15924 script code (`"Latn"`,
`"Cyrl"`).
- **`formatCalendarName(code, locale, style?)`** — Returns the calendar
display name. `code` is a Unicode calendar identifier (`"gregory"`,
`"buddhist"`, `"islamic"`).
Every helper throws `I18nError(UNSUPPORTED_LOCALE)` on a malformed
locale tag (matching the standalone semantics).
## Implementation Notes
- Each helper is a one-line wrapper around `formatDisplayName`. There
is no separate cache layer — the underlying `formatDisplayName`
cache by `(locale, type, style, fallback)` covers all five.
- The helpers are pure functions with no shared state.

View File

@@ -0,0 +1,98 @@
# DisplayName observability
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The DisplayName formatter exports two OpenTelemetry instruments and a
small in-memory state ring so spec-conformance tests can assert exact
attribute payloads without standing up a full SDK exporter. The
instruments match the canonical Wave-3 formatter contract — every
sibling formatter (number, relative-time, list, date, currency,
collator, segmenter) emits the same two metric names so dashboards
and alerts compose cleanly across the family.
## Features
- Histogram `i18n.format.display_name.duration_ms` (unit `ms`) — per-format wall-clock time
- Counter `i18n.format.cache_hit{formatter="display_name"}` — increments on cache reuse only
- In-memory state ring (`getDisplayNameMetricsState()`) for spec-conformance assertions
- `resetDisplayNameMetricsState()` test-only helper to clear the ring between cases
## Usage: Basic Example
```ts
import { metrics } from "@opentelemetry/api";
import { MeterProvider } from "@opentelemetry/sdk-metrics";
// import your MetricReader / OTLP exporter ...
const provider = new MeterProvider({ readers: [/* reader */] });
metrics.setGlobalMeterProvider(provider);
// Subsequent formatDisplayName calls now emit through your reader.
import { formatDisplayName } from "@suaveplan/i18n-formatter-display-name";
formatDisplayName("fr", "en-GB", { type: "language" });
```
## Usage: Advanced Example
```ts
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
clearDisplayNameCache,
formatDisplayName,
} from "@suaveplan/i18n-formatter-display-name";
import {
getDisplayNameMetricsState,
resetDisplayNameMetricsState,
} from "@suaveplan/i18n-formatter-display-name";
it("emits histogram + cache-hit counter", () => {
clearDisplayNameCache();
resetDisplayNameMetricsState();
formatDisplayName("fr", "en-GB", { type: "language" });
formatDisplayName("fr", "en-GB", { type: "language" });
const state = getDisplayNameMetricsState();
expect(state.durationSamples).toHaveLength(2);
expect(state.cacheHitSamples).toHaveLength(1);
});
```
## API Reference
### `recordDisplayNameFormat(locale, type, style, durationMs): void`
Records the duration histogram sample. Tags: `locale`, `type`,
`style`. Called internally by `formatDisplayName`; consumers do not
need to invoke it directly.
### `recordDisplayNameCacheHit(): void`
Increments the cache-hit counter with the constant tag
`{ formatter: "display_name" }`. Called internally on a cache hit.
### `getDisplayNameMetricsState(): DisplayNameMetricsState`
Returns the in-memory ring of `durationSamples` and `cacheHitSamples`.
Used by spec-conformance tests to assert exact attribute payloads.
### `resetDisplayNameMetricsState(): void`
Clears both ring buffers. Test-only helper; call before the
under-test code to assert exactly the samples your scenario
generates.
## Implementation Notes
- The OTel meter is named `suaveplan.i18n.formatter.display_name`.
Every formatter in the family has its own meter so OTel resource
attributes can be tied per-package if a consumer chooses.
- Sample dashboard query (PromQL/Grafana over OTLP):
`histogram_quantile(0.99, sum(rate(i18n_format_display_name_duration_ms_bucket[5m])) by (le, locale))`
- The in-memory ring is intentionally unbounded — tests are short and
call `resetDisplayNameMetricsState()` between cases. A long-running
test loop should call reset to avoid memory growth.
- `style` defaults to `"long"` in the histogram tag when the caller
omits it; `type` is always required so it is never defaulted.

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.*`.

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.

View File

@@ -0,0 +1,109 @@
# @suaveplan/i18n-formatter-relative-time - Locale-aware relative-time formatting backed by Intl.RelativeTimeFormat. Class wrapper + standalone helper + ergonomic helpers (formatTimeAgo, formatTimeIn, selectRelativeUnit), Zod-validated options, per-(locale, style, numeric) cache, OTel histogram + cache-hit counter. Wave 3 of the @suaveplan/i18n modular split.
## Summary
Locale-aware relative-time formatting backed by `Intl.RelativeTimeFormat`.
Wave 3 of the 14-package i18n modular split. Provides both a class
(`RelativeTimeFormatter`) and a standalone helper
(`formatRelativeTime`) plus ergonomic shortcuts (`formatTimeAgo`,
`formatTimeIn`, `selectRelativeUnit`,
`formatRelativeTimeStyled`) on top of a per-`(locale, style, numeric)`
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 relative-time formatting backed by `Intl.RelativeTimeFormat`. Given a delta in milliseconds (positive for…
- [formatter](./src/formatter/formatter.md) — Locale-aware relative-time formatting backed by `Intl.RelativeTimeFormat`. Given a delta in milliseconds (positive for…
- [helpers](./src/helpers/helpers.md) — Pre-bound shortcuts over `formatRelativeTime` for the two most common relative-time shapes in product UIs: past-tense (…
- [helpers](./src/helpers/helpers.md) — Pre-bound shortcuts over `formatRelativeTime` for the two most common relative-time shapes in product UIs: past-tense (…
- [observability](./src/observability/observability.md) — Per-package OpenTelemetry instruments for `@suaveplan/i18n-formatter-relative-time`. Exposes a histogram (`i18n.format.…
- [observability](./src/observability/observability.md) — Per-package OpenTelemetry instruments for `@suaveplan/i18n-formatter-relative-time`. Exposes a histogram (`i18n.format.…
<!-- 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
relative-time slice of Wave 3, with the same shape as
`@suaveplan/i18n-formatter-number` (per-(locale, options-hash) cache,
Zod schema, OTel metrics, helper + class twin surface).
## Install
```bash
bun add @suaveplan/i18n-formatter-relative-time
```
## Quick Start
```ts
import {
formatRelativeTime,
formatTimeAgo,
formatTimeIn,
RelativeTimeFormatter,
selectRelativeUnit,
} from "@suaveplan/i18n-formatter-relative-time";
formatRelativeTime(-3_600_000, "en-US"); // → "1 hour ago"
formatRelativeTime(86_400_000, "en-US", {
numeric: "auto",
}); // → "tomorrow"
formatTimeAgo(60_000, "en-US"); // → "1 minute ago"
formatTimeIn(60_000, "en-US"); // → "in 1 minute"
selectRelativeUnit(90_000); // → { unit: "minutes", value: 2 }
const compact = new RelativeTimeFormatter({ style: "short" });
compact.format(60_000, "en-US"); // → "in 1 min."
```
## Modules
| Subpath | Description |
|---|---|
| `@suaveplan/i18n-formatter-relative-time` | Root barrel — class + standalone + helpers + observability |
| `@suaveplan/i18n-formatter-relative-time/formatter` | `RelativeTimeFormatter`, `formatRelativeTime`, `selectRelativeUnit`, `clearRelativeTimeFormatterCache`, types, schema |
| `@suaveplan/i18n-formatter-relative-time/helpers` | `formatTimeAgo`, `formatTimeIn`, `formatRelativeTimeStyled`, `selectRelativeUnit` only |
## Modules Documentation
- [formatter.md](src/formatter.md) — `RelativeTimeFormatter` class + `formatRelativeTime`
- [helpers.md](src/helpers.md) — `formatTimeAgo`, `formatTimeIn`, `selectRelativeUnit`, `formatRelativeTimeStyled`
- [observability.md](src/observability.md) — OTel histogram + cache-hit counter
## Public Surface (snapshot)
| Export | Kind | Origin |
|---|---|---|
| `RelativeTimeFormatter` | class | `formatter.ts` |
| `formatRelativeTime` | function | `formatter.ts` |
| `selectRelativeUnit` | function | `formatter.ts` |
| `clearRelativeTimeFormatterCache` | function | `formatter.ts` |
| `RelativeTimeFormatterOptionsSchema` | const | `formatter.ts` |
| `RelativeTimeUnit` | type | `formatter.types.ts` |
| `RelativeTimeStyle` | type | `formatter.types.ts` |
| `RelativeTimeNumeric` | type | `formatter.types.ts` |
| `RelativeTimeFormatterOptions` | type | `formatter.types.ts` |
| `RelativeTimeUnitSelection` | type | `formatter.types.ts` |
| `formatTimeAgo` | function | `helpers.ts` |
| `formatTimeIn` | function | `helpers.ts` |
| `formatRelativeTimeStyled` | function | `helpers.ts` |
| `recordRelativeTimeFormat` | function | `observability.ts` |
| `recordRelativeTimeCacheHit` | function | `observability.ts` |
| `getRelativeTimeFormatMetricsState` | function | `observability.ts` |
| `resetRelativeTimeFormatMetricsState` | 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/relative-time/` is
preserved in the monolith until Wave 6 deletes it.

View File

@@ -0,0 +1,82 @@
# RelativeTimeFormatter
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Locale-aware relative-time formatting backed by `Intl.RelativeTimeFormat`. Given a delta in milliseconds (positive for future, negative for past), the formatter auto-selects the largest unit whose absolute value is ≥ 1 from the ordered set `seconds → minutes → hours → days → weeks → months → years` and renders the result through `Intl.RelativeTimeFormat`.
Thresholds are fixed: 60 s → 1 min, 60 min → 1 h, 24 h → 1 d, 7 d → 1 w, 30.4375 d → 1 mo, 365.25 d → 1 y. The Gregorian-average month and Julian-average year are used for thresholds; consumers needing calendar-precise computation should compute the unit themselves and pass `options.unit` explicitly.
A per-`(locale, style, numeric)` cache amortises `Intl.RelativeTimeFormat` construction. Cache reuse increments `i18n.format.cache_hit{formatter="relative_time"}`; every format call records a sample on `i18n.format.relative_time.duration_ms` tagged with `locale`, `style`, and the resolved `unit` (auto-selected or explicit).
## Features
- Auto-unit selection across the full `seconds | minutes | hours | days | weeks | months | years` range
- Explicit `options.unit` to override auto-selection
- `style: 'long' | 'short' | 'narrow'` (default `'long'`)
- `numeric: 'always' | 'auto'` (default `'always'`); `auto` produces phrases like "yesterday", "tomorrow"
- Class form (`RelativeTimeFormatter`) for fixed-options reuse + standalone helper (`formatRelativeTime`) for one-off calls
- Stable error codes: `I18N.INVALID_FORMATTER_OPTIONS`, `I18N.UNSUPPORTED_LOCALE`
- Process-wide `Intl.RelativeTimeFormat` cache; `clearRelativeTimeFormatterCache()` for tests
- `selectRelativeUnit(deltaMs)` exposed as a standalone for callers that only need the chosen `(unit, value)` pair
## Usage: Basic Example
```typescript
import { formatRelativeTime } from "@suaveplan/i18n-formatter-relative-time";
formatRelativeTime(30_000, "en-US"); // → "in 30 seconds"
formatRelativeTime(-3_600_000, "en-US"); // → "1 hour ago"
formatRelativeTime(86_400_000, "en-US", { numeric: "auto" }); // → "tomorrow"
```
## Usage: Advanced Example — Class Form for Repeated Use
```typescript
import { RelativeTimeFormatter } from "@suaveplan/i18n-formatter-relative-time";
const compact = new RelativeTimeFormatter({ style: "short", numeric: "auto" });
// Many call sites, single options validation:
const summary = events.map((event) =>
compact.format(event.timestamp - Date.now(), "en-US"),
);
```
## API Reference
### `formatRelativeTime(deltaMs, locale, options?): string`
Formats a millisecond delta. Auto-selects a unit when `options.unit` is omitted; otherwise honours the explicit unit. Validates options through Zod, throws `I18nError(I18N.INVALID_FORMATTER_OPTIONS)` on failure. Maps `Intl.RelativeTimeFormat`'s `RangeError` for unsupported locales to `I18nError(I18N.UNSUPPORTED_LOCALE)`.
### `class RelativeTimeFormatter`
- `constructor(options?: RelativeTimeFormatterOptions)` — validates options at construction
- `format(deltaMs: number, locale: string): string` — formats with stored options
### `selectRelativeUnit(deltaMs): RelativeTimeUnitSelection`
Exposes the auto-unit-selection algorithm without rendering. Returns `{ unit, value }` where `unit` is one of `'seconds' | 'minutes' | ...` and `value` is the rounded count.
### `clearRelativeTimeFormatterCache(): void`
Test-only.
### `RelativeTimeFormatterOptionsSchema`
Zod schema validating the `RelativeTimeFormatterOptions` surface.
### Types
`RelativeTimeUnit`, `RelativeTimeStyle`, `RelativeTimeNumeric`, `RelativeTimeFormatterOptions`, `RelativeTimeUnitSelection`.
## Implementation Notes
The threshold table uses `30.4375 d` for "month" (Gregorian average: 365.25 / 12) and `365.25 d` for "year" (Julian average). These are the same approximations `moment.js` and `dayjs` use for relative-time. Calendar-precise rendering would require knowing the exact reference date, which the function deliberately doesn't take — it accepts a delta, not two dates.
`numeric: 'auto'` produces locale-defined idiom strings ("yesterday", "tomorrow", "last week") for specific deltas. The exact set is platform-defined by `Intl.RelativeTimeFormat`. Tests assert idioms only for `en-US -1 day → "yesterday"` because that's stable across V8/JSC/SpiderMonkey; other idioms vary by runtime.
The cache key is `${locale}|${style}|${numeric}` rather than a JSON-stringified options bag because RelativeTimeFormat has only three knobs. Adding more options to the cache key (e.g., `unit`) would be wrong because the same formatter instance can render multiple units — it's only the locale + style + numeric combo that determines the formatter's identity.

View File

@@ -0,0 +1,73 @@
# Relative-Time Helper Formatters
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Pre-bound shortcuts over `formatRelativeTime` for the two most common relative-time shapes in product UIs: past-tense ("5 minutes ago") and future-tense ("in 5 minutes"). These are net-new ergonomic helpers introduced by the Wave-3 split — the monolith's `formatting/relative-time/` ships only the class and standalone, while every other Wave-3 formatter carries a sibling helper module.
The helpers route every call through `formatRelativeTime`, so they inherit Zod option validation, the per-`(locale, style, numeric)` cache, and the OTel histogram + cache-hit counter. `selectRelativeUnit` is re-exported from the helpers subpath so callers that import only `@suaveplan/i18n-formatter-relative-time/helpers` for tree-shaking still get the auto-unit-selection function.
## Features
- `formatTimeAgo(ageMs, locale, options?)` — past-tense shortcut that negates internally so callers never have to remember the past = negative convention
- `formatTimeIn(intervalMs, locale, options?)` — future-tense shortcut that abs-es internally
- `formatRelativeTimeStyled(deltaMs, locale, style)` — style-only override without an options bag
- `selectRelativeUnit(deltaMs)` — re-export of the auto-unit-selection helper
## Usage: Basic Example
```typescript
import {
formatRelativeTimeStyled,
formatTimeAgo,
formatTimeIn,
selectRelativeUnit,
} from "@suaveplan/i18n-formatter-relative-time/helpers";
formatTimeAgo(3_600_000, "en-US"); // → "1 hour ago"
formatTimeIn(60_000, "en-US"); // → "in 1 minute"
formatRelativeTimeStyled(86_400_000, "en-US", "short"); // → "in 1 day"
selectRelativeUnit(90_000); // → { unit: "minutes", value: 2 }
```
## Usage: Advanced Example — Activity feed timestamps
```typescript
import { formatTimeAgo } from "@suaveplan/i18n-formatter-relative-time/helpers";
function renderActivity(events, locale) {
return events.map((event) => ({
title: event.title,
when: formatTimeAgo(Date.now() - event.timestamp, locale, {
style: "short",
}),
}));
}
```
## API Reference
### `formatTimeAgo(ageMs, locale, options?)`
Pre-bound shortcut for past-tense relative-time strings. Accepts an absolute (non-negative) age in milliseconds and renders it as "5 seconds ago", "1 hour ago", etc. Negates the input internally so callers never have to remember the past = negative convention of `Intl.RelativeTimeFormat`.
### `formatTimeIn(intervalMs, locale, options?)`
Pre-bound shortcut for future-tense relative-time strings. Accepts a non-negative interval in milliseconds and renders it as "in 5 seconds", "in 1 hour", etc.
### `formatRelativeTimeStyled(deltaMs, locale, style)`
Re-binds the style argument so the caller can skip the options bag for a one-shot style override. Accepts the original signed-delta convention (positive = future, negative = past).
### `selectRelativeUnit(deltaMs)`
Re-export of the formatter's auto-unit-selection helper. Returns `{ unit, value }` so callers can inspect the chosen unit without rendering through `Intl.RelativeTimeFormat`.
## Implementation Notes
`formatTimeAgo` uses `Math.abs(ageMs)` and negates the result before passing to `formatRelativeTime` so callers can pass either a positive duration or the natural `Date.now() - eventTime` form (which can be either sign depending on clock skew) without surprises. The same pattern in `formatTimeIn` ensures the future-tense helper always renders future-tense output even if the caller accidentally supplies a negative value.
`formatRelativeTimeStyled` is intentionally kept as a separate function rather than a default-arg overload of `formatRelativeTime` because the latter would change the standalone helper's call signature, breaking the `formatBridge` design contract that the slim core wires up in Wave 6.

View File

@@ -0,0 +1,91 @@
# RelativeTimeFormatter Observability
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Per-package OpenTelemetry instruments for `@suaveplan/i18n-formatter-relative-time`. Exposes a histogram (`i18n.format.relative_time.duration_ms`) recording wall-clock duration of every `formatRelativeTime` call (tagged with `locale`, `style`, and the resolved `unit`) and a counter (`i18n.format.cache_hit`) tagged with `formatter: "relative_time"` 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 so spec-conformance tests can assert exact attribute payloads without standing up a full SDK exporter.
## Features
- Lazy instrument construction
- Stable histogram name `i18n.format.relative_time.duration_ms` with attributes `{ locale, style, unit }`
- Stable counter name `i18n.format.cache_hit` with attributes `{ formatter: "relative_time" }`
- In-memory sample ring (`relativeTimeDurationSamples`, `cacheHitSamples`)
- `resetRelativeTimeFormatMetricsState()` clears both arrays so tests stay isolated
## Usage: Basic Example
```typescript
import {
recordRelativeTimeFormat,
recordRelativeTimeCacheHit,
getRelativeTimeFormatMetricsState,
resetRelativeTimeFormatMetricsState,
} from "@suaveplan/i18n-formatter-relative-time/observability";
resetRelativeTimeFormatMetricsState();
recordRelativeTimeFormat("en-GB", "long", "minutes", 1.23);
recordRelativeTimeCacheHit();
const state = getRelativeTimeFormatMetricsState();
state.relativeTimeDurationSamples.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 {
formatRelativeTime,
clearRelativeTimeFormatterCache,
} from "@suaveplan/i18n-formatter-relative-time";
import {
getRelativeTimeFormatMetricsState,
resetRelativeTimeFormatMetricsState,
} from "@suaveplan/i18n-formatter-relative-time/observability";
describe("metrics emission", () => {
it("emits duration histogram and cache-hit counter", { timeout: 5000 }, () => {
clearRelativeTimeFormatterCache();
resetRelativeTimeFormatMetricsState();
formatRelativeTime(60_000, "en-GB");
formatRelativeTime(60_000, "en-GB");
const state = getRelativeTimeFormatMetricsState();
expect(state.relativeTimeDurationSamples).toHaveLength(2);
expect(state.cacheHitSamples).toHaveLength(1);
});
});
```
## API Reference
### `recordRelativeTimeFormat(locale, style, unit, durationMs): void`
Records one observation on the `i18n.format.relative_time.duration_ms` histogram and pushes a `{ attributes: { locale, style, unit } }` entry into the in-memory sample ring.
### `recordRelativeTimeCacheHit(): void`
Adds 1 to the `i18n.format.cache_hit` counter with `formatter: "relative_time"` and pushes a matching entry into the in-memory sample ring.
### `getRelativeTimeFormatMetricsState(): RelativeTimeFormatMetricsState`
Returns the in-memory sample ring (live reference — do not mutate).
### `resetRelativeTimeFormatMetricsState(): void`
Empties both sample arrays in place.
### `RelativeTimeFormatMetricsState`
`{ relativeTimeDurationSamples: FormatMetricSample[]; cacheHitSamples: FormatMetricSample[] }` where `FormatMetricSample = { attributes: Record<string, string | number> }`.
## Implementation Notes
The instruments are built lazily so consumers who never call `formatRelativeTime` (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 `resetRelativeTimeFormatMetricsState` calls (resetting state does not invalidate the meter).
The module deliberately keeps the in-memory sample ring unbounded. Tests should call `resetRelativeTimeFormatMetricsState()` between cases. Long-running production processes should not retain the ring; the slim-core wave (Wave 6) 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.

View File

@@ -0,0 +1,111 @@
# @suaveplan/i18n-formatter-segmenter - Intl.Segmenter wrapper for the @suaveplan/i18n family — locale-aware text segmentation by grapheme, word, or sentence (compound-emoji-aware grapheme boundaries, ICU word-boundary rules, sentence segmentation). Per-(locale, granularity) cache, Zod-validated options, OTel histogram + cache-hit counter, structured I18nError mapping. Brand-new formatter — closes the gap from the original i18n-formatters change scope. Universal leaf package.
## Summary
Wrapper around `Intl.Segmenter` for the `@suaveplan/i18n` family —
locale-aware text segmentation by grapheme, word, or sentence.
Compound emoji count as a single grapheme; word boundaries follow
ICU rules (handles non-space-separated scripts like Thai / Khmer /
Japanese correctly); sentence boundaries follow ICU sentence-break
rules.
This is a **brand-new formatter** in the i18n family — `Intl.Segmenter`
was listed in the original `i18n-formatters` change scope but was
never implemented in the monolith. This package closes that gap.
<!-- begin:auto-docs -->
## Documentation tree
- [formatter](./src/formatter/formatter.md) — `SegmenterFormatter` and the `segmentText` / `formatSegments` standalone helpers expose `Intl.Segmenter` for the `@suav…
- [formatter](./src/formatter/formatter.md) — `SegmenterFormatter` and the `segmentText` / `formatSegments` standalone helpers expose `Intl.Segmenter` for the `@suav…
- [helpers](./src/helpers/helpers.md) — The three ergonomic helpers (`countGraphemes`, `splitWords`, `splitSentences`) pre-bind `segmentText` and `formatSegmen…
- [helpers](./src/helpers/helpers.md) — The three ergonomic helpers (`countGraphemes`, `splitWords`, `splitSentences`) pre-bind `segmentText` and `formatSegmen…
- [observability](./src/observability/observability.md) — The Segmenter formatter exports two OpenTelemetry instruments and a small in-memory state ring so spec-conformance test…
- [observability](./src/observability/observability.md) — The Segmenter formatter exports two OpenTelemetry instruments and a small in-memory state ring so spec-conformance test…
<!-- end:auto-docs -->
## Why this exists
The 14-package i18n modular split (cross-cutting design
`2026-05-05-i18n-modular-split.md`) lifts each `Intl.*` wrapper into
its own sibling package and adds the two formatters
(`Collator` + `Segmenter`) that the original change scope listed but
never shipped. Locale-aware segmentation is the right primitive for
character-limited inputs (tweet-style counters), full-text search
tokenisation, and sentence-aware UI truncation.
## Install
```bash
bun add @suaveplan/i18n-formatter-segmenter
```
## Quick Start
```ts
import {
SegmenterFormatter,
segmentText,
formatSegments,
countGraphemes,
splitWords,
splitSentences,
} from "@suaveplan/i18n-formatter-segmenter";
segmentText("ab", "en-GB"); // ["a", "b"]
segmentText("hello world", "en-GB", { granularity: "word" });
// ["hello", " ", "world"]
countGraphemes("👨‍👩‍👧", "en-GB"); // 1 (single grapheme)
splitWords("hello, world!", "en-GB"); // ["hello", "world"]
splitSentences("Hi. How are you?", "en-GB");
// ["Hi.", "How are you?"]
const f = new SegmenterFormatter({ granularity: "word" });
f.segmentWithMetadata("hi!", "en-GB");
// [
// { segment: "hi", index: 0, isWordLike: true },
// { segment: "!", index: 2, isWordLike: false },
// ]
```
## Modules
| Subpath | Description |
|---|---|
| `@suaveplan/i18n-formatter-segmenter` | Root barrel — class + standalones + helpers + observability |
| `@suaveplan/i18n-formatter-segmenter/formatter` | `SegmenterFormatter`, `segmentText`, `formatSegments`, types, schema |
| `@suaveplan/i18n-formatter-segmenter/helpers` | `countGraphemes`, `splitWords`, `splitSentences` |
## Modules Documentation
- [formatter.md](src/formatter.md) — `SegmenterFormatter`, `segmentText`, `formatSegments`, `clearSegmenterCache`
- [helpers.md](src/helpers.md) — three ergonomic helpers
- [observability.md](src/observability.md) — OTel histogram + cache-hit counter
## Public Surface (snapshot)
| Export | Kind | Origin |
|---|---|---|
| `SegmenterFormatter` | class | `formatter.ts` |
| `segmentText` | function | `formatter.ts` |
| `formatSegments` | function | `formatter.ts` |
| `clearSegmenterCache` | function | `formatter.ts` |
| `SegmenterFormatterOptionsSchema` | Zod schema | `formatter.schemas.ts` |
| `SegmenterFormatterOptions` | type | `formatter.types.ts` |
| `SegmenterGranularity` | type | `formatter.types.ts` |
| `SegmentData` | type | `formatter.types.ts` |
| `SegmenterFormatterCacheKey` | type | `formatter.types.ts` |
| `countGraphemes` | function | `helpers.ts` |
| `splitWords` | function | `helpers.ts` |
| `splitSentences` | function | `helpers.ts` |
| `recordSegmenterFormat` | function | `observability.ts` |
| `recordSegmenterCacheHit` | function | `observability.ts` |
| `getSegmenterMetricsState` | function | `observability.ts` |
| `resetSegmenterMetricsState` | function | `observability.ts` |
## Versioning
Part of the 14-package i18n modular-split rollout. Stays at `0.1.0`
until the slim core (Wave 6) cuts the family `1.0.0` release.

View File

@@ -0,0 +1,127 @@
# SegmenterFormatter
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`SegmenterFormatter` and the `segmentText` / `formatSegments`
standalone helpers expose `Intl.Segmenter` for the `@suaveplan/i18n`
family at the canonical Wave-3 formatter surface. Locale-aware text
segmentation by grapheme, word, or sentence is the right primitive
for emoji-aware character counts, ICU word-boundary tokenisation,
and sentence splitting.
This is a brand-new formatter — `Intl.Segmenter` was listed in the
original `i18n-formatters` change scope but never implemented in the
monolith. The split package closes that gap with the canonical Wave-3
shape.
## Features
- `SegmenterFormatter` class with `segment(input, locale)` and `segmentWithMetadata(input, locale)` methods
- `segmentText(input, locale, options?)` standalone — returns plain `string[]`
- `formatSegments(input, locale, options?)` standalone — returns `SegmentData[]` with `segment` / `index` / `isWordLike` metadata
- Three granularity values: `"grapheme"` (default), `"word"`, `"sentence"`
- Construction-time Zod validation with `INVALID_FORMATTER_OPTIONS` mapping
- `Intl.Segmenter` `RangeError` mapped to `UNSUPPORTED_LOCALE` with original error attached as `cause`
- Runtime-availability check: when the host runtime lacks `Intl.Segmenter`, throws `I18nError(UNSUPPORTED_LOCALE)` with message key `intl.segmenter.unavailable`
- Per-(locale, granularity) cache — repeated calls reuse the same `Intl.Segmenter`
- `clearSegmenterCache()` test-only helper
## Usage: Basic Example
```ts
import {
formatSegments,
segmentText,
} from "@suaveplan/i18n-formatter-segmenter";
segmentText("ab", "en-GB"); // ["a", "b"]
segmentText("hello world", "en-GB", { granularity: "word" });
// ["hello", " ", "world"]
formatSegments("hi!", "en-GB", { granularity: "word" });
// [
// { segment: "hi", index: 0, isWordLike: true },
// { segment: "!", index: 2, isWordLike: false },
// ]
```
## Usage: Advanced Example
```ts
import {
SegmenterFormatter,
type SegmenterFormatterOptions,
} from "@suaveplan/i18n-formatter-segmenter";
// Emoji-aware character counter — handles compound emoji
// (family-of-three ZWJ sequence is ONE grapheme, not 7+ code units).
const f = new SegmenterFormatter({ granularity: "grapheme" });
const count = f.segment("👨‍👩‍👧 hello", "en-GB").length;
// count === 8 (1 emoji + space + 5 letters + ... locale-correct)
// Build a word-level token stream for full-text search indexing.
const search = new SegmenterFormatter({ granularity: "word" });
const tokens = search
.segmentWithMetadata("Quick brown fox.", "en-GB")
.filter((s) => s.isWordLike === true)
.map((s) => s.segment.toLowerCase());
// ["quick", "brown", "fox"]
// Sentence-aware truncation for chat previews.
const sentences = new SegmenterFormatter({ granularity: "sentence" });
const first = sentences.segment(
"Hello there. How are you today?",
"en-GB"
)[0];
// "Hello there. "
```
## API Reference
### `segmentText(input, locale, options?): string[]`
Standalone helper. Returns a plain `string[]` of segments. Default
`granularity` is `"grapheme"`. Throws
`I18nError(INVALID_FORMATTER_OPTIONS)` on Zod failure;
`I18nError(UNSUPPORTED_LOCALE)` on runtime unavailability or
`Intl.Segmenter` `RangeError`.
### `formatSegments(input, locale, options?): SegmentData[]`
Standalone helper. Returns `SegmentData[]` preserving each segment's
`index` and (for `granularity: "word"`) `isWordLike` flag. The
`isWordLike` field is omitted on `grapheme` and `sentence`
granularities (matches `Intl.Segmenter` semantics).
### `class SegmenterFormatter`
- `constructor(options?: SegmenterFormatterOptions)` — Zod-validates options
- `segment(input, locale): string[]`
- `segmentWithMetadata(input, locale): SegmentData[]`
### `clearSegmenterCache(): void`
Test-only helper that empties the per-(locale, granularity) cache.
## Implementation Notes
- The cache key is `${locale}|${granularity}` — only two dimensions
because `Intl.Segmenter` accepts only `granularity` as a configurable
option. Cache size stays small even with many locales.
- Runtime-availability guard checks `typeof Intl.Segmenter === "function"`
before construction. This catches Node < 16 / very-old browsers /
test stubs that null out `Intl.Segmenter`. The error code reuses
`UNSUPPORTED_LOCALE` (the closest fit in the `I18N_CODES`
registry) with a discriminating message key
`intl.segmenter.unavailable` so consumers can branch on the message.
- `Intl.Segmenter` shipped in Node 16+, Bun, and every current major
browser. The package does not provide a polyfill.
- The `segmentWithMetadata` method exists on the class as the
programmatic-friendly companion to the standalone `formatSegments`
— both produce identical output.
- Compound emoji (e.g. `"👨‍👩‍👧"`, the family-of-three ZWJ sequence)
count as a single grapheme per ECMA-402.

View File

@@ -0,0 +1,105 @@
# Segmenter ergonomic helpers
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The three ergonomic helpers (`countGraphemes`, `splitWords`,
`splitSentences`) pre-bind `segmentText` and `formatSegments` with
the appropriate granularity for the most common UI patterns. They
exist because counting graphemes (for character limits) and splitting
words (for token streams) are universal needs that should not require
remembering the `granularity` discriminant.
## Features
- `countGraphemes(input, locale)` — emoji-aware grapheme count
- `splitWords(input, locale)` — word-segments only (filters non-word-like)
- `splitSentences(input, locale)` — sentence-segments, trimmed, empty entries dropped
## Usage: Basic Example
```ts
import {
countGraphemes,
splitSentences,
splitWords,
} from "@suaveplan/i18n-formatter-segmenter";
countGraphemes("hello", "en-GB"); // 5
countGraphemes("👨‍👩‍👧", "en-GB"); // 1 (single grapheme)
splitWords("hello, world!", "en-GB"); // ["hello", "world"]
splitSentences("Hi. How are you?", "en-GB"); // ["Hi.", "How are you?"]
```
## Usage: Advanced Example
```ts
import {
countGraphemes,
splitSentences,
splitWords,
} from "@suaveplan/i18n-formatter-segmenter";
// Tweet-style character limit that respects compound emoji.
const MAX_CHARS = 280;
function tweetFits(text: string, locale: string) {
return countGraphemes(text, locale) <= MAX_CHARS;
}
tweetFits("👨‍👩‍👧 hello world", "en-GB"); // true (8 graphemes)
// Word-frequency map for full-text indexing.
function wordFrequencies(input: string, locale: string) {
const out: Record<string, number> = {};
for (const word of splitWords(input, locale)) {
const k = word.toLowerCase();
out[k] = (out[k] ?? 0) + 1;
}
return out;
}
// Sentence-aware preview that always ends at a sentence boundary.
function preview(input: string, locale: string, maxSentences: number) {
return splitSentences(input, locale).slice(0, maxSentences).join(" ");
}
```
## API Reference
### `countGraphemes(input, locale): number`
Returns the grapheme count. Uses `granularity: "grapheme"` so
compound emoji and combining-character sequences count as one.
### `splitWords(input, locale): string[]`
Returns word-segments only. Internally calls `formatSegments` with
`granularity: "word"` and filters segments where `isWordLike === true`.
Punctuation, whitespace, and other non-word-like segments are
dropped.
### `splitSentences(input, locale): string[]`
Returns sentence-segments. Each entry is trimmed of leading/trailing
whitespace; empty entries are dropped. Punctuation that ends a
sentence (`.`/`?`/`!`) is included with the preceding sentence per
ICU sentence-boundary rules.
## Implementation Notes
- Each helper is a thin wrapper around `segmentText` / `formatSegments`.
The underlying per-(locale, granularity) cache means calling
`splitWords` and `splitSentences` for the same locale uses two
cached `Intl.Segmenter` instances (one per granularity) — minimal
per-call cost.
- `splitSentences` trim-and-filter chain is deliberate: ICU's
sentence-boundary rules emit each sentence WITH its trailing
whitespace; trimming gives the consumer the expected "sentence
text" without trailing space, and filtering drops the trailing
empty segment that `Intl.Segmenter` sometimes emits for terminator-
only input.
- The helpers are pure functions with no shared state.

View File

@@ -0,0 +1,98 @@
# Segmenter observability
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The Segmenter formatter exports two OpenTelemetry instruments and a
small in-memory state ring so spec-conformance tests can assert exact
attribute payloads without standing up a full SDK exporter. The
instrument names match the canonical Wave-3 formatter contract —
every sibling formatter emits the same two metric names so dashboards
and alerts compose cleanly across the family.
## Features
- Histogram `i18n.format.segmenter.duration_ms` (unit `ms`) — per-segment wall-clock time
- Counter `i18n.format.cache_hit{formatter="segmenter"}` — increments on cache reuse only
- In-memory state ring (`getSegmenterMetricsState()`) for spec-conformance
- `resetSegmenterMetricsState()` test-only helper
## Usage: Basic Example
```ts
import { metrics } from "@opentelemetry/api";
import { MeterProvider } from "@opentelemetry/sdk-metrics";
const provider = new MeterProvider({ readers: [/* reader */] });
metrics.setGlobalMeterProvider(provider);
import { segmentText } from "@suaveplan/i18n-formatter-segmenter";
segmentText("hello", "en-GB", { granularity: "word" });
// emits via your reader
```
## Usage: Advanced Example
```ts
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
clearSegmenterCache,
segmentText,
} from "@suaveplan/i18n-formatter-segmenter";
import {
getSegmenterMetricsState,
resetSegmenterMetricsState,
} from "@suaveplan/i18n-formatter-segmenter";
it("emits histogram + cache-hit counter", () => {
clearSegmenterCache();
resetSegmenterMetricsState();
segmentText("hello", "en-GB", { granularity: "word" });
segmentText("hello", "en-GB", { granularity: "word" });
const state = getSegmenterMetricsState();
expect(state.durationSamples).toHaveLength(2);
expect(state.cacheHitSamples).toHaveLength(1);
expect(state.durationSamples[0]?.attributes).toMatchObject({
locale: "en-GB",
granularity: "word",
});
});
```
## API Reference
### `recordSegmenterFormat(locale, granularity, durationMs): void`
Records the duration histogram sample. Tags: `locale`, `granularity`.
Called internally by `segmentText` / `formatSegments`.
### `recordSegmenterCacheHit(): void`
Increments the cache-hit counter with the constant tag
`{ formatter: "segmenter" }`.
### `getSegmenterMetricsState(): SegmenterMetricsState`
Returns the in-memory ring of `durationSamples` and
`cacheHitSamples`. Used by spec-conformance tests.
### `resetSegmenterMetricsState(): void`
Clears both ring buffers. Test-only helper.
## Implementation Notes
- The OTel meter is named `suaveplan.i18n.formatter.segmenter`. Every
formatter in the family has its own meter so OTel resource
attributes can be tied per-package if a consumer chooses.
- `granularity` defaults to `"grapheme"` in the histogram tag when
the caller omits it; `locale` is always required so it's never
defaulted.
- Sample dashboard query (PromQL/Grafana over OTLP):
`histogram_quantile(0.99, sum(rate(i18n_format_segmenter_duration_ms_bucket[5m])) by (le, locale, granularity))`
- The in-memory ring is intentionally unbounded — tests are short
and call `resetSegmenterMetricsState()` between cases. A
long-running test loop should call reset to avoid memory growth.

View File

@@ -0,0 +1,182 @@
# @suaveplan/i18n-icu-parser - ICU MessageFormat parser, {{var}} interpolation engine, and 9-language CLDR pluralization for the @suaveplan/i18n family. Universal leaf package — no formatter, backend, or core dependencies. Renders ICU placeholders through a FormatBridge DI seam so the package stays free of Intl wrappers.
## Summary
ICU MessageFormat parser, `{{var}}` interpolation engine, and
9-language CLDR pluralization for the `@suaveplan/i18n` family.
Universal leaf package — depends only on
`@suaveplan/i18n-shared`, `@suaveplan/error`, `@suaveplan/types`,
and the OTel API. No formatter, backend, detector, or core
dependencies.
<!-- begin:auto-docs -->
## Documentation tree
- [interpolation](./src/interpolation/interpolation.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [escape-html](./src/interpolation/escape-html.md) — `escapeHtmlChars` converts the five HTML special characters (`&`, `<`, `>`, `"`, `'`) to their numeric / named entity e…
- [escape-regex](./src/interpolation/escape-regex.md) — `escapeRegex` escapes every regular-expression metacharacter in a string so the string can be embedded literally inside…
- [interpolation](./src/interpolation/interpolation.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [observability](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [metrics](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [format-bridge](./src/parser/format-bridge.md) — `FormatBridge` is the dependency-injection seam that lets `@suaveplan/i18n-icu-parser` render ICU format placeholders (…
- [message-render](./src/parser/message-render.md) — `message-render` provides two standalone utility functions for `@suaveplan/i18n`: `extractVariables`, which collects al…
- [message](./src/parser/message.md) — `MessageParser` converts ICU-like message strings into a typed AST (Abstract Syntax Tree), and provides utilities to re…
- [pluralization](./src/pluralization/pluralization.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [pluralization](./src/pluralization/pluralization.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
<!-- end:auto-docs --> - [escape-html](./src/interpolation/escape-html.md) — `escapeHtmlChars` converts the five HTML special characters (`&`, `<`, `>`, `"`, `'`) to their numeric / named entity e…
- [escape-regex](./src/interpolation/escape-regex.md) — `escapeRegex` escapes every regular-expression metacharacter in a string so the string can be embedded literally inside…
- [interpolation](./src/interpolation/interpolation.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [metrics](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [format-bridge](./src/parser/format-bridge.md) — `FormatBridge` is the dependency-injection seam that lets `@suaveplan/i18n-icu-parser` render ICU format placeholders (…
- [message-render](./src/parser/message-render.md) — `message-render` provides two standalone utility functions for `@suaveplan/i18n`: `extractVariables`, which collects al…
- [message](./src/parser/message.md) — `MessageParser` converts ICU-like message strings into a typed AST (Abstract Syntax Tree), and provides utilities to re…
- [pluralization](./src/pluralization/pluralization.md) — <!-- begin:auto-docs --> _(no submodules)_- [pluralization](./src/pluralization/pluralization.md) — <!-- begin:auto-docs --> _(no submodules)_- [escape-html](./src/interpolation/escape-html.md) — `escapeHtmlChars` converts the five HTML special characters (`&`, `<`, `>`, `"`, `'`) to their numeric / named entity e…
- [escape-regex](./src/interpolation/escape-regex.md) — `escapeRegex` escapes every regular-expression metacharacter in a string so the string can be embedded literally inside…
- [interpolation](./src/interpolation/interpolation.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [metrics](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [format-bridge](./src/parser/format-bridge.md) — `FormatBridge` is the dependency-injection seam that lets `@suaveplan/i18n-icu-parser` render ICU format placeholders (…
- [message-render](./src/parser/message-render.md) — `message-render` provides two standalone utility functions for `@suaveplan/i18n`: `extractVariables`, which collects al…
- [message](./src/parser/message.md) — `MessageParser` converts ICU-like message strings into a typed AST (Abstract Syntax Tree), and provides utilities to re…
- [pluralization](./src/pluralization/pluralization.md) — <!-- begin:auto-docs --> _(no submodules)_- [pluralization](./src/pluralization/pluralization.md) — <!-- begin:auto-docs --> _(no submodules)_- [escape-html](./src/interpolation/escape-html.md) — `escapeHtmlChars` converts the five HTML special characters (`&`, `<`, `>`, `"`, `'`) to their numeric / named entity e…
- [escape-regex](./src/interpolation/escape-regex.md) — `escapeRegex` escapes every regular-expression metacharacter in a string so the string can be embedded literally inside…
- [interpolation](./src/interpolation/interpolation.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [metrics](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [format-bridge](./src/parser/format-bridge.md) — `FormatBridge` is the dependency-injection seam that lets `@suaveplan/i18n-icu-parser` render ICU format placeholders (…
- [message-render](./src/parser/message-render.md) — `message-render` provides two standalone utility functions for `@suaveplan/i18n`: `extractVariables`, which collects al…
- [message](./src/parser/message.md) — `MessageParser` converts ICU-like message strings into a typed AST (Abstract Syntax Tree), and provides utilities to re…
- [pluralization](./src/pluralization/pluralization.md) — <!-- begin:auto-docs --> _(no submodules)_- [pluralization](./src/pluralization/pluralization.md) — <!-- begin:auto-docs --> _(no submodules)_- [escape-html](./src/interpolation/escape-html.md) — `escapeHtmlChars` converts the five HTML special characters (`&`, `<`, `>`, `"`, `'`) to their numeric / named entity e…
- [escape-regex](./src/interpolation/escape-regex.md) — `escapeRegex` escapes every regular-expression metacharacter in a string so the string can be embedded literally inside…
- [interpolation](./src/interpolation/interpolation.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_ - [metrics](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [format-bridge](./src/parser/format-bridge.md) — `FormatBridge` is the dependency-injection seam that lets `@suaveplan/i18n-icu-parser` render ICU format placeholders (…
- [message-render](./src/parser/message-render.md) — `message-render` provides two standalone utility functions for `@suaveplan/i18n`: `extractVariables`, which collects al…
- [message](./src/parser/message.md) — `MessageParser` converts ICU-like message strings into a typed AST (Abstract Syntax Tree), and provides utilities to re…
- [pluralization](./src/pluralization/pluralization.md) — <!-- begin:auto-docs --> _(no submodules)_ - [pluralization](./src/pluralization/pluralization.md) — <!-- begin:auto-docs --> _(no submodules)_
## Why this exists
The 14-package i18n modular split needs one home for the three
pure-text-processing concerns that share the `MessageNode` AST,
the `INTERPOLATION_ERROR` / `PLURAL_RULE_NOT_FOUND` /
`NESTING_DEPTH_EXCEEDED` error codes, and the same renderer
contract: ICU parsing, `{{var}}` interpolation, and CLDR
pluralization. Splitting them further would fragment the seam
without benefit; bundling them as `@suaveplan/i18n-icu-parser`
keeps the AST internal and exposes a clean public surface
(`MessageParser`, `InterpolationEngine`, `PluralizationEngine`,
`renderNodes`) for the slim core to compose.
## The FormatBridge seam
ICU format placeholders (`{value, number}`, `{amount, number,
currency=USD}`, `{date, date, short}`, `{ts, time, long}`,
`{delta, relativeTime}`) need locale-aware output. To stay free
of the per-formatter packages, the renderer accepts a
`FormatBridge` via `RenderOptions.formatBridge`. When the bridge
(or a specific method on it) is undefined, the renderer falls
back to `String(value)` and never throws — the parser remains
fully usable in isolation, with no `Intl.*` runtime cost.
The slim `@suaveplan/i18n` core (Wave 6) constructs a default
bridge by importing the formatter packages and threads it through
`createI18n({ ... })` so consumers who use the high-level façade
get full ICU output with zero wiring.
```typescript
import {
MessageParser,
type FormatBridge,
} from "@suaveplan/i18n-icu-parser";
const bridge: FormatBridge = {
number: (v, locale) => new Intl.NumberFormat(locale).format(v),
currency: (v, currency, locale) =>
new Intl.NumberFormat(locale, { style: "currency", currency }).format(v),
};
const parser = new MessageParser();
parser.format(
"{amount, number, currency/USD} on {when, date, short}",
{ amount: 1234.5, when: new Date() },
{ locale: "en-US", formatBridge: bridge }
);
```
## Install
```bash
bun add @suaveplan/i18n-icu-parser
```
## Quick Start
```typescript
import {
InterpolationEngine,
MessageParser,
PluralizationEngine,
} from "@suaveplan/i18n-icu-parser";
const parser = new MessageParser();
parser.format("{count, plural, one {1 item} other {# items}}", { count: 3 });
// "3 items"
const engine = new InterpolationEngine({ escapeHtml: true });
engine.render("hello {{ name }}", { name: "Ada" });
// { text: "hello Ada", missingVariables: [] }
const plural = new PluralizationEngine();
plural.resolve(0, { zero: "none", one: "one", other: "many" }, { locale: "ar" });
// { category: "zero", message: "none", count: 0 }
```
## Modules
| Subpath | Description |
|---|---|
| `@suaveplan/i18n-icu-parser` | Root barrel — every public symbol |
| `@suaveplan/i18n-icu-parser/parser` | `MessageParser`, `renderNodes`, `extractVariables`, `FormatBridge`, `noopFormatBridge`, AST types |
| `@suaveplan/i18n-icu-parser/parser/format-bridge` | `FormatBridge` interface + `noopFormatBridge` ONLY (single-file import for slim-core wiring) |
| `@suaveplan/i18n-icu-parser/interpolation` | `InterpolationEngine`, `escapeHtmlChars`, `escapeRegex`, `InterpolationConfigSchema`, types |
| `@suaveplan/i18n-icu-parser/pluralization` | `PluralizationEngine`, the 9 plural-rule functions, `defaultPluralRule`, `normalizeLocale`, `getBaseLocale`, types |
## Modules Documentation
- [parser/format-bridge.md](src/parser/format-bridge.md) — DI seam between this package and the formatter packages
- [parser/message.md](src/parser/message.md) — `MessageParser` class
- [parser/message-render.md](src/parser/message-render.md) — `renderNodes` / `extractVariables` standalone helpers
- [interpolation/interpolation.md](src/interpolation/interpolation.md) — `InterpolationEngine`
- [interpolation/escape-html.md](src/interpolation/escape-html.md) — `escapeHtmlChars` standalone helper
- [interpolation/escape-regex.md](src/interpolation/escape-regex.md) — `escapeRegex` standalone helper
- [pluralization/pluralization.md](src/pluralization/pluralization.md) — `PluralizationEngine` + 9 plural-rule fns
- [observability/metrics.md](src/observability/metrics.md) — OTel histogram recorders
## Public Surface (snapshot)
| Export | Kind | Origin |
|---|---|---|
| `MessageParser` | class | `parser/message.ts` |
| `extractVariables` | function | `parser/message-render.ts` |
| `renderNodes` | function | `parser/message-render.ts` |
| `renderToChunks` | function | `parser/message-render.ts` |
| `preprocessIcuApostrophes` | function | `parser/message.ts` |
| `findUnknownArgTypes` | function | `parser/message.ts` |
| `FormatBridge` | type | `parser/format-bridge.ts` |
| `noopFormatBridge` | const | `parser/format-bridge.ts` |
| `MessageNode`, `ParsedMessage`, `ParserOptions`, `RenderOptions`, `RenderContext`, `ValidationIssue`, `ValidationResult` | types | `parser/*.types.ts` |
| `InterpolationEngine` | class | `interpolation/interpolation.ts` |
| `escapeHtmlChars` | function | `interpolation/escape-html.ts` |
| `escapeRegex` | function | `interpolation/escape-regex.ts` |
| `InterpolationConfigSchema` | const | `interpolation/interpolation.schemas.ts` |
| `PluralizationEngine` | class | `pluralization/pluralization.ts` |
| `arabicPlural` / `chinesePlural` / `englishPlural` / `frenchPlural` / `germanPlural` / `japanesePlural` / `polishPlural` / `russianPlural` / `welshPlural` / `spanishPlural` / `defaultPluralRule` | functions | `pluralization/pluralization.ts` |
| `recordParseDuration` / `recordInterpolationDuration` / `recordPluralDuration` | functions | `observability/metrics.ts` |
## Versioning
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. Test
titles in `parser/message.test.ts`, `interpolation/interpolation.test.ts`,
and `pluralization/pluralization.test.ts` mirror the pre-split
monolith one-for-one so the core's conformance suite re-runs
unchanged against the bridged surface.
## Wave 2 Scope
Pure addition. `packages/i18n/i18n/src/` retains its copies of
the parser, interpolation engine, and pluralization sources; both
copies coexist during Waves 2–5 and the monolith remains green.
The slim core (Wave 6) deletes the monolith copies and re-exports
from this package.

View File

@@ -0,0 +1,96 @@
# escape-html
## Purpose
`escapeHtmlChars` converts the five HTML special characters
(`&`, `<`, `>`, `"`, `'`) to their numeric / named entity
equivalents so a string can be safely concatenated into an HTML
context without opening an XSS vector. The helper is the single
source of truth for HTML escaping inside this package — the
{@link InterpolationEngine} calls it when `escapeHtml: true` is
configured, and downstream consumers can call it directly when
they need to sanitise an already-rendered string before inserting
into `innerHTML`, an attribute value, or a `<script type="application/json">`
block.
The helper is extracted into its own file so consumers who only
need the escape function can tree-shake the rest of the
interpolation engine. This is particularly useful in
size-sensitive bundles (mobile web, edge workers) that ship a
prerendered translation catalog and never need the full
interpolation surface.
## Features
- Escapes the canonical OWASP-recommended five characters
- Order-stable replacement (`&` first, so subsequent
`&lt;`/`&gt;` entities don't get double-escaped)
- No HTML allow-list — escapes EVERY occurrence of every char
- Pure function: no I/O, no allocation beyond the output string,
no observable side effects
- Zero dependencies — safe to inline in edge / worker contexts
## Usage: Basic Example
```typescript
import { escapeHtmlChars } from "@suaveplan/i18n-icu-parser/interpolation";
const safe = escapeHtmlChars("<b>bold</b>");
// "&lt;b&gt;bold&lt;/b&gt;"
```
## Usage: Advanced Example
```typescript
import { escapeHtmlChars } from "@suaveplan/i18n-icu-parser/interpolation";
// Sanitise user input before inserting into a template literal
function tooltipHtml(label: string): string {
return `<span class="tooltip" title="${escapeHtmlChars(label)}">…</span>`;
}
console.log(tooltipHtml(`I'm "the boss" & <admin>`));
// '<span class="tooltip" title="I&#039;m &quot;the boss&quot; &amp; &lt;admin&gt;">…</span>'
// Combine with InterpolationEngine for catalog values
import { InterpolationEngine } from "@suaveplan/i18n-icu-parser/interpolation";
const engine = new InterpolationEngine({ escapeHtml: true });
// Internally calls escapeHtmlChars on every interpolated value.
```
## API Reference
### `escapeHtmlChars(str: string): string`
Escape every occurrence of `&`, `<`, `>`, `"`, `'` in `str` to
its HTML entity equivalent.
| Input char | Output |
|---|---|
| `&` | `&amp;` |
| `<` | `&lt;` |
| `>` | `&gt;` |
| `"` | `&quot;` |
| `'` | `&#039;` |
**Returns:** the escaped string. Strings without any of the five
characters return unchanged.
## Implementation Notes
- The replacements run as five sequential `String.prototype.replace`
calls. This is faster than a single regex with a callback for
realistic translation-string sizes (10-200 characters), which
is the dominant case for i18n.
- `&` is escaped first so that the `&` in subsequent entity
outputs (`&lt;`, `&gt;`, `&quot;`, `&#039;`) is not re-escaped
on a second pass. The rule is invariant: every entity starts
with `&`, and that `&` was already produced by the helper, so
it is intentionally not re-escaped.
- The single-quote escape uses the numeric entity `&#039;` rather
than the named entity `&apos;` because the latter is not part
of the HTML 4 entity set and is unsafe in attribute contexts
served as `text/html` to legacy browsers.
- The function is `O(n)` in the input length and produces at
most `5n` output characters (worst case: a string of only `'`
characters expands 5x to `&#039;` per char).

View File

@@ -0,0 +1,85 @@
# escape-regex
## Purpose
`escapeRegex` escapes every regular-expression metacharacter in a
string so the string can be embedded literally inside a `RegExp`
constructor. The {@link InterpolationEngine} uses it to compile
placeholder patterns from caller-supplied delimiters (`{{`,
`%{`, `<%`, …) into a working RegExp without inadvertently
treating delimiter characters as regex syntax. Exposing it as a
standalone export lets downstream consumers build their own
delimiter-aware patterns or sanitise raw search queries.
The helper lives in its own file (alongside `escape-html`) so
tree-shaking can strip the rest of the interpolation engine when
a consumer needs only the escape primitive — for example, a
documentation tooling pipeline that compiles user-typed search
patterns into a forgiving regex.
## Features
- Escapes the canonical 12 ECMAScript regex metacharacters
(`. * + ? ^ $ { } ( ) | [ ] \`) plus the closing brace
- Pure function: no I/O, no state, deterministic per input
- Single-pass `String.prototype.replace` with a precompiled
regex literal — no allocation beyond the output string
- Zero dependencies — drops cleanly into edge / worker bundles
## Usage: Basic Example
```typescript
import { escapeRegex } from "@suaveplan/i18n-icu-parser/interpolation";
const safe = escapeRegex("a.b*c");
// "a\\.b\\*c"
const re = new RegExp(safe);
re.test("a.b*c"); // true (literal match)
```
## Usage: Advanced Example
```typescript
import { escapeRegex } from "@suaveplan/i18n-icu-parser/interpolation";
// Build a placeholder pattern from caller-supplied delimiters.
function buildPlaceholderPattern(open: string, close: string): RegExp {
const o = escapeRegex(open);
const c = escapeRegex(close);
return new RegExp(`${o}\\s*([^${c}]+)\\s*${c}`, "g");
}
const re = buildPlaceholderPattern("<%=", "%>");
const matches = "Hello <%= name %>".matchAll(re);
for (const m of matches) console.log(m[1]?.trim()); // "name"
```
## API Reference
### `escapeRegex(str: string): string`
Escape every regular-expression metacharacter in `str`. The
escaped output can be inserted unchanged into a `RegExp` source
string and will match the original character literally.
Escaped characters: `.`, `*`, `+`, `?`, `^`, `$`, `{`, `}`,
`(`, `)`, `|`, `[`, `]`, `\`.
**Returns:** the escaped string. Strings without metacharacters
return unchanged.
## Implementation Notes
- The replacement uses `\\$&` (backref to the whole match)
rather than enumerating every escape sequence, which keeps the
function bytes-small and easy to audit.
- The character class `[.*+?^${}()|[\\]\\\\]` is the canonical
V8 / SpiderMonkey escape set for the standard `RegExp`
grammar. Alternation operators inside character classes (`-`)
do NOT need escaping at the outer level and are intentionally
omitted.
- The function is `O(n)` in input length. Worst case is a string
of only metacharacters which expands 2x in the output.
- This is the canonical regex escape across Suaveplan
— every package that needs to embed user input in a RegExp
consumes this helper or its inlined twin in the parser file.

View File

@@ -0,0 +1,111 @@
# InterpolationEngine
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`InterpolationEngine` replaces placeholder tokens (default: `{{ name }}`) in message template strings with caller-supplied values. It is the rendering workhorse behind `MessageCatalog.t()` and standalone template usage. The engine is designed to be instantiated once and reused across many render calls, with all configuration captured in the constructor.
The engine handles the common i18n case of named variable substitution, and additionally supports pluggable per-slot value formatters (e.g., number, date, currency) via a named formatter registry. Values can be HTML-escaped on output to prevent XSS when templates are rendered into HTML.
## Features
- Named placeholder substitution: `{{ name }}`, `{{ count, number }}`, etc.
- Custom delimiter pairs (e.g., `%{` / `}` for Ruby-style templates)
- Per-slot `ValueFormatter` registry — applied when a placeholder contains a format name
- Optional `defaultFormatter` called for all slots without an explicit formatter
- HTML escaping of rendered values (`escapeHtml: true`) via `escapeHtmlChars`
- Recursive interpolation with configurable depth limit (`maxDepth`)
- `missingVariables` tracking in the result — non-throwing for production resilience
## Usage: Basic Example
```typescript
import { InterpolationEngine } from "@suaveplan/i18n-icu-parser/interpolation";
const engine = new InterpolationEngine();
const { text } = engine.render(
"Hello, {{ name }}! You have {{ count }} messages.",
{ name: "Alice", count: 3 }
);
// text === "Hello, Alice! You have 3 messages."
```
## Usage: Advanced Example
```typescript
import { InterpolationEngine } from "@suaveplan/i18n-icu-parser/interpolation";
const engine = new InterpolationEngine({
escapeHtml: true,
formatters: {
upper: (v) => String(v).toUpperCase(),
number: (v, locale) =>
new Intl.NumberFormat(locale).format(Number(v)),
},
});
const { text, missingVariables } = engine.render(
"Welcome, {{ user, upper }}! Balance: {{ amount, number }}.",
{ user: "alice", amount: 1234567 },
"en-US"
);
// text === "Welcome, ALICE! Balance: 1,234,567."
// missingVariables === []
// HTML escaping
const { text: safe } = engine.render(
"Comment: {{ comment }}",
{ comment: "<script>alert('xss')</script>" }
);
// safe === "Comment: &lt;script&gt;alert(&#039;xss&#039;)&lt;/script&gt;"
```
## API Reference
### `new InterpolationEngine(options?: InterpolationOptions)`
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `prefix` | `string` | `"{{"` | Placeholder open delimiter |
| `suffix` | `string` | `"}}"` | Placeholder close delimiter |
| `escapeHtml` | `boolean` | `false` | HTML-escape rendered values |
| `maxDepth` | `number` | `10` | Maximum recursion depth |
| `formatters` | `Record<string, ValueFormatter>?` | `{}` | Named formatter map |
| `defaultFormatter` | `ValueFormatter?` | — | Applied when no named formatter matches |
### `.render(template, values?, locale?): InterpolationResult`
Renders `template` replacing all placeholders. Returns `{ text: string; missingVariables: string[] }`.
### `.addFormatter(name, formatter): void`
Registers or replaces a named formatter at runtime.
### `.removeFormatter(name): boolean`
Removes a named formatter; returns `true` if it existed.
### `.hasPlaceholders(template): boolean`
Returns `true` if the template contains at least one placeholder.
### `.extractVariables(template): string[]`
Returns all unique variable names referenced in the template.
### `escapeHtmlChars(str: string): string`
Standalone export. Escapes `&`, `<`, `>`, `"`, `'`.
### `escapeRegex(str: string): string`
Standalone export. Escapes all regex metacharacters for safe use in `RegExp`.
## Implementation Notes
- The placeholder regex is rebuilt on each call to `replaceValues` rather than cached, because `options.prefix` / `suffix` could theoretically contain characters whose escaped forms would produce a different pattern — rebuilding is correct and the cost is negligible for the expected template sizes
- Recursive interpolation is capped at `maxDepth` (default 10) to prevent infinite loops when a value itself contains placeholder syntax
- HTML escaping is applied _after_ formatting so that formatter output (e.g., `"$1,234"`) is escaped as a unit, not before
- `missingVariables` accumulates in-place via the closure; the engine never throws on a missing variable — it leaves the placeholder in place and records the name

View File

@@ -0,0 +1,97 @@
# observability/metrics
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module is the single home for the three OTel histograms emitted
from the hot paths of the `@suaveplan/i18n-icu-parser` package:
`i18n.parse.duration_ms`, `i18n.interpolation.duration_ms`, and
`i18n.plural.duration_ms`. Centralising the metric construction here
keeps the production code paths free of meter boilerplate and makes
it trivial to audit (or relabel) every emission point in the
package.
The module wraps `@opentelemetry/api` so that consumers without an
SDK installed observe a no-op meter — there is no runtime cost when
no SDK is present, and no leaf package or test fixture has to opt
out of telemetry to avoid stray exports.
## Features
- Three OTel histograms with documented `description` and `unit`
fields so dashboards can render them without per-app config
- Bounded attribute cardinality: every recorder enforces a small
fixed set of attributes (`icu_syntax`, `escape_html`, `locale`,
`ordinal`) so metric pipelines do not blow up on unbounded
cardinality
- Single shared meter named `@suaveplan/i18n-icu-parser` so log
sinks can group every package's emissions without parsing
histogram names
- No-op fallback when no OTel SDK is wired (the upstream API
returns no-op meters) — production-safe with zero deps
## Usage: Basic Example
```typescript
import {
recordInterpolationDuration,
recordParseDuration,
recordPluralDuration,
} from "@suaveplan/i18n-icu-parser";
const t0 = performance.now();
parser.parse(template);
recordParseDuration(performance.now() - t0, true);
```
## Usage: Advanced Example
```typescript
import { metrics } from "@opentelemetry/api";
import { recordInterpolationDuration } from "@suaveplan/i18n-icu-parser";
// Wire any SDK (NodeSDK, BrowserSDK, etc.) — the histograms below
// flow through automatically because the package shares the
// global meter provider.
const provider = metrics.getMeterProvider();
// Inside a render loop:
for (const tpl of templates) {
const t0 = performance.now();
engine.render(tpl, values);
recordInterpolationDuration(performance.now() - t0, false);
}
```
## API Reference
### `recordParseDuration(durationMs: number, icuSyntax: boolean): void`
Record a sample on the `i18n.parse.duration_ms` histogram.
Attribute set: `{ "i18n.parse.icu_syntax": boolean }`.
### `recordInterpolationDuration(durationMs: number, escapeHtml: boolean): void`
Record a sample on the `i18n.interpolation.duration_ms` histogram.
Attribute set: `{ "i18n.interpolation.escape_html": boolean }`.
### `recordPluralDuration(durationMs: number, locale: string, ordinal: boolean): void`
Record a sample on the `i18n.plural.duration_ms` histogram.
Attribute set: `{ "i18n.plural.locale": string, "i18n.plural.ordinal": boolean }`.
## Implementation Notes
- The meter is created once at module-load and shared across every
recording path. OTel meters are designed to be long-lived; the
module never re-creates them.
- Attribute names use the `i18n.` prefix so consumers' dashboards
can group by package without inspecting the histogram name.
- The `locale` attribute is the **normalised** base locale (e.g.
`"en"` not `"en-GB"`) — call sites pre-normalise so cardinality
stays bounded by the CLDR set.
- No counters, gauges, or span events are emitted from this module
— only histograms. Span emission would require an active tracer;
the parser stays pure to keep that surface clean.

View File

@@ -0,0 +1,142 @@
# FormatBridge
## Purpose
`FormatBridge` is the dependency-injection seam that lets
`@suaveplan/i18n-icu-parser` render ICU format placeholders
(`{value, number}`, `{amount, number, currency=USD}`,
`{date, date, short}`, `{ts, time, long}`,
`{delta, relativeTime}`) without statically depending on any
formatter package. The renderer (`renderNodes`) consults whichever
methods are present on the bridge and falls back to
`String(value)` when a method (or the bridge itself) is undefined.
This keeps the parser package a true universal leaf with no
`Intl.*` runtime cost when the consumer doesn't need locale-aware
formatting.
The slim `@suaveplan/i18n` core (Wave 6) constructs a default
bridge by importing the formatter packages
(`@suaveplan/i18n-formatter-number`, `-date`, `-currency`,
`-relative-time`) and threads it through every render call so
consumers who use `createI18n({ ... })` get full ICU output with
zero wiring. Consumers who construct a `MessageParser` directly
MAY pass their own bridge or accept the no-Intl fallback.
## Features
- Five method slots, every one OPTIONAL: `number`, `currency`,
`date`, `time`, `relativeTime`
- Renderer never throws on missing bridge or missing method —
always falls back to `String(value)` (or
`${currency} ${String(value)}` for currency)
- Style enums (`NumberFormatStyle`, `DateStyle`,
`RelativeTimeStyle`) declared in `format-bridge.types.ts` for
type-only imports without runtime cost
- `noopFormatBridge` exported as a frozen singleton consumers can
use directly or spread-and-extend
- Single-file subpath export (`@suaveplan/i18n-icu-parser/parser/format-bridge`)
so the slim core's wiring imports only this one module — keeps
bundle size minimal
## Usage: Basic Example
```typescript
import { renderNodes, type FormatBridge } from "@suaveplan/i18n-icu-parser/parser";
const bridge: FormatBridge = {
number: (value, locale) =>
new Intl.NumberFormat(locale).format(value),
};
const nodes = [
{ type: "text" as const, value: "Total: " },
{
type: "typed-argument" as const,
name: "amount",
argType: "number" as const,
style: "decimal",
},
];
const text = renderNodes(nodes, { amount: 1234.5 }, {
locale: "en-US",
formatBridge: bridge,
});
// "Total: 1,234.5"
```
## Usage: Advanced Example
```typescript
import {
MessageParser,
noopFormatBridge,
type FormatBridge,
} from "@suaveplan/i18n-icu-parser/parser";
// Spread-and-extend pattern: keep the no-op fallback for anything
// you don't care about, override just the methods you need.
const bridge: FormatBridge = {
...noopFormatBridge,
currency: (value, currency, locale) =>
new Intl.NumberFormat(locale, {
style: "currency",
currency,
}).format(value),
date: (value, locale, style = "medium") => {
const d = value instanceof Date ? value : new Date(value);
const presets: Record<string, Intl.DateTimeFormatOptions> = {
short: { dateStyle: "short" },
medium: { dateStyle: "medium" },
long: { dateStyle: "long" },
full: { dateStyle: "full" },
};
return new Intl.DateTimeFormat(locale, presets[style]).format(d);
},
};
const parser = new MessageParser();
const out = parser.format(
"Total: {amount, number, currency/USD} on {when, date, long}",
{ amount: 1234.5, when: new Date("2026-05-05T00:00:00Z") },
{ locale: "en-US", formatBridge: bridge }
);
```
## API Reference
### `interface FormatBridge`
| Method | Signature | Notes |
|---|---|---|
| `number?` | `(value, locale, style?) => string` | Style is `NumberFormatStyle \| string \| undefined` so notation styles (`compact`, `scientific`) pass through as raw strings |
| `currency?` | `(value, currency, locale) => string` | `currency` is an ISO 4217 code |
| `date?` | `(value, locale, style?) => string` | Default style is `medium` |
| `time?` | `(value, locale, longForm?) => string` | `longForm: true` should include seconds |
| `relativeTime?` | `(value, locale, style?) => string` | Style is `"long" \| "short" \| "narrow"` |
### `noopFormatBridge: Readonly<FormatBridge>`
Frozen identity bridge. Every method coerces inputs to
`String(value)` (or `${currency} ${String(value)}` for
`currency`). Safe to share by reference; mutation throws TypeError.
## Implementation Notes
- The bridge is intentionally OPTIONAL across the entire surface
area. The renderer never asserts `formatBridge != null`; it
uses optional chaining (`bridge?.number?.(...)`) and falls
through to `String(value)` on undefined results. This means a
partial bridge (only `number` defined) still renders date and
currency placeholders — they just emit raw `String(value)` for
the missing methods.
- `noopFormatBridge` is `Object.freeze`d at module load. This
guards against accidental mutation in tests or downstream
packages corrupting the shared singleton.
- `currency` returns `${code} ${String(value)}` rather than
`String(value)` alone so the no-op fallback still surfaces the
ISO 4217 code in test output, making fallback hits visible
during development.
- The style enums are declared in `format-bridge.types.ts` to keep
this implementation file zero-runtime-cost when imported for
the bridge interface alone.

View File

@@ -0,0 +1,117 @@
# message-render
## Purpose
`message-render` provides two standalone utility functions for `@suaveplan/i18n`: `extractVariables`, which collects all unique variable names referenced in a parsed message AST, and `renderNodes`, which renders an AST to a string using caller-supplied values. These are the hot-path primitives consumed by `MessageParser.render` and exposed as public exports for callers that need to work with pre-parsed ASTs directly.
Separating these pure functions from the `MessageParser` class allows them to be imported without pulling in the full parser, which is useful for rendering pre-parsed messages in tight loops or edge environments.
## Features
- `extractVariables` — collects unique variable names from a `MessageNode[]`, recursing into plural and select choices
- `renderNodes` — renders a `MessageNode[]` to a string, supporting text, variable, plural, and select node types
- Plural `offset` support: `count - offset` is passed as `"#"` into nested renders
- Exact-match plural keys (`=0`, `=1`) take priority over category keys (`one`, `other`)
- Nullable/undefined values are rendered as empty strings
- Optional `pluralSelect` callback for language-specific plural category resolution
## Usage: Basic Example
```ts
import { extractVariables, renderNodes } from "@suaveplan/i18n-icu-parser/parser";
const nodes = [
{ type: "text", value: "Hello, " },
{ type: "variable", name: "name" },
{ type: "text", value: "!" },
];
const vars = extractVariables(nodes);
console.log(vars); // ["name"]
const text = renderNodes(nodes, { name: "Alice" });
console.log(text); // "Hello, Alice!"
```
## Usage: Advanced Example
```ts
import { renderNodes } from "@suaveplan/i18n-icu-parser/parser";
import type { MessageNode } from "@suaveplan/i18n-icu-parser/parser";
// Pre-parsed plural node (normally produced by MessageParser)
const nodes: MessageNode[] = [
{
type: "plural",
variable: "count",
offset: 0,
choices: {
one: [{ type: "variable", name: "#" }, { type: "text", value: " item" }],
other: [{ type: "variable", name: "#" }, { type: "text", value: " items" }],
},
},
];
// Custom plural selector for a language with more categories
const pluralSelect = (n: number) => (n === 1 ? "one" : "other");
console.log(renderNodes(nodes, { count: 1 }, pluralSelect)); // "1 item"
console.log(renderNodes(nodes, { count: 5 }, pluralSelect)); // "5 items"
```
## API Reference
### `extractVariables(nodes: MessageNode[]): string[]`
Extract all unique variable names referenced in `nodes`. Recurses into `plural` and `select` choice branches. Excludes the special `"#"` sentinel used inside plural branches.
**Returns:** Array of unique variable name strings in order of first appearance.
### `renderNodes(nodes: MessageNode[], values: Record<string, unknown>, options?: RenderOptions | RenderContext | pluralSelect): string`
Render `nodes` to a string.
| Node type | Behaviour |
|-----------|-----------|
| `text` | Appended verbatim |
| `variable` | Replaced with `String(values[name])`, or `""` if `null`/`undefined`. Throws `I18nError(I18N.INTERPOLATION_ERROR)` when the key is ABSENT. `"#"` resolves from `values["#"]` |
| `plural` | Resolves count, adjusts by offset, selects branch by exact key then category then `"other"`, recurses |
| `select` | Selects branch by string value of variable, falls back to `"other"`, recurses |
| `typed-argument` | Routes through `options.formatBridge` (`number` / `currency` / `date` / `time`); falls back to `String(value)` if bridge or method is undefined |
| `tag` | If `values[tagName]` is a function, calls it with the rendered inner string; otherwise renders inner content as-is |
The `pluralSelect(count, category)` callback is used to select plural categories for plural nodes. When absent, falls back to `count === 1 ? "one" : "other"`.
### `RenderOptions`
```typescript
interface RenderOptions extends RenderContext {
readonly formatBridge?: FormatBridge;
}
interface RenderContext {
readonly locale?: string;
readonly pluralSelect?: (count: number, category: string) => string;
readonly ordinalSelect?: (count: number) => string;
}
```
The `formatBridge` slot is the dependency-injection seam between
this parser package and the per-formatter packages
(see `format-bridge.md`). When supplied, the renderer dispatches
ICU format placeholders (`{value, number}`, `{amount, number,
currency=USD}`, `{date, date, short}`, `{ts, time, long}`) to the
bridge methods. When the bridge OR a specific method is undefined,
the renderer coerces the value to `String(value)` and never throws.
## Implementation Notes
`renderNodes` is recursive: plural and select nodes recursively call `renderNodes` on the matched choice's node array. For plural nodes, the adjusted count (`count - offset`) is injected as `values["#"]` into the recursive call so that `#` placeholders inside plural branches resolve correctly.
Exact-match keys (e.g. `=0`, `=2`) in plural choices are checked before category keys, matching the ICU specification for exact-value overrides.
The `formatBridge` slot keeps this package a true universal
leaf with no `Intl.*` dependency. The slim `@suaveplan/i18n` core
(Wave 6) constructs a default bridge over the formatter packages
and threads it through `createI18n({ ... })` so consumers get
locale-aware output automatically.

View File

@@ -0,0 +1,99 @@
# MessageParser
## Purpose
`MessageParser` converts ICU-like message strings into a typed AST (Abstract Syntax Tree), and provides utilities to render that AST back to a string with caller-supplied values. It enables rich localised messages that include plural selection, gender/select branching, and nested variable substitution — without pulling in a full ICU implementation.
The parser is also available in a simpler non-ICU mode that only processes `{variable}` substitution with configurable delimiters, which is sufficient for many template-string use cases.
## Features
- Parses ICU-style plural: `{count, plural, one {# item} other {# items}}`
- Parses ICU-style select: `{gender, select, male {He} female {She} other {They}}`
- Nested ICU expressions and recursive choices
- Plural `offset:` support (`{count, plural, offset:1 ...}`)
- Exact-value plural matching: `=0 {no results}`, `=1 {one result}`
- `#` placeholder inside plural branches resolves to `count - offset`
- Non-ICU simple-variable mode with configurable open/close delimiters
- `extractVariables(nodes)` — extracts all referenced variable names from an AST
- `renderNodes(nodes, values, pluralSelect?)` — renders an AST to a string
## Usage: Basic Example
```typescript
import { MessageParser } from "@suaveplan/i18n-icu-parser/parser";
const parser = new MessageParser();
// Simple variable
const { nodes } = parser.parse("Hello, {name}!");
const text = parser.render({ nodes, variables: ["name"] }, { name: "World" });
// text === "Hello, World!"
// One-liner
const msg = parser.format(
"{count, plural, one {# apple} other {# apples}}",
{ count: 3 }
);
// msg === "3 apples"
```
## Usage: Advanced Example
```typescript
import { MessageParser, renderNodes, extractVariables } from "@suaveplan/i18n-icu-parser/parser";
import { PluralizationEngine } from "@suaveplan/i18n-icu-parser/pluralization";
const parser = new MessageParser();
const engine = new PluralizationEngine();
const template = "{gender, select, male {He has} female {She has} other {They have}} {count, plural, one {# item} other {# items}}.";
const parsed = parser.parse(template);
// Use PluralizationEngine for the plural select callback
const text = parser.render(parsed, { gender: "female", count: 5 }, (count) =>
engine.getCategory(count, "en")
);
// text === "She has 5 items."
// Extract variable names from a template without rendering
const vars = extractVariables(parsed.nodes);
// vars === ["gender", "count"]
```
## API Reference
### `new MessageParser(options?: ParserOptions)`
| Option | Default | Description |
|--------|---------|-------------|
| `variablePrefix` | `"{"` | Simple-mode open delimiter |
| `variableSuffix` | `"}"` | Simple-mode close delimiter |
| `icuSyntax` | `true` | Enable plural/select parsing |
### `.parse(message): ParsedMessage`
Returns `{ nodes: MessageNode[]; variables: string[] }`.
### `.render(parsed, values, pluralSelect?): string`
Renders a `ParsedMessage` to a string. `pluralSelect(count, category)` is used to select plural forms — defaults to `one`/`other` if omitted.
### `.format(message, values, pluralSelect?): string`
Convenience: `parse` + `render` in one call.
### `extractVariables(nodes: MessageNode[]): string[]`
Standalone export. Returns all unique variable names (excluding `"#"`) referenced in the node tree.
### `renderNodes(nodes, values, pluralSelect?): string`
Standalone export. Renders an array of `MessageNode` to a string.
## Implementation Notes
- The parser is a hand-written recursive descent parser; it does not use a grammar generator or regex for structural parsing (only for simple-mode delimiter matching)
- ICU `plural` and `select` nodes are parsed in a single forward pass; backtracking is never required
- The `#` sentinel in plural branches is emitted as a `VariableNode` with `name === "#"` and resolved during rendering by injecting `{ "#": adjusted }` into the values map
- `renderNodes` is exported to allow rendering pre-parsed ASTs without re-parsing, which is the expected hot-path in production (parse once, render many times)

View File

@@ -0,0 +1,98 @@
# PluralizationEngine
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`PluralizationEngine` selects the correct plural form of a message for a given count and locale, following the Unicode CLDR plural rules. Rather than requiring callers to know the intricacies of each language's plural grammar (Russian has four forms; Arabic six; Welsh six with unique triggers), the engine encapsulates that knowledge and exposes a simple `resolve(count, messages, options)` API.
The engine is pre-loaded with built-in rules for 14 languages and supports runtime extension via `registerRule`. The rule functions are exported as standalone named exports so they can be tree-shaken or used directly without the class.
## Features
- Pre-built rules for Arabic, Chinese, English, French, German, Italian, Japanese, Korean, Dutch, Polish, Portuguese, Russian, Spanish, Welsh
- Cardinal and ordinal plural support (English ordinal: 1st, 2nd, 3rd)
- `PluralRuleRegistry` backed by a `Map` for O(1) locale lookup
- Locale normalisation: `"en-GB"` → `"en"`, `"ZH"` → `"zh"`
- `getCategories(locale)` — introspects which CLDR categories a locale uses
- All rule functions exported for standalone use
## Usage: Basic Example
```typescript
import { PluralizationEngine } from "@suaveplan/i18n-icu-parser/pluralization";
const engine = new PluralizationEngine();
const result = engine.resolve(3, { one: "1 item", other: "# items" }, { locale: "en" });
// result === { category: "other", message: "# items", count: 3 }
```
## Usage: Advanced Example
```typescript
import { PluralizationEngine, arabicPlural } from "@suaveplan/i18n-icu-parser/pluralization";
const engine = new PluralizationEngine({
// override Welsh with a custom rule
cy: (n) => (n === 0 ? "zero" : n === 1 ? "one" : "other"),
});
// Arabic: 0/1/2/few(3-10)/many(11-99)/other(100+)
const ar0 = engine.resolve(0, { zero: "لا عناصر", one: "عنصر", other: "عناصر" }, { locale: "ar" });
// { category: "zero", message: "لا عناصر", count: 0 }
// English ordinal
const { category } = engine.resolve(3, {}, { locale: "en", ordinal: true });
// category === "few" (3rd)
// Fallback to "other" when category absent
const r = engine.resolve(5, { one: "one thing" }, { locale: "en" });
// { category: "other", message: undefined, count: 5 }
```
## API Reference
### `new PluralizationEngine(customRules?: Record<string, PluralRuleFunction>)`
`customRules` is merged over the built-in rule set after registration.
### `.resolve(count, messages, options?): PluralResolveResult`
| Param | Type | Description |
|-------|------|-------------|
| `count` | `number` | Numeric quantity |
| `messages` | `PluralMessages` | Partial record of category → message string |
| `options.locale` | `string?` | BCP-47 locale (default `"en"`) |
| `options.ordinal` | `boolean?` | Use ordinal rules (default `false`) |
Returns `{ category: PluralCategory; message: string | undefined; count: number }`.
### `.getCategory(count, locale, ordinal?): PluralCategory`
Returns the CLDR plural category without resolving a message string.
### `.registerRule(locale, rule): void`
Registers or overrides a rule for a locale code. The locale is normalised before storage.
### `.hasRule(locale): boolean`
Returns `true` if a rule is registered for the locale or its base language.
### `.getRule(locale): PluralRuleFunction`
Returns the rule for the locale, its base language, or `defaultPluralRule`.
### `.getCategories(locale): PluralCategory[]`
Returns all CLDR categories reachable for a locale by probing values `[0,1,2,3,6,11,100]`.
## Implementation Notes
- `normalizeLocale` strips the region subtag: `"ru-RU"` → `"ru"`. Registering `"ru"` covers all regional variants
- Russian and Polish rules use modulo 10 and modulo 100 together; the order of checks matters — always test the teens exception (`mod100 >= 11 && mod100 <= 14`) before the single-digit rule
- Arabic plurals use `n % 100` for the few/many boundary, which correctly handles values ≥ 100
- Welsh plural rules are exact-value based (n === 0/1/2/3/6) rather than modulo based
- `chinesePlural` always returns `"other"` — Chinese, Japanese, and Korean use the same zero-variation plural system

View File

@@ -0,0 +1,114 @@
# @suaveplan/i18n-shared - Shared runtime atoms for the @suaveplan/i18n family — locale constants, BCP-47 map, RTL set, locale descriptors, interpolation defaults, the I18N_CODES error registry, and the I18nError class. Consumed by every i18n leaf package (parser, formatters, detector, backends) and re-exported by the slim core.
## Summary
Shared runtime atoms for the entire `@suaveplan/i18n*` package family —
locale constants, BCP-47 normalisation map, RTL set, locale
descriptors, interpolation defaults, the `I18N_CODES` error registry,
and the `I18nError` base class. Consumed by every i18n leaf package
(parser, formatters, detector, backends) and re-exported by the slim
core (Wave 6).
<!-- begin:auto-docs -->
## Documentation tree
- [interpolation-defaults](./src/constants/interpolation-defaults.md) — `DEFAULT_INTERPOLATION` is the canonical default configuration for the ICU interpolation engine that ships with `@suave…
- [locale-bcp47-map](./src/constants/locale-bcp47-map.md) — `LOCALE_BCP47_MAP` is the single source of truth for normalising the short and mixed-case locale identifiers consumers…
- [locale-descriptors](./src/constants/locale-descriptors.md) — These three exports together define the built-in supported-locale inventory for the Suaveplan i18n family. `DEFAULT_LOC…
- [rtl-locales](./src/constants/rtl-locales.md) — `RTL_LOCALES` and `RTL_LOCALE_LIST` are the canonical inventory of ISO 639-1 language codes whose primary script render…
- [error-codes](./src/errors/error-codes.md) — `I18N_CODES` is the single, frozen registry of every fully-qualified error code that any package in the `@suaveplan/i18…
- [i18n-error](./src/errors/i18n-error.md) — `I18nError` is the single base class every package in the `@suaveplan/i18n*` family throws. It extends `SuaveplanError<…
- [testing](./src/testing/conformance.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [conformance](./src/testing/conformance.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
<!-- end:auto-docs --> - [conformance](./src/testing/conformance.md) — <!-- begin:auto-docs --> _(no submodules)_- [conformance](./src/testing/conformance.md) — <!-- begin:auto-docs --> _(no submodules)_- [conformance](./src/testing/conformance.md) — <!-- begin:auto-docs --> _(no submodules)_- [conformance](./src/testing/conformance.md) — <!-- begin:auto-docs --> _(no submodules)_
## Why this exists
The 14-package i18n modular split needs one home for the runtime
atoms every leaf consumes. Constants and the `I18nError` class can
not live in `@suaveplan/types` (zero runtime) and they can not stay
in `@suaveplan/i18n` core because formatters / backends / detectors
would each need a dependency on the core, which causes a cycle the
moment the slim core re-exports any of them. The clean answer is a
universal leaf runtime package — this one.
## Install
```bash
bun add @suaveplan/i18n-shared
```
## Quick Start
```ts
import {
DEFAULT_INTERPOLATION,
I18N_CODES,
I18nError,
LOCALE_BCP47_MAP,
RTL_LOCALES,
} from "@suaveplan/i18n-shared";
// BCP-47 normalisation
const tag = LOCALE_BCP47_MAP["en"]; // "en-GB"
// RTL detection
const isRtl = RTL_LOCALES.has("ar"); // true
// Error throwing
throw new I18nError(I18N_CODES.MISSING_KEY, "checkout.button");
```
## Modules
| Subpath | Description |
|---|---|
| `@suaveplan/i18n-shared` | Root barrel — every constant + `I18nError` + codes |
| `@suaveplan/i18n-shared/constants` | All constants (zero error-class dep) |
| `@suaveplan/i18n-shared/constants/locale-bcp47-map` | `LOCALE_BCP47_MAP` only |
| `@suaveplan/i18n-shared/constants/rtl-locales` | `RTL_LOCALES` and `RTL_LOCALE_LIST` only |
| `@suaveplan/i18n-shared/constants/locale-descriptors` | `LOCALE_DESCRIPTORS`, `DEFAULT_LOCALES`, `DefaultLocale` only |
| `@suaveplan/i18n-shared/constants/interpolation-defaults` | `DEFAULT_INTERPOLATION` only |
| `@suaveplan/i18n-shared/errors` | `I18N_CODES`, `I18nError`, `I18nErrorCode` |
| `@suaveplan/i18n-shared/testing` | `runBackendConformance` — shared `Backend` contract harness used by every `@suaveplan/i18n-backend-*` package |
## Modules Documentation
- [locale-bcp47-map.md](src/constants/locale-bcp47-map.md) — short / mixed-case → canonical BCP-47 tag
- [rtl-locales.md](src/constants/rtl-locales.md) — `RTL_LOCALES` + `RTL_LOCALE_LIST`
- [locale-descriptors.md](src/constants/locale-descriptors.md) — `LOCALE_DESCRIPTORS`, `DEFAULT_LOCALES`, `DefaultLocale`
- [interpolation-defaults.md](src/constants/interpolation-defaults.md) — `DEFAULT_INTERPOLATION`
- [error-codes.md](src/errors/error-codes.md) — `I18N_CODES`, `I18nErrorCode`
- [i18n-error.md](src/errors/i18n-error.md) — `I18nError` base class
- [conformance.md](src/testing/conformance.md) — `runBackendConformance` — shared backend conformance harness
## Public Surface (snapshot)
| Export | Kind | Origin |
|---|---|---|
| `LOCALE_BCP47_MAP` | const | `constants/locale-bcp47-map.ts` |
| `RTL_LOCALES` | const | `constants/rtl-locales.ts` |
| `RTL_LOCALE_LIST` | const | `constants/rtl-locales.ts` |
| `LOCALE_DESCRIPTORS` | const | `constants/locale-descriptors.ts` |
| `DEFAULT_LOCALES` | const | `constants/locale-descriptors.ts` |
| `DefaultLocale` | type | `constants/locale-descriptors.ts` |
| `DEFAULT_INTERPOLATION` | const | `constants/interpolation-defaults.ts` |
| `I18N_CODES` | const | `errors/error-codes.ts` |
| `I18nErrorCode` | type | `errors/error-codes.ts` |
| `I18nCode` | type alias | `errors/error-codes.ts` |
| `I18nError` | class | `errors/i18n-error.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. The `I18N_CODES` registry is append-only across the wave —
existing codes never get removed or renamed.
## Wave 1 Scope
This package is Wave 1b of the 14-package i18n modular split. It
ships pure additions only — `packages/i18n/i18n/src/` retains its
copies of these atoms until Wave 6 deletes them. Both copies coexist
during Waves 2–5; downstream consumers continue to import from the
monolith and observe no behaviour change.

View File

@@ -0,0 +1,77 @@
# `DEFAULT_INTERPOLATION` — interpolation engine defaults
## Purpose
`DEFAULT_INTERPOLATION` is the canonical default configuration for the
ICU interpolation engine that ships with `@suaveplan/i18n-icu-parser`
(Wave 2 of the modular split). Centralising the defaults here lets
every leaf package — the parser, the catalog, the slim core, even
custom plugins — agree on the same delimiter pair and nesting limit
without re-declaring the literals. Consumers that want to override
the defaults can spread them at construction time
(`{ ...DEFAULT_INTERPOLATION, prefix: "${" }`); the immutable shape
ensures no consumer can accidentally mutate the defaults at module
init.
## Features
- Mustache-compatible delimiters (`{{` / `}}`) — drop-in for catalogs
authored against any popular i18n format
- `maxDepth: 10` ceiling — short-circuits pathological recursion
- `as const` shape — string literals narrow at the type level for
branded types
- Frozen at module-init via `as const` — no consumer can mutate
## Usage: Basic Example
```ts
import { DEFAULT_INTERPOLATION } from "@suaveplan/i18n-shared";
const prefix = DEFAULT_INTERPOLATION.prefix; // "{{"
const suffix = DEFAULT_INTERPOLATION.suffix; // "}}"
const maxDepth = DEFAULT_INTERPOLATION.maxDepth; // 10
```
## Usage: Advanced Example
```ts
import { DEFAULT_INTERPOLATION } from "@suaveplan/i18n-shared/constants/interpolation-defaults";
// Override the prefix / suffix while keeping the depth limit
const customDelimiters = {
...DEFAULT_INTERPOLATION,
prefix: "${",
suffix: "}",
} as const;
// Pass the custom shape to the interpolation engine (parser package
// will accept this shape verbatim)
const interpolation = customDelimiters; // type-narrowed
```
## API Reference
### `DEFAULT_INTERPOLATION`
Frozen object literal with these fields:
| Field | Type | Value |
|---|---|---|
| `prefix` | `"{{"` | Opening placeholder delimiter |
| `suffix` | `"}}"` | Closing placeholder delimiter |
| `maxDepth` | `10` | Maximum nested-resolution depth |
Throws: nothing — read-only constant.
## Implementation Notes
- The choice of `{{` / `}}` matches Mustache, Handlebars, and i18next
out of the box. Catalogs authored against any of those tools load
without rewriting placeholders.
- The `maxDepth` of 10 is empirical — pathological self-referencing
catalogs (`{"a": "{{a}}"}`) are detected within 10 expansions
while well-formed nested-key cases (rare beyond depth 3) still
complete normally.
- The shape is `as const` (NOT a Zod schema) because every consumer
of this constant treats it as the authoritative literal — there is
no boundary parsing required at this layer.

View File

@@ -0,0 +1,74 @@
# `LOCALE_BCP47_MAP` — short / mixed-case → canonical BCP-47 tag
## Purpose
`LOCALE_BCP47_MAP` is the single source of truth for normalising the
short and mixed-case locale identifiers consumers commonly type
("en", "zh-cn", "PT-BR") into the canonical BCP-47 tags that the
runtime `Intl.*` constructors expect (`en-GB`, `zh-CN`, `pt-BR`).
Every formatter (`Intl.NumberFormat`, `Intl.DateTimeFormat`, etc.)
and the catalog use the map at the public-API boundary so the
underlying Intl implementation always receives a tag it can parse
without ambiguity. Centralising the table here means the entire i18n
family agrees on the same canonical tag for any input — there is no
risk of a formatter returning `2,000` for `de` while another formats
the same value as `2.000` because one chose `de-DE` and the other
chose `de-AT`.
## Features
- 22 entries covering the 16 default locales plus six common
mixed-case variants
- All-lowercase keys (matches the standard "lowercase before lookup"
pattern most consumers already follow)
- Pure data — zero runtime cost beyond the lookup itself
- Lookups for unknown codes pass through unchanged at the consumer
level (the caller treats the input as already canonical)
## Usage: Basic Example
```ts
import { LOCALE_BCP47_MAP } from "@suaveplan/i18n-shared";
const canonical = LOCALE_BCP47_MAP["en"]; // "en-GB"
new Intl.NumberFormat(canonical).format(1234.5);
```
## Usage: Advanced Example
```ts
import { LOCALE_BCP47_MAP } from "@suaveplan/i18n-shared/constants/locale-bcp47-map";
function toBcp47(input: string): string {
const lower = input.toLowerCase();
return LOCALE_BCP47_MAP[lower] ?? input;
}
// Mixed-case input → canonical
toBcp47("EN-GB"); // "en-GB"
toBcp47("zh-CN"); // "zh-CN"
toBcp47("zh-cn"); // "zh-CN"
toBcp47("xx-YY"); // "xx-YY" (pass-through; consumer handles miss)
```
## API Reference
### `LOCALE_BCP47_MAP: Record<string, string>`
Plain object mapping short / mixed-case locale codes (keys) to
canonical BCP-47 locale tags (values). Keys are always lowercase.
Values follow the `<lang>-<REGION>` BCP-47 pattern.
Throws: nothing — lookups for missing keys return `undefined`.
## Implementation Notes
- The map is intentionally a plain `Record<string, string>` rather
than a `Map` — `Record` ships zero-overhead JSON-equivalent shape
and is bundler-friendly for tree-shaking.
- Keys are lowercase by convention; consumers are expected to
lowercase before lookup. This avoids exploding the table size 4x
for the four common case variations (`en`, `EN`, `eN`, `En`).
- The list mirrors the legacy `@suaveplan/i18n` constants exactly so
the back-compat re-export shim in the slim core (Wave 6) is a
no-op for every observable consumer.

View File

@@ -0,0 +1,91 @@
# `LOCALE_DESCRIPTORS`, `DEFAULT_LOCALES`, `DefaultLocale`
## Purpose
These three exports together define the built-in supported-locale
inventory for the Suaveplan i18n family. `DEFAULT_LOCALES` is the
ordered tuple of locale codes that ship in every bundle by default;
`DefaultLocale` is the string-literal union derived from that tuple
(consumers can use it as a strongly-typed parameter); and
`LOCALE_DESCRIPTORS` is the per-locale display-metadata table
(English `name`, locale-native `nativeName`, text `direction`). The
data lets a UI render a locale picker, a sitemap generator emit
`<link hreflang>` tags, and the locale detector resolve fallback
chains — all without round-tripping through external locale data.
## Features
- 16 default locales: `en`, `es`, `fr`, `de`, `cy`, `zh`, `ar`, `ru`,
`pl`, `ja`, `ko`, `pt`, `it`, `nl`, `he`, `fa`
- Cross-checked invariant — every `DEFAULT_LOCALES` entry has a
descriptor; every descriptor's `direction` agrees with the
locale's membership in `RTL_LOCALES`
- Static, tree-shakeable, zero-dependency
## Usage: Basic Example
```ts
import { LOCALE_DESCRIPTORS } from "@suaveplan/i18n-shared";
const meta = LOCALE_DESCRIPTORS["ar"];
console.log(meta.name); // "Arabic"
console.log(meta.nativeName); // "العربية"
console.log(meta.direction); // "rtl"
```
## Usage: Advanced Example
```ts
import {
DEFAULT_LOCALES,
type DefaultLocale,
LOCALE_DESCRIPTORS,
} from "@suaveplan/i18n-shared/constants/locale-descriptors";
// Render a typed locale picker
function buildPicker(): Array<{ value: DefaultLocale; label: string }> {
return DEFAULT_LOCALES.map((code) => {
const descriptor = LOCALE_DESCRIPTORS[code];
return {
value: code,
label: `${descriptor?.nativeName ?? code} (${descriptor?.name ?? "?"})`,
};
});
}
// DefaultLocale narrows at the API boundary
function setLocale(locale: DefaultLocale): void {
document.documentElement.lang = locale;
const direction = LOCALE_DESCRIPTORS[locale]?.direction ?? "ltr";
document.documentElement.dir = direction;
}
```
## API Reference
### `DEFAULT_LOCALES: readonly [...]`
Ordered tuple of 16 ISO 639-1 codes. Iteration order is the canonical
default order used when the locale detector exhausts its
preference-list inputs.
### `DefaultLocale`
Type alias equal to `(typeof DEFAULT_LOCALES)[number]`. Use as a
parameter type to constrain a function to the built-in locale set.
### `LOCALE_DESCRIPTORS: Record<string, { name; nativeName; direction }>`
Per-locale display metadata. Lookup is O(1). Returns `undefined` for
unknown codes — consumers handle the miss.
## Implementation Notes
- The table is keyed on the short ISO 639-1 code (NOT the canonical
BCP-47 tag) so consumers using `LOCALE_BCP47_MAP` for runtime
formatting can still read the descriptor with the un-mapped key.
- `nativeName` values use the script's actual code points (e.g.
`"العربية"`, `"中文"`) — file encoding MUST be UTF-8.
- The list is intentionally small — adding a locale is a deliberate
product decision, and the cross-file invariant test catches drift
with `RTL_LOCALES`.

View File

@@ -0,0 +1,81 @@
# `RTL_LOCALES` and `RTL_LOCALE_LIST` — right-to-left locale set
## Purpose
`RTL_LOCALES` and `RTL_LOCALE_LIST` are the canonical inventory of
ISO 639-1 language codes whose primary script renders right-to-left.
Together they let any consumer (the locale detector, a UI component
deciding `dir="rtl"`, a logger annotating a span) determine text
direction with one O(1) lookup and zero parsing. Centralising the
list here means every package in the i18n family agrees on the same
seven RTL codes — no drift between formatters that might independently
hard-code Arabic + Hebrew while ignoring Persian or Urdu.
## Features
- 7 ISO 639-1 codes: `ar`, `he`, `fa`, `ur`, `ps`, `sd`, `yi`
- Two shapes — `ReadonlySet<string>` for membership checks,
`readonly tuple` for iteration / serialisation
- Both shapes share the same data and the test suite asserts they
agree on cardinality
- Tree-shake-friendly subpath at
`@suaveplan/i18n-shared/constants/rtl-locales` for size-sensitive
consumers
## Usage: Basic Example
```ts
import { RTL_LOCALES } from "@suaveplan/i18n-shared";
if (RTL_LOCALES.has("ar")) {
document.documentElement.dir = "rtl";
}
```
## Usage: Advanced Example
```ts
import {
RTL_LOCALE_LIST,
RTL_LOCALES,
} from "@suaveplan/i18n-shared/constants/rtl-locales";
// Membership check — O(1) on the Set
function getDirection(locale: string): "ltr" | "rtl" {
const language = locale.split("-")[0]?.toLowerCase() ?? "";
return RTL_LOCALES.has(language) ? "rtl" : "ltr";
}
// Iteration — stable order from the tuple
for (const code of RTL_LOCALE_LIST) {
console.log(`Loading RTL bundle for ${code}`);
}
// Serialisation — bundlers can inline the tuple as a JSON literal
const manifest = { rtl: [...RTL_LOCALE_LIST] };
```
## API Reference
### `RTL_LOCALES: ReadonlySet<string>`
Set of ISO 639-1 codes whose primary script is right-to-left. Use
this for O(1) membership checks.
### `RTL_LOCALE_LIST: readonly ["ar", "he", "fa", "ur", "ps", "sd", "yi"]`
Tuple form of the same data. Use this when iteration order matters,
when bundling into a manifest, or when the consumer wants to ship the
code list without the Set overhead.
## Implementation Notes
- The two exports are deliberately not derived from one another at
module-init time — the Set takes the array path on construction and
the tuple is a literal. A unit test asserts
`RTL_LOCALE_LIST.length === RTL_LOCALES.size` so any future
addition to one stays in sync with the other.
- The seven codes match the pre-split monolith exactly. Future
additions (e.g. `dv` for Dhivehi) require updating both exports
AND adding a corresponding `LOCALE_DESCRIPTORS` entry with
`direction: "rtl"`.

View File

@@ -0,0 +1,109 @@
# `I18N_CODES` and `I18nErrorCode` — i18n error code registry
## Purpose
`I18N_CODES` is the single, frozen registry of every fully-qualified
error code that any package in the `@suaveplan/i18n*` family is
permitted to throw. Keeping all 18 codes in one runtime constant has
two non-negotiable benefits: (1) `instanceof I18nError` plus a
`switch (err.code)` discrimination is exhaustive at the type level
without importing every leaf, and (2) OTel span events, log sinks,
and dashboards see one stable `"I18N."` prefix rather than 13
separate per-leaf prefixes. `I18nErrorCode` is the discriminant
union derived from the registry — use it as the `TCode` parameter
when extending `SuaveplanError`.
## Features
- 18 codes covering parser, interpolation, pluralization, backends,
formatters, detector, and plugin failures
- Every code is namespaced under `"I18N."` (e.g. `"I18N.MISSING_KEY"`)
- Frozen registry — `I18N_CODES.MISSING_KEY = "x"` throws at runtime
- Type-level discriminant union (`I18nErrorCode`) is exhaustive
- Back-compat alias `I18nCode` for pre-split call-sites
## Usage: Basic Example
```ts
import { I18N_CODES, I18nError } from "@suaveplan/i18n-shared";
throw new I18nError(I18N_CODES.MISSING_KEY, "i18n.missing", {
context: { key: "checkout.button" },
});
```
## Usage: Advanced Example
```ts
import {
I18N_CODES,
I18nError,
type I18nErrorCode,
} from "@suaveplan/i18n-shared/errors";
function categorise(err: I18nError): "user" | "config" | "system" {
const code: I18nErrorCode = err.code;
switch (code) {
case I18N_CODES.MISSING_KEY:
case I18N_CODES.INVALID_LOCALE:
return "user";
case I18N_CODES.INVALID_OPTIONS:
case I18N_CODES.INVALID_FORMATTER_OPTIONS:
case I18N_CODES.UNSUPPORTED_LOCALE:
case I18N_CODES.PLUGIN_ALREADY_INSTALLED:
return "config";
default:
return "system";
}
}
```
## API Reference
### `I18N_CODES`
Frozen `Record` of code-name → `"I18N.<code>"` literal. The 18 codes:
| Cluster | Code | Failure |
|---|---|---|
| Parser | `MISSING_KEY` | Lookup found no value |
| Parser | `INVALID_LOCALE` | Locale tag is not BCP-47 parseable |
| Parser | `INVALID_OPTIONS` | Catalog/parser options failed validation |
| Parser | `LOADER_FAILED` | Async catalog loader rejected |
| Parser | `INTERPOLATION_ERROR` | Placeholder render threw |
| Parser | `PLURAL_RULE_NOT_FOUND` | No plural rule for the locale |
| Parser | `NESTING_DEPTH_EXCEEDED` | Recursion exceeded `maxDepth` |
| Backend | `BACKEND_READ_FAILED` | Read raised |
| Backend | `BACKEND_WRITE_FAILED` | Write raised |
| Backend | `BACKEND_NOT_REGISTERED` | Lookup against unknown backend |
| Backend | `BACKEND_LOCALE_NOT_AVAILABLE` | Locale missing from backend |
| Backend | `BACKEND_TIMEOUT` | Backend exceeded its deadline |
| Backend | `CHAINED_BACKEND_ALL_FAILED` | Every chain candidate raised |
| Formatter | `INVALID_FORMATTER_OPTIONS` | Formatter opts rejected |
| Formatter | `UNSUPPORTED_LOCALE` | Locale not in supported set |
| Formatter | `FORMATTER_CACHE_OVERFLOW` | LRU cap exceeded |
| Detector | `DETECTOR_CHAIN_EXHAUSTED` | No detector returned a locale |
| Plugin | `PLUGIN_ALREADY_INSTALLED` | Duplicate plugin registration |
### `I18nErrorCode`
`type I18nErrorCode = (typeof I18N_CODES)[keyof typeof I18N_CODES]` —
discriminant union over all 18 fully-qualified strings.
### `I18nCode`
Back-compat alias for `I18nErrorCode`. New code SHOULD prefer
`I18nErrorCode`.
## Implementation Notes
- The registry is constructed via `defineCodes("I18N", [...] as const)`
from `@suaveplan/error`, which freezes the result with
`Object.freeze` and infers the literal-typed `Record`.
- The four "new" codes (`BACKEND_TIMEOUT`,
`CHAINED_BACKEND_ALL_FAILED`, `DETECTOR_CHAIN_EXHAUSTED`,
`FORMATTER_CACHE_OVERFLOW`) model failure modes the per-package
split surfaces — they were latent in the monolith but never
formally typed.
- Cardinality 18 is asserted at type level via `expectTypeOf` in the
unit test so future drift is caught at typecheck time.

View File

@@ -0,0 +1,106 @@
# `I18nError` — base error class for the i18n family
## Purpose
`I18nError` is the single base class every package in the
`@suaveplan/i18n*` family throws. It extends
`SuaveplanError<I18nErrorCode>` from `@suaveplan/error`, which gives
it cause-chain traversal, a redaction-aware wire format, an OTel
span-event adapter, and the i18n message-resolver hook for free —
without any leaf package having to re-implement those primitives.
Centralising the class also means consumers can catch every i18n
failure with a single `instanceof` check and discriminate by `.code`
against the 18-code `I18N_CODES` registry.
## Features
- Extends `SuaveplanError<I18nErrorCode>` so `instanceof` narrows
the code union
- Stamps `context.tier = "i18n"` on every instance so log sinks can
group by tier without inspecting the constructor name
- Preserves caller-supplied context fields (the tier merge is
non-destructive)
- Carries the full `SuaveplanErrorOptions` bag: `cause`, `traceId`,
`clock`, `context`
- No `// biome-ignore` suppression — the constructor body has a
meaningful merge step, so `noUselessConstructor` does not fire
## Usage: Basic Example
```ts
import { I18N_CODES, I18nError } from "@suaveplan/i18n-shared";
throw new I18nError(I18N_CODES.MISSING_KEY, "checkout.button.text", {
context: { locale: "en-GB", namespace: "checkout" },
});
```
## Usage: Advanced Example
```ts
import { I18N_CODES, I18nError } from "@suaveplan/i18n-shared/errors";
import { SuaveplanError } from "@suaveplan/error";
async function loadNamespace(locale: string, ns: string): Promise<unknown> {
try {
return await fetch(`/i18n/${locale}/${ns}.json`).then((r) => r.json());
} catch (cause) {
throw new I18nError(I18N_CODES.LOADER_FAILED, "i18n.loader_failed", {
cause,
context: { locale, namespace: ns },
traceId: crypto.randomUUID(),
});
}
}
try {
await loadNamespace("ar-SA", "checkout");
} catch (err) {
if (err instanceof I18nError) {
// Discriminate by code
if (err.code === I18N_CODES.LOADER_FAILED) {
console.warn("loader failed", err.context, err.cause);
}
} else if (err instanceof SuaveplanError) {
console.error("non-i18n suaveplan error", err.code);
}
}
```
## API Reference
### `class I18nError extends SuaveplanError<I18nErrorCode>`
#### `new I18nError(code, messageKey, options?)`
Constructs a new `I18nError`.
| Param | Type | Description |
|---|---|---|
| `code` | `I18nErrorCode` | One of the 18 codes in `I18N_CODES` |
| `messageKey` | `string` | i18n message key resolved by the installed `MessageResolver` (or used verbatim if none is installed) |
| `options` | `SuaveplanErrorOptions \| undefined` | Optional `{ cause, context, traceId, clock }` |
The constructor stamps `tier: "i18n"` into `context` while preserving
every caller-supplied field. The merge order is `{ tier, ...caller }`,
so a caller-supplied `tier` field overrides the default — useful for
sub-tier tagging (e.g. `tier: "i18n.parser"`).
Inherited public surface from `SuaveplanError`: `.code`, `.message`,
`.cause`, `.context`, `.timestamp`, `.traceId`, `.name`,
`.stack`.
Throws: nothing during construction (the resolver hook is wrapped in
defensive try/catch by `SuaveplanError`).
## Implementation Notes
- The `tier` stamp is the meaningful body that lets the constructor
pass biome's `noUselessConstructor` check without a suppression
comment, satisfying CLAUDE.md §9.
- The class follows the exact same shape as `ReactI18nError` and
`ReactFormsError` so cross-package error handling stays uniform.
- Subclasses are NOT exported from this package — every leaf package
throws `I18nError` directly with the appropriate code. If a leaf
needs richer context, it adds it via the `context` field rather
than subclassing.

View File

@@ -0,0 +1,129 @@
# `runBackendConformance`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`runBackendConformance` is the shared, runner-neutral conformance
harness every concrete `@suaveplan/i18n-backend-*` package runs
against its `Backend` implementation. It exists so the universal
contract — name + capabilities surface, read-of-absent → typed
`I18nError`, optional `readMulti` merge, optional `save` round-trip,
optional `lastModified` monotonicity — gets one canonical test body
that every backend re-uses verbatim. New backends inherit the
guarantees automatically; no copy-pasted test code drifting between
implementations.
The harness lives under the `@suaveplan/i18n-shared/testing` subpath
so backend test files can import it without pulling the full i18n
core or any other backend. It deliberately uses
`@suaveplan/testing/runner` so it executes identically under Vitest
and Bun's test runner.
## Features
- One `describe(...)` per backend, one `it(...)` per universal
contract clause.
- Capability-gated assertions: `readMulti` / `save` /
`lastModified` tests early-return for backends that do not declare
the corresponding capability flag, keeping the suite green for
read-only backends like HTTP.
- Optional `seed` adapter on the factory return so backends with
`save` can pre-populate without exposing internals; backends
without `save` (HTTP-style) seed via their constructor options
instead.
- Runner-neutral: imports `describe` / `it` / `expect` from
`@suaveplan/testing/runner`, so the same harness runs under
Vitest or Bun's test runner without modification.
## Usage: Basic Example
```ts
import { runBackendConformance } from "@suaveplan/i18n-shared/testing";
import { MemoryBackend } from "@suaveplan/i18n-backend-memory";
runBackendConformance("MemoryBackend", async () => {
const backend = new MemoryBackend();
return {
backend,
async seed(locale, namespace, record) {
await backend.save(locale, namespace, record as never);
},
};
});
```
## Usage: Advanced Example
A read-only backend (no `save`, no `readMulti`) supplies its own
seeding via constructor options and omits `seed` from the factory
return. The harness inspects `backend.capabilities` and the
structural presence of optional methods to decide which assertions
to run; unsupported assertions exit early.
```ts
import { runBackendConformance } from "@suaveplan/i18n-shared/testing";
import { httpBackend } from "@suaveplan/i18n-backend-http";
runBackendConformance("HttpBackend (seeded fetch)", async () => {
const backend = httpBackend({
loadPath: "https://i18n.example/{{locale}}/{{ns}}.json",
fetch: async () =>
new Response(JSON.stringify({ login: { title: "Sign in" } })),
});
return { backend };
});
```
## API Reference
### `runBackendConformance(label, makeFactory)`
- `label: string` — Human-readable backend label inserted into the
outer `describe(...)` title.
- `makeFactory: () => Promise<ConformanceContext>` — Async factory
invoked once per `it(...)`; produces a fresh backend instance and
optional `seed` callback. Always producing a fresh backend per
call prevents cross-test state leakage.
- Returns: `void`. The harness registers `describe` / `it` blocks
with the active runner; assertions execute when the runner runs
the file.
### `ConformanceContext`
```ts
interface ConformanceContext {
readonly backend: Backend; // from @suaveplan/types
readonly seed?: (
locale: string,
namespace: string,
record: Record<string, unknown>
) => Promise<void>;
}
```
### `ConformanceFactory`
`type ConformanceFactory = () => Promise<ConformanceContext>;`
## Implementation Notes
- The harness gates each capability check on **both** the structural
presence of the method (`backend.save`) **and** the corresponding
capability flag (`backend.capabilities.canSave`). This catches
backends whose flags drift out of sync with their methods at
authoring time and keeps the harness honest for partial
implementations.
- The harness imports the `Backend` interface from
`@suaveplan/types`, never from `@suaveplan/i18n` core. Backends
consume the same type from the same location, so the harness adds
zero coupling to the slim core.
- `lastModified` monotonicity uses a 2-ms `setTimeout` between the
two `save` calls to defend against backends whose injected clock
has 1-ms resolution. Backends with deterministic counters always
satisfy `t2 >= t1`.
- This harness is the Wave 5 sequencing keystone: the five backend
packages (`memory`, `http`, `storage`, `fs-multifile`, `chained`)
all call it. Lifting it here lets every backend land independently
without copy-pasting test fixtures.

View File

@@ -0,0 +1,180 @@
# @suaveplan/i18n - Internationalization core: message catalog, ICU-like interpolation, pluralization, locale resolution, middleware, date/currency formatting.
## Summary
Slim integration core for the i18n family. After the Wave 6 modular
split, this package owns the `MessageCatalog`, the `createI18n` /
`I18nInstance` plugin slot, the locale negotiation/resolver, the
server middleware, the observability state, the schemas, the
key/translation utilities, and the default `FormatBridge` that wires
the per-formatter packages into the universal ICU parser.
Everything else — constants, error codes, the ICU parser itself, the
interpolation engine, the pluralization engine, every formatter, the
locale detector chain, and the translation backends — lives in a
sibling package and is re-exported here for back-compat.
Works in Bun, Node.js, and modern browsers (zero DOM dependencies).
<!-- begin:auto-docs -->
## Documentation tree
- [catalog](./src/core/catalog/catalog.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [catalog](./src/core/catalog/catalog.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [plugin](./src/core/plugin/instance.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [instance](./src/core/plugin/instance.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [negotiation](./src/locale/negotiation/negotiation.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [negotiation](./src/locale/negotiation/negotiation.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [resolver](./src/locale/resolver/resolver.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [resolver](./src/locale/resolver/resolver.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [server](./src/middleware/server/server.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [server](./src/middleware/server/server.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [backend-metrics](./src/observability/backend-metrics.md) — OpenTelemetry instruments for the `Backend` plugin family. Every concrete backend (`MemoryBackend`, `HttpBackend`, `Sto…
- [detector-metrics](./src/observability/detector-metrics.md) — This module hosts the OTel instruments and an in-memory test ring for the locale-detector chain. The instruments live h…
- [metrics](./src/observability/metrics.md) — `@suaveplan/i18n/observability/metrics` is the package's instrumentation surface. It exposes a lazily-built singleton h…
- [format-bridge-default](./src/parser/format-bridge-default/format-bridge-default.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [format-bridge-default](./src/parser/format-bridge-default/format-bridge-default.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [missing-key-reporter](./src/post-processors/missing-key-reporter.md) — A development-time plugin that subscribes to the `missingKey` event on an `I18nInstance` and forwards every miss to a l…
- [pseudo-localize](./src/post-processors/pseudo-localize.md) — Layout-stress transform for translated strings. Three modes deliberately produce non-translated output that exercises f…
- [schemas](./src/schemas/i18n.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [i18n.schemas](./src/schemas/i18n.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [utils](./src/utils/utils.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [utils](./src/utils/utils.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
<!-- end:auto-docs --> - [catalog](./src/core/catalog/catalog.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/core/plugin/instance.md) — <!-- begin:auto-docs --> _(no submodules)_- [instance](./src/core/plugin/instance.md) — <!-- begin:auto-docs --> _(no submodules)_- [negotiation](./src/locale/negotiation/negotiation.md) — <!-- begin:auto-docs --> _(no submodules)_- [negotiation](./src/locale/negotiation/negotiation.md) — <!-- begin:auto-docs --> _(no submodules)_- [resolver](./src/locale/resolver/resolver.md) — <!-- begin:auto-docs --> _(no submodules)_- [resolver](./src/locale/resolver/resolver.md) — <!-- begin:auto-docs --> _(no submodules)_- [server](./src/middleware/server/server.md) — <!-- begin:auto-docs --> _(no submodules)_- [server](./src/middleware/server/server.md) — <!-- begin:auto-docs --> _(no submodules)_- [backend-metrics](./src/observability/backend-metrics.md) — OpenTelemetry instruments for the `Backend` plugin family. Every concrete backend (`MemoryBackend`, `HttpBackend`, `Sto…
- [detector-metrics](./src/observability/detector-metrics.md) — This module hosts the OTel instruments and an in-memory test ring for the locale-detector chain. The instruments live h…
- [metrics](./src/observability/metrics.md) — `@suaveplan/i18n/observability/metrics` is the package's instrumentation surface. It exposes a lazily-built singleton h…
- [format-bridge-default](./src/parser/format-bridge-default/format-bridge-default.md) — <!-- begin:auto-docs --> _(no submodules)_- [format-bridge-default](./src/parser/format-bridge-default/format-bridge-default.md) — <!-- begin:auto-docs --> _(no submodules)_- [missing-key-reporter](./src/post-processors/missing-key-reporter.md) — A development-time plugin that subscribes to the `missingKey` event on an `I18nInstance` and forwards every miss to a l…
- [pseudo-localize](./src/post-processors/pseudo-localize.md) — Layout-stress transform for translated strings. Three modes deliberately produce non-translated output that exercises f…
- [schemas](./src/schemas/i18n.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [i18n.schemas](./src/schemas/i18n.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [utils](./src/utils/utils.md) — <!-- begin:auto-docs --> _(no submodules)_- [utils](./src/utils/utils.md) — <!-- begin:auto-docs --> _(no submodules)_- [catalog](./src/core/catalog/catalog.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/core/plugin/instance.md) — <!-- begin:auto-docs --> _(no submodules)_- [instance](./src/core/plugin/instance.md) — <!-- begin:auto-docs --> _(no submodules)_- [negotiation](./src/locale/negotiation/negotiation.md) — <!-- begin:auto-docs --> _(no submodules)_- [negotiation](./src/locale/negotiation/negotiation.md) — <!-- begin:auto-docs --> _(no submodules)_- [resolver](./src/locale/resolver/resolver.md) — <!-- begin:auto-docs --> _(no submodules)_- [resolver](./src/locale/resolver/resolver.md) — <!-- begin:auto-docs --> _(no submodules)_- [server](./src/middleware/server/server.md) — <!-- begin:auto-docs --> _(no submodules)_- [server](./src/middleware/server/server.md) — <!-- begin:auto-docs --> _(no submodules)_- [backend-metrics](./src/observability/backend-metrics.md) — OpenTelemetry instruments for the `Backend` plugin family. Every concrete backend (`MemoryBackend`, `HttpBackend`, `Sto…
- [detector-metrics](./src/observability/detector-metrics.md) — This module hosts the OTel instruments and an in-memory test ring for the locale-detector chain. The instruments live h…
- [metrics](./src/observability/metrics.md) — `@suaveplan/i18n/observability/metrics` is the package's instrumentation surface. It exposes a lazily-built singleton h…
- [format-bridge-default](./src/parser/format-bridge-default/format-bridge-default.md) — <!-- begin:auto-docs --> _(no submodules)_- [format-bridge-default](./src/parser/format-bridge-default/format-bridge-default.md) — <!-- begin:auto-docs --> _(no submodules)_- [missing-key-reporter](./src/post-processors/missing-key-reporter.md) — A development-time plugin that subscribes to the `missingKey` event on an `I18nInstance` and forwards every miss to a l…
- [pseudo-localize](./src/post-processors/pseudo-localize.md) — Layout-stress transform for translated strings. Three modes deliberately produce non-translated output that exercises f…
- [schemas](./src/schemas/i18n.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [i18n.schemas](./src/schemas/i18n.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [utils](./src/utils/utils.md) — <!-- begin:auto-docs --> _(no submodules)_- [utils](./src/utils/utils.md) — <!-- begin:auto-docs --> _(no submodules)_- [catalog](./src/core/catalog/catalog.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/core/plugin/instance.md) — <!-- begin:auto-docs --> _(no submodules)_- [instance](./src/core/plugin/instance.md) — <!-- begin:auto-docs --> _(no submodules)_- [negotiation](./src/locale/negotiation/negotiation.md) — <!-- begin:auto-docs --> _(no submodules)_- [negotiation](./src/locale/negotiation/negotiation.md) — <!-- begin:auto-docs --> _(no submodules)_- [resolver](./src/locale/resolver/resolver.md) — <!-- begin:auto-docs --> _(no submodules)_- [resolver](./src/locale/resolver/resolver.md) — <!-- begin:auto-docs --> _(no submodules)_- [server](./src/middleware/server/server.md) — <!-- begin:auto-docs --> _(no submodules)_- [server](./src/middleware/server/server.md) — <!-- begin:auto-docs --> _(no submodules)_- [backend-metrics](./src/observability/backend-metrics.md) — OpenTelemetry instruments for the `Backend` plugin family. Every concrete backend (`MemoryBackend`, `HttpBackend`, `Sto…
- [detector-metrics](./src/observability/detector-metrics.md) — This module hosts the OTel instruments and an in-memory test ring for the locale-detector chain. The instruments live h…
- [metrics](./src/observability/metrics.md) — `@suaveplan/i18n/observability/metrics` is the package's instrumentation surface. It exposes a lazily-built singleton h…
- [format-bridge-default](./src/parser/format-bridge-default/format-bridge-default.md) — <!-- begin:auto-docs --> _(no submodules)_- [format-bridge-default](./src/parser/format-bridge-default/format-bridge-default.md) — <!-- begin:auto-docs --> _(no submodules)_- [missing-key-reporter](./src/post-processors/missing-key-reporter.md) — A development-time plugin that subscribes to the `missingKey` event on an `I18nInstance` and forwards every miss to a l…
- [pseudo-localize](./src/post-processors/pseudo-localize.md) — Layout-stress transform for translated strings. Three modes deliberately produce non-translated output that exercises f…
- [schemas](./src/schemas/i18n.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [i18n.schemas](./src/schemas/i18n.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [utils](./src/utils/utils.md) — <!-- begin:auto-docs --> _(no submodules)_- [utils](./src/utils/utils.md) — <!-- begin:auto-docs --> _(no submodules)_- [catalog](./src/core/catalog/catalog.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/core/plugin/instance.md) — <!-- begin:auto-docs --> _(no submodules)_ - [instance](./src/core/plugin/instance.md) — <!-- begin:auto-docs --> _(no submodules)_- [negotiation](./src/locale/negotiation/negotiation.md) — <!-- begin:auto-docs --> _(no submodules)_ - [negotiation](./src/locale/negotiation/negotiation.md) — <!-- begin:auto-docs --> _(no submodules)_- [resolver](./src/locale/resolver/resolver.md) — <!-- begin:auto-docs --> _(no submodules)_ - [resolver](./src/locale/resolver/resolver.md) — <!-- begin:auto-docs --> _(no submodules)_- [server](./src/middleware/server/server.md) — <!-- begin:auto-docs --> _(no submodules)_ - [server](./src/middleware/server/server.md) — <!-- begin:auto-docs --> _(no submodules)_- [backend-metrics](./src/observability/backend-metrics.md) — OpenTelemetry instruments for the `Backend` plugin family. Every concrete backend (`MemoryBackend`, `HttpBackend`, `Sto…
- [detector-metrics](./src/observability/detector-metrics.md) — This module hosts the OTel instruments and an in-memory test ring for the locale-detector chain. The instruments live h…
- [metrics](./src/observability/metrics.md) — `@suaveplan/i18n/observability/metrics` is the package's instrumentation surface. It exposes a lazily-built singleton h…
- [format-bridge-default](./src/parser/format-bridge-default/format-bridge-default.md) — <!-- begin:auto-docs --> _(no submodules)_ - [format-bridge-default](./src/parser/format-bridge-default/format-bridge-default.md) — <!-- begin:auto-docs --> _(no submodules)_- [missing-key-reporter](./src/post-processors/missing-key-reporter.md) — A development-time plugin that subscribes to the `missingKey` event on an `I18nInstance` and forwards every miss to a l…
- [pseudo-localize](./src/post-processors/pseudo-localize.md) — Layout-stress transform for translated strings. Three modes deliberately produce non-translated output that exercises f…
- [schemas](./src/schemas/i18n.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_ - [i18n.schemas](./src/schemas/i18n.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [utils](./src/utils/utils.md) — <!-- begin:auto-docs --> _(no submodules)_ - [utils](./src/utils/utils.md) — <!-- begin:auto-docs --> _(no submodules)_
## Modules in the slim core
| Module | Path | Description |
|--------|------|-------------|
| Catalog | `src/core/catalog/` | `MessageCatalog`, `deepMerge` — namespaced + lazy-loaded translations |
| Plugin | `src/core/plugin/` | `createI18n`, `I18nInstance` — facade with plugin slot + post-processors |
| Locale negotiation | `src/locale/negotiation/` | `negotiateLocale`, `parseAcceptLanguage` — RFC 4647 lookup |
| Locale resolver | `src/locale/resolver/` | `LocaleResolver`, `extractLanguage`, `extractRegion`, `normalizeLocaleCode`, `isValidBcp47` — strategy-based resolver with BCP-47 validation |
| Server middleware | `src/middleware/server/` | `createLocaleMiddleware`, `detectLocale`, `getDirection` |
| Observability | `src/observability/` | Detector + backend metrics state + OTel adapters |
| Schemas | `src/schemas/` | `CatalogOptionsSchema`, `InterpolationConfigSchema`, `ResolverConfigSchema`, `TranslationRecordSchema` |
| Utilities | `src/utils/` | `buildKey`, `countKeys`, `flattenRecord`, `getNestedValue`, `getTextDirection`, `hasKey`, `isRtl`, `parseKey`, `sanitize`, `unflattenRecord` |
| Default `FormatBridge` | `src/parser/format-bridge-default/` | Wires the formatter packages into `MessageParser.format` |
| Subpath shims | `src/subpaths/` | Forwarders for the four kept back-compat subpaths |
## Re-exports from sibling packages
Every symbol the pre-split monolith exposed continues to resolve from
`@suaveplan/i18n`. The source of each symbol is now a sibling package:
| Capability | Sibling package |
|---|---|
| Constants (locale descriptors, RTL set, BCP-47 map, defaults) | `@suaveplan/i18n-shared` |
| Error registry (`I18N_CODES`, `I18nError`, `I18nErrorCode`) | `@suaveplan/i18n-shared` |
| ICU parser (`MessageParser`, `renderNodes`, AST node types) | `@suaveplan/i18n-icu-parser` |
| Interpolation (`InterpolationEngine`, `escapeHtmlChars`, `escapeRegex`) | `@suaveplan/i18n-icu-parser` |
| Pluralization (9 CLDR rules, `PluralizationEngine`, `getBaseLocale`) | `@suaveplan/i18n-icu-parser` |
| Number formatting (`formatNumber`, helpers, observability) | `@suaveplan/i18n-formatter-number` |
| Currency formatting (`formatCurrency`, `formatCurrencyValue`, info helpers) | `@suaveplan/i18n-formatter-currency` |
| Date / time formatting (`formatDate`, `formatTime`, `formatDateOnly`, `formatDateRange`, `getDayNames`, `getMonthNames`) | `@suaveplan/i18n-formatter-date` |
| Relative time (`formatRelativeTime`, `selectRelativeUnit`, helpers) | `@suaveplan/i18n-formatter-relative-time` |
| List formatting (`formatList`, `formatConjunction`, `formatDisjunction`) | `@suaveplan/i18n-formatter-list` |
| Display name formatting (`formatDisplayName`, language/region/script helpers) | `@suaveplan/i18n-formatter-display-name` |
| Locale detection chain (`createDetectorChain`, every detector factory, direction helpers) | `@suaveplan/i18n-detector` |
| Memory backend | `@suaveplan/i18n-backend-memory` |
| HTTP backend | `@suaveplan/i18n-backend-http` |
| Storage-backed backend | `@suaveplan/i18n-backend-storage` |
| Chained backend | `@suaveplan/i18n-backend-chained` |
| Filesystem multi-file backend (server-tier — NOT re-exported at root) | `@suaveplan/i18n-backend-fs-multifile` |
Collator (`@suaveplan/i18n-formatter-collator`) and Segmenter
(`@suaveplan/i18n-formatter-segmenter`) are not currently re-exported
at the root — consumers import directly from the leaf packages. Add
them to `src/index.ts` if cross-package call-sites stabilise.
## Subpaths
Four subpaths are kept indefinitely for back-compat with downstream
consumers (`@suaveplan/react-i18n`, `react-ui`, `react-forms`):
| Subpath | Forwards to |
|---|---|
| `@suaveplan/i18n/detector` | `@suaveplan/i18n-detector` |
| `@suaveplan/i18n/parser` | `@suaveplan/i18n-icu-parser` |
| `@suaveplan/i18n/plugin` | local `core/plugin/instance.ts` |
| `@suaveplan/i18n/errors` | `@suaveplan/i18n-shared` |
New consumers SHOULD import directly from the sibling package for
tighter tree-shaking.
## Quick start
```ts
import { createI18n, defaultFormatBridge, MessageParser } from "@suaveplan/i18n";
const i18n = createI18n({
defaultLocale: "en",
fallbackLocale: "en",
messages: {
en: { greeting: "Hello, {name}!" },
fr: { greeting: "Bonjour, {name} !" },
},
});
i18n.t("greeting", { values: { name: "world" } });
// "Hello, world!"
// ICU placeholders are rendered through the default FormatBridge
// when consumers reach for the parser directly:
const parser = new MessageParser();
const parsed = parser.parse("Total: {amount, number, currency/USD}");
const formatted = parsed.format(
{ amount: 49.99 },
{ locale: "en-US", formatBridge: defaultFormatBridge }
);
// "Total: $49.99"
```
## Custom `FormatBridge`
The slim core's `defaultFormatBridge` covers every method on the
`FormatBridge` contract by delegating to the formatter packages.
Consumers MAY override individual methods (or build a no-Intl bridge
via `noopFormatBridge`):
```ts
import { defaultFormatBridge, noopFormatBridge } from "@suaveplan/i18n";
import type { FormatBridge } from "@suaveplan/i18n";
const customBridge: FormatBridge = {
...defaultFormatBridge,
relativeTime: () => "(some other language)",
};
```
## Bundle size (post-slim, 2026-05-09)
- `dist/` total: 592 KB
- `dist/index.js`: 4.76 KB (1.60 KB gzip) — pure barrel
- Heaviest module: `dist/core/catalog/catalog.js` at 17.52 KB (5.48 KB gzip)
- 15 modules in the build output (was ~120 pre-Wave-6)
## Status
- 320 tests in the slim core (`bunx turbo test --filter=@suaveplan/i18n`) — green
- Coverage: 100/100/100/100 on the surviving modules (Istanbul)
- Downstream sweep (`bunx turbo test --filter='@suaveplan/react-*'`): 40 tasks green, 1238 tests in `react-ui` alone

View File

@@ -0,0 +1,127 @@
# MessageCatalog
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`MessageCatalog` is the central registry for translation messages in `@suaveplan/i18n`. It manages per-locale translation records (flat or nested), handles namespace separation, provides typed lookup results, supports async lazy loading, and calls a pluggable missing-key handler when a translation is absent. The catalog is designed to be long-lived in server and browser environments alike, accepting locale switches at runtime without restarting.
The companion `deepMerge` function is exported separately because it is also used outside the catalog (e.g., when applying partial namespace patches to existing records).
## Features
- Generic over `TLocale` and `TMessages` for full type inference
- Configurable `keySeparator` (default `.`) and `namespaceSeparator` (default `:`)
- Fallback locale chain: primary → fallback → missing-key handler → raw key
- `get(key, locale?)` returns a `LookupResult` with `value`, `locale`, and `isFallback` fields
- `has(key, locale?)` for existence checks without triggering the missing-key handler
- `load(locale, loader)` for async message loading (merges into existing)
- `extend(locale, record)` for synchronous merging of additional keys
- `keys(locale?)` returns a flat list of all registered dot-separated keys
- `deepMerge` performs recursive object merging at the leaf level
## Usage: Basic Example
```typescript
import { MessageCatalog } from "@suaveplan/i18n/catalog";
const catalog = new MessageCatalog({
defaultLocale: "en",
messages: {
en: { home: { title: "Welcome" } },
fr: { home: { title: "Bienvenue" } },
},
});
const result = catalog.get("home.title", "fr");
// { value: "Bienvenue", locale: "fr", isFallback: false }
```
## Usage: Advanced Example
```typescript
import { MessageCatalog } from "@suaveplan/i18n/catalog";
const catalog = new MessageCatalog({
defaultLocale: "en",
fallbackLocale: "en",
namespaceSeparator: ":",
onMissingKey: (locale, key) => `[MISSING: ${locale}:${key}]`,
});
// Async load from remote
await catalog.load("fr", async (locale) => {
const res = await fetch(`/locales/${locale}.json`);
return res.json();
});
// Namespace lookup
catalog.addMessages("en", { ui: { button: { save: "Save" } } } as never);
const r = catalog.get("ui:button.save", "en");
// { value: "Save", locale: "en", isFallback: false }
// Fallback chain
const missing = catalog.get("nonexistent.key", "fr");
// { value: "[MISSING: fr:nonexistent.key]", locale: "fr", isFallback: false }
```
## API Reference
### `new MessageCatalog<TLocale, TMessages>(options: ExtendedCatalogOptions)`
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `defaultLocale` | `TLocale` | required | Initial active locale |
| `fallbackLocale` | `TLocale?` | — | Used when primary lookup misses |
| `messages` | `TranslationCatalogMap?` | — | Pre-loaded messages |
| `onMissingKey` | `MissingKeyHandler?` | — | Called when no value found |
| `keySeparator` | `string` | `"."` | Path segment separator |
| `namespaceSeparator` | `string` | `":"` | Namespace prefix separator |
### `.get(key, locale?): LookupResult`
Returns `{ value: string | undefined; locale: string; isFallback: boolean }`.
### `.has(key, locale?): boolean`
Returns `true` if the key resolves to a string in the given locale.
### `.preload(locales, namespaces?): Promise<void>`
Bulk-load via the registered backend. Prefers `backend.readMulti` when available; falls back to N concurrent `backend.read` calls otherwise. Concurrent calls for the same `(locale, namespace)` pair are deduplicated. Throws `I18nError(BACKEND_NOT_REGISTERED)` if no backend is registered.
### `.invalidate(locale?, namespace?): void`
Drop cached entries so the next `getAsync` re-fetches via the backend. With no arguments drops every locale; with `locale` only drops every namespace under it; with both drops just one `(locale, namespace)` pair.
### `.getAsync(key, locale?): Promise<LookupResult>`
Async sibling of `.get()` that triggers a backend `read` for the missing namespace and resolves once the load completes. Synchronous `.get()` and `.t()` continue to never trigger backend reads — only `getAsync` does.
### `.saveMissingKey(locale, key, defaultValue): Promise<void>`
Best-effort write-back through `backend.save`. Requires `saveMissing: true` in catalog options. Save failures are swallowed (the OTel `i18n.backend.write.total{outcome=error}` counter increments) — saveMissing is a translator-workflow signal, not a critical-path operation.
### `.load(locale, namespace?, loader?): Promise<void>`
Calls `loader(locale)` and deep-merges the result.
### `.extend(locale, record): void`
Synchronous deep-merge of `record` into the locale.
### `.keys(locale?): string[]`
Returns all dot-separated leaf keys for the locale.
### `deepMerge<T extends TranslationRecord>(target, source): T`
Recursively merges `source` into `target` without mutating either.
## Implementation Notes
- Locale lookups use a `Map<string, TMessages>` for O(1) access per locale
- The namespace parsing always splits on the **first** occurrence of `namespaceSeparator`, so namespaces may not themselves contain the separator character
- `deepMerge` creates a shallow copy of `target` first, then overlays `source` keys recursively — objects at both levels are merged, primitives in source win
- `load` uses `mergeMessages` internally, meaning repeated loads accumulate rather than replace — callers should use `addMessages` when a full replacement is intended

View File

@@ -0,0 +1,92 @@
# I18nInstance
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The top-level i18n facade. Composes `MessageCatalog`, a plugin registry, an event bus (via `@suaveplan/events`), and a post-processor chain into a single object. Most consumers use `createI18n(options)` and never construct the underlying engines directly.
The instance is the natural place to wire cross-cutting concerns — backends (registered through the catalog's options), missing-key reporters, post-processors (pseudo-localization, markdown render), language detection. Plugins extend the runtime through a uniform `I18nPlugin` shape; the registry guarantees one-time install, reverse-order dispose, and duplicate-name rejection.
## Features
- `createI18n(options)` factory + `I18nInstance` class
- `i18n.t(key, options?)` mirrors `MessageCatalog.t` and runs the result through registered post-processors
- `i18n.changeLanguage(locale)` emits `languageChanged` (no-op when already on the target locale)
- `i18n.use(plugin)` install — sync or async — with duplicate-name rejection
- `i18n.dispose()` reverse-order plugin disposal; failures swallowed (best-effort)
- `i18n.events` is an `EventBus<I18nEventMap>` exposing `languageChanged | loaded | missingKey | keyAdded | pluralRuleAdded`
- `i18n.addPostProcessor(fn)` registers a `(value, key, locale) => string` transformer
- New error codes: `I18N.PLUGIN_ALREADY_INSTALLED`
## Usage: Basic Example
```typescript
import { createI18n } from "@suaveplan/i18n";
const i18n = createI18n({
defaultLocale: "en",
messages: {
en: { greeting: "Hello, {name}!" },
},
});
i18n.t("greeting", { values: { name: "Alice" } }); // → "Hello, Alice!"
await i18n.changeLanguage("fr");
i18n.events.on("languageChanged", ({ from, to }) =>
console.log(`switched ${from} → ${to}`)
);
```
## Usage: Advanced Example — Plugin Lifecycle
```typescript
import { createI18n, type I18nPlugin } from "@suaveplan/i18n";
const trackingPlugin: I18nPlugin = {
name: "missing-key-tracker",
kinds: ["logger"],
install(i18n) {
i18n.events.on("missingKey", ({ locale, namespace, key }) => {
reportToTms({ locale, namespace, key });
});
},
};
const i18n = createI18n({ defaultLocale: "en", plugins: [trackingPlugin] });
// ... at shutdown:
await i18n.dispose();
```
## API Reference
### `class I18nInstance<TLocale, TMessages>`
- `catalog: MessageCatalog<TLocale, TMessages>` — the underlying catalog
- `events: EventBus<I18nEventMap>` — the bus for `languageChanged | loaded | missingKey | keyAdded | pluralRuleAdded`
- `locale: TLocale` — getter for the active locale
- `t(key, options?): string` — translation + post-processor chain
- `changeLanguage(locale): Promise<void>` — emits `languageChanged`
- `use(plugin): Promise<void>` — register plugin
- `addPostProcessor(fn): void` — register post-processor
- `getBackend(): Backend | undefined` — proxy through to the catalog's backend
- `listPlugins(): readonly string[]` — registered plugin names
- `dispose(): Promise<void>` — reverse-order plugin disposal
### `createI18n(options): I18nInstance`
Factory form. Equivalent to `new I18nInstance(options)`.
### `I18nInstanceOptions`
Extends `ExtendedCatalogOptions` with `plugins?: I18nPlugin[]` and `postProcessors?: PostProcessor[]`.
## Implementation Notes
`changeLanguage` is a no-op (no event emission) when the target locale equals the current locale. This matches the i18next contract and prevents reactive consumers from re-rendering on a no-change. If a consumer needs an unconditional event, it can emit on the bus directly: `i18n.events.emit('languageChanged', { from, to })`.
Plugin disposal failures are intentionally swallowed. A failing `dispose` should not block the rest of the disposal chain — in production, that would leave half-disposed state. Any error logging belongs in the plugin's own `dispose` body before it throws.
Post-processors run AFTER the catalog's plural / context / interpolation pipeline, so they see fully-substituted strings. The pseudo-localization plugin uses this hook so every `t()` output is wrapped in `[!! ... !!]` markers.

View File

@@ -0,0 +1,78 @@
# Locale Negotiation
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides RFC 4647 locale negotiation for HTTP `Accept-Language` headers. Parses the header into quality-weighted entries and selects the best supported locale using exact matching first, followed by language-only fallback (e.g. `fr-CA` matching `fr-FR` when `fr-CA` is unavailable). Returns the default locale when no candidate meets the quality threshold.
## Features
- Parses `Accept-Language` headers including multiple locales with `q=` quality values
- Entries sorted by quality score descending before matching
- Exact BCP-47 match attempted first (case-insensitive)
- Language-only fallback with 10% quality penalty when region variant is unavailable
- Configurable quality threshold to reject low-confidence candidates
- Returns a `NegotiationResult` with the chosen locale, its effective quality, and an `isDefault` flag
- Pure function — no IO, no global state
## Usage: Basic Example
```typescript
import { negotiateLocale } from "@suaveplan/i18n/locale";
const result = negotiateLocale("en-GB,en;q=0.9,fr;q=0.8", {
supported: ["en-US", "fr-FR"],
defaultLocale: "en-US",
});
// result.locale → "en-US" (language-only fallback from en-GB → en-US)
// result.quality → 0.9 * 0.9 (10% penalty for region mismatch)
// result.isDefault → false
```
## Usage: Advanced Example
```typescript
import { parseAcceptLanguage, negotiateLocale } from "@suaveplan/i18n/locale";
// Parse and inspect before negotiation
const entries = parseAcceptLanguage("de-AT,de;q=0.9,en;q=0.5");
// entries → [
// { code: "de-at", quality: 1 },
// { code: "de", quality: 0.9 },
// { code: "en", quality: 0.5 },
// ]
// Raise the quality threshold to only accept high-confidence matches
const result = negotiateLocale("zh-Hans,en;q=0.3", {
supported: ["en-US", "de-DE"],
defaultLocale: "en-US",
threshold: 0.5, // en;q=0.3 is below threshold, zh-Hans has no match
});
// result.locale → "en-US"
// result.isDefault → true
```
## API Reference
### `parseAcceptLanguage(header: string): AcceptLanguageEntry[]`
Parses a raw `Accept-Language` header string into a sorted array of quality-weighted entries. Wildcard (`*`) and empty entries are filtered out. Returns an empty array for an empty or whitespace-only header.
- **`header`** — raw header value, e.g. `"en-GB,en;q=0.9,fr;q=0.8"`
- **Returns** `AcceptLanguageEntry[]` sorted by `quality` descending
### `negotiateLocale<TLocale>(header, options): NegotiationResult<TLocale>`
Selects the best supported locale for the given `Accept-Language` header.
- **`header`** — raw `Accept-Language` value
- **`options.supported`** — array of supported locale codes
- **`options.defaultLocale`** — fallback when no match is found or all candidates are below threshold
- **`options.threshold`** — minimum quality to consider (default `0`); set to e.g. `0.1` to ignore `q=0` explicit rejections
- **Returns** `NegotiationResult<TLocale>` with `.locale`, `.quality`, `.isDefault`
## Implementation Notes
Matching is done case-insensitively by normalising both the incoming code and the supported locale list to lowercase for comparison, while preserving the original casing in the returned locale string. The language-only fallback applies a 0.9 multiplier to the quality score to signal that it is a less-precise match than an exact hit. This multiplier is intentionally conservative — it keeps a `fr-FR` fallback ranked below an exact `en` match when the header contains `en;q=0.95,fr-CA;q=1`.

View File

@@ -0,0 +1,101 @@
# LocaleResolver
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`LocaleResolver` maps a requested locale string (or ordered list of preferred locales) to the best available supported locale. It implements a four-level resolution cascade — exact match, alias, language-only, region-family — and returns a confidence score alongside the resolved locale so callers can make decisions based on match quality.
The standalone exports `normalizeLocaleCode`, `extractLanguage`, and `extractRegion` provide the building blocks for locale string manipulation without instantiating the full resolver.
## Features
- Four-tier resolution: exact → alias → language match → region family → default
- Case-normalised matching by default (`normalizeCase: true`)
- Alias map for legacy or non-standard locale spellings
- `resolveFromList` for processing `Accept-Language`-style preference lists
- `isSupported`, `getSupportedLocales`, `getDefaultLocale` for introspection
- Runtime `addAlias` for dynamic alias registration
- Fully generic over `TLocale extends string` for typed locale unions
## Usage: Basic Example
```typescript
import { LocaleResolver } from "@suaveplan/i18n/locale";
const resolver = new LocaleResolver({
supportedLocales: ["en", "fr", "de"],
defaultLocale: "en",
});
const result = resolver.resolve("fr-FR");
// { locale: "fr", source: "language", confidence: 0.8 }
```
## Usage: Advanced Example
```typescript
import {
LocaleResolver,
normalizeLocaleCode,
extractLanguage,
extractRegion,
} from "@suaveplan/i18n/locale";
const resolver = new LocaleResolver({
supportedLocales: ["en-US", "en-GB", "fr-FR"],
defaultLocale: "en-US",
aliases: { "en": "en-US", "en_gb": "en-GB" },
});
// Accept-Language list resolution
const r = resolver.resolveFromList(["de-DE", "fr;q=0.9", "en;q=0.8"]);
// { locale: "fr-FR", source: "language", confidence: 0.8 }
// Standalone helpers
console.log(normalizeLocaleCode("EN_US")); // "en-us"
console.log(extractLanguage("zh-CN")); // "zh"
console.log(extractRegion("en-GB")); // "gb"
```
## API Reference
### `new LocaleResolver<TLocale>(config: ExtendedResolverConfig<TLocale>)`
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `supportedLocales` | `TLocale[]` | required | Accepted locale codes |
| `defaultLocale` | `TLocale` | required | Returned when no match is found |
| `aliases` | `Record<string, TLocale>?` | `{}` | Alternative locale spellings |
| `normalizeCase` | `boolean?` | `true` | Lowercase all locales before comparison |
### `.resolve(requested: string): ResolvedLocale<TLocale>`
Returns `{ locale, source, confidence }`. `confidence` is `1.0` for exact, `0.95` for alias, `0.8` for language, `0.6` for region, `0.0` for default.
### `.resolveFromList(preferred: string[]): ResolvedLocale<TLocale>`
Iterates `preferred` and returns the highest-confidence result.
### `.isSupported(locale: string): boolean`
Returns `true` if the locale matches any supported locale exactly.
### `normalizeLocaleCode(locale: string): string`
Lowercases and replaces underscores with hyphens.
### `extractLanguage(locale: string): string`
Returns the language subtag (before the first `-`).
### `extractRegion(locale: string): string | undefined`
Returns the region subtag (second `-`-separated segment) or `undefined`.
## Implementation Notes
- `findByRegion` is intentionally broad: it matches any supported locale sharing the same language subtag, not specifically the requested region. This is the correct fallback for cases like `"en-AU"` resolving to `"en-GB"` when `"en-AU"` is not supported
- Aliases are normalised before storage so `addAlias("EN", "en-US")` works as expected regardless of case
- The resolver is synchronous; async resolution (e.g., reading user preferences from a database) should be handled at the calling layer

View File

@@ -0,0 +1,100 @@
# Locale Middleware
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The server middleware module provides framework-agnostic locale detection for server-side request handling. It inspects request headers, cookies, query parameters, and URL path prefixes to determine the best locale, then returns a typed `MiddlewareResult` containing the resolved locale, the detection source, and the text direction. The module makes no assumptions about the HTTP framework in use — it operates on the `I18nRequest` abstraction, which maps to the minimum interface needed for locale extraction.
This design allows the middleware to be wrapped by thin adapters for Express, Fastify, H3, Hono, or any other framework without modifying the core logic.
## Features
- Configurable lookup order (path, query, cookie, header — any order)
- `customExtractor` hook for application-specific locale sources
- Accept-Language header negotiation via `negotiateLocale`
- Cookie and query parameter locale detection with case-insensitive matching
- URL path prefix detection (`/fr/products` → `"fr"`)
- `getDirection(locale)` — RTL detection backed by the canonical RTL locale set
- `createLocaleMiddleware(config)` — factory for reusable middleware functions
## Usage: Basic Example
```typescript
import { createLocaleMiddleware } from "@suaveplan/i18n/middleware";
const middleware = createLocaleMiddleware({
supportedLocales: ["en", "fr", "de"],
defaultLocale: "en",
});
const result = middleware({ headers: { "accept-language": "fr-FR,fr;q=0.9" } });
// { locale: "fr", source: "header", direction: "ltr" }
```
## Usage: Advanced Example
```typescript
import { detectLocale, getDirection } from "@suaveplan/i18n/middleware";
const result = detectLocale(
{
path: "/ar/dashboard",
cookies: { locale: "fr" },
query: { lang: "de" },
headers: { "accept-language": "en" },
},
{
supportedLocales: ["en", "fr", "de", "ar"],
defaultLocale: "en",
lookupOrder: ["path", "query", "cookie", "header"],
pathPrefix: true,
}
);
// { locale: "ar", source: "path", direction: "rtl" }
const dir = getDirection("he-IL"); // "rtl"
```
## API Reference
### `createLocaleMiddleware<TLocale>(config): (request: I18nRequest) => MiddlewareResult<TLocale>`
Returns a reusable function that accepts `I18nRequest` and returns `MiddlewareResult`.
### `detectLocale<TLocale>(request, config): MiddlewareResult<TLocale>`
Performs the locale detection. Used internally by the middleware function.
### `getDirection(locale: string): TextDirection`
Returns `"rtl"` for RTL language codes, otherwise `"ltr"`.
### `I18nRequest`
```typescript
interface I18nRequest {
headers?: Record<string, string | string[] | undefined>;
query?: Record<string, string | string[] | undefined>;
cookies?: Record<string, string | undefined>;
path?: string;
}
```
### `MiddlewareResult<TLocale>`
```typescript
interface MiddlewareResult<TLocale> {
locale: TLocale;
source: "header" | "cookie" | "query" | "path" | "default";
direction: "ltr" | "rtl";
}
```
## Implementation Notes
- The `lookupFromHeader` function delegates to `negotiateLocale` from the negotiation module; it returns `undefined` (not the default) when the header resolves to the default locale, so the lookup order can continue checking other sources
- Cookie and query matching is case-insensitive but not normalised — `"FR"` in a cookie matches `"fr"` in the supported list
- `customExtractor` is checked before the standard lookup order; if it returns a supported locale, that locale is used with `source: "query"` as a placeholder source (callers who need the source can extend this)
- Path-prefix detection is disabled when `pathPrefix: false`; it is enabled by default

View File

@@ -0,0 +1,76 @@
# Backend Metrics
## Purpose
OpenTelemetry instruments for the `Backend` plugin family. Every concrete backend (`MemoryBackend`, `HttpBackend`, `StorageBackend`, `MultiFileFsBackend`, `ChainedBackend`) emits through this module so consumers see consistent attribute shapes regardless of the underlying source. The module also retains a lightweight in-memory state ring so spec-conformance tests can assert exact attribute payloads without standing up a full OTel SDK exporter pipeline.
## Features
- `i18n.backend.read.duration_ms` histogram — labels: `backend.name`, `locale`, `namespace`, `outcome` (`hit | miss | error`)
- `i18n.backend.read.total` counter — labels: `backend.name`, `outcome`
- `i18n.backend.write.total` counter — labels: `backend.name`, `outcome` (`ok | error`)
- `i18n.backend.cache.size` observable gauge — observes the catalog's in-memory `Map.size` via a registered provider callback
- All instruments are no-ops when no `MeterProvider` is registered (zero overhead by default)
- `getBackendMetricsState()` / `resetBackendMetricsState()` — test-only state inspection
## Usage: Basic Example
```typescript
import { recordRead, recordWrite } from "@suaveplan/i18n/observability";
// Inside a Backend.read implementation:
const t0 = performance.now();
try {
const record = await source.read(locale, namespace);
recordRead(this.name, locale, namespace, "hit", performance.now() - t0);
return record;
} catch (err) {
const outcome = err.code === "I18N.BACKEND_LOCALE_NOT_AVAILABLE" ? "miss" : "error";
recordRead(this.name, locale, namespace, outcome, performance.now() - t0);
throw err;
}
```
## Usage: Advanced Example — Test-Side State Inspection
```typescript
import {
getBackendMetricsState,
resetBackendMetricsState,
} from "@suaveplan/i18n/observability";
resetBackendMetricsState();
const backend = new StorageBackend({ storage: new MemoryStorage() });
await backend.save("en", "auth", { ok: "Ok" });
await backend.read("en", "auth");
const state = getBackendMetricsState();
expect(state.readDurationMsCount).toBe(1);
expect(state.readTotal[0]).toEqual({
attributes: { "backend.name": "storage", outcome: "hit" },
});
```
## API Reference
### `recordRead(backendName, locale, namespace, outcome, durationMs)`
Emits one sample on `i18n.backend.read.duration_ms` and one increment on `i18n.backend.read.total`. `outcome` MUST be one of `'hit' | 'miss' | 'error'`. Concrete backends call this exactly once per `read` invocation.
### `recordWrite(backendName, outcome)`
Increments `i18n.backend.write.total` by one. `outcome` MUST be `'ok' | 'error'`. Concrete backends call this exactly once per `save` invocation.
### `registerCacheSizeProvider(provider)`
Registers the catalog's in-memory map size provider. Called once from `MessageCatalog`'s constructor; the observable gauge invokes the provider every collection cycle.
### `getBackendMetricsState() / resetBackendMetricsState()`
Test-only handles. The state ring is **not** intended for production observability — for that, register a `MeterProvider` and use the OTel exporters.
## Implementation Notes
The module uses a single module-level `state` object as the test-side state ring. This is process-global and reset between tests via `resetBackendMetricsState()`. The OTel instruments themselves are also process-global, cached after first construction (`metrics.getMeter` returns the same meter for repeat calls).
The cache-size gauge uses an observable callback rather than a synchronous gauge because OTel's API only supports observable gauges. The callback consults `cacheSizeProvider` if registered (via `registerCacheSizeProvider`); otherwise it falls back to the in-memory `state.cacheSize` field, which is updated by tests directly.

View File

@@ -0,0 +1,113 @@
# Detector telemetry — `detector-metrics`
## Purpose
This module hosts the OTel instruments and an in-memory test ring for the
locale-detector chain. The instruments live here (rather than inside
`detector/chain/chain.ts`) so multiple call sites — chain runner, React
glue's `useLocaleDetection` hook, future custom detectors — can record
through one set of named instruments. The in-memory ring lets
spec-conformance tests assert on exact attribute payloads without
spinning up a full OTel SDK pipeline.
The instruments are built lazily via `metrics.getMeter('suaveplan.i18n')`
so packages that don't wire an SDK pay zero overhead.
## Features
- `i18n.detector.match_total{detector}` counter — incremented on every
detector match
- `i18n.detector.duration_ms{detector}` histogram — recorded for every
async detector execution
- `i18n.detect` span — opened by `createDetectorChain.detect` to wrap the
whole chain run
- `getDetectorMetrics()` — returns the cached instruments + tracer
- `recordDetectorMatch(detector)` / `recordDetectorDuration(detector, ms)`
— emit + push to in-memory state
- `getDetectorMetricsState()` / `resetDetectorMetricsState()` —
test-only state inspection helpers
- `resetDetectorMetricsCache()` — drop cached meter (for tests that swap
the global MeterProvider)
## Usage: Basic Example
```ts
import { recordDetectorMatch } from "@suaveplan/i18n";
// From inside a custom detector wrapper:
recordDetectorMatch("my-custom-detector");
```
## Usage: Advanced Example
Asserting on emitted samples in a test:
```ts
import {
getDetectorMetricsState,
recordDetectorMatch,
recordDetectorDuration,
resetDetectorMetricsState,
} from "@suaveplan/i18n";
import { afterEach, expect, it } from "@suaveplan/testing/runner";
afterEach(() => {
resetDetectorMetricsState();
});
it("emits a match counter with the winning detector name", () => {
recordDetectorMatch("cookie");
recordDetectorDuration("user-preference", 42);
const state = getDetectorMetricsState();
expect(state.matchTotal).toEqual([{ attributes: { detector: "cookie" } }]);
expect(state.durationMs[0]?.value).toBe(42);
});
```
## API Reference
### `function getDetectorMetrics(): { tracer, matchTotal, durationMs }`
Returns the cached OTel instruments. Builds them on first call via
`metrics.getMeter('suaveplan.i18n')` and `trace.getTracer('suaveplan.i18n')`.
Idempotent.
### `function recordDetectorMatch(detector: string): void`
Increments `i18n.detector.match_total{detector}` by one and appends a
sample to the in-memory state ring.
### `function recordDetectorDuration(detector: string, durationMs: number): void`
Records a sample on `i18n.detector.duration_ms{detector}` and appends a
sample to the state ring.
### `function getDetectorMetricsState(): DetectorMetricsState`
Returns the live (mutable but stable-reference) state ring. Tests should
call `resetDetectorMetricsState()` before reading.
### `function resetDetectorMetricsState(): void`
Truncates both arrays in the state ring. Safe to call from
`beforeEach` / `afterEach`.
### `function resetDetectorMetricsCache(): void`
Drops the cached instrument bundle so the next `getDetectorMetrics()` call
rebuilds against the currently-registered MeterProvider. Use only in
tests that swap the global MeterProvider mid-suite.
## Implementation Notes
The state ring is a single shared mutable object — fine for tests where
each case resets between runs, but unsuitable for production analytics
because it never bounds memory. The design tradeoff is intentional: the
ring exists *only* to make spec-conformance assertions cheap. Production
observability runs through the OTel pipeline.
The cached instruments bundle includes the tracer too; `chain.ts` opens
its `i18n.detect` span via `tracer.startSpan(...)` so test runs have a
single place to wire a `TracerProvider` and observe the resulting span
tree.

View File

@@ -0,0 +1,59 @@
# OpenTelemetry Metrics
## Purpose
`@suaveplan/i18n/observability/metrics` is the package's instrumentation surface. It exposes a lazily-built singleton holding the three OpenTelemetry instruments mandated by the i18n capability spec: `i18n.translate.total`, `i18n.locale.resolve.total`, and `i18n.translate.duration_ms`. The singleton is wired into `MessageCatalog.get` and the locale-detection middleware so consumers get production telemetry without writing any additional code.
The instruments are obtained from the global `metrics` API (`@opentelemetry/api`), which means they are **no-ops by default** — recording incurs effectively zero cost when the application has not registered a `MeterProvider`. Wiring up an SDK (for example `@opentelemetry/sdk-node`) immediately starts capturing real measurements without changing any i18n code.
## Features
- One `getMeter('suaveplan.i18n')` per process; cached after first call.
- Counter `i18n.translate.total` with attributes `{ outcome: 'hit' | 'miss', locale }`.
- Counter `i18n.locale.resolve.total` with attributes `{ strategy, locale }`.
- Histogram `i18n.translate.duration_ms` with attribute `{ locale }`, unit `"ms"`.
- `resetI18nMetrics()` test hook for swapping in a fresh `MeterProvider` between assertions.
## Usage: Basic Example
```ts
import { MessageCatalog } from "@suaveplan/i18n";
const catalog = new MessageCatalog({
defaultLocale: "en",
messages: { en: { home: { title: "Welcome" } } },
});
// No meter provider registered → counter increments are no-ops.
catalog.get("home.title");
```
## Usage: Advanced Example
```ts
import { metrics } from "@opentelemetry/api";
import { MeterProvider, InMemoryMetricReader } from "@opentelemetry/sdk-metrics";
import { resetI18nMetrics } from "@suaveplan/i18n/observability/metrics";
const reader = new InMemoryMetricReader();
metrics.setGlobalMeterProvider(new MeterProvider({ readers: [reader] }));
resetI18nMetrics();
// All subsequent MessageCatalog.get / detectLocale calls record real metrics.
```
## API Reference
### `getI18nMetrics(): I18nMetrics`
Returns the cached singleton `{ translateTotal, localeResolveTotal, translateDuration }`. First call constructs the instruments via `metrics.getMeter('suaveplan.i18n')`; subsequent calls return the same object.
### `resetI18nMetrics(): void`
Drops the cache so the next `getI18nMetrics()` call rebuilds against the current global `MeterProvider`. Use only in tests; production code should not invalidate the cache.
## Implementation Notes
- The singleton is module-scoped, not per-class, so all consumers within a process share the same `Meter` and the global SDK only sees one set of instrument registrations.
- `Counter` and `Histogram` are imported as types only — the runtime API uses the global `metrics` proxy, which is the recommended pattern from the OpenTelemetry JS authors for libraries that should not bundle the SDK.
- No-op behaviour is guaranteed by `@opentelemetry/api`'s built-in fallback `NoopMeterProvider`; we do not need to guard call sites.

View File

@@ -0,0 +1,123 @@
# `defaultFormatBridge`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`defaultFormatBridge` is the slim-core wiring between
`@suaveplan/i18n-icu-parser`'s `MessageParser` renderer and the
per-formatter packages shipped in Wave 3 of the i18n modular split. It
implements every method on the `FormatBridge` contract — `number`,
`currency`, `date`, `time`, `relativeTime` — by delegating to
`@suaveplan/i18n-formatter-number`, `-currency`, `-date`, and
`-relative-time`. Consumers that build an `I18nInstance` via
`createI18n({ ... })` get locale-aware ICU placeholder rendering
without any manual bridge wiring; advanced consumers who need a
reduced or specialised bridge can pass their own through the parser's
`RenderOptions.formatBridge` slot.
## Features
- Full coverage of the `FormatBridge` contract (5 methods)
- Frozen singleton — accidental mutation surfaces as a `TypeError`
- Coerces `Date | number` (epoch-millis) inputs to `Date` for the date
and time methods — the underlying formatters require `Date`
- Maps ICU number sub-styles (`decimal`, `percent`, `integer`,
`compact`, `scientific`, `engineering`) onto the formatter's option
bundles
- Defaults the date `style` to `"medium"` to match the monolith's
pre-split behaviour
- Re-exports `formatDateOnly` from `@suaveplan/i18n-formatter-date` for
consumers that previously imported it from the in-tree
`formatting/date` module
## Usage: Basic Example
```ts
import { createI18n } from "@suaveplan/i18n";
import { MessageParser } from "@suaveplan/i18n";
import { defaultFormatBridge } from "@suaveplan/i18n";
const parser = new MessageParser();
const parsed = parser.parse("Hello, you have {n, number} messages.");
const rendered = parsed.format(
{ n: 1234.5 },
{ locale: "en-US", formatBridge: defaultFormatBridge }
);
// "Hello, you have 1,234.5 messages."
```
## Usage: Advanced Example
Construct a custom bridge that overrides only the methods you care
about, falling back to the default for the rest:
```ts
import { defaultFormatBridge } from "@suaveplan/i18n";
import type { FormatBridge } from "@suaveplan/i18n";
import { renderNodes, MessageParser } from "@suaveplan/i18n";
const parser = new MessageParser();
const parsed = parser.parse("Bonjour {n, number, percent}");
const customBridge: FormatBridge = {
...defaultFormatBridge,
number(value, locale, style) {
// Force fr-CA grouping regardless of caller-supplied locale
return defaultFormatBridge.number?.(value, "fr-CA", style) ??
String(value);
},
};
const out = renderNodes(
parsed.nodes,
{ n: 0.42 },
{ locale: "en-US", formatBridge: customBridge }
);
```
## API Reference
### `defaultFormatBridge: Readonly<FormatBridge>`
A frozen object implementing every method on the `FormatBridge`
contract. Each method delegates to the corresponding formatter package:
- `number(value, locale, style?)` — `formatNumber` with style mapping
- `currency(value, currency, locale)` — `formatCurrency`
- `date(value, locale, style?)` — `formatDate` with `preset` defaulted
to `"medium"`
- `time(value, locale, longForm?)` — `formatTime` with `long`
parameter
- `relativeTime(value, locale, style?)` — `formatRelativeTime` with
optional `style` option
### `formatDateOnly(date, locale, long?)`
Re-exported from `@suaveplan/i18n-formatter-date`. Convenience for
date-only formatting without the time portion.
## Implementation Notes
- The bridge is `Object.freeze`d at module load time. Any `import`
receives the same singleton; reassigning a method on the import
throws a `TypeError` in strict mode (which is the runtime mode for
every ESM consumer).
- Number style mapping deliberately falls through unknown styles to
the formatter's default decimal output rather than throwing — this
matches the pre-split behaviour where `mapNumberStyle` returned the
raw string and the formatter ignored unknown notations.
- `Date | number` coercion uses `new Date(value)` for non-`Date`
inputs. Invalid epoch-millis (`NaN`, non-finite) produces an
`Invalid Date` that the formatters surface as locale-specific
"Invalid Date" output rather than throwing — preserving the
monolith's never-throws-on-format invariant.
- The bridge does not cache — each formatter package owns its own
per-locale cache (`Intl.NumberFormat`, `Intl.DateTimeFormat`,
`Intl.RelativeTimeFormat`). Calling the bridge twice with the same
arguments hits each formatter's cache on the second call.
- `time` always passes `long` as a strict boolean. `longForm === true`
produces the long form (with seconds); any other input — including
`undefined` — produces the short form. This matches the
parser's documented `time` style mapping (`long` is opt-in).

View File

@@ -0,0 +1,144 @@
# `createMissingKeyReporter`
## Purpose
A development-time plugin that subscribes to the `missingKey` event on
an `I18nInstance` and forwards every miss to a logger, callback, or
the underlying backend. The reporter is the standard tool for
discovering untranslated keys during development without paying the
runtime cost of strict-mode lookups in production. It composes with
the slim core's plugin slot — register it once with
`createI18n({ plugins: [createMissingKeyReporter(...)] })` and every
`catalog.get` miss becomes a structured warning.
## Features
- Default behaviour: emits a `warn`-level log line per missing key,
carrying the full payload (`locale`, `namespace`, `key`,
`defaultValue`) as structured context
- Custom logger injection via `options.logger` — accepts any object
with a `warn(message, context?)` method, including `console`,
`@suaveplan/logger`, or a translation-management-system client
- Custom callback via `options.onMissing` — receives the same payload
as the event so consumers can route to TMS providers (Phrase,
Lokalise, Crowdin) or alerting systems
- Best-effort backend write-back via `options.persistMissing: true` —
invokes `i18n.catalog.saveMissingKey` so the default value lands in
the backend for translator pickup. Requires the catalog to be
constructed with `saveMissing: true` AND a backend with a `save`
method. The reporter does not enforce these preconditions; it
delegates entirely to the catalog's best-effort contract.
- Pure plugin shape — the reporter installs in `O(1)` time and adds
zero overhead to the hot translate path. Every miss already emits
an event; the reporter is a passive listener.
## Usage: Basic Example
```ts
import { createI18n } from "@suaveplan/i18n";
import { createMissingKeyReporter } from "@suaveplan/i18n";
const i18n = createI18n({
defaultLocale: "en",
messages: { en: { hi: "Hello" } },
plugins: [createMissingKeyReporter()],
});
i18n.t("missing.key");
// → console.warn(
// "[i18n] missing key 'missing.key' for locale 'en'",
// { locale: 'en', namespace: '', key: 'missing.key' }
// )
```
## Usage: Advanced Example
Forward every miss to a TMS provider AND persist the default value
back to a Memory backend so the next page load resolves the key:
```ts
import { createI18n } from "@suaveplan/i18n";
import { createMissingKeyReporter } from "@suaveplan/i18n";
import { MemoryBackend } from "@suaveplan/i18n";
const tms = {
sendMissing(payload: {
locale: string;
key: string;
defaultValue?: string;
}) {
return fetch("https://tms.example/missing", {
method: "POST",
body: JSON.stringify(payload),
});
},
};
const i18n = createI18n({
defaultLocale: "en",
backend: new MemoryBackend(),
saveMissing: true,
plugins: [
createMissingKeyReporter({
persistMissing: true,
onMissing: (payload) => {
void tms.sendMissing({
locale: payload.locale,
key: payload.key,
defaultValue: payload.defaultValue,
});
},
}),
],
});
```
## API Reference
### `createMissingKeyReporter(options?: MissingKeyReporterOptions): I18nPlugin`
Returns an `I18nPlugin` named `"missing-key-reporter"` with the
`"logger"` kind tag. Install via `i18n.use(plugin)` or the
`createI18n({ plugins })` shortcut. Subsequent `catalog.get` misses
trigger the configured behaviour.
### `MissingKeyReporterOptions`
- `logger?: MissingKeyReporterLogger` — defaults to `console`. Any
shape with `warn(message: string, context?: Record<string, unknown>)`
satisfies the contract.
- `onMissing?: (payload: { locale, namespace, key, defaultValue? }) => void`
— when present, replaces the default `logger.warn` call so consumers
can route to TMS / alerting / observability without double-logging.
- `persistMissing?: boolean` — when `true`, additionally calls
`i18n.catalog.saveMissingKey(locale, key, defaultValue ?? key)`.
Requires `saveMissing: true` on the catalog AND a backend with
`save` support.
### `MissingKeyReporterLogger`
```ts
interface MissingKeyReporterLogger {
warn(message: string, context?: Record<string, unknown>): void;
}
```
## Implementation Notes
- `onMissing` is mutually exclusive with the default `logger.warn`
path. Consumers that want both behaviours can call `console.warn`
inside their `onMissing` callback explicitly.
- `persistMissing` swallows errors via the catalog's best-effort
contract (see `MessageCatalog.saveMissingKey` — backend errors
surface as a `recordWrite(name, "error")` OTel counter increment
but never propagate).
- Throw-on-missing is OUT OF SCOPE for this reporter. Use
`MessageCatalog`'s `strict: true` option to throw `I18nError(MISSING_KEY)`
on every miss; the reporter is purely observational.
- The reporter does not deduplicate misses. Every event triggers one
invocation. If a key is requested 1,000 times across a render pass,
the reporter logs/forwards 1,000 times. Layer a simple `Set` of
seen keys inside `onMissing` if dedup is required.
- The plugin is idempotent at registration: installing it twice
throws `I18nError(PLUGIN_ALREADY_INSTALLED)` because every
`I18nPlugin.name` must be unique on a single instance.

View File

@@ -0,0 +1,61 @@
# Pseudo-Localization
## Purpose
Layout-stress transform for translated strings. Three modes deliberately produce non-translated output that exercises font fallback (`'accent'`), visually flags every un-translated string in QA (`'bracket'`), and tests layout overflow (`'expand'`). Use during development with a feature flag, or via the `pseudoLocalizationPlugin` post-processor on an `I18nInstance` so every `t()` call routes through it.
This is the standard layout-bug-finding workflow — i18next, FormatJS, LinguiJS all ship a similar primitive. The "accent" mode produces text like `Wéłçömé` that's still legible to engineers but immediately reveals which strings are actually translatable in the UI vs. which are hardcoded. "Bracket" mode wraps every translated string in `[!! ... !!]` markers — perfect for ride-along visual QA. "Expand" mode pads strings by a configurable ratio so layout-overflow bugs surface during development rather than after a German release.
## Features
- Three modes: `'accent'` (default), `'bracket'`, `'expand'`
- `expandRatio` option for `'expand'` mode (default 0.3 → 30% padding)
- Pure function — no state, no side effects
- ASCII letters mapped to diacritic equivalents using a 52-entry table (covers `a-z` and `A-Z`)
- Non-letters (digits, punctuation, whitespace, emoji) pass through unchanged
## Usage: Basic Example
```typescript
import { pseudoLocalize } from "@suaveplan/i18n/post-processors";
pseudoLocalize("Welcome"); // → "Wéłçömé"
pseudoLocalize("Welcome", { mode: "bracket" }); // → "[!! Welcome !!]"
pseudoLocalize("Hi", { mode: "expand", expandRatio: 2 }); // → "Hi~~~~"
```
## Usage: Advanced Example — Plugin Form (planned)
```typescript
import { createI18n } from "@suaveplan/i18n";
import { pseudoLocalizationPlugin } from "@suaveplan/i18n/post-processors";
const i18n = createI18n({ defaultLocale: "en", ... });
if (process.env.PSEUDO_LOCALIZE === "1") {
i18n.use(pseudoLocalizationPlugin({ mode: "bracket" }));
}
i18n.t("home.title"); // → "[!! Welcome !!]"
```
The plugin form lands as part of the `I18nInstance` plugin lifecycle (§8 of `i18n-completion`).
## API Reference
### `pseudoLocalize(text, options?): string`
Applies the chosen transform. Default mode is `'accent'`.
### `PseudoLocalizeOptions`
- `mode?: 'accent' | 'bracket' | 'expand'` (default `'accent'`)
- `expandRatio?: number` (default `0.3`; only used when `mode === 'expand'`)
## Implementation Notes
The accent table maps ASCII letters one-to-one to common Latin-script diacritic variants. The mapping is deliberately not exhaustive — uppercase `J` maps to `Ĵ` rather than the more diacritic-heavy `J̈` because the latter is two code points and breaks string-length computations. Where a single-codepoint diacritic exists, we use it; otherwise the letter passes through.
Bracket mode markers (`[!! ... !!]`) are an industry convention dating to LinguiJS / FormatJS pseudo-locale tooling. Choose your own markers if you need URL-safe variants — but the engineering team you're handing off to will likely already recognise the standard form.
Expand mode pads with `~` characters because they're visually obvious in any font and don't introduce false-positive layout breaks. The padding sits *after* the original text so screen-reader output for QA still announces the original string.

Some files were not shown because too many files have changed in this diff Show More