@vielzeug/codex 26.10.0 → 26.10.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +1 -1
  2. package/data/catalog.json +56 -60
  3. package/data/llms-full.txt +1473 -1069
  4. package/data/llms.txt +6 -6
  5. package/data/manifest.json +1 -1
  6. package/data/packages/arsenal.json +13 -13
  7. package/data/packages/assay.json +4 -4
  8. package/data/packages/clockwork.json +4 -4
  9. package/data/packages/codex.json +3 -3
  10. package/data/packages/coins.json +4 -4
  11. package/data/packages/conduit.json +4 -4
  12. package/data/packages/courier.json +4 -4
  13. package/data/packages/dnd.json +10 -10
  14. package/data/packages/familiar.json +4 -4
  15. package/data/packages/flux.json +4 -4
  16. package/data/packages/focus.json +8 -5
  17. package/data/packages/forge.json +4 -4
  18. package/data/packages/gesture.json +4 -4
  19. package/data/packages/herald.json +12 -12
  20. package/data/packages/illusionist.json +5 -5
  21. package/data/packages/keymap.json +9 -9
  22. package/data/packages/ledger.json +4 -4
  23. package/data/packages/lingua.json +4 -4
  24. package/data/packages/mesh.json +5 -5
  25. package/data/packages/necromancer.json +5 -5
  26. package/data/packages/orbit.json +7 -7
  27. package/data/packages/ore.json +5 -5
  28. package/data/packages/postmaster.json +4 -4
  29. package/data/packages/prism.json +50 -42
  30. package/data/packages/pulse.json +8 -8
  31. package/data/packages/refine.json +3 -3
  32. package/data/packages/ripple.json +4 -4
  33. package/data/packages/rune.json +9 -9
  34. package/data/packages/sandbox.json +4 -4
  35. package/data/packages/scout.json +7 -7
  36. package/data/packages/scroll.json +10 -10
  37. package/data/packages/sentinel.json +4 -4
  38. package/data/packages/sigil.json +6 -6
  39. package/data/packages/sourcerer.json +4 -4
  40. package/data/packages/spell.json +9 -9
  41. package/data/packages/tandem.json +5 -5
  42. package/data/packages/tavern.json +12 -12
  43. package/data/packages/tempo.json +4 -4
  44. package/data/packages/vault.json +13 -13
  45. package/data/packages/ward.json +9 -9
  46. package/data/packages/wayfinder.json +17 -17
  47. package/data/refine.json +259 -87
  48. package/data/search.json +249 -249
  49. package/dist/http.js +2 -2
  50. package/dist/http.js.map +1 -1
  51. package/dist/index.d.ts +1 -1
  52. package/dist/index.js +1 -1
  53. package/dist/refine.d.ts +1 -1
  54. package/dist/refine.js +1 -1
  55. package/dist/server.d.ts +1 -1
  56. package/dist/server.js +1 -1
  57. package/dist/tools/refine.js +1 -1
  58. package/package.json +2 -2
  59. package/skills/vielzeug/SKILL.md +57 -57
