first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,72 @@
# `react-i18n.suspense.*` fallback bundle
## Purpose
Ships the strings that `<I18nSuspense>` needs before the active catalog
loads its own copies. Consumers can override these keys by adding entries
under the same dotted path in their own catalog (the catalog's `t()` will
prefer the consumer-provided value). Without this bundle the screen-
reader-only loading label would either be hardcoded English (violating
the package-wide no-hardcoded-strings rule) or empty (a poor a11y
experience while a backend round-trip is in flight).
The bundle is intentionally tiny — only the strings rendered inside the
React-i18n package itself live here. Application-facing copy belongs in
the consumer's own catalog.
## Features
- One frozen object: `REACT_I18N_FALLBACK_BUNDLE`
- Two-token interface: `ReactI18nFallbackKey` enumerates every key
- Stringly-typed values: zero runtime cost, plain JSON
- Consumer override: any key the active catalog defines wins
## Usage: Basic Example
```ts
import {
REACT_I18N_FALLBACK_BUNDLE,
type ReactI18nFallbackKey,
} from "@suaveplan/react-i18n/components/i18n-suspense/fallback-bundle";
const label = REACT_I18N_FALLBACK_BUNDLE["react-i18n.suspense.loading"];
// ^? string
```
## Usage: Advanced Example
Resolving a fallback key with the consumer-provided override taking
precedence over the bundled default:
```ts
import { REACT_I18N_FALLBACK_BUNDLE } from "@suaveplan/react-i18n/components/i18n-suspense/fallback-bundle";
function resolveLoading(i18n: { catalog: { has(k: string): boolean; t(k: string): string } }): string {
const key = "react-i18n.suspense.loading";
return i18n.catalog.has(key)
? i18n.catalog.t(key)
: REACT_I18N_FALLBACK_BUNDLE[key];
}
```
## API Reference
### `REACT_I18N_FALLBACK_BUNDLE: Record<ReactI18nFallbackKey, string>`
Frozen mapping of fallback keys to their default English strings.
### `ReactI18nFallbackKey`
Union of every legal key in the bundle. Currently:
- `'react-i18n.suspense.loading'`
## Implementation Notes
- The bundle ships in source form (no JSON parse cost) so there is zero
runtime initialisation overhead.
- New keys added here MUST be documented in this file and surfaced
through the public exports of the consuming module so consumers know
which keys are eligible for override.
- The strings are deliberately concise. Suspense fallbacks are short-lived;
long sentences confuse screen-reader users who navigate by character.

View File

@@ -0,0 +1,122 @@
# `<I18nSuspense>`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`<I18nSuspense>` is a thin convenience wrapper around React's built-in
`<Suspense>` boundary. It gives a translation-aware default fallback
that announces loading state to assistive technology via
`aria-live="polite"`, increments the
`react_i18n.suspense.fallback.total` OTel counter every time the
fallback renders, and resolves the fallback label through the active
catalog so consumers can localise the loading announcement without
forking the component. The wrapper exists so consumers do not need to
re-implement the same a11y + telemetry boilerplate around every
namespace boundary.
## Features
- Default fallback with `role="status"`, `aria-live="polite"`, and
`aria-busy="true"` for screen-reader-friendly loading state
- Visually hidden loading label so the announcement does not push DOM
layout around
- Counter `react_i18n.suspense.fallback.total{ namespace, locale }`
records every fallback render
- Loading label resolves from the active catalog with the bundled
English string (`react-i18n.suspense.loading`) as the fallback
- Custom `fallback` prop bypasses the default entirely (consumer-owned
spinner, skeleton, or loading screen)
- Zero hardcoded user-facing strings — every visible string flows
through a translation key
## Usage: Basic Example
Wrap a subtree that may suspend during a backend-driven namespace load:
```tsx
import { I18nSuspense } from "@suaveplan/react-i18n/components/i18n-suspense";
<I18nSuspense namespace="auth">
<LoginForm />
</I18nSuspense>;
```
When `LoginForm` reads from a not-yet-loaded namespace and `useTranslation`
throws the preload promise, the wrapper renders the default fallback,
records the metric sample, and re-renders the children once the load
resolves.
## Usage: Advanced Example
Custom fallback + per-namespace telemetry segmentation:
```tsx
import { I18nSuspense } from "@suaveplan/react-i18n/components/i18n-suspense";
<I18nSuspense
namespace="checkout"
fallback={
<div role="status" aria-live="polite" aria-busy="true">
<Spinner />
<span>{t("checkout.loading")}</span>
</div>
}
>
<CheckoutFlow />
</I18nSuspense>;
```
Consumers can override the bundled loading label by adding the
`react-i18n.suspense.loading` key to their catalog:
```ts
const i18n = createI18n({
defaultLocale: "de-DE",
messages: {
"de-DE": {
"react-i18n": {
suspense: { loading: "Übersetzungen werden geladen…" },
},
},
},
});
```
The default fallback will pick up the override automatically.
## API Reference
### `I18nSuspense(props: I18nSuspenseProps): ReactElement`
Props:
- `fallback?: ReactNode` — replaces the default screen-reader-friendly
fallback element. When omitted, the wrapper renders its built-in
`aria-live="polite"` div.
- `namespace?: string` — namespace label attached to the
`react_i18n.suspense.fallback.total` counter sample. Default: `""`.
- `children: ReactNode` — subtree wrapped by the underlying
`<Suspense>` boundary.
## Implementation Notes
- The default-fallback element is a child component (`DefaultFallback`)
rather than an inline `<div>` so the `useEffect` that records the
metric runs only when the fallback is actually rendered (Suspense
defers child rendering, but the fallback always renders fresh on each
suspend cycle).
- A per-instance `useRef<boolean>` guards against React's Strict-Mode
double-invoke so the counter records exactly once per logical
fallback render.
- The visually-hidden style block uses the canonical `clip:
rect(0,0,0,0)` pattern so the label is exposed to assistive tech but
invisible to sighted users.
- `resolveLoadingLabel` checks `i18n.catalog.has` before `t()` so a
missing key does not also fire a `missingKey` event for the fallback
bundle path — the bundled string is the silent default.
- `aria-busy` on the fallback container hints to assistive tech that the
region is updating; combined with `role="status"` + `aria-live="polite"`
this is the WAI-ARIA-recommended pattern for transient loading
indicators.

