Initial Commit
This commit is contained in:
@@ -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`.
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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" },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { createI18nConfig } from "./create-i18n-config.js";
|
||||
export { toI18nInstanceOptions } from "./to-i18n-instance-options.js";
|
||||
@@ -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).
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user