first commit
This commit is contained in:
180
docs/localization/i18n/i18n/README.md
Normal file
180
docs/localization/i18n/i18n/README.md
Normal file
@@ -0,0 +1,180 @@
|
||||
# @suaveplan/i18n - Internationalization core: message catalog, ICU-like interpolation, pluralization, locale resolution, middleware, date/currency formatting.
|
||||
|
||||
## Summary
|
||||
|
||||
Slim integration core for the i18n family. After the Wave 6 modular
|
||||
split, this package owns the `MessageCatalog`, the `createI18n` /
|
||||
`I18nInstance` plugin slot, the locale negotiation/resolver, the
|
||||
server middleware, the observability state, the schemas, the
|
||||
key/translation utilities, and the default `FormatBridge` that wires
|
||||
the per-formatter packages into the universal ICU parser.
|
||||
Everything else — constants, error codes, the ICU parser itself, the
|
||||
interpolation engine, the pluralization engine, every formatter, the
|
||||
locale detector chain, and the translation backends — lives in a
|
||||
sibling package and is re-exported here for back-compat.
|
||||
|
||||
Works in Bun, Node.js, and modern browsers (zero DOM dependencies).
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [catalog](./src/core/catalog/catalog.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [catalog](./src/core/catalog/catalog.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [plugin](./src/core/plugin/instance.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [instance](./src/core/plugin/instance.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [negotiation](./src/locale/negotiation/negotiation.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [negotiation](./src/locale/negotiation/negotiation.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [resolver](./src/locale/resolver/resolver.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [resolver](./src/locale/resolver/resolver.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [server](./src/middleware/server/server.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [server](./src/middleware/server/server.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [backend-metrics](./src/observability/backend-metrics.md) — OpenTelemetry instruments for the `Backend` plugin family. Every concrete backend (`MemoryBackend`, `HttpBackend`, `Sto…
|
||||
- [detector-metrics](./src/observability/detector-metrics.md) — This module hosts the OTel instruments and an in-memory test ring for the locale-detector chain. The instruments live h…
|
||||
- [metrics](./src/observability/metrics.md) — `@suaveplan/i18n/observability/metrics` is the package's instrumentation surface. It exposes a lazily-built singleton h…
|
||||
- [format-bridge-default](./src/parser/format-bridge-default/format-bridge-default.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [format-bridge-default](./src/parser/format-bridge-default/format-bridge-default.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [missing-key-reporter](./src/post-processors/missing-key-reporter.md) — A development-time plugin that subscribes to the `missingKey` event on an `I18nInstance` and forwards every miss to a l…
|
||||
- [pseudo-localize](./src/post-processors/pseudo-localize.md) — Layout-stress transform for translated strings. Three modes deliberately produce non-translated output that exercises f…
|
||||
- [schemas](./src/schemas/i18n.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [i18n.schemas](./src/schemas/i18n.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [utils](./src/utils/utils.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [utils](./src/utils/utils.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
<!-- end:auto-docs --> - [catalog](./src/core/catalog/catalog.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/core/plugin/instance.md) — <!-- begin:auto-docs --> _(no submodules)_- [instance](./src/core/plugin/instance.md) — <!-- begin:auto-docs --> _(no submodules)_- [negotiation](./src/locale/negotiation/negotiation.md) — <!-- begin:auto-docs --> _(no submodules)_- [negotiation](./src/locale/negotiation/negotiation.md) — <!-- begin:auto-docs --> _(no submodules)_- [resolver](./src/locale/resolver/resolver.md) — <!-- begin:auto-docs --> _(no submodules)_- [resolver](./src/locale/resolver/resolver.md) — <!-- begin:auto-docs --> _(no submodules)_- [server](./src/middleware/server/server.md) — <!-- begin:auto-docs --> _(no submodules)_- [server](./src/middleware/server/server.md) — <!-- begin:auto-docs --> _(no submodules)_- [backend-metrics](./src/observability/backend-metrics.md) — OpenTelemetry instruments for the `Backend` plugin family. Every concrete backend (`MemoryBackend`, `HttpBackend`, `Sto…
|
||||
- [detector-metrics](./src/observability/detector-metrics.md) — This module hosts the OTel instruments and an in-memory test ring for the locale-detector chain. The instruments live h…
|
||||
- [metrics](./src/observability/metrics.md) — `@suaveplan/i18n/observability/metrics` is the package's instrumentation surface. It exposes a lazily-built singleton h…
|
||||
- [format-bridge-default](./src/parser/format-bridge-default/format-bridge-default.md) — <!-- begin:auto-docs --> _(no submodules)_- [format-bridge-default](./src/parser/format-bridge-default/format-bridge-default.md) — <!-- begin:auto-docs --> _(no submodules)_- [missing-key-reporter](./src/post-processors/missing-key-reporter.md) — A development-time plugin that subscribes to the `missingKey` event on an `I18nInstance` and forwards every miss to a l…
|
||||
- [pseudo-localize](./src/post-processors/pseudo-localize.md) — Layout-stress transform for translated strings. Three modes deliberately produce non-translated output that exercises f…
|
||||
- [schemas](./src/schemas/i18n.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [i18n.schemas](./src/schemas/i18n.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [utils](./src/utils/utils.md) — <!-- begin:auto-docs --> _(no submodules)_- [utils](./src/utils/utils.md) — <!-- begin:auto-docs --> _(no submodules)_- [catalog](./src/core/catalog/catalog.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/core/plugin/instance.md) — <!-- begin:auto-docs --> _(no submodules)_- [instance](./src/core/plugin/instance.md) — <!-- begin:auto-docs --> _(no submodules)_- [negotiation](./src/locale/negotiation/negotiation.md) — <!-- begin:auto-docs --> _(no submodules)_- [negotiation](./src/locale/negotiation/negotiation.md) — <!-- begin:auto-docs --> _(no submodules)_- [resolver](./src/locale/resolver/resolver.md) — <!-- begin:auto-docs --> _(no submodules)_- [resolver](./src/locale/resolver/resolver.md) — <!-- begin:auto-docs --> _(no submodules)_- [server](./src/middleware/server/server.md) — <!-- begin:auto-docs --> _(no submodules)_- [server](./src/middleware/server/server.md) — <!-- begin:auto-docs --> _(no submodules)_- [backend-metrics](./src/observability/backend-metrics.md) — OpenTelemetry instruments for the `Backend` plugin family. Every concrete backend (`MemoryBackend`, `HttpBackend`, `Sto…
|
||||
- [detector-metrics](./src/observability/detector-metrics.md) — This module hosts the OTel instruments and an in-memory test ring for the locale-detector chain. The instruments live h…
|
||||
- [metrics](./src/observability/metrics.md) — `@suaveplan/i18n/observability/metrics` is the package's instrumentation surface. It exposes a lazily-built singleton h…
|
||||
- [format-bridge-default](./src/parser/format-bridge-default/format-bridge-default.md) — <!-- begin:auto-docs --> _(no submodules)_- [format-bridge-default](./src/parser/format-bridge-default/format-bridge-default.md) — <!-- begin:auto-docs --> _(no submodules)_- [missing-key-reporter](./src/post-processors/missing-key-reporter.md) — A development-time plugin that subscribes to the `missingKey` event on an `I18nInstance` and forwards every miss to a l…
|
||||
- [pseudo-localize](./src/post-processors/pseudo-localize.md) — Layout-stress transform for translated strings. Three modes deliberately produce non-translated output that exercises f…
|
||||
- [schemas](./src/schemas/i18n.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [i18n.schemas](./src/schemas/i18n.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [utils](./src/utils/utils.md) — <!-- begin:auto-docs --> _(no submodules)_- [utils](./src/utils/utils.md) — <!-- begin:auto-docs --> _(no submodules)_- [catalog](./src/core/catalog/catalog.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/core/plugin/instance.md) — <!-- begin:auto-docs --> _(no submodules)_- [instance](./src/core/plugin/instance.md) — <!-- begin:auto-docs --> _(no submodules)_- [negotiation](./src/locale/negotiation/negotiation.md) — <!-- begin:auto-docs --> _(no submodules)_- [negotiation](./src/locale/negotiation/negotiation.md) — <!-- begin:auto-docs --> _(no submodules)_- [resolver](./src/locale/resolver/resolver.md) — <!-- begin:auto-docs --> _(no submodules)_- [resolver](./src/locale/resolver/resolver.md) — <!-- begin:auto-docs --> _(no submodules)_- [server](./src/middleware/server/server.md) — <!-- begin:auto-docs --> _(no submodules)_- [server](./src/middleware/server/server.md) — <!-- begin:auto-docs --> _(no submodules)_- [backend-metrics](./src/observability/backend-metrics.md) — OpenTelemetry instruments for the `Backend` plugin family. Every concrete backend (`MemoryBackend`, `HttpBackend`, `Sto…
|
||||
- [detector-metrics](./src/observability/detector-metrics.md) — This module hosts the OTel instruments and an in-memory test ring for the locale-detector chain. The instruments live h…
|
||||
- [metrics](./src/observability/metrics.md) — `@suaveplan/i18n/observability/metrics` is the package's instrumentation surface. It exposes a lazily-built singleton h…
|
||||
- [format-bridge-default](./src/parser/format-bridge-default/format-bridge-default.md) — <!-- begin:auto-docs --> _(no submodules)_- [format-bridge-default](./src/parser/format-bridge-default/format-bridge-default.md) — <!-- begin:auto-docs --> _(no submodules)_- [missing-key-reporter](./src/post-processors/missing-key-reporter.md) — A development-time plugin that subscribes to the `missingKey` event on an `I18nInstance` and forwards every miss to a l…
|
||||
- [pseudo-localize](./src/post-processors/pseudo-localize.md) — Layout-stress transform for translated strings. Three modes deliberately produce non-translated output that exercises f…
|
||||
- [schemas](./src/schemas/i18n.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [i18n.schemas](./src/schemas/i18n.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [utils](./src/utils/utils.md) — <!-- begin:auto-docs --> _(no submodules)_- [utils](./src/utils/utils.md) — <!-- begin:auto-docs --> _(no submodules)_- [catalog](./src/core/catalog/catalog.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/core/plugin/instance.md) — <!-- begin:auto-docs --> _(no submodules)_ - [instance](./src/core/plugin/instance.md) — <!-- begin:auto-docs --> _(no submodules)_- [negotiation](./src/locale/negotiation/negotiation.md) — <!-- begin:auto-docs --> _(no submodules)_ - [negotiation](./src/locale/negotiation/negotiation.md) — <!-- begin:auto-docs --> _(no submodules)_- [resolver](./src/locale/resolver/resolver.md) — <!-- begin:auto-docs --> _(no submodules)_ - [resolver](./src/locale/resolver/resolver.md) — <!-- begin:auto-docs --> _(no submodules)_- [server](./src/middleware/server/server.md) — <!-- begin:auto-docs --> _(no submodules)_ - [server](./src/middleware/server/server.md) — <!-- begin:auto-docs --> _(no submodules)_- [backend-metrics](./src/observability/backend-metrics.md) — OpenTelemetry instruments for the `Backend` plugin family. Every concrete backend (`MemoryBackend`, `HttpBackend`, `Sto…
|
||||
- [detector-metrics](./src/observability/detector-metrics.md) — This module hosts the OTel instruments and an in-memory test ring for the locale-detector chain. The instruments live h…
|
||||
- [metrics](./src/observability/metrics.md) — `@suaveplan/i18n/observability/metrics` is the package's instrumentation surface. It exposes a lazily-built singleton h…
|
||||
- [format-bridge-default](./src/parser/format-bridge-default/format-bridge-default.md) — <!-- begin:auto-docs --> _(no submodules)_ - [format-bridge-default](./src/parser/format-bridge-default/format-bridge-default.md) — <!-- begin:auto-docs --> _(no submodules)_- [missing-key-reporter](./src/post-processors/missing-key-reporter.md) — A development-time plugin that subscribes to the `missingKey` event on an `I18nInstance` and forwards every miss to a l…
|
||||
- [pseudo-localize](./src/post-processors/pseudo-localize.md) — Layout-stress transform for translated strings. Three modes deliberately produce non-translated output that exercises f…
|
||||
- [schemas](./src/schemas/i18n.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_ - [i18n.schemas](./src/schemas/i18n.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [utils](./src/utils/utils.md) — <!-- begin:auto-docs --> _(no submodules)_ - [utils](./src/utils/utils.md) — <!-- begin:auto-docs --> _(no submodules)_
|
||||
## Modules in the slim core
|
||||
|
||||
| Module | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| Catalog | `src/core/catalog/` | `MessageCatalog`, `deepMerge` — namespaced + lazy-loaded translations |
|
||||
| Plugin | `src/core/plugin/` | `createI18n`, `I18nInstance` — facade with plugin slot + post-processors |
|
||||
| Locale negotiation | `src/locale/negotiation/` | `negotiateLocale`, `parseAcceptLanguage` — RFC 4647 lookup |
|
||||
| Locale resolver | `src/locale/resolver/` | `LocaleResolver`, `extractLanguage`, `extractRegion`, `normalizeLocaleCode`, `isValidBcp47` — strategy-based resolver with BCP-47 validation |
|
||||
| Server middleware | `src/middleware/server/` | `createLocaleMiddleware`, `detectLocale`, `getDirection` |
|
||||
| Observability | `src/observability/` | Detector + backend metrics state + OTel adapters |
|
||||
| Schemas | `src/schemas/` | `CatalogOptionsSchema`, `InterpolationConfigSchema`, `ResolverConfigSchema`, `TranslationRecordSchema` |
|
||||
| Utilities | `src/utils/` | `buildKey`, `countKeys`, `flattenRecord`, `getNestedValue`, `getTextDirection`, `hasKey`, `isRtl`, `parseKey`, `sanitize`, `unflattenRecord` |
|
||||
| Default `FormatBridge` | `src/parser/format-bridge-default/` | Wires the formatter packages into `MessageParser.format` |
|
||||
| Subpath shims | `src/subpaths/` | Forwarders for the four kept back-compat subpaths |
|
||||
|
||||
## Re-exports from sibling packages
|
||||
|
||||
Every symbol the pre-split monolith exposed continues to resolve from
|
||||
`@suaveplan/i18n`. The source of each symbol is now a sibling package:
|
||||
|
||||
| Capability | Sibling package |
|
||||
|---|---|
|
||||
| Constants (locale descriptors, RTL set, BCP-47 map, defaults) | `@suaveplan/i18n-shared` |
|
||||
| Error registry (`I18N_CODES`, `I18nError`, `I18nErrorCode`) | `@suaveplan/i18n-shared` |
|
||||
| ICU parser (`MessageParser`, `renderNodes`, AST node types) | `@suaveplan/i18n-icu-parser` |
|
||||
| Interpolation (`InterpolationEngine`, `escapeHtmlChars`, `escapeRegex`) | `@suaveplan/i18n-icu-parser` |
|
||||
| Pluralization (9 CLDR rules, `PluralizationEngine`, `getBaseLocale`) | `@suaveplan/i18n-icu-parser` |
|
||||
| Number formatting (`formatNumber`, helpers, observability) | `@suaveplan/i18n-formatter-number` |
|
||||
| Currency formatting (`formatCurrency`, `formatCurrencyValue`, info helpers) | `@suaveplan/i18n-formatter-currency` |
|
||||
| Date / time formatting (`formatDate`, `formatTime`, `formatDateOnly`, `formatDateRange`, `getDayNames`, `getMonthNames`) | `@suaveplan/i18n-formatter-date` |
|
||||
| Relative time (`formatRelativeTime`, `selectRelativeUnit`, helpers) | `@suaveplan/i18n-formatter-relative-time` |
|
||||
| List formatting (`formatList`, `formatConjunction`, `formatDisjunction`) | `@suaveplan/i18n-formatter-list` |
|
||||
| Display name formatting (`formatDisplayName`, language/region/script helpers) | `@suaveplan/i18n-formatter-display-name` |
|
||||
| Locale detection chain (`createDetectorChain`, every detector factory, direction helpers) | `@suaveplan/i18n-detector` |
|
||||
| Memory backend | `@suaveplan/i18n-backend-memory` |
|
||||
| HTTP backend | `@suaveplan/i18n-backend-http` |
|
||||
| Storage-backed backend | `@suaveplan/i18n-backend-storage` |
|
||||
| Chained backend | `@suaveplan/i18n-backend-chained` |
|
||||
| Filesystem multi-file backend (server-tier — NOT re-exported at root) | `@suaveplan/i18n-backend-fs-multifile` |
|
||||
|
||||
Collator (`@suaveplan/i18n-formatter-collator`) and Segmenter
|
||||
(`@suaveplan/i18n-formatter-segmenter`) are not currently re-exported
|
||||
at the root — consumers import directly from the leaf packages. Add
|
||||
them to `src/index.ts` if cross-package call-sites stabilise.
|
||||
|
||||
## Subpaths
|
||||
|
||||
Four subpaths are kept indefinitely for back-compat with downstream
|
||||
consumers (`@suaveplan/react-i18n`, `react-ui`, `react-forms`):
|
||||
|
||||
| Subpath | Forwards to |
|
||||
|---|---|
|
||||
| `@suaveplan/i18n/detector` | `@suaveplan/i18n-detector` |
|
||||
| `@suaveplan/i18n/parser` | `@suaveplan/i18n-icu-parser` |
|
||||
| `@suaveplan/i18n/plugin` | local `core/plugin/instance.ts` |
|
||||
| `@suaveplan/i18n/errors` | `@suaveplan/i18n-shared` |
|
||||
|
||||
New consumers SHOULD import directly from the sibling package for
|
||||
tighter tree-shaking.
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import { createI18n, defaultFormatBridge, MessageParser } from "@suaveplan/i18n";
|
||||
|
||||
const i18n = createI18n({
|
||||
defaultLocale: "en",
|
||||
fallbackLocale: "en",
|
||||
messages: {
|
||||
en: { greeting: "Hello, {name}!" },
|
||||
fr: { greeting: "Bonjour, {name} !" },
|
||||
},
|
||||
});
|
||||
|
||||
i18n.t("greeting", { values: { name: "world" } });
|
||||
// "Hello, world!"
|
||||
|
||||
// ICU placeholders are rendered through the default FormatBridge
|
||||
// when consumers reach for the parser directly:
|
||||
const parser = new MessageParser();
|
||||
const parsed = parser.parse("Total: {amount, number, currency/USD}");
|
||||
const formatted = parsed.format(
|
||||
{ amount: 49.99 },
|
||||
{ locale: "en-US", formatBridge: defaultFormatBridge }
|
||||
);
|
||||
// "Total: $49.99"
|
||||
```
|
||||
|
||||
## Custom `FormatBridge`
|
||||
|
||||
The slim core's `defaultFormatBridge` covers every method on the
|
||||
`FormatBridge` contract by delegating to the formatter packages.
|
||||
Consumers MAY override individual methods (or build a no-Intl bridge
|
||||
via `noopFormatBridge`):
|
||||
|
||||
```ts
|
||||
import { defaultFormatBridge, noopFormatBridge } from "@suaveplan/i18n";
|
||||
import type { FormatBridge } from "@suaveplan/i18n";
|
||||
|
||||
const customBridge: FormatBridge = {
|
||||
...defaultFormatBridge,
|
||||
relativeTime: () => "(some other language)",
|
||||
};
|
||||
```
|
||||
|
||||
## Bundle size (post-slim, 2026-05-09)
|
||||
|
||||
- `dist/` total: 592 KB
|
||||
- `dist/index.js`: 4.76 KB (1.60 KB gzip) — pure barrel
|
||||
- Heaviest module: `dist/core/catalog/catalog.js` at 17.52 KB (5.48 KB gzip)
|
||||
- 15 modules in the build output (was ~120 pre-Wave-6)
|
||||
|
||||
## Status
|
||||
|
||||
- 320 tests in the slim core (`bunx turbo test --filter=@suaveplan/i18n`) — green
|
||||
- Coverage: 100/100/100/100 on the surviving modules (Istanbul)
|
||||
- Downstream sweep (`bunx turbo test --filter='@suaveplan/react-*'`): 40 tasks green, 1238 tests in `react-ui` alone
|
||||
|
||||
127
docs/localization/i18n/i18n/src/core/catalog/catalog.md
Normal file
127
docs/localization/i18n/i18n/src/core/catalog/catalog.md
Normal file
@@ -0,0 +1,127 @@
|
||||
# MessageCatalog
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`MessageCatalog` is the central registry for translation messages in `@suaveplan/i18n`. It manages per-locale translation records (flat or nested), handles namespace separation, provides typed lookup results, supports async lazy loading, and calls a pluggable missing-key handler when a translation is absent. The catalog is designed to be long-lived in server and browser environments alike, accepting locale switches at runtime without restarting.
|
||||
|
||||
The companion `deepMerge` function is exported separately because it is also used outside the catalog (e.g., when applying partial namespace patches to existing records).
|
||||
|
||||
## Features
|
||||
|
||||
- Generic over `TLocale` and `TMessages` for full type inference
|
||||
- Configurable `keySeparator` (default `.`) and `namespaceSeparator` (default `:`)
|
||||
- Fallback locale chain: primary → fallback → missing-key handler → raw key
|
||||
- `get(key, locale?)` returns a `LookupResult` with `value`, `locale`, and `isFallback` fields
|
||||
- `has(key, locale?)` for existence checks without triggering the missing-key handler
|
||||
- `load(locale, loader)` for async message loading (merges into existing)
|
||||
- `extend(locale, record)` for synchronous merging of additional keys
|
||||
- `keys(locale?)` returns a flat list of all registered dot-separated keys
|
||||
- `deepMerge` performs recursive object merging at the leaf level
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import { MessageCatalog } from "@suaveplan/i18n/catalog";
|
||||
|
||||
const catalog = new MessageCatalog({
|
||||
defaultLocale: "en",
|
||||
messages: {
|
||||
en: { home: { title: "Welcome" } },
|
||||
fr: { home: { title: "Bienvenue" } },
|
||||
},
|
||||
});
|
||||
|
||||
const result = catalog.get("home.title", "fr");
|
||||
// { value: "Bienvenue", locale: "fr", isFallback: false }
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```typescript
|
||||
import { MessageCatalog } from "@suaveplan/i18n/catalog";
|
||||
|
||||
const catalog = new MessageCatalog({
|
||||
defaultLocale: "en",
|
||||
fallbackLocale: "en",
|
||||
namespaceSeparator: ":",
|
||||
onMissingKey: (locale, key) => `[MISSING: ${locale}:${key}]`,
|
||||
});
|
||||
|
||||
// Async load from remote
|
||||
await catalog.load("fr", async (locale) => {
|
||||
const res = await fetch(`/locales/${locale}.json`);
|
||||
return res.json();
|
||||
});
|
||||
|
||||
// Namespace lookup
|
||||
catalog.addMessages("en", { ui: { button: { save: "Save" } } } as never);
|
||||
const r = catalog.get("ui:button.save", "en");
|
||||
// { value: "Save", locale: "en", isFallback: false }
|
||||
|
||||
// Fallback chain
|
||||
const missing = catalog.get("nonexistent.key", "fr");
|
||||
// { value: "[MISSING: fr:nonexistent.key]", locale: "fr", isFallback: false }
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `new MessageCatalog<TLocale, TMessages>(options: ExtendedCatalogOptions)`
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
|--------|------|---------|-------------|
|
||||
| `defaultLocale` | `TLocale` | required | Initial active locale |
|
||||
| `fallbackLocale` | `TLocale?` | — | Used when primary lookup misses |
|
||||
| `messages` | `TranslationCatalogMap?` | — | Pre-loaded messages |
|
||||
| `onMissingKey` | `MissingKeyHandler?` | — | Called when no value found |
|
||||
| `keySeparator` | `string` | `"."` | Path segment separator |
|
||||
| `namespaceSeparator` | `string` | `":"` | Namespace prefix separator |
|
||||
|
||||
### `.get(key, locale?): LookupResult`
|
||||
|
||||
Returns `{ value: string | undefined; locale: string; isFallback: boolean }`.
|
||||
|
||||
### `.has(key, locale?): boolean`
|
||||
|
||||
Returns `true` if the key resolves to a string in the given locale.
|
||||
|
||||
### `.preload(locales, namespaces?): Promise<void>`
|
||||
|
||||
Bulk-load via the registered backend. Prefers `backend.readMulti` when available; falls back to N concurrent `backend.read` calls otherwise. Concurrent calls for the same `(locale, namespace)` pair are deduplicated. Throws `I18nError(BACKEND_NOT_REGISTERED)` if no backend is registered.
|
||||
|
||||
### `.invalidate(locale?, namespace?): void`
|
||||
|
||||
Drop cached entries so the next `getAsync` re-fetches via the backend. With no arguments drops every locale; with `locale` only drops every namespace under it; with both drops just one `(locale, namespace)` pair.
|
||||
|
||||
### `.getAsync(key, locale?): Promise<LookupResult>`
|
||||
|
||||
Async sibling of `.get()` that triggers a backend `read` for the missing namespace and resolves once the load completes. Synchronous `.get()` and `.t()` continue to never trigger backend reads — only `getAsync` does.
|
||||
|
||||
### `.saveMissingKey(locale, key, defaultValue): Promise<void>`
|
||||
|
||||
Best-effort write-back through `backend.save`. Requires `saveMissing: true` in catalog options. Save failures are swallowed (the OTel `i18n.backend.write.total{outcome=error}` counter increments) — saveMissing is a translator-workflow signal, not a critical-path operation.
|
||||
|
||||
### `.load(locale, namespace?, loader?): Promise<void>`
|
||||
|
||||
Calls `loader(locale)` and deep-merges the result.
|
||||
|
||||
### `.extend(locale, record): void`
|
||||
|
||||
Synchronous deep-merge of `record` into the locale.
|
||||
|
||||
### `.keys(locale?): string[]`
|
||||
|
||||
Returns all dot-separated leaf keys for the locale.
|
||||
|
||||
### `deepMerge<T extends TranslationRecord>(target, source): T`
|
||||
|
||||
Recursively merges `source` into `target` without mutating either.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Locale lookups use a `Map<string, TMessages>` for O(1) access per locale
|
||||
- The namespace parsing always splits on the **first** occurrence of `namespaceSeparator`, so namespaces may not themselves contain the separator character
|
||||
- `deepMerge` creates a shallow copy of `target` first, then overlays `source` keys recursively — objects at both levels are merged, primitives in source win
|
||||
- `load` uses `mergeMessages` internally, meaning repeated loads accumulate rather than replace — callers should use `addMessages` when a full replacement is intended
|
||||
92
docs/localization/i18n/i18n/src/core/plugin/instance.md
Normal file
92
docs/localization/i18n/i18n/src/core/plugin/instance.md
Normal file
@@ -0,0 +1,92 @@
|
||||
# I18nInstance
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
The top-level i18n facade. Composes `MessageCatalog`, a plugin registry, an event bus (via `@suaveplan/events`), and a post-processor chain into a single object. Most consumers use `createI18n(options)` and never construct the underlying engines directly.
|
||||
|
||||
The instance is the natural place to wire cross-cutting concerns — backends (registered through the catalog's options), missing-key reporters, post-processors (pseudo-localization, markdown render), language detection. Plugins extend the runtime through a uniform `I18nPlugin` shape; the registry guarantees one-time install, reverse-order dispose, and duplicate-name rejection.
|
||||
|
||||
## Features
|
||||
|
||||
- `createI18n(options)` factory + `I18nInstance` class
|
||||
- `i18n.t(key, options?)` mirrors `MessageCatalog.t` and runs the result through registered post-processors
|
||||
- `i18n.changeLanguage(locale)` emits `languageChanged` (no-op when already on the target locale)
|
||||
- `i18n.use(plugin)` install — sync or async — with duplicate-name rejection
|
||||
- `i18n.dispose()` reverse-order plugin disposal; failures swallowed (best-effort)
|
||||
- `i18n.events` is an `EventBus<I18nEventMap>` exposing `languageChanged | loaded | missingKey | keyAdded | pluralRuleAdded`
|
||||
- `i18n.addPostProcessor(fn)` registers a `(value, key, locale) => string` transformer
|
||||
- New error codes: `I18N.PLUGIN_ALREADY_INSTALLED`
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import { createI18n } from "@suaveplan/i18n";
|
||||
|
||||
const i18n = createI18n({
|
||||
defaultLocale: "en",
|
||||
messages: {
|
||||
en: { greeting: "Hello, {name}!" },
|
||||
},
|
||||
});
|
||||
|
||||
i18n.t("greeting", { values: { name: "Alice" } }); // → "Hello, Alice!"
|
||||
|
||||
await i18n.changeLanguage("fr");
|
||||
i18n.events.on("languageChanged", ({ from, to }) =>
|
||||
console.log(`switched ${from} → ${to}`)
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example — Plugin Lifecycle
|
||||
|
||||
```typescript
|
||||
import { createI18n, type I18nPlugin } from "@suaveplan/i18n";
|
||||
|
||||
const trackingPlugin: I18nPlugin = {
|
||||
name: "missing-key-tracker",
|
||||
kinds: ["logger"],
|
||||
install(i18n) {
|
||||
i18n.events.on("missingKey", ({ locale, namespace, key }) => {
|
||||
reportToTms({ locale, namespace, key });
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
const i18n = createI18n({ defaultLocale: "en", plugins: [trackingPlugin] });
|
||||
// ... at shutdown:
|
||||
await i18n.dispose();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `class I18nInstance<TLocale, TMessages>`
|
||||
|
||||
- `catalog: MessageCatalog<TLocale, TMessages>` — the underlying catalog
|
||||
- `events: EventBus<I18nEventMap>` — the bus for `languageChanged | loaded | missingKey | keyAdded | pluralRuleAdded`
|
||||
- `locale: TLocale` — getter for the active locale
|
||||
- `t(key, options?): string` — translation + post-processor chain
|
||||
- `changeLanguage(locale): Promise<void>` — emits `languageChanged`
|
||||
- `use(plugin): Promise<void>` — register plugin
|
||||
- `addPostProcessor(fn): void` — register post-processor
|
||||
- `getBackend(): Backend | undefined` — proxy through to the catalog's backend
|
||||
- `listPlugins(): readonly string[]` — registered plugin names
|
||||
- `dispose(): Promise<void>` — reverse-order plugin disposal
|
||||
|
||||
### `createI18n(options): I18nInstance`
|
||||
|
||||
Factory form. Equivalent to `new I18nInstance(options)`.
|
||||
|
||||
### `I18nInstanceOptions`
|
||||
|
||||
Extends `ExtendedCatalogOptions` with `plugins?: I18nPlugin[]` and `postProcessors?: PostProcessor[]`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`changeLanguage` is a no-op (no event emission) when the target locale equals the current locale. This matches the i18next contract and prevents reactive consumers from re-rendering on a no-change. If a consumer needs an unconditional event, it can emit on the bus directly: `i18n.events.emit('languageChanged', { from, to })`.
|
||||
|
||||
Plugin disposal failures are intentionally swallowed. A failing `dispose` should not block the rest of the disposal chain — in production, that would leave half-disposed state. Any error logging belongs in the plugin's own `dispose` body before it throws.
|
||||
|
||||
Post-processors run AFTER the catalog's plural / context / interpolation pipeline, so they see fully-substituted strings. The pseudo-localization plugin uses this hook so every `t()` output is wrapped in `[!! ... !!]` markers.
|
||||
@@ -0,0 +1,78 @@
|
||||
# Locale Negotiation
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Provides RFC 4647 locale negotiation for HTTP `Accept-Language` headers. Parses the header into quality-weighted entries and selects the best supported locale using exact matching first, followed by language-only fallback (e.g. `fr-CA` matching `fr-FR` when `fr-CA` is unavailable). Returns the default locale when no candidate meets the quality threshold.
|
||||
|
||||
## Features
|
||||
|
||||
- Parses `Accept-Language` headers including multiple locales with `q=` quality values
|
||||
- Entries sorted by quality score descending before matching
|
||||
- Exact BCP-47 match attempted first (case-insensitive)
|
||||
- Language-only fallback with 10% quality penalty when region variant is unavailable
|
||||
- Configurable quality threshold to reject low-confidence candidates
|
||||
- Returns a `NegotiationResult` with the chosen locale, its effective quality, and an `isDefault` flag
|
||||
- Pure function — no IO, no global state
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import { negotiateLocale } from "@suaveplan/i18n/locale";
|
||||
|
||||
const result = negotiateLocale("en-GB,en;q=0.9,fr;q=0.8", {
|
||||
supported: ["en-US", "fr-FR"],
|
||||
defaultLocale: "en-US",
|
||||
});
|
||||
// result.locale → "en-US" (language-only fallback from en-GB → en-US)
|
||||
// result.quality → 0.9 * 0.9 (10% penalty for region mismatch)
|
||||
// result.isDefault → false
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```typescript
|
||||
import { parseAcceptLanguage, negotiateLocale } from "@suaveplan/i18n/locale";
|
||||
|
||||
// Parse and inspect before negotiation
|
||||
const entries = parseAcceptLanguage("de-AT,de;q=0.9,en;q=0.5");
|
||||
// entries → [
|
||||
// { code: "de-at", quality: 1 },
|
||||
// { code: "de", quality: 0.9 },
|
||||
// { code: "en", quality: 0.5 },
|
||||
// ]
|
||||
|
||||
// Raise the quality threshold to only accept high-confidence matches
|
||||
const result = negotiateLocale("zh-Hans,en;q=0.3", {
|
||||
supported: ["en-US", "de-DE"],
|
||||
defaultLocale: "en-US",
|
||||
threshold: 0.5, // en;q=0.3 is below threshold, zh-Hans has no match
|
||||
});
|
||||
// result.locale → "en-US"
|
||||
// result.isDefault → true
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `parseAcceptLanguage(header: string): AcceptLanguageEntry[]`
|
||||
|
||||
Parses a raw `Accept-Language` header string into a sorted array of quality-weighted entries. Wildcard (`*`) and empty entries are filtered out. Returns an empty array for an empty or whitespace-only header.
|
||||
|
||||
- **`header`** — raw header value, e.g. `"en-GB,en;q=0.9,fr;q=0.8"`
|
||||
- **Returns** `AcceptLanguageEntry[]` sorted by `quality` descending
|
||||
|
||||
### `negotiateLocale<TLocale>(header, options): NegotiationResult<TLocale>`
|
||||
|
||||
Selects the best supported locale for the given `Accept-Language` header.
|
||||
|
||||
- **`header`** — raw `Accept-Language` value
|
||||
- **`options.supported`** — array of supported locale codes
|
||||
- **`options.defaultLocale`** — fallback when no match is found or all candidates are below threshold
|
||||
- **`options.threshold`** — minimum quality to consider (default `0`); set to e.g. `0.1` to ignore `q=0` explicit rejections
|
||||
- **Returns** `NegotiationResult<TLocale>` with `.locale`, `.quality`, `.isDefault`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Matching is done case-insensitively by normalising both the incoming code and the supported locale list to lowercase for comparison, while preserving the original casing in the returned locale string. The language-only fallback applies a 0.9 multiplier to the quality score to signal that it is a less-precise match than an exact hit. This multiplier is intentionally conservative — it keeps a `fr-FR` fallback ranked below an exact `en` match when the header contains `en;q=0.95,fr-CA;q=1`.
|
||||
101
docs/localization/i18n/i18n/src/locale/resolver/resolver.md
Normal file
101
docs/localization/i18n/i18n/src/locale/resolver/resolver.md
Normal file
@@ -0,0 +1,101 @@
|
||||
# LocaleResolver
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`LocaleResolver` maps a requested locale string (or ordered list of preferred locales) to the best available supported locale. It implements a four-level resolution cascade — exact match, alias, language-only, region-family — and returns a confidence score alongside the resolved locale so callers can make decisions based on match quality.
|
||||
|
||||
The standalone exports `normalizeLocaleCode`, `extractLanguage`, and `extractRegion` provide the building blocks for locale string manipulation without instantiating the full resolver.
|
||||
|
||||
## Features
|
||||
|
||||
- Four-tier resolution: exact → alias → language match → region family → default
|
||||
- Case-normalised matching by default (`normalizeCase: true`)
|
||||
- Alias map for legacy or non-standard locale spellings
|
||||
- `resolveFromList` for processing `Accept-Language`-style preference lists
|
||||
- `isSupported`, `getSupportedLocales`, `getDefaultLocale` for introspection
|
||||
- Runtime `addAlias` for dynamic alias registration
|
||||
- Fully generic over `TLocale extends string` for typed locale unions
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import { LocaleResolver } from "@suaveplan/i18n/locale";
|
||||
|
||||
const resolver = new LocaleResolver({
|
||||
supportedLocales: ["en", "fr", "de"],
|
||||
defaultLocale: "en",
|
||||
});
|
||||
|
||||
const result = resolver.resolve("fr-FR");
|
||||
// { locale: "fr", source: "language", confidence: 0.8 }
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```typescript
|
||||
import {
|
||||
LocaleResolver,
|
||||
normalizeLocaleCode,
|
||||
extractLanguage,
|
||||
extractRegion,
|
||||
} from "@suaveplan/i18n/locale";
|
||||
|
||||
const resolver = new LocaleResolver({
|
||||
supportedLocales: ["en-US", "en-GB", "fr-FR"],
|
||||
defaultLocale: "en-US",
|
||||
aliases: { "en": "en-US", "en_gb": "en-GB" },
|
||||
});
|
||||
|
||||
// Accept-Language list resolution
|
||||
const r = resolver.resolveFromList(["de-DE", "fr;q=0.9", "en;q=0.8"]);
|
||||
// { locale: "fr-FR", source: "language", confidence: 0.8 }
|
||||
|
||||
// Standalone helpers
|
||||
console.log(normalizeLocaleCode("EN_US")); // "en-us"
|
||||
console.log(extractLanguage("zh-CN")); // "zh"
|
||||
console.log(extractRegion("en-GB")); // "gb"
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `new LocaleResolver<TLocale>(config: ExtendedResolverConfig<TLocale>)`
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
|--------|------|---------|-------------|
|
||||
| `supportedLocales` | `TLocale[]` | required | Accepted locale codes |
|
||||
| `defaultLocale` | `TLocale` | required | Returned when no match is found |
|
||||
| `aliases` | `Record<string, TLocale>?` | `{}` | Alternative locale spellings |
|
||||
| `normalizeCase` | `boolean?` | `true` | Lowercase all locales before comparison |
|
||||
|
||||
### `.resolve(requested: string): ResolvedLocale<TLocale>`
|
||||
|
||||
Returns `{ locale, source, confidence }`. `confidence` is `1.0` for exact, `0.95` for alias, `0.8` for language, `0.6` for region, `0.0` for default.
|
||||
|
||||
### `.resolveFromList(preferred: string[]): ResolvedLocale<TLocale>`
|
||||
|
||||
Iterates `preferred` and returns the highest-confidence result.
|
||||
|
||||
### `.isSupported(locale: string): boolean`
|
||||
|
||||
Returns `true` if the locale matches any supported locale exactly.
|
||||
|
||||
### `normalizeLocaleCode(locale: string): string`
|
||||
|
||||
Lowercases and replaces underscores with hyphens.
|
||||
|
||||
### `extractLanguage(locale: string): string`
|
||||
|
||||
Returns the language subtag (before the first `-`).
|
||||
|
||||
### `extractRegion(locale: string): string | undefined`
|
||||
|
||||
Returns the region subtag (second `-`-separated segment) or `undefined`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `findByRegion` is intentionally broad: it matches any supported locale sharing the same language subtag, not specifically the requested region. This is the correct fallback for cases like `"en-AU"` resolving to `"en-GB"` when `"en-AU"` is not supported
|
||||
- Aliases are normalised before storage so `addAlias("EN", "en-US")` works as expected regardless of case
|
||||
- The resolver is synchronous; async resolution (e.g., reading user preferences from a database) should be handled at the calling layer
|
||||
100
docs/localization/i18n/i18n/src/middleware/server/server.md
Normal file
100
docs/localization/i18n/i18n/src/middleware/server/server.md
Normal file
@@ -0,0 +1,100 @@
|
||||
# Locale Middleware
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
The server middleware module provides framework-agnostic locale detection for server-side request handling. It inspects request headers, cookies, query parameters, and URL path prefixes to determine the best locale, then returns a typed `MiddlewareResult` containing the resolved locale, the detection source, and the text direction. The module makes no assumptions about the HTTP framework in use — it operates on the `I18nRequest` abstraction, which maps to the minimum interface needed for locale extraction.
|
||||
|
||||
This design allows the middleware to be wrapped by thin adapters for Express, Fastify, H3, Hono, or any other framework without modifying the core logic.
|
||||
|
||||
## Features
|
||||
|
||||
- Configurable lookup order (path, query, cookie, header — any order)
|
||||
- `customExtractor` hook for application-specific locale sources
|
||||
- Accept-Language header negotiation via `negotiateLocale`
|
||||
- Cookie and query parameter locale detection with case-insensitive matching
|
||||
- URL path prefix detection (`/fr/products` → `"fr"`)
|
||||
- `getDirection(locale)` — RTL detection backed by the canonical RTL locale set
|
||||
- `createLocaleMiddleware(config)` — factory for reusable middleware functions
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import { createLocaleMiddleware } from "@suaveplan/i18n/middleware";
|
||||
|
||||
const middleware = createLocaleMiddleware({
|
||||
supportedLocales: ["en", "fr", "de"],
|
||||
defaultLocale: "en",
|
||||
});
|
||||
|
||||
const result = middleware({ headers: { "accept-language": "fr-FR,fr;q=0.9" } });
|
||||
// { locale: "fr", source: "header", direction: "ltr" }
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```typescript
|
||||
import { detectLocale, getDirection } from "@suaveplan/i18n/middleware";
|
||||
|
||||
const result = detectLocale(
|
||||
{
|
||||
path: "/ar/dashboard",
|
||||
cookies: { locale: "fr" },
|
||||
query: { lang: "de" },
|
||||
headers: { "accept-language": "en" },
|
||||
},
|
||||
{
|
||||
supportedLocales: ["en", "fr", "de", "ar"],
|
||||
defaultLocale: "en",
|
||||
lookupOrder: ["path", "query", "cookie", "header"],
|
||||
pathPrefix: true,
|
||||
}
|
||||
);
|
||||
// { locale: "ar", source: "path", direction: "rtl" }
|
||||
|
||||
const dir = getDirection("he-IL"); // "rtl"
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `createLocaleMiddleware<TLocale>(config): (request: I18nRequest) => MiddlewareResult<TLocale>`
|
||||
|
||||
Returns a reusable function that accepts `I18nRequest` and returns `MiddlewareResult`.
|
||||
|
||||
### `detectLocale<TLocale>(request, config): MiddlewareResult<TLocale>`
|
||||
|
||||
Performs the locale detection. Used internally by the middleware function.
|
||||
|
||||
### `getDirection(locale: string): TextDirection`
|
||||
|
||||
Returns `"rtl"` for RTL language codes, otherwise `"ltr"`.
|
||||
|
||||
### `I18nRequest`
|
||||
|
||||
```typescript
|
||||
interface I18nRequest {
|
||||
headers?: Record<string, string | string[] | undefined>;
|
||||
query?: Record<string, string | string[] | undefined>;
|
||||
cookies?: Record<string, string | undefined>;
|
||||
path?: string;
|
||||
}
|
||||
```
|
||||
|
||||
### `MiddlewareResult<TLocale>`
|
||||
|
||||
```typescript
|
||||
interface MiddlewareResult<TLocale> {
|
||||
locale: TLocale;
|
||||
source: "header" | "cookie" | "query" | "path" | "default";
|
||||
direction: "ltr" | "rtl";
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The `lookupFromHeader` function delegates to `negotiateLocale` from the negotiation module; it returns `undefined` (not the default) when the header resolves to the default locale, so the lookup order can continue checking other sources
|
||||
- Cookie and query matching is case-insensitive but not normalised — `"FR"` in a cookie matches `"fr"` in the supported list
|
||||
- `customExtractor` is checked before the standard lookup order; if it returns a supported locale, that locale is used with `source: "query"` as a placeholder source (callers who need the source can extend this)
|
||||
- Path-prefix detection is disabled when `pathPrefix: false`; it is enabled by default
|
||||
@@ -0,0 +1,76 @@
|
||||
# Backend Metrics
|
||||
|
||||
## Purpose
|
||||
|
||||
OpenTelemetry instruments for the `Backend` plugin family. Every concrete backend (`MemoryBackend`, `HttpBackend`, `StorageBackend`, `MultiFileFsBackend`, `ChainedBackend`) emits through this module so consumers see consistent attribute shapes regardless of the underlying source. The module also retains a lightweight in-memory state ring so spec-conformance tests can assert exact attribute payloads without standing up a full OTel SDK exporter pipeline.
|
||||
|
||||
## Features
|
||||
|
||||
- `i18n.backend.read.duration_ms` histogram — labels: `backend.name`, `locale`, `namespace`, `outcome` (`hit | miss | error`)
|
||||
- `i18n.backend.read.total` counter — labels: `backend.name`, `outcome`
|
||||
- `i18n.backend.write.total` counter — labels: `backend.name`, `outcome` (`ok | error`)
|
||||
- `i18n.backend.cache.size` observable gauge — observes the catalog's in-memory `Map.size` via a registered provider callback
|
||||
- All instruments are no-ops when no `MeterProvider` is registered (zero overhead by default)
|
||||
- `getBackendMetricsState()` / `resetBackendMetricsState()` — test-only state inspection
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import { recordRead, recordWrite } from "@suaveplan/i18n/observability";
|
||||
|
||||
// Inside a Backend.read implementation:
|
||||
const t0 = performance.now();
|
||||
try {
|
||||
const record = await source.read(locale, namespace);
|
||||
recordRead(this.name, locale, namespace, "hit", performance.now() - t0);
|
||||
return record;
|
||||
} catch (err) {
|
||||
const outcome = err.code === "I18N.BACKEND_LOCALE_NOT_AVAILABLE" ? "miss" : "error";
|
||||
recordRead(this.name, locale, namespace, outcome, performance.now() - t0);
|
||||
throw err;
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example — Test-Side State Inspection
|
||||
|
||||
```typescript
|
||||
import {
|
||||
getBackendMetricsState,
|
||||
resetBackendMetricsState,
|
||||
} from "@suaveplan/i18n/observability";
|
||||
|
||||
resetBackendMetricsState();
|
||||
const backend = new StorageBackend({ storage: new MemoryStorage() });
|
||||
await backend.save("en", "auth", { ok: "Ok" });
|
||||
await backend.read("en", "auth");
|
||||
|
||||
const state = getBackendMetricsState();
|
||||
expect(state.readDurationMsCount).toBe(1);
|
||||
expect(state.readTotal[0]).toEqual({
|
||||
attributes: { "backend.name": "storage", outcome: "hit" },
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `recordRead(backendName, locale, namespace, outcome, durationMs)`
|
||||
|
||||
Emits one sample on `i18n.backend.read.duration_ms` and one increment on `i18n.backend.read.total`. `outcome` MUST be one of `'hit' | 'miss' | 'error'`. Concrete backends call this exactly once per `read` invocation.
|
||||
|
||||
### `recordWrite(backendName, outcome)`
|
||||
|
||||
Increments `i18n.backend.write.total` by one. `outcome` MUST be `'ok' | 'error'`. Concrete backends call this exactly once per `save` invocation.
|
||||
|
||||
### `registerCacheSizeProvider(provider)`
|
||||
|
||||
Registers the catalog's in-memory map size provider. Called once from `MessageCatalog`'s constructor; the observable gauge invokes the provider every collection cycle.
|
||||
|
||||
### `getBackendMetricsState() / resetBackendMetricsState()`
|
||||
|
||||
Test-only handles. The state ring is **not** intended for production observability — for that, register a `MeterProvider` and use the OTel exporters.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The module uses a single module-level `state` object as the test-side state ring. This is process-global and reset between tests via `resetBackendMetricsState()`. The OTel instruments themselves are also process-global, cached after first construction (`metrics.getMeter` returns the same meter for repeat calls).
|
||||
|
||||
The cache-size gauge uses an observable callback rather than a synchronous gauge because OTel's API only supports observable gauges. The callback consults `cacheSizeProvider` if registered (via `registerCacheSizeProvider`); otherwise it falls back to the in-memory `state.cacheSize` field, which is updated by tests directly.
|
||||
@@ -0,0 +1,113 @@
|
||||
# Detector telemetry — `detector-metrics`
|
||||
|
||||
## Purpose
|
||||
|
||||
This module hosts the OTel instruments and an in-memory test ring for the
|
||||
locale-detector chain. The instruments live here (rather than inside
|
||||
`detector/chain/chain.ts`) so multiple call sites — chain runner, React
|
||||
glue's `useLocaleDetection` hook, future custom detectors — can record
|
||||
through one set of named instruments. The in-memory ring lets
|
||||
spec-conformance tests assert on exact attribute payloads without
|
||||
spinning up a full OTel SDK pipeline.
|
||||
|
||||
The instruments are built lazily via `metrics.getMeter('suaveplan.i18n')`
|
||||
so packages that don't wire an SDK pay zero overhead.
|
||||
|
||||
## Features
|
||||
|
||||
- `i18n.detector.match_total{detector}` counter — incremented on every
|
||||
detector match
|
||||
- `i18n.detector.duration_ms{detector}` histogram — recorded for every
|
||||
async detector execution
|
||||
- `i18n.detect` span — opened by `createDetectorChain.detect` to wrap the
|
||||
whole chain run
|
||||
- `getDetectorMetrics()` — returns the cached instruments + tracer
|
||||
- `recordDetectorMatch(detector)` / `recordDetectorDuration(detector, ms)`
|
||||
— emit + push to in-memory state
|
||||
- `getDetectorMetricsState()` / `resetDetectorMetricsState()` —
|
||||
test-only state inspection helpers
|
||||
- `resetDetectorMetricsCache()` — drop cached meter (for tests that swap
|
||||
the global MeterProvider)
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { recordDetectorMatch } from "@suaveplan/i18n";
|
||||
|
||||
// From inside a custom detector wrapper:
|
||||
recordDetectorMatch("my-custom-detector");
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Asserting on emitted samples in a test:
|
||||
|
||||
```ts
|
||||
import {
|
||||
getDetectorMetricsState,
|
||||
recordDetectorMatch,
|
||||
recordDetectorDuration,
|
||||
resetDetectorMetricsState,
|
||||
} from "@suaveplan/i18n";
|
||||
import { afterEach, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
afterEach(() => {
|
||||
resetDetectorMetricsState();
|
||||
});
|
||||
|
||||
it("emits a match counter with the winning detector name", () => {
|
||||
recordDetectorMatch("cookie");
|
||||
recordDetectorDuration("user-preference", 42);
|
||||
|
||||
const state = getDetectorMetricsState();
|
||||
expect(state.matchTotal).toEqual([{ attributes: { detector: "cookie" } }]);
|
||||
expect(state.durationMs[0]?.value).toBe(42);
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `function getDetectorMetrics(): { tracer, matchTotal, durationMs }`
|
||||
|
||||
Returns the cached OTel instruments. Builds them on first call via
|
||||
`metrics.getMeter('suaveplan.i18n')` and `trace.getTracer('suaveplan.i18n')`.
|
||||
Idempotent.
|
||||
|
||||
### `function recordDetectorMatch(detector: string): void`
|
||||
|
||||
Increments `i18n.detector.match_total{detector}` by one and appends a
|
||||
sample to the in-memory state ring.
|
||||
|
||||
### `function recordDetectorDuration(detector: string, durationMs: number): void`
|
||||
|
||||
Records a sample on `i18n.detector.duration_ms{detector}` and appends a
|
||||
sample to the state ring.
|
||||
|
||||
### `function getDetectorMetricsState(): DetectorMetricsState`
|
||||
|
||||
Returns the live (mutable but stable-reference) state ring. Tests should
|
||||
call `resetDetectorMetricsState()` before reading.
|
||||
|
||||
### `function resetDetectorMetricsState(): void`
|
||||
|
||||
Truncates both arrays in the state ring. Safe to call from
|
||||
`beforeEach` / `afterEach`.
|
||||
|
||||
### `function resetDetectorMetricsCache(): void`
|
||||
|
||||
Drops the cached instrument bundle so the next `getDetectorMetrics()` call
|
||||
rebuilds against the currently-registered MeterProvider. Use only in
|
||||
tests that swap the global MeterProvider mid-suite.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The state ring is a single shared mutable object — fine for tests where
|
||||
each case resets between runs, but unsuitable for production analytics
|
||||
because it never bounds memory. The design tradeoff is intentional: the
|
||||
ring exists *only* to make spec-conformance assertions cheap. Production
|
||||
observability runs through the OTel pipeline.
|
||||
|
||||
The cached instruments bundle includes the tracer too; `chain.ts` opens
|
||||
its `i18n.detect` span via `tracer.startSpan(...)` so test runs have a
|
||||
single place to wire a `TracerProvider` and observe the resulting span
|
||||
tree.
|
||||
59
docs/localization/i18n/i18n/src/observability/metrics.md
Normal file
59
docs/localization/i18n/i18n/src/observability/metrics.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# OpenTelemetry Metrics
|
||||
|
||||
## Purpose
|
||||
|
||||
`@suaveplan/i18n/observability/metrics` is the package's instrumentation surface. It exposes a lazily-built singleton holding the three OpenTelemetry instruments mandated by the i18n capability spec: `i18n.translate.total`, `i18n.locale.resolve.total`, and `i18n.translate.duration_ms`. The singleton is wired into `MessageCatalog.get` and the locale-detection middleware so consumers get production telemetry without writing any additional code.
|
||||
|
||||
The instruments are obtained from the global `metrics` API (`@opentelemetry/api`), which means they are **no-ops by default** — recording incurs effectively zero cost when the application has not registered a `MeterProvider`. Wiring up an SDK (for example `@opentelemetry/sdk-node`) immediately starts capturing real measurements without changing any i18n code.
|
||||
|
||||
## Features
|
||||
|
||||
- One `getMeter('suaveplan.i18n')` per process; cached after first call.
|
||||
- Counter `i18n.translate.total` with attributes `{ outcome: 'hit' | 'miss', locale }`.
|
||||
- Counter `i18n.locale.resolve.total` with attributes `{ strategy, locale }`.
|
||||
- Histogram `i18n.translate.duration_ms` with attribute `{ locale }`, unit `"ms"`.
|
||||
- `resetI18nMetrics()` test hook for swapping in a fresh `MeterProvider` between assertions.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { MessageCatalog } from "@suaveplan/i18n";
|
||||
|
||||
const catalog = new MessageCatalog({
|
||||
defaultLocale: "en",
|
||||
messages: { en: { home: { title: "Welcome" } } },
|
||||
});
|
||||
|
||||
// No meter provider registered → counter increments are no-ops.
|
||||
catalog.get("home.title");
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import { MeterProvider, InMemoryMetricReader } from "@opentelemetry/sdk-metrics";
|
||||
import { resetI18nMetrics } from "@suaveplan/i18n/observability/metrics";
|
||||
|
||||
const reader = new InMemoryMetricReader();
|
||||
metrics.setGlobalMeterProvider(new MeterProvider({ readers: [reader] }));
|
||||
resetI18nMetrics();
|
||||
|
||||
// All subsequent MessageCatalog.get / detectLocale calls record real metrics.
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `getI18nMetrics(): I18nMetrics`
|
||||
|
||||
Returns the cached singleton `{ translateTotal, localeResolveTotal, translateDuration }`. First call constructs the instruments via `metrics.getMeter('suaveplan.i18n')`; subsequent calls return the same object.
|
||||
|
||||
### `resetI18nMetrics(): void`
|
||||
|
||||
Drops the cache so the next `getI18nMetrics()` call rebuilds against the current global `MeterProvider`. Use only in tests; production code should not invalidate the cache.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The singleton is module-scoped, not per-class, so all consumers within a process share the same `Meter` and the global SDK only sees one set of instrument registrations.
|
||||
- `Counter` and `Histogram` are imported as types only — the runtime API uses the global `metrics` proxy, which is the recommended pattern from the OpenTelemetry JS authors for libraries that should not bundle the SDK.
|
||||
- No-op behaviour is guaranteed by `@opentelemetry/api`'s built-in fallback `NoopMeterProvider`; we do not need to guard call sites.
|
||||
@@ -0,0 +1,123 @@
|
||||
# `defaultFormatBridge`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`defaultFormatBridge` is the slim-core wiring between
|
||||
`@suaveplan/i18n-icu-parser`'s `MessageParser` renderer and the
|
||||
per-formatter packages shipped in Wave 3 of the i18n modular split. It
|
||||
implements every method on the `FormatBridge` contract — `number`,
|
||||
`currency`, `date`, `time`, `relativeTime` — by delegating to
|
||||
`@suaveplan/i18n-formatter-number`, `-currency`, `-date`, and
|
||||
`-relative-time`. Consumers that build an `I18nInstance` via
|
||||
`createI18n({ ... })` get locale-aware ICU placeholder rendering
|
||||
without any manual bridge wiring; advanced consumers who need a
|
||||
reduced or specialised bridge can pass their own through the parser's
|
||||
`RenderOptions.formatBridge` slot.
|
||||
|
||||
## Features
|
||||
|
||||
- Full coverage of the `FormatBridge` contract (5 methods)
|
||||
- Frozen singleton — accidental mutation surfaces as a `TypeError`
|
||||
- Coerces `Date | number` (epoch-millis) inputs to `Date` for the date
|
||||
and time methods — the underlying formatters require `Date`
|
||||
- Maps ICU number sub-styles (`decimal`, `percent`, `integer`,
|
||||
`compact`, `scientific`, `engineering`) onto the formatter's option
|
||||
bundles
|
||||
- Defaults the date `style` to `"medium"` to match the monolith's
|
||||
pre-split behaviour
|
||||
- Re-exports `formatDateOnly` from `@suaveplan/i18n-formatter-date` for
|
||||
consumers that previously imported it from the in-tree
|
||||
`formatting/date` module
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { createI18n } from "@suaveplan/i18n";
|
||||
import { MessageParser } from "@suaveplan/i18n";
|
||||
import { defaultFormatBridge } from "@suaveplan/i18n";
|
||||
|
||||
const parser = new MessageParser();
|
||||
const parsed = parser.parse("Hello, you have {n, number} messages.");
|
||||
const rendered = parsed.format(
|
||||
{ n: 1234.5 },
|
||||
{ locale: "en-US", formatBridge: defaultFormatBridge }
|
||||
);
|
||||
// "Hello, you have 1,234.5 messages."
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Construct a custom bridge that overrides only the methods you care
|
||||
about, falling back to the default for the rest:
|
||||
|
||||
```ts
|
||||
import { defaultFormatBridge } from "@suaveplan/i18n";
|
||||
import type { FormatBridge } from "@suaveplan/i18n";
|
||||
import { renderNodes, MessageParser } from "@suaveplan/i18n";
|
||||
|
||||
const parser = new MessageParser();
|
||||
const parsed = parser.parse("Bonjour {n, number, percent}");
|
||||
|
||||
const customBridge: FormatBridge = {
|
||||
...defaultFormatBridge,
|
||||
number(value, locale, style) {
|
||||
// Force fr-CA grouping regardless of caller-supplied locale
|
||||
return defaultFormatBridge.number?.(value, "fr-CA", style) ??
|
||||
String(value);
|
||||
},
|
||||
};
|
||||
|
||||
const out = renderNodes(
|
||||
parsed.nodes,
|
||||
{ n: 0.42 },
|
||||
{ locale: "en-US", formatBridge: customBridge }
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `defaultFormatBridge: Readonly<FormatBridge>`
|
||||
|
||||
A frozen object implementing every method on the `FormatBridge`
|
||||
contract. Each method delegates to the corresponding formatter package:
|
||||
|
||||
- `number(value, locale, style?)` — `formatNumber` with style mapping
|
||||
- `currency(value, currency, locale)` — `formatCurrency`
|
||||
- `date(value, locale, style?)` — `formatDate` with `preset` defaulted
|
||||
to `"medium"`
|
||||
- `time(value, locale, longForm?)` — `formatTime` with `long`
|
||||
parameter
|
||||
- `relativeTime(value, locale, style?)` — `formatRelativeTime` with
|
||||
optional `style` option
|
||||
|
||||
### `formatDateOnly(date, locale, long?)`
|
||||
|
||||
Re-exported from `@suaveplan/i18n-formatter-date`. Convenience for
|
||||
date-only formatting without the time portion.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The bridge is `Object.freeze`d at module load time. Any `import`
|
||||
receives the same singleton; reassigning a method on the import
|
||||
throws a `TypeError` in strict mode (which is the runtime mode for
|
||||
every ESM consumer).
|
||||
- Number style mapping deliberately falls through unknown styles to
|
||||
the formatter's default decimal output rather than throwing — this
|
||||
matches the pre-split behaviour where `mapNumberStyle` returned the
|
||||
raw string and the formatter ignored unknown notations.
|
||||
- `Date | number` coercion uses `new Date(value)` for non-`Date`
|
||||
inputs. Invalid epoch-millis (`NaN`, non-finite) produces an
|
||||
`Invalid Date` that the formatters surface as locale-specific
|
||||
"Invalid Date" output rather than throwing — preserving the
|
||||
monolith's never-throws-on-format invariant.
|
||||
- The bridge does not cache — each formatter package owns its own
|
||||
per-locale cache (`Intl.NumberFormat`, `Intl.DateTimeFormat`,
|
||||
`Intl.RelativeTimeFormat`). Calling the bridge twice with the same
|
||||
arguments hits each formatter's cache on the second call.
|
||||
- `time` always passes `long` as a strict boolean. `longForm === true`
|
||||
produces the long form (with seconds); any other input — including
|
||||
`undefined` — produces the short form. This matches the
|
||||
parser's documented `time` style mapping (`long` is opt-in).
|
||||
@@ -0,0 +1,144 @@
|
||||
# `createMissingKeyReporter`
|
||||
|
||||
## Purpose
|
||||
|
||||
A development-time plugin that subscribes to the `missingKey` event on
|
||||
an `I18nInstance` and forwards every miss to a logger, callback, or
|
||||
the underlying backend. The reporter is the standard tool for
|
||||
discovering untranslated keys during development without paying the
|
||||
runtime cost of strict-mode lookups in production. It composes with
|
||||
the slim core's plugin slot — register it once with
|
||||
`createI18n({ plugins: [createMissingKeyReporter(...)] })` and every
|
||||
`catalog.get` miss becomes a structured warning.
|
||||
|
||||
## Features
|
||||
|
||||
- Default behaviour: emits a `warn`-level log line per missing key,
|
||||
carrying the full payload (`locale`, `namespace`, `key`,
|
||||
`defaultValue`) as structured context
|
||||
- Custom logger injection via `options.logger` — accepts any object
|
||||
with a `warn(message, context?)` method, including `console`,
|
||||
`@suaveplan/logger`, or a translation-management-system client
|
||||
- Custom callback via `options.onMissing` — receives the same payload
|
||||
as the event so consumers can route to TMS providers (Phrase,
|
||||
Lokalise, Crowdin) or alerting systems
|
||||
- Best-effort backend write-back via `options.persistMissing: true` —
|
||||
invokes `i18n.catalog.saveMissingKey` so the default value lands in
|
||||
the backend for translator pickup. Requires the catalog to be
|
||||
constructed with `saveMissing: true` AND a backend with a `save`
|
||||
method. The reporter does not enforce these preconditions; it
|
||||
delegates entirely to the catalog's best-effort contract.
|
||||
- Pure plugin shape — the reporter installs in `O(1)` time and adds
|
||||
zero overhead to the hot translate path. Every miss already emits
|
||||
an event; the reporter is a passive listener.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { createI18n } from "@suaveplan/i18n";
|
||||
import { createMissingKeyReporter } from "@suaveplan/i18n";
|
||||
|
||||
const i18n = createI18n({
|
||||
defaultLocale: "en",
|
||||
messages: { en: { hi: "Hello" } },
|
||||
plugins: [createMissingKeyReporter()],
|
||||
});
|
||||
|
||||
i18n.t("missing.key");
|
||||
// → console.warn(
|
||||
// "[i18n] missing key 'missing.key' for locale 'en'",
|
||||
// { locale: 'en', namespace: '', key: 'missing.key' }
|
||||
// )
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Forward every miss to a TMS provider AND persist the default value
|
||||
back to a Memory backend so the next page load resolves the key:
|
||||
|
||||
```ts
|
||||
import { createI18n } from "@suaveplan/i18n";
|
||||
import { createMissingKeyReporter } from "@suaveplan/i18n";
|
||||
import { MemoryBackend } from "@suaveplan/i18n";
|
||||
|
||||
const tms = {
|
||||
sendMissing(payload: {
|
||||
locale: string;
|
||||
key: string;
|
||||
defaultValue?: string;
|
||||
}) {
|
||||
return fetch("https://tms.example/missing", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
const i18n = createI18n({
|
||||
defaultLocale: "en",
|
||||
backend: new MemoryBackend(),
|
||||
saveMissing: true,
|
||||
plugins: [
|
||||
createMissingKeyReporter({
|
||||
persistMissing: true,
|
||||
onMissing: (payload) => {
|
||||
void tms.sendMissing({
|
||||
locale: payload.locale,
|
||||
key: payload.key,
|
||||
defaultValue: payload.defaultValue,
|
||||
});
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `createMissingKeyReporter(options?: MissingKeyReporterOptions): I18nPlugin`
|
||||
|
||||
Returns an `I18nPlugin` named `"missing-key-reporter"` with the
|
||||
`"logger"` kind tag. Install via `i18n.use(plugin)` or the
|
||||
`createI18n({ plugins })` shortcut. Subsequent `catalog.get` misses
|
||||
trigger the configured behaviour.
|
||||
|
||||
### `MissingKeyReporterOptions`
|
||||
|
||||
- `logger?: MissingKeyReporterLogger` — defaults to `console`. Any
|
||||
shape with `warn(message: string, context?: Record<string, unknown>)`
|
||||
satisfies the contract.
|
||||
- `onMissing?: (payload: { locale, namespace, key, defaultValue? }) => void`
|
||||
— when present, replaces the default `logger.warn` call so consumers
|
||||
can route to TMS / alerting / observability without double-logging.
|
||||
- `persistMissing?: boolean` — when `true`, additionally calls
|
||||
`i18n.catalog.saveMissingKey(locale, key, defaultValue ?? key)`.
|
||||
Requires `saveMissing: true` on the catalog AND a backend with
|
||||
`save` support.
|
||||
|
||||
### `MissingKeyReporterLogger`
|
||||
|
||||
```ts
|
||||
interface MissingKeyReporterLogger {
|
||||
warn(message: string, context?: Record<string, unknown>): void;
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `onMissing` is mutually exclusive with the default `logger.warn`
|
||||
path. Consumers that want both behaviours can call `console.warn`
|
||||
inside their `onMissing` callback explicitly.
|
||||
- `persistMissing` swallows errors via the catalog's best-effort
|
||||
contract (see `MessageCatalog.saveMissingKey` — backend errors
|
||||
surface as a `recordWrite(name, "error")` OTel counter increment
|
||||
but never propagate).
|
||||
- Throw-on-missing is OUT OF SCOPE for this reporter. Use
|
||||
`MessageCatalog`'s `strict: true` option to throw `I18nError(MISSING_KEY)`
|
||||
on every miss; the reporter is purely observational.
|
||||
- The reporter does not deduplicate misses. Every event triggers one
|
||||
invocation. If a key is requested 1,000 times across a render pass,
|
||||
the reporter logs/forwards 1,000 times. Layer a simple `Set` of
|
||||
seen keys inside `onMissing` if dedup is required.
|
||||
- The plugin is idempotent at registration: installing it twice
|
||||
throws `I18nError(PLUGIN_ALREADY_INSTALLED)` because every
|
||||
`I18nPlugin.name` must be unique on a single instance.
|
||||
@@ -0,0 +1,61 @@
|
||||
# Pseudo-Localization
|
||||
|
||||
## Purpose
|
||||
|
||||
Layout-stress transform for translated strings. Three modes deliberately produce non-translated output that exercises font fallback (`'accent'`), visually flags every un-translated string in QA (`'bracket'`), and tests layout overflow (`'expand'`). Use during development with a feature flag, or via the `pseudoLocalizationPlugin` post-processor on an `I18nInstance` so every `t()` call routes through it.
|
||||
|
||||
This is the standard layout-bug-finding workflow — i18next, FormatJS, LinguiJS all ship a similar primitive. The "accent" mode produces text like `Wéłçömé` that's still legible to engineers but immediately reveals which strings are actually translatable in the UI vs. which are hardcoded. "Bracket" mode wraps every translated string in `[!! ... !!]` markers — perfect for ride-along visual QA. "Expand" mode pads strings by a configurable ratio so layout-overflow bugs surface during development rather than after a German release.
|
||||
|
||||
## Features
|
||||
|
||||
- Three modes: `'accent'` (default), `'bracket'`, `'expand'`
|
||||
- `expandRatio` option for `'expand'` mode (default 0.3 → 30% padding)
|
||||
- Pure function — no state, no side effects
|
||||
- ASCII letters mapped to diacritic equivalents using a 52-entry table (covers `a-z` and `A-Z`)
|
||||
- Non-letters (digits, punctuation, whitespace, emoji) pass through unchanged
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import { pseudoLocalize } from "@suaveplan/i18n/post-processors";
|
||||
|
||||
pseudoLocalize("Welcome"); // → "Wéłçömé"
|
||||
pseudoLocalize("Welcome", { mode: "bracket" }); // → "[!! Welcome !!]"
|
||||
pseudoLocalize("Hi", { mode: "expand", expandRatio: 2 }); // → "Hi~~~~"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example — Plugin Form (planned)
|
||||
|
||||
```typescript
|
||||
import { createI18n } from "@suaveplan/i18n";
|
||||
import { pseudoLocalizationPlugin } from "@suaveplan/i18n/post-processors";
|
||||
|
||||
const i18n = createI18n({ defaultLocale: "en", ... });
|
||||
|
||||
if (process.env.PSEUDO_LOCALIZE === "1") {
|
||||
i18n.use(pseudoLocalizationPlugin({ mode: "bracket" }));
|
||||
}
|
||||
|
||||
i18n.t("home.title"); // → "[!! Welcome !!]"
|
||||
```
|
||||
|
||||
The plugin form lands as part of the `I18nInstance` plugin lifecycle (§8 of `i18n-completion`).
|
||||
|
||||
## API Reference
|
||||
|
||||
### `pseudoLocalize(text, options?): string`
|
||||
|
||||
Applies the chosen transform. Default mode is `'accent'`.
|
||||
|
||||
### `PseudoLocalizeOptions`
|
||||
|
||||
- `mode?: 'accent' | 'bracket' | 'expand'` (default `'accent'`)
|
||||
- `expandRatio?: number` (default `0.3`; only used when `mode === 'expand'`)
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The accent table maps ASCII letters one-to-one to common Latin-script diacritic variants. The mapping is deliberately not exhaustive — uppercase `J` maps to `Ĵ` rather than the more diacritic-heavy `J̈` because the latter is two code points and breaks string-length computations. Where a single-codepoint diacritic exists, we use it; otherwise the letter passes through.
|
||||
|
||||
Bracket mode markers (`[!! ... !!]`) are an industry convention dating to LinguiJS / FormatJS pseudo-locale tooling. Choose your own markers if you need URL-safe variants — but the engineering team you're handing off to will likely already recognise the standard form.
|
||||
|
||||
Expand mode pads with `~` characters because they're visually obvious in any font and don't introduce false-positive layout breaks. The padding sits *after* the original text so screen-reader output for QA still announces the original string.
|
||||
96
docs/localization/i18n/i18n/src/schemas/i18n.schemas.md
Normal file
96
docs/localization/i18n/i18n/src/schemas/i18n.schemas.md
Normal file
@@ -0,0 +1,96 @@
|
||||
# i18n Zod Schemas
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Provides Zod validation schemas for the four primary configuration boundaries of `@suaveplan/i18n`: translation records, catalog options, interpolation config, and resolver config. Schemas act as runtime guardrails at API entry points, converting unknown external data (JSON configs, HTTP request bodies, environment variables) into fully typed, validated values before they touch internal logic.
|
||||
|
||||
Using Zod here rather than manual `if`/`throw` guards ensures consistent error messages, supports `.safeParse` for non-throwing paths, and makes the valid input shape visible to consumers who use `z.infer`.
|
||||
|
||||
## Features
|
||||
|
||||
- `TranslationRecordSchema` — recursive Zod schema for nested translation trees
|
||||
- `CatalogOptionsSchema` — validates catalog constructor options including separators and missing-key handler
|
||||
- `InterpolationConfigSchema` — validates delimiter pairs, HTML escape flag, and nesting depth
|
||||
- `ResolverConfigSchema` — validates supported-locale arrays, fallback strategy, and alias maps
|
||||
- Exported `*Input` type aliases via `z.input<>` for correct generic typing
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import { CatalogOptionsSchema } from "@suaveplan/i18n/schemas";
|
||||
|
||||
const opts = CatalogOptionsSchema.parse({
|
||||
defaultLocale: "en",
|
||||
fallbackLocale: "en",
|
||||
});
|
||||
// opts is fully typed CatalogOptions
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```typescript
|
||||
import {
|
||||
ResolverConfigSchema,
|
||||
InterpolationConfigSchema,
|
||||
} from "@suaveplan/i18n/schemas";
|
||||
|
||||
// Safe parse for non-throwing validation
|
||||
const result = ResolverConfigSchema.safeParse(unknownConfig);
|
||||
if (!result.success) {
|
||||
console.error(result.error.format());
|
||||
} else {
|
||||
const { supportedLocales, defaultLocale } = result.data;
|
||||
}
|
||||
|
||||
// Interpolation config with defaults applied
|
||||
const cfg = InterpolationConfigSchema.parse({});
|
||||
// cfg.prefix === "{{", cfg.suffix === "}}", cfg.maxDepth === 10
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `TranslationRecordSchema`
|
||||
|
||||
`z.ZodType<Record<string, string | Record<string, unknown>>>`
|
||||
|
||||
Recursive lazy schema. Validates that every leaf is a `string` and every non-leaf is an object. Supports arbitrary nesting depth.
|
||||
|
||||
### `CatalogOptionsSchema`
|
||||
|
||||
`z.ZodObject<...>`
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `defaultLocale` | `string (min 1)` | Yes | Active locale |
|
||||
| `fallbackLocale` | `string?` | No | Fallback when key is missing |
|
||||
| `onMissingKey` | `function?` | No | Handler for missing keys |
|
||||
| `namespaces` | `string[]?` | No | Allowed namespace list |
|
||||
| `namespaceSeparator` | `string?` | No | Defaults to `:` |
|
||||
| `keySeparator` | `string?` | No | Defaults to `.` |
|
||||
|
||||
### `InterpolationConfigSchema`
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `prefix` | `string` | `"{{"` | Variable open delimiter |
|
||||
| `suffix` | `string` | `"}}"` | Variable close delimiter |
|
||||
| `escapeHtml` | `boolean` | `false` | Escape HTML chars in output |
|
||||
| `maxDepth` | `number (1-100)` | `10` | Max nesting recursion |
|
||||
|
||||
### `ResolverConfigSchema`
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `supportedLocales` | `string[] (min 1)` | Yes | Accepted locale codes |
|
||||
| `defaultLocale` | `string` | Yes | Used when no match found |
|
||||
| `strategy` | `enum?` | No | `exact \| language-only \| region-fallback \| chain` |
|
||||
| `aliases` | `Record<string,string>?` | No | Alternative locale spellings |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `TranslationRecordSchema` uses `z.lazy()` to allow self-referential typing; at runtime this creates a new Zod instance on first access, which is an acceptable trade-off for the simplicity it provides
|
||||
- Default values on `InterpolationConfigSchema` mean `parse({})` always succeeds — schemas with defaults behave like `z.object({}).transform(...)` internally
|
||||
- `onMissingKey` is typed as `z.function()` without parameter shapes — stricter typing would require exposing internal handler signatures in the schema layer, which would couple schema and runtime types too tightly
|
||||
120
docs/localization/i18n/i18n/src/utils/utils.md
Normal file
120
docs/localization/i18n/i18n/src/utils/utils.md
Normal file
@@ -0,0 +1,120 @@
|
||||
# i18n Utilities
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
The `utils` module provides a collection of pure utility functions for manipulating translation records, resolving text direction, sanitizing strings, and building or parsing dot-separated translation keys. These helpers are used internally across the catalog, interpolation, middleware, and formatting subsystems, and are also part of the public API so application code can perform the same operations without duplicating logic.
|
||||
|
||||
All functions are stateless and side-effect free. No async operations are involved. The module imports only from `../constants` (for RTL locale set) and internal type files, ensuring zero circular dependencies.
|
||||
|
||||
## Features
|
||||
|
||||
- Record flattening (`flattenRecord`) and reconstruction (`unflattenRecord`) with round-trip fidelity
|
||||
- Nested value access (`getNestedValue`, `hasKey`) without mutation
|
||||
- Key construction (`buildKey`) and decomposition (`parseKey`) with namespace support
|
||||
- Key counting (`countKeys`) over arbitrarily deep records
|
||||
- HTML special-character escaping (`sanitize`) for XSS prevention
|
||||
- RTL direction detection (`getTextDirection`, `isRtl`) backed by the canonical RTL locale set
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import {
|
||||
flattenRecord,
|
||||
buildKey,
|
||||
sanitize,
|
||||
isRtl,
|
||||
} from "@suaveplan/i18n/utils";
|
||||
|
||||
const record = { home: { title: "Welcome", subtitle: "Hello" } };
|
||||
const flat = flattenRecord(record);
|
||||
// { "home.title": "Welcome", "home.subtitle": "Hello" }
|
||||
|
||||
const key = buildKey("home", "title"); // "home.title"
|
||||
const safe = sanitize('<script>'); // "<script>"
|
||||
const rtl = isRtl("ar"); // true
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```typescript
|
||||
import {
|
||||
parseKey,
|
||||
unflattenRecord,
|
||||
getNestedValue,
|
||||
countKeys,
|
||||
getTextDirection,
|
||||
} from "@suaveplan/i18n/utils";
|
||||
|
||||
// Namespace-aware key parsing
|
||||
const { namespace, path } = parseKey("common:ui.button.save");
|
||||
// namespace === "common", path === ["ui", "button", "save"]
|
||||
|
||||
// Round-trip flatten/unflatten
|
||||
const flat = { "a.b.c": "deep" };
|
||||
const nested = unflattenRecord(flat);
|
||||
// { a: { b: { c: "deep" } } }
|
||||
|
||||
// Count leaf keys
|
||||
const count = countKeys({ a: { b: "x", c: "y" }, d: "z" }); // 3
|
||||
|
||||
// Value retrieval
|
||||
const val = getNestedValue({ home: { title: "Welcome" } }, "home.title");
|
||||
// "Welcome"
|
||||
|
||||
// Direction
|
||||
const dir = getTextDirection("he-IL"); // "rtl"
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `buildKey(...parts: string[]): string`
|
||||
|
||||
Joins non-empty string parts with `.`. Empty strings are filtered out.
|
||||
|
||||
### `parseKey(key: string, namespaceSeparator?: string): KeyPathResult`
|
||||
|
||||
Splits a key on the namespace separator (default `:`) and then on `.` for the path.
|
||||
|
||||
Returns `KeyPathResult`: `{ namespace?: string; path: string[]; fullKey: string }`.
|
||||
|
||||
### `hasKey(record: TranslationRecord, key: string): boolean`
|
||||
|
||||
Returns `true` if the dot-separated `key` resolves to a string leaf in `record`.
|
||||
|
||||
### `getNestedValue(record: TranslationRecord, key: string): string | undefined`
|
||||
|
||||
Returns the string value at the nested `key`, or `undefined` if missing.
|
||||
|
||||
### `flattenRecord(record: TranslationRecord, prefix?: string): Record<string, string>`
|
||||
|
||||
Recursively flattens nested record into dot-keyed flat map. `prefix` is prepended to all keys.
|
||||
|
||||
### `unflattenRecord(flat: Record<string, string>): TranslationRecord`
|
||||
|
||||
Reconstructs a nested record from a dot-keyed flat map. Conflicting paths (string vs object) favour the object form.
|
||||
|
||||
### `countKeys(record: TranslationRecord): number`
|
||||
|
||||
Returns the total number of string leaf nodes (equivalent to `Object.keys(flattenRecord(record)).length`).
|
||||
|
||||
### `sanitize(text: string, opts?: SanitizeOptions): string`
|
||||
|
||||
Escapes `&`, `<`, `>`, `"`, `'` to HTML entities. Pass `{ stripAll: false }` to bypass escaping.
|
||||
|
||||
### `getTextDirection(locale: string): TextDirection`
|
||||
|
||||
Returns `"rtl"` if the language subtag is in the canonical RTL set, otherwise `"ltr"`.
|
||||
|
||||
### `isRtl(locale: string): boolean`
|
||||
|
||||
Convenience wrapper: `getTextDirection(locale) === "rtl"`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `flattenRecord` uses `Object.entries` recursion rather than a stack; for typical translation files (depth ≤ 5, ≤ 5 000 keys) the recursive approach is fast enough and simpler to reason about
|
||||
- `unflattenRecord` handles path conflicts by always preferring object nodes over string leaves — this matches the common "merge partial namespaces" use case
|
||||
- The RTL set is imported as a `ReadonlySet<string>` from `constants/index`; `.has()` is O(1) making `isRtl` safe to call in render hot paths
|
||||
- `sanitize` deliberately does not allow tags — it is a full escape, not a sanitiser with allow lists. For rich-text use cases, consumers should use a dedicated HTML sanitiser library
|
||||
Reference in New Issue
Block a user