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,135 @@
# `tDynamic` — runtime escape hatch for dynamic translation keys
## Purpose
`tDynamic(i18n, key, values?)` is the rare-use escape hatch for translation lookups whose key cannot be statically known —
the canonical example is mapping an error code to its localised message:
``tDynamic(i18n, `errors.${error.code.toLowerCase()}`)``.
The branded `TranslationKey` contract enforces a closed set of keys at compile time; `tDynamic` is the one supported way to
bypass that contract while still keeping the runtime contract loud and observable.
The function is a plain (non-React) helper so it works in any context — hooks, event handlers, stores, server-side. Inside a
React component, callers obtain `i18n` from `useI18n()` (sub-wave 1c). Outside React, callers thread the instance manually.
Either way, every call emits a `warn`-level log entry and a missing key throws `ReactI18nError(REACT_I18N.UNKNOWN_KEY)` so
no raw key can ever leak into rendered output.
## Features
- **Hard runtime contract** — missing keys throw `ReactI18nError(REACT_I18N.UNKNOWN_KEY)` with `context: { key, locale }`.
- **Always-warn observability** — every successful AND missing call emits one `warn`-level log entry. Dashboards filter on
the `outcome` field of the log context (`"hit"` or `"miss"`) to track drift back to dynamic-key patterns.
- **Optional interpolation** — pass `values` through the same options bag the universal core's `t()` accepts.
- **Injectable sink** — production wires `@suaveplan/logger` via the `sink` option; the default falls back to a
`console.warn` indirection so the helper degrades gracefully when no logger peer is available.
- **Structural typing** — accepts any object matching `TDynamicI18nLike` (mirrors the public surface of
`@suaveplan/i18n`'s `I18nInstance` just enough to drive the lookup), so tests can drive it with a hand-rolled fake.
## Usage: Basic Example
```ts
import { tDynamic } from "@suaveplan/react-i18n";
import { useI18n } from "@suaveplan/react-i18n";
function ErrorBanner({ error }: { error: SuaveplanError }) {
const { i18n } = useI18n();
// Resolves the catalog key `errors.auth.invalid_token` for an
// error whose code is `AUTH.INVALID_TOKEN`.
const message = tDynamic(
i18n,
`errors.${error.code.toLowerCase().replace(".", "_")}`
);
return <div role="alert">{message}</div>;
}
```
If the catalog has no entry under `errors.auth.invalid_token`, the call throws
`ReactI18nError(REACT_I18N.UNKNOWN_KEY)` with `context.key === "errors.auth.invalid_token"` and `context.locale ===
i18n.locale`. The nearest error boundary catches the throw — `tDynamic` never silently returns a key string to the user.
## Usage: Advanced Example — wired logger sink + interpolation
In production, swap the default `console.warn` sink for the package's wired `@suaveplan/logger` warn channel and pass
interpolation values:
```ts
import type { LoggerLike } from "@suaveplan/types";
import { tDynamic } from "@suaveplan/react-i18n";
function localiseValidation(
i18n: I18nInstance,
logger: LoggerLike,
code: string,
fieldName: string
): string {
return tDynamic(i18n, `validation.${code}`, {
values: { field: fieldName },
sink: (message, context) => {
logger.warn(message, context);
},
});
}
```
Two call paths:
- **Hit:** `validation.required` resolves to `"{field} is required"` — `field` interpolates via the universal core's
interpolation engine. The sink fires once with `{ key: "validation.required", locale, outcome: "hit" }`.
- **Miss:** `validation.unknown_thing` is absent — the sink fires once with `{ key: "validation.unknown_thing", locale,
outcome: "miss" }`, then `tDynamic` throws `ReactI18nError(REACT_I18N.UNKNOWN_KEY)` so the caller's error boundary takes
over.
The `sink` is the testability seam: tests pass a `(msg, ctx) => calls.push([msg, ctx])` array-pusher and assert on the
captured calls without touching the global `console`.
## API Reference
### `tDynamic(i18n, key, options?) → string`
Parameters:
- `i18n: TDynamicI18nLike` — active i18n instance. Must expose `locale: string`, `catalog.has(key): boolean`, and
`t(key, opts?): string`. Real `I18nInstance` from `@suaveplan/i18n` satisfies this structurally.
- `key: string` — runtime-derived translation key.
- `options.values?: Record<string, unknown>` — optional interpolation values.
- `options.sink?: (message, context?) => void` — optional warn-log sink (defaults to a `console.warn` indirection).
Returns: the translated string.
Throws: `ReactI18nError(REACT_I18N.UNKNOWN_KEY)` with `context: { key, locale }` when `key` is absent from the active catalog.
Side-effect: every call (hit OR miss) invokes `sink` exactly once with `(WARN_MESSAGE, { key, locale, outcome })` where
`outcome` is `"hit"` or `"miss"`.
### `interface TDynamicI18nLike`
Structural shape required of the `i18n` argument: `locale: string`, `catalog: { has(key): boolean }`, and `t(key, opts?):
string`. Mirrors the public surface of `@suaveplan/i18n`'s `I18nInstance` minimally so the helper stays decoupled from
implementation details.
### `type TranslationValues = Record<string, unknown>`
Convenience alias for the interpolation values bag. Mirrors `TranslateOptions.values` from `@suaveplan/i18n`.
### `interface TDynamicOptions`
`{ values?: TranslationValues; sink?: TDynamicWarnSink }`. Both optional.
### `type TDynamicWarnSink = (message: string, context?: Record<string, unknown>) => void`
Per-call warn-log sink. The default sink is a `console.warn` indirection that no-ops when no `console` is available.
## Implementation Notes
- The presence check uses `i18n.catalog.has(key)` — the universal core's `MessageCatalog` exposes this directly. The helper
short-circuits BEFORE calling `t()` so the warn-then-throw ordering on the miss path is deterministic (sink fires, then
the throw happens in the same tick).
- The thrown `ReactI18nError` carries `context: { key, locale }` so observers can correlate the failure with the offending
call site without parsing the error message.
- The default sink is wrapped in a tiny `invokeConsoleWarn` indirection so the call site stays free of any
static-analysis suppression directive (CLAUDE.md "Critical" rule 9). The indirection is named so call-graph audits can
grep for the single sink call across the package.
- Performance: the hot path is one `Map.has` check + one `t()` call + one sink invocation. The sink invocation is the
intentional cost — it's the observability surface, not overhead.
- Limitation: `tDynamic` cannot enforce that the key was *intended* to be dynamic. Reviewers should reject any PR that
introduces a `tDynamic(i18n, "literal.string")` call — that case must use `useTranslation()` or `<Trans>` instead so the
branded `TranslationKey` gate fires.

View File

@@ -0,0 +1,129 @@
# `TranslationKey` — branded typing contract
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `TranslationKey` type is the spine of the React-i18n no-hardcoded-strings rule. By making translation-accepting prop slots
require a value drawn from a closed union of registered keys, the compiler rejects typos and out-of-bundle keys before they
ever reach the runtime catalog. This sibling module ships the type definitions and an empty pair of augmentable interfaces
that downstream packages (`@suaveplan/react-ui`, `@suaveplan/react-forms`, `@suaveplan/react-ui-theme-*`,
`@suaveplan/react-ui-pack-*`) and consumer applications populate via TypeScript module augmentation.
The two interfaces (`AppTranslationKeys` and `ContributedTranslationKeys`) are deliberately separate. App-side keys live in
`AppTranslationKeys`; package-side keys live in `ContributedTranslationKeys`. The split keeps "who contributed which keys"
auditable and lets the CI gate `scripts/lint/check-no-backwards-deps.ts` enforce that `@suaveplan/react-i18n`'s `package.json`
never lists a leaf package as a dependency. Population always flows in one direction (downstream to here), so the leaf-most
React surface stays leaf-clean by construction.
## Features
- **Empty by default** — the union of `keyof AppTranslationKeys | keyof ContributedTranslationKeys` is `never` until something
augments at least one interface. Out-of-the-box, no string literal satisfies `TranslationKey`.
- **String-compatible head** — `TranslationKey` includes `string` in its intersection so values can be passed straight into
`i18n.t(key)`. The brand only tightens the _consumer-facing_ prop slot.
- **Phantom brand** — the `__brand: "TranslationKey"` field is a compile-time-only marker. No runtime cost, no extra
serialisation surface.
- **Module-augmentation friendly** — both interfaces are open for declaration merging. Augmentation is the only entry point;
there is no `registerKey()` runtime API to misuse.
- **Codegen-ready** — the script `packages/react/react-i18n/scripts/generate-translation-types.ts` reads a package's canonical
`src/locales/en-GB.json` and emits an `augmentation.d.ts` that registers every key into `ContributedTranslationKeys`.
## Usage: Basic Example — consumer-app augmentation
A Next.js (or any consumer) app declares its own keys against `AppTranslationKeys`. Place the snippet in any `.d.ts` file
inside the app's source tree (e.g. `types/translations.d.ts`) — TypeScript picks it up automatically through
`compilerOptions.include`.
```ts
// types/translations.d.ts
import "@suaveplan/react-i18n";
declare module "@suaveplan/react-i18n" {
interface AppTranslationKeys {
"app.welcome": true;
"app.checkout.title": true;
}
}
```
Now `const k: TranslationKey = "app.welcome";` typechecks. `const k: TranslationKey = "app.typo";` does not — the literal
isn't a member of the union.
## Usage: Advanced Example — downstream-package augmentation via codegen
A workspace package such as `@suaveplan/react-ui` ships its keys via the codegen pipeline so the union stays in sync with the
canonical English bundle. The package's build step runs:
```sh
bun packages/react/react-i18n/scripts/generate-translation-types.ts \
packages/react/react-ui \
--package-name @suaveplan/react-ui
```
The script reads `packages/react/react-ui/src/locales/en-GB.json` and writes:
- `packages/react/react-ui/src/locales/translation-keys.ts` — a string-literal union type of every key in the bundle, sorted
alphabetically for diff stability.
- `packages/react/react-ui/src/locales/augmentation.d.ts` — the module-augmentation block:
```ts
// generated — do not edit by hand
import "@suaveplan/react-i18n";
declare module "@suaveplan/react-i18n" {
interface ContributedTranslationKeys {
"react-ui.dialog.close": true;
"react-ui.dialog.confirm": true;
"react-ui.calendar.previousMonth": true;
// …every key in en-GB.json
}
}
```
The package's `src/index.ts` includes a side-effect import (`import "./locales/augmentation.js";`) so consumers who add the
package to their dependency tree automatically inherit the registered keys. The TypeScript service merges every active
`@suaveplan/react-i18n` module-augmentation block, so a consumer app gets the full union of `AppTranslationKeys ∪
ContributedTranslationKeys` everywhere `TranslationKey` appears.
## API Reference
### `interface AppTranslationKeys`
Empty-by-default surface for consumer-app augmentation. Carries one phantom unique-symbol key
(`[APP_TRANSLATION_KEYS_BRAND]?: never`) whose only purpose is to keep biome's `noEmptyInterface` rule quiet — it cannot
collide with any user-supplied augmentation because the symbol is locally scoped and not exported. Augmenting consumers
register translation keys whose value is `true` (the value slot is unused — only the key set matters).
### `interface ContributedTranslationKeys`
Empty-by-default surface for downstream-package augmentation. Same phantom-symbol pattern as `AppTranslationKeys`. Generated
augmentation files come from `generate-translation-types.ts`.
### `type TranslationKey`
`string & Readonly<{ __brand: "TranslationKey" }> & (Extract<keyof AppTranslationKeys, string> | Extract<keyof
ContributedTranslationKeys, string>)`.
- The `string` head keeps `TranslationKey` assignable into APIs typed as `string`.
- The `__brand` field is phantom — present only at compile time.
- `Extract<keyof X, string>` filters out the phantom unique-symbol slot so the keyof union still evaluates to `never` until
string-keyed augmentations land.
## Implementation Notes
- The `__brand` field is wrapped in `Readonly<{ __brand: "TranslationKey" }>` rather than `{ readonly __brand: "TranslationKey" }`
to preserve homomorphic mapped-type behaviour when used inside generics. Both forms are runtime-equivalent; the chosen form
is safer when the type appears in conditional/distributive positions downstream.
- The phantom unique-symbol slot exists for biome compatibility only. Without it, biome's `noEmptyInterface` rule rewrites
`interface Foo {}` → `type Foo = {}` under `--unsafe`, which BREAKS module augmentation. The symbol is `declare const … :
unique symbol`, so it has no runtime presence and no consumer can address it.
- The interfaces are intentionally side-effect-free. Augmentation ordering does not matter — TypeScript's declaration-merging
is commutative.
- The brand is _not_ exported as a value because there is nothing to import. Augment the interface and the union grows
automatically.
- Performance: the union shape only affects type-checking; runtime values are plain strings, so there is zero cost at runtime
and no impact on bundle size.
- Limitation: the brand cannot prevent `as TranslationKey` casts. The CI gate `scripts/lint/check-no-hardcoded-strings.ts`
(sibling change) catches the most common abuse vector — JSX text nodes — at the source level.