@@ -1,25 +1,25 @@
1
1
  {
2
- "apiSource": "// Core API — most users only need these\nexport type { ConflictOptions } from './conflicts';\nexport { findShortcutConflicts } from './conflicts';\nexport { KeymapConfigError, KeymapError, KeymapParseError } from './errors';\nexport { formatShortcut, formatShortcutParts } from './format';\nexport { createKeymap } from './keymap';\nexport { detectModKey, matchKey } from './parser';\nexport type { Binding, BindingEntry, Handler, Keymap, KeymapEvent, KeymapOptions, When } from './types';\n",
2
+ "apiSource": "// Core API: most users only need these\nexport type { ConflictOptions } from './conflicts';\nexport { findShortcutConflicts } from './conflicts';\nexport { KeymapConfigError, KeymapError, KeymapParseError } from './errors';\nexport { formatShortcut, formatShortcutParts } from './format';\nexport { createKeymap } from './keymap';\nexport { detectModKey, matchKey } from './parser';\nexport type { Binding, BindingEntry, Handler, Keymap, KeymapEvent, KeymapOptions, When } from './types';\n",
3
3
  "docs": {
4
- "index": "---\ntitle: Keymap — Headless keyboard shortcut manager\ndescription: Target-local keyboard shortcut manager with chords, event-aware guards, modifier aliases, and terminal disposal.\npackage: keymap\ncategory: app-infrastructure\nkeywords: [keyboard, shortcuts, hotkeys, chord, keybinding, headless, accessibility]\nexports:\n [\n createKeymap,\n findShortcutConflicts,\n formatShortcut,\n matchKey,\n detectModKey,\n KeymapConfigError,\n KeymapError,\n KeymapParseError,\n ]\nrelated: [herald, refine, ore]\nenvironments: [browser, node, ssr, deno]\n---\n\n<!-- markdownlint-disable MD025 MD033 MD060 -->\n\n<PackageHero package=\"keymap\" />\n\n## Why Keymap?\n\nBrowser keyboard handling needs modifier normalization, chord state, context policy, and listener ownership. Keymap keeps those concerns in one headless, zero-dependency handle.\n\n```ts\n// Before\nwindow.addEventListener('keydown', (event) => {\n if ((event.ctrlKey || event.metaKey) && event.key === 's') event.preventDefault();\n});\n\n// After\nimport { createKeymap } from '@vielzeug/keymap';\n\nconst map = createKeymap([\n { id: 'save', shortcut: 'mod+s', handler: () => console.log('save') },\n]);\nconst unmount = map.mount(document);\n\nunmount();\nmap.dispose();\n```\n\n| Feature | Raw `addEventListener` | Keymap |\n| ------------------- | -------------------------------------------- | -------------------------------------------- |\n| Bundle size | 0 B (built-in) | <PackageInfo package=\"keymap\" type=\"size\" /> |\n| Zero dependencies | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"check\" size=\"16\"></ore-icon> |\n| Chord sequences | <ore-icon name=\"x\" size=\"16\"></ore-icon> | <ore-icon name=\"check\" size=\"16\"></ore-icon> |\n| Modifier aliases | <ore-icon name=\"x\" size=\"16\"></ore-icon> | `cmd`, `win`, `option` → canonical |\n| Context guards | Manual `if` in handler | Event-aware `when(event)` predicate |\n| Chord ownership | Application-managed state | Per mounted target |\n| Per-binding control | Manual `preventDefault`/`stopPropagation` | Per-binding `preventDefault`/`stopPropagation` with safe defaults |\n| Disposable | Manual `removeEventListener` | Terminal `dispose()` + `[Symbol.dispose]()` |\n\n<div class=\"decision-callout\">\n\n**Use Keymap when** you need chord sequences (`g g`, `ctrl+k ctrl+s`), modifier aliases, or context-scoped hotkeys that can be cleanly mounted and unmounted.\n\n**Consider raw `addEventListener` when** you have a single, static, never-removed hotkey and don't need chords.\n\n</div>\n\n## Installation\n\n::: code-group\n\n```sh [pnpm]\npnpm add @vielzeug/keymap\n```\n\n```sh [npm]\nnpm install @vielzeug/keymap\n```\n\n```sh [yarn]\nyarn add @vielzeug/keymap\n```\n\n:::\n\n## Quick Start\n\nCreate, mount, then dispose one map owned by your UI scope.\n\n```ts\nimport { createKeymap } from '@vielzeug/keymap';\n\nconst map = createKeymap([\n { id: 'save', shortcut: 'mod+k mod+s', handler: () => console.log('save') },\n { id: 'palette', shortcut: 'mod+shift+p', handler: () => console.log('open palette') },\n { id: 'top', shortcut: 'g g', handler: () => window.scrollTo({ top: 0 }) },\n { id: 'close', shortcut: 'escape', handler: () => console.log('close panel') },\n]);\n\nconst unmount = map.mount(document);\n\nunmount();\nmap.dispose();\n```\n\n## Features\n\n<div class=\"features-grid\">\n\n- `createKeymap()` — Create a keymap from an ordered binding array; mount to any `EventTarget`\n- Chord sequences — `\"g g\"`, `\"ctrl+k ctrl+s\"` with configurable timeout (default 1 s)\n- Modifier aliases — `cmd`/`command`/`win` → `meta`; `opt`/`option` → `alt`; `mod` → platform-aware\n- `Binding` objects — each binding has an explicit `id`, `shortcut`, `handler`, and optional `trigger`, `when`, `preventDefault`, `stopPropagation`\n- Per-binding `preventDefault`/`stopPropagation` — safe defaults (`true`/`false`) applied to completed shortcuts and intermediate chord steps\n- `tap()` — observe chord progress, timeout, matches, and disposal without changing behavior\n- `modKey` option — explicit platform override for SSR and cross-platform tests\n- `formatShortcut()` — platform-aware display (`⇧⌘P` on Mac, `Ctrl+Shift+P` elsewhere)\n- `@vielzeug/keymap/parse` subpath — `parseShortcut()`, `parseStep()`, `matchStep()`, `matchKey()`, `canonicalizeShortcut()`, `detectModKey()` for custom tooling\n- `listBindings()` — snapshot all active bindings (id, shortcut, trigger, preventDefault, stopPropagation) for palette UIs\n- `findShortcutConflicts()` — detect prefix/duplicate conflicts before binding a user-customized shortcut\n- Disposable — `dispose()` + `[Symbol.dispose]` for `using` declarations\n\n</div>\n\n## Documentation\n\n<div class=\"doc-links\">\n\n- [Usage Guide](./usage.md)\n- [API Reference](./api.md)\n- [Examples](./examples.md)\n- [Migration Guide](./migration.md)\n\n</div>\n\n## See Also\n\n<div class=\"see-also\">\n\n- [Herald](/herald/) — Typed event bus; pair with Keymap by publishing shortcut events to a bus instead of calling handlers directly\n- [Refine](/refine/) — `ore-command-palette` uses Keymap internally; register your own shortcuts alongside it\n- [Ore](/ore/) — Attach a keymap inside a `define()` setup function for component-scoped shortcuts\n\n</div>\n\n<!-- markdownlint-enable MD025 MD033 MD060 -->\n",
5
- "api": "---\ntitle: Keymap — API Reference\ndescription: Complete API reference for @vielzeug/keymap bindings, chords, parsing, formatting, and lifecycle.\n---\n\n[[toc]]\n\n## API Overview\n\n### Core API (Most Users)\n\n| Symbol | Purpose | Execution mode | Common gotcha |\n| --- | --- | --- | --- |\n| `createKeymap()` | Create shortcut manager from an ordered binding array | Sync | `dispose()` is terminal |\n| `findShortcutConflicts()` | Find duplicate and prefix paths | Sync | Invalid non-empty input throws |\n| `formatShortcut()` | Format shortcut labels | Sync | Invalid input returns `''` |\n| `matchKey()` | Match an event against one shortcut pattern | Sync | Unparseable pattern returns `false`; chord patterns never match |\n| `detectModKey()` | Resolve platform primary modifier | Sync | Returns `ctrl` without `navigator` |\n| `tap()` | Observe chord lifecycle, matches, and disposal | Sync | Observer failures are swallowed |\n| `Binding` | Per-binding config type (id, shortcut, handler, trigger, when, preventDefault, stopPropagation) | — | Reusing an `id` replaces its binding |\n\n### Parser Subpath (`@vielzeug/keymap/parse`)\n\nUse the parser subpath if you're building keyboard-aware config validators, custom UI, or framework integrations.\n\n| Symbol | Purpose | Execution mode | Common gotcha |\n| --- | --- | --- | --- |\n| `parseShortcut()` | Strictly parse full shortcut | Sync | Empty input throws |\n| `parseStep()` | Parse one step without throwing | Sync | Invalid input returns `null` |\n| `canonicalizeShortcut()` | Create stable shortcut key | Sync | Input must already be parsed |\n| `matchStep()` | Test event against parsed step | Sync | Extra modifiers prevent a match |\n| `detectModKey()` | Resolve platform primary modifier | Sync | Returns `ctrl` without `navigator` |\n\n### Errors\n\n| Symbol | Purpose | Execution mode | Common gotcha |\n| --- | --- | --- | --- |\n| `KeymapError` | Base Keymap error | Sync | Includes parse, config, and lifecycle errors |\n| `KeymapConfigError` | Invalid `createKeymap` option | Sync | Thrown for a non-positive `chordTimeout` |\n| `KeymapParseError` | Strict parser error | Sync | `parseStep()` never throws it |\n\n## Package Entry Points\n\n| Import | Purpose |\n| --- | --- |\n| `@vielzeug/keymap` | Root entry point for `createKeymap`, `formatShortcut`, `findShortcutConflicts`, `matchKey`, `detectModKey`, errors, and public types. |\n| `@vielzeug/keymap/parse` | Parser internals subpath for custom tooling: `parseShortcut`, `parseStep`, `matchStep`, `matchKey`, `canonicalizeShortcut`, `detectModKey`. |\n\n## Core Manager\n\n### `createKeymap()`\n\n```ts\nfunction createKeymap(\n bindings?: readonly Binding[],\n options?: KeymapOptions,\n): Keymap;\n```\n\nCreates shortcut manager with independent chord state for each mounted target.\n\n| Parameter | Type | Description |\n| --- | --- | --- |\n| `bindings` | `readonly Binding[]` | Ordered array of bindings. Each binding has an explicit `id`, `shortcut`, and `handler`. |\n| `options` | `KeymapOptions` | Chord, modifier, and global-guard configuration. |\n\n**Returns:** `Keymap`.\n\n```ts\nimport { createKeymap } from '@vielzeug/keymap';\n\nconst map = createKeymap([\n { id: 'save', shortcut: 'ctrl+s', handler: () => console.log('save') },\n]);\nconst unmount = map.mount(document);\n\nunmount();\nmap.dispose();\n```\n\n| `Keymap` member | Return | Contract |\n| --- | --- | --- |\n| `bind(binding)` | `() => void` | Adds or replaces a binding with the same `id`. Returned callback removes that binding while active. |\n| `mount(target)` | `() => void` | Adds target listener. Repeat mounts of same target are reference-counted. |\n| `unbind(id)` | `void` | Removes binding by id. Warns in development when unknown. |\n| `listBindings()` | `readonly BindingEntry[]` | Returns a detached binding snapshot. |\n| `tap(handler, options?)` | `() => void` | Observes chord lifecycle, matches, and disposal. Supports signal-owned teardown. |\n| `dispose()` | `void` | Removes all listeners, aborts signal, and permanently disposes map. Idempotent. |\n| `disposed` | `boolean` | `true` after first `dispose()`. |\n| `disposalSignal` | `AbortSignal` | Aborts when map is disposed. |\n| `[Symbol.dispose]()` | `void` | Calls `dispose()`. |\n\nAfter disposal, `bind()`, `unbind()`, and `mount()` throw `KeymapError`.\n\n## Runtime Observation\n\n### `tap()`\n\n```ts\nfunction tap(\n handler: (event: KeymapEvent) => void,\n options?: { signal?: AbortSignal },\n): () => void;\n```\n\nObserves chord progress, cancellation, completed matches, timeouts, and disposal without affecting shortcut behavior.\n\n| Parameter | Type | Description |\n| --- | --- | --- |\n| `handler` | `(event: KeymapEvent) => void` | Receives each runtime event synchronously. Errors are swallowed. |\n| `options.signal` | `AbortSignal` | Detaches the handler when aborted. |\n\n**Returns:** An idempotent detach function.\n\n```ts\nconst stop = map.tap((event) => {\n if (event.type === 'chord-start') showChordHint(event.step);\n if (event.type === 'chord-cancel' || event.type === 'chord-timeout' || event.type === 'match') hideChordHint();\n});\n```\n\n## Conflict Analysis\n\n### `findShortcutConflicts()`\n\n```ts\nfunction findShortcutConflicts(\n shortcut: string,\n entries: readonly BindingEntry[],\n options?: ConflictOptions,\n): BindingEntry[];\n```\n\nReturns entries with same-trigger exact or prefix-conflicting shortcut paths.\n\n| Parameter | Type | Description |\n| --- | --- | --- |\n| `shortcut` | `string` | Proposed shortcut. Empty or whitespace-only input returns no conflicts. |\n| `entries` | `readonly BindingEntry[]` | Bindings to compare, commonly `map.listBindings()`. |\n| `options` | `ConflictOptions` | Optional modifier resolution and trigger filter. |\n\n**Returns:** Matching entries. Returns `[]` when no conflict exists.\n\n```ts\nimport { createKeymap, findShortcutConflicts } from '@vielzeug/keymap';\n\nconst map = createKeymap([{ id: 'top', shortcut: 'g', handler: () => console.log('top') }]);\nconst conflicts = findShortcutConflicts('g g', map.listBindings());\n\nconsole.log(conflicts.length); // 1\n```\n\n## Formatting\n\n### `formatShortcut()`\n\n```ts\nfunction formatShortcut(shortcut: string, modKey?: 'ctrl' | 'meta'): string;\n```\n\nFormats parsed shortcut into Mac symbols for `meta` or word labels for `ctrl`.\n\n| Parameter | Type | Description |\n| --- | --- | --- |\n| `shortcut` | `string` | Shortcut string to format. |\n| `modKey` | `'ctrl' \\| 'meta'` | Platform primary modifier. Defaults to `detectModKey()`. |\n\n**Returns:** Display label, or `''` for invalid input.\n\n```ts\nimport { formatShortcut } from '@vielzeug/keymap';\n\nformatShortcut('mod+shift+p', 'meta'); // ⇧⌘P\nformatShortcut('mod+shift+p', 'ctrl'); // Ctrl+Shift+P\n```\n\n## Parser Subpath (`@vielzeug/keymap/parse`)\n\n### `parseShortcut()`\n\n```ts\nfunction parseShortcut(raw: string, modKey?: 'ctrl' | 'meta'): Shortcut;\n```\n\nStrictly parses one or more space-separated shortcut steps.\n\n| Parameter | Type | Description |\n| --- | --- | --- |\n| `raw` | `string` | Full shortcut string. |\n| `modKey` | `'ctrl' \\| 'meta'` | Platform primary modifier. Defaults to `detectModKey()`. |\n\n**Returns:** Parsed `Shortcut`.\n\n```ts\nimport { parseShortcut } from '@vielzeug/keymap/parse';\n\nconst shortcut = parseShortcut('ctrl+k ctrl+s', 'ctrl');\nconsole.log(shortcut.length); // 2\n```\n\nThrows `KeymapParseError` for empty, modifier-only, or ambiguous steps.\n\n---\n\n### `parseStep()`\n\n```ts\nfunction parseStep(raw: string, modKey?: 'ctrl' | 'meta'): ShortcutStep | null;\n```\n\nParses one shortcut step without throwing.\n\n| Parameter | Type | Description |\n| --- | --- | --- |\n| `raw` | `string` | One shortcut step. |\n| `modKey` | `'ctrl' \\| 'meta'` | Platform primary modifier. Defaults to `detectModKey()`. |\n\n**Returns:** Parsed `ShortcutStep`, or `null` for empty, modifier-only, or ambiguous input.\n\n```ts\nimport { parseStep } from '@vielzeug/keymap/parse';\n\nparseStep('ctrl+k', 'ctrl'); // { key: 'k', modifiers: Set(['ctrl']) }\nparseStep('ctrl+k+j', 'ctrl'); // null\n```\n\n---\n\n### `canonicalizeShortcut()`\n\n```ts\nfunction canonicalizeShortcut(steps: readonly ShortcutStep[]): string;\n```\n\nConverts parsed steps into stable canonical string with sorted modifier order.\n\n| Parameter | Type | Description |\n| --- | --- | --- |\n| `steps` | `readonly ShortcutStep[]` | Parsed shortcut steps. |\n\n**Returns:** Canonical shortcut string.\n\n```ts\nimport { canonicalizeShortcut, parseShortcut } from '@vielzeug/keymap/parse';\n\ncanonicalizeShortcut(parseShortcut('shift+ctrl+k', 'ctrl')); // ctrl+shift+k\n```\n\n---\n\n### `matchStep()`\n\n```ts\nfunction matchStep(event: KeyboardEvent, step: ShortcutStep): boolean;\n```\n\nTests exact key and modifier equality for one parsed step.\n\n| Parameter | Type | Description |\n| --- | --- | --- |\n| `event` | `KeyboardEvent` | Event to match. Missing runtime `key` returns `false`. |\n| `step` | `ShortcutStep` | Parsed step. |\n\n**Returns:** `true` only when key and all modifier states match.\n\n```ts\nimport { matchStep, parseStep } from '@vielzeug/keymap/parse';\n\nconst step = parseStep('ctrl+k', 'ctrl')!;\nmatchStep(new KeyboardEvent('keydown', { ctrlKey: true, key: 'k' }), step); // true\n```\n\n---\n\n### `matchKey()`\n\n```ts\nfunction matchKey(event: KeyboardEvent, pattern: string, modKey?: 'ctrl' | 'meta'): boolean;\n```\n\nMatches a keyboard event against a single shortcut pattern such as `'ArrowDown'`, `'esc'`, `'mod+k'`, or `'shift+Home'`. Aliases and `mod` resolve through the same parser `createKeymap` uses, and modifier state must match exactly: `'ArrowDown'` does not match Ctrl+ArrowDown. Exported from both the root entry point and `@vielzeug/keymap/parse`.\n\n| Parameter | Type | Description |\n| --- | --- | --- |\n| `event` | `KeyboardEvent` | Event to match. Missing runtime `key` returns `false`. |\n| `pattern` | `string` | One shortcut step. Unparseable patterns and multi-step chords return `false` (never throws). |\n| `modKey` | `'ctrl' \\| 'meta'` | Optional. Resolution for `mod`; defaults to `detectModKey()`. |\n\n**Returns:** `true` only when key and all modifier states match.\n\n```ts\nimport { matchKey } from '@vielzeug/keymap';\n\nif (matchKey(event, 'mod+k')) openPalette();\nif (matchKey(event, 'shift+Home')) selectToStart(event);\n```\n\n---\n\n### `detectModKey()`\n\n```ts\nfunction detectModKey(): 'ctrl' | 'meta';\n```\n\nDetects Mac platform from `navigator` and otherwise returns `ctrl`.\n\n**Returns:** `'meta'` on Mac platforms; `'ctrl'` elsewhere or without `navigator`.\n\n```ts\nimport { detectModKey } from '@vielzeug/keymap/parse';\n\nconst modKey = detectModKey();\n```\n\n## Types\n\n### `Keymap`\n\nStateful shortcut manager returned by `createKeymap()`.\n\n```ts\ninterface Keymap {\n [Symbol.dispose](): void;\n bind(binding: Binding): () => void;\n dispose(): void;\n readonly disposalSignal: AbortSignal;\n readonly disposed: boolean;\n listBindings(): readonly BindingEntry[];\n mount(target: EventTarget): () => void;\n tap(handler: (event: KeymapEvent) => void, options?: { signal?: AbortSignal }): () => void;\n unbind(id: string): void;\n}\n```\n\n### `KeymapOptions`\n\nOptions applied to every binding owned by one manager.\n\n```ts\ninterface KeymapOptions {\n chordTimeout?: number;\n modKey?: 'ctrl' | 'meta';\n when?: When;\n}\n```\n\n- `when`: Guard function called for all bindings. When combined with per-binding `when` guards, both must return `true` for the handler to fire (AND composition). Global guard is checked first.\n- `chordTimeout`: Must be a positive finite number; `createKeymap()` throws `KeymapConfigError` otherwise. Defaults to `1000`.\n\n### `KeymapEvent`\n\nRuntime event observed through `tap()`.\n\n```ts\ntype KeymapEvent =\n | { type: 'chord-cancel'; target: EventTarget; trigger: 'keydown' | 'keyup' }\n | { type: 'chord-start'; step: ShortcutStep; target: EventTarget; trigger: 'keydown' | 'keyup' }\n | { type: 'chord-progress'; steps: readonly ShortcutStep[]; target: EventTarget; trigger: 'keydown' | 'keyup' }\n | { type: 'chord-timeout'; target: EventTarget; trigger: 'keydown' | 'keyup' }\n | { type: 'match'; binding: BindingEntry; target: EventTarget; trigger: 'keydown' | 'keyup' }\n | { type: 'dispose' };\n```\n\n`step`, `steps`, and `binding` are detached snapshots. Mutating them does not affect matching.\n\n### `Binding`\n\nPer-binding configuration. Each binding has an explicit `id` so duplicate shortcuts can coexist. Reusing an ID replaces its binding; among duplicate shortcuts, the first binding whose guard passes wins.\n\n```ts\ninterface Binding {\n id: string;\n shortcut: string;\n handler: Handler;\n trigger?: 'keydown' | 'keyup';\n when?: When;\n preventDefault?: boolean;\n stopPropagation?: boolean;\n}\n```\n\n- `trigger`: Defaults to `'keydown'`.\n- `preventDefault`: Defaults to `true`. Set to `false` for shortcuts that must retain browser behavior.\n- `stopPropagation`: Defaults to `false`.\n\n### `Handler` and `When`\n\n```ts\ntype Handler = (event: KeyboardEvent) => void;\ntype When = (event: KeyboardEvent) => boolean;\n```\n\n### `BindingEntry`\n\nDetached binding metadata returned by `listBindings()`.\n\n```ts\ntype BindingEntry = {\n readonly id: string;\n readonly shortcut: readonly ShortcutStep[];\n readonly trigger: 'keydown' | 'keyup';\n readonly preventDefault: boolean;\n readonly stopPropagation: boolean;\n};\n```\n\n### `ModifierKey`, `Shortcut`, and `ShortcutStep`\n\nParser types used by `parseShortcut()`, `parseStep()`, `matchStep()`, and `canonicalizeShortcut()`. Available from `@vielzeug/keymap/parse`.\n\n```ts\ntype ModifierKey = 'alt' | 'ctrl' | 'meta' | 'shift';\n\ntype ShortcutStep = {\n key: string;\n modifiers: Set<ModifierKey>;\n};\n\ntype Shortcut = ShortcutStep[];\n```\n\n### `ConflictOptions`\n\nComparison options for `findShortcutConflicts()`.\n\n```ts\ninterface ConflictOptions {\n modKey?: 'ctrl' | 'meta';\n trigger?: 'keydown' | 'keyup';\n}\n```\n\n## Errors\n\n| Error | Trigger | Notable properties |\n| --- | --- | --- |\n| `KeymapError` | Lifecycle operation after disposal | Use `instanceof KeymapError` to narrow Keymap errors. |\n| `KeymapConfigError` | `createKeymap()` receives an invalid option | Extends `KeymapError`. Thrown for a non-positive or non-finite `chordTimeout`. |\n| `KeymapParseError` | Strict shortcut parser receives invalid input | Extends `KeymapError`. |\n",
6
- "usage": "---\ntitle: Keymap — Usage Guide\ndescription: Bind keyboard shortcuts, chords, event-aware guards, and target-local listeners with @vielzeug/keymap.\n---\n\n[[toc]]\n\n## Basic Usage\n\nMount one keymap, then release its target listener and dispose its owner during teardown.\n\n```ts\nimport { createKeymap } from '@vielzeug/keymap';\n\nconst map = createKeymap([\n { id: 'save', shortcut: 'ctrl+s', handler: () => console.log('save') },\n { id: 'undo', shortcut: 'ctrl+z', handler: () => console.log('undo') },\n { id: 'close', shortcut: 'escape', handler: () => console.log('close') },\n]);\n\nconst unmount = map.mount(document);\n\n// Call this when the owning UI scope ends.\nunmount();\nmap.dispose();\n```\n\n`unmount()` only releases that target. `dispose()` releases every target, aborts `disposalSignal`, and makes `bind()`, `unbind()`, and `mount()` unavailable.\n\n## Modifier Aliases\n\nUse aliases to accept platform terminology while Keymap stores one canonical shortcut.\n\n| Input | Canonical modifier |\n| --- | --- |\n| `cmd`, `command`, `win` | `meta` |\n| `opt`, `option` | `alt` |\n| `ctrl`, `control` | `ctrl` |\n| `mod` | `meta` on Mac; `ctrl` elsewhere |\n\nPass `modKey` when rendering or testing a specific platform.\n\n```ts\nimport { createKeymap } from '@vielzeug/keymap';\n\nconst map = createKeymap(\n [{ id: 'palette', shortcut: 'mod+k', handler: () => console.log('open palette') }],\n { modKey: 'ctrl' },\n);\n\nmap.mount(document);\n```\n\n## Chord Sequences\n\nSeparate chord steps with spaces. Keymap resets an incomplete sequence after `chordTimeout` milliseconds.\n\n```ts\nconst map = createKeymap(\n [\n { id: 'save', shortcut: 'ctrl+k ctrl+s', handler: () => console.log('save') },\n { id: 'top', shortcut: 'g g', handler: () => window.scrollTo({ top: 0 }) },\n { id: 'end', shortcut: 'g e', handler: () => window.scrollTo({ top: document.body.scrollHeight }) },\n ],\n { chordTimeout: 800 },\n);\n```\n\nDo not bind a complete shortcut and a longer chord beginning with that shortcut. `g` fires immediately, so `g g` cannot complete. Check proposed user bindings with `findShortcutConflicts()`.\n\n## Binding Options\n\nEach binding is an object with an explicit `id`, `shortcut`, and `handler`. Add a guard, choose `keyup`, or control event behavior with optional fields.\n\n```ts\nconst map = createKeymap([\n { id: 'save', shortcut: 'ctrl+s', handler: () => saveDocument() },\n { id: 'close', shortcut: 'escape', handler: closePanel, when: (event) => event.target === panel },\n { id: 'play', shortcut: 'space', handler: togglePlayback, trigger: 'keyup' },\n { id: 'inspect', shortcut: 'f12', handler: inspectElement, preventDefault: false },\n]);\n```\n\n### Per-Binding preventDefault and stopPropagation\n\nEach binding controls `preventDefault` and `stopPropagation` independently:\n\n- `preventDefault` defaults to `true` — completed shortcuts and matched chord prefixes suppress browser defaults.\n- `stopPropagation` defaults to `false` — events continue bubbling unless explicitly stopped.\n\nSet `preventDefault: false` for shortcuts that must coexist with native controls (e.g. developer tools, accessibility features).\n\n## Context Guards\n\nUse global `when(event)` for policy shared by every binding. Use per-binding `when(event)` when one shortcut needs a narrower policy.\n\n```ts\nconst map = createKeymap(\n [\n { id: 'close', shortcut: 'escape', handler: closePanel, when: (event) => event.target === panel },\n { id: 'save', shortcut: 'ctrl+s', handler: () => saveDocument() },\n ],\n { when: (event) => !modalIsOpen() && event.isTrusted },\n);\n```\n\nAccept `KeyboardEvent` when guard logic needs target, modifier, composition, or shadow-DOM context.\n\n### Guard Composition: Global + Per-Binding\n\nWhen you provide both a global `when` (in `KeymapOptions`) and per-binding `when` guards, both must return `true` for the handler to fire. This is AND composition.\n\n**Guard evaluation and chord tracking order:**\n\n1. **Global guard runs first.** If it returns `false`, the target's pending chord state resets.\n2. **Per-binding guards select candidates.** A binding participates in each chord step only while its guard passes.\n3. **Chord state advances for eligible candidates.** The first completed candidate in binding order runs.\n\nA chord cannot begin while its map is disabled and finish after the global guard becomes active.\n\n```ts\nconst map = createKeymap(\n [\n { id: 'close', shortcut: 'escape', handler: closePanel, when: (event) => event.target === panel },\n { id: 'save', shortcut: 'ctrl+s', handler: () => saveDocument() },\n ],\n { when: (event) => !isModalOpen() && event.isTrusted },\n);\n\n// Global guard runs first; if false, both bindings are skipped (handler doesn't fire).\n// If global passes:\n// - 'save' handler fires immediately.\n// - 'close' handler fires only if event.target is the panel.\n```\n\n### Preserve Native Text Editing\n\nUse `event.composedPath()` to keep browser undo and redo inside inputs, textareas, and `contenteditable` elements. CRM app shell uses this policy for its global undo and redo shortcuts.\n\n```ts\nconst isTypingInField = (event: KeyboardEvent): boolean =>\n event.composedPath().some(\n (target) =>\n target instanceof HTMLElement &&\n (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || target.isContentEditable),\n );\n\nconst map = createKeymap(\n [\n { id: 'undo', shortcut: 'mod+z', handler: () => undo() },\n { id: 'redo', shortcut: 'mod+shift+z', handler: () => redo() },\n ],\n { when: (event) => !isTypingInField(event) },\n);\n```\n\nDo not make editable-field suppression a hidden package default. Applications may intentionally bind shortcuts inside editable controls.\n\n## Trigger Control\n\nBind on `keyup` when an action must run after key release.\n\n```ts\nconst map = createKeymap([\n { id: 'confirm', shortcut: 'space', handler: confirmAction, trigger: 'keyup' },\n]);\n```\n\n`keydown` and `keyup` maintain independent chord state.\n\n## Replace Bindings at Runtime\n\n`bind(binding)` adds or replaces a binding with the same `id` and returns a targeted removal callback.\n\n```ts\nconst map = createKeymap([{ id: 'palette', shortcut: 'ctrl+k', handler: defaultAction }]);\nconst removePluginBinding = map.bind({ id: 'palette', shortcut: 'ctrl+k', handler: pluginAction });\n\nremovePluginBinding();\nmap.bind({ id: 'palette', shortcut: 'ctrl+k', handler: defaultAction });\n```\n\n`unbind(id)` removes the binding with that id and warns in development when no binding exists.\n\n### Duplicate Shortcuts with Different IDs\n\nEach binding has an explicit `id`, so duplicate shortcuts can coexist. Keymap evaluates them in insertion order and fires the first binding whose guard passes.\n\n```ts\nconst map = createKeymap([\n { id: 'primary', shortcut: 'ctrl+k', handler: primaryAction },\n { id: 'fallback', shortcut: 'ctrl+k', handler: fallbackAction },\n]);\n\n// 'primary' fires while eligible; use mutually exclusive guards for contextual fallback.\n```\n\n## Format Shortcut Labels\n\nFormat labels with explicit platform behavior when your UI is cross-platform.\n\n```ts\nimport { formatShortcut } from '@vielzeug/keymap';\n\nconsole.log(formatShortcut('mod+shift+p', 'meta')); // ⇧⌘P\nconsole.log(formatShortcut('mod+shift+p', 'ctrl')); // Ctrl+Shift+P\n```\n\n`formatShortcut()` returns `''` and emits a development warning for invalid input.\n\n## Detect Conflicts\n\nCheck a custom shortcut before binding it to prevent duplicate or unreachable chord paths.\n\n```ts\nimport { createKeymap, findShortcutConflicts } from '@vielzeug/keymap';\n\nconst map = createKeymap([{ id: 'top', shortcut: 'g', handler: () => scrollToTop() }]);\nconst conflicts = findShortcutConflicts('g g', map.listBindings());\n\nif (conflicts.length === 0) map.bind({ id: 'bottom', shortcut: 'g g', handler: () => scrollToBottom() });\n```\n\nConflict detection compares only bindings with same trigger. An empty proposal returns no conflicts; other invalid proposals throw `KeymapParseError`.\n\n## Parser Subpath\n\nFor custom tooling, validators, or framework integrations that need direct access to the shortcut parser, import from `@vielzeug/keymap/parse`:\n\n```ts\nimport { parseShortcut, parseStep, matchStep, canonicalizeShortcut } from '@vielzeug/keymap/parse';\n\nconst steps = parseShortcut('ctrl+k ctrl+s', 'ctrl');\nconst step = parseStep('ctrl+k', 'ctrl');\nconst isMatch = matchStep(new KeyboardEvent('keydown', { ctrlKey: true, key: 'k' }), steps[0]);\n```\n\n`matchKey()` and `detectModKey()` are also exported from the root entry point. For a one-off check against a pattern string, prefer `matchKey()` — it parses and matches in one call and returns `false` instead of throwing:\n\n```ts\nimport { matchKey } from '@vielzeug/keymap';\n\nif (matchKey(event, 'mod+k')) openPalette();\n```\n\n## Mount Targets\n\nMount one keymap on multiple independent targets when each target should own its own chord progression.\n\n```ts\nconst map = createKeymap([{ id: 'top', shortcut: 'g g', handler: () => console.log('go to top') }]);\nconst unmountEditor = map.mount(editor);\nconst unmountPreview = map.mount(preview);\n```\n\nA chord started on `editor` cannot complete on `preview`. Repeated `mount(editor)` calls share one listener and require one unmount call each. For nested targets, Keymap handles one bubbled event at its innermost mounted target.\n\n## Scoped Maps\n\nCreate separate keymaps for separate UI owners. If maps share a target and shortcut, guards must be mutually exclusive because Keymap has no implicit layer precedence.\n\n```ts\nconst baseMap = createKeymap(\n [{ id: 'close-sidebar', shortcut: 'escape', handler: () => closeSidebar() }],\n { when: () => !modalIsOpen() },\n);\n\nconst modalMap = createKeymap(\n [{ id: 'close-modal', shortcut: 'escape', handler: () => closeModal() }],\n { when: () => modalIsOpen() },\n);\n\nconst unmountBase = baseMap.mount(document);\nconst unmountModal = modalMap.mount(document);\n```\n\n## Observe Chord Activity\n\nUse `tap()` for chord hints, diagnostics, and matched-binding telemetry. Observers cannot alter shortcut behavior.\n\n```ts\nconst stopTrace = map.tap((event) => {\n if (event.type === 'chord-start') showChordHint(event.step);\n if (event.type === 'chord-progress') updateChordHint(event.steps);\n if (event.type === 'chord-cancel' || event.type === 'chord-timeout' || event.type === 'match') hideChordHint();\n}, { signal: owner.signal });\n```\n\nTap handlers receive detached shortcut and binding snapshots. Handler errors are swallowed, and disposal emits a final `dispose` event before observers are cleared.\n\n## Testing\n\nDispatch `KeyboardEvent` instances against a mounted DOM target to test handlers and default prevention.\n\n```ts\nimport { expect, it, vi } from 'vitest';\n\nimport { createKeymap } from '@vielzeug/keymap';\n\nit('handles save', () => {\n const save = vi.fn();\n const target = document.createElement('button');\n const map = createKeymap([{ id: 'save', shortcut: 'ctrl+s', handler: save }]);\n const unmount = map.mount(target);\n\n target.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, ctrlKey: true, key: 's' }));\n\n expect(save).toHaveBeenCalledOnce();\n unmount();\n map.dispose();\n});\n```\n\nMount nested DOM targets in tests when your application uses both a container and a descendant listener. This verifies one bubbled event cannot complete a chord twice.\n\n## Framework Integration\n\nCreate map during framework lifecycle, then dispose it during teardown.\n\n::: code-group\n\n```tsx [React]\nimport { useEffect } from 'react';\n\nimport { createKeymap } from '@vielzeug/keymap';\n\nexport function App() {\n useEffect(() => {\n const map = createKeymap([{ id: 'palette', shortcut: 'ctrl+k', handler: () => console.log('open palette') }]);\n const unmount = map.mount(document);\n\n return () => {\n unmount();\n map.dispose();\n };\n }, []);\n\n return null;\n}\n```\n\n```vue [Vue 3]\n<script setup lang=\"ts\">\nimport { onMounted, onUnmounted } from 'vue';\n\nimport { createKeymap } from '@vielzeug/keymap';\n\nconst map = createKeymap([{ id: 'close', shortcut: 'escape', handler: () => console.log('close palette') }]);\nlet unmount: (() => void) | undefined;\n\nonMounted(() => {\n unmount = map.mount(document);\n});\n\nonUnmounted(() => {\n unmount?.();\n map.dispose();\n});\n</script>\n```\n\n```ts [Svelte]\nimport { onMount } from 'svelte';\n\nimport { createKeymap } from '@vielzeug/keymap';\n\nconst map = createKeymap([{ id: 'close', shortcut: 'escape', handler: () => console.log('close palette') }]);\n\nonMount(() => {\n const unmount = map.mount(document);\n\n return () => {\n unmount();\n map.dispose();\n };\n});\n```\n\n:::\n\n## Working with Other Vielzeug Libraries\n\n### Keymap + Ledger\n\nConnect undo and redo handlers to a Ledger owner.\n\n```ts\nimport { createKeymap } from '@vielzeug/keymap';\nimport { createLedger } from '@vielzeug/ledger';\n\nconst ledger = createLedger();\nconst reportHistoryError = (error: unknown): void => console.error(error);\nconst map = createKeymap([\n { id: 'undo', shortcut: 'mod+z', handler: () => void ledger.undo().catch(reportHistoryError) },\n { id: 'redo', shortcut: 'mod+shift+z', handler: () => void ledger.redo().catch(reportHistoryError) },\n]);\n\nmap.mount(document);\n```\n\n### Keymap + Herald\n\nEmit domain events instead of calling application actions from shortcut handlers.\n\n```ts\nimport { createBus } from '@vielzeug/herald';\nimport { createKeymap } from '@vielzeug/keymap';\n\nconst bus = createBus<{ 'shortcut:save': void }>();\nconst map = createKeymap([\n { id: 'save', shortcut: 'ctrl+s', handler: () => bus.emit('shortcut:save') },\n]);\n\nmap.mount(document);\n```\n\n## Best Practices\n\n- **Dispose** every map when its owner ends.\n- **Unmount** temporary target listeners instead of disposing reusable maps.\n- **Guard** global text-editing shortcuts with `event.composedPath()`.\n- **Check** conflicts before accepting customized shortcuts.\n- **Keep** shared-target guards mutually exclusive.\n- **Use** `mod` for primary cross-platform shortcuts.\n- **Avoid** prefix pairs such as `g` and `g g`.\n- **Use** unique `id`s for each binding and mutually exclusive guards for intentional duplicate shortcuts.\n",
7
- "examples": "---\ntitle: Keymap — Examples\ndescription: Worked examples for @vielzeug/keymap.\n---\n\n## Examples\n\n- [Global Shortcuts](./examples/global-shortcuts.md)\n- [Vim-style Navigation](./examples/vim-navigation.md)\n"
4
+ "index": "---\ntitle: 'Keymap: Headless keyboard shortcut manager'\ndescription: Target-local keyboard shortcut manager with chords, event-aware guards, modifier aliases, and terminal disposal.\npackage: keymap\ncategory: app-infrastructure\nkeywords: [keyboard, shortcuts, hotkeys, chord, keybinding, headless, accessibility]\nexports:\n [\n createKeymap,\n findShortcutConflicts,\n formatShortcut,\n matchKey,\n detectModKey,\n KeymapConfigError,\n KeymapError,\n KeymapParseError,\n ]\nrelated: [herald, refine, ore]\nenvironments: [browser, node, ssr, deno]\n---\n\n<!-- markdownlint-disable MD025 MD033 MD060 -->\n\n<PackageHero package=\"keymap\" />\n\n## Why Keymap?\n\nBrowser keyboard handling needs modifier normalization, chord state, context policy, and listener ownership. Keymap keeps those concerns in one headless, zero-dependency handle.\n\n```ts\n// Before\nwindow.addEventListener('keydown', (event) => {\n if ((event.ctrlKey || event.metaKey) && event.key === 's') event.preventDefault();\n});\n\n// After\nimport { createKeymap } from '@vielzeug/keymap';\n\nconst map = createKeymap([\n { id: 'save', shortcut: 'mod+s', handler: () => console.log('save') },\n]);\nconst unmount = map.mount(document);\n\nunmount();\nmap.dispose();\n```\n\n| Feature | Raw `addEventListener` | Keymap |\n| ------------------- | -------------------------------------------- | -------------------------------------------- |\n| Bundle size | 0 B (built-in) | <PackageInfo package=\"keymap\" type=\"size\" /> |\n| Zero dependencies | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"check\" size=\"16\"></ore-icon> |\n| Chord sequences | <ore-icon name=\"x\" size=\"16\"></ore-icon> | <ore-icon name=\"check\" size=\"16\"></ore-icon> |\n| Modifier aliases | <ore-icon name=\"x\" size=\"16\"></ore-icon> | `cmd`, `win`, `option` → canonical |\n| Context guards | Manual `if` in handler | Event-aware `when(event)` predicate |\n| Chord ownership | Application-managed state | Per mounted target |\n| Per-binding control | Manual `preventDefault`/`stopPropagation` | Per-binding `preventDefault`/`stopPropagation` with safe defaults |\n| Disposable | Manual `removeEventListener` | Terminal `dispose()` + `[Symbol.dispose]()` |\n\n<div class=\"decision-callout\">\n\n**Use Keymap when** you need chord sequences (`g g`, `ctrl+k ctrl+s`), modifier aliases, or context-scoped hotkeys that can be cleanly mounted and unmounted.\n\n**Consider raw `addEventListener` when** you have a single, static, never-removed hotkey and don't need chords.\n\n</div>\n\n## Installation\n\n::: code-group\n\n```sh [pnpm]\npnpm add @vielzeug/keymap\n```\n\n```sh [npm]\nnpm install @vielzeug/keymap\n```\n\n```sh [yarn]\nyarn add @vielzeug/keymap\n```\n\n:::\n\n## Quick Start\n\nCreate, mount, then dispose one map owned by your UI scope.\n\n```ts\nimport { createKeymap } from '@vielzeug/keymap';\n\nconst map = createKeymap([\n { id: 'save', shortcut: 'mod+k mod+s', handler: () => console.log('save') },\n { id: 'palette', shortcut: 'mod+shift+p', handler: () => console.log('open palette') },\n { id: 'top', shortcut: 'g g', handler: () => window.scrollTo({ top: 0 }) },\n { id: 'close', shortcut: 'escape', handler: () => console.log('close panel') },\n]);\n\nconst unmount = map.mount(document);\n\nunmount();\nmap.dispose();\n```\n\n## Features\n\n<div class=\"features-grid\">\n\n- `createKeymap()`: Create a keymap from an ordered binding array; mount to any `EventTarget`\n- Chord sequences: `\"g g\"`, `\"ctrl+k ctrl+s\"` with configurable timeout (default 1 s)\n- Modifier aliases: `cmd`/`command`/`win` → `meta`; `opt`/`option` → `alt`; `mod` → platform-aware\n- `Binding` objects: each binding has an explicit `id`, `shortcut`, `handler`, and optional `trigger`, `when`, `preventDefault`, `stopPropagation`\n- Per-binding `preventDefault`/`stopPropagation`: safe defaults (`true`/`false`) applied to completed shortcuts and intermediate chord steps\n- `tap()`: observe chord progress, timeout, matches, and disposal without changing behavior\n- `modKey` option: explicit platform override for SSR and cross-platform tests\n- `formatShortcut()`: platform-aware display (`⇧⌘P` on Mac, `Ctrl+Shift+P` elsewhere)\n- `@vielzeug/keymap/parse` subpath: `parseShortcut()`, `parseStep()`, `matchStep()`, `matchKey()`, `canonicalizeShortcut()`, `detectModKey()` for custom tooling\n- `listBindings()`: snapshot all active bindings (id, shortcut, trigger, preventDefault, stopPropagation) for palette UIs\n- `findShortcutConflicts()`: detect prefix/duplicate conflicts before binding a user-customized shortcut\n- Disposable: `dispose()` + `[Symbol.dispose]` for `using` declarations\n\n</div>\n\n## Documentation\n\n<div class=\"doc-links\">\n\n- [Usage Guide](./usage.md)\n- [API Reference](./api.md)\n- [Examples](./examples.md)\n- [Migration Guide](./migration.md)\n\n</div>\n\n## See Also\n\n<div class=\"see-also\">\n\n- [Herald](/herald/): Typed event bus; pair with Keymap by publishing shortcut events to a bus instead of calling handlers directly\n- [Refine](/refine/): `ore-command-palette` uses Keymap internally; register your own shortcuts alongside it\n- [Ore](/ore/): Attach a keymap inside a `define()` setup function for component-scoped shortcuts\n\n</div>\n\n<!-- markdownlint-enable MD025 MD033 MD060 -->\n",
5
+ "api": "---\ntitle: 'Keymap: API Reference'\ndescription: Complete API reference for @vielzeug/keymap bindings, chords, parsing, formatting, and lifecycle.\n---\n\n[[toc]]\n\n## API Overview\n\n### Core API (Most Users)\n\n| Symbol | Purpose | Execution mode | Common gotcha |\n| --- | --- | --- | --- |\n| `createKeymap()` | Create shortcut manager from an ordered binding array | Sync | `dispose()` is terminal |\n| `findShortcutConflicts()` | Find duplicate and prefix paths | Sync | Invalid non-empty input throws |\n| `formatShortcut()` | Format shortcut labels | Sync | Invalid input returns `''` |\n| `matchKey()` | Match an event against one shortcut pattern | Sync | Unparseable pattern returns `false`; chord patterns never match |\n| `detectModKey()` | Resolve platform primary modifier | Sync | Returns `ctrl` without `navigator` |\n| `tap()` | Observe chord lifecycle, matches, and disposal | Sync | Observer failures are swallowed |\n| `Binding` | Per-binding config type (id, shortcut, handler, trigger, when, preventDefault, stopPropagation) | N/A | Reusing an `id` replaces its binding |\n\n### Parser Subpath (`@vielzeug/keymap/parse`)\n\nUse the parser subpath if you're building keyboard-aware config validators, custom UI, or framework integrations.\n\n| Symbol | Purpose | Execution mode | Common gotcha |\n| --- | --- | --- | --- |\n| `parseShortcut()` | Strictly parse full shortcut | Sync | Empty input throws |\n| `parseStep()` | Parse one step without throwing | Sync | Invalid input returns `null` |\n| `canonicalizeShortcut()` | Create stable shortcut key | Sync | Input must already be parsed |\n| `matchStep()` | Test event against parsed step | Sync | Extra modifiers prevent a match |\n| `detectModKey()` | Resolve platform primary modifier | Sync | Returns `ctrl` without `navigator` |\n\n### Errors\n\n| Symbol | Purpose | Execution mode | Common gotcha |\n| --- | --- | --- | --- |\n| `KeymapError` | Base Keymap error | Sync | Includes parse, config, and lifecycle errors |\n| `KeymapConfigError` | Invalid `createKeymap` option | Sync | Thrown for a non-positive `chordTimeout` |\n| `KeymapParseError` | Strict parser error | Sync | `parseStep()` never throws it |\n\n## Package Entry Points\n\n| Import | Purpose |\n| --- | --- |\n| `@vielzeug/keymap` | Root entry point for `createKeymap`, `formatShortcut`, `findShortcutConflicts`, `matchKey`, `detectModKey`, errors, and public types. |\n| `@vielzeug/keymap/parse` | Parser internals subpath for custom tooling: `parseShortcut`, `parseStep`, `matchStep`, `matchKey`, `canonicalizeShortcut`, `detectModKey`. |\n\n## Core Manager\n\n### `createKeymap()`\n\n```ts\nfunction createKeymap(\n bindings?: readonly Binding[],\n options?: KeymapOptions,\n): Keymap;\n```\n\nCreates shortcut manager with independent chord state for each mounted target.\n\n| Parameter | Type | Description |\n| --- | --- | --- |\n| `bindings` | `readonly Binding[]` | Ordered array of bindings. Each binding has an explicit `id`, `shortcut`, and `handler`. |\n| `options` | `KeymapOptions` | Chord, modifier, and global-guard configuration. |\n\n**Returns:** `Keymap`.\n\n```ts\nimport { createKeymap } from '@vielzeug/keymap';\n\nconst map = createKeymap([\n { id: 'save', shortcut: 'ctrl+s', handler: () => console.log('save') },\n]);\nconst unmount = map.mount(document);\n\nunmount();\nmap.dispose();\n```\n\n| `Keymap` member | Return | Contract |\n| --- | --- | --- |\n| `bind(binding)` | `() => void` | Adds or replaces a binding with the same `id`. Returned callback removes that binding while active. |\n| `mount(target)` | `() => void` | Adds target listener. Repeat mounts of same target are reference-counted. |\n| `unbind(id)` | `void` | Removes binding by id. Warns in development when unknown. |\n| `listBindings()` | `readonly BindingEntry[]` | Returns a detached binding snapshot. |\n| `tap(handler, options?)` | `() => void` | Observes chord lifecycle, matches, and disposal. Supports signal-owned teardown. |\n| `dispose()` | `void` | Removes all listeners, aborts signal, and permanently disposes map. Idempotent. |\n| `disposed` | `boolean` | `true` after first `dispose()`. |\n| `disposalSignal` | `AbortSignal` | Aborts when map is disposed. |\n| `[Symbol.dispose]()` | `void` | Calls `dispose()`. |\n\nAfter disposal, `bind()`, `unbind()`, and `mount()` throw `KeymapError`.\n\n## Runtime Observation\n\n### `tap()`\n\n```ts\nfunction tap(\n handler: (event: KeymapEvent) => void,\n options?: { signal?: AbortSignal },\n): () => void;\n```\n\nObserves chord progress, cancellation, completed matches, timeouts, and disposal without affecting shortcut behavior.\n\n| Parameter | Type | Description |\n| --- | --- | --- |\n| `handler` | `(event: KeymapEvent) => void` | Receives each runtime event synchronously. Errors are swallowed. |\n| `options.signal` | `AbortSignal` | Detaches the handler when aborted. |\n\n**Returns:** An idempotent detach function.\n\n```ts\nconst stop = map.tap((event) => {\n if (event.type === 'chord-start') showChordHint(event.step);\n if (event.type === 'chord-cancel' || event.type === 'chord-timeout' || event.type === 'match') hideChordHint();\n});\n```\n\n## Conflict Analysis\n\n### `findShortcutConflicts()`\n\n```ts\nfunction findShortcutConflicts(\n shortcut: string,\n entries: readonly BindingEntry[],\n options?: ConflictOptions,\n): BindingEntry[];\n```\n\nReturns entries with same-trigger exact or prefix-conflicting shortcut paths.\n\n| Parameter | Type | Description |\n| --- | --- | --- |\n| `shortcut` | `string` | Proposed shortcut. Empty or whitespace-only input returns no conflicts. |\n| `entries` | `readonly BindingEntry[]` | Bindings to compare, commonly `map.listBindings()`. |\n| `options` | `ConflictOptions` | Optional modifier resolution and trigger filter. |\n\n**Returns:** Matching entries. Returns `[]` when no conflict exists.\n\n```ts\nimport { createKeymap, findShortcutConflicts } from '@vielzeug/keymap';\n\nconst map = createKeymap([{ id: 'top', shortcut: 'g', handler: () => console.log('top') }]);\nconst conflicts = findShortcutConflicts('g g', map.listBindings());\n\nconsole.log(conflicts.length); // 1\n```\n\n## Formatting\n\n### `formatShortcut()`\n\n```ts\nfunction formatShortcut(shortcut: string, modKey?: 'ctrl' | 'meta'): string;\n```\n\nFormats parsed shortcut into Mac symbols for `meta` or word labels for `ctrl`.\n\n| Parameter | Type | Description |\n| --- | --- | --- |\n| `shortcut` | `string` | Shortcut string to format. |\n| `modKey` | `'ctrl' \\| 'meta'` | Platform primary modifier. Defaults to `detectModKey()`. |\n\n**Returns:** Display label, or `''` for invalid input.\n\n```ts\nimport { formatShortcut } from '@vielzeug/keymap';\n\nformatShortcut('mod+shift+p', 'meta'); // ⇧⌘P\nformatShortcut('mod+shift+p', 'ctrl'); // Ctrl+Shift+P\n```\n\n## Parser Subpath (`@vielzeug/keymap/parse`)\n\n### `parseShortcut()`\n\n```ts\nfunction parseShortcut(raw: string, modKey?: 'ctrl' | 'meta'): Shortcut;\n```\n\nStrictly parses one or more space-separated shortcut steps.\n\n| Parameter | Type | Description |\n| --- | --- | --- |\n| `raw` | `string` | Full shortcut string. |\n| `modKey` | `'ctrl' \\| 'meta'` | Platform primary modifier. Defaults to `detectModKey()`. |\n\n**Returns:** Parsed `Shortcut`.\n\n```ts\nimport { parseShortcut } from '@vielzeug/keymap/parse';\n\nconst shortcut = parseShortcut('ctrl+k ctrl+s', 'ctrl');\nconsole.log(shortcut.length); // 2\n```\n\nThrows `KeymapParseError` for empty, modifier-only, or ambiguous steps.\n\n---\n\n### `parseStep()`\n\n```ts\nfunction parseStep(raw: string, modKey?: 'ctrl' | 'meta'): ShortcutStep | null;\n```\n\nParses one shortcut step without throwing.\n\n| Parameter | Type | Description |\n| --- | --- | --- |\n| `raw` | `string` | One shortcut step. |\n| `modKey` | `'ctrl' \\| 'meta'` | Platform primary modifier. Defaults to `detectModKey()`. |\n\n**Returns:** Parsed `ShortcutStep`, or `null` for empty, modifier-only, or ambiguous input.\n\n```ts\nimport { parseStep } from '@vielzeug/keymap/parse';\n\nparseStep('ctrl+k', 'ctrl'); // { key: 'k', modifiers: Set(['ctrl']) }\nparseStep('ctrl+k+j', 'ctrl'); // null\n```\n\n---\n\n### `canonicalizeShortcut()`\n\n```ts\nfunction canonicalizeShortcut(steps: readonly ShortcutStep[]): string;\n```\n\nConverts parsed steps into stable canonical string with sorted modifier order.\n\n| Parameter | Type | Description |\n| --- | --- | --- |\n| `steps` | `readonly ShortcutStep[]` | Parsed shortcut steps. |\n\n**Returns:** Canonical shortcut string.\n\n```ts\nimport { canonicalizeShortcut, parseShortcut } from '@vielzeug/keymap/parse';\n\ncanonicalizeShortcut(parseShortcut('shift+ctrl+k', 'ctrl')); // ctrl+shift+k\n```\n\n---\n\n### `matchStep()`\n\n```ts\nfunction matchStep(event: KeyboardEvent, step: ShortcutStep): boolean;\n```\n\nTests exact key and modifier equality for one parsed step.\n\n| Parameter | Type | Description |\n| --- | --- | --- |\n| `event` | `KeyboardEvent` | Event to match. Missing runtime `key` returns `false`. |\n| `step` | `ShortcutStep` | Parsed step. |\n\n**Returns:** `true` only when key and all modifier states match.\n\n```ts\nimport { matchStep, parseStep } from '@vielzeug/keymap/parse';\n\nconst step = parseStep('ctrl+k', 'ctrl')!;\nmatchStep(new KeyboardEvent('keydown', { ctrlKey: true, key: 'k' }), step); // true\n```\n\n---\n\n### `matchKey()`\n\n```ts\nfunction matchKey(event: KeyboardEvent, pattern: string, modKey?: 'ctrl' | 'meta'): boolean;\n```\n\nMatches a keyboard event against a single shortcut pattern such as `'ArrowDown'`, `'esc'`, `'mod+k'`, or `'shift+Home'`. Aliases and `mod` resolve through the same parser `createKeymap` uses, and modifier state must match exactly: `'ArrowDown'` does not match Ctrl+ArrowDown. Exported from both the root entry point and `@vielzeug/keymap/parse`.\n\n| Parameter | Type | Description |\n| --- | --- | --- |\n| `event` | `KeyboardEvent` | Event to match. Missing runtime `key` returns `false`. |\n| `pattern` | `string` | One shortcut step. Unparseable patterns and multi-step chords return `false` (never throws). |\n| `modKey` | `'ctrl' \\| 'meta'` | Optional. Resolution for `mod`; defaults to `detectModKey()`. |\n\n**Returns:** `true` only when key and all modifier states match.\n\n```ts\nimport { matchKey } from '@vielzeug/keymap';\n\nif (matchKey(event, 'mod+k')) openPalette();\nif (matchKey(event, 'shift+Home')) selectToStart(event);\n```\n\n---\n\n### `detectModKey()`\n\n```ts\nfunction detectModKey(): 'ctrl' | 'meta';\n```\n\nDetects Mac platform from `navigator` and otherwise returns `ctrl`.\n\n**Returns:** `'meta'` on Mac platforms; `'ctrl'` elsewhere or without `navigator`.\n\n```ts\nimport { detectModKey } from '@vielzeug/keymap/parse';\n\nconst modKey = detectModKey();\n```\n\n## Types\n\n### `Keymap`\n\nStateful shortcut manager returned by `createKeymap()`.\n\n```ts\ninterface Keymap {\n [Symbol.dispose](): void;\n bind(binding: Binding): () => void;\n dispose(): void;\n readonly disposalSignal: AbortSignal;\n readonly disposed: boolean;\n listBindings(): readonly BindingEntry[];\n mount(target: EventTarget): () => void;\n tap(handler: (event: KeymapEvent) => void, options?: { signal?: AbortSignal }): () => void;\n unbind(id: string): void;\n}\n```\n\n### `KeymapOptions`\n\nOptions applied to every binding owned by one manager.\n\n```ts\ninterface KeymapOptions {\n chordTimeout?: number;\n modKey?: 'ctrl' | 'meta';\n when?: When;\n}\n```\n\n- `when`: Guard function called for all bindings. When combined with per-binding `when` guards, both must return `true` for the handler to fire (AND composition). Global guard is checked first.\n- `chordTimeout`: Must be a positive finite number; `createKeymap()` throws `KeymapConfigError` otherwise. Defaults to `1000`.\n\n### `KeymapEvent`\n\nRuntime event observed through `tap()`.\n\n```ts\ntype KeymapEvent =\n | { type: 'chord-cancel'; target: EventTarget; trigger: 'keydown' | 'keyup' }\n | { type: 'chord-start'; step: ShortcutStep; target: EventTarget; trigger: 'keydown' | 'keyup' }\n | { type: 'chord-progress'; steps: readonly ShortcutStep[]; target: EventTarget; trigger: 'keydown' | 'keyup' }\n | { type: 'chord-timeout'; target: EventTarget; trigger: 'keydown' | 'keyup' }\n | { type: 'match'; binding: BindingEntry; target: EventTarget; trigger: 'keydown' | 'keyup' }\n | { type: 'dispose' };\n```\n\n`step`, `steps`, and `binding` are detached snapshots. Mutating them does not affect matching.\n\n### `Binding`\n\nPer-binding configuration. Each binding has an explicit `id` so duplicate shortcuts can coexist. Reusing an ID replaces its binding; among duplicate shortcuts, the first binding whose guard passes wins.\n\n```ts\ninterface Binding {\n id: string;\n shortcut: string;\n handler: Handler;\n trigger?: 'keydown' | 'keyup';\n when?: When;\n preventDefault?: boolean;\n stopPropagation?: boolean;\n}\n```\n\n- `trigger`: Defaults to `'keydown'`.\n- `preventDefault`: Defaults to `true`. Set to `false` for shortcuts that must retain browser behavior.\n- `stopPropagation`: Defaults to `false`.\n\n### `Handler` and `When`\n\n```ts\ntype Handler = (event: KeyboardEvent) => void;\ntype When = (event: KeyboardEvent) => boolean;\n```\n\n### `BindingEntry`\n\nDetached binding metadata returned by `listBindings()`.\n\n```ts\ntype BindingEntry = {\n readonly id: string;\n readonly shortcut: readonly ShortcutStep[];\n readonly trigger: 'keydown' | 'keyup';\n readonly preventDefault: boolean;\n readonly stopPropagation: boolean;\n};\n```\n\n### `ModifierKey`, `Shortcut`, and `ShortcutStep`\n\nParser types used by `parseShortcut()`, `parseStep()`, `matchStep()`, and `canonicalizeShortcut()`. Available from `@vielzeug/keymap/parse`.\n\n```ts\ntype ModifierKey = 'alt' | 'ctrl' | 'meta' | 'shift';\n\ntype ShortcutStep = {\n key: string;\n modifiers: Set<ModifierKey>;\n};\n\ntype Shortcut = ShortcutStep[];\n```\n\n### `ConflictOptions`\n\nComparison options for `findShortcutConflicts()`.\n\n```ts\ninterface ConflictOptions {\n modKey?: 'ctrl' | 'meta';\n trigger?: 'keydown' | 'keyup';\n}\n```\n\n## Errors\n\n| Error | Trigger | Notable properties |\n| --- | --- | --- |\n| `KeymapError` | Lifecycle operation after disposal | Use `instanceof KeymapError` to narrow Keymap errors. |\n| `KeymapConfigError` | `createKeymap()` receives an invalid option | Extends `KeymapError`. Thrown for a non-positive or non-finite `chordTimeout`. |\n| `KeymapParseError` | Strict shortcut parser receives invalid input | Extends `KeymapError`. |\n",
6
+ "usage": "---\ntitle: 'Keymap: Usage Guide'\ndescription: Bind keyboard shortcuts, chords, event-aware guards, and target-local listeners with @vielzeug/keymap.\n---\n\n[[toc]]\n\n## Basic Usage\n\nMount one keymap, then release its target listener and dispose its owner during teardown.\n\n```ts\nimport { createKeymap } from '@vielzeug/keymap';\n\nconst map = createKeymap([\n { id: 'save', shortcut: 'ctrl+s', handler: () => console.log('save') },\n { id: 'undo', shortcut: 'ctrl+z', handler: () => console.log('undo') },\n { id: 'close', shortcut: 'escape', handler: () => console.log('close') },\n]);\n\nconst unmount = map.mount(document);\n\n// Call this when the owning UI scope ends.\nunmount();\nmap.dispose();\n```\n\n`unmount()` only releases that target. `dispose()` releases every target, aborts `disposalSignal`, and makes `bind()`, `unbind()`, and `mount()` unavailable.\n\n## Modifier Aliases\n\nUse aliases to accept platform terminology while Keymap stores one canonical shortcut.\n\n| Input | Canonical modifier |\n| --- | --- |\n| `cmd`, `command`, `win` | `meta` |\n| `opt`, `option` | `alt` |\n| `ctrl`, `control` | `ctrl` |\n| `mod` | `meta` on Mac; `ctrl` elsewhere |\n\nPass `modKey` when rendering or testing a specific platform.\n\n```ts\nimport { createKeymap } from '@vielzeug/keymap';\n\nconst map = createKeymap(\n [{ id: 'palette', shortcut: 'mod+k', handler: () => console.log('open palette') }],\n { modKey: 'ctrl' },\n);\n\nmap.mount(document);\n```\n\n## Chord Sequences\n\nSeparate chord steps with spaces. Keymap resets an incomplete sequence after `chordTimeout` milliseconds.\n\n```ts\nconst map = createKeymap(\n [\n { id: 'save', shortcut: 'ctrl+k ctrl+s', handler: () => console.log('save') },\n { id: 'top', shortcut: 'g g', handler: () => window.scrollTo({ top: 0 }) },\n { id: 'end', shortcut: 'g e', handler: () => window.scrollTo({ top: document.body.scrollHeight }) },\n ],\n { chordTimeout: 800 },\n);\n```\n\nDo not bind a complete shortcut and a longer chord beginning with that shortcut. `g` fires immediately, so `g g` cannot complete. Check proposed user bindings with `findShortcutConflicts()`.\n\n## Binding Options\n\nEach binding is an object with an explicit `id`, `shortcut`, and `handler`. Add a guard, choose `keyup`, or control event behavior with optional fields.\n\n```ts\nconst map = createKeymap([\n { id: 'save', shortcut: 'ctrl+s', handler: () => saveDocument() },\n { id: 'close', shortcut: 'escape', handler: closePanel, when: (event) => event.target === panel },\n { id: 'play', shortcut: 'space', handler: togglePlayback, trigger: 'keyup' },\n { id: 'inspect', shortcut: 'f12', handler: inspectElement, preventDefault: false },\n]);\n```\n\n### Per-Binding preventDefault and stopPropagation\n\nEach binding controls `preventDefault` and `stopPropagation` independently:\n\n- `preventDefault` defaults to `true`: completed shortcuts and matched chord prefixes suppress browser defaults.\n- `stopPropagation` defaults to `false`: events continue bubbling unless explicitly stopped.\n\nSet `preventDefault: false` for shortcuts that must coexist with native controls (e.g. developer tools, accessibility features).\n\n## Context Guards\n\nUse global `when(event)` for policy shared by every binding. Use per-binding `when(event)` when one shortcut needs a narrower policy.\n\n```ts\nconst map = createKeymap(\n [\n { id: 'close', shortcut: 'escape', handler: closePanel, when: (event) => event.target === panel },\n { id: 'save', shortcut: 'ctrl+s', handler: () => saveDocument() },\n ],\n { when: (event) => !modalIsOpen() && event.isTrusted },\n);\n```\n\nAccept `KeyboardEvent` when guard logic needs target, modifier, composition, or shadow-DOM context.\n\n### Guard Composition: Global + Per-Binding\n\nWhen you provide both a global `when` (in `KeymapOptions`) and per-binding `when` guards, both must return `true` for the handler to fire. This is AND composition.\n\n**Guard evaluation and chord tracking order:**\n\n1. **Global guard runs first.** If it returns `false`, the target's pending chord state resets.\n2. **Per-binding guards select candidates.** A binding participates in each chord step only while its guard passes.\n3. **Chord state advances for eligible candidates.** The first completed candidate in binding order runs.\n\nA chord cannot begin while its map is disabled and finish after the global guard becomes active.\n\n```ts\nconst map = createKeymap(\n [\n { id: 'close', shortcut: 'escape', handler: closePanel, when: (event) => event.target === panel },\n { id: 'save', shortcut: 'ctrl+s', handler: () => saveDocument() },\n ],\n { when: (event) => !isModalOpen() && event.isTrusted },\n);\n\n// Global guard runs first; if false, both bindings are skipped (handler doesn't fire).\n// If global passes:\n// - 'save' handler fires immediately.\n// - 'close' handler fires only if event.target is the panel.\n```\n\n### Preserve Native Text Editing\n\nUse `event.composedPath()` to keep browser undo and redo inside inputs, textareas, and `contenteditable` elements. CRM app shell uses this policy for its global undo and redo shortcuts.\n\n```ts\nconst isTypingInField = (event: KeyboardEvent): boolean =>\n event.composedPath().some(\n (target) =>\n target instanceof HTMLElement &&\n (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || target.isContentEditable),\n );\n\nconst map = createKeymap(\n [\n { id: 'undo', shortcut: 'mod+z', handler: () => undo() },\n { id: 'redo', shortcut: 'mod+shift+z', handler: () => redo() },\n ],\n { when: (event) => !isTypingInField(event) },\n);\n```\n\nDo not make editable-field suppression a hidden package default. Applications may intentionally bind shortcuts inside editable controls.\n\n## Trigger Control\n\nBind on `keyup` when an action must run after key release.\n\n```ts\nconst map = createKeymap([\n { id: 'confirm', shortcut: 'space', handler: confirmAction, trigger: 'keyup' },\n]);\n```\n\n`keydown` and `keyup` maintain independent chord state.\n\n## Replace Bindings at Runtime\n\n`bind(binding)` adds or replaces a binding with the same `id` and returns a targeted removal callback.\n\n```ts\nconst map = createKeymap([{ id: 'palette', shortcut: 'ctrl+k', handler: defaultAction }]);\nconst removePluginBinding = map.bind({ id: 'palette', shortcut: 'ctrl+k', handler: pluginAction });\n\nremovePluginBinding();\nmap.bind({ id: 'palette', shortcut: 'ctrl+k', handler: defaultAction });\n```\n\n`unbind(id)` removes the binding with that id and warns in development when no binding exists.\n\n### Duplicate Shortcuts with Different IDs\n\nEach binding has an explicit `id`, so duplicate shortcuts can coexist. Keymap evaluates them in insertion order and fires the first binding whose guard passes.\n\n```ts\nconst map = createKeymap([\n { id: 'primary', shortcut: 'ctrl+k', handler: primaryAction },\n { id: 'fallback', shortcut: 'ctrl+k', handler: fallbackAction },\n]);\n\n// 'primary' fires while eligible; use mutually exclusive guards for contextual fallback.\n```\n\n## Format Shortcut Labels\n\nFormat labels with explicit platform behavior when your UI is cross-platform.\n\n```ts\nimport { formatShortcut } from '@vielzeug/keymap';\n\nconsole.log(formatShortcut('mod+shift+p', 'meta')); // ⇧⌘P\nconsole.log(formatShortcut('mod+shift+p', 'ctrl')); // Ctrl+Shift+P\n```\n\n`formatShortcut()` returns `''` and emits a development warning for invalid input.\n\n## Detect Conflicts\n\nCheck a custom shortcut before binding it to prevent duplicate or unreachable chord paths.\n\n```ts\nimport { createKeymap, findShortcutConflicts } from '@vielzeug/keymap';\n\nconst map = createKeymap([{ id: 'top', shortcut: 'g', handler: () => scrollToTop() }]);\nconst conflicts = findShortcutConflicts('g g', map.listBindings());\n\nif (conflicts.length === 0) map.bind({ id: 'bottom', shortcut: 'g g', handler: () => scrollToBottom() });\n```\n\nConflict detection compares only bindings with same trigger. An empty proposal returns no conflicts; other invalid proposals throw `KeymapParseError`.\n\n## Parser Subpath\n\nFor custom tooling, validators, or framework integrations that need direct access to the shortcut parser, import from `@vielzeug/keymap/parse`:\n\n```ts\nimport { parseShortcut, parseStep, matchStep, canonicalizeShortcut } from '@vielzeug/keymap/parse';\n\nconst steps = parseShortcut('ctrl+k ctrl+s', 'ctrl');\nconst step = parseStep('ctrl+k', 'ctrl');\nconst isMatch = matchStep(new KeyboardEvent('keydown', { ctrlKey: true, key: 'k' }), steps[0]);\n```\n\n`matchKey()` and `detectModKey()` are also exported from the root entry point. For a one-off check against a pattern string, prefer `matchKey()`: it parses and matches in one call and returns `false` instead of throwing:\n\n```ts\nimport { matchKey } from '@vielzeug/keymap';\n\nif (matchKey(event, 'mod+k')) openPalette();\n```\n\n## Mount Targets\n\nMount one keymap on multiple independent targets when each target should own its own chord progression.\n\n```ts\nconst map = createKeymap([{ id: 'top', shortcut: 'g g', handler: () => console.log('go to top') }]);\nconst unmountEditor = map.mount(editor);\nconst unmountPreview = map.mount(preview);\n```\n\nA chord started on `editor` cannot complete on `preview`. Repeated `mount(editor)` calls share one listener and require one unmount call each. For nested targets, Keymap handles one bubbled event at its innermost mounted target.\n\n## Scoped Maps\n\nCreate separate keymaps for separate UI owners. If maps share a target and shortcut, guards must be mutually exclusive because Keymap has no implicit layer precedence.\n\n```ts\nconst baseMap = createKeymap(\n [{ id: 'close-sidebar', shortcut: 'escape', handler: () => closeSidebar() }],\n { when: () => !modalIsOpen() },\n);\n\nconst modalMap = createKeymap(\n [{ id: 'close-modal', shortcut: 'escape', handler: () => closeModal() }],\n { when: () => modalIsOpen() },\n);\n\nconst unmountBase = baseMap.mount(document);\nconst unmountModal = modalMap.mount(document);\n```\n\n## Observe Chord Activity\n\nUse `tap()` for chord hints, diagnostics, and matched-binding telemetry. Observers cannot alter shortcut behavior.\n\n```ts\nconst stopTrace = map.tap((event) => {\n if (event.type === 'chord-start') showChordHint(event.step);\n if (event.type === 'chord-progress') updateChordHint(event.steps);\n if (event.type === 'chord-cancel' || event.type === 'chord-timeout' || event.type === 'match') hideChordHint();\n}, { signal: owner.signal });\n```\n\nTap handlers receive detached shortcut and binding snapshots. Handler errors are swallowed, and disposal emits a final `dispose` event before observers are cleared.\n\n## Testing\n\nDispatch `KeyboardEvent` instances against a mounted DOM target to test handlers and default prevention.\n\n```ts\nimport { expect, it, vi } from 'vitest';\n\nimport { createKeymap } from '@vielzeug/keymap';\n\nit('handles save', () => {\n const save = vi.fn();\n const target = document.createElement('button');\n const map = createKeymap([{ id: 'save', shortcut: 'ctrl+s', handler: save }]);\n const unmount = map.mount(target);\n\n target.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, ctrlKey: true, key: 's' }));\n\n expect(save).toHaveBeenCalledOnce();\n unmount();\n map.dispose();\n});\n```\n\nMount nested DOM targets in tests when your application uses both a container and a descendant listener. This verifies one bubbled event cannot complete a chord twice.\n\n## Framework Integration\n\nCreate map during framework lifecycle, then dispose it during teardown.\n\n::: code-group\n\n```tsx [React]\nimport { useEffect } from 'react';\n\nimport { createKeymap } from '@vielzeug/keymap';\n\nexport function App() {\n useEffect(() => {\n const map = createKeymap([{ id: 'palette', shortcut: 'ctrl+k', handler: () => console.log('open palette') }]);\n const unmount = map.mount(document);\n\n return () => {\n unmount();\n map.dispose();\n };\n }, []);\n\n return null;\n}\n```\n\n```vue [Vue 3]\n<script setup lang=\"ts\">\nimport { onMounted, onUnmounted } from 'vue';\n\nimport { createKeymap } from '@vielzeug/keymap';\n\nconst map = createKeymap([{ id: 'close', shortcut: 'escape', handler: () => console.log('close palette') }]);\nlet unmount: (() => void) | undefined;\n\nonMounted(() => {\n unmount = map.mount(document);\n});\n\nonUnmounted(() => {\n unmount?.();\n map.dispose();\n});\n</script>\n```\n\n```ts [Svelte]\nimport { onMount } from 'svelte';\n\nimport { createKeymap } from '@vielzeug/keymap';\n\nconst map = createKeymap([{ id: 'close', shortcut: 'escape', handler: () => console.log('close palette') }]);\n\nonMount(() => {\n const unmount = map.mount(document);\n\n return () => {\n unmount();\n map.dispose();\n };\n});\n```\n\n:::\n\n## Working with Other Vielzeug Libraries\n\n### Keymap + Ledger\n\nConnect undo and redo handlers to a Ledger owner.\n\n```ts\nimport { createKeymap } from '@vielzeug/keymap';\nimport { createLedger } from '@vielzeug/ledger';\n\nconst ledger = createLedger();\nconst reportHistoryError = (error: unknown): void => console.error(error);\nconst map = createKeymap([\n { id: 'undo', shortcut: 'mod+z', handler: () => void ledger.undo().catch(reportHistoryError) },\n { id: 'redo', shortcut: 'mod+shift+z', handler: () => void ledger.redo().catch(reportHistoryError) },\n]);\n\nmap.mount(document);\n```\n\n### Keymap + Herald\n\nEmit domain events instead of calling application actions from shortcut handlers.\n\n```ts\nimport { createBus } from '@vielzeug/herald';\nimport { createKeymap } from '@vielzeug/keymap';\n\nconst bus = createBus<{ 'shortcut:save': void }>();\nconst map = createKeymap([\n { id: 'save', shortcut: 'ctrl+s', handler: () => bus.emit('shortcut:save') },\n]);\n\nmap.mount(document);\n```\n\n## Best Practices\n\n- **Dispose** every map when its owner ends.\n- **Unmount** temporary target listeners instead of disposing reusable maps.\n- **Guard** global text-editing shortcuts with `event.composedPath()`.\n- **Check** conflicts before accepting customized shortcuts.\n- **Keep** shared-target guards mutually exclusive.\n- **Use** `mod` for primary cross-platform shortcuts.\n- **Avoid** prefix pairs such as `g` and `g g`.\n- **Use** unique `id`s for each binding and mutually exclusive guards for intentional duplicate shortcuts.\n",
7
+ "examples": "---\ntitle: 'Keymap: Examples'\ndescription: Worked examples for @vielzeug/keymap.\n---\n\n## Examples\n\n- [Global Shortcuts](./examples/global-shortcuts.md)\n- [Vim-style Navigation](./examples/vim-navigation.md)\n"
8
8
  },
9
9
  "examples": [
10
10
  {
11
11
  "id": "basic-shortcuts",
12
- "code": "import { createKeymap, formatShortcut } from '@vielzeug/keymap'\n\n// Create a keymap — each binding has an explicit id, shortcut, and handler.\n// Bindings fire on keydown by default; preventDefault defaults to true.\nconst map = createKeymap([\n { id: 'save', shortcut: 'ctrl+s', handler: () => console.log('save triggered') },\n { id: 'close', shortcut: 'escape', handler: () => console.log('close panel'), when: () => true },\n { id: 'play', shortcut: 'space', handler: () => console.log('toggle play'), trigger: 'keyup' },\n], { modKey: 'ctrl' })\n\n// Mount to document (required for event listening).\nconst unmount = map.mount(document)\n\n// Simulate events for demonstration.\ndocument.dispatchEvent(new KeyboardEvent('keydown', { key: 's', ctrlKey: true, bubbles: true }))\ndocument.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))\ndocument.dispatchEvent(new KeyboardEvent('keyup', { key: ' ', bubbles: true }))\n\n// Format shortcuts for display in UI tooltips or menus.\nconsole.log(formatShortcut('ctrl+s', 'ctrl')) // 'Ctrl+S'\nconsole.log(formatShortcut('mod+shift+p', 'meta')) // '⇧⌘P'\nconsole.log(formatShortcut('ctrl+k ctrl+s', 'ctrl')) // 'Ctrl+K Ctrl+S'\n\nunmount()",
12
+ "code": "import { createKeymap, formatShortcut } from '@vielzeug/keymap'\n\n// Create a keymap: each binding has an explicit id, shortcut, and handler.\n// Bindings fire on keydown by default; preventDefault defaults to true.\nconst map = createKeymap([\n { id: 'save', shortcut: 'ctrl+s', handler: () => console.log('save triggered') },\n { id: 'close', shortcut: 'escape', handler: () => console.log('close panel'), when: () => true },\n { id: 'play', shortcut: 'space', handler: () => console.log('toggle play'), trigger: 'keyup' },\n], { modKey: 'ctrl' })\n\n// Mount to document (required for event listening).\nconst unmount = map.mount(document)\n\n// Simulate events for demonstration.\ndocument.dispatchEvent(new KeyboardEvent('keydown', { key: 's', ctrlKey: true, bubbles: true }))\ndocument.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))\ndocument.dispatchEvent(new KeyboardEvent('keyup', { key: ' ', bubbles: true }))\n\n// Format shortcuts for display in UI tooltips or menus.\nconsole.log(formatShortcut('ctrl+s', 'ctrl')) // 'Ctrl+S'\nconsole.log(formatShortcut('mod+shift+p', 'meta')) // '⇧⌘P'\nconsole.log(formatShortcut('ctrl+k ctrl+s', 'ctrl')) // 'Ctrl+K Ctrl+S'\n\nunmount()",
13
13
  "name": "Basic Shortcuts"
14
14
  },
15
15
  {
16
16
  "id": "chord-sequences",
17
- "code": "import { createKeymap, findShortcutConflicts } from '@vielzeug/keymap'\n\n// Chord sequences fire only after all steps are pressed in order within the timeout.\n// Shortcut strings are lowercased before matching, so 'g g' and 'g G' are equivalent.\n// Different ids let both coexist, but the first eligible binding wins. Vim's actual 'G'\n// is shift+g, a single keystroke rather than a 'g'-prefixed chord.\nconst map = createKeymap([\n { id: 'save-all', shortcut: 'ctrl+k ctrl+s', handler: () => console.log('save all (VS Code-style)') },\n { id: 'top', shortcut: 'g g', handler: () => console.log('go to top (Vim-style)') },\n { id: 'bottom', shortcut: 'shift+g', handler: () => console.log('go to bottom') },\n], { chordTimeout: 800, modKey: 'ctrl' })\n\nconst unmount = map.mount(document)\n\n// Simulate completing 'ctrl+k ctrl+s' chord.\ndocument.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', ctrlKey: true, bubbles: true }))\ndocument.dispatchEvent(new KeyboardEvent('keydown', { key: 's', ctrlKey: true, bubbles: true }))\n\n// Simulate Vim 'g g' chord.\ndocument.dispatchEvent(new KeyboardEvent('keydown', { key: 'g', bubbles: true }))\ndocument.dispatchEvent(new KeyboardEvent('keydown', { key: 'g', bubbles: true }))\n\n// Gotcha: a single-key binding sharing a chord's first step always wins immediately,\n// making the longer chord unreachable — findShortcutConflicts() catches this up front.\nconsole.log('Would ctrl+k (alone) conflict with the chord above?')\nconsole.log(findShortcutConflicts('ctrl+k', map.listBindings()))\n\nunmount()",
17
+ "code": "import { createKeymap, findShortcutConflicts } from '@vielzeug/keymap'\n\n// Chord sequences fire only after all steps are pressed in order within the timeout.\n// Shortcut strings are lowercased before matching, so 'g g' and 'g G' are equivalent.\n// Different ids let both coexist, but the first eligible binding wins. Vim's actual 'G'\n// is shift+g, a single keystroke rather than a 'g'-prefixed chord.\nconst map = createKeymap([\n { id: 'save-all', shortcut: 'ctrl+k ctrl+s', handler: () => console.log('save all (VS Code-style)') },\n { id: 'top', shortcut: 'g g', handler: () => console.log('go to top (Vim-style)') },\n { id: 'bottom', shortcut: 'shift+g', handler: () => console.log('go to bottom') },\n], { chordTimeout: 800, modKey: 'ctrl' })\n\nconst unmount = map.mount(document)\n\n// Simulate completing 'ctrl+k ctrl+s' chord.\ndocument.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', ctrlKey: true, bubbles: true }))\ndocument.dispatchEvent(new KeyboardEvent('keydown', { key: 's', ctrlKey: true, bubbles: true }))\n\n// Simulate Vim 'g g' chord.\ndocument.dispatchEvent(new KeyboardEvent('keydown', { key: 'g', bubbles: true }))\ndocument.dispatchEvent(new KeyboardEvent('keydown', { key: 'g', bubbles: true }))\n\n// Gotcha: a single-key binding sharing a chord's first step always wins immediately,\n// making the longer chord unreachable: findShortcutConflicts() catches this up front.\nconsole.log('Would ctrl+k (alone) conflict with the chord above?')\nconsole.log(findShortcutConflicts('ctrl+k', map.listBindings()))\n\nunmount()",
18
18
  "name": "Chord Sequences"
19
19
  },
20
20
  {
21
21
  "id": "conflict-detection",
22
- "code": "import { createKeymap, findShortcutConflicts } from '@vielzeug/keymap'\n\n// findShortcutConflicts() catches unreachable bindings before you register them —\n// useful for a shortcut-customization UI driven by user input.\nconst map = createKeymap([\n { id: 'top', shortcut: 'g', handler: () => console.log('go to top') },\n])\n\nconst proposed = 'g g'\nconst conflicts = findShortcutConflicts(proposed, map.listBindings())\n\nif (conflicts.length > 0) {\n console.log(`\"${proposed}\" would never fire — shadowed by an existing binding`)\n} else {\n map.bind({ id: 'bottom', shortcut: proposed, handler: () => console.log('go to bottom') })\n}\n\n// A shortcut with no relationship to existing bindings reports no conflicts.\nconsole.log('ctrl+s conflicts:', findShortcutConflicts('ctrl+s', map.listBindings()).length)\n\n// keydown and keyup bindings never conflict — they're matched independently.\nconst withKeyup = createKeymap([\n { id: 'space-up', shortcut: 'space', handler: () => {}, trigger: 'keyup' },\n])\nconsole.log(\n 'space (keydown) vs space (keyup):',\n findShortcutConflicts('space', withKeyup.listBindings(), { trigger: 'keydown' }).length,\n)",
22
+ "code": "import { createKeymap, findShortcutConflicts } from '@vielzeug/keymap'\n\n// findShortcutConflicts() catches unreachable bindings before you register them :\n// useful for a shortcut-customization UI driven by user input.\nconst map = createKeymap([\n { id: 'top', shortcut: 'g', handler: () => console.log('go to top') },\n])\n\nconst proposed = 'g g'\nconst conflicts = findShortcutConflicts(proposed, map.listBindings())\n\nif (conflicts.length > 0) {\n console.log(`\"${proposed}\" would never fire: shadowed by an existing binding`)\n} else {\n map.bind({ id: 'bottom', shortcut: proposed, handler: () => console.log('go to bottom') })\n}\n\n// A shortcut with no relationship to existing bindings reports no conflicts.\nconsole.log('ctrl+s conflicts:', findShortcutConflicts('ctrl+s', map.listBindings()).length)\n\n// keydown and keyup bindings never conflict: they're matched independently.\nconst withKeyup = createKeymap([\n { id: 'space-up', shortcut: 'space', handler: () => {}, trigger: 'keyup' },\n])\nconsole.log(\n 'space (keydown) vs space (keyup):',\n findShortcutConflicts('space', withKeyup.listBindings(), { trigger: 'keydown' }).length,\n)",
23
23
  "name": "Conflict Detection"
24
24
  },
25
25
  {
@@ -29,7 +29,7 @@
29
29
  },
30
30
  {
31
31
  "id": "shortcut-utilities",
32
- "code": "import { createKeymap, formatShortcut, findShortcutConflicts } from '@vielzeug/keymap'\n\n// listBindings() — inspect active bindings at runtime.\n// Each entry has id, shortcut (parsed steps), trigger, preventDefault, and stopPropagation.\nconst map = createKeymap(\n [\n { id: 'ctrl-k', shortcut: 'ctrl+k', handler: () => console.log('ctrl+k fired') },\n { id: 'save', shortcut: 'ctrl+shift+s', handler: () => console.log('save fired'), trigger: 'keyup' },\n { id: 'close', shortcut: 'escape', handler: () => console.log('close'), preventDefault: false },\n ],\n { modKey: 'ctrl' },\n)\n\nconst entries = map.listBindings()\nconsole.log('Bindings:', entries.length)\n\nfor (const entry of entries) {\n console.log(` ${entry.id}: trigger=${entry.trigger}, preventDefault=${entry.preventDefault}, stopPropagation=${entry.stopPropagation}`)\n}\n\n// formatShortcut() — display labels for UI tooltips or menus.\nconsole.log(formatShortcut('ctrl+k', 'ctrl')) // 'Ctrl+K'\nconsole.log(formatShortcut('mod+shift+p', 'meta')) // '⇧⌘P'\n\n// bind() returns an unbind closure — removes by binding id.\nconst unbind = map.bind({ id: 'ctrl-j', shortcut: 'ctrl+j', handler: () => console.log('ctrl+j') })\nconsole.log('After bind:', map.listBindings().length)\n\nunbind()\nconsole.log('After unbind:', map.listBindings().length)\n\n// unbind() removes by id, not by shortcut string — duplicate shortcuts coexist.\nmap.bind({ id: 'dup-a', shortcut: 'ctrl+d', handler: () => console.log('a') })\nmap.bind({ id: 'dup-b', shortcut: 'ctrl+d', handler: () => console.log('b') })\nconsole.log('Duplicate shortcut bindings:', map.listBindings().filter(e => e.shortcut.length === 1 && e.shortcut[0].key === 'd').length)\n\nmap.unbind('dup-a')\nconsole.log('After unbind dup-a:', map.listBindings().filter(e => e.shortcut.length === 1 && e.shortcut[0].key === 'd').length)\n\n// findShortcutConflicts() — check before binding a user-customized shortcut.\nconsole.log('ctrl+k conflicts:', findShortcutConflicts('ctrl+k', map.listBindings()).length)",
32
+ "code": "import { createKeymap, formatShortcut, findShortcutConflicts } from '@vielzeug/keymap'\n\n// listBindings(): inspect active bindings at runtime.\n// Each entry has id, shortcut (parsed steps), trigger, preventDefault, and stopPropagation.\nconst map = createKeymap(\n [\n { id: 'ctrl-k', shortcut: 'ctrl+k', handler: () => console.log('ctrl+k fired') },\n { id: 'save', shortcut: 'ctrl+shift+s', handler: () => console.log('save fired'), trigger: 'keyup' },\n { id: 'close', shortcut: 'escape', handler: () => console.log('close'), preventDefault: false },\n ],\n { modKey: 'ctrl' },\n)\n\nconst entries = map.listBindings()\nconsole.log('Bindings:', entries.length)\n\nfor (const entry of entries) {\n console.log(` ${entry.id}: trigger=${entry.trigger}, preventDefault=${entry.preventDefault}, stopPropagation=${entry.stopPropagation}`)\n}\n\n// formatShortcut(): display labels for UI tooltips or menus.\nconsole.log(formatShortcut('ctrl+k', 'ctrl')) // 'Ctrl+K'\nconsole.log(formatShortcut('mod+shift+p', 'meta')) // '⇧⌘P'\n\n// bind() returns an unbind closure: removes by binding id.\nconst unbind = map.bind({ id: 'ctrl-j', shortcut: 'ctrl+j', handler: () => console.log('ctrl+j') })\nconsole.log('After bind:', map.listBindings().length)\n\nunbind()\nconsole.log('After unbind:', map.listBindings().length)\n\n// unbind() removes by id, not by shortcut string: duplicate shortcuts coexist.\nmap.bind({ id: 'dup-a', shortcut: 'ctrl+d', handler: () => console.log('a') })\nmap.bind({ id: 'dup-b', shortcut: 'ctrl+d', handler: () => console.log('b') })\nconsole.log('Duplicate shortcut bindings:', map.listBindings().filter(e => e.shortcut.length === 1 && e.shortcut[0].key === 'd').length)\n\nmap.unbind('dup-a')\nconsole.log('After unbind dup-a:', map.listBindings().filter(e => e.shortcut.length === 1 && e.shortcut[0].key === 'd').length)\n\n// findShortcutConflicts(): check before binding a user-customized shortcut.\nconsole.log('ctrl+k conflicts:', findShortcutConflicts('ctrl+k', map.listBindings()).length)",
33
33
  "name": "Shortcut Utilities"
34
34
  }
35
35
  ],
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "apiSource": "export { compose } from './compose';\nexport {\n LedgerCancelledError,\n LedgerConfigError,\n LedgerDisposedError,\n LedgerError,\n LedgerExecutionError,\n LedgerRollbackError,\n} from './errors';\nexport { createLedger } from './ledger';\nexport type {\n CommandContext,\n HistoryEntry,\n Ledger,\n LedgerCallOptions,\n LedgerOptions,\n LedgerState,\n ReversibleCommand,\n Subscribable,\n Unsubscribe,\n} from './types';\n",
3
3
  "docs": {
4
- "index": "---\ntitle: Ledger — Reversible async history\ndescription: Serialized reversible command history with cancellation ownership and atomic reactive snapshots.\npackage: ledger\ncategory: utilities\nkeywords: [undo, redo, history, commands, async, reactive, ripple]\nexports: [compose, createLedger, LedgerError, LedgerCancelledError, LedgerConfigError, LedgerDisposedError, LedgerExecutionError, LedgerRollbackError]\nrelated: [ripple, keymap, forge, vault]\nenvironments: [browser, node, ssr, deno]\n---\n\n<!-- markdownlint-disable MD025 MD033 MD060 -->\n\n<PackageHero package=\"ledger\" />\n\n## Why Ledger?\n\nUndo and redo require more than array manipulation when operations are asynchronous, cancellable, and visible in a UI. Ledger serializes only reversible commands, owns queue lifecycle, and publishes one atomic framework-neutral state snapshot without a reactive runtime dependency.\n\n```ts\n// Before\nconst undo = () => changes.pop()?.revert();\n\n// After\nimport { createLedger } from '@vielzeug/ledger';\n\nconst ledger = createLedger();\nawait ledger.do({ apply: saveNext, revert: restorePrevious });\nawait ledger.undo();\n```\n\n| Feature | Roll your own | Ledger |\n| --- | --- | --- |\n| Bundle size | 0 B | <PackageInfo package=\"ledger\" type=\"size\" /> |\n| Reversible history | Manual arrays | <ore-icon name=\"check\" size=\"16\"></ore-icon> |\n| Serialized async work | Manual queue | <ore-icon name=\"check\" size=\"16\"></ore-icon> |\n| Queue cancellation | Manual ownership | Abort-aware lifecycle |\n| Framework-neutral state | Manual events | Structural `Subscribable<LedgerState>` |\n| Composition | Custom transaction code | `compose()` |\n\n<div class=\"decision-callout\">\n\n**Use Ledger when** you own reversible asynchronous state transitions and need undo, redo, or history UI.\n\n**Consider direct application code when** work is irreversible, fire-and-forget, or does not need history.\n\n</div>\n\n## Installation\n\n::: code-group\n\n```sh [pnpm]\npnpm add @vielzeug/ledger\n```\n\n```sh [npm]\nnpm install @vielzeug/ledger\n```\n\n```sh [yarn]\nyarn add @vielzeug/ledger\n```\n\n:::\n\n## Quick Start\n\nSubmit a reversible command, read state, then dispose its owner.\n\n```ts\nimport { createLedger } from '@vielzeug/ledger';\n\nlet value = 'before';\nconst ledger = createLedger();\n\nawait ledger.do({\n apply: () => { value = 'after'; },\n label: 'Rename value',\n revert: () => { value = 'before'; },\n});\n\nawait ledger.undo();\nconsole.log(ledger.state.getSnapshot().undo.length); // 0\nledger.dispose();\n```\n\n## Features\n\n<div class=\"features-grid\">\n\n- `createLedger()` — Create serialized reversible command history\n- `state` — Read atomic queue, undo, redo, and acceptance state\n- `compose()` — Combine reversible commands into one reversible command\n- `whenIdle()` — Await queued and active operation settlement\n- `LedgerCancelledError` — Distinguish cancellation from execution failure\n- `maxHistory` — Keep a non-negative safe-integer undo depth\n- `[Symbol.dispose]()` — Seal, abort, and clear a ledger owner\n\n</div>\n\n## Documentation\n\n<div class=\"doc-links\">\n\n- [Usage Guide](./usage.md)\n- [API Reference](./api.md)\n- [Examples](./examples.md)\n- [Migration Guide](./migration.md)\n\n</div>\n\n## See Also\n\n<div class=\"see-also\">\n\n- [Ripple](/ripple/) — Project Ledger's structural state readable into a signal when reactive derivations are needed.\n- [Keymap](/keymap/) — Route undo and redo shortcuts to a Ledger error boundary.\n- [Forge](/forge/) — Record reversible form transitions.\n- [Vault](/vault/) — Persist application snapshots outside transient undo history.\n\n</div>\n\n<!-- markdownlint-enable MD025 MD033 MD060 -->\n",
5
- "api": "---\ntitle: Ledger — API Reference\ndescription: Reference for serialized reversible commands, history state, composition, cancellation, disposal, and errors.\n---\n\n[[toc]]\n\n## API Overview\n\n| Symbol | Purpose | Execution | Common gotcha |\n| --- | --- | --- | --- |\n| `createLedger()` | Create serialized reversible history | Sync construction | Every operation method is queued |\n| `compose()` | Combine commands into one history entry | Sync construction | Child metadata is not aggregated |\n| `ledger.do()` | Apply and record a command | Async | Failed or cancelled apply is not recorded |\n| `ledger.undo()` / `redo()` | Move the latest history entry | Async | Failures leave history on its original side |\n| `ledger.clear()` | Remove history without reverting | Async | Serialized behind earlier work |\n| `ledger.whenIdle()` | Await an empty operation queue | Async | Cannot force non-cooperative work to settle |\n\n## Package Entry Point\n\n| Import | Purpose |\n| --- | --- |\n| `@vielzeug/ledger` | Complete Ledger runtime, error hierarchy, state-readable contract, unsubscribe type, and command/history types |\n\n## `createLedger()`\n\n```ts\nfunction createLedger<TMeta = undefined>(options?: LedgerOptions): Ledger<TMeta>\n```\n\nCreates a ledger with one submission-order operation queue and one atomic state readable.\n\n```ts\nimport { createLedger } from '@vielzeug/ledger';\n\nconst ledger = createLedger<{ field: string }>({ maxHistory: 50 });\n```\n\n### `LedgerOptions`\n\n```ts\ninterface LedgerOptions {\n maxHistory?: number;\n}\n```\n\n`maxHistory` defaults to `100`. It must be a non-negative safe integer. `0` executes commands without retaining history.\n\n## `compose()`\n\n```ts\nfunction compose<TMeta = undefined>(\n commands: readonly ReversibleCommand<TMeta>[],\n label?: string,\n): ReversibleCommand<TMeta>\n```\n\nSnapshots child command callbacks, applies children in order, and reverts them in reverse order. The result creates one history entry with the supplied label and `meta: undefined`.\n\nIf child application fails, completed children are compensated in reverse order. Compensation attempts continue after failures. Combined apply and compensation failures are preserved in an `AggregateError`, which Ledger exposes as `LedgerExecutionError.cause`. Normal composed reversion also attempts every child and reports failures through `LedgerRollbackError.cause` as an `AggregateError`.\n\n## `Ledger`\n\n```ts\ninterface Ledger<TMeta = undefined> {\n clear(): Promise<void>;\n readonly disposalSignal: AbortSignal;\n dispose(): void;\n readonly disposed: boolean;\n do(command: ReversibleCommand<TMeta>, options?: LedgerCallOptions): Promise<void>;\n redo(options?: LedgerCallOptions): Promise<void>;\n readonly state: Subscribable<LedgerState<TMeta>>;\n undo(options?: LedgerCallOptions): Promise<void>;\n whenIdle(): Promise<void>;\n [Symbol.dispose](): void;\n}\n```\n\nAll operation methods are serialized in call order.\n\n### `do()`\n\nSnapshots the command's `apply`, `revert`, `label`, and metadata reference when submitted. On successful apply it appends one undo entry, evicts the oldest entry beyond `maxHistory`, and clears redo history. Apply failure rejects with `LedgerExecutionError` and records nothing. Ledger does not automatically revert a failed individual command.\n\n### `undo()`\n\nReverts the final undo entry. Success moves it to the end of redo history. If no undo entry exists, the queued operation resolves without changing history. Revert failure rejects with `LedgerRollbackError` and leaves the entry in undo history.\n\n### `redo()`\n\nApplies the final redo entry. Success moves it to the end of undo history. If no redo entry exists, it resolves without changing history. Apply failure rejects with `LedgerExecutionError` and leaves the entry in redo history.\n\n### `record()`\n\nAppends an already-executed command to undo history synchronously, without running it through the queue. For stores that execute their own writes — typically synchronous, embedded state stores — `undo()` calls the recorded `revert`, and `redo()` calls the recorded `apply`, so `apply` must be able to re-apply the effect. Recording shares `do()`'s bookkeeping: it clears redo history and evicts the oldest entry beyond `maxHistory`. It throws `LedgerDisposedError` on a disposed ledger.\n\n### `clear()`\n\nQueues removal of all undo and redo entries. It does not call command revert functions and does not cancel earlier work. When history is already empty it resolves without queueing; after disposal it rejects with `LedgerDisposedError`.\n\n### `whenIdle()`\n\nResolves immediately when `queued` and `running` are zero, otherwise resolves after both reach zero. Multiple waiters are supported. It rejects with `LedgerDisposedError` when the ledger is disposed, immediately or while waiting.\n\n### Disposal\n\n`dispose()` is permanent and idempotent. It clears undo and redo history, aborts active command contexts, rejects queued operations that have not started with `LedgerDisposedError`, and rejects pending `whenIdle()` waiters. Active commands must observe their signal and settle before `running` reaches zero. Read `disposed` to detect permanent disposal. `[Symbol.dispose]()` delegates to `dispose()`.\n\n## Command Types\n\n### `ReversibleCommand`\n\n```ts\ninterface ReversibleCommand<TMeta = undefined> {\n readonly apply: (context: CommandContext) => Promise<void> | void;\n readonly label?: string;\n readonly meta?: TMeta;\n readonly revert: (context: CommandContext) => Promise<void> | void;\n}\n```\n\n`apply` and `revert` may be synchronous or asynchronous. Metadata is retained by reference; Ledger does not deep-clone or deep-freeze it.\n\n### `CommandContext`\n\n```ts\ninterface CommandContext {\n readonly signal: AbortSignal;\n}\n```\n\nThe signal combines ledger disposal with the operation signal. Queued cancellation prevents user code from running. Active cancellation is cooperative. If user code ignores abort and completes, the operation still rejects with `LedgerCancelledError` and its history transition is not committed.\n\n### `LedgerCallOptions`\n\n```ts\ninterface LedgerCallOptions {\n signal?: AbortSignal;\n}\n```\n\nSupported by `do()`, `undo()`, and `redo()`. `clear()` has no call options.\n\n## State Types\n\n### `LedgerState`\n\n```ts\ninterface LedgerState<TMeta = undefined> {\n readonly queued: number;\n readonly redo: readonly HistoryEntry<TMeta>[];\n readonly running: number;\n readonly undo: readonly HistoryEntry<TMeta>[];\n}\n```\n\n| Field | Meaning |\n| --- | --- |\n| `queued` | Submitted operations that have not started |\n| `running` | Currently executing operation; serialization keeps this at 0 or 1 |\n| `undo` | Chronological successfully applied history; final entry is next to undo |\n| `redo` | Chronological reverted history; final entry is next to redo |\n\nState objects and history arrays are frozen replacements.\n\n### `HistoryEntry`\n\n```ts\ninterface HistoryEntry<TMeta = undefined> {\n readonly label: string | undefined;\n readonly meta: TMeta | undefined;\n}\n```\n\nHistory entries are frozen and intentionally omit command callbacks.\n\n### `Subscribable` and `Unsubscribe`\n\n`Ledger.state` implements this framework-neutral structural contract, which matches `Subscribable` in `@vielzeug/arsenal`. Both names are root type exports.\n\n```ts\ntype Unsubscribe = () => void;\n\ninterface Subscribable<T> {\n getSnapshot(): T;\n subscribe(listener: () => void): Unsubscribe;\n}\n```\n\nSubscriptions are not immediate and receive no value argument. Listeners run synchronously from a snapshot after state replacement. A listener failure does not interrupt Ledger bookkeeping or later listeners; it is rethrown in a microtask.\n\n## Errors\n\n| Error | Trigger | History result |\n| --- | --- | --- |\n| `LedgerCancelledError` | Queued signal cancellation or active cooperative cancellation | No transition committed |\n| `LedgerDisposedError` | Submission after disposal or queued work rejected by disposal | History already cleared by disposal |\n| `LedgerExecutionError` | `apply()` fails during `do()` or `redo()` | New command absent, or redo entry retained |\n| `LedgerRollbackError` | `revert()` fails during `undo()` | Undo entry retained |\n| `LedgerConfigError` | Invalid `maxHistory` | Construction fails |\n| `LedgerError` | Base class and internal history corruption | Depends on operation |\n\nWrapped operation failures preserve the original value in `.cause`. Use `instanceof LedgerError` to catch the hierarchy, then narrow to a specific subtype.\n",
6
- "usage": "---\ntitle: Ledger — Usage Guide\ndescription: Record reversible commands, observe serialized history, handle failures, and own cancellation and disposal.\n---\n\n[[toc]]\n\n## Basic Usage\n\nCapture both sides of a reversible transition before submitting it.\n\n```ts\nimport { createLedger } from '@vielzeug/ledger';\n\nusing ledger = createLedger();\nconst item = { name: 'Old name' };\nconst previous = item.name;\nconst next = 'New name';\n\nawait ledger.do({\n apply: () => { item.name = next; },\n label: 'Rename item',\n revert: () => { item.name = previous; },\n});\n\nawait ledger.undo();\nawait ledger.redo();\n```\n\nAll `do()`, `undo()`, `redo()`, and `clear()` calls join one submission-order queue. Keep irreversible effects outside commands unless they have a real compensating operation.\n\n## Read Atomic State\n\n`state` is a framework-neutral `Subscribable`, not a Ripple signal. Read `getSnapshot()`, and subscribe to invalidation callbacks.\n\n```ts\nfunction renderHistory(): void {\n const { queued, redo, running, undo } = ledger.state.getSnapshot();\n undoButton.disabled = undo.length === 0 || ledger.disposed;\n redoButton.disabled = redo.length === 0 || ledger.disposed;\n spinner.hidden = queued + running === 0;\n}\n\nrenderHistory();\nconst stop = ledger.state.subscribe(renderHistory);\n```\n\nSubscriptions are not immediate and receive no value argument. They run synchronously from a listener snapshot after every state replacement. A failure is rethrown in a microtask without interrupting Ledger bookkeeping or later subscribers.\n\nState objects, history arrays, and history entries are frozen snapshots. `undo` and `redo` are chronological; the next command to undo or redo is the final item. Metadata values are retained by reference rather than deep-cloned.\n\n## Attach History Metadata\n\nSet `label` for presentation and use the ledger's metadata type for application context.\n\n```ts\nconst ledger = createLedger<{ field: string }>();\n\nawait ledger.do({\n apply: () => { form.email = next; },\n label: 'Edit email',\n meta: { field: 'email' },\n revert: () => { form.email = previous; },\n});\n\nconsole.log(ledger.state.getSnapshot().undo.at(-1));\n```\n\nLedger snapshots command callbacks, label, and metadata references when `do()` is submitted. Later mutation of the command object does not replace the captured callbacks.\n\n## Compose Reversible Commands\n\n`compose()` produces one command and one history entry. Children apply in order and revert in reverse order.\n\n```ts\nimport { compose } from '@vielzeug/ledger';\n\nawait ledger.do(\n compose([\n { apply: () => { node.x = nextX; }, revert: () => { node.x = previousX; } },\n { apply: () => { node.y = nextY; }, revert: () => { node.y = previousY; } },\n ], 'Move node'),\n);\n```\n\nIf a child apply fails, already-applied children are compensated in reverse order. Compensation continues after failures. If application and compensation both fail, `LedgerExecutionError.cause` is an `AggregateError` containing all failures. A composed command records the supplied label but does not aggregate child metadata.\n\n## Handle Operation Failures\n\n```ts\nimport {\n LedgerCancelledError,\n LedgerExecutionError,\n LedgerRollbackError,\n} from '@vielzeug/ledger';\n\ntry {\n await ledger.undo();\n} catch (error) {\n if (error instanceof LedgerCancelledError) {\n // Expected cancellation.\n } else if (error instanceof LedgerRollbackError) {\n showUndoError(error.cause);\n } else if (error instanceof LedgerExecutionError) {\n showApplyError(error.cause);\n } else {\n throw error;\n }\n}\n```\n\nA failed `do()` is not recorded and is not automatically reverted. A failed `undo()` remains in undo history; a failed `redo()` remains in redo history, allowing retry after the underlying problem is corrected.\n\n## Cancel Work\n\nPass a signal per operation. Queued cancellation prevents user code from starting. Active work receives a combined signal and must stop cooperatively.\n\n```ts\nconst controller = new AbortController();\nconst operation = ledger.do(\n {\n apply: ({ signal }) => saveDraft(nextDraft, { signal }),\n revert: ({ signal }) => saveDraft(previousDraft, { signal }),\n },\n { signal: controller.signal },\n);\n\ncontroller.abort();\nawait operation.catch(handleHistoryFailure);\n```\n\nIf active user code ignores cancellation and finishes, Ledger rejects with `LedgerCancelledError` and does not record or move history. It does not compensate partial effects automatically, so command implementations must check or forward `context.signal` before committing changes.\n\n## Record Externally Executed Work\n\nWhen a synchronous store owns its writes, execute first and record the reversible entry afterwards: `undo()` calls the recorded `revert`, `redo()` calls the recorded `apply` — so `apply` must be able to re-apply the effect.\n\n```ts\nconst previous = item.name;\n\nitem.name = next; // the store's own synchronous write\nledger.record({\n apply: () => {\n item.name = next;\n },\n label: 'rename item',\n revert: () => {\n item.name = previous;\n },\n});\n```\n\nRecording is synchronous and shares `do()`'s bookkeeping: it clears redo history and evicts the oldest entry beyond `maxHistory`.\n\n## Limit and Clear History\n\n`maxHistory` defaults to `100` and accepts non-negative safe integers. Successful commands beyond the cap evict the oldest undo entries. `0` executes commands without retaining undo or redo history.\n\n```ts\nconst ledger = createLedger({ maxHistory: 30 });\nawait ledger.clear();\n```\n\n`clear()` is serialized behind earlier work and clears both history arrays without reverting commands.\n\n## Drain and Dispose\n\n`whenIdle()` resolves when both `queued` and `running` reach zero. It does not cancel work.\n\n```ts\nconst operation = ledger.do(command);\nconst idle = ledger.whenIdle();\n\nledger.dispose();\nawait operation.catch(handleHistoryFailure);\nawait idle;\n```\n\nDisposal is permanent and idempotent. It clears history, aborts active contexts, rejects queued work with `LedgerDisposedError`, and rejects pending `whenIdle()` waiters. Read `disposed` to detect it. Active operations settle cooperatively; `whenIdle()` waits for them. Work that never settles can keep `whenIdle()` pending.\n\n## Framework Integration\n\nKeep one ledger per owning scope and adapt its structural readable through the framework's subscription API.\n\n::: code-group\n\n```tsx [React]\nimport { useEffect, useMemo, useSyncExternalStore } from 'react';\nimport { createLedger } from '@vielzeug/ledger';\n\nexport function UndoStatus() {\n const ledger = useMemo(() => createLedger(), []);\n const state = useSyncExternalStore(\n (notify) => ledger.state.subscribe(notify),\n () => ledger.state.getSnapshot(),\n () => ledger.state.getSnapshot(),\n );\n\n useEffect(() => () => ledger.dispose(), [ledger]);\n return <span>{state.undo.length} undo / {state.redo.length} redo</span>;\n}\n```\n\n```ts [Vue 3]\nimport { onUnmounted, shallowRef } from 'vue';\nimport { createLedger } from '@vielzeug/ledger';\n\nconst ledger = createLedger();\nconst state = shallowRef(ledger.state.getSnapshot());\nconst stop = ledger.state.subscribe(() => { state.value = ledger.state.getSnapshot(); });\n\nonUnmounted(() => {\n stop();\n ledger.dispose();\n});\n```\n\n```ts [Ripple]\nimport { signal } from '@vielzeug/ripple';\nimport { createLedger } from '@vielzeug/ledger';\n\nconst ledger = createLedger();\nconst state = signal(ledger.state.getSnapshot());\nconst stop = ledger.state.subscribe(() => { state.value = ledger.state.getSnapshot(); });\n\nfunction dispose(): void {\n stop();\n ledger.dispose();\n}\n```\n\n:::\n\n## Working with Other Vielzeug Libraries\n\nUse Keymap to route keyboard shortcuts into `undo()` and `redo()`. Project `ledger.state` into Ripple when reactive derivations are needed. Persist application snapshots with Vault; Ledger history itself is transient.\n\n## Best Practices\n\n- Capture previous and next values before submitting a command.\n- Keep irreversible effects outside Ledger commands.\n- Forward or check `context.signal` in asynchronous command work.\n- Catch every operation promise at the application boundary.\n- Read one `state` snapshot per render.\n- Keep state subscribers synchronous and handle failures through the application's global error boundary.\n- Use `whenIdle()` before releasing owners that require a drain boundary.\n- Dispose each owner-scoped ledger.\n",
7
- "examples": "---\ntitle: Ledger — Examples\ndescription: Recipes for reversible editor and form history with Ledger.\n---\n\n## Examples\n\n- [Text Editor History](./examples/text-editor.md)\n- [Form History](./examples/form-history.md)\n"
4
+ "index": "---\ntitle: 'Ledger: Reversible async history'\ndescription: Serialized reversible command history with cancellation ownership and atomic reactive snapshots.\npackage: ledger\ncategory: utilities\nkeywords: [undo, redo, history, commands, async, reactive, ripple]\nexports: [compose, createLedger, LedgerError, LedgerCancelledError, LedgerConfigError, LedgerDisposedError, LedgerExecutionError, LedgerRollbackError]\nrelated: [ripple, keymap, forge, vault]\nenvironments: [browser, node, ssr, deno]\n---\n\n<!-- markdownlint-disable MD025 MD033 MD060 -->\n\n<PackageHero package=\"ledger\" />\n\n## Why Ledger?\n\nUndo and redo require more than array manipulation when operations are asynchronous, cancellable, and visible in a UI. Ledger serializes only reversible commands, owns queue lifecycle, and publishes one atomic framework-neutral state snapshot without a reactive runtime dependency.\n\n```ts\n// Before\nconst undo = () => changes.pop()?.revert();\n\n// After\nimport { createLedger } from '@vielzeug/ledger';\n\nconst ledger = createLedger();\nawait ledger.do({ apply: saveNext, revert: restorePrevious });\nawait ledger.undo();\n```\n\n| Feature | Roll your own | Ledger |\n| --- | --- | --- |\n| Bundle size | 0 B | <PackageInfo package=\"ledger\" type=\"size\" /> |\n| Reversible history | Manual arrays | <ore-icon name=\"check\" size=\"16\"></ore-icon> |\n| Serialized async work | Manual queue | <ore-icon name=\"check\" size=\"16\"></ore-icon> |\n| Queue cancellation | Manual ownership | Abort-aware lifecycle |\n| Framework-neutral state | Manual events | Structural `Subscribable<LedgerState>` |\n| Composition | Custom transaction code | `compose()` |\n\n<div class=\"decision-callout\">\n\n**Use Ledger when** you own reversible asynchronous state transitions and need undo, redo, or history UI.\n\n**Consider direct application code when** work is irreversible, fire-and-forget, or does not need history.\n\n</div>\n\n## Installation\n\n::: code-group\n\n```sh [pnpm]\npnpm add @vielzeug/ledger\n```\n\n```sh [npm]\nnpm install @vielzeug/ledger\n```\n\n```sh [yarn]\nyarn add @vielzeug/ledger\n```\n\n:::\n\n## Quick Start\n\nSubmit a reversible command, read state, then dispose its owner.\n\n```ts\nimport { createLedger } from '@vielzeug/ledger';\n\nlet value = 'before';\nconst ledger = createLedger();\n\nawait ledger.do({\n apply: () => { value = 'after'; },\n label: 'Rename value',\n revert: () => { value = 'before'; },\n});\n\nawait ledger.undo();\nconsole.log(ledger.state.getSnapshot().undo.length); // 0\nledger.dispose();\n```\n\n## Features\n\n<div class=\"features-grid\">\n\n- `createLedger()`: Create serialized reversible command history\n- `state`: Read atomic queue, undo, redo, and acceptance state\n- `compose()`: Combine reversible commands into one reversible command\n- `whenIdle()`: Await queued and active operation settlement\n- `LedgerCancelledError`: Distinguish cancellation from execution failure\n- `maxHistory`: Keep a non-negative safe-integer undo depth\n- `[Symbol.dispose]()`: Seal, abort, and clear a ledger owner\n\n</div>\n\n## Documentation\n\n<div class=\"doc-links\">\n\n- [Usage Guide](./usage.md)\n- [API Reference](./api.md)\n- [Examples](./examples.md)\n- [Migration Guide](./migration.md)\n\n</div>\n\n## See Also\n\n<div class=\"see-also\">\n\n- [Ripple](/ripple/): Project Ledger's structural state readable into a signal when reactive derivations are needed.\n- [Keymap](/keymap/): Route undo and redo shortcuts to a Ledger error boundary.\n- [Forge](/forge/): Record reversible form transitions.\n- [Vault](/vault/): Persist application snapshots outside transient undo history.\n\n</div>\n\n<!-- markdownlint-enable MD025 MD033 MD060 -->\n",
5
+ "api": "---\ntitle: 'Ledger: API Reference'\ndescription: Reference for serialized reversible commands, history state, composition, cancellation, disposal, and errors.\n---\n\n[[toc]]\n\n## API Overview\n\n| Symbol | Purpose | Execution | Common gotcha |\n| --- | --- | --- | --- |\n| `createLedger()` | Create serialized reversible history | Sync construction | Every operation method is queued |\n| `compose()` | Combine commands into one history entry | Sync construction | Child metadata is not aggregated |\n| `ledger.do()` | Apply and record a command | Async | Failed or cancelled apply is not recorded |\n| `ledger.undo()` / `redo()` | Move the latest history entry | Async | Failures leave history on its original side |\n| `ledger.clear()` | Remove history without reverting | Async | Serialized behind earlier work |\n| `ledger.whenIdle()` | Await an empty operation queue | Async | Cannot force non-cooperative work to settle |\n\n## Package Entry Point\n\n| Import | Purpose |\n| --- | --- |\n| `@vielzeug/ledger` | Complete Ledger runtime, error hierarchy, state-readable contract, unsubscribe type, and command/history types |\n\n## `createLedger()`\n\n```ts\nfunction createLedger<TMeta = undefined>(options?: LedgerOptions): Ledger<TMeta>\n```\n\nCreates a ledger with one submission-order operation queue and one atomic state readable.\n\n```ts\nimport { createLedger } from '@vielzeug/ledger';\n\nconst ledger = createLedger<{ field: string }>({ maxHistory: 50 });\n```\n\n### `LedgerOptions`\n\n```ts\ninterface LedgerOptions {\n maxHistory?: number;\n}\n```\n\n`maxHistory` defaults to `100`. It must be a non-negative safe integer. `0` executes commands without retaining history.\n\n## `compose()`\n\n```ts\nfunction compose<TMeta = undefined>(\n commands: readonly ReversibleCommand<TMeta>[],\n label?: string,\n): ReversibleCommand<TMeta>\n```\n\nSnapshots child command callbacks, applies children in order, and reverts them in reverse order. The result creates one history entry with the supplied label and `meta: undefined`.\n\nIf child application fails, completed children are compensated in reverse order. Compensation attempts continue after failures. Combined apply and compensation failures are preserved in an `AggregateError`, which Ledger exposes as `LedgerExecutionError.cause`. Normal composed reversion also attempts every child and reports failures through `LedgerRollbackError.cause` as an `AggregateError`.\n\n## `Ledger`\n\n```ts\ninterface Ledger<TMeta = undefined> {\n clear(): Promise<void>;\n readonly disposalSignal: AbortSignal;\n dispose(): void;\n readonly disposed: boolean;\n do(command: ReversibleCommand<TMeta>, options?: LedgerCallOptions): Promise<void>;\n redo(options?: LedgerCallOptions): Promise<void>;\n readonly state: Subscribable<LedgerState<TMeta>>;\n undo(options?: LedgerCallOptions): Promise<void>;\n whenIdle(): Promise<void>;\n [Symbol.dispose](): void;\n}\n```\n\nAll operation methods are serialized in call order.\n\n### `do()`\n\nSnapshots the command's `apply`, `revert`, `label`, and metadata reference when submitted. On successful apply it appends one undo entry, evicts the oldest entry beyond `maxHistory`, and clears redo history. Apply failure rejects with `LedgerExecutionError` and records nothing. Ledger does not automatically revert a failed individual command.\n\n### `undo()`\n\nReverts the final undo entry. Success moves it to the end of redo history. If no undo entry exists, the queued operation resolves without changing history. Revert failure rejects with `LedgerRollbackError` and leaves the entry in undo history.\n\n### `redo()`\n\nApplies the final redo entry. Success moves it to the end of undo history. If no redo entry exists, it resolves without changing history. Apply failure rejects with `LedgerExecutionError` and leaves the entry in redo history.\n\n### `record()`\n\nAppends an already-executed command to undo history synchronously, without running it through the queue. For stores that execute their own writes: typically synchronous, embedded state stores: `undo()` calls the recorded `revert`, and `redo()` calls the recorded `apply`, so `apply` must be able to re-apply the effect. Recording shares `do()`'s bookkeeping: it clears redo history and evicts the oldest entry beyond `maxHistory`. It throws `LedgerDisposedError` on a disposed ledger.\n\n### `clear()`\n\nQueues removal of all undo and redo entries. It does not call command revert functions and does not cancel earlier work. When history is already empty it resolves without queueing; after disposal it rejects with `LedgerDisposedError`.\n\n### `whenIdle()`\n\nResolves immediately when `queued` and `running` are zero, otherwise resolves after both reach zero. Multiple waiters are supported. It rejects with `LedgerDisposedError` when the ledger is disposed, immediately or while waiting.\n\n### Disposal\n\n`dispose()` is permanent and idempotent. It clears undo and redo history, aborts active command contexts, rejects queued operations that have not started with `LedgerDisposedError`, and rejects pending `whenIdle()` waiters. Active commands must observe their signal and settle before `running` reaches zero. Read `disposed` to detect permanent disposal. `[Symbol.dispose]()` delegates to `dispose()`.\n\n## Command Types\n\n### `ReversibleCommand`\n\n```ts\ninterface ReversibleCommand<TMeta = undefined> {\n readonly apply: (context: CommandContext) => Promise<void> | void;\n readonly label?: string;\n readonly meta?: TMeta;\n readonly revert: (context: CommandContext) => Promise<void> | void;\n}\n```\n\n`apply` and `revert` may be synchronous or asynchronous. Metadata is retained by reference; Ledger does not deep-clone or deep-freeze it.\n\n### `CommandContext`\n\n```ts\ninterface CommandContext {\n readonly signal: AbortSignal;\n}\n```\n\nThe signal combines ledger disposal with the operation signal. Queued cancellation prevents user code from running. Active cancellation is cooperative. If user code ignores abort and completes, the operation still rejects with `LedgerCancelledError` and its history transition is not committed.\n\n### `LedgerCallOptions`\n\n```ts\ninterface LedgerCallOptions {\n signal?: AbortSignal;\n}\n```\n\nSupported by `do()`, `undo()`, and `redo()`. `clear()` has no call options.\n\n## State Types\n\n### `LedgerState`\n\n```ts\ninterface LedgerState<TMeta = undefined> {\n readonly queued: number;\n readonly redo: readonly HistoryEntry<TMeta>[];\n readonly running: number;\n readonly undo: readonly HistoryEntry<TMeta>[];\n}\n```\n\n| Field | Meaning |\n| --- | --- |\n| `queued` | Submitted operations that have not started |\n| `running` | Currently executing operation; serialization keeps this at 0 or 1 |\n| `undo` | Chronological successfully applied history; final entry is next to undo |\n| `redo` | Chronological reverted history; final entry is next to redo |\n\nState objects and history arrays are frozen replacements.\n\n### `HistoryEntry`\n\n```ts\ninterface HistoryEntry<TMeta = undefined> {\n readonly label: string | undefined;\n readonly meta: TMeta | undefined;\n}\n```\n\nHistory entries are frozen and intentionally omit command callbacks.\n\n### `Subscribable` and `Unsubscribe`\n\n`Ledger.state` implements this framework-neutral structural contract, which matches `Subscribable` in `@vielzeug/arsenal`. Both names are root type exports.\n\n```ts\ntype Unsubscribe = () => void;\n\ninterface Subscribable<T> {\n getSnapshot(): T;\n subscribe(listener: () => void): Unsubscribe;\n}\n```\n\nSubscriptions are not immediate and receive no value argument. Listeners run synchronously from a snapshot after state replacement. A listener failure does not interrupt Ledger bookkeeping or later listeners; it is rethrown in a microtask.\n\n## Errors\n\n| Error | Trigger | History result |\n| --- | --- | --- |\n| `LedgerCancelledError` | Queued signal cancellation or active cooperative cancellation | No transition committed |\n| `LedgerDisposedError` | Submission after disposal or queued work rejected by disposal | History already cleared by disposal |\n| `LedgerExecutionError` | `apply()` fails during `do()` or `redo()` | New command absent, or redo entry retained |\n| `LedgerRollbackError` | `revert()` fails during `undo()` | Undo entry retained |\n| `LedgerConfigError` | Invalid `maxHistory` | Construction fails |\n| `LedgerError` | Base class and internal history corruption | Depends on operation |\n\nWrapped operation failures preserve the original value in `.cause`. Use `instanceof LedgerError` to catch the hierarchy, then narrow to a specific subtype.\n",
6
+ "usage": "---\ntitle: 'Ledger: Usage Guide'\ndescription: Record reversible commands, observe serialized history, handle failures, and own cancellation and disposal.\n---\n\n[[toc]]\n\n## Basic Usage\n\nCapture both sides of a reversible transition before submitting it.\n\n```ts\nimport { createLedger } from '@vielzeug/ledger';\n\nusing ledger = createLedger();\nconst item = { name: 'Old name' };\nconst previous = item.name;\nconst next = 'New name';\n\nawait ledger.do({\n apply: () => { item.name = next; },\n label: 'Rename item',\n revert: () => { item.name = previous; },\n});\n\nawait ledger.undo();\nawait ledger.redo();\n```\n\nAll `do()`, `undo()`, `redo()`, and `clear()` calls join one submission-order queue. Keep irreversible effects outside commands unless they have a real compensating operation.\n\n## Read Atomic State\n\n`state` is a framework-neutral `Subscribable`, not a Ripple signal. Read `getSnapshot()`, and subscribe to invalidation callbacks.\n\n```ts\nfunction renderHistory(): void {\n const { queued, redo, running, undo } = ledger.state.getSnapshot();\n undoButton.disabled = undo.length === 0 || ledger.disposed;\n redoButton.disabled = redo.length === 0 || ledger.disposed;\n spinner.hidden = queued + running === 0;\n}\n\nrenderHistory();\nconst stop = ledger.state.subscribe(renderHistory);\n```\n\nSubscriptions are not immediate and receive no value argument. They run synchronously from a listener snapshot after every state replacement. A failure is rethrown in a microtask without interrupting Ledger bookkeeping or later subscribers.\n\nState objects, history arrays, and history entries are frozen snapshots. `undo` and `redo` are chronological; the next command to undo or redo is the final item. Metadata values are retained by reference rather than deep-cloned.\n\n## Attach History Metadata\n\nSet `label` for presentation and use the ledger's metadata type for application context.\n\n```ts\nconst ledger = createLedger<{ field: string }>();\n\nawait ledger.do({\n apply: () => { form.email = next; },\n label: 'Edit email',\n meta: { field: 'email' },\n revert: () => { form.email = previous; },\n});\n\nconsole.log(ledger.state.getSnapshot().undo.at(-1));\n```\n\nLedger snapshots command callbacks, label, and metadata references when `do()` is submitted. Later mutation of the command object does not replace the captured callbacks.\n\n## Compose Reversible Commands\n\n`compose()` produces one command and one history entry. Children apply in order and revert in reverse order.\n\n```ts\nimport { compose } from '@vielzeug/ledger';\n\nawait ledger.do(\n compose([\n { apply: () => { node.x = nextX; }, revert: () => { node.x = previousX; } },\n { apply: () => { node.y = nextY; }, revert: () => { node.y = previousY; } },\n ], 'Move node'),\n);\n```\n\nIf a child apply fails, already-applied children are compensated in reverse order. Compensation continues after failures. If application and compensation both fail, `LedgerExecutionError.cause` is an `AggregateError` containing all failures. A composed command records the supplied label but does not aggregate child metadata.\n\n## Handle Operation Failures\n\n```ts\nimport {\n LedgerCancelledError,\n LedgerExecutionError,\n LedgerRollbackError,\n} from '@vielzeug/ledger';\n\ntry {\n await ledger.undo();\n} catch (error) {\n if (error instanceof LedgerCancelledError) {\n // Expected cancellation.\n } else if (error instanceof LedgerRollbackError) {\n showUndoError(error.cause);\n } else if (error instanceof LedgerExecutionError) {\n showApplyError(error.cause);\n } else {\n throw error;\n }\n}\n```\n\nA failed `do()` is not recorded and is not automatically reverted. A failed `undo()` remains in undo history; a failed `redo()` remains in redo history, allowing retry after the underlying problem is corrected.\n\n## Cancel Work\n\nPass a signal per operation. Queued cancellation prevents user code from starting. Active work receives a combined signal and must stop cooperatively.\n\n```ts\nconst controller = new AbortController();\nconst operation = ledger.do(\n {\n apply: ({ signal }) => saveDraft(nextDraft, { signal }),\n revert: ({ signal }) => saveDraft(previousDraft, { signal }),\n },\n { signal: controller.signal },\n);\n\ncontroller.abort();\nawait operation.catch(handleHistoryFailure);\n```\n\nIf active user code ignores cancellation and finishes, Ledger rejects with `LedgerCancelledError` and does not record or move history. It does not compensate partial effects automatically, so command implementations must check or forward `context.signal` before committing changes.\n\n## Record Externally Executed Work\n\nWhen a synchronous store owns its writes, execute first and record the reversible entry afterwards: `undo()` calls the recorded `revert`, `redo()` calls the recorded `apply`, so `apply` must be able to re-apply the effect.\n\n```ts\nconst previous = item.name;\n\nitem.name = next; // the store's own synchronous write\nledger.record({\n apply: () => {\n item.name = next;\n },\n label: 'rename item',\n revert: () => {\n item.name = previous;\n },\n});\n```\n\nRecording is synchronous and shares `do()`'s bookkeeping: it clears redo history and evicts the oldest entry beyond `maxHistory`.\n\n## Limit and Clear History\n\n`maxHistory` defaults to `100` and accepts non-negative safe integers. Successful commands beyond the cap evict the oldest undo entries. `0` executes commands without retaining undo or redo history.\n\n```ts\nconst ledger = createLedger({ maxHistory: 30 });\nawait ledger.clear();\n```\n\n`clear()` is serialized behind earlier work and clears both history arrays without reverting commands.\n\n## Drain and Dispose\n\n`whenIdle()` resolves when both `queued` and `running` reach zero. It does not cancel work.\n\n```ts\nconst operation = ledger.do(command);\nconst idle = ledger.whenIdle();\n\nledger.dispose();\nawait operation.catch(handleHistoryFailure);\nawait idle;\n```\n\nDisposal is permanent and idempotent. It clears history, aborts active contexts, rejects queued work with `LedgerDisposedError`, and rejects pending `whenIdle()` waiters. Read `disposed` to detect it. Active operations settle cooperatively; `whenIdle()` waits for them. Work that never settles can keep `whenIdle()` pending.\n\n## Framework Integration\n\nKeep one ledger per owning scope and adapt its structural readable through the framework's subscription API.\n\n::: code-group\n\n```tsx [React]\nimport { useEffect, useMemo, useSyncExternalStore } from 'react';\nimport { createLedger } from '@vielzeug/ledger';\n\nexport function UndoStatus() {\n const ledger = useMemo(() => createLedger(), []);\n const state = useSyncExternalStore(\n (notify) => ledger.state.subscribe(notify),\n () => ledger.state.getSnapshot(),\n () => ledger.state.getSnapshot(),\n );\n\n useEffect(() => () => ledger.dispose(), [ledger]);\n return <span>{state.undo.length} undo / {state.redo.length} redo</span>;\n}\n```\n\n```ts [Vue 3]\nimport { onUnmounted, shallowRef } from 'vue';\nimport { createLedger } from '@vielzeug/ledger';\n\nconst ledger = createLedger();\nconst state = shallowRef(ledger.state.getSnapshot());\nconst stop = ledger.state.subscribe(() => { state.value = ledger.state.getSnapshot(); });\n\nonUnmounted(() => {\n stop();\n ledger.dispose();\n});\n```\n\n```ts [Ripple]\nimport { signal } from '@vielzeug/ripple';\nimport { createLedger } from '@vielzeug/ledger';\n\nconst ledger = createLedger();\nconst state = signal(ledger.state.getSnapshot());\nconst stop = ledger.state.subscribe(() => { state.value = ledger.state.getSnapshot(); });\n\nfunction dispose(): void {\n stop();\n ledger.dispose();\n}\n```\n\n:::\n\n## Working with Other Vielzeug Libraries\n\nUse Keymap to route keyboard shortcuts into `undo()` and `redo()`. Project `ledger.state` into Ripple when reactive derivations are needed. Persist application snapshots with Vault; Ledger history itself is transient.\n\n## Best Practices\n\n- Capture previous and next values before submitting a command.\n- Keep irreversible effects outside Ledger commands.\n- Forward or check `context.signal` in asynchronous command work.\n- Catch every operation promise at the application boundary.\n- Read one `state` snapshot per render.\n- Keep state subscribers synchronous and handle failures through the application's global error boundary.\n- Use `whenIdle()` before releasing owners that require a drain boundary.\n- Dispose each owner-scoped ledger.\n",
7
+ "examples": "---\ntitle: 'Ledger: Examples'\ndescription: Recipes for reversible editor and form history with Ledger.\n---\n\n## Examples\n\n- [Text Editor History](./examples/text-editor.md)\n- [Form History](./examples/form-history.md)\n"
8
8
  },
9
9
  "examples": [
10
10
  {
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "apiSource": "export { catalogKeys } from './catalog';\nexport {\n LinguaDisposedError,\n LinguaError,\n LinguaInvalidCatalogError,\n LinguaInvalidLocaleError,\n LinguaInvalidPluralCountError,\n LinguaInvalidStateError,\n LinguaMissingCatalogError,\n LinguaMissingKeyError,\n LinguaMissingValueError,\n} from './errors';\nexport { createI18n, type I18n, type I18nSnapshot } from './i18n';\nexport { createTranslator, type Translator } from './translator';\nexport type {\n Catalog,\n CatalogNode,\n Catalogs,\n I18nOptions,\n Locale,\n MessageKey,\n MissingHandler,\n MissingInfo,\n MissingStrategy,\n Part,\n PluralCategory,\n PluralKey,\n PluralMessage,\n PluralOptions,\n SubscribeOptions,\n TextKey,\n TextPart,\n TranslateOptions,\n TranslationState,\n TranslatorOptions,\n ValuePart,\n Values,\n} from './types';\n",
3
3
  "docs": {
4
- "index": "---\ntitle: Lingua — Explicit localization for TypeScript\ndescription: Framework-neutral locale catalogs, typed translations, and explicit plural messages.\npackage: lingua\ncategory: i18n\nkeywords: [internationalization, translations, pluralization, locale, i18n, catalog-loading]\nrelated: [ripple, wayfinder, courier]\nexports: [createI18n, createTranslator, LinguaError, LinguaDisposedError, LinguaInvalidCatalogError, LinguaInvalidLocaleError, LinguaInvalidPluralCountError, LinguaInvalidStateError, LinguaMissingCatalogError, LinguaMissingKeyError, LinguaMissingValueError]\nenvironments: [browser, node, ssr, deno]\n---\n\n<!-- markdownlint-disable MD025 MD033 MD060 -->\n\n<PackageHero package=\"lingua\" />\n\n## Why Lingua?\n\nLingua separates immutable translation from mutable locale state. Use `createTranslator` for one fixed-locale catalog, or `createI18n` when locale changes at runtime.\n\n```ts\n// Before\nconst message = catalogs[locale]?.inbox?.[count === 1 ? 'one' : 'other'] ?? 'inbox';\n\n// After\nconst output = i18n.translate('inbox', { count });\n```\n\n| Feature | Lingua | i18next | FormatJS |\n| --- | --- | --- | --- |\n| Bundle size | <PackageInfo package=\"lingua\" type=\"size\" /> | Varies by selected modules | Varies by selected modules |\n| Zero runtime dependencies | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"triangle-alert\" size=\"16\"></ore-icon> | <ore-icon name=\"triangle-alert\" size=\"16\"></ore-icon> |\n| Explicit plural catalog nodes | <ore-icon name=\"check\" size=\"16\"></ore-icon> | Convention/config dependent | ICU-message dependent |\n| Static and lazy locale catalogs | <ore-icon name=\"check\" size=\"16\"></ore-icon> | Plugin/config dependent | Application-defined |\n| Immutable locale snapshots | <ore-icon name=\"check\" size=\"16\"></ore-icon> | Application-defined | Application-defined |\n\n<div class=\"decision-callout\">\n\n**Use Lingua when** you need a compact TypeScript runtime with explicit catalog structure, deterministic fallback, and framework-neutral subscriptions.\n\n**Consider i18next or FormatJS when** you need their plugin ecosystems, message extraction pipelines, or framework-specific integrations.\n\n</div>\n\n## Installation\n\n::: code-group\n\n```sh [pnpm]\npnpm add @vielzeug/lingua\n```\n\n```sh [npm]\nnpm install @vielzeug/lingua\n```\n\n```sh [yarn]\nyarn add @vielzeug/lingua\n```\n\n:::\n\n## Quick Start\n\nCreate an i18n instance with an eager default catalog and a loader for other locales.\n\n```ts\nimport { createI18n } from '@vielzeug/lingua';\n\nconst en = { inbox: { plural: { one: 'One message', other: '{count} messages' } } };\nconst i18n = createI18n({\n catalogs: { en },\n locale: 'en',\n loadCatalog: (locale) => import(`./locales/${locale}.ts`).then((m) => m.default),\n});\n\ntry {\n console.log(i18n.translate('inbox', { count: 3 }));\n await i18n.setLocale('de');\n console.log(i18n.translate('inbox', { count: 1 }));\n} finally {\n i18n.dispose();\n}\n```\n\n## Features\n\n<div class=\"features-grid\">\n\n- `createTranslator()` compiles one immutable fixed-locale catalog.\n- `createI18n()` manages eager catalogs, locale changes, and optional lazy loading.\n- `translate()` renders text and plural messages through explicit catalog nodes.\n- `translateDynamic()` makes runtime-key lookup explicit.\n- `parts()` returns typed discriminated parts for framework content.\n- `load()` deduplicates loading and resolves the active locale plus configured fallbacks.\n- `getSnapshot()` and `subscribe()` expose immutable translator revisions.\n- `serialize()` and `state` option transfer resolved SSR catalogs.\n- `missing: 'throw' | 'key' | handler` controls failure behaviour.\n- `validateCatalog()` remains an isolated subpath tool.\n\n</div>\n\n## Documentation\n\n<div class=\"doc-links\">\n\n- [Usage Guide](./usage.md)\n- [API Reference](./api.md)\n- [Examples](./examples.md)\n- [Migration Guide](./migration.md)\n\n</div>\n\n## See Also\n\n<div class=\"see-also\">\n\n- [Ripple](../ripple/index.md) adapts Lingua snapshots into reactive application state.\n- [Courier](../courier/index.md) can fetch locale catalogs before passing them to Lingua loaders.\n- [Wayfinder](../wayfinder/index.md) can drive locale selection from route state.\n\n</div>\n\n<!-- markdownlint-enable MD025 MD033 MD060 -->\n",
5
- "api": "---\ntitle: Lingua — API Reference\ndescription: Complete API reference for @vielzeug/lingua.\n---\n\n[[toc]]\n\n## API Overview\n\n| Symbol | Purpose | Execution mode | Common gotcha |\n| --- | --- | --- | --- |\n| `createTranslator()` | Compile one immutable locale catalog | Sync | No fallback locales |\n| `createI18n()` | Create mutable locale and catalog store | Sync | Provide catalogs, serialized state, or a loader |\n| `createFormatter()` | Create a cached Intl facade from `/format` | Sync | Pass a locale getter for dynamic locale changes |\n| `createReactiveI18n()` | Mirror an i18n instance into ripple readables from `/ripple` | Sync | Optional `@vielzeug/ripple` peer; import from subpath |\n| `catalogKeys()` | Enumerate message keys as dotted paths | Sync | Takes a catalog (or subtree) only; for an i18n instance pass `state.catalogs[state.locale]` |\n| `validateCatalog()` | Check explicit plural forms from `/validate` | Sync | Import from subpath |\n| `compareCatalogs()` | Compare key parity across locales from `/validate` | Sync | First locale is the base; import from subpath |\n| `LinguaError` | Base class for Lingua errors | Sync | Use `instanceof LinguaError` for broad narrowing |\n\n## Package Entry Point\n\n| Import | Purpose |\n| --- | --- |\n| `@vielzeug/lingua` | Translation factories, state types, and Lingua errors |\n| `@vielzeug/lingua/format` | Standalone cached Intl formatter factory |\n| `@vielzeug/lingua/ripple` | `createReactiveI18n()` — reactive locale/translate bridge (optional `@vielzeug/ripple` peer) |\n| `@vielzeug/lingua/validate` | `validateCatalog()`, `compareCatalogs()`, and `ValidationIssue` |\n\n## Translation Factories\n\n### createTranslator\n\n```ts\nfunction createTranslator<C extends Catalog>(catalog: C, options?: TranslatorOptions): Translator<C>;\n```\n\nCompiles one catalog and returns an immutable fixed-locale translator. Locale defaults to `en` and controls plural selection and diagnostics.\n\n| Parameter | Type | Description |\n| --- | --- | --- |\n| `catalog` | `C` | One catalog containing only messages and grouping objects |\n| `options` | `TranslatorOptions` | Locale and missing-message strategy |\n\n**Returns:** `Translator<C>`.\n\n**Example:**\n\n```ts\nimport { createTranslator } from '@vielzeug/lingua';\n\nconst translator = createTranslator(\n { save: 'Enregistrer' },\n { locale: 'fr' },\n);\n\ntranslator.translate('save');\n```\n\n| Method | Signature | Returns |\n| --- | --- | --- |\n| `translate` | `(textKey, options?)` or `(pluralKey, { count, ordinal?, values? })` | Rendered string |\n| `translateDynamic` | `(key, options?)` | Rendered string for runtime key |\n| `parts` | `(textKey, { values })` or `(pluralKey, { count, ordinal?, values? })` | Typed discriminated parts |\n| `partsDynamic` | `(key, options)` | Parts for runtime key |\n| `locale` | `Locale` | Resolved active locale |\n\n---\n\n### createI18n\n\n```ts\nfunction createI18n<C extends Catalog>(options: I18nOptions<C>): I18n<C>;\n```\n\nCreates catalog store, current locale state, and immutable translator snapshots.\n\n| Parameter | Type | Description |\n| --- | --- | --- |\n| `options.catalogs` | `Catalogs<C>` | Eager locale catalogs; mutually exclusive with `state` |\n| `options.locale` | `Locale` | Initial locale; defaults to serialized locale, then `en` |\n| `options.loadCatalog` | `(locale) => Promise<C> \\| C` | Optional loader for catalogs not supplied eagerly |\n| `options.fallback` | `Locale \\| readonly Locale[]` | Fallback locales loaded with the selected locale |\n| `options.missing` | `MissingStrategy` | Missing-key/value strategy |\n| `options.state` | `TranslationState<C>` | Eager serialized state; mutually exclusive with `catalogs` |\n\nProvide `catalogs`, `state`, or `loadCatalog`. You may combine eager `catalogs` or hydrated `state` with `loadCatalog` for later locales.\n\n**Returns:** `I18n<C>`, with every `Translator<C>` method plus lifecycle methods.\n\n**Example:**\n\n```ts\nimport { createI18n } from '@vielzeug/lingua';\n\nconst i18n = createI18n({\n catalogs: { en: { title: 'Home' } },\n locale: 'en',\n});\ni18n.translate('title');\n```\n\n| Method or property | Signature | Returns |\n| --- | --- | --- |\n| `translate` | Translator method | Rendered string |\n| `parts` | Translator method | Typed discriminated parts |\n| `load` | `({ locale? })` | `Promise<void>` after the locale and configured fallbacks load |\n| `setLocale` | `(locale)` | `Promise<void>` after an atomic loaded-locale commit |\n| `isLoaded` | `({ locale? })` | `boolean` |\n| `getSnapshot` | `()` | `I18nSnapshot<C>` |\n| `subscribe` | `(listener, { immediate?, signal? })` | Unsubscribe function |\n| `serialize` | `()` | Loader-free `TranslationState<C>` |\n| `dispose` | `()` | `void` |\n| `locale` | `Locale` | Current canonical locale |\n| `disposed` | `boolean` | Disposal state |\n| `disposalSignal` | `AbortSignal` | Aborts on disposal |\n| `[Symbol.dispose]` | `()` | Delegates to `dispose()` |\n\n---\n\n## Reactive bridge (`@vielzeug/lingua/ripple`)\n\n### createReactiveI18n\n\n```ts\nfunction createReactiveI18n<C extends Catalog>(i18n: I18n<C>): ReactiveI18n<C>;\n```\n\nBridges a lingua instance into `@vielzeug/ripple` so translated strings are reactive. `i18n.translate()` is not ripple-reactive on its own: reading it inside a `computed()` computes once and never re-runs, since it registers no tracked dependency. Both translate methods here first read the reactive `locale` readable, so any binding built on them re-evaluates the instant `setLocale()` resolves.\n\n`@vielzeug/ripple` is an optional peer dependency — install it only when you use this subpath.\n\n```ts\nimport { createI18n } from '@vielzeug/lingua';\nimport { createReactiveI18n } from '@vielzeug/lingua/ripple';\nimport { computed } from '@vielzeug/ripple';\n\nconst reactive = createReactiveI18n(createI18n({ catalogs, locale: 'en' }));\n\nconst heading = computed(() => reactive.translate('title')); // re-computes on setLocale()\nawait reactive.i18n.setLocale('fr');\n```\n\n| Property or method | Signature | Returns |\n| --- | --- | --- |\n| `i18n` | `I18n<C>` | The underlying instance for imperative work (`setLocale`, `load`, disposal) |\n| `locale` | `Readable<Locale>` | Reactive locale — reading it inside a computed/template registers the dependency |\n| `translate` | `(key, options?)` | Rendered string; re-evaluates whenever the locale changes |\n| `translateDynamic` | `(key, options?)` | Same, for runtime-assembled keys |\n\n---\n\n## Catalog Utilities\n\n### catalogKeys\n\n```ts\nfunction catalogKeys<C extends Catalog>(catalog: C): ReadonlyArray<MessageKey<C>>;\n```\n\nEnumerates every message key as a dotted path. Traverses nested grouping objects and explicit `{ plural: ... }` messages, producing the same paths that `TextKey<C>` represents at the type level. Pass any catalog object or subtree; for an `I18n` instance, read its current locale catalog first.\n\n| Parameter | Type | Description |\n| --------- | ------------------- | ----------------------------------------- |\n| `catalog` | `C extends Catalog` | Raw catalog object or any catalog subtree |\n\n**Returns:** `ReadonlyArray<MessageKey<C>>` — dotted paths to every text and plural message.\n\n```ts\nimport { catalogKeys, createI18n } from '@vielzeug/lingua';\n\nconst i18n = createI18n({\n catalogs: { en: { nav: { home: 'Home', settings: 'Settings' } } },\n locale: 'en',\n});\n\nconst state = i18n.serialize();\nconst allKeys = catalogKeys(state.catalogs[state.locale]); // ['nav.home', 'nav.settings']\nconst navKeys = catalogKeys(state.catalogs.en.nav); // ['home', 'settings']\n```\n\n---\n\n## Intl formatting\n\n### createFormatter\n\n```ts\nimport { createFormatter } from '@vielzeug/lingua/format';\n\nconst format = createFormatter(() => i18n.locale);\nformat.number(1_234.5);\nformat.currency(19.99, 'USD');\nformat.date(new Date(), { dateStyle: 'medium' });\n```\n\nThe formatter caches bounded `Intl` instances and resolves a locale getter for every call.\n\n## Validation\n\n### validateCatalog\n\n```ts\nfunction validateCatalog(catalog: Catalog, locale: Locale): ValidationIssue[];\n```\n\nValidates explicit plural messages against locale plural categories after catalog structural validation.\n\n| Parameter | Type | Description |\n| --- | --- | --- |\n| `catalog` | `Catalog` | Explicit catalog to validate |\n| `locale` | `Locale` | BCP 47 locale tag |\n\n**Returns:** `ValidationIssue[]`.\n\n**Example:**\n\n```ts\nimport { validateCatalog } from '@vielzeug/lingua/validate';\n\nvalidateCatalog({ inbox: { plural: { one: 'One message' } } }, 'en');\n```\n\n### compareCatalogs\n\n```ts\nfunction compareCatalogs<C extends Catalog>(catalogs: Catalogs<C>): CatalogComparison;\n```\n\nCompares key sets across locales. First locale is the base — reports keys missing in each target and keys present in targets but absent from base. Validates each catalog structurally.\n\n| Parameter | Type | Description |\n| --- | --- | --- |\n| `catalogs` | `Catalogs<C>` | Locale-keyed catalogs to compare |\n\n**Returns:** `CatalogComparison` with `missing` and `extra` arrays.\n\n```ts\nimport { compareCatalogs } from '@vielzeug/lingua/validate';\n\nconst result = compareCatalogs({\n en: { greeting: 'Hello', farewell: 'Goodbye' },\n de: { greeting: 'Hallo' },\n});\n// { missing: [{ key: 'farewell', locale: 'de' }], extra: [] }\n```\n\n## Types\n\n```ts\ntype Locale = string;\ntype PluralCategory = Intl.LDMLPluralRule;\ntype PluralMessage = { readonly plural: Partial<Record<PluralCategory, string>> };\ntype CatalogNode = Catalog | PluralMessage | string;\ntype Catalog = { readonly [key: string]: CatalogNode };\ntype Catalogs<C extends Catalog = Catalog> = Record<Locale, C>;\n\ntype TextPart = { readonly type: 'text'; readonly value: string };\ntype ValuePart<V> = { readonly type: 'value'; readonly value: V };\ntype Part<V> = TextPart | ValuePart<V>;\n\ntype MissingInfo = { readonly key: string; readonly locale: Locale; readonly name?: string };\ntype MissingHandler = (info: MissingInfo) => string;\ntype MissingStrategy = 'throw' | 'key' | MissingHandler;\n\ntype TranslatorOptions = { readonly locale?: Locale; readonly missing?: MissingStrategy };\n\ntype I18nOptions<C extends Catalog = Catalog> = {\n readonly fallback?: Locale | readonly Locale[];\n readonly locale?: Locale;\n readonly missing?: MissingStrategy;\n} & (\n | { readonly catalogs: Catalogs<C>; readonly loadCatalog?: CatalogLoader<C>; readonly state?: never }\n | { readonly catalogs?: never; readonly loadCatalog?: CatalogLoader<C>; readonly state: TranslationState<C> }\n | { readonly catalogs?: never; readonly loadCatalog: CatalogLoader<C>; readonly state?: never }\n);\n\ntype CatalogLoader<C extends Catalog> = (locale: Locale) => Promise<C> | C;\n\ntype TranslationState<C extends Catalog = Catalog> = {\n readonly catalogs: Catalogs<C>;\n readonly locale: Locale;\n readonly version: 1;\n};\n\ntype I18nSnapshot<C extends Catalog = Catalog> = {\n readonly locale: Locale;\n readonly revision: number;\n readonly translator: Translator<C>;\n};\n\ntype I18n<C extends Catalog = Catalog> = Translator<C> & {\n readonly disposalSignal: AbortSignal;\n dispose(): void;\n readonly disposed: boolean;\n getSnapshot(): I18nSnapshot<C>;\n isLoaded(options?: { locale?: Locale }): boolean;\n load(options?: { locale?: Locale }): Promise<void>;\n serialize(): TranslationState<C>;\n setLocale(locale: Locale): Promise<void>;\n subscribe(listener: (snapshot: I18nSnapshot<C>) => void, options?: SubscribeOptions): () => void;\n [Symbol.dispose](): void;\n};\n\ntype Translator<C extends Catalog = Catalog> = {\n readonly locale: Locale;\n parts<V>(key: TextKey<C>, options: TranslateOptions & { values: Record<string, V> }): Array<Part<V>>;\n parts<V>(key: PluralKey<C>, options: PluralOptions & { values?: Record<string, V> }): Array<Part<number | V>>;\n partsDynamic<V>(\n key: string,\n options: (TranslateOptions | PluralOptions) & { values?: Record<string, V> },\n ): Array<Part<number | V>>;\n translate(key: TextKey<C>, options?: TranslateOptions): string;\n translate(key: PluralKey<C>, options: PluralOptions): string;\n translateDynamic(key: string, options?: TranslateOptions | PluralOptions): string;\n};\n```\n\n```ts\ntype Values = Record<string, unknown>;\ntype TranslateOptions = { values?: Values };\ntype PluralOptions = TranslateOptions & { count: number; ordinal?: boolean };\ntype SubscribeOptions = { immediate?: boolean; signal?: AbortSignal };\n\ntype MessageKey<\n C,\n Prefix extends string = '',\n Depth extends readonly unknown[] = readonly [1, 1, 1, 1, 1, 1],\n> = Depth extends readonly [unknown, ...infer Rest]\n ? C extends string | PluralMessage\n ? Prefix\n : C extends Catalog\n ? {\n [K in string & keyof C]: MessageKey<C[K], Prefix extends '' ? K : `${Prefix}.${K}`, Rest>;\n }[string & keyof C]\n : never\n : never;\n\ntype TextKey<\n C,\n Prefix extends string = '',\n Depth extends readonly unknown[] = readonly [1, 1, 1, 1, 1, 1],\n> = Depth extends readonly [unknown, ...infer Rest]\n ? C extends string\n ? Prefix\n : C extends Catalog\n ? {\n [K in string & keyof C]: TextKey<C[K], Prefix extends '' ? K : `${Prefix}.${K}`, Rest>;\n }[string & keyof C]\n : never\n : never;\n\ntype PluralKey<\n C,\n Prefix extends string = '',\n Depth extends readonly unknown[] = readonly [1, 1, 1, 1, 1, 1],\n> = Depth extends readonly [unknown, ...infer Rest]\n ? C extends PluralMessage\n ? Prefix\n : C extends Catalog\n ? {\n [K in string & keyof C]: PluralKey<C[K], Prefix extends '' ? K : `${Prefix}.${K}`, Rest>;\n }[string & keyof C]\n : never\n : never;\n\ntype ValidationIssue = { key: string; locale: Locale; missing: Intl.LDMLPluralRule };\ntype CatalogComparison = {\n readonly missing: ReadonlyArray<{ key: string; locale: Locale }>;\n readonly extra: ReadonlyArray<{ key: string; locale: Locale }>;\n};\n```\n\n## Errors\n\n| Error | Trigger |\n| --- | --- |\n| `LinguaDisposedError` | State mutation or subscription after `dispose()` |\n| `LinguaInvalidCatalogError` | Invalid catalog node or reserved key |\n| `LinguaInvalidLocaleError` | Invalid BCP 47 locale tag |\n| `LinguaInvalidPluralCountError` | Non-finite plural count |\n| `LinguaInvalidStateError` | Unsupported serialized state version |\n| `LinguaMissingCatalogError` | No `loadCatalog` function or catalog source unavailable |\n| `LinguaMissingKeyError` | Missing translation key when `missing: 'throw'` |\n| `LinguaMissingValueError` | Missing interpolation value when `missing: 'throw'` |\n",
6
- "usage": "---\ntitle: Lingua — Usage Guide\ndescription: Translate explicit catalogs, load lazy locales, and connect locale snapshots to UI state.\n---\n\n[[toc]]\n\n## Basic Usage\n\nCreate an i18n instance with eager locale catalogs. Strings are text messages; plural messages use `{ plural: ... }`.\n\n```ts\nimport { createI18n } from '@vielzeug/lingua';\n\nconst i18n = createI18n({\n catalogs: {\n en: {\n greeting: 'Hello, {name}!',\n inbox: { plural: { one: 'One message', other: '{count} messages' } },\n },\n },\n locale: 'en',\n});\n\nconsole.log(i18n.translate('greeting', { values: { name: 'Ada' } }));\nconsole.log(i18n.translate('inbox', { count: 3 }));\n```\n\nCall `dispose()` when the instance belongs to a temporary request, test, or route owner.\n\n## Define Explicit Catalogs\n\nUse nested objects only to group keys. A plural message always has `plural`, so regular objects containing `one` or `other` remain groups.\n\n```ts\nconst catalog = {\n account: {\n greeting: 'Hello, {name}!',\n unread: { plural: { one: 'One unread message', other: '{count} unread messages' } },\n },\n};\n```\n\nUse `{ values }` for text replacements. Pass `count` at top level for plural selection; Lingua injects it into selected template.\n\nCatalogs contain strings, grouping objects, and explicit `{ plural: ... }` messages only. Keep application data outside catalog, then translate display labels while constructing it.\n\n```ts\nimport { createTranslator } from '@vielzeug/lingua';\n\nconst messages = {\n status: { blocked: 'Blocked', done: 'Done', inProgress: 'In progress' },\n};\nconst statusDefinitions = [\n { labelKey: 'status.inProgress', value: 'in-progress' },\n { labelKey: 'status.blocked', value: 'blocked' },\n { labelKey: 'status.done', value: 'done' },\n] as const;\nconst translator = createTranslator(messages, { locale: 'en' });\nconst statusOptions = statusDefinitions.map(({ labelKey, value }) => ({ label: translator.translate(labelKey), value }));\n```\n\n## Missing Keys and Values\n\nMissing keys and values do not silently echo placeholders by default — Lingua warns in development and returns the key or `{name}`. Control failure behaviour with the `missing` option:\n\n| `missing` | Behaviour |\n| --- | --- |\n| `undefined` (default) | Warn in development; return key / `{name}` |\n| `'key'` | Return key / `{name}` silently |\n| `'throw'` | Throw `LinguaMissingKeyError` / `LinguaMissingValueError` |\n| `handler` | Call `({ key, locale, name? }) => string` |\n\n```ts\nconst translator = createTranslator(messages, { locale: 'en', missing: 'throw' });\n```\n\n## Enumerate Catalog Keys\n\nUse `catalogKeys()` to derive key arrays from the catalog itself instead of maintaining a parallel list that can go stale. It traverses nested grouping objects and explicit `{ plural: ... }` messages, returning the same dotted paths that `MessageKey<C>` represents at the type level.\n\nFor an `I18n` instance, read its current locale catalog first.\n\n```ts\nimport { catalogKeys, createI18n } from '@vielzeug/lingua';\n\nconst i18n = createI18n({\n catalogs: {\n en: {\n greeting: 'Hello, {name}!',\n inbox: { plural: { one: 'One message', other: '{count} messages' } },\n nav: { home: 'Home', settings: 'Settings' },\n },\n },\n locale: 'en',\n});\n\nconst state = i18n.serialize();\nconst allKeys = catalogKeys(state.catalogs[state.locale]);\n// ['greeting', 'inbox', 'nav.home', 'nav.settings']\n```\n\nPass a raw catalog object to enumerate keys directly. Call `catalogKeys()` on a nested subtree to get exactly the keys in that group — no filtering, no casts.\n\n```ts\nimport { catalogKeys } from '@vielzeug/lingua';\n\nconst messages = {\n nav: { home: 'Home', settings: 'Settings' },\n} as const;\n\nconst allKeys = catalogKeys(messages);\n// ['nav.home', 'nav.settings']\n\nconst navKeys = catalogKeys(messages.nav);\n// ['home', 'settings']\n```\n\n## Render Framework Content\n\nUse `parts()` when replacements are framework nodes, links, or other values that must not be stringified. `parts()` returns a discriminated union: `{ type: 'text', value: string }` for literal text and `{ type: 'value', value: V }` for interpolated values.\n\n```ts\nimport { createTranslator } from '@vielzeug/lingua';\n\nconst translator = createTranslator({ error: 'Try {retry} or {support}.' }, { locale: 'en' });\n\nconst retry = { href: '/retry', label: 'retry' };\nconst support = { href: '/support', label: 'support' };\n\nconst result = translator.parts('error', { values: { retry, support } });\n// [\n// { type: 'text', value: 'Try ' },\n// { type: 'value', value: { href: '/retry', label: 'retry' } },\n// { type: 'text', value: ' or ' },\n// { type: 'value', value: { href: '/support', label: 'support' } },\n// { type: 'text', value: '.' },\n// ]\n```\n\nRender returned array with framework fragment or list primitive. Give UI values consumer-owned keys before passing them to `parts()`; Lingua preserves value identity and never clones or mutates them.\n\n## Use a Fixed-Locale Translator\n\nUse `createTranslator()` when one catalog and locale stay fixed for the translator's lifetime. It defaults locale to `en`; pass `locale` when plural rules or diagnostics need another locale. Lingua snapshots catalog messages during construction. Do not mutate source catalog objects afterward.\n\n```ts\nimport { createTranslator } from '@vielzeug/lingua';\n\nconst translator = createTranslator(\n { save: 'Enregistrer' },\n { locale: 'fr' },\n);\n\nconsole.log(translator.translate('save'));\n```\n\n## Load Catalogs and Switch Locales\n\nProvide eager catalogs for translations needed immediately and a `loadCatalog` function for other locales. `setLocale()` loads the selected locale and configured fallbacks before it commits the change.\n\n```ts\nimport { createI18n } from '@vielzeug/lingua';\n\nconst i18n = createI18n({\n catalogs: { en: { navigation: { settings: 'Settings' } } },\n fallback: 'en',\n locale: 'en',\n loadCatalog: (locale) => import(`./locales/${locale}.ts`).then((m) => m.default),\n});\n\nawait i18n.setLocale('fr');\nconsole.log(i18n.translate('navigation.settings'));\n```\n\nUse loader-only configuration when every locale is lazy, and call `load()` before the first translation. Concurrent loads for the same locale share work. A failed or superseded locale selection never changes the current snapshot.\n\n## Subscribe to Immutable Snapshots\n\nSubscribe when UI state must change with locale or loaded active/fallback catalog. Every callback receives a snapshot containing the translator for that revision.\n\n```ts\nconst unsubscribe = i18n.subscribe(\n ({ locale, translator }) => {\n console.log(locale, translator.translate('navigation.settings'));\n },\n { immediate: true },\n);\n\nunsubscribe();\n```\n\nPass `{ signal }` when an `AbortController` owns subscription lifetime.\n\n## SSR State\n\nSerialize resolved catalogs on the server, then hydrate a client instance from the same payload via the `state` option. `getSnapshot()` stays referentially stable until the store revision changes, so use the same hydrated instance throughout initial client render.\n\n```ts\nimport { createI18n } from '@vielzeug/lingua';\n\nconst serverI18n = createI18n({\n catalogs: { en: { title: 'Server title' } },\n locale: 'en',\n});\n\nconst state = serverI18n.serialize();\nconst clientI18n = createI18n({ state });\n\nconsole.log(clientI18n.translate('title'));\nserverI18n.dispose();\nclientI18n.dispose();\n```\n\nState contains raw loaded catalogs and the active locale. It never contains loader functions. Pass an explicit `locale` only when the client should override the serialized locale.\n\n## Validation\n\nImport catalog validation from the dedicated subpath to keep translation state focused.\n\n```ts\nimport { compareCatalogs, validateCatalog } from '@vielzeug/lingua/validate';\n\nconst catalog = { inbox: { plural: { one: 'One message', other: '{count} messages' } } };\n\nconsole.log(validateCatalog(catalog, 'en'));\n```\n\nUse `compareCatalogs()` to catch missing or extra keys across locales — the most common i18n defect. First locale is the base.\n\n```ts\nimport { compareCatalogs } from '@vielzeug/lingua/validate';\n\nconst result = compareCatalogs({\n en: { greeting: 'Hello', farewell: 'Goodbye' },\n de: { greeting: 'Hallo' },\n});\n// { missing: [{ key: 'farewell', locale: 'de' }], extra: [] }\n```\n\n## Framework Integration\n\nPass stable `getSnapshot()` and `subscribe()` methods to framework state primitives. For SSR, create client instance from the same serialized state used by the server before calling `useSyncExternalStore`.\n\n::: code-group\n\n```ts [React]\nimport { useSyncExternalStore } from 'react';\n\nimport type { I18n } from '@vielzeug/lingua';\n\nexport function useTranslator(i18n: I18n) {\n const snapshot = useSyncExternalStore(i18n.subscribe, i18n.getSnapshot, i18n.getSnapshot);\n\n return snapshot.translator;\n}\n```\n\n```ts [Vue 3]\nimport { onUnmounted, shallowRef } from 'vue';\n\nimport type { I18n } from '@vielzeug/lingua';\n\nexport function useTranslator(i18n: I18n) {\n const snapshot = shallowRef(i18n.getSnapshot());\n const unsubscribe = i18n.subscribe((next) => {\n snapshot.value = next;\n });\n\n onUnmounted(unsubscribe);\n return snapshot;\n}\n```\n\n```ts [Svelte]\nimport { readable } from 'svelte/store';\n\nimport type { I18n } from '@vielzeug/lingua';\n\nexport function translatorStore(i18n: I18n) {\n return readable(i18n.getSnapshot().translator, (set) => i18n.subscribe(({ translator }) => set(translator)));\n}\n```\n\n:::\n\n## Working with Other Vielzeug Libraries\n\nBridge Lingua's structural snapshot directly when templates need reactive locale reads.\n\n```ts\nimport { createRipple } from '@vielzeug/ripple';\n\nconst ripple = createRipple();\nconst i18nState = ripple.fromSubscribable(i18n);\nexport const locale = ripple.computed(() => i18nState.value.locale);\n\nexport const disposeLocaleBridge = () => ripple.dispose();\n```\n\nUse Courier loaders when locale catalogs come from HTTP rather than bundled modules; pass each loader to `loadCatalog`.\n\n## Best Practices\n\n- Define plural messages with `{ plural: ... }` and no sibling metadata.\n- Keep arrays and application metadata outside catalogs.\n- Treat source catalog objects as immutable after construction.\n- Use `translateDynamic()` only for runtime-generated keys.\n- Provide eager catalogs for translations required during initial render.\n- Give UI values keys before passing them to `parts()`.\n- Keep loader functions out of SSR payloads.\n- Dispose temporary instances after requests, tests, and route lifetimes.\n- Use `missing: 'throw'` in development to catch missing keys and values early.\n",
7
- "examples": "---\ntitle: Lingua — Examples\ndescription: Focused examples for explicit catalogs and locale resources.\n---\n\n- [Static Translator](./examples/static-translator.md)\n- [Lazy Locale Catalog](./examples/feature-resources.md)\n- [SSR Hydration](./examples/ssr-hydration.md)\n"
4
+ "index": "---\ntitle: 'Lingua: Explicit localization for TypeScript'\ndescription: Framework-neutral locale catalogs, typed translations, and explicit plural messages.\npackage: lingua\ncategory: i18n\nkeywords: [internationalization, translations, pluralization, locale, i18n, catalog-loading]\nrelated: [ripple, wayfinder, courier]\nexports: [createI18n, createTranslator, LinguaError, LinguaDisposedError, LinguaInvalidCatalogError, LinguaInvalidLocaleError, LinguaInvalidPluralCountError, LinguaInvalidStateError, LinguaMissingCatalogError, LinguaMissingKeyError, LinguaMissingValueError]\nenvironments: [browser, node, ssr, deno]\n---\n\n<!-- markdownlint-disable MD025 MD033 MD060 -->\n\n<PackageHero package=\"lingua\" />\n\n## Why Lingua?\n\nLingua separates immutable translation from mutable locale state. Use `createTranslator` for one fixed-locale catalog, or `createI18n` when locale changes at runtime.\n\n```ts\n// Before\nconst message = catalogs[locale]?.inbox?.[count === 1 ? 'one' : 'other'] ?? 'inbox';\n\n// After\nconst output = i18n.translate('inbox', { count });\n```\n\n| Feature | Lingua | i18next | FormatJS |\n| --- | --- | --- | --- |\n| Bundle size | <PackageInfo package=\"lingua\" type=\"size\" /> | Varies by selected modules | Varies by selected modules |\n| Zero runtime dependencies | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"triangle-alert\" size=\"16\"></ore-icon> | <ore-icon name=\"triangle-alert\" size=\"16\"></ore-icon> |\n| Explicit plural catalog nodes | <ore-icon name=\"check\" size=\"16\"></ore-icon> | Convention/config dependent | ICU-message dependent |\n| Static and lazy locale catalogs | <ore-icon name=\"check\" size=\"16\"></ore-icon> | Plugin/config dependent | Application-defined |\n| Immutable locale snapshots | <ore-icon name=\"check\" size=\"16\"></ore-icon> | Application-defined | Application-defined |\n\n<div class=\"decision-callout\">\n\n**Use Lingua when** you need a compact TypeScript runtime with explicit catalog structure, deterministic fallback, and framework-neutral subscriptions.\n\n**Consider i18next or FormatJS when** you need their plugin ecosystems, message extraction pipelines, or framework-specific integrations.\n\n</div>\n\n## Installation\n\n::: code-group\n\n```sh [pnpm]\npnpm add @vielzeug/lingua\n```\n\n```sh [npm]\nnpm install @vielzeug/lingua\n```\n\n```sh [yarn]\nyarn add @vielzeug/lingua\n```\n\n:::\n\n## Quick Start\n\nCreate an i18n instance with an eager default catalog and a loader for other locales.\n\n```ts\nimport { createI18n } from '@vielzeug/lingua';\n\nconst en = { inbox: { plural: { one: 'One message', other: '{count} messages' } } };\nconst i18n = createI18n({\n catalogs: { en },\n locale: 'en',\n loadCatalog: (locale) => import(`./locales/${locale}.ts`).then((m) => m.default),\n});\n\ntry {\n console.log(i18n.translate('inbox', { count: 3 }));\n await i18n.setLocale('de');\n console.log(i18n.translate('inbox', { count: 1 }));\n} finally {\n i18n.dispose();\n}\n```\n\n## Features\n\n<div class=\"features-grid\">\n\n- `createTranslator()` compiles one immutable fixed-locale catalog.\n- `createI18n()` manages eager catalogs, locale changes, and optional lazy loading.\n- `translate()` renders text and plural messages through explicit catalog nodes.\n- `translateDynamic()` makes runtime-key lookup explicit.\n- `parts()` returns typed discriminated parts for framework content.\n- `load()` deduplicates loading and resolves the active locale plus configured fallbacks.\n- `getSnapshot()` and `subscribe()` expose immutable translator revisions.\n- `serialize()` and `state` option transfer resolved SSR catalogs.\n- `missing: 'throw' | 'key' | handler` controls failure behaviour.\n- `validateCatalog()` remains an isolated subpath tool.\n\n</div>\n\n## Documentation\n\n<div class=\"doc-links\">\n\n- [Usage Guide](./usage.md)\n- [API Reference](./api.md)\n- [Examples](./examples.md)\n- [Migration Guide](./migration.md)\n\n</div>\n\n## See Also\n\n<div class=\"see-also\">\n\n- [Ripple](../ripple/index.md) adapts Lingua snapshots into reactive application state.\n- [Courier](../courier/index.md) can fetch locale catalogs before passing them to Lingua loaders.\n- [Wayfinder](../wayfinder/index.md) can drive locale selection from route state.\n\n</div>\n\n<!-- markdownlint-enable MD025 MD033 MD060 -->\n",
5
+ "api": "---\ntitle: 'Lingua: API Reference'\ndescription: Complete API reference for @vielzeug/lingua.\n---\n\n[[toc]]\n\n## API Overview\n\n| Symbol | Purpose | Execution mode | Common gotcha |\n| --- | --- | --- | --- |\n| `createTranslator()` | Compile one immutable locale catalog | Sync | No fallback locales |\n| `createI18n()` | Create mutable locale and catalog store | Sync | Provide catalogs, serialized state, or a loader |\n| `createFormatter()` | Create a cached Intl facade from `/format` | Sync | Pass a locale getter for dynamic locale changes |\n| `createReactiveI18n()` | Mirror an i18n instance into ripple readables from `/ripple` | Sync | Optional `@vielzeug/ripple` peer; import from subpath |\n| `catalogKeys()` | Enumerate message keys as dotted paths | Sync | Takes a catalog (or subtree) only; for an i18n instance pass `state.catalogs[state.locale]` |\n| `validateCatalog()` | Check explicit plural forms from `/validate` | Sync | Import from subpath |\n| `compareCatalogs()` | Compare key parity across locales from `/validate` | Sync | First locale is the base; import from subpath |\n| `LinguaError` | Base class for Lingua errors | Sync | Use `instanceof LinguaError` for broad narrowing |\n\n## Package Entry Point\n\n| Import | Purpose |\n| --- | --- |\n| `@vielzeug/lingua` | Translation factories, state types, and Lingua errors |\n| `@vielzeug/lingua/format` | Standalone cached Intl formatter factory |\n| `@vielzeug/lingua/ripple` | `createReactiveI18n()`: reactive locale/translate bridge (optional `@vielzeug/ripple` peer) |\n| `@vielzeug/lingua/validate` | `validateCatalog()`, `compareCatalogs()`, and `ValidationIssue` |\n\n## Translation Factories\n\n### createTranslator\n\n```ts\nfunction createTranslator<C extends Catalog>(catalog: C, options?: TranslatorOptions): Translator<C>;\n```\n\nCompiles one catalog and returns an immutable fixed-locale translator. Locale defaults to `en` and controls plural selection and diagnostics.\n\n| Parameter | Type | Description |\n| --- | --- | --- |\n| `catalog` | `C` | One catalog containing only messages and grouping objects |\n| `options` | `TranslatorOptions` | Locale and missing-message strategy |\n\n**Returns:** `Translator<C>`.\n\n**Example:**\n\n```ts\nimport { createTranslator } from '@vielzeug/lingua';\n\nconst translator = createTranslator(\n { save: 'Enregistrer' },\n { locale: 'fr' },\n);\n\ntranslator.translate('save');\n```\n\n| Method | Signature | Returns |\n| --- | --- | --- |\n| `translate` | `(textKey, options?)` or `(pluralKey, { count, ordinal?, values? })` | Rendered string |\n| `translateDynamic` | `(key, options?)` | Rendered string for runtime key |\n| `parts` | `(textKey, { values })` or `(pluralKey, { count, ordinal?, values? })` | Typed discriminated parts |\n| `partsDynamic` | `(key, options)` | Parts for runtime key |\n| `locale` | `Locale` | Resolved active locale |\n\n---\n\n### createI18n\n\n```ts\nfunction createI18n<C extends Catalog>(options: I18nOptions<C>): I18n<C>;\n```\n\nCreates catalog store, current locale state, and immutable translator snapshots.\n\n| Parameter | Type | Description |\n| --- | --- | --- |\n| `options.catalogs` | `Catalogs<C>` | Eager locale catalogs; mutually exclusive with `state` |\n| `options.locale` | `Locale` | Initial locale; defaults to serialized locale, then `en` |\n| `options.loadCatalog` | `(locale) => Promise<C> \\| C` | Optional loader for catalogs not supplied eagerly |\n| `options.fallback` | `Locale \\| readonly Locale[]` | Fallback locales loaded with the selected locale |\n| `options.missing` | `MissingStrategy` | Missing-key/value strategy |\n| `options.state` | `TranslationState<C>` | Eager serialized state; mutually exclusive with `catalogs` |\n\nProvide `catalogs`, `state`, or `loadCatalog`. You may combine eager `catalogs` or hydrated `state` with `loadCatalog` for later locales.\n\n**Returns:** `I18n<C>`, with every `Translator<C>` method plus lifecycle methods.\n\n**Example:**\n\n```ts\nimport { createI18n } from '@vielzeug/lingua';\n\nconst i18n = createI18n({\n catalogs: { en: { title: 'Home' } },\n locale: 'en',\n});\ni18n.translate('title');\n```\n\n| Method or property | Signature | Returns |\n| --- | --- | --- |\n| `translate` | Translator method | Rendered string |\n| `parts` | Translator method | Typed discriminated parts |\n| `load` | `({ locale? })` | `Promise<void>` after the locale and configured fallbacks load |\n| `setLocale` | `(locale)` | `Promise<void>` after an atomic loaded-locale commit |\n| `isLoaded` | `({ locale? })` | `boolean` |\n| `getSnapshot` | `()` | `I18nSnapshot<C>` |\n| `subscribe` | `(listener, { immediate?, signal? })` | Unsubscribe function |\n| `serialize` | `()` | Loader-free `TranslationState<C>` |\n| `dispose` | `()` | `void` |\n| `locale` | `Locale` | Current canonical locale |\n| `disposed` | `boolean` | Disposal state |\n| `disposalSignal` | `AbortSignal` | Aborts on disposal |\n| `[Symbol.dispose]` | `()` | Delegates to `dispose()` |\n\n---\n\n## Reactive bridge (`@vielzeug/lingua/ripple`)\n\n### createReactiveI18n\n\n```ts\nfunction createReactiveI18n<C extends Catalog>(i18n: I18n<C>): ReactiveI18n<C>;\n```\n\nBridges a lingua instance into `@vielzeug/ripple` so translated strings are reactive. `i18n.translate()` is not ripple-reactive on its own: reading it inside a `computed()` computes once and never re-runs, since it registers no tracked dependency. Both translate methods here first read the reactive `locale` readable, so any binding built on them re-evaluates the instant `setLocale()` resolves.\n\n`@vielzeug/ripple` is an optional peer dependency: install it only when you use this subpath.\n\n```ts\nimport { createI18n } from '@vielzeug/lingua';\nimport { createReactiveI18n } from '@vielzeug/lingua/ripple';\nimport { computed } from '@vielzeug/ripple';\n\nconst reactive = createReactiveI18n(createI18n({ catalogs, locale: 'en' }));\n\nconst heading = computed(() => reactive.translate('title')); // re-computes on setLocale()\nawait reactive.i18n.setLocale('fr');\n```\n\n| Property or method | Signature | Returns |\n| --- | --- | --- |\n| `i18n` | `I18n<C>` | The underlying instance for imperative work (`setLocale`, `load`, disposal) |\n| `locale` | `Readable<Locale>` | Reactive locale: reading it inside a computed/template registers the dependency |\n| `translate` | `(key, options?)` | Rendered string; re-evaluates whenever the locale changes |\n| `translateDynamic` | `(key, options?)` | Same, for runtime-assembled keys |\n\n---\n\n## Catalog Utilities\n\n### catalogKeys\n\n```ts\nfunction catalogKeys<C extends Catalog>(catalog: C): ReadonlyArray<MessageKey<C>>;\n```\n\nEnumerates every message key as a dotted path. Traverses nested grouping objects and explicit `{ plural: ... }` messages, producing the same paths that `TextKey<C>` represents at the type level. Pass any catalog object or subtree; for an `I18n` instance, read its current locale catalog first.\n\n| Parameter | Type | Description |\n| --------- | ------------------- | ----------------------------------------- |\n| `catalog` | `C extends Catalog` | Raw catalog object or any catalog subtree |\n\n**Returns:** `ReadonlyArray<MessageKey<C>>`: dotted paths to every text and plural message.\n\n```ts\nimport { catalogKeys, createI18n } from '@vielzeug/lingua';\n\nconst i18n = createI18n({\n catalogs: { en: { nav: { home: 'Home', settings: 'Settings' } } },\n locale: 'en',\n});\n\nconst state = i18n.serialize();\nconst allKeys = catalogKeys(state.catalogs[state.locale]); // ['nav.home', 'nav.settings']\nconst navKeys = catalogKeys(state.catalogs.en.nav); // ['home', 'settings']\n```\n\n---\n\n## Intl formatting\n\n### createFormatter\n\n```ts\nimport { createFormatter } from '@vielzeug/lingua/format';\n\nconst format = createFormatter(() => i18n.locale);\nformat.number(1_234.5);\nformat.currency(19.99, 'USD');\nformat.date(new Date(), { dateStyle: 'medium' });\n```\n\nThe formatter caches bounded `Intl` instances and resolves a locale getter for every call.\n\n## Validation\n\n### validateCatalog\n\n```ts\nfunction validateCatalog(catalog: Catalog, locale: Locale): ValidationIssue[];\n```\n\nValidates explicit plural messages against locale plural categories after catalog structural validation.\n\n| Parameter | Type | Description |\n| --- | --- | --- |\n| `catalog` | `Catalog` | Explicit catalog to validate |\n| `locale` | `Locale` | BCP 47 locale tag |\n\n**Returns:** `ValidationIssue[]`.\n\n**Example:**\n\n```ts\nimport { validateCatalog } from '@vielzeug/lingua/validate';\n\nvalidateCatalog({ inbox: { plural: { one: 'One message' } } }, 'en');\n```\n\n### compareCatalogs\n\n```ts\nfunction compareCatalogs<C extends Catalog>(catalogs: Catalogs<C>): CatalogComparison;\n```\n\nCompares key sets across locales. First locale is the base: reports keys missing in each target and keys present in targets but absent from base. Validates each catalog structurally.\n\n| Parameter | Type | Description |\n| --- | --- | --- |\n| `catalogs` | `Catalogs<C>` | Locale-keyed catalogs to compare |\n\n**Returns:** `CatalogComparison` with `missing` and `extra` arrays.\n\n```ts\nimport { compareCatalogs } from '@vielzeug/lingua/validate';\n\nconst result = compareCatalogs({\n en: { greeting: 'Hello', farewell: 'Goodbye' },\n de: { greeting: 'Hallo' },\n});\n// { missing: [{ key: 'farewell', locale: 'de' }], extra: [] }\n```\n\n## Types\n\n```ts\ntype Locale = string;\ntype PluralCategory = Intl.LDMLPluralRule;\ntype PluralMessage = { readonly plural: Partial<Record<PluralCategory, string>> };\ntype CatalogNode = Catalog | PluralMessage | string;\ntype Catalog = { readonly [key: string]: CatalogNode };\ntype Catalogs<C extends Catalog = Catalog> = Record<Locale, C>;\n\ntype TextPart = { readonly type: 'text'; readonly value: string };\ntype ValuePart<V> = { readonly type: 'value'; readonly value: V };\ntype Part<V> = TextPart | ValuePart<V>;\n\ntype MissingInfo = { readonly key: string; readonly locale: Locale; readonly name?: string };\ntype MissingHandler = (info: MissingInfo) => string;\ntype MissingStrategy = 'throw' | 'key' | MissingHandler;\n\ntype TranslatorOptions = { readonly locale?: Locale; readonly missing?: MissingStrategy };\n\ntype I18nOptions<C extends Catalog = Catalog> = {\n readonly fallback?: Locale | readonly Locale[];\n readonly locale?: Locale;\n readonly missing?: MissingStrategy;\n} & (\n | { readonly catalogs: Catalogs<C>; readonly loadCatalog?: CatalogLoader<C>; readonly state?: never }\n | { readonly catalogs?: never; readonly loadCatalog?: CatalogLoader<C>; readonly state: TranslationState<C> }\n | { readonly catalogs?: never; readonly loadCatalog: CatalogLoader<C>; readonly state?: never }\n);\n\ntype CatalogLoader<C extends Catalog> = (locale: Locale) => Promise<C> | C;\n\ntype TranslationState<C extends Catalog = Catalog> = {\n readonly catalogs: Catalogs<C>;\n readonly locale: Locale;\n readonly version: 1;\n};\n\ntype I18nSnapshot<C extends Catalog = Catalog> = {\n readonly locale: Locale;\n readonly revision: number;\n readonly translator: Translator<C>;\n};\n\ntype I18n<C extends Catalog = Catalog> = Translator<C> & {\n readonly disposalSignal: AbortSignal;\n dispose(): void;\n readonly disposed: boolean;\n getSnapshot(): I18nSnapshot<C>;\n isLoaded(options?: { locale?: Locale }): boolean;\n load(options?: { locale?: Locale }): Promise<void>;\n serialize(): TranslationState<C>;\n setLocale(locale: Locale): Promise<void>;\n subscribe(listener: (snapshot: I18nSnapshot<C>) => void, options?: SubscribeOptions): () => void;\n [Symbol.dispose](): void;\n};\n\ntype Translator<C extends Catalog = Catalog> = {\n readonly locale: Locale;\n parts<V>(key: TextKey<C>, options: TranslateOptions & { values: Record<string, V> }): Array<Part<V>>;\n parts<V>(key: PluralKey<C>, options: PluralOptions & { values?: Record<string, V> }): Array<Part<number | V>>;\n partsDynamic<V>(\n key: string,\n options: (TranslateOptions | PluralOptions) & { values?: Record<string, V> },\n ): Array<Part<number | V>>;\n translate(key: TextKey<C>, options?: TranslateOptions): string;\n translate(key: PluralKey<C>, options: PluralOptions): string;\n translateDynamic(key: string, options?: TranslateOptions | PluralOptions): string;\n};\n```\n\n```ts\ntype Values = Record<string, unknown>;\ntype TranslateOptions = { values?: Values };\ntype PluralOptions = TranslateOptions & { count: number; ordinal?: boolean };\ntype SubscribeOptions = { immediate?: boolean; signal?: AbortSignal };\n\ntype MessageKey<\n C,\n Prefix extends string = '',\n Depth extends readonly unknown[] = readonly [1, 1, 1, 1, 1, 1],\n> = Depth extends readonly [unknown, ...infer Rest]\n ? C extends string | PluralMessage\n ? Prefix\n : C extends Catalog\n ? {\n [K in string & keyof C]: MessageKey<C[K], Prefix extends '' ? K : `${Prefix}.${K}`, Rest>;\n }[string & keyof C]\n : never\n : never;\n\ntype TextKey<\n C,\n Prefix extends string = '',\n Depth extends readonly unknown[] = readonly [1, 1, 1, 1, 1, 1],\n> = Depth extends readonly [unknown, ...infer Rest]\n ? C extends string\n ? Prefix\n : C extends Catalog\n ? {\n [K in string & keyof C]: TextKey<C[K], Prefix extends '' ? K : `${Prefix}.${K}`, Rest>;\n }[string & keyof C]\n : never\n : never;\n\ntype PluralKey<\n C,\n Prefix extends string = '',\n Depth extends readonly unknown[] = readonly [1, 1, 1, 1, 1, 1],\n> = Depth extends readonly [unknown, ...infer Rest]\n ? C extends PluralMessage\n ? Prefix\n : C extends Catalog\n ? {\n [K in string & keyof C]: PluralKey<C[K], Prefix extends '' ? K : `${Prefix}.${K}`, Rest>;\n }[string & keyof C]\n : never\n : never;\n\ntype ValidationIssue = { key: string; locale: Locale; missing: Intl.LDMLPluralRule };\ntype CatalogComparison = {\n readonly missing: ReadonlyArray<{ key: string; locale: Locale }>;\n readonly extra: ReadonlyArray<{ key: string; locale: Locale }>;\n};\n```\n\n## Errors\n\n| Error | Trigger |\n| --- | --- |\n| `LinguaDisposedError` | State mutation or subscription after `dispose()` |\n| `LinguaInvalidCatalogError` | Invalid catalog node or reserved key |\n| `LinguaInvalidLocaleError` | Invalid BCP 47 locale tag |\n| `LinguaInvalidPluralCountError` | Non-finite plural count |\n| `LinguaInvalidStateError` | Unsupported serialized state version |\n| `LinguaMissingCatalogError` | No `loadCatalog` function or catalog source unavailable |\n| `LinguaMissingKeyError` | Missing translation key when `missing: 'throw'` |\n| `LinguaMissingValueError` | Missing interpolation value when `missing: 'throw'` |\n",
6
+ "usage": "---\ntitle: 'Lingua: Usage Guide'\ndescription: Translate explicit catalogs, load lazy locales, and connect locale snapshots to UI state.\n---\n\n[[toc]]\n\n## Basic Usage\n\nCreate an i18n instance with eager locale catalogs. Strings are text messages; plural messages use `{ plural: ... }`.\n\n```ts\nimport { createI18n } from '@vielzeug/lingua';\n\nconst i18n = createI18n({\n catalogs: {\n en: {\n greeting: 'Hello, {name}!',\n inbox: { plural: { one: 'One message', other: '{count} messages' } },\n },\n },\n locale: 'en',\n});\n\nconsole.log(i18n.translate('greeting', { values: { name: 'Ada' } }));\nconsole.log(i18n.translate('inbox', { count: 3 }));\n```\n\nCall `dispose()` when the instance belongs to a temporary request, test, or route owner.\n\n## Define Explicit Catalogs\n\nUse nested objects only to group keys. A plural message always has `plural`, so regular objects containing `one` or `other` remain groups.\n\n```ts\nconst catalog = {\n account: {\n greeting: 'Hello, {name}!',\n unread: { plural: { one: 'One unread message', other: '{count} unread messages' } },\n },\n};\n```\n\nUse `{ values }` for text replacements. Pass `count` at top level for plural selection; Lingua injects it into selected template.\n\nCatalogs contain strings, grouping objects, and explicit `{ plural: ... }` messages only. Keep application data outside catalog, then translate display labels while constructing it.\n\n```ts\nimport { createTranslator } from '@vielzeug/lingua';\n\nconst messages = {\n status: { blocked: 'Blocked', done: 'Done', inProgress: 'In progress' },\n};\nconst statusDefinitions = [\n { labelKey: 'status.inProgress', value: 'in-progress' },\n { labelKey: 'status.blocked', value: 'blocked' },\n { labelKey: 'status.done', value: 'done' },\n] as const;\nconst translator = createTranslator(messages, { locale: 'en' });\nconst statusOptions = statusDefinitions.map(({ labelKey, value }) => ({ label: translator.translate(labelKey), value }));\n```\n\n## Missing Keys and Values\n\nMissing keys and values do not silently echo placeholders by default: Lingua warns in development and returns the key or `{name}`. Control failure behaviour with the `missing` option:\n\n| `missing` | Behaviour |\n| --- | --- |\n| `undefined` (default) | Warn in development; return key / `{name}` |\n| `'key'` | Return key / `{name}` silently |\n| `'throw'` | Throw `LinguaMissingKeyError` / `LinguaMissingValueError` |\n| `handler` | Call `({ key, locale, name? }) => string` |\n\n```ts\nconst translator = createTranslator(messages, { locale: 'en', missing: 'throw' });\n```\n\n## Enumerate Catalog Keys\n\nUse `catalogKeys()` to derive key arrays from the catalog itself instead of maintaining a parallel list that can go stale. It traverses nested grouping objects and explicit `{ plural: ... }` messages, returning the same dotted paths that `MessageKey<C>` represents at the type level.\n\nFor an `I18n` instance, read its current locale catalog first.\n\n```ts\nimport { catalogKeys, createI18n } from '@vielzeug/lingua';\n\nconst i18n = createI18n({\n catalogs: {\n en: {\n greeting: 'Hello, {name}!',\n inbox: { plural: { one: 'One message', other: '{count} messages' } },\n nav: { home: 'Home', settings: 'Settings' },\n },\n },\n locale: 'en',\n});\n\nconst state = i18n.serialize();\nconst allKeys = catalogKeys(state.catalogs[state.locale]);\n// ['greeting', 'inbox', 'nav.home', 'nav.settings']\n```\n\nPass a raw catalog object to enumerate keys directly. Call `catalogKeys()` on a nested subtree to get exactly the keys in that group: no filtering, no casts.\n\n```ts\nimport { catalogKeys } from '@vielzeug/lingua';\n\nconst messages = {\n nav: { home: 'Home', settings: 'Settings' },\n} as const;\n\nconst allKeys = catalogKeys(messages);\n// ['nav.home', 'nav.settings']\n\nconst navKeys = catalogKeys(messages.nav);\n// ['home', 'settings']\n```\n\n## Render Framework Content\n\nUse `parts()` when replacements are framework nodes, links, or other values that must not be stringified. `parts()` returns a discriminated union: `{ type: 'text', value: string }` for literal text and `{ type: 'value', value: V }` for interpolated values.\n\n```ts\nimport { createTranslator } from '@vielzeug/lingua';\n\nconst translator = createTranslator({ error: 'Try {retry} or {support}.' }, { locale: 'en' });\n\nconst retry = { href: '/retry', label: 'retry' };\nconst support = { href: '/support', label: 'support' };\n\nconst result = translator.parts('error', { values: { retry, support } });\n// [\n// { type: 'text', value: 'Try ' },\n// { type: 'value', value: { href: '/retry', label: 'retry' } },\n// { type: 'text', value: ' or ' },\n// { type: 'value', value: { href: '/support', label: 'support' } },\n// { type: 'text', value: '.' },\n// ]\n```\n\nRender returned array with framework fragment or list primitive. Give UI values consumer-owned keys before passing them to `parts()`; Lingua preserves value identity and never clones or mutates them.\n\n## Use a Fixed-Locale Translator\n\nUse `createTranslator()` when one catalog and locale stay fixed for the translator's lifetime. It defaults locale to `en`; pass `locale` when plural rules or diagnostics need another locale. Lingua snapshots catalog messages during construction. Do not mutate source catalog objects afterward.\n\n```ts\nimport { createTranslator } from '@vielzeug/lingua';\n\nconst translator = createTranslator(\n { save: 'Enregistrer' },\n { locale: 'fr' },\n);\n\nconsole.log(translator.translate('save'));\n```\n\n## Load Catalogs and Switch Locales\n\nProvide eager catalogs for translations needed immediately and a `loadCatalog` function for other locales. `setLocale()` loads the selected locale and configured fallbacks before it commits the change.\n\n```ts\nimport { createI18n } from '@vielzeug/lingua';\n\nconst i18n = createI18n({\n catalogs: { en: { navigation: { settings: 'Settings' } } },\n fallback: 'en',\n locale: 'en',\n loadCatalog: (locale) => import(`./locales/${locale}.ts`).then((m) => m.default),\n});\n\nawait i18n.setLocale('fr');\nconsole.log(i18n.translate('navigation.settings'));\n```\n\nUse loader-only configuration when every locale is lazy, and call `load()` before the first translation. Concurrent loads for the same locale share work. A failed or superseded locale selection never changes the current snapshot.\n\n## Subscribe to Immutable Snapshots\n\nSubscribe when UI state must change with locale or loaded active/fallback catalog. Every callback receives a snapshot containing the translator for that revision.\n\n```ts\nconst unsubscribe = i18n.subscribe(\n ({ locale, translator }) => {\n console.log(locale, translator.translate('navigation.settings'));\n },\n { immediate: true },\n);\n\nunsubscribe();\n```\n\nPass `{ signal }` when an `AbortController` owns subscription lifetime.\n\n## SSR State\n\nSerialize resolved catalogs on the server, then hydrate a client instance from the same payload via the `state` option. `getSnapshot()` stays referentially stable until the store revision changes, so use the same hydrated instance throughout initial client render.\n\n```ts\nimport { createI18n } from '@vielzeug/lingua';\n\nconst serverI18n = createI18n({\n catalogs: { en: { title: 'Server title' } },\n locale: 'en',\n});\n\nconst state = serverI18n.serialize();\nconst clientI18n = createI18n({ state });\n\nconsole.log(clientI18n.translate('title'));\nserverI18n.dispose();\nclientI18n.dispose();\n```\n\nState contains raw loaded catalogs and the active locale. It never contains loader functions. Pass an explicit `locale` only when the client should override the serialized locale.\n\n## Validation\n\nImport catalog validation from the dedicated subpath to keep translation state focused.\n\n```ts\nimport { compareCatalogs, validateCatalog } from '@vielzeug/lingua/validate';\n\nconst catalog = { inbox: { plural: { one: 'One message', other: '{count} messages' } } };\n\nconsole.log(validateCatalog(catalog, 'en'));\n```\n\nUse `compareCatalogs()` to catch missing or extra keys across locales: the most common i18n defect. First locale is the base.\n\n```ts\nimport { compareCatalogs } from '@vielzeug/lingua/validate';\n\nconst result = compareCatalogs({\n en: { greeting: 'Hello', farewell: 'Goodbye' },\n de: { greeting: 'Hallo' },\n});\n// { missing: [{ key: 'farewell', locale: 'de' }], extra: [] }\n```\n\n## Framework Integration\n\nPass stable `getSnapshot()` and `subscribe()` methods to framework state primitives. For SSR, create client instance from the same serialized state used by the server before calling `useSyncExternalStore`.\n\n::: code-group\n\n```ts [React]\nimport { useSyncExternalStore } from 'react';\n\nimport type { I18n } from '@vielzeug/lingua';\n\nexport function useTranslator(i18n: I18n) {\n const snapshot = useSyncExternalStore(i18n.subscribe, i18n.getSnapshot, i18n.getSnapshot);\n\n return snapshot.translator;\n}\n```\n\n```ts [Vue 3]\nimport { onUnmounted, shallowRef } from 'vue';\n\nimport type { I18n } from '@vielzeug/lingua';\n\nexport function useTranslator(i18n: I18n) {\n const snapshot = shallowRef(i18n.getSnapshot());\n const unsubscribe = i18n.subscribe((next) => {\n snapshot.value = next;\n });\n\n onUnmounted(unsubscribe);\n return snapshot;\n}\n```\n\n```ts [Svelte]\nimport { readable } from 'svelte/store';\n\nimport type { I18n } from '@vielzeug/lingua';\n\nexport function translatorStore(i18n: I18n) {\n return readable(i18n.getSnapshot().translator, (set) => i18n.subscribe(({ translator }) => set(translator)));\n}\n```\n\n:::\n\n## Working with Other Vielzeug Libraries\n\nBridge Lingua's structural snapshot directly when templates need reactive locale reads.\n\n```ts\nimport { createRipple } from '@vielzeug/ripple';\n\nconst ripple = createRipple();\nconst i18nState = ripple.fromSubscribable(i18n);\nexport const locale = ripple.computed(() => i18nState.value.locale);\n\nexport const disposeLocaleBridge = () => ripple.dispose();\n```\n\nUse Courier loaders when locale catalogs come from HTTP rather than bundled modules; pass each loader to `loadCatalog`.\n\n## Best Practices\n\n- Define plural messages with `{ plural: ... }` and no sibling metadata.\n- Keep arrays and application metadata outside catalogs.\n- Treat source catalog objects as immutable after construction.\n- Use `translateDynamic()` only for runtime-generated keys.\n- Provide eager catalogs for translations required during initial render.\n- Give UI values keys before passing them to `parts()`.\n- Keep loader functions out of SSR payloads.\n- Dispose temporary instances after requests, tests, and route lifetimes.\n- Use `missing: 'throw'` in development to catch missing keys and values early.\n",
7
+ "examples": "---\ntitle: 'Lingua: Examples'\ndescription: Focused examples for explicit catalogs and locale resources.\n---\n\n- [Static Translator](./examples/static-translator.md)\n- [Lazy Locale Catalog](./examples/feature-resources.md)\n- [SSR Hydration](./examples/ssr-hydration.md)\n"
8
8
  },
9
9
  "examples": [
10
10
  {