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.
|
||||
Reference in New Issue
Block a user