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,105 @@
# `create-i18n-config.ts`
## Purpose
Builds a validated `I18nConfig` from a caller-supplied option bag and is
the canonical entry point for `<TranslationProvider config={...}>`. The
function runs the bag through `I18nConfigSchema` (Zod) and, on failure,
routes the error to the correct namespace so observability dashboards
group SSR-hydration failures separately from generic option-shape
failures.
The split is spec-driven: the
`enterprise-react-i18n` proposal explicitly requires
`ReactI18nError(REACT_I18N.HYDRATION_MISMATCH)` for `initialMessages`
shape failures (because they correspond to the SSR contract violation
React consumers debug under that umbrella) and
`I18nError(I18N.INVALID_OPTIONS)` for every other shape failure
(because those would have surfaced from the universal core anyway if
they had escaped past the React boundary).
## Features
- Pure validation — no side effects, no global state.
- Distinguishes `initialMessages` failures from generic option failures
with a deterministic Zod-issue path scan (`issue.path[0] ===
"initialMessages"`).
- Attaches `context.zodIssues` to every thrown error so structured-log
sinks, OTel span events, and the wire-format envelope all surface the
precise field that broke.
- Returns `I18nConfig` (the parsed type), not the raw input — downstream
code can rely on defaults having been applied.
## Usage: Basic Example
```ts
import { createI18nConfig } from "@suaveplan/react-i18n/config";
const cfg = createI18nConfig({
defaultLocale: "en-GB",
fallbackLocale: "en-GB",
supportedLocales: ["en-GB", "fr-FR"],
});
```
## Usage: Advanced Example
```ts
import { createI18nConfig } from "@suaveplan/react-i18n/config";
import { ReactI18nError } from "@suaveplan/react-i18n/errors";
import { I18nError } from "@suaveplan/i18n";
function loadConfig(input: unknown) {
try {
return createI18nConfig(input);
} catch (err) {
if (err instanceof ReactI18nError) {
// SSR-hydration failure — the initialMessages payload
// produced by the server cannot be hydrated client-side.
renderHydrationError(err);
} else if (err instanceof I18nError) {
// Generic option failure — typo or wrong type on a
// non-hydration field (defaultLocale, plugins, …).
renderConfigError(err);
}
throw err;
}
}
```
## API Reference
### `createI18nConfig(input: unknown): I18nConfig`
Validates `input` against `I18nConfigSchema` (see
`schemas/i18n-config.schemas.ts`) and returns the parsed config.
Throws:
- `ReactI18nError(REACT_I18N.HYDRATION_MISMATCH)` — when ANY Zod issue is
rooted at `initialMessages.*`. Carries `context.zodIssues` (the full
issue array, not just the matching one, so callers can debug
cascade failures without re-running the parser).
- `I18nError(I18N.INVALID_OPTIONS)` — for every other shape failure.
Carries `context.zodIssues` (full issue array) and
`context.source === "react-i18n.config"` so log sinks know the failure
originated at the React boundary, not deeper in the universal core.
## Implementation Notes
- The function uses `safeParse` rather than `parse` so the Zod issue
array is reachable for the path-scan that selects between the two
error namespaces.
- `issue.path[0]` is the right discriminator because Zod issue paths
always start at the schema root. A nested failure inside
`initialMessages.en-GB.greetings.hello` still yields
`path[0] === "initialMessages"`.
- The function is intentionally separate from
`toI18nInstanceOptions` so callers can validate config without
triggering the universal-core `createI18n` machinery (useful in
Storybook, in design-time editors, and in test harnesses).
- Both error classes carry `context.zodIssues`. Sensitive fields inside
`initialMessages` are not redacted because translation strings are
not secret data; if a consumer needs redaction (e.g. for templates
containing PII placeholders), they should pre-process the payload
before calling `createI18nConfig`.

View File

