first commit
This commit is contained in:
35
docs/localization/README.md
Normal file
35
docs/localization/README.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# `localization`
|
||||
|
||||
Internationalization, localization, locale detection, and translation infrastructure.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Subcategories
|
||||
|
||||
1 subcategory, 19 packages.
|
||||
|
||||
### `i18n`
|
||||
|
||||
Internationalization core, backends, detectors, and locale resolution.
|
||||
|
||||
| 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 -->
|
||||
3
docs/localization/_intro.md
Normal file
3
docs/localization/_intro.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# `localization`
|
||||
|
||||
Internationalization, localization, locale detection, and translation infrastructure.
|
||||
31
docs/localization/i18n/README.md
Normal file
31
docs/localization/i18n/README.md
Normal 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 -->
|
||||
1
docs/localization/i18n/_intro.md
Normal file
1
docs/localization/i18n/_intro.md
Normal file
@@ -0,0 +1 @@
|
||||
Internationalization core, backends, detectors, and locale resolution.
|
||||
45
docs/localization/i18n/i18n-backend-chained/README.md
Normal file
45
docs/localization/i18n/i18n-backend-chained/README.md
Normal 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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
57
docs/localization/i18n/i18n-backend-fs-multifile/README.md
Normal file
57
docs/localization/i18n/i18n-backend-fs-multifile/README.md
Normal 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).
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
96
docs/localization/i18n/i18n-backend-http/README.md
Normal file
96
docs/localization/i18n/i18n-backend-http/README.md
Normal 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`.
|
||||
|
||||
119
docs/localization/i18n/i18n-backend-http/src/backend/backend.md
Normal file
119
docs/localization/i18n/i18n-backend-http/src/backend/backend.md
Normal 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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
121
docs/localization/i18n/i18n-backend-memory/README.md
Normal file
121
docs/localization/i18n/i18n-backend-memory/README.md
Normal 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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
130
docs/localization/i18n/i18n-backend-storage/README.md
Normal file
130
docs/localization/i18n/i18n-backend-storage/README.md
Normal 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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
154
docs/localization/i18n/i18n-detector/README.md
Normal file
154
docs/localization/i18n/i18n-detector/README.md
Normal 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
|
||||
|
||||
@@ -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.
|
||||
105
docs/localization/i18n/i18n-detector/src/chain/chain.md
Normal file
105
docs/localization/i18n/i18n-detector/src/chain/chain.md
Normal 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.
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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".
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
104
docs/localization/i18n/i18n-formatter-collator/README.md
Normal file
104
docs/localization/i18n/i18n-formatter-collator/README.md
Normal 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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
119
docs/localization/i18n/i18n-formatter-currency/README.md
Normal file
119
docs/localization/i18n/i18n-formatter-currency/README.md
Normal 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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
128
docs/localization/i18n/i18n-formatter-date/README.md
Normal file
128
docs/localization/i18n/i18n-formatter-date/README.md
Normal 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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
106
docs/localization/i18n/i18n-formatter-display-name/README.md
Normal file
106
docs/localization/i18n/i18n-formatter-display-name/README.md
Normal 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.
|
||||
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
107
docs/localization/i18n/i18n-formatter-list/README.md
Normal file
107
docs/localization/i18n/i18n-formatter-list/README.md
Normal 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.
|
||||
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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.*`.
|
||||
113
docs/localization/i18n/i18n-formatter-number/README.md
Normal file
113
docs/localization/i18n/i18n-formatter-number/README.md
Normal 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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
109
docs/localization/i18n/i18n-formatter-relative-time/README.md
Normal file
109
docs/localization/i18n/i18n-formatter-relative-time/README.md
Normal 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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
111
docs/localization/i18n/i18n-formatter-segmenter/README.md
Normal file
111
docs/localization/i18n/i18n-formatter-segmenter/README.md
Normal 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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
182
docs/localization/i18n/i18n-icu-parser/README.md
Normal file
182
docs/localization/i18n/i18n-icu-parser/README.md
Normal 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.
|
||||
|
||||
@@ -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
|
||||
`<`/`>` 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>");
|
||||
// "<b>bold</b>"
|
||||
```
|
||||
|
||||
## 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'm "the boss" & <admin>">…</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 |
|
||||
|---|---|
|
||||
| `&` | `&` |
|
||||
| `<` | `<` |
|
||||
| `>` | `>` |
|
||||
| `"` | `"` |
|
||||
| `'` | `'` |
|
||||
|
||||
**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 (`<`, `>`, `"`, `'`) 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 `'` rather
|
||||
than the named entity `'` 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 `'` per char).
|
||||
@@ -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.
|
||||
@@ -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: <script>alert('xss')</script>"
|
||||
```
|
||||
|
||||
## 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
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
99
docs/localization/i18n/i18n-icu-parser/src/parser/message.md
Normal file
99
docs/localization/i18n/i18n-icu-parser/src/parser/message.md
Normal 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)
|
||||
@@ -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
|
||||
114
docs/localization/i18n/i18n-shared/README.md
Normal file
114
docs/localization/i18n/i18n-shared/README.md
Normal 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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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"`.
|
||||
109
docs/localization/i18n/i18n-shared/src/errors/error-codes.md
Normal file
109
docs/localization/i18n/i18n-shared/src/errors/error-codes.md
Normal 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.
|
||||
106
docs/localization/i18n/i18n-shared/src/errors/i18n-error.md
Normal file
106
docs/localization/i18n/i18n-shared/src/errors/i18n-error.md
Normal 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.
|
||||
129
docs/localization/i18n/i18n-shared/src/testing/conformance.md
Normal file
129
docs/localization/i18n/i18n-shared/src/testing/conformance.md
Normal 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.
|
||||
180
docs/localization/i18n/i18n/README.md
Normal file
180
docs/localization/i18n/i18n/README.md
Normal 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
|
||||
|
||||
127
docs/localization/i18n/i18n/src/core/catalog/catalog.md
Normal file
127
docs/localization/i18n/i18n/src/core/catalog/catalog.md
Normal 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
|
||||
92
docs/localization/i18n/i18n/src/core/plugin/instance.md
Normal file
92
docs/localization/i18n/i18n/src/core/plugin/instance.md
Normal 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.
|
||||
@@ -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`.
|
||||
101
docs/localization/i18n/i18n/src/locale/resolver/resolver.md
Normal file
101
docs/localization/i18n/i18n/src/locale/resolver/resolver.md
Normal 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
|
||||
100
docs/localization/i18n/i18n/src/middleware/server/server.md
Normal file
100
docs/localization/i18n/i18n/src/middleware/server/server.md
Normal 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
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
59
docs/localization/i18n/i18n/src/observability/metrics.md
Normal file
59
docs/localization/i18n/i18n/src/observability/metrics.md
Normal 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.
|
||||
@@ -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).
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user