Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 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,110 @@
{
"name": "@suaveplan/i18n-shared",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "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.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./constants": {
"import": {
"types": "./dist/constants/index.d.ts",
"default": "./dist/constants/index.js"
}
},
"./constants/locale-bcp47-map": {
"import": {
"types": "./dist/constants/locale-bcp47-map.d.ts",
"default": "./dist/constants/locale-bcp47-map.js"
}
},
"./constants/rtl-locales": {
"import": {
"types": "./dist/constants/rtl-locales.d.ts",
"default": "./dist/constants/rtl-locales.js"
}
},
"./constants/locale-descriptors": {
"import": {
"types": "./dist/constants/locale-descriptors.d.ts",
"default": "./dist/constants/locale-descriptors.js"
}
},
"./constants/interpolation-defaults": {
"import": {
"types": "./dist/constants/interpolation-defaults.d.ts",
"default": "./dist/constants/interpolation-defaults.js"
}
},
"./errors": {
"import": {
"types": "./dist/errors/index.d.ts",
"default": "./dist/errors/index.js"
}
},
"./testing": {
"import": {
"types": "./dist/testing/index.d.ts",
"default": "./dist/testing/index.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/error": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build"
},
"peerDependencies": {
"@suaveplan/testing": "workspace:^"
},
"peerDependenciesMeta": {
"@suaveplan/testing": {
"optional": true
}
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

View File

@@ -0,0 +1,77 @@
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "@suaveplan/testing/runner";
/**
* Documented byte budget for the entire src/ tree.
*
* The shared package is the smallest runtime unit in the i18n family:
* a handful of constants + a single error class. Every leaf depends
* on it, so a regression here multiplies through every consumer.
*
* Budget: 16 KiB total raw source (uncompressed) across every .ts
* file under src/ excluding tests. This intentionally generous
* ceiling catches accidental fat (e.g. inlining the entire CLDR
* locale list) while leaving headroom for the four new error codes
* the per-package split added.
*/
const BYTE_BUDGET = 16 * 1024;
const PACKAGE_JSON_PATH = fileURLToPath(
new URL("../package.json", import.meta.url)
);
const SRC_FILES = [
"./constants/locale-bcp47-map.ts",
"./constants/rtl-locales.ts",
"./constants/locale-descriptors.ts",
"./constants/interpolation-defaults.ts",
"./constants/index.ts",
"./errors/error-codes.ts",
"./errors/i18n-error.ts",
"./errors/index.ts",
"./index.ts",
];
describe("byte-size budget", () => {
it("total src/ byte size stays under documented budget", {
timeout: 5000,
}, () => {
let total = 0;
for (const relative of SRC_FILES) {
const path = fileURLToPath(new URL(relative, import.meta.url));
const bytes = readFileSync(path);
total += bytes.byteLength;
}
expect(total).toBeLessThanOrEqual(BYTE_BUDGET);
});
});
describe("package.json invariants", () => {
it("package.json declares sideEffects false", { timeout: 5000 }, () => {
const raw = readFileSync(PACKAGE_JSON_PATH, "utf8");
const pkg = JSON.parse(raw) as { sideEffects?: boolean };
expect(pkg.sideEffects).toBe(false);
});
it("package.json declares every documented subpath export", {
timeout: 5000,
}, () => {
const raw = readFileSync(PACKAGE_JSON_PATH, "utf8");
const pkg = JSON.parse(raw) as { exports?: Record<string, unknown> };
const exports = pkg.exports ?? {};
const required = [
".",
"./constants",
"./constants/locale-bcp47-map",
"./constants/rtl-locales",
"./constants/locale-descriptors",
"./constants/interpolation-defaults",
"./errors",
"./testing",
];
for (const subpath of required) {
expect(exports).toHaveProperty(subpath);
}
});
});

View File

@@ -0,0 +1,10 @@
// Explicit named re-exports — no `export *` per CLAUDE.md §10.
export { DEFAULT_INTERPOLATION } from "./interpolation-defaults.js";
export { LOCALE_BCP47_MAP } from "./locale-bcp47-map.js";
export {
DEFAULT_LOCALES,
type DefaultLocale,
LOCALE_DESCRIPTORS,
} from "./locale-descriptors.js";
export { RTL_LOCALE_LIST, RTL_LOCALES } from "./rtl-locales.js";

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,30 @@
import { describe, expect, expectTypeOf, it } from "@suaveplan/testing/runner";
import { DEFAULT_INTERPOLATION } from "./interpolation-defaults.js";
describe("DEFAULT_INTERPOLATION", () => {
it("default prefix is {{", { timeout: 5000 }, () => {
expect(DEFAULT_INTERPOLATION.prefix).toBe("{{");
});
it("default suffix is }}", { timeout: 5000 }, () => {
expect(DEFAULT_INTERPOLATION.suffix).toBe("}}");
});
it("default maxDepth is 10", { timeout: 5000 }, () => {
expect(DEFAULT_INTERPOLATION.maxDepth).toBe(10);
});
it("DEFAULT_INTERPOLATION is narrowed via as const", {
timeout: 5000,
}, () => {
expectTypeOf<
typeof DEFAULT_INTERPOLATION.prefix
>().toEqualTypeOf<"{{">();
expectTypeOf<
typeof DEFAULT_INTERPOLATION.suffix
>().toEqualTypeOf<"}}">();
expectTypeOf<
typeof DEFAULT_INTERPOLATION.maxDepth
>().toEqualTypeOf<10>();
});
});

View File

@@ -0,0 +1,19 @@
/**
* Default delimiters and nesting limit for the ICU interpolation
* engine.
*
* The defaults are intentionally Mustache-compatible (`{{` / `}}`)
* so a catalog authored against any popular i18n format can be
* loaded without rewriting placeholders. The `maxDepth` ceiling
* exists to short-circuit pathological self-referencing nests
* (e.g. `{{a}}` resolving to `{{a}}`); the parser will throw
* `I18N.NESTING_DEPTH_EXCEEDED` when the depth is exceeded.
*
* The shape is `as const` so consumers can narrow the prefix /
* suffix string-literals at the type level for branded types.
*/
export const DEFAULT_INTERPOLATION = {
prefix: "{{",
suffix: "}}",
maxDepth: 10,
} as const;

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,52 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { LOCALE_BCP47_MAP } from "./locale-bcp47-map.js";
describe("LOCALE_BCP47_MAP", () => {
it("short code en maps to en-GB", { timeout: 5000 }, () => {
expect(LOCALE_BCP47_MAP["en"]).toBe("en-GB");
});
it("mixed-case zh-cn maps to zh-CN", { timeout: 5000 }, () => {
expect(LOCALE_BCP47_MAP["zh-cn"]).toBe("zh-CN");
});
it("short code zh maps to zh-CN", { timeout: 5000 }, () => {
expect(LOCALE_BCP47_MAP["zh"]).toBe("zh-CN");
});
it("pt-br maps to pt-BR", { timeout: 5000 }, () => {
expect(LOCALE_BCP47_MAP["pt-br"]).toBe("pt-BR");
});
it("RTL languages map to canonical region tags", { timeout: 5000 }, () => {
expect(LOCALE_BCP47_MAP["ar"]).toBe("ar-SA");
expect(LOCALE_BCP47_MAP["he"]).toBe("he-IL");
expect(LOCALE_BCP47_MAP["fa"]).toBe("fa-IR");
});
it("returns undefined for unknown codes (consumer handles miss)", {
timeout: 5000,
}, () => {
expect(LOCALE_BCP47_MAP["xx-yy"]).toBeUndefined();
expect(LOCALE_BCP47_MAP["klingon"]).toBeUndefined();
});
it("every value follows the BCP-47 lang-REGION shape", {
timeout: 5000,
}, () => {
const bcp47Pattern = /^[a-z]{2,3}-[A-Z]{2}$/;
for (const value of Object.values(LOCALE_BCP47_MAP)) {
expect(value).toMatch(bcp47Pattern);
}
});
it("every key is lowercase", { timeout: 5000 }, () => {
for (const key of Object.keys(LOCALE_BCP47_MAP)) {
expect(key).toBe(key.toLowerCase());
}
});
it("contains the documented 22 entries", { timeout: 5000 }, () => {
expect(Object.keys(LOCALE_BCP47_MAP)).toHaveLength(22);
});
});

View File

@@ -0,0 +1,37 @@
/**
* Map from short / internal locale codes (and common mixed-case forms)
* to canonical BCP-47 locale tags.
*
* Used by every formatter (Intl.* wrappers) and the catalog before
* forwarding a locale string to the runtime so the underlying Intl
* implementation always receives a canonical, non-ambiguous tag.
*
* Lookups for unknown codes pass through unchanged at the consumer
* level — the constant is a plain `Record`, so consumers handle the
* miss themselves (typically by treating the input as an already-
* canonical tag).
*/
export const LOCALE_BCP47_MAP: Record<string, string> = {
en: "en-GB",
es: "es-ES",
fr: "fr-FR",
de: "de-DE",
cy: "cy-GB",
zh: "zh-CN",
ar: "ar-SA",
ru: "ru-RU",
pl: "pl-PL",
ja: "ja-JP",
ko: "ko-KR",
pt: "pt-PT",
it: "it-IT",
nl: "nl-NL",
he: "he-IL",
fa: "fa-IR",
"en-gb": "en-GB",
"en-us": "en-US",
"cy-gb": "cy-GB",
"zh-cn": "zh-CN",
"zh-tw": "zh-TW",
"pt-br": "pt-BR",
};

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,68 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { DEFAULT_LOCALES, LOCALE_DESCRIPTORS } from "./locale-descriptors.js";
import { RTL_LOCALES } from "./rtl-locales.js";
describe("DEFAULT_LOCALES", () => {
it("contains the canonical 16 codes", { timeout: 5000 }, () => {
expect(DEFAULT_LOCALES).toHaveLength(16);
});
it("starts with en", { timeout: 5000 }, () => {
expect(DEFAULT_LOCALES[0]).toBe("en");
});
});
describe("LOCALE_DESCRIPTORS", () => {
it("every DEFAULT_LOCALES code has a descriptor", {
timeout: 5000,
}, () => {
for (const code of DEFAULT_LOCALES) {
const descriptor = LOCALE_DESCRIPTORS[code];
expect(descriptor).toBeDefined();
}
});
it("ar descriptor direction is rtl", { timeout: 5000 }, () => {
expect(LOCALE_DESCRIPTORS["ar"]?.direction).toBe("rtl");
});
it("he descriptor direction is rtl", { timeout: 5000 }, () => {
expect(LOCALE_DESCRIPTORS["he"]?.direction).toBe("rtl");
});
it("fa descriptor direction is rtl", { timeout: 5000 }, () => {
expect(LOCALE_DESCRIPTORS["fa"]?.direction).toBe("rtl");
});
it("en descriptor direction is ltr", { timeout: 5000 }, () => {
expect(LOCALE_DESCRIPTORS["en"]?.direction).toBe("ltr");
});
it("every descriptor nativeName is non-empty", {
timeout: 5000,
}, () => {
for (const descriptor of Object.values(LOCALE_DESCRIPTORS)) {
expect(descriptor.nativeName.length).toBeGreaterThan(0);
}
});
it("every descriptor name is non-empty", { timeout: 5000 }, () => {
for (const descriptor of Object.values(LOCALE_DESCRIPTORS)) {
expect(descriptor.name.length).toBeGreaterThan(0);
}
});
it("descriptor.direction agrees with RTL_LOCALES membership", {
timeout: 5000,
}, () => {
for (const code of DEFAULT_LOCALES) {
const descriptor = LOCALE_DESCRIPTORS[code];
const expected = RTL_LOCALES.has(code) ? "rtl" : "ltr";
expect(descriptor?.direction).toBe(expected);
}
});
it("returns undefined for unknown codes", { timeout: 5000 }, () => {
expect(LOCALE_DESCRIPTORS["xx"]).toBeUndefined();
});
});

View File

@@ -0,0 +1,58 @@
/**
* Ordered list of built-in supported locale codes shipped with the
* Suaveplan i18n family. The order is the canonical default order
* used by the locale detector when no caller-supplied preference list
* is available.
*/
export const DEFAULT_LOCALES = [
"en",
"es",
"fr",
"de",
"cy",
"zh",
"ar",
"ru",
"pl",
"ja",
"ko",
"pt",
"it",
"nl",
"he",
"fa",
] as const;
/** String-literal union of every code in {@link DEFAULT_LOCALES}. */
export type DefaultLocale = (typeof DEFAULT_LOCALES)[number];
/**
* Per-locale display metadata: English `name`, locale-native
* `nativeName`, and the canonical text `direction`.
*
* Every entry in {@link DEFAULT_LOCALES} has a corresponding
* descriptor here. The `direction` field SHALL agree with the
* locale's membership in `RTL_LOCALES` — a unit test asserts the
* cross-file invariant.
*/
export const LOCALE_DESCRIPTORS: Record<
string,
{ name: string; nativeName: string; direction: "ltr" | "rtl" }
> = {
en: { name: "English", nativeName: "English", direction: "ltr" },
es: { name: "Spanish", nativeName: "Español", direction: "ltr" },
fr: { name: "French", nativeName: "Français", direction: "ltr" },
de: { name: "German", nativeName: "Deutsch", direction: "ltr" },
cy: { name: "Welsh", nativeName: "Cymraeg", direction: "ltr" },
zh: { name: "Chinese", nativeName: "中文", direction: "ltr" },
ar: { name: "Arabic", nativeName: "العربية", direction: "rtl" },
ru: { name: "Russian", nativeName: "Русский", direction: "ltr" },
pl: { name: "Polish", nativeName: "Polski", direction: "ltr" },
ja: { name: "Japanese", nativeName: "日本語", direction: "ltr" },
ko: { name: "Korean", nativeName: "한국어", direction: "ltr" },
pt: { name: "Portuguese", nativeName: "Português", direction: "ltr" },
it: { name: "Italian", nativeName: "Italiano", direction: "ltr" },
nl: { name: "Dutch", nativeName: "Nederlands", direction: "ltr" },
he: { name: "Hebrew", nativeName: "עברית", direction: "rtl" },
fa: { name: "Persian", nativeName: "فارسی", direction: "rtl" },
};

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,72 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { RTL_LOCALE_LIST, RTL_LOCALES } from "./rtl-locales.js";
describe("RTL_LOCALES", () => {
it("ar is RTL", { timeout: 5000 }, () => {
expect(RTL_LOCALES.has("ar")).toBe(true);
});
it("he is RTL", { timeout: 5000 }, () => {
expect(RTL_LOCALES.has("he")).toBe(true);
});
it("fa is RTL", { timeout: 5000 }, () => {
expect(RTL_LOCALES.has("fa")).toBe(true);
});
it("ur is RTL", { timeout: 5000 }, () => {
expect(RTL_LOCALES.has("ur")).toBe(true);
});
it("ps is RTL", { timeout: 5000 }, () => {
expect(RTL_LOCALES.has("ps")).toBe(true);
});
it("sd is RTL", { timeout: 5000 }, () => {
expect(RTL_LOCALES.has("sd")).toBe(true);
});
it("yi is RTL", { timeout: 5000 }, () => {
expect(RTL_LOCALES.has("yi")).toBe(true);
});
it("en is not RTL", { timeout: 5000 }, () => {
expect(RTL_LOCALES.has("en")).toBe(false);
});
it("non-existent locale is not RTL", { timeout: 5000 }, () => {
expect(RTL_LOCALES.has("xx")).toBe(false);
});
it("contains exactly 7 codes", { timeout: 5000 }, () => {
expect(RTL_LOCALES.size).toBe(7);
});
});
describe("RTL_LOCALE_LIST", () => {
it("RTL_LOCALE_LIST length matches RTL_LOCALES size", {
timeout: 5000,
}, () => {
expect(RTL_LOCALE_LIST.length).toBe(RTL_LOCALES.size);
});
it("every entry in the list is in the set", { timeout: 5000 }, () => {
for (const code of RTL_LOCALE_LIST) {
expect(RTL_LOCALES.has(code)).toBe(true);
}
});
it("contains the canonical seven codes in stable order", {
timeout: 5000,
}, () => {
expect(RTL_LOCALE_LIST).toEqual([
"ar",
"he",
"fa",
"ur",
"ps",
"sd",
"yi",
]);
});
});

View File

@@ -0,0 +1,36 @@
/**
* Set of ISO 639-1 language codes whose primary script renders
* right-to-left.
*
* The seven codes covered (`ar`, `he`, `fa`, `ur`, `ps`, `sd`, `yi`)
* match the legacy monolith and are referenced by the locale detector
* for `getTextDirection()` lookups and by the locale descriptors for
* the per-locale `direction` property.
*/
export const RTL_LOCALES: ReadonlySet<string> = new Set([
"ar",
"he",
"fa",
"ur",
"ps",
"sd",
"yi",
]);
/**
* Tree-shake-friendly tuple form of {@link RTL_LOCALES}. Useful for
* consumers that need a stable iteration order or want to ship only
* the code list (without the Set overhead) into a constrained bundle.
*
* The list and the set MUST stay in sync — a unit test in this file
* folder asserts `RTL_LOCALE_LIST.length === RTL_LOCALES.size`.
*/
export const RTL_LOCALE_LIST = [
"ar",
"he",
"fa",
"ur",
"ps",
"sd",
"yi",
] as const;

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,92 @@
import { describe, expect, expectTypeOf, it } from "@suaveplan/testing/runner";
import {
I18N_CODES,
type I18nCode,
type I18nErrorCode,
} from "./error-codes.js";
describe("I18N_CODES — registry shape", () => {
it("every documented code is in I18N_CODES", { timeout: 5000 }, () => {
expect(I18N_CODES.MISSING_KEY).toBeDefined();
expect(I18N_CODES.INVALID_LOCALE).toBeDefined();
expect(I18N_CODES.INVALID_OPTIONS).toBeDefined();
expect(I18N_CODES.LOADER_FAILED).toBeDefined();
expect(I18N_CODES.INTERPOLATION_ERROR).toBeDefined();
expect(I18N_CODES.PLURAL_RULE_NOT_FOUND).toBeDefined();
expect(I18N_CODES.NESTING_DEPTH_EXCEEDED).toBeDefined();
expect(I18N_CODES.BACKEND_READ_FAILED).toBeDefined();
expect(I18N_CODES.BACKEND_WRITE_FAILED).toBeDefined();
expect(I18N_CODES.BACKEND_NOT_REGISTERED).toBeDefined();
expect(I18N_CODES.BACKEND_LOCALE_NOT_AVAILABLE).toBeDefined();
expect(I18N_CODES.BACKEND_TIMEOUT).toBeDefined();
expect(I18N_CODES.CHAINED_BACKEND_ALL_FAILED).toBeDefined();
expect(I18N_CODES.INVALID_FORMATTER_OPTIONS).toBeDefined();
expect(I18N_CODES.UNSUPPORTED_LOCALE).toBeDefined();
expect(I18N_CODES.FORMATTER_CACHE_OVERFLOW).toBeDefined();
expect(I18N_CODES.DETECTOR_CHAIN_EXHAUSTED).toBeDefined();
expect(I18N_CODES.PLUGIN_ALREADY_INSTALLED).toBeDefined();
});
it("code values are namespaced under I18N.", { timeout: 5000 }, () => {
for (const value of Object.values(I18N_CODES)) {
expect(value.startsWith("I18N.")).toBe(true);
}
});
it('MISSING_KEY value equals "I18N.MISSING_KEY"', {
timeout: 5000,
}, () => {
expect(I18N_CODES.MISSING_KEY).toBe("I18N.MISSING_KEY");
});
it('BACKEND_TIMEOUT value equals "I18N.BACKEND_TIMEOUT"', {
timeout: 5000,
}, () => {
expect(I18N_CODES.BACKEND_TIMEOUT).toBe("I18N.BACKEND_TIMEOUT");
});
it("DETECTOR_CHAIN_EXHAUSTED value is namespaced", {
timeout: 5000,
}, () => {
expect(I18N_CODES.DETECTOR_CHAIN_EXHAUSTED).toBe(
"I18N.DETECTOR_CHAIN_EXHAUSTED"
);
});
it("registry is frozen", { timeout: 5000 }, () => {
expect(Object.isFrozen(I18N_CODES)).toBe(true);
});
it("I18nErrorCode union has cardinality 18", { timeout: 5000 }, () => {
const allCodes = Object.values(I18N_CODES);
expect(new Set(allCodes).size).toBe(18);
// Type-level assertion that the union covers exactly the 18 string
// literals (compile-time check; runs zero work).
expectTypeOf<I18nErrorCode>().toEqualTypeOf<
| "I18N.MISSING_KEY"
| "I18N.INVALID_LOCALE"
| "I18N.INVALID_OPTIONS"
| "I18N.LOADER_FAILED"
| "I18N.INTERPOLATION_ERROR"
| "I18N.PLURAL_RULE_NOT_FOUND"
| "I18N.NESTING_DEPTH_EXCEEDED"
| "I18N.BACKEND_READ_FAILED"
| "I18N.BACKEND_WRITE_FAILED"
| "I18N.BACKEND_NOT_REGISTERED"
| "I18N.BACKEND_LOCALE_NOT_AVAILABLE"
| "I18N.BACKEND_TIMEOUT"
| "I18N.CHAINED_BACKEND_ALL_FAILED"
| "I18N.INVALID_FORMATTER_OPTIONS"
| "I18N.UNSUPPORTED_LOCALE"
| "I18N.FORMATTER_CACHE_OVERFLOW"
| "I18N.DETECTOR_CHAIN_EXHAUSTED"
| "I18N.PLUGIN_ALREADY_INSTALLED"
>();
});
it("I18nCode is a back-compat alias for I18nErrorCode", {
timeout: 5000,
}, () => {
expectTypeOf<I18nCode>().toEqualTypeOf<I18nErrorCode>();
});
});

View File

@@ -0,0 +1,58 @@
import { defineCodes } from "@suaveplan/error";
/**
* Stable error code registry for the entire `@suaveplan/i18n*`
* package family. Every code is namespaced under `"I18N."` so
* downstream observability (OTel span events, log sinks, error
* dashboards) can group by prefix without inspecting class names.
*
* The registry is the single source of truth — leaf packages (parser,
* formatters, detector, backends) MUST throw `I18nError` instances
* carrying one of these codes; never invent a code at the leaf level.
*
* Cardinality: 18 codes total — 14 carried over from the pre-split
* monolith, 4 added to model failure modes the per-package split
* surfaces (chained-backend exhaustion, detector exhaustion,
* formatter cache overflow, backend timeout).
*/
export const I18N_CODES = defineCodes("I18N", [
// Parser / interpolation / pluralization
"MISSING_KEY",
"INVALID_LOCALE",
"INVALID_OPTIONS",
"LOADER_FAILED",
"INTERPOLATION_ERROR",
"PLURAL_RULE_NOT_FOUND",
"NESTING_DEPTH_EXCEEDED",
// Backends
"BACKEND_READ_FAILED",
"BACKEND_WRITE_FAILED",
"BACKEND_NOT_REGISTERED",
"BACKEND_LOCALE_NOT_AVAILABLE",
"BACKEND_TIMEOUT",
"CHAINED_BACKEND_ALL_FAILED",
// Formatters
"INVALID_FORMATTER_OPTIONS",
"UNSUPPORTED_LOCALE",
"FORMATTER_CACHE_OVERFLOW",
// Detector
"DETECTOR_CHAIN_EXHAUSTED",
// Plugin / core
"PLUGIN_ALREADY_INSTALLED",
] as const);
/**
* Discriminant union of every fully-qualified i18n error code,
* derived from the {@link I18N_CODES} registry. Use this as the
* `TCode` parameter when extending `SuaveplanError` from a leaf
* package that throws i18n-namespaced errors.
*/
export type I18nErrorCode = (typeof I18N_CODES)[keyof typeof I18N_CODES];
/**
* Back-compat alias for the pre-split `I18nCode` name. The slim core
* (Wave 6) re-exports `I18nCode` from this package so existing
* `import { I18nCode } from "@suaveplan/i18n"` call-sites keep
* compiling. New code should prefer `I18nErrorCode`.
*/
export type I18nCode = I18nErrorCode;

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,105 @@
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { SuaveplanError } from "@suaveplan/error";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { I18N_CODES } from "./error-codes.js";
import { I18nError } from "./i18n-error.js";
describe("I18nError — construction", () => {
it("constructs with every code in I18N_CODES", { timeout: 5000 }, () => {
for (const code of Object.values(I18N_CODES)) {
const err = new I18nError(code, "test.message");
expect(err.code).toBe(code);
expect(err.message).toBe("test.message");
}
});
it("instanceof I18nError and SuaveplanError", { timeout: 5000 }, () => {
const err = new I18nError(I18N_CODES.MISSING_KEY, "x");
expect(err instanceof I18nError).toBe(true);
expect(err instanceof SuaveplanError).toBe(true);
expect(err instanceof Error).toBe(true);
});
it("name reflects the subclass constructor", { timeout: 5000 }, () => {
const err = new I18nError(I18N_CODES.MISSING_KEY, "x");
expect(err.name).toBe("I18nError");
});
it("preserves cause chain", { timeout: 5000 }, () => {
const cause = new Error("network");
const err = new I18nError(I18N_CODES.LOADER_FAILED, "k", { cause });
expect(err.cause).toBe(cause);
expect((err.cause as Error).message).toBe("network");
});
it('stamps tier="i18n" into context by default', {
timeout: 5000,
}, () => {
const err = new I18nError(I18N_CODES.MISSING_KEY, "x");
expect(err.context).toEqual({ tier: "i18n" });
});
it("merges caller-supplied context fields with the tier stamp", {
timeout: 5000,
}, () => {
const err = new I18nError(I18N_CODES.MISSING_KEY, "x", {
context: { locale: "ar-SA", namespace: "checkout" },
});
expect(err.context).toEqual({
tier: "i18n",
locale: "ar-SA",
namespace: "checkout",
});
});
it("caller-supplied tier overrides the default stamp", {
timeout: 5000,
}, () => {
const err = new I18nError(I18N_CODES.MISSING_KEY, "x", {
context: { tier: "i18n.parser" },
});
expect((err.context as { tier: string }).tier).toBe("i18n.parser");
});
it("propagates traceId", { timeout: 5000 }, () => {
const err = new I18nError(I18N_CODES.MISSING_KEY, "x", {
traceId: "trace-1",
});
expect(err.traceId).toBe("trace-1");
});
it("uses injected clock for timestamp", { timeout: 5000 }, () => {
const err = new I18nError(I18N_CODES.MISSING_KEY, "x", {
clock: () => 999_000,
});
expect(err.timestamp).toBe(999_000);
});
it("works with no options argument", { timeout: 5000 }, () => {
const err = new I18nError(I18N_CODES.PLUGIN_ALREADY_INSTALLED, "k");
expect(err.code).toBe(I18N_CODES.PLUGIN_ALREADY_INSTALLED);
expect(err.context).toEqual({ tier: "i18n" });
});
});
describe("I18nError — clean source (no static-analysis suppressions)", () => {
it("source contains no suppression directives", {
timeout: 5000,
}, () => {
const sourcePath = fileURLToPath(
new URL("./i18n-error.ts", import.meta.url)
);
const source = readFileSync(sourcePath, "utf8");
// Each token is rebuilt at runtime so this very test file is not
// a self-referential trap when later authors grep the tree.
const directive = ["biome", "ignore"].join("-");
const eslintDirective = ["eslint", "disable"].join("-");
const tsDirective = ["@", "ts-ignore"].join("");
const expectErrorDirective = ["@", "ts-expect-error"].join("");
expect(source.includes(directive)).toBe(false);
expect(source.includes(eslintDirective)).toBe(false);
expect(source.includes(tsDirective)).toBe(false);
expect(source.includes(expectErrorDirective)).toBe(false);
});
});

View File

@@ -0,0 +1,42 @@
import { SuaveplanError, type SuaveplanErrorOptions } from "@suaveplan/error";
import type { I18nErrorCode } from "./error-codes.js";
/**
* Tier tag stamped into every `I18nError.context` so log sinks and
* dashboards can group i18n-family failures without inspecting
* `error.constructor.name` (which is unstable across minified
* bundles). The tier survives serialisation through
* `@suaveplan/error`'s wire format.
*/
const TIER = "i18n";
/**
* Base error class for every error thrown by the `@suaveplan/i18n*`
* package family.
*
* Extends `SuaveplanError` so the error participates in the
* monorepo-wide error substrate (cause-chain traversal, redaction-
* aware wire format, OTel span-event adapter, i18n message resolver
* hook). Every leaf package (parser, formatters, detector, backends,
* slim core) throws `I18nError` instances carrying a code from
* `I18N_CODES`.
*
* The constructor stamps `context.tier = "i18n"` (preserving any
* caller-supplied context fields) — this is a meaningful body, so
* biome's `noUselessConstructor` rule does not flag the constructor
* and no static-analysis suppression directive is needed
* (CLAUDE.md §9 forbids those in new code).
*/
export class I18nError extends SuaveplanError<I18nErrorCode> {
public constructor(
code: I18nErrorCode,
messageKey: string,
options?: SuaveplanErrorOptions
) {
const merged: SuaveplanErrorOptions = {
...options,
context: { tier: TIER, ...(options?.context ?? {}) },
};
super(code, messageKey, merged);
}
}

View File

@@ -0,0 +1,8 @@
// Explicit named re-exports — no `export *` per CLAUDE.md §10.
export {
I18N_CODES,
type I18nCode,
type I18nErrorCode,
} from "./error-codes.js";
export { I18nError } from "./i18n-error.js";

View File

@@ -0,0 +1,122 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import * as root from "./index.js";
describe("@suaveplan/i18n-shared root barrel", () => {
it("re-exports DEFAULT_INTERPOLATION", { timeout: 5000 }, () => {
expect(root.DEFAULT_INTERPOLATION).toBeDefined();
expect(root.DEFAULT_INTERPOLATION.prefix).toBe("{{");
});
it("re-exports DEFAULT_LOCALES", { timeout: 5000 }, () => {
expect(root.DEFAULT_LOCALES).toBeDefined();
expect(root.DEFAULT_LOCALES.length).toBe(16);
});
it("re-exports LOCALE_BCP47_MAP", { timeout: 5000 }, () => {
expect(root.LOCALE_BCP47_MAP).toBeDefined();
expect(root.LOCALE_BCP47_MAP["en"]).toBe("en-GB");
});
it("re-exports LOCALE_DESCRIPTORS", { timeout: 5000 }, () => {
expect(root.LOCALE_DESCRIPTORS).toBeDefined();
expect(root.LOCALE_DESCRIPTORS["ar"]?.direction).toBe("rtl");
});
it("re-exports RTL_LOCALES", { timeout: 5000 }, () => {
expect(root.RTL_LOCALES).toBeDefined();
expect(root.RTL_LOCALES.has("ar")).toBe(true);
});
it("re-exports RTL_LOCALE_LIST", { timeout: 5000 }, () => {
expect(root.RTL_LOCALE_LIST).toBeDefined();
expect(root.RTL_LOCALE_LIST.length).toBe(7);
});
it("re-exports I18N_CODES", { timeout: 5000 }, () => {
expect(root.I18N_CODES).toBeDefined();
expect(root.I18N_CODES.MISSING_KEY).toBe("I18N.MISSING_KEY");
});
it("re-exports I18nError", { timeout: 5000 }, () => {
expect(root.I18nError).toBeDefined();
const err = new root.I18nError(root.I18N_CODES.MISSING_KEY, "k");
expect(err.code).toBe("I18N.MISSING_KEY");
});
it("every documented subpath export resolves", { timeout: 5000 }, () => {
const expectedSurface = [
"DEFAULT_INTERPOLATION",
"DEFAULT_LOCALES",
"LOCALE_BCP47_MAP",
"LOCALE_DESCRIPTORS",
"RTL_LOCALE_LIST",
"RTL_LOCALES",
"I18N_CODES",
"I18nError",
];
const exported = Object.keys(root);
for (const name of expectedSurface) {
expect(exported).toContain(name);
}
});
});
describe("@suaveplan/i18n-shared subpath imports", () => {
it("constants barrel resolves with every constant", {
timeout: 5000,
}, async () => {
const constants = await import("./constants/index.js");
expect(constants.DEFAULT_INTERPOLATION).toBeDefined();
expect(constants.DEFAULT_LOCALES).toBeDefined();
expect(constants.LOCALE_BCP47_MAP).toBeDefined();
expect(constants.LOCALE_DESCRIPTORS).toBeDefined();
expect(constants.RTL_LOCALES).toBeDefined();
expect(constants.RTL_LOCALE_LIST).toBeDefined();
});
it("errors barrel resolves with codes + class", {
timeout: 5000,
}, async () => {
const errors = await import("./errors/index.js");
expect(errors.I18N_CODES).toBeDefined();
expect(errors.I18nError).toBeDefined();
});
it("locale-bcp47-map subpath resolves the constant", {
timeout: 5000,
}, async () => {
const mod = await import("./constants/locale-bcp47-map.js");
expect(mod.LOCALE_BCP47_MAP["en"]).toBe("en-GB");
});
it("rtl-locales subpath resolves both exports", {
timeout: 5000,
}, async () => {
const mod = await import("./constants/rtl-locales.js");
expect(mod.RTL_LOCALES.has("ar")).toBe(true);
expect(mod.RTL_LOCALE_LIST.length).toBe(7);
});
it("locale-descriptors subpath resolves all three exports", {
timeout: 5000,
}, async () => {
const mod = await import("./constants/locale-descriptors.js");
expect(mod.DEFAULT_LOCALES.length).toBe(16);
expect(mod.LOCALE_DESCRIPTORS["en"]?.name).toBe("English");
});
it("interpolation-defaults subpath resolves the constant", {
timeout: 5000,
}, async () => {
const mod = await import("./constants/interpolation-defaults.js");
expect(mod.DEFAULT_INTERPOLATION.maxDepth).toBe(10);
});
it("testing subpath resolves runBackendConformance", {
timeout: 5000,
}, async () => {
const mod = await import("./testing/index.js");
expect(typeof mod.runBackendConformance).toBe("function");
expect(mod.runBackendConformance.length).toBe(2);
});
});

View File

@@ -0,0 +1,20 @@
// Root barrel for @suaveplan/i18n-shared. Explicit named re-exports
// only — no `export *` per CLAUDE.md §10. Mirrors the per-feature
// barrels under ./constants and ./errors so the surface stays
// auditable and tree-shakeable.
export {
DEFAULT_INTERPOLATION,
DEFAULT_LOCALES,
type DefaultLocale,
LOCALE_BCP47_MAP,
LOCALE_DESCRIPTORS,
RTL_LOCALE_LIST,
RTL_LOCALES,
} from "./constants/index.js";
export {
I18N_CODES,
type I18nCode,
I18nError,
type I18nErrorCode,
} from "./errors/index.js";

View File

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

View File

@@ -0,0 +1,221 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { Backend, TranslationRecord } from "@suaveplan/types";
import { I18N_CODES } from "../errors/error-codes.js";
import { I18nError } from "../errors/i18n-error.js";
import { runBackendConformance } from "./conformance.js";
import type { ConformanceContext } from "./conformance.types.js";
/**
* Inline full-featured mock backend exercising every capability path
* the harness inspects. Reused across the harness self-tests below.
*/
class MockFullBackend implements Backend {
public readonly name = "mock-full";
public readonly capabilities = {
canRead: true as const,
canReadMulti: true,
canSave: true,
canTrackModification: true,
canWatch: false,
};
readonly #data = new Map<string, Map<string, TranslationRecord>>();
readonly #modified = new Map<string, number>();
#clock = 1000;
public async read(
locale: string,
namespace: string
): Promise<TranslationRecord> {
const ns = this.#data.get(locale)?.get(namespace);
if (ns === undefined) {
throw new I18nError(
I18N_CODES.BACKEND_LOCALE_NOT_AVAILABLE,
`mock: no record for ${locale}/${namespace}`,
{ context: { locale, namespace, backend: this.name } }
);
}
return ns;
}
public async readMulti(
locale: string,
namespaces: readonly string[]
): Promise<Record<string, TranslationRecord>> {
const out: Record<string, TranslationRecord> = {};
const byLocale = this.#data.get(locale);
if (!byLocale) return out;
for (const ns of namespaces) {
const r = byLocale.get(ns);
if (r !== undefined) out[ns] = r;
}
return out;
}
public async save(
locale: string,
namespace: string,
record: TranslationRecord
): Promise<void> {
let byNs = this.#data.get(locale);
if (!byNs) {
byNs = new Map();
this.#data.set(locale, byNs);
}
byNs.set(namespace, record);
this.#modified.set(`${locale} ${namespace}`, this.#clock++);
}
public async lastModified(
locale: string,
namespace: string
): Promise<number | undefined> {
return this.#modified.get(`${locale} ${namespace}`);
}
}
/**
* Read-only mock — `canRead: true`, every other flag `false`. Used to
* exercise the harness's capability-gating branches (early returns
* for save / readMulti / lastModified).
*/
class MockReadOnlyBackend implements Backend {
public readonly name = "mock-readonly";
public readonly capabilities = {
canRead: true as const,
canReadMulti: false,
canSave: false,
canTrackModification: false,
canWatch: false,
};
public async read(
locale: string,
namespace: string
): Promise<TranslationRecord> {
throw new I18nError(
I18N_CODES.BACKEND_LOCALE_NOT_AVAILABLE,
`mock-readonly: no record for ${locale}/${namespace}`
);
}
}
/**
* Capability-mismatch mock — declares methods are present but flags
* them off. Exercises the second disjunct of every capability gate
* (`backend.<method>` truthy AND `capabilities.canX === false` →
* early return).
*/
class MockCapabilityMismatchBackend implements Backend {
public readonly name = "mock-mismatch";
public readonly capabilities = {
canRead: true as const,
canReadMulti: false,
canSave: false,
canTrackModification: false,
canWatch: false,
};
public async read(
locale: string,
namespace: string
): Promise<TranslationRecord> {
throw new I18nError(
I18N_CODES.BACKEND_LOCALE_NOT_AVAILABLE,
`mock-mismatch: no record for ${locale}/${namespace}`
);
}
public async readMulti(
_locale: string,
_namespaces: readonly string[]
): Promise<Record<string, TranslationRecord>> {
return {};
}
public async save(
_locale: string,
_namespace: string,
_record: TranslationRecord
): Promise<void> {
// no-op
}
public async lastModified(
_locale: string,
_namespace: string
): Promise<number | undefined> {
return undefined;
}
}
// ── Run the harness against the full-featured mock — exercises every
// "supported" branch. Registered at module load so vitest picks it up.
runBackendConformance(
"MockFullBackend",
async (): Promise<ConformanceContext> => {
const backend = new MockFullBackend();
return {
backend,
async seed(locale, namespace, record) {
await backend.save(locale, namespace, record as never);
},
};
}
);
// ── Run the harness against the read-only mock — exercises every
// "not supported" early-return branch (no seed, no save, no
// readMulti, no lastModified).
runBackendConformance(
"MockReadOnlyBackend",
async (): Promise<ConformanceContext> => ({
backend: new MockReadOnlyBackend(),
})
);
// ── Mismatch mock with seed: methods exist but capability flags off
// — exercises the second disjunct of each capability gate.
runBackendConformance(
"MockCapabilityMismatchBackend",
async (): Promise<ConformanceContext> => ({
backend: new MockCapabilityMismatchBackend(),
})
);
// ── Full backend whose capability flags are all flipped off — this
// exercises the second disjunct of every capability gate (the
// `!ctx.backend.capabilities.canX` short-circuit) when the seed
// callback IS present.
class MockFlagsOffBackend extends MockFullBackend {
public override readonly capabilities = {
canRead: true as const,
canReadMulti: false,
canSave: false,
canTrackModification: false,
canWatch: false,
};
}
runBackendConformance(
"MockFlagsOffBackend",
async (): Promise<ConformanceContext> => {
const backend = new MockFlagsOffBackend();
return {
backend,
async seed(locale, namespace, record) {
await backend.save(locale, namespace, record as never);
},
};
}
);
describe("runBackendConformance", () => {
it("is a callable function", { timeout: 5000 }, () => {
expect(typeof runBackendConformance).toBe("function");
expect(runBackendConformance.length).toBe(2);
});
});

View File

@@ -0,0 +1,132 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { I18nError } from "../errors/i18n-error.js";
import type { ConformanceFactory } from "./conformance.types.js";
/**
* Run the universal `Backend` interface contract against a factory.
*
* Every concrete backend in the `@suaveplan/i18n-backend-*` family
* MUST call this from its own test file with a factory that produces
* a fresh backend per call. The harness covers the universal contract
* (name + capabilities surface, read of an absent record, read of a
* seeded record, optional readMulti merge, optional save round-trip,
* optional lastModified monotonicity) — per-backend specifics live
* in their own test files.
*
* Capability gating: the harness inspects `backend.capabilities` and
* the structural presence of `readMulti` / `save` / `lastModified`
* to decide which assertions apply. Backends that do not support a
* capability silently skip the corresponding assertion (it still
* runs, but exits early), keeping the suite green for read-only
* backends like HTTP.
*
* @param label - Human-readable backend label inserted into the
* `describe(...)` block title. Conventionally the class name.
* @param makeFactory - Async factory producing a fresh
* {@link ConformanceContext} per invocation.
*/
export function runBackendConformance(
label: string,
makeFactory: ConformanceFactory
): void {
describe(`Backend conformance: ${label}`, () => {
it("exposes a non-empty name", { timeout: 5000 }, async () => {
const { backend } = await makeFactory();
expect(typeof backend.name).toBe("string");
expect(backend.name.length).toBeGreaterThan(0);
});
it("exposes capabilities with canRead === true", {
timeout: 5000,
}, async () => {
const { backend } = await makeFactory();
expect(backend.capabilities.canRead).toBe(true);
});
it("exposes capabilities with the full 5-flag matrix", {
timeout: 5000,
}, async () => {
const { backend } = await makeFactory();
const c = backend.capabilities;
expect(typeof c.canRead).toBe("boolean");
expect(typeof c.canReadMulti).toBe("boolean");
expect(typeof c.canSave).toBe("boolean");
expect(typeof c.canTrackModification).toBe("boolean");
expect(typeof c.canWatch).toBe("boolean");
});
it("read(absent) rejects with BACKEND_LOCALE_NOT_AVAILABLE", {
timeout: 5000,
}, async () => {
const { backend } = await makeFactory();
await expect(backend.read("xx", "missing")).rejects.toBeInstanceOf(
I18nError
);
await expect(backend.read("xx", "missing")).rejects.toMatchObject({
code: "I18N.BACKEND_LOCALE_NOT_AVAILABLE",
});
});
it("read(seeded) resolves to the seeded record", {
timeout: 5000,
}, async () => {
const ctx = await makeFactory();
if (!ctx.seed) return;
await ctx.seed("en", "auth", { login: { title: "Sign in" } });
const record = await ctx.backend.read("en", "auth");
expect(record).toEqual({ login: { title: "Sign in" } });
});
it("readMulti merges when supported", { timeout: 5000 }, async () => {
const ctx = await makeFactory();
if (!ctx.backend.readMulti || !ctx.seed) return;
if (!ctx.backend.capabilities.canReadMulti) return;
await ctx.seed("en", "auth", { ok: "Ok" });
await ctx.seed("en", "common", { cancel: "Cancel" });
const result = await ctx.backend.readMulti("en", [
"auth",
"common",
]);
expect(result).toEqual({
auth: { ok: "Ok" },
common: { cancel: "Cancel" },
});
});
it("save then read round-trips when supported", {
timeout: 5000,
}, async () => {
const { backend } = await makeFactory();
if (!backend.save || !backend.capabilities.canSave) return;
await backend.save("en", "auth", { login: { title: "Sign in" } });
const record = await backend.read("en", "auth");
expect(record).toEqual({ login: { title: "Sign in" } });
});
it("lastModified increases monotonically when supported", {
timeout: 5000,
}, async () => {
const { backend } = await makeFactory();
if (!backend.save || !backend.lastModified) return;
if (!backend.capabilities.canTrackModification) return;
await backend.save("en", "auth", { a: "1" });
const t1 = await backend.lastModified("en", "auth");
expect(t1).toBeDefined();
await new Promise((r) => setTimeout(r, 2));
await backend.save("en", "auth", { a: "2" });
const t2 = await backend.lastModified("en", "auth");
expect(t2).toBeDefined();
expect(t2 as number).toBeGreaterThanOrEqual(t1 as number);
});
it("lastModified(absent) returns undefined when supported", {
timeout: 5000,
}, async () => {
const { backend } = await makeFactory();
if (!backend.lastModified) return;
if (!backend.capabilities.canTrackModification) return;
const t = await backend.lastModified("xx", "missing");
expect(t).toBeUndefined();
});
});
}

View File

@@ -0,0 +1,28 @@
import type { Backend } from "@suaveplan/types";
/**
* Context returned by a {@link ConformanceFactory}. The harness calls
* the factory once per assertion to produce a fresh, empty backend
* along with an optional `seed` adapter that pre-populates the backend
* with a record at a given `(locale, namespace)` slot.
*
* Backends that lack `save` (HTTP-style read-only sources) supply
* their own seeding through their factory's options instead and omit
* the `seed` field — the harness skips read-after-seed assertions
* that depend on it.
*/
export interface ConformanceContext {
readonly backend: Backend;
readonly seed?: (
locale: string,
namespace: string,
record: Record<string, unknown>
) => Promise<void>;
}
/**
* Factory signature for {@link runBackendConformance}. The harness
* invokes this once per `it(...)` so the backend instance under test
* is always fresh — no cross-test state leakage.
*/
export type ConformanceFactory = () => Promise<ConformanceContext>;

View File

@@ -0,0 +1,7 @@
// Explicit named re-exports — no `export *` per CLAUDE.md §10.
export { runBackendConformance } from "./conformance.js";
export type {
ConformanceContext,
ConformanceFactory,
} from "./conformance.types.js";

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,43 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/i18n-shared",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: {
entry: {
index: "src/index.ts",
"testing/index": "src/testing/index.ts",
},
formats: ["es"],
},
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@suaveplan/error",
"@suaveplan/testing",
"@suaveplan/testing/runner",
"@suaveplan/types",
"tslib",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,29 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
include: ["src/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.types.ts",
"src/**/*.schemas.ts",
"src/**/index.ts",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 5000,
},
});