@@ -0,0 +1,89 @@
import { I18nError } from "@suaveplan/i18n-shared";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { ReactI18nError } from "../errors/react-i18n-error.js";
import { createI18nConfig } from "./create-i18n-config.js";
describe("createI18nConfig", () => {
it("returns the parsed config on the happy path", { timeout: 5000 }, () => {
const cfg = createI18nConfig({
defaultLocale: "en-GB",
fallbackLocale: "en-GB",
supportedLocales: ["en-GB", "fr-FR"],
initialMessages: { "en-GB": { hello: "Hi" } },
});
expect(cfg.defaultLocale).toBe("en-GB");
expect(cfg.supportedLocales).toEqual(["en-GB", "fr-FR"]);
});
it("throws ReactI18nError(HYDRATION_MISMATCH) on bad initialMessages", {
timeout: 5000,
}, () => {
try {
createI18nConfig({
defaultLocale: "en-GB",
initialMessages: {
"en-GB": "not-an-object" as unknown as Record<
string,
string
>,
},
});
expect.unreachable("should throw");
} catch (err) {
expect(err).toBeInstanceOf(ReactI18nError);
if (err instanceof ReactI18nError) {
expect(err.code).toBe("REACT_I18N.HYDRATION_MISMATCH");
expect(err.context).toBeDefined();
const ctx = err.context as
| undefined
| { zodIssues?: ReadonlyArray<unknown> };
expect(ctx?.zodIssues).toBeDefined();
}
}
});
it("throws I18nError(INVALID_OPTIONS) on generic shape failures", {
timeout: 5000,
}, () => {
try {
createI18nConfig({ defaultLocale: "" });
expect.unreachable("should throw");
} catch (err) {
expect(err).toBeInstanceOf(I18nError);
if (err instanceof I18nError) {
expect(err.code).toBe("I18N.INVALID_OPTIONS");
const ctx = err.context as
| undefined
| {
zodIssues?: ReadonlyArray<unknown>;
source?: string;
};
expect(ctx?.zodIssues).toBeDefined();
expect(ctx?.source).toBe("react-i18n.config");
}
}
});
it("throws I18nError(INVALID_OPTIONS) on unknown fields", {
timeout: 5000,
}, () => {
try {
createI18nConfig({
defaultLocale: "en-GB",
typo: true,
});
expect.unreachable("should throw");
} catch (err) {
expect(err).toBeInstanceOf(I18nError);
}
});
it("throws I18nError on non-object input", { timeout: 5000 }, () => {
try {
createI18nConfig("not-an-object");
expect.unreachable("should throw");
} catch (err) {
expect(err).toBeInstanceOf(I18nError);
}
});
});

View File

@@ -0,0 +1,48 @@
import { I18N_CODES, I18nError } from "@suaveplan/i18n-shared";
import { REACT_I18N_CODES } from "../errors/error-codes.js";
import { ReactI18nError } from "../errors/react-i18n-error.js";
import {
type I18nConfig,
I18nConfigSchema,
} from "../schemas/i18n-config.schemas.js";
/**
* Build a validated `I18nConfig` from a caller-supplied option bag.
*
* Runs the option bag through `I18nConfigSchema` (Zod). On success returns
* the parsed config. On failure:
*
* - When ANY Zod issue is rooted in `initialMessages`, throws
* `ReactI18nError(REACT_I18N.HYDRATION_MISMATCH)` so SSR-hydration
* failures surface with the React-tier code observability dashboards
* group on (this is the exact contract the spec scenario
* "invalid initialMessages rejected" requires).
* - Otherwise throws `I18nError(I18N.INVALID_OPTIONS)` so generic
* shape failures land in the universal-core error namespace they
* would have hit if they had escaped to `createI18n(...)` deeper in
* the stack.
*
* Both errors carry `context.zodIssues` so structured logs, span events,
* and the wire-format envelope all surface the precise field that broke.
*/
export function createI18nConfig(input: unknown): I18nConfig {
const result = I18nConfigSchema.safeParse(input);
if (result.success) return result.data;
const issues = result.error.issues;
const isInitialMessagesFailure = issues.some(
(issue) => issue.path[0] === "initialMessages"
);
if (isInitialMessagesFailure) {
throw new ReactI18nError(
REACT_I18N_CODES.HYDRATION_MISMATCH,
"react-i18n.hydration_mismatch.initial_messages",
{ context: { zodIssues: issues } }
);
}
throw new I18nError(I18N_CODES.INVALID_OPTIONS, "i18n.invalid_options", {
context: { zodIssues: issues, source: "react-i18n.config" },
});
}

View File

@@ -0,0 +1,2 @@
export { createI18nConfig } from "./create-i18n-config.js";
export { toI18nInstanceOptions } from "./to-i18n-instance-options.js";

View File