View File

@@ -0,0 +1,138 @@
# `<LanguageSwitcher>`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`<LanguageSwitcher>` is a headless render-prop component that exposes the
language-switching surface to consumer-controlled UI. It renders no DOM
markup of its own — the caller controls the visual treatment via the
render-prop child. The component bundles the bookkeeping that every
language switcher needs: it normalises the supplied locale list into
`LocaleDescriptor` objects with derived text-direction, subscribes to
`languageChanged` / `loaded` events through the active Provider's
`I18nInstance`, wraps the locale-change call in `useTransition` so the
namespace load triggered by the switch never blocks urgent updates, and
exposes `isPending` so the caller can render a busy state.
## Features
- Headless: zero DOM, zero CSS — render any UI in the render prop
- Locale normalisation: accepts string codes or `LocaleDescriptor` objects
- Auto-derives `direction` (`ltr` / `rtl`) via `getTextDirection` from
`@suaveplan/i18n`
- `change(locale): Promise<void>` returns when the language change
resolves so callers can chain `await change(...)`
- `isPending` aggregates the `useTransition` flag and the awaitable
in-flight flag so both render-time pendingness and async resolution
are surfaced
- Throws `ReactI18nError(REACT_I18N.NO_PROVIDER)` outside a Provider
## Usage: Basic Example
```tsx
import { TranslationProvider } from "@suaveplan/react-i18n";
import { LanguageSwitcher } from "@suaveplan/react-i18n/components/language-switcher";
<TranslationProvider i18n={i18n}>
<LanguageSwitcher locales={["en-GB", "fr-FR", "ar-AE"]}>
{({ locales, current, change, isPending }) => (
<select
value={current}
disabled={isPending}
onChange={(e) => change(e.target.value)}
>
{locales.map((l) => (
<option key={l.code} value={l.code}>
{l.code}
</option>
))}
</select>
)}
</LanguageSwitcher>
</TranslationProvider>;
```
## Usage: Advanced Example
A typed dropdown with custom labels, RTL-aware ordering, and loading
spinner:
```tsx
import { LanguageSwitcher } from "@suaveplan/react-i18n/components/language-switcher";
<LanguageSwitcher
locales={[
{ code: "en-GB", name: "English", nativeName: "English" },
{ code: "fr-FR", name: "French", nativeName: "Français" },
{ code: "ar-AE", name: "Arabic", nativeName: "العربية", direction: "rtl" },
]}
>
{({ locales, current, change, isPending }) => (
<div role="group" aria-label="Language">
{locales.map((l) => (
<button
key={l.code}
type="button"
aria-pressed={l.code === current}
aria-busy={isPending && l.code !== current}
dir={l.direction}
onClick={() => {
void change(l.code);
}}
>
{l.nativeName ?? l.code}
</button>
))}
</div>
)}
</LanguageSwitcher>;
```
## API Reference
### `LanguageSwitcher(props: LanguageSwitcherProps): ReactElement | null`
Props:
- `locales: ReadonlyArray<string | LocaleDescriptor>` — locales to
surface to the render-prop. Strings are upgraded to `{ code,
direction }` descriptors via `getTextDirection`.
- `children: (state: LanguageSwitcherState) => ReactNode` — render-prop
child. Receives the resolved state object.
### `LanguageSwitcherState`
- `locales: readonly LocaleDescriptor[]`
- `current: string`
- `change: (locale: string) => Promise<void>` — wrapped in
`useTransition`; returns when the underlying `i18n.changeLanguage`
resolves
- `isPending: boolean` — true while a transition or awaitable change is
in flight
### `LocaleDescriptor`
- `code: string`
- `name?: string` — Latin-script display name
- `nativeName?: string` — native-script display name
- `direction?: "ltr" | "rtl"` — derived when not supplied
Throws `ReactI18nError(REACT_I18N.NO_PROVIDER)` outside a
`TranslationProvider`.
## Implementation Notes
- The component uses `useSyncExternalStore` so `current` reflects the
active locale across concurrent renders.
- `useTransition` alone surfaces pendingness only during a render. The
component layers an explicit awaiting flag so an `await change(...)`
in user code observes the entire async lifecycle (start → load → end)
rather than only the React-scheduled portion.
- `getTextDirection` is invoked once per render via `useMemo` keyed on
`props.locales`. Stable input arrays therefore avoid re-deriving
direction on every render.
- The render-prop is invoked as a function, not as JSX (`children(state)`),
so React does not treat the function as a component instance — no
`displayName` warnings, no Strict Mode double-invocation surprise.

