first commit

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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