@@ -0,0 +1,98 @@
# `to-i18n-instance-options.ts`
## Purpose
Translates a Zod-parsed `I18nConfig` (the React-tier shape) into the
universal-core `I18nInstanceOptions` accepted by `createI18n(...)` from
`@suaveplan/i18n`. This is the bridge between the React Provider's
config surface (where field names favour SSR semantics, e.g.
`initialMessages`) and the universal core's option bag (where field
names favour catalog semantics, e.g. `messages`).
The function is pure and stable across calls so the Provider can wrap it
in `useMemo` without churning the universal core's instance lifecycle on
every render.
## Features
- Single-pass field mapping with explicit conditional forwarding so
`exactOptionalPropertyTypes` (the tsconfig setting) does not
require sentinel `undefined`s on the output.
- Renames `initialMessages` → `messages` so the React-tier name
("initial" matches the SSR-hydration story) and the core-tier name
("messages" matches the catalog story) can each be ergonomic in their
own context.
- Forwards every other field as-is (`fallbackLocale`, `backend`,
`backends`, `saveMissing`, `plugins`).
## Usage: Basic Example
```ts
import {
createI18nConfig,
toI18nInstanceOptions,
} from "@suaveplan/react-i18n/config";
import { createI18n } from "@suaveplan/i18n";
const cfg = createI18nConfig({
defaultLocale: "en-GB",
initialMessages: { "en-GB": { hello: "Hi" } },
});
const i18n = createI18n(toI18nInstanceOptions(cfg));
```
## Usage: Advanced Example
```ts
import { useMemo } from "react";
import { createI18n, type I18nInstance } from "@suaveplan/i18n";
import {
type I18nConfig,
toI18nInstanceOptions,
} from "@suaveplan/react-i18n/config";
function useStableInstance(config: I18nConfig): I18nInstance {
return useMemo(
() => createI18n(toI18nInstanceOptions(config)),
[config]
);
}
```
## API Reference
### `toI18nInstanceOptions(config: I18nConfig): I18nInstanceOptions`
Pure mapping. Returns a new `I18nInstanceOptions` object on every call;
fields not present on the input config are omitted from the output (so
the universal-core defaults take over).
| Input field | Output field | Notes |
| --- | --- | --- |
| `defaultLocale` | `defaultLocale` | Always present (required). |
| `fallbackLocale` | `fallbackLocale` | Forwarded when defined. |
| `initialMessages` | `messages` | Renamed; structurally identical. |
| `backend` | `backend` | Forwarded as-is (structural). |
| `backends` | `backends` | Forwarded as-is (structural). |
| `saveMissing` | `saveMissing` | Forwarded when defined. |
| `plugins` | `plugins` | Forwarded as-is (structural). |
## Implementation Notes
- Field forwarding uses explicit `if (value !== undefined)` guards
rather than spread because the tsconfig sets
`exactOptionalPropertyTypes: true` — explicit guards keep the output
shape free of sentinel `undefined` properties that would otherwise
conflict with optional fields on `I18nInstanceOptions`.
- `Backend` and `I18nPlugin` casts at the conversion boundary are
unavoidable: the React-tier Zod schema validates only the structural
fields (`name`, `capabilities`) of these types because their full
surface includes function fields Zod cannot meaningfully validate.
The Provider's runtime guard is the universal core itself, which
exercises the function fields when it boots the instance.
- The function is pure — calling it twice with the same input yields
equivalent options (deep-equal but not necessarily reference-equal).
- The function does NOT call `createI18n`. The Provider does that
separately so config building and instance construction can be wired
to different React lifecycles (`useMemo` on the config,
`useState`/`useRef` on the instance).

View File