View File

@@ -0,0 +1,106 @@
# `<MissingKeyOverlay>`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`<MissingKeyOverlay>` is a dev-mode visual diagnostic that surfaces every
`missingKey` event emitted by the active `I18nInstance` as a fixed-position
list anchored to the bottom-right corner of the viewport. The overlay
gives engineers a passive, always-on view of translation gaps as they
develop — no console-tab toggling, no debug flag, no polling. In
production builds the component renders `null` and registers no event
listener so it costs nothing once shipped.
## Features
- Subscribes to `missingKey` events on the Provider's instance bus
- Renders a fixed-position list with `aria-live="polite"` so screen
readers announce new entries without focus theft
- Caps retained entries at the most-recent `maxEntries` (default 50);
older entries silently drop off
- Returns `null` in production (`process.env.NODE_ENV === 'production'`)
- Returns `null` when no Provider is mounted yet (no listener leak)
- Ergonomic style defaults (monospace, dark background) plus per-entry
locale and namespace meta
## Usage: Basic Example
Mount the overlay once near the root of the application — it auto-tracks
every miss for the lifetime of the active Provider:
```tsx
import { TranslationProvider } from "@suaveplan/react-i18n";
import { MissingKeyOverlay } from "@suaveplan/react-i18n/components/missing-key-overlay";
<TranslationProvider config={i18nConfig}>
<App />
<MissingKeyOverlay />
</TranslationProvider>;
```
In dev, every `t('absent.key')` call appears in the overlay. In a
production bundle the component compiles to a no-op return.
## Usage: Advanced Example
A custom cap with explicit prod override and a portal mount so the
overlay sits in a dedicated DOM root:
```tsx
import { createPortal } from "react-dom";
import { MissingKeyOverlay } from "@suaveplan/react-i18n/components/missing-key-overlay";
function DiagnosticsPortal() {
const root = document.getElementById("diagnostics-root");
if (!root) return null;
return createPortal(
<MissingKeyOverlay
// Force the overlay on even in a prod build for QA staging
production={false}
maxEntries={200}
/>,
root
);
}
```
## API Reference
### `MissingKeyOverlay(props?: MissingKeyOverlayProps): ReactElement | null`
Props:
- `maxEntries?: number` — hard cap on retained entries. Default: `50`.
- `production?: boolean` — override for the runtime env-check. When
`true`, the component returns `null` and does not subscribe.
Returns: `null` in production, when no Provider ancestor is mounted, or
when no `missingKey` events have fired yet. Otherwise returns the
fixed-position overlay.
### `MissingKeyEntry`
- `id: number` — monotonically increasing counter (used as React key)
- `key: string` — the missing translation key
- `locale: string` — locale at the moment the event was observed
- `namespace: string` — namespace label (empty string when no namespace)
## Implementation Notes
- The component uses `useEffect` (not `useSyncExternalStore`) for the bus
subscription because the entry list is a piece of internal React state
driven by the bus rather than a snapshot of an external store. The two
patterns are interchangeable here, and `useEffect` is simpler to
reason about for a buffer with bounded capacity.
- The append-and-cap helper (`appendCapped`) does the slice work outside
the React update so the overlay never re-allocates the entire history
on every event — only the bounded tail.
- The fixed-position styles are applied inline so the overlay is
self-contained: no external CSS file, no Tailwind dependency, no risk
of the host app's stylesheet hiding it.
- The `production` prop exists to make the prod-path testable without
mutating `process.env.NODE_ENV`. The default reads `isProductionEnv()`
on every render so consumers do not need to thread the flag through
manually in their own builds.

