Initial Commit
This commit is contained in:
114
packages/localization/i18n/i18n-shared/README.md
Normal file
114
packages/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.
|
||||
|
||||
110
packages/localization/i18n/i18n-shared/package.json
Normal file
110
packages/localization/i18n/i18n-shared/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
77
packages/localization/i18n/i18n-shared/src/byte-size.test.ts
Normal file
77
packages/localization/i18n/i18n-shared/src/byte-size.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
@@ -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,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>();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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,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);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
};
|
||||
@@ -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,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();
|
||||
});
|
||||
});
|
||||
@@ -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" },
|
||||
};
|
||||
@@ -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"`.
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
109
packages/localization/i18n/i18n-shared/src/errors/error-codes.md
Normal file
109
packages/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.
|
||||
@@ -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>();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
106
packages/localization/i18n/i18n-shared/src/errors/i18n-error.md
Normal file
106
packages/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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
122
packages/localization/i18n/i18n-shared/src/index.test.ts
Normal file
122
packages/localization/i18n/i18n-shared/src/index.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
20
packages/localization/i18n/i18n-shared/src/index.ts
Normal file
20
packages/localization/i18n/i18n-shared/src/index.ts
Normal 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";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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";
|
||||
12
packages/localization/i18n/i18n-shared/tsconfig.lib.json
Normal file
12
packages/localization/i18n/i18n-shared/tsconfig.lib.json
Normal 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"]
|
||||
}
|
||||
43
packages/localization/i18n/i18n-shared/vite.config.ts
Normal file
43
packages/localization/i18n/i18n-shared/vite.config.ts
Normal 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:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
29
packages/localization/i18n/i18n-shared/vitest.config.ts
Normal file
29
packages/localization/i18n/i18n-shared/vitest.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user