@@ -0,0 +1,66 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { createI18nConfig } from "./create-i18n-config.js";
import { toI18nInstanceOptions } from "./to-i18n-instance-options.js";
describe("toI18nInstanceOptions", () => {
it("maps the minimal config (defaultLocale only)", {
timeout: 5000,
}, () => {
const cfg = createI18nConfig({ defaultLocale: "en-GB" });
const opts = toI18nInstanceOptions(cfg);
expect(opts.defaultLocale).toBe("en-GB");
expect(opts.fallbackLocale).toBeUndefined();
expect(opts.messages).toBeUndefined();
expect(opts.backend).toBeUndefined();
expect(opts.backends).toBeUndefined();
expect(opts.plugins).toBeUndefined();
expect(opts.saveMissing).toBeUndefined();
});
it("renames initialMessages → messages", { timeout: 5000 }, () => {
const cfg = createI18nConfig({
defaultLocale: "en-GB",
initialMessages: { "en-GB": { hello: "Hi" } },
});
const opts = toI18nInstanceOptions(cfg);
expect(opts.messages).toEqual({ "en-GB": { hello: "Hi" } });
});
it("forwards every documented field", { timeout: 5000 }, () => {
const cfg = createI18nConfig({
defaultLocale: "en-GB",
fallbackLocale: "en-US",
initialMessages: { "en-GB": { hello: "Hi" } },
backend: {
name: "memory",
capabilities: { canRead: true },
},
backends: [
{ name: "memory", capabilities: { canRead: true } },
{ name: "http", capabilities: { canRead: true } },
],
saveMissing: true,
plugins: [{ name: "missing-key-reporter" }],
});
const opts = toI18nInstanceOptions(cfg);
expect(opts.defaultLocale).toBe("en-GB");
expect(opts.fallbackLocale).toBe("en-US");
expect(opts.messages).toEqual({ "en-GB": { hello: "Hi" } });
expect(opts.backend).toBeDefined();
expect(opts.backends).toHaveLength(2);
expect(opts.saveMissing).toBe(true);
expect(opts.plugins).toHaveLength(1);
});
it("is pure — calling it twice yields equivalent options", {
timeout: 5000,
}, () => {
const cfg = createI18nConfig({
defaultLocale: "en-GB",
fallbackLocale: "en-US",
});
const a = toI18nInstanceOptions(cfg);
const b = toI18nInstanceOptions(cfg);
expect(a).toEqual(b);
});
});

View File

@@ -0,0 +1,72 @@
import type { I18nInstanceOptions } from "@suaveplan/i18n/plugin";
import type { I18nConfig } from "../schemas/i18n-config.schemas.js";
type Mutable<T> = { -readonly [K in keyof T]: T[K] };
type Required<T, K extends keyof T> = Exclude<T[K], undefined>;
/**
* Translate a Zod-parsed `I18nConfig` (the React-tier shape) into the
* universal-core `I18nInstanceOptions` accepted by `createI18n(...)`.
*
* Field mapping:
*
* - `defaultLocale` → `defaultLocale`
* - `fallbackLocale` → `fallbackLocale` (when present)
* - `initialMessages` → `messages` (the universal core uses the
* shorter name; the React surface uses `initialMessages` so SSR
* semantics are obvious to consumers)
* - `backend` / `backends` / `saveMissing` → forwarded as-is
* - `plugins` → forwarded after a structural cast since the React
* schema only validates the `name` field of each plugin and the core
* accepts the full `I18nPlugin` shape
*
* The function is pure and has no side effects so the Provider can call
* it inside `useMemo` without destabilising the rendered tree.
*
* The `Backend` / `I18nPlugin` / `TranslationRecord` types from the
* universal core are deliberately consumed via structural casts here
* (`as unknown as Required<I18nInstanceOptions, "backend">` etc.) so
* this module does not need a hard dependency on the universal core's
* type-only subpaths beyond `./plugin`. This keeps the React boundary
* loosely coupled: the universal core can re-shape its `Backend` /
* `I18nPlugin` interfaces without invalidating the React surface as
* long as `I18nInstanceOptions` stays compatible.
*/
export function toI18nInstanceOptions(config: I18nConfig): I18nInstanceOptions {
const opts: Mutable<I18nInstanceOptions> = {
defaultLocale: config.defaultLocale,
};
if (config.fallbackLocale !== undefined) {
opts.fallbackLocale = config.fallbackLocale;
}
if (config.initialMessages !== undefined) {
opts.messages = config.initialMessages as unknown as Required<
I18nInstanceOptions,
"messages"
>;
}
if (config.backend !== undefined) {
opts.backend = config.backend as unknown as Required<
I18nInstanceOptions,
"backend"
>;
}
if (config.backends !== undefined) {
opts.backends = config.backends as unknown as Required<
I18nInstanceOptions,
"backends"
>;
}
if (config.saveMissing !== undefined) {
opts.saveMissing = config.saveMissing;
}
if (config.plugins !== undefined) {
opts.plugins = config.plugins as unknown as Required<
I18nInstanceOptions,
"plugins"
>;
}
return opts;
}