View File

@@ -0,0 +1,85 @@
# `stringify-children`
## Purpose
Helpers that translate a `<Trans>` `children` block into a canonical
ICU message body string so the message parser renders a missing-key
fallback identically to a catalog entry. The module exists as a sibling
of `<Trans>` (rather than inline) so the trans component file stays
within the per-file size budget and so the structurally-invalid-child
branch can be unit-tested directly without going through React's JSX
surface (which validates and rejects invalid children before they
reach our `Children.forEach` callback).
## Features
- `childrenToMessageString` — entry point that walks `Children.forEach`
and concatenates each child's stringified form
- `stringifyChild` — single-child stringifier, exported for direct unit
testing of the defensive `TRANS_INVALID_CHILD` throw
- Nested children are recursed; tag-wrapped children render as
`<tagName>...</tagName>` with the same component-map semantics
- Boolean, `null`, and `undefined` children render as the empty string
(matches React's own rendering rules)
- Defensive throw of `ReactI18nError(REACT_I18N.TRANS_INVALID_CHILD)`
for any structurally-invalid value (functions, custom objects)
## Usage: Basic Example
```ts
import { childrenToMessageString } from "@suaveplan/react-i18n/components/trans/stringify-children";
const body = childrenToMessageString(
<>Hello <strong>{"{name}"}</strong>!</>,
"absent.key"
);
// body === "Hello <strong>{name}</strong>!"
```
## Usage: Advanced Example
The helpers operate purely on React children, so consumers can use
them outside `<Trans>` for snapshot / logging surfaces where a
React-children tree needs a stable string serialisation:
```ts
import { stringifyChild } from "@suaveplan/react-i18n/components/trans/stringify-children";
function snapshotChild(node: React.ReactNode): string {
try {
return stringifyChild(node, "snapshot");
} catch (err) {
return `[invalid: ${(err as Error).message}]`;
}
}
```
## API Reference
### `childrenToMessageString(children: ReactNode, key: string): string`
Walks `React.Children.forEach` over `children` and concatenates each
result of `stringifyChild`. Returns the literal `key` string when
`children` is `null` or `undefined`.
### `stringifyChild(child: ReactNode, key: string): string`
Stringifies one React child. Recurses into element children via
`childrenToMessageString` for the tag body. Throws
`ReactI18nError(REACT_I18N.TRANS_INVALID_CHILD)` with `context: { key }`
for structurally-invalid input.
## Implementation Notes
- The module is intentionally side-effect-free; both helpers are pure
string returners with the single exception of the defensive throw.
- Element children with no `props.children` produce `<tagName></tagName>`
rather than `<tagName>key</tagName>` — the nested-children helper
reads the children prop with a fall-through to the empty string so
the recursion does not mistakenly inject the outer key into a
childless tag body.
- The defensive throw exists for the edge case where TypeScript's
`ReactNode` typing is bypassed by a caller who hand-constructs a
child via `React.createElement`. In typical JSX usage React's own
pre-render validation rejects invalid values before our callback
runs.

View File

@@ -0,0 +1,143 @@
# `<Trans>`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`<Trans>` renders rich-text translations whose message body contains HTML-like
tag interpolation. The component looks up `i18nKey` in the active catalog,
parses the resolved string through `MessageParser`, and calls
`formatChunks<ReactNode>` so each `<tagName>` placeholder maps to a JSX
element supplied via the `components` prop. When the key is missing from
the catalog, the component falls back to `children` parsed as a default
ICU message, increments the missing-key telemetry counter, and still
performs tag interpolation against the provided `components` map. This
makes `<Trans>` the right surface for anything that mixes plain text with
inline elements (links, bold, code, footnote markers) — `useTranslation`
remains the choice for plain-text values.
## Features
- ICU tag interpolation with arbitrarily deep nesting (`<a><b>nested</b></a>`)
- Plural-branch selection driven by the `count` prop with automatic
`{count}` injection into the interpolation values
- Default-message fallback: `children` are stringified back to an ICU body
when the key is absent from the catalog
- Missing-key telemetry: `react_i18n.missing_key.total{ locale, namespace,
componentName? }` increments on every fallback render
- Render-duration histogram: `react_i18n.render.duration_ms` records the
wall-clock cost of `formatChunks`
- Concurrent-safe subscription: re-renders on `languageChanged` and
`loaded` via `useSyncExternalStore`
- Throws `ReactI18nError(REACT_I18N.NO_PROVIDER)` outside a Provider
- Throws `ReactI18nError(REACT_I18N.TRANS_INVALID_CHILD)` when a child
cannot be matched to a tag pair
## Usage: Basic Example
```tsx
import { TranslationProvider } from "@suaveplan/react-i18n";
import { Trans } from "@suaveplan/react-i18n/components/trans";
const i18n = createI18n({
defaultLocale: "en-GB",
messages: { "en-GB": { welcome: "Hello <b>{name}</b>!" } },
});
export function App() {
return (
<TranslationProvider i18n={i18n}>
<Trans
i18nKey="welcome"
values={{ name: "Alice" }}
components={{ b: <strong /> }}
/>
</TranslationProvider>
);
}
```
The component renders the equivalent of `Hello <strong>Alice</strong>!`.
## Usage: Advanced Example
Plural-aware rendering with nested tags and a missing-key children
fallback that still resolves through the same component map:
```tsx
import { Trans } from "@suaveplan/react-i18n/components/trans";
// Plural branch via count + nested tags
<Trans
i18nKey="cart.summary"
count={items.length}
values={{ name: user.name }}
components={{
b: <strong />,
a: <a href="/cart" />,
}}
/>;
// Default-message fallback when the key is not yet translated
<Trans
i18nKey="onboarding.intro"
values={{ product: "Suaveplan" }}
components={{ em: <em /> }}
>
Welcome to <em>{"{product}"}</em>!
</Trans>;
```
When `cart.summary` is `{ one: '<b>1</b> item by <a>{name}</a>', other:
'<b>{count}</b> items by <a>{name}</a>' }`, the catalog's `t()` selects
the right branch and `formatChunks` substitutes the JSX tags. The fallback
example will render as `Welcome to <em>Suaveplan</em>!` even when
`onboarding.intro` is absent from the catalog.
## API Reference
### `Trans(props: TransProps): ReactElement`
Props:
- `i18nKey: string` — lookup key in the active catalog. Required.
- `values?: Record<string, unknown>` — interpolation variable map. The
`count` prop is auto-merged into this map under key `count`.
- `components?: Record<string, ReactElement>` — tag-name → JSX element
map used by `formatChunks` to substitute `<tagName>...</tagName>`
placeholders.
- `count?: number` — drives plural-branch selection through the
catalog's `t(key, { count })` path.
- `namespace?: string` — optional namespace label attached to telemetry
samples; does NOT alter lookup (the namespace is encoded in `i18nKey`).
- `componentName?: string` — optional caller-supplied component name
attached to missing-key and render-duration samples.
- `children?: ReactNode` — default ICU message used on key miss; element
children serialise to `<tagName>...</tagName>` via `displayName` /
function `name`.
Throws:
- `ReactI18nError(REACT_I18N.NO_PROVIDER)` — when rendered outside a
`TranslationProvider` ancestor.
- `ReactI18nError(REACT_I18N.TRANS_INVALID_CHILD)` — when a fallback
child is neither string, number, boolean, null/undefined, nor a valid
React element (e.g. a function passed directly as a child).
## Implementation Notes
- The parser instance is module-level (`TRANS_PARSER`) so it is reused
across every render — the parser is stateless so this is safe.
- `cloneElement` is used to inject children into each tag-mapped element;
the original `components` map is never mutated.
- Boolean, null, and undefined children are rendered as the empty string
to match React's own rendering rules. Only structurally invalid
children (functions, custom objects) trigger `TRANS_INVALID_CHILD`.
- The component does NOT memoise the rendered output beyond what
`useMemo` provides for the message-resolution step. Re-renders on
parent prop changes always re-run `formatChunks` so React diffs see the
latest tag elements.
- Telemetry is recorded after `formatChunks` returns so the histogram
reflects the parse + render cost rather than the full React commit
cycle. The Provider's bus subscription handles re-render scheduling.