first commit
This commit is contained in:
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.
|
||||
Reference in New Issue
Block a user