first commit
This commit is contained in:
@@ -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.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user