@vielzeug/codex 2.2.9 → 2.3.0
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.
- package/data/catalog.json +73 -30
- package/data/llms-full.txt +1472 -370
- package/data/llms.txt +2 -1
- package/data/manifest.json +1 -1
- package/data/packages/clockwork.json +2 -2
- package/data/packages/conduit.json +1 -1
- package/data/packages/courier.json +7 -6
- package/data/packages/dnd.json +1 -1
- package/data/packages/familiar.json +1 -1
- package/data/packages/forge.json +1 -1
- package/data/packages/gesture.json +1 -1
- package/data/packages/herald.json +18 -18
- package/data/packages/keymap.json +2 -2
- package/data/packages/lingua.json +1 -1
- package/data/packages/necromancer.json +1 -1
- package/data/packages/ore.json +1 -1
- package/data/packages/postmaster.json +45 -0
- package/data/packages/pulse.json +31 -30
- package/data/packages/scout.json +13 -12
- package/data/packages/scroll.json +1 -1
- package/data/packages/sentinel.json +1 -1
- package/data/packages/spell.json +1 -1
- package/data/packages/vault.json +22 -28
- package/data/packages/ward.json +28 -28
- package/data/packages/wayfinder.json +5 -5
- package/data/refine.json +3926 -3926
- package/data/search.json +76 -54
- package/package.json +2 -1
package/data/search.json
CHANGED
|
@@ -159,8 +159,8 @@
|
|
|
159
159
|
"description": "framework neutral typed state machines with pure transitions, actor owned runtime work, timers, invokes, and explicit effects.",
|
|
160
160
|
"docs": {
|
|
161
161
|
"index": " \ntitle: clockwork — typed finite state machines for typescript\ndescription: framework neutral typed state machines with pure transitions, actor owned runtime work, timers, invokes, and explicit effects.\npackage: clockwork\ncategory: state\nkeywords: [state machine, finite state, typed, actor, async tasks]\nrelated: [herald, ripple, ward]\nexports: [definemachine, clockworkerror, machine, actor, machineconfig, machinesnapshot, transitionresult]\nenvironments: [browser, node, ssr, deno]\n \n\n<! markdownlint disable md025 md033 md060 >\n\n<packagehero package=\"clockwork\" />\n\n## why clockwork?\n\napplication workflows often mix state changes with timers, requests, rendering, and cleanup. clockwork keeps transition logic pure while each disposable actor owns runtime work. you can test state decisions without starting effects or invokes.\n\n```ts\nimport { definemachine } from '@vielzeug/clockwork';\n\n// before\nif (status === 'idle') status = 'loading';\nfetchitems().then((items) => {\n status = 'ready';\n data = items;\n});\n\n// after\ntype event = { type: 'fetch' } | { items: string[]; type: 'done' };\nconst machine = definemachine<{ items: string[] }, event>()({\n context: { items: [] },\n initial: 'idle',\n states: {\n idle: { on: { fetch: { target: 'loading' } } },\n loading: {\n invoke: [{\n src: ({ signal }) => fetch('/api/items', { signal }).then((response) => response.json() as promise<string[]>),\n ondone: ({ result }) => ({ items: result, type: 'done' }),\n }],\n on: { done: { reduce: ({ event }) => ({ items: event.items }), target: 'ready' } },\n },\n ready: {},\n },\n});\n```\n\n| feature | clockwork | xstate | zustand |\n| | | | |\n| bundle size | <packageinfo package=\"clockwork\" type=\"size\" /> | larger actor/statechart runtime | smaller store runtime |\n| zero dependencies | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> |\n| pure transition api | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"triangle alert\" size=\"16\"></ore icon> statechart focused | <ore icon name=\"x\" size=\"16\"></ore icon> |\n| owned cancellation | <ore icon name=\"check\" size=\"16\"></ore icon> actor disposal | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> |\n| framework coupling | <ore icon name=\"check\" size=\"16\"></ore icon> none | <ore icon name=\"check\" size=\"16\"></ore icon> none | <ore icon name=\"check\" size=\"16\"></ore icon> none |\n\n<div class=\"decision callout\">\n\n**use clockwork when** your feature has explicit workflow states, cancellable work, or effects that must run after a state commit.\n\n**consider xstate when** you need statecharts, visual tooling, or its broader actor ecosystem.\n\n</div>\n\n## installation\n\n::: code group\n\n```sh [pnpm]\npnpm add @vielzeug/clockwork\n```\n\n```sh [npm]\nnpm install @vielzeug/clockwork\n```\n\n```sh [yarn]\nyarn add @vielzeug/clockwork\n```\n\n:::\n\n## quick start\n\ndefine the context and event union, create an actor, observe its snapshot, then dispose it when its owner ends.\n\n```ts\nimport { definemachine } from '@vielzeug/clockwork';\n\ntype event = { type: 'dec' } | { type: 'inc' };\n\nconst counter = definemachine<{ count: number }, event>()({\n context: { count: 0 },\n initial: 'idle',\n states: {\n idle: {\n on: {\n dec: { reduce: ({ context }) => ({ count: context.count 1 }), target: 'idle' },\n inc: { reduce: ({ context }) => ({ count: context.count + 1 }), target: 'idle' },\n },\n },\n },\n});\n\nusing actor = counter.createactor();\nactor.subscribe((snapshot) => console.log(snapshot));\nactor.send({ type: 'inc' });\n// { context: { count: 1 }, state: 'idle' }\n```\n\n## features\n\n<div class=\"features grid\">\n\n **`definemachine()`** — validates and compiles one flat machine definition.\n **`machine.transition()`** — evaluates a transition without actor runtime work.\n **`machine.createactor()`** — creates isolated, disposable runtime ownership.\n **`reduce`** — returns a replacement context from a transition.\n **`effects`** — run only after the actor commits and notifies subscribers.\n **`invoke`** — runs cancellable asynchronous work on state entry.\n **`after`** — schedules cancellable delayed transitions.\n **`actor.snapshot`** — exposes the current readonly state/context value.\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/) — publish events between independent actors without coupling machine definitions.\n [ripple](/ripple/) — bridge actor snapshots into a reactive graph when you need fine grained rendering.\n [ward](/ward/) — call authorization predicates from transition guards.\n\n</div>\n\n<! markdownlint enable md025 md033 md060 >\n",
|
|
162
|
-
"api": " \ntitle: clockwork — api reference\ndescription: reference for clockwork machine definitions, actors, devtools, and types.\n \n\n[[toc]]\n\n## api overview\n\n| symbol | purpose | execution mode | common gotcha |\n| | | | |\n| `definemachine()` | compile a typed flat machine definition | sync | call the generic factory before supplying the definition |\n| `machine.transition()` | resolve a pure next snapshot | sync | does not run effects, invokes, or timers |\n| `machine.createactor()` | create a runtime owner | sync | fresh and restored actors have different entry behavior |\n| `actor.send()` | dispatch an event | sync | returns `void`; re entrant events queue internally |\n| `debugactor()` | observe committed snapshots | sync | observes only; it does not trace sends or errors |\n| `clockworkerror` | report definition and snapshot validation failures | sync | use `code`, not message text |\n\n## package entry points\n\n| import | purpose |\n| | |\n| `@vielzeug/clockwork` | machine compiler, actor runtime, errors, and types |\n| `@vielzeug/clockwork/devtools` | opt in snapshot observation through `debugactor()` |\n\n## core functions\n\n### `definemachine()`\n\n```ts\nfunction definemachine<\n context extends record<string, unknown> = record<string, never>,\n event extends machineevent = machineevent,\n>(): <state extends string>(definition: machineconfig<state, context, event>) => machine<state, context, event>;\n```\n\nreturns a factory that validates and compiles a typed flat machine definition. context must be a non array record. omit `context` only when the context type has no keys.\n\n**returns:** a definition function that returns `machine`.\n\n**example:**\n\n```ts\nimport { definemachine } from '@vielzeug/clockwork';\n\ntype event = { type: 'start' };\n\nconst machine = definemachine<record<string, never>, event>()({\n initial: 'idle',\n states: { idle: { on: { start: { target: 'running' } } }, running: {} },\n});\n```\n\nthrows `clockworkerror` when a definition has an invalid context, initial state, target, transition, effect, invoke, or timer delay.\n\n \n\n### `debugactor()`\n\n```ts\nfunction debugactor<state extends string, context extends record<string, unknown>, event extends machineevent>(\n actor: actor<state, context, event>,\n options?: debugactoroptions<state, context>,\n): () => void;\n```\n\nsubscribes to committed actor snapshots and logs each one with `console.debug` by default. it does not modify actor behavior and does not observe dispatched events or runtime errors.\n\n**returns:** an unsubscribe cleanup function.\n\n**example:**\n\n```ts\nimport { definemachine } from '@vielzeug/clockwork';\nimport { debugactor } from '@vielzeug/clockwork/devtools';\n\nconst machine = definemachine<record<string, never>, { type: 'next' }>()({\n initial: 'idle',\n states: { idle: { on: { next: { target: 'idle' } } } },\n});\n\nconst actor = machine.createactor();\nconst stopdebugging = debugactor(actor);\nactor.send({ type: 'next' });\nstopdebugging();\nactor.dispose();\n```\n\n## machine methods\n\n### `machine.transition()`\n\n```ts\ntransition(\n snapshot: machinesnapshot<state, context>,\n event: event,\n): transitionresult<state, context>;\n```\n\nresolves a snapshot for one user event without actor runtime work.\n\n| parameter | type | description |\n| | | |\n| `snapshot` | `machinesnapshot<state, context>` | input state and context |\n| `event` | `event` | user event to evaluate |\n\n**returns:** a `transitionresult` with `transition` or `ignored` type.\n\n**example:**\n\n```ts\nconst result = machine.transition(machine.initialsnapshot, { type: 'start' });\n```\n\n \n\n### `machine.can()`\n\n```ts\ncan(snapshot: machinesnapshot<state, context>, event: event): boolean;\n```\n\nreturns whether a transition exists and its guard passes.\n\n**returns:** `true` when the supplied snapshot accepts the event.\n\n \n\n### `machine.createactor()`\n\n```ts\ncreateactor(options?: actoroptions<state, context, event>): actor<state, context, event>;\n```\n\ncreates an independent actor for event dispatch, timers, invokes, effects, subscriptions, and disposal. a fresh actor starts the initial state's entry effects and resources. an actor restored with `options.snapshot` starts only the restored state's resources: invokes and timers, not entry effects.\n\n| parameter | type | description |\n| | | |\n| `options.snapshot` | `machinesnapshot<state, context>` | optional restored actor snapshot |\n| `options.maxtransitions` | `number` | positive queued transition limit for one synchronous flush |\n| `options.onerror` | `(error, context) => 'continue' \\| 'dispose'` | explicit disposition for runtime failures |\n\n**returns:** disposable `actor`.\n\n**example:**\n\n```ts\nconst actor = machine.createactor({\n onerror(error, { phase, state }) {\n console.error(phase, state, error);\n return 'continue';\n },\n snapshot: { context: {}, state: 'idle' },\n});\n```\n\n## actor methods\n\n### `actor.send()`\n\n```ts\nsend(event: event): void;\n```\n\ndispatches a user event to the current actor state. events sent while the actor is processing queue and flush synchronously; sends to a disposed actor are ignored. for active actors, malformed events without a string `type` log a development warning and are ignored. valid but unhandled event types are ignored without a warning. use `actor.snapshot` after sending to read the current snapshot.\n\n**returns:** nothing.\n\n \n\n### `actor.can()`\n\n```ts\ncan(event: event): boolean;\n```\n\nreturns whether the current actor snapshot accepts an event. returns `false` after disposal.\n\n**returns:** boolean transition availability.\n\n \n\n### `actor.subscribe()`\n\n```ts\nsubscribe(listener: (snapshot: machinesnapshot<state, context>) => void): () => void;\n```\n\nregisters a listener for committed snapshots. the listener does not run immediately.\n\n**returns:** an unsubscribe function.\n\n \n\n### `actor.dispose()`\n\n```ts\ndispose(): void;\n[symbol.dispose](): void;\n```\n\ncancels timers and invokes, clears queued events and listeners, and aborts `disposalsignal`.\n\n**returns:** nothing. idempotent.\n\n## types\n\n### `machineevent`\n\n```ts\ntype machineevent = { readonly type: string };\n```\n\nbase constraint for event unions.\n\n### `eventtype<event>` and `eventbytype<event, type>`\n\n```ts\ntype eventtype<event extends machineevent> = event['type'] & string;\n\ntype eventbytype<event extends machineevent, type extends eventtype<event>> =\n extract<event, { type: type }>;\n```\n\nextract event type names and a matching event from an event union.\n\n### `machinesnapshot<state, context>`\n\n```ts\ntype machinesnapshot<state extends string, context extends record<string, unknown>> = {\n readonly context: readonly<context>;\n readonly state: state;\n};\n```\n\nthe plain readonly snapshot value used by machines and actors. readonly is a typescript contract; clockwork does not copy or freeze snapshots at runtime.\n\n### `guard<context, event>` and `reducer<context, event>`\n\n```ts\ntype guard<context extends record<string, unknown>, event> = (args: {\n readonly context: readonly<context>;\n readonly event: event;\n}) => boolean;\n\ntype reducer<context extends record<string, unknown>, event> = (args: {\n readonly context: readonly<context>;\n readonly event: event;\n}) => context;\n```\n\na guard selects a transition. a reducer returns replacement context, which must be a non array record.\n\n### `effectargs<context, event>` and `effect<context, event>`\n\n```ts\ntype effectargs<context extends record<string, unknown>, event extends machineevent> = {\n readonly context: readonly<context>;\n readonly event: event | undefined;\n readonly send: (event: event) => void;\n readonly signal: abortsignal;\n};\n\ntype effect<context extends record<string, unknown>, event extends machineevent> =\n (args: effectargs<context, event>) => void;\n```\n\npost commit effects receive `undefined` for initial entry and actor timer transitions. they cannot update machine context directly.\n\n### `transition<state, context, event, type>` and `transitioninput`\n\n```ts\ntype transition<\n state extends string,\n context extends record<string, unknown>,\n event extends machineevent,\n type extends eventtype<event> = eventtype<event>,\n> = {\n readonly effects?: readonly effect<context, event>[];\n readonly guard?: guard<context, eventbytype<event, type>>;\n readonly reduce?: reducer<context, eventbytype<event, type>>;\n readonly target: state;\n};\n\ntype transitioninput<\n state extends string,\n context extends record<string, unknown>,\n event extends machineevent,\n type extends eventtype<event> = eventtype<event>,\n> = transition<state, context, event, type> | readonly transition<state, context, event, type>[];\n```\n\nan ordered transition array selects the first guard that passes.\n\n### `after<state, context, event>`\n\n```ts\ntype after<state extends string, context extends record<string, unknown>, event extends machineevent> = {\n readonly delay: number;\n readonly effects?: readonly effect<context, event>[];\n readonly guard?: guard<context, event | undefined>;\n readonly reduce?: reducer<context, event | undefined>;\n readonly target: state;\n};\n```\n\na delayed state transition. its guard and reducer receive `event: undefined`.\n\n### `invokeargs<context, event>` and `invoke<context, event, result>`\n\n```ts\ntype invokeargs<context extends record<string, unknown>, event extends machineevent> = {\n readonly context: readonly<context>;\n readonly event: event | undefined;\n readonly signal: abortsignal;\n};\n\ntype invoke<context extends record<string, unknown>, event extends machineevent, result = unknown> = {\n readonly ondone?: (args: { readonly context: readonly<context>; readonly result: result }) => event;\n readonly onerror?: (args: { readonly context: readonly<context>; readonly error: unknown }) => event;\n readonly src: (args: invokeargs<context, event>) => promise<result> | result;\n};\n```\n\nan actor owned task started on state entry. `event` is the triggering event or `undefined` for initial or restored resources.\n\n### `statenode<state, context, event>` and `machineconfig<state, context, event>`\n\n```ts\ntype statenode<state extends string, context extends record<string, unknown>, event extends machineevent> = {\n readonly after?: readonly after<state, context, event>[];\n readonly entry?: readonly effect<context, event>[];\n readonly exit?: readonly effect<context, event>[];\n readonly invoke?: readonly invoke<context, event>[];\n readonly on?: partial<{ [type in eventtype<event>]: transitioninput<state, context, event, type> }>;\n};\n\ntype machineconfig<state extends string, context extends record<string, unknown>, event extends machineevent> =\n (keyof context extends never ? { readonly context?: context } : { readonly context: context }) & {\n readonly initial: state;\n readonly states: record<state, statenode<state, context, event>>;\n };\n```\n\na flat machine definition. state nodes cannot contain child states.\n\n### `transitionresult<state, context>`\n\n```ts\ntype transitionresult<state extends string, context extends record<string, unknown>> = {\n readonly snapshot: machinesnapshot<state, context>;\n readonly type: 'ignored' | 'transition';\n};\n```\n\nresult of a pure user event transition. it contains no effect plan.\n\n### `actorerrorcontext<state, event>`, `actorerrordisposition`, and `actoroptions<state, context, event>`\n\n```ts\ntype actorerrorcontext<state extends string, event extends machineevent> = {\n readonly event?: event;\n readonly phase: 'effect' | 'invoke' | 'subscriber' | 'transition';\n readonly state: state;\n};\n\ntype actorerrordisposition = 'continue' | 'dispose';\n\ntype actoroptions<state extends string, context extends record<string, unknown>, event extends machineevent> = {\n readonly maxtransitions?: number;\n readonly onerror?: (error: unknown, context: actorerrorcontext<state, event>) => actorerrordisposition;\n readonly snapshot?: machinesnapshot<state, context>;\n};\n```\n\n`onerror` must explicitly return `'continue'` to keep the actor alive or `'dispose'` to end it. without an error handler, clockwork disposes the actor silently.\n\n### `actor<state, context, event>`\n\n```ts\ntype actor<state extends string, context extends record<string, unknown>, event extends machineevent> = {\n [symbol.dispose](): void;\n can(event: event): boolean;\n readonly disposalsignal: abortsignal;\n dispose(): void;\n readonly disposed: boolean;\n send(event: event): void;\n readonly snapshot: machinesnapshot<state, context>;\n subscribe(listener: (snapshot: machinesnapshot<state, context>) => void): () => void;\n};\n```\n\nan actor's `snapshot` is the current plain readonly snapshot.\n\n### `machine<state, context, event>`\n\n```ts\ntype machine<state extends string, context extends record<string, unknown>, event extends machineevent> = {\n can(snapshot: machinesnapshot<state, context>, event: event): boolean;\n createactor(options?: actoroptions<state, context, event>): actor<state, context, event>;\n readonly initialsnapshot: machinesnapshot<state, context>;\n transition(snapshot: machinesnapshot<state, context>, event: event): transitionresult<state, context>;\n};\n```\n\na compiled, reusable machine. its transition lookup is map based, so unknown or poison event names such as `__proto__` are safely ignored when no transition exists.\n\n### `debugactoroptions<state, context>`\n\n```ts\ntype debugactoroptions<state extends string, context extends record<string, unknown>> = {\n readonly logger?: (snapshot: machinesnapshot<state, context>) => void;\n};\n```\n\noptional logger for `debugactor()`. logger failures are ignored so observation cannot affect the actor's error policy.\n\n## errors\n\n### `clockworkerrorcode`\n\n```ts\ntype clockworkerrorcode =\n | 'invalid_after_delay'\n | 'invalid_context'\n | 'invalid_definition'\n | 'invalid_effect'\n | 'invalid_initial_state'\n | 'invalid_invoke'\n | 'invalid_max_transitions'\n | 'invalid_snapshot_state'\n | 'invalid_transition'\n | 'invalid_transition_limit'\n | 'unknown_target';\n```\n\nstable machine readable code identifying a clockwork failure category.\n\n### `clockworkerror`\n\n`clockworkerror` reports invalid definitions, contexts, snapshots, and actor transition limits. it has `code`, `details`, and standard `error` fields. use `instanceof clockworkerror` to narrow an unknown error.\n\n```ts\nif (error instanceof clockworkerror) {\n console.error(error.code, error.details);\n}\n```\n",
|
|
163
|
-
"usage": " \ntitle: clockwork — usage guide\ndescription: build deterministic state machines with pure transitions and actor owned runtime work.\n \n\n[[toc]]\n\n## basic usage\n\ncall `definemachine<context, event>()` first to bind context and event types; the returned definition function infers state labels from `states`. context is optional only when its type has no keys. create one actor for each independently owned workflow.\n\n```ts\nimport { definemachine } from '@vielzeug/clockwork';\n\ntype event = { type: 'toggle' };\n\nconst machine = definemachine<record<string, never>, event>()({\n initial: 'on',\n states: {\n off: { on: { toggle: { target: 'on' } } },\n on: { on: { toggle: { target: 'off' } } },\n },\n});\n\nconst actor = machine.createactor();\nactor.send({ type: 'toggle' });\nconsole.log(actor.snapshot.state); // 'off'\nactor.dispose();\n```\n\ndispose actors when a feature, request, or test ends. you can use `using` when the surrounding runtime supports `symbol.dispose`.\n\n```ts\nusing actor = machine.createactor();\nactor.send({ type: 'toggle' });\n```\n\n## context reducers\n\na reducer receives readonly context and returns the next context. clockwork does not copy or freeze context at runtime, so do not mutate data that other code may retain.\n\n```ts\ntype event = { type: 'dec' } | { type: 'inc' } | { type: 'reset' };\n\nconst counter = definemachine<{ count: number }, event>()({\n context: { count: 0 },\n initial: 'idle',\n states: {\n idle: {\n on: {\n dec: { reduce: ({ context }) => ({ count: context.count 1 }), target: 'idle' },\n inc: { reduce: ({ context }) => ({ count: context.count + 1 }), target: 'idle' },\n reset: { reduce: () => ({ count: 0 }), target: 'idle' },\n },\n },\n },\n});\n```\n\nkeep reducers pure. make nested copies yourself when nested data changes.\n\n```ts\nsave: {\n reduce: ({ context, event }) => ({\n ...context,\n profile: { ...context.profile, name: event.name },\n }),\n target: 'editing',\n}\n```\n\n## guards\n\nguards decide whether a transition can run. they receive readonly context and the matching event. for several choices, use an ordered array; the first passing guard wins.\n\n```ts\npay: [\n {\n guard: ({ context }) => context.balance >= context.total,\n reduce: ({ context }) => ({ ...context, balance: context.balance context.total }),\n target: 'success',\n },\n { target: 'insufficientfunds' },\n]\n```\n\ncall `actor.can(event)` for the current actor snapshot or `machine.can(snapshot, event)` for an arbitrary snapshot.\n\n## pure transitions\n\n`machine.transition()` enables isolated unit tests and decision uis. it returns the unchanged snapshot with `type: 'ignored'` when no transition matches; it does not expose or run effects.\n\n```ts\nconst result = counter.transition(\n { context: { count: 3 }, state: 'idle' },\n { type: 'inc' },\n);\n\nif (result.type === 'transition') {\n console.log(result.snapshot.context.count); // 4\n}\n```\n\n## effects\n\nentry, exit, and transition effects run only through an actor. the actor commits, establishes the new state's timers and invokes, notifies subscribers, then runs exit, transition, and entry effects. effects cannot change context; send a regular event for another state change.\n\n```ts\ntype workflowevent = { type: 'submit' };\nconst workflow = definemachine<{ orderid: string }, workflowevent>()({\n context: { orderid: '' },\n initial: 'draft',\n states: {\n draft: {\n on: {\n submit: {\n effects: [({ context }) => console.debug('submitted', context)],\n target: 'submitted',\n },\n },\n },\n submitted: { entry: [({ context }) => console.log(`submitted ${context.orderid}`)] },\n },\n});\n```\n\neffects receive `context`, the triggering `event` (or `undefined` for initial entry), actor `send`, and the actor lifetime `signal`.\n\n## async invokes\n\ninvokes start on state entry. `src` gets readonly entry context, the triggering event or `undefined`, and an `abortsignal`. `ondone` or `onerror` map settlement to ordinary events. all invokes are cancelled when the actor exits the state or disposes.\n\n```ts\ntype loadevent =\n | { type: 'fetch' }\n | { items: string[]; type: 'success' }\n | { message: string; type: 'failure' }\n | { type: 'retry' };\n\nconst loader = definemachine<{ error: string; items: string[] }, loadevent>()({\n context: { error: '', items: [] },\n initial: 'idle',\n states: {\n idle: { on: { fetch: { target: 'loading' } } },\n loading: {\n invoke: [{\n src: async ({ signal }) => {\n const response = await fetch('/api/items', { signal });\n if (!response.ok) throw new error(`http ${response.status}`);\n return response.json() as promise<string[]>;\n },\n ondone: ({ result }) => ({ items: result, type: 'success' }),\n onerror: ({ error }) => ({ message: string(error), type: 'failure' }),\n }],\n on: {\n failure: { reduce: ({ event }) => ({ error: event.message, items: [] }), target: 'error' },\n success: { reduce: ({ event }) => ({ error: '', items: event.items }), target: 'ready' },\n },\n },\n ready: {},\n error: { on: { retry: { target: 'loading' } } },\n },\n});\n```\n\n## delayed transitions\n\n`after` starts timers on state entry and cancels them on exit or disposal. its guard and reducer receive `event: undefined`; a user event with `type: '$after'` remains a normal user event.\n\n```ts\ntype notificationevent = { type: 'dismiss' } | { message: string; type: 'show' };\nconst notification = definemachine<{ message: string }, notificationevent>()({\n context: { message: '' },\n initial: 'hidden',\n states: {\n hidden: { on: { show: { reduce: ({ event }) => ({ message: event.message }), target: 'visible' } } },\n visible: {\n after: [{ delay: 5_000, target: 'hidden' }],\n on: { dismiss: { target: 'hidden' } },\n },\n },\n});\n```\n\n## snapshot observation and persistence\n\n`actor.snapshot` is the current plain readonly snapshot; read it directly rather than calling a snapshot method. use `subscribe()` to integrate a state library or persist future committed snapshots. fresh actors run their initial entry effects and resources; restored actors start only the restored state's invokes and timers, not its entry effects.\n\n```ts\nconst stored = sessionstorage.getitem('wizard');\nconst actor = machine.createactor({\n snapshot: stored ? json.parse(stored) : undefined,\n});\n\nconst stopsaving = actor.subscribe((snapshot) => {\n sessionstorage.setitem('wizard', json.stringify(snapshot));\n});\n\nconsole.log(actor.snapshot);\nstopsaving();\nactor.dispose();\n```\n\nvalidate untrusted persisted data before passing it to `createactor()`. clockwork validates the restored state name but cannot validate application specific context fields.\n\n## error handling\n\nuse `onerror` to choose what happens after failures from transitions, effects, invokes, or subscribers. the context identifies the runtime phase and state; an event is present when one triggered the failure. return `'continue'` to keep the actor alive or `'dispose'` to end it.\n\n```ts\nconst actor = machine.createactor({\n onerror(error, { event, phase, state }) {\n console.error({ error, event, phase, state });\n return 'continue';\n },\n});\n```\n\nwithout `onerror`, an actor disposes silently. return `'dispose'` explicitly when an error handler logs an unrecoverable failure.\n\n## debugging\n\nuse opt in snapshot logging during development. `debugactor()` observes committed snapshots only; it does not trace dispatched events or runtime errors.\n\n```ts\nimport { debugactor } from '@vielzeug/clockwork/devtools';\n\nconst actor = machine.createactor();\nconst stopdebugging = debugactor(actor);\nactor.send({ type: 'next' });\nstopdebugging();\nactor.dispose();\n```\n\nfor richer inspection, subscribe to snapshots and record them in application devtools. clockwork intentionally has no internal trace buffer.\n\n## flat state maps\n\nclockwork has flat state ids. prefer explicit states such as `editingdraft` and `editingsaving`, or compose several actors when domains have independent lifecycles.\n\n## ssr\n\nreuse a compiled machine definition, but create and dispose an actor per request. never share an actor across concurrent requests.\n\n## testing\n\ntest deterministic state behavior through `machine.transition()`. create actors only for timers, invokes, effects, queueing, subscriptions, or disposal behavior.\n\n```ts\nimport { expect, test } from 'vitest';\n\ntest('increments without an actor', () => {\n const result = counter.transition(\n { context: { count: 2 }, state: 'idle' },\n { type: 'inc' },\n );\n\n expect(result).tomatchobject({\n snapshot: { context: { count: 3 }, state: 'idle' },\n type: 'transition',\n });\n});\n```\n\n## framework integration\n\nbridge the current actor snapshot into renderer state through one subscription. dispose that subscription with component lifecycle.\n\n::: code group\n\n```ts [react]\nimport { usesyncexternalstore } from 'react';\n\nfunction useactor<snapshot>(actor: { readonly snapshot: snapshot; subscribe(listener: (snapshot: snapshot) => void): () => void }) {\n return usesyncexternalstore(\n (notify) => actor.subscribe(() => notify()),\n () => actor.snapshot,\n );\n}\n```\n\n```ts [vue 3]\nimport { onunmounted, shallowref } from 'vue';\n\nconst snapshot = shallowref(actor.snapshot);\nconst stop = actor.subscribe((next) => (snapshot.value = next));\nonunmounted(stop);\n```\n\n```ts [svelte]\nimport { ondestroy } from 'svelte';\n\nlet snapshot = actor.snapshot;\nconst stop = actor.subscribe((next) => (snapshot = next));\nondestroy(stop);\n```\n\n:::\n\n## working with other vielzeug libraries\n\nuse herald when separate actors exchange application events. bridge clockwork snapshots into ripple only at a ui or application boundary.\n\n```ts\nimport { createbus } from '@vielzeug/herald';\n\nconst bus = createbus<{ refresh: void }>();\nbus.on('refresh', () => actor.send({ type: 'fetch' }));\n```\n\n## best practices\n\n define context and event unions with `definemachine<context, event>()`.\n return replacement context from reducers; do not rely on runtime copying or freezing.\n keep guards and reducers pure.\n use actors for effects, timers, invokes, subscriptions, and cancellation.\n read the current snapshot from `actor.snapshot`, not a wrapper value.\n validate persisted context before restoring a snapshot.\n dispose every actor at its ownership boundary.\n route runtime failures through `onerror` when the owner can recover.\n",
|
|
162
|
+
"api": " \ntitle: clockwork — api reference\ndescription: reference for clockwork machine definitions, actors, devtools, and types.\n \n\n[[toc]]\n\n## api overview\n\n| symbol | purpose | execution mode | common gotcha |\n| | | | |\n| `definemachine()` | compile a typed flat machine definition | sync | call the generic factory before supplying the definition |\n| `machine.transition()` | resolve a pure next snapshot | sync | does not run effects, invokes, or timers |\n| `machine.createactor()` | create a runtime owner | sync | fresh and restored actors have different entry behavior |\n| `actor.send()` | dispatch an event | sync | returns `void`; re entrant events queue internally |\n| `actor.subscribe()` | observe committed snapshots | sync | observes only; it does not trace sends or errors |\n| `clockworkerror` | report definition and snapshot validation failures | sync | use `code`, not message text |\n\n## package entry points\n\n| import | purpose |\n| | |\n| `@vielzeug/clockwork` | machine compiler, actor runtime, errors, and types |\n\n## core functions\n\n### `definemachine()`\n\n```ts\nfunction definemachine<\n context extends record<string, unknown> = record<string, never>,\n event extends machineevent = machineevent,\n>(): <state extends string>(definition: machineconfig<state, context, event>) => machine<state, context, event>;\n```\n\nreturns a factory that validates and compiles a typed flat machine definition. context must be a non array record. omit `context` only when the context type has no keys.\n\n**returns:** a definition function that returns `machine`.\n\n**example:**\n\n```ts\nimport { definemachine } from '@vielzeug/clockwork';\n\ntype event = { type: 'start' };\n\nconst machine = definemachine<record<string, never>, event>()({\n initial: 'idle',\n states: { idle: { on: { start: { target: 'running' } } }, running: {} },\n});\n```\n\nthrows `clockworkerror` when a definition has an invalid context, initial state, target, transition, effect, invoke, or timer delay.\n\n \n\n### `actor.subscribe()`\n\n```ts\nsubscribe(listener: (snapshot: actorsnapshot<state, context>) => void): () => void;\n```\n\nsubscribes to committed actor snapshots. returns an unsubscribe function. the listener receives the current snapshot immediately on subscribe, then on every committed transition. it does not observe dispatched events or runtime errors.\n\n**example:**\n\n```ts\nimport { definemachine } from '@vielzeug/clockwork';\n\nconst machine = definemachine<record<string, never>, { type: 'next' }>()({\n initial: 'idle',\n states: { idle: { on: { next: { target: 'idle' } } } },\n});\n\nconst actor = machine.createactor();\nconst stop = actor.subscribe((snapshot) => console.debug(snapshot));\nactor.send({ type: 'next' });\nstop();\nactor.dispose();\n```\n\n## machine methods\n\n### `machine.transition()`\n\n```ts\ntransition(\n snapshot: machinesnapshot<state, context>,\n event: event,\n): transitionresult<state, context>;\n```\n\nresolves a snapshot for one user event without actor runtime work.\n\n| parameter | type | description |\n| | | |\n| `snapshot` | `machinesnapshot<state, context>` | input state and context |\n| `event` | `event` | user event to evaluate |\n\n**returns:** a `transitionresult` with `transition` or `ignored` type.\n\n**example:**\n\n```ts\nconst result = machine.transition(machine.initialsnapshot, { type: 'start' });\n```\n\n \n\n### `machine.can()`\n\n```ts\ncan(snapshot: machinesnapshot<state, context>, event: event): boolean;\n```\n\nreturns whether a transition exists and its guard passes.\n\n**returns:** `true` when the supplied snapshot accepts the event.\n\n \n\n### `machine.createactor()`\n\n```ts\ncreateactor(options?: actoroptions<state, context, event>): actor<state, context, event>;\n```\n\ncreates an independent actor for event dispatch, timers, invokes, effects, subscriptions, and disposal. a fresh actor starts the initial state's entry effects and resources. an actor restored with `options.snapshot` starts only the restored state's resources: invokes and timers, not entry effects.\n\n| parameter | type | description |\n| | | |\n| `options.snapshot` | `machinesnapshot<state, context>` | optional restored actor snapshot |\n| `options.maxtransitions` | `number` | positive queued transition limit for one synchronous flush |\n| `options.onerror` | `(error, context) => 'continue' \\| 'dispose'` | explicit disposition for runtime failures |\n\n**returns:** disposable `actor`.\n\n**example:**\n\n```ts\nconst actor = machine.createactor({\n onerror(error, { phase, state }) {\n console.error(phase, state, error);\n return 'continue';\n },\n snapshot: { context: {}, state: 'idle' },\n});\n```\n\n## actor methods\n\n### `actor.send()`\n\n```ts\nsend(event: event): void;\n```\n\ndispatches a user event to the current actor state. events sent while the actor is processing queue and flush synchronously; sends to a disposed actor are ignored. for active actors, malformed events without a string `type` log a development warning and are ignored. valid but unhandled event types are ignored without a warning. use `actor.snapshot` after sending to read the current snapshot.\n\n**returns:** nothing.\n\n \n\n### `actor.can()`\n\n```ts\ncan(event: event): boolean;\n```\n\nreturns whether the current actor snapshot accepts an event. returns `false` after disposal.\n\n**returns:** boolean transition availability.\n\n \n\n### `actor.subscribe()`\n\n```ts\nsubscribe(listener: (snapshot: machinesnapshot<state, context>) => void): () => void;\n```\n\nregisters a listener for committed snapshots. the listener does not run immediately.\n\n**returns:** an unsubscribe function.\n\n \n\n### `actor.dispose()`\n\n```ts\ndispose(): void;\n[symbol.dispose](): void;\n```\n\ncancels timers and invokes, clears queued events and listeners, and aborts `disposalsignal`.\n\n**returns:** nothing. idempotent.\n\n## types\n\n### `machineevent`\n\n```ts\ntype machineevent = { readonly type: string };\n```\n\nbase constraint for event unions.\n\n### `eventtype<event>` and `eventbytype<event, type>`\n\n```ts\ntype eventtype<event extends machineevent> = event['type'] & string;\n\ntype eventbytype<event extends machineevent, type extends eventtype<event>> =\n extract<event, { type: type }>;\n```\n\nextract event type names and a matching event from an event union.\n\n### `machinesnapshot<state, context>`\n\n```ts\ntype machinesnapshot<state extends string, context extends record<string, unknown>> = {\n readonly context: readonly<context>;\n readonly state: state;\n};\n```\n\nthe plain readonly snapshot value used by machines and actors. readonly is a typescript contract; clockwork does not copy or freeze snapshots at runtime.\n\n### `guard<context, event>` and `reducer<context, event>`\n\n```ts\ntype guard<context extends record<string, unknown>, event> = (args: {\n readonly context: readonly<context>;\n readonly event: event;\n}) => boolean;\n\ntype reducer<context extends record<string, unknown>, event> = (args: {\n readonly context: readonly<context>;\n readonly event: event;\n}) => context;\n```\n\na guard selects a transition. a reducer returns replacement context, which must be a non array record.\n\n### `effectargs<context, event>` and `effect<context, event>`\n\n```ts\ntype effectargs<context extends record<string, unknown>, event extends machineevent> = {\n readonly context: readonly<context>;\n readonly event: event | undefined;\n readonly send: (event: event) => void;\n readonly signal: abortsignal;\n};\n\ntype effect<context extends record<string, unknown>, event extends machineevent> =\n (args: effectargs<context, event>) => void;\n```\n\npost commit effects receive `undefined` for initial entry and actor timer transitions. they cannot update machine context directly.\n\n### `transition<state, context, event, type>` and `transitioninput`\n\n```ts\ntype transition<\n state extends string,\n context extends record<string, unknown>,\n event extends machineevent,\n type extends eventtype<event> = eventtype<event>,\n> = {\n readonly effects?: readonly effect<context, event>[];\n readonly guard?: guard<context, eventbytype<event, type>>;\n readonly reduce?: reducer<context, eventbytype<event, type>>;\n readonly target: state;\n};\n\ntype transitioninput<\n state extends string,\n context extends record<string, unknown>,\n event extends machineevent,\n type extends eventtype<event> = eventtype<event>,\n> = transition<state, context, event, type> | readonly transition<state, context, event, type>[];\n```\n\nan ordered transition array selects the first guard that passes.\n\n### `after<state, context, event>`\n\n```ts\ntype after<state extends string, context extends record<string, unknown>, event extends machineevent> = {\n readonly delay: number;\n readonly effects?: readonly effect<context, event>[];\n readonly guard?: guard<context, event | undefined>;\n readonly reduce?: reducer<context, event | undefined>;\n readonly target: state;\n};\n```\n\na delayed state transition. its guard and reducer receive `event: undefined`.\n\n### `invokeargs<context, event>` and `invoke<context, event, result>`\n\n```ts\ntype invokeargs<context extends record<string, unknown>, event extends machineevent> = {\n readonly context: readonly<context>;\n readonly event: event | undefined;\n readonly signal: abortsignal;\n};\n\ntype invoke<context extends record<string, unknown>, event extends machineevent, result = unknown> = {\n readonly ondone?: (args: { readonly context: readonly<context>; readonly result: result }) => event;\n readonly onerror?: (args: { readonly context: readonly<context>; readonly error: unknown }) => event;\n readonly src: (args: invokeargs<context, event>) => promise<result> | result;\n};\n```\n\nan actor owned task started on state entry. `event` is the triggering event or `undefined` for initial or restored resources.\n\n### `statenode<state, context, event>` and `machineconfig<state, context, event>`\n\n```ts\ntype statenode<state extends string, context extends record<string, unknown>, event extends machineevent> = {\n readonly after?: readonly after<state, context, event>[];\n readonly entry?: readonly effect<context, event>[];\n readonly exit?: readonly effect<context, event>[];\n readonly invoke?: readonly invoke<context, event>[];\n readonly on?: partial<{ [type in eventtype<event>]: transitioninput<state, context, event, type> }>;\n};\n\ntype machineconfig<state extends string, context extends record<string, unknown>, event extends machineevent> =\n (keyof context extends never ? { readonly context?: context } : { readonly context: context }) & {\n readonly initial: state;\n readonly states: record<state, statenode<state, context, event>>;\n };\n```\n\na flat machine definition. state nodes cannot contain child states.\n\n### `transitionresult<state, context>`\n\n```ts\ntype transitionresult<state extends string, context extends record<string, unknown>> = {\n readonly snapshot: machinesnapshot<state, context>;\n readonly type: 'ignored' | 'transition';\n};\n```\n\nresult of a pure user event transition. it contains no effect plan.\n\n### `actorerrorcontext<state, event>`, `actorerrordisposition`, and `actoroptions<state, context, event>`\n\n```ts\ntype actorerrorcontext<state extends string, event extends machineevent> = {\n readonly event?: event;\n readonly phase: 'effect' | 'invoke' | 'subscriber' | 'transition';\n readonly state: state;\n};\n\ntype actorerrordisposition = 'continue' | 'dispose';\n\ntype actoroptions<state extends string, context extends record<string, unknown>, event extends machineevent> = {\n readonly maxtransitions?: number;\n readonly onerror?: (error: unknown, context: actorerrorcontext<state, event>) => actorerrordisposition;\n readonly snapshot?: machinesnapshot<state, context>;\n};\n```\n\n`onerror` must explicitly return `'continue'` to keep the actor alive or `'dispose'` to end it. without an error handler, clockwork disposes the actor silently.\n\n### `actor<state, context, event>`\n\n```ts\ntype actor<state extends string, context extends record<string, unknown>, event extends machineevent> = {\n [symbol.dispose](): void;\n can(event: event): boolean;\n readonly disposalsignal: abortsignal;\n dispose(): void;\n readonly disposed: boolean;\n send(event: event): void;\n readonly snapshot: machinesnapshot<state, context>;\n subscribe(listener: (snapshot: machinesnapshot<state, context>) => void): () => void;\n};\n```\n\nan actor's `snapshot` is the current plain readonly snapshot.\n\n### `machine<state, context, event>`\n\n```ts\ntype machine<state extends string, context extends record<string, unknown>, event extends machineevent> = {\n can(snapshot: machinesnapshot<state, context>, event: event): boolean;\n createactor(options?: actoroptions<state, context, event>): actor<state, context, event>;\n readonly initialsnapshot: machinesnapshot<state, context>;\n transition(snapshot: machinesnapshot<state, context>, event: event): transitionresult<state, context>;\n};\n```\n\na compiled, reusable machine. its transition lookup is map based, so unknown or poison event names such as `__proto__` are safely ignored when no transition exists.\n\n## errors\n\n### `clockworkerrorcode`\n\n```ts\ntype clockworkerrorcode =\n | 'invalid_after_delay'\n | 'invalid_context'\n | 'invalid_definition'\n | 'invalid_effect'\n | 'invalid_initial_state'\n | 'invalid_invoke'\n | 'invalid_max_transitions'\n | 'invalid_snapshot_state'\n | 'invalid_transition'\n | 'invalid_transition_limit'\n | 'unknown_target';\n```\n\nstable machine readable code identifying a clockwork failure category.\n\n### `clockworkerror`\n\n`clockworkerror` reports invalid definitions, contexts, snapshots, and actor transition limits. it has `code`, `details`, and standard `error` fields. use `instanceof clockworkerror` to narrow an unknown error.\n\n```ts\nif (error instanceof clockworkerror) {\n console.error(error.code, error.details);\n}\n```\n",
|
|
163
|
+
"usage": " \ntitle: clockwork — usage guide\ndescription: build deterministic state machines with pure transitions and actor owned runtime work.\n \n\n[[toc]]\n\n## basic usage\n\ncall `definemachine<context, event>()` first to bind context and event types; the returned definition function infers state labels from `states`. context is optional only when its type has no keys. create one actor for each independently owned workflow.\n\n```ts\nimport { definemachine } from '@vielzeug/clockwork';\n\ntype event = { type: 'toggle' };\n\nconst machine = definemachine<record<string, never>, event>()({\n initial: 'on',\n states: {\n off: { on: { toggle: { target: 'on' } } },\n on: { on: { toggle: { target: 'off' } } },\n },\n});\n\nconst actor = machine.createactor();\nactor.send({ type: 'toggle' });\nconsole.log(actor.snapshot.state); // 'off'\nactor.dispose();\n```\n\ndispose actors when a feature, request, or test ends. you can use `using` when the surrounding runtime supports `symbol.dispose`.\n\n```ts\nusing actor = machine.createactor();\nactor.send({ type: 'toggle' });\n```\n\n## context reducers\n\na reducer receives readonly context and returns the next context. clockwork does not copy or freeze context at runtime, so do not mutate data that other code may retain.\n\n```ts\ntype event = { type: 'dec' } | { type: 'inc' } | { type: 'reset' };\n\nconst counter = definemachine<{ count: number }, event>()({\n context: { count: 0 },\n initial: 'idle',\n states: {\n idle: {\n on: {\n dec: { reduce: ({ context }) => ({ count: context.count 1 }), target: 'idle' },\n inc: { reduce: ({ context }) => ({ count: context.count + 1 }), target: 'idle' },\n reset: { reduce: () => ({ count: 0 }), target: 'idle' },\n },\n },\n },\n});\n```\n\nkeep reducers pure. make nested copies yourself when nested data changes.\n\n```ts\nsave: {\n reduce: ({ context, event }) => ({\n ...context,\n profile: { ...context.profile, name: event.name },\n }),\n target: 'editing',\n}\n```\n\n## guards\n\nguards decide whether a transition can run. they receive readonly context and the matching event. for several choices, use an ordered array; the first passing guard wins.\n\n```ts\npay: [\n {\n guard: ({ context }) => context.balance >= context.total,\n reduce: ({ context }) => ({ ...context, balance: context.balance context.total }),\n target: 'success',\n },\n { target: 'insufficientfunds' },\n]\n```\n\ncall `actor.can(event)` for the current actor snapshot or `machine.can(snapshot, event)` for an arbitrary snapshot.\n\n## pure transitions\n\n`machine.transition()` enables isolated unit tests and decision uis. it returns the unchanged snapshot with `type: 'ignored'` when no transition matches; it does not expose or run effects.\n\n```ts\nconst result = counter.transition(\n { context: { count: 3 }, state: 'idle' },\n { type: 'inc' },\n);\n\nif (result.type === 'transition') {\n console.log(result.snapshot.context.count); // 4\n}\n```\n\n## effects\n\nentry, exit, and transition effects run only through an actor. the actor commits, establishes the new state's timers and invokes, notifies subscribers, then runs exit, transition, and entry effects. effects cannot change context; send a regular event for another state change.\n\n```ts\ntype workflowevent = { type: 'submit' };\nconst workflow = definemachine<{ orderid: string }, workflowevent>()({\n context: { orderid: '' },\n initial: 'draft',\n states: {\n draft: {\n on: {\n submit: {\n effects: [({ context }) => console.debug('submitted', context)],\n target: 'submitted',\n },\n },\n },\n submitted: { entry: [({ context }) => console.log(`submitted ${context.orderid}`)] },\n },\n});\n```\n\neffects receive `context`, the triggering `event` (or `undefined` for initial entry), actor `send`, and the actor lifetime `signal`.\n\n## async invokes\n\ninvokes start on state entry. `src` gets readonly entry context, the triggering event or `undefined`, and an `abortsignal`. `ondone` or `onerror` map settlement to ordinary events. all invokes are cancelled when the actor exits the state or disposes.\n\n```ts\ntype loadevent =\n | { type: 'fetch' }\n | { items: string[]; type: 'success' }\n | { message: string; type: 'failure' }\n | { type: 'retry' };\n\nconst loader = definemachine<{ error: string; items: string[] }, loadevent>()({\n context: { error: '', items: [] },\n initial: 'idle',\n states: {\n idle: { on: { fetch: { target: 'loading' } } },\n loading: {\n invoke: [{\n src: async ({ signal }) => {\n const response = await fetch('/api/items', { signal });\n if (!response.ok) throw new error(`http ${response.status}`);\n return response.json() as promise<string[]>;\n },\n ondone: ({ result }) => ({ items: result, type: 'success' }),\n onerror: ({ error }) => ({ message: string(error), type: 'failure' }),\n }],\n on: {\n failure: { reduce: ({ event }) => ({ error: event.message, items: [] }), target: 'error' },\n success: { reduce: ({ event }) => ({ error: '', items: event.items }), target: 'ready' },\n },\n },\n ready: {},\n error: { on: { retry: { target: 'loading' } } },\n },\n});\n```\n\n## delayed transitions\n\n`after` starts timers on state entry and cancels them on exit or disposal. its guard and reducer receive `event: undefined`; a user event with `type: '$after'` remains a normal user event.\n\n```ts\ntype notificationevent = { type: 'dismiss' } | { message: string; type: 'show' };\nconst notification = definemachine<{ message: string }, notificationevent>()({\n context: { message: '' },\n initial: 'hidden',\n states: {\n hidden: { on: { show: { reduce: ({ event }) => ({ message: event.message }), target: 'visible' } } },\n visible: {\n after: [{ delay: 5_000, target: 'hidden' }],\n on: { dismiss: { target: 'hidden' } },\n },\n },\n});\n```\n\n## snapshot observation and persistence\n\n`actor.snapshot` is the current plain readonly snapshot; read it directly rather than calling a snapshot method. use `subscribe()` to integrate a state library or persist future committed snapshots. fresh actors run their initial entry effects and resources; restored actors start only the restored state's invokes and timers, not its entry effects.\n\n```ts\nconst stored = sessionstorage.getitem('wizard');\nconst actor = machine.createactor({\n snapshot: stored ? json.parse(stored) : undefined,\n});\n\nconst stopsaving = actor.subscribe((snapshot) => {\n sessionstorage.setitem('wizard', json.stringify(snapshot));\n});\n\nconsole.log(actor.snapshot);\nstopsaving();\nactor.dispose();\n```\n\nvalidate untrusted persisted data before passing it to `createactor()`. clockwork validates the restored state name but cannot validate application specific context fields.\n\n## error handling\n\nuse `onerror` to choose what happens after failures from transitions, effects, invokes, or subscribers. the context identifies the runtime phase and state; an event is present when one triggered the failure. return `'continue'` to keep the actor alive or `'dispose'` to end it.\n\n```ts\nconst actor = machine.createactor({\n onerror(error, { event, phase, state }) {\n console.error({ error, event, phase, state });\n return 'continue';\n },\n});\n```\n\nwithout `onerror`, an actor disposes silently. return `'dispose'` explicitly when an error handler logs an unrecoverable failure.\n\n## debugging\n\nuse `actor.subscribe()` to observe committed snapshots during development. it observes snapshots only; it does not trace dispatched events or runtime errors.\n\n```ts\nconst actor = machine.createactor();\nconst stop = actor.subscribe((snapshot) => console.debug(snapshot));\nactor.send({ type: 'next' });\nstop();\nactor.dispose();\n```\n\nfor richer inspection, route snapshots to application devtools. clockwork intentionally has no internal trace buffer.\n\n## flat state maps\n\nclockwork has flat state ids. prefer explicit states such as `editingdraft` and `editingsaving`, or compose several actors when domains have independent lifecycles.\n\n## ssr\n\nreuse a compiled machine definition, but create and dispose an actor per request. never share an actor across concurrent requests.\n\n## testing\n\ntest deterministic state behavior through `machine.transition()`. create actors only for timers, invokes, effects, queueing, subscriptions, or disposal behavior.\n\n```ts\nimport { expect, test } from 'vitest';\n\ntest('increments without an actor', () => {\n const result = counter.transition(\n { context: { count: 2 }, state: 'idle' },\n { type: 'inc' },\n );\n\n expect(result).tomatchobject({\n snapshot: { context: { count: 3 }, state: 'idle' },\n type: 'transition',\n });\n});\n```\n\n## framework integration\n\nbridge the current actor snapshot into renderer state through one subscription. dispose that subscription with component lifecycle.\n\n::: code group\n\n```ts [react]\nimport { usesyncexternalstore } from 'react';\n\nfunction useactor<snapshot>(actor: { readonly snapshot: snapshot; subscribe(listener: (snapshot: snapshot) => void): () => void }) {\n return usesyncexternalstore(\n (notify) => actor.subscribe(() => notify()),\n () => actor.snapshot,\n );\n}\n```\n\n```ts [vue 3]\nimport { onunmounted, shallowref } from 'vue';\n\nconst snapshot = shallowref(actor.snapshot);\nconst stop = actor.subscribe((next) => (snapshot.value = next));\nonunmounted(stop);\n```\n\n```ts [svelte]\nimport { ondestroy } from 'svelte';\n\nlet snapshot = actor.snapshot;\nconst stop = actor.subscribe((next) => (snapshot = next));\nondestroy(stop);\n```\n\n:::\n\n## working with other vielzeug libraries\n\nuse herald when separate actors exchange application events. bridge clockwork snapshots into ripple only at a ui or application boundary.\n\n```ts\nimport { createbus } from '@vielzeug/herald';\n\nconst bus = createbus<{ refresh: void }>();\nbus.on('refresh', () => actor.send({ type: 'fetch' }));\n```\n\n## best practices\n\n define context and event unions with `definemachine<context, event>()`.\n return replacement context from reducers; do not rely on runtime copying or freezing.\n keep guards and reducers pure.\n use actors for effects, timers, invokes, subscriptions, and cancellation.\n read the current snapshot from `actor.snapshot`, not a wrapper value.\n validate persisted context before restoring a snapshot.\n dispose every actor at its ownership boundary.\n route runtime failures through `onerror` when the owner can recover.\n",
|
|
164
164
|
"examples": " \ntitle: clockwork — examples\ndescription: practical state machine patterns with pure transitions and actors.\n \n\n [counter with reset](./examples/counter with reset.md)\n [form validation](./examples/form validation.md)\n [auto dismiss notification](./examples/auto dismiss notification.md)\n [model nested workflows with flat states](./examples/hierarchical states.md)\n [pure transition testing](./examples/unit testing.md)\n [auth flow with guards](./examples/auth flow.md)\n [data fetching with error recovery](./examples/data fetching.md)\n [fetch with retry](./examples/fetch retry.md)\n [paginated data loading](./examples/paginated data loading.md)\n [media player](./examples/media player.md)\n [persisted wizard](./examples/persisted wizard.md)\n [multi step wizard with routing](./examples/wizard with routing.md)\n [shopping cart checkout](./examples/checkout.md)\n [permission based access control](./examples/permission based access.md)\n [event boundaries](./examples/middleware pipeline.md)\n [multi machine coordination](./examples/multi machine coordination.md)\n [debugging transitions](./examples/debugging transitions.md)\n"
|
|
165
165
|
},
|
|
166
166
|
"examples": [
|
|
@@ -272,7 +272,7 @@
|
|
|
272
272
|
"description": "dependency first asynchronous dependency injection with typed tokens, lifecycle scopes, startup validation, and deterministic disposal.",
|
|
273
273
|
"docs": {
|
|
274
274
|
"index": " \ntitle: conduit — dependency injection for typescript\ndescription: dependency first asynchronous dependency injection with typed tokens, lifecycle scopes, startup validation, and deterministic disposal.\npackage: conduit\ncategory: infrastructure\nkeywords: [dependency injection, container, token, lifecycle, scope]\nexports: [createcontainer, token, scope]\nrelated: [courier, vault, rune]\nenvironments: [browser, node, ssr, deno]\n \n\n<! markdownlint disable md025 md033 md060 >\n\n<packagehero package=\"conduit\" />\n\n## why conduit?\n\nconduit makes service wiring explicit. factory dependency tuples are source of truth for creation, startup validation, and disposal order.\n\n```ts\n// before\nconst service = createservice(createapi(config), logger);\n\n// after\ncontainer.factory(service, [api, logger], (api, logger) => createservice(api, logger));\n```\n\n| feature | conduit | inversify | tsyringe |\n| | | | |\n| dependencies | explicit token tuples | decorators/runtime metadata | decorators/runtime metadata |\n| async factories | <ore icon name=\"check\" size=\"16\"></ore icon> | partial | partial |\n| lifecycle scopes | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> |\n| runtime dependencies | 0 | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> |\n\n<div class=\"decision callout\">\n\n**use conduit when** application services need explicit wiring and owned lifecycle cleanup.\n\n**consider direct imports when** dependencies are static, small, and need no replacement or disposal boundary.\n\n</div>\n\n## installation\n\n::: code group\n\n```sh [pnpm]\npnpm add @vielzeug/conduit\n```\n\n```sh [npm]\nnpm install @vielzeug/conduit\n```\n\n```sh [yarn]\nyarn add @vielzeug/conduit\n```\n\n:::\n\n## quick start\n\n```ts\nimport { createcontainer, token } from '@vielzeug/conduit';\n\nconst config = token<{ baseurl: string }>('config');\nconst client = token<{ url: string }>('client');\nconst container = createcontainer();\n\ncontainer.value(config, { baseurl: '/api' });\ncontainer.factory(client, [config], (config) => ({ url: `${config.baseurl}/users` }));\n\nconsole.log(await container.resolve(client));\nawait container.dispose();\n```\n\n## features\n\n<div class=\"features grid\">\n\n **`token`**: typed dependency identity\n **`factory`**: static dependency first creation\n **`validate`**: startup graph validation\n **`scope`**: explicit request and job ownership\n **`dispose`**: in flight safe resource cleanup\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 [courier](/courier/) — inject http clients into application services.\n [vault](/vault/) — inject persistence adapters with scoped ownership.\n [rune](/rune/) — provide application logging services.\n\n</div>\n\n<! markdownlint enable md025 md033 md060 >\n",
|
|
275
|
-
"api": " \ntitle: conduit — api reference\ndescription: reference for conduit tokens, dependency first factories, scopes, validation, and lifecycle disposal.\n \n\n[[toc]]\n\n## api overview\n\n| symbol | purpose | mode | common gotcha |\n| | | | |\n| `token` | create typed dependency identity | sync | same description does not mean same token |\n| `scope` | create named lifecycle identity | sync | must match factory lifetime |\n| `createcontainer` | create root registry | sync | dispose when application ends |\n| `value` | register an existing value | sync | one registration per token/container |\n| `factory` | register static dependency factory | sync | tuple is copied and authoritative |\n| `has` | check registration visibility | sync | walks parent containers |\n| `resolve` | resolve one dependency | async | missing provider throws |\n| `validate` | validate static graph | sync | run after registration |\n| `createscope` | create child owner | sync | named scope required for scoped factories |\n| `dispose` | release owned resources | async | may throw `conduitdisposeerror` after cleanup attempts |\n\n## package entry point\n\n| import | purpose |\n| | |\n| `@vielzeug/conduit` | complete conduit api |\n\n## tokens and scopes\n\n```ts\ntoken<t>(description: string): token<t>\nscope(name: string): scopetoken\n```\n\ntokens and scopes are unique symbols. descriptions exist only for diagnostics.\n\n## container\n\n```ts\ncreatecontainer(options?: { name?: string }): container\n```\n\n### value\n\n```ts\ncontainer.value(token, value, options?)\n```\n\n`options.dispose` runs during container disposal.\n\n### has\n\n```ts\ncontainer.has(token): boolean\n```\n\nchecks local and parent registrations without creating a factory result.\n\n### factory\n\n```ts\ncontainer.factory(token, dependencies, create, options?)\n```\n\n```ts\ncontainer.factory(service, [api, logger], (api, logger) => createservice(api, logger));\n```\n\n`dependencies` is copied at registration and drives creation, validation, cycle detection, and teardown order. factories may return a value or promise.\n\n`options.lifetime` accepts `'singleton'`, `'transient'`, or `scopetoken`. a singleton cannot depend on a scoped resource.\n\n```ts\ntype factoryoptions<t> = {\n dispose?: (value: t) => void | promise<void>;\n lifetime?: 'singleton' | 'transient' | scopetoken;\n};\n```\n\n### resolve\n\n```ts\ncontainer.resolve(token): promise<t>\n```\n\nsingleton resolutions deduplicate concurrent callers.\n\n### validate\n\n```ts\ncontainer.validate(): container\n```\n\nthrows for missing dependencies and circular factory tuples.\n\n### createscope\n\n```ts\ncontainer.createscope(scope?: scopetoken, options?: { name?: string }): container\n```\n\na matching scope owns resources registered with its `scopetoken` lifetime. disposing a parent also disposes its active child scopes.\n\n### dispose\n\n```ts\ncontainer.dispose(): promise<void>\ncontainer.disposalsignal: abortsignal\ncontainer.disposed: boolean\n```\n\ndisposal blocks new work, aborts `disposalsignal`, disposes active child scopes, waits for in flight creation, then disposes owned resources in reverse creation order. cleanup failures are aggregated in `conduitdisposeerror.errors`.\n\n## types\n\n```ts\ntype token<t = unknown> = symbol;\ntype scopetoken = symbol;\ntype lifetime = 'singleton' | 'transient' | scopetoken;\n\ntype valueoptions<t> = readonly<{\n dispose?: (value: t) => promise<void> | void;\n}>;\n\ntype factoryoptions<t> = readonly<{\n dispose?: (value: t) => promise<void> | void;\n lifetime?: lifetime;\n}>;\n\ntype infertokens<t extends readonly token<unknown>[]> = {\n [k in keyof t]: t[k] extends token<infer value> ? value : never;\n};\n\ninterface container {\n createscope(scope?: scopetoken, options?: { name?: string }): container;\n readonly disposalsignal: abortsignal;\n dispose(): promise<void>;\n readonly disposed: boolean;\n factory<t, dependencies extends readonly token<unknown>[]>(\n token: token<t>,\n dependencies: dependencies,\n create: (...values: infertokens<dependencies>) => promise<t> | t,\n options?: factoryoptions<t>,\n ): this;\n has<t>(token: token<t>): boolean;\n readonly name: string;\n resolve<t>(token: token<t>): promise<t>;\n validate(): this;\n value<t>(token: token<t>, value: t, options?: valueoptions<t>): this;\n [symbol.asyncdispose](): promise<void>;\n}\n```\n\n## errors\n\n `conduiterror` — base class; `conduiterror
|
|
275
|
+
"api": " \ntitle: conduit — api reference\ndescription: reference for conduit tokens, dependency first factories, scopes, validation, and lifecycle disposal.\n \n\n[[toc]]\n\n## api overview\n\n| symbol | purpose | mode | common gotcha |\n| | | | |\n| `token` | create typed dependency identity | sync | same description does not mean same token |\n| `scope` | create named lifecycle identity | sync | must match factory lifetime |\n| `createcontainer` | create root registry | sync | dispose when application ends |\n| `value` | register an existing value | sync | one registration per token/container |\n| `factory` | register static dependency factory | sync | tuple is copied and authoritative |\n| `has` | check registration visibility | sync | walks parent containers |\n| `resolve` | resolve one dependency | async | missing provider throws |\n| `validate` | validate static graph | sync | run after registration |\n| `createscope` | create child owner | sync | named scope required for scoped factories |\n| `dispose` | release owned resources | async | may throw `conduitdisposeerror` after cleanup attempts |\n\n## package entry point\n\n| import | purpose |\n| | |\n| `@vielzeug/conduit` | complete conduit api |\n\n## tokens and scopes\n\n```ts\ntoken<t>(description: string): token<t>\nscope(name: string): scopetoken\n```\n\ntokens and scopes are unique symbols. descriptions exist only for diagnostics.\n\n## container\n\n```ts\ncreatecontainer(options?: { name?: string }): container\n```\n\n### value\n\n```ts\ncontainer.value(token, value, options?)\n```\n\n`options.dispose` runs during container disposal.\n\n### has\n\n```ts\ncontainer.has(token): boolean\n```\n\nchecks local and parent registrations without creating a factory result.\n\n### factory\n\n```ts\ncontainer.factory(token, dependencies, create, options?)\n```\n\n```ts\ncontainer.factory(service, [api, logger], (api, logger) => createservice(api, logger));\n```\n\n`dependencies` is copied at registration and drives creation, validation, cycle detection, and teardown order. factories may return a value or promise.\n\n`options.lifetime` accepts `'singleton'`, `'transient'`, or `scopetoken`. a singleton cannot depend on a scoped resource.\n\n```ts\ntype factoryoptions<t> = {\n dispose?: (value: t) => void | promise<void>;\n lifetime?: 'singleton' | 'transient' | scopetoken;\n};\n```\n\n### resolve\n\n```ts\ncontainer.resolve(token): promise<t>\n```\n\nsingleton resolutions deduplicate concurrent callers.\n\n### validate\n\n```ts\ncontainer.validate(): container\n```\n\nthrows for missing dependencies and circular factory tuples.\n\n### createscope\n\n```ts\ncontainer.createscope(scope?: scopetoken, options?: { name?: string }): container\n```\n\na matching scope owns resources registered with its `scopetoken` lifetime. disposing a parent also disposes its active child scopes.\n\n### dispose\n\n```ts\ncontainer.dispose(): promise<void>\ncontainer.disposalsignal: abortsignal\ncontainer.disposed: boolean\n```\n\ndisposal blocks new work, aborts `disposalsignal`, disposes active child scopes, waits for in flight creation, then disposes owned resources in reverse creation order. cleanup failures are aggregated in `conduitdisposeerror.errors`.\n\n## types\n\n```ts\ntype token<t = unknown> = symbol;\ntype scopetoken = symbol;\ntype lifetime = 'singleton' | 'transient' | scopetoken;\n\ntype valueoptions<t> = readonly<{\n dispose?: (value: t) => promise<void> | void;\n}>;\n\ntype factoryoptions<t> = readonly<{\n dispose?: (value: t) => promise<void> | void;\n lifetime?: lifetime;\n}>;\n\ntype infertokens<t extends readonly token<unknown>[]> = {\n [k in keyof t]: t[k] extends token<infer value> ? value : never;\n};\n\ninterface container {\n createscope(scope?: scopetoken, options?: { name?: string }): container;\n readonly disposalsignal: abortsignal;\n dispose(): promise<void>;\n readonly disposed: boolean;\n factory<t, dependencies extends readonly token<unknown>[]>(\n token: token<t>,\n dependencies: dependencies,\n create: (...values: infertokens<dependencies>) => promise<t> | t,\n options?: factoryoptions<t>,\n ): this;\n has<t>(token: token<t>): boolean;\n readonly name: string;\n resolve<t>(token: token<t>): promise<t>;\n validate(): this;\n value<t>(token: token<t>, value: t, options?: valueoptions<t>): this;\n [symbol.asyncdispose](): promise<void>;\n}\n```\n\n## errors\n\n `conduiterror` — base class; use `instanceof conduiterror` to narrow package errors.\n `conduitprovidernotfounderror` — dependency has no registration.\n `conduitcirculardependencyerror` — static factory tuple graph contains a cycle.\n `conduitduplicateregistrationerror` — token registered twice in one container.\n `conduitscopedresolutionerror` — scoped factory resolved without matching scope.\n `conduitdisposederror` — operation attempted after disposal began.\n `conduitdisposeerror` — one or more cleanup hooks failed.\n",
|
|
276
276
|
"usage": " \ntitle: conduit — usage guide\ndescription: register static dependency tuples, resolve services asynchronously, create scopes, validate startup wiring, and dispose owned resources.\n \n\n[[toc]]\n\n## basic usage\n\ncreate tokens once, register values and factories, then resolve through one async api.\n\n```ts\nimport { createcontainer, token } from '@vielzeug/conduit';\n\nconst config = token<{ baseurl: string }>('config');\nconst client = token<{ url: string }>('client');\n\nconst container = createcontainer();\ncontainer.value(config, { baseurl: '/api' });\ncontainer.factory(client, [config], (config) => ({ url: `${config.baseurl}/users` }));\n\nconsole.log(await container.resolve(client));\nawait container.dispose();\n```\n\n## define dependencies\n\nfactory token tuples are authoritative. conduit resolves tuple values in order, validates every edge, and disposes created services in reverse dependency order.\n\n```ts\nconst logger = token<{ info(message: string): void }>('logger');\nconst api = token<{ get(path: string): promise<unknown> }>('api');\nconst service = token<{ load(): promise<unknown> }>('service');\n\ncontainer.factory(service, [api, logger], (api, logger) => ({\n async load() {\n logger.info('loading data');\n return api.get('/data');\n },\n}));\n```\n\n## choose lifetimes\n\nfactories are singletons by default. use transient lifetime for a new value on every resolution. conduit retains a transient only when its factory has a `dispose` hook.\n\n```ts\nconst requestid = token<{ id: string }>('requestid');\n\ncontainer.factory(requestid, [], () => ({ id: crypto.randomuuid() }), {\n lifetime: 'transient',\n});\n```\n\nconcurrent singleton resolutions share one in flight factory result. a singleton cannot depend on a scoped resource; give dependent factory equal or shorter lifetime instead. factory dependency tuples are copied at registration, so later caller mutation cannot change conduit's graph.\n\n## create named scopes\n\nuse a scope token when a resource belongs to a request, job, or test lifecycle.\n\n```ts\nimport { createcontainer, scope, token } from '@vielzeug/conduit';\n\nconst request = scope('request');\nconst session = token<{ id: string }>('session');\nconst root = createcontainer();\n\nroot.factory(session, [], () => ({ id: crypto.randomuuid() }), { lifetime: request });\n\nconst request = root.createscope(request);\nconst session = await request.resolve(session);\nawait request.dispose();\nawait root.dispose();\n```\n\n## validate startup wiring\n\ncall `validate()` after registration. it detects missing dependencies and cycles before service resolution. parent singleton factories validate dependencies from their registration owner; child overrides do not satisfy them.\n\n```ts\ncontainer.validate();\n```\n\n## dispose resources\n\n`dispose()` rejects new work, aborts `disposalsignal`, disposes child scopes, waits for in flight creation, then releases services in reverse creation order. a factory that finishes after disposal starts is immediately cleaned up and its resolver receives `conduitdisposederror`.\n\n```ts\nawait container.dispose();\n```\n\n`conduitdisposeerror.errors` contains every cleanup failure after conduit attempts all hooks, including cleanup from in flight factories and child scopes.\n\n## testing\n\ncreate a container per test and register explicit values for external dependencies.\n\n```ts\nconst clock = token<{ now(): number }>('clock');\nconst service = token<{ timestamp: number }>('service');\nconst container = createcontainer();\n\ncontainer.value(clock, { now: () => 123 });\ncontainer.factory(service, [clock], (clock) => ({ timestamp: clock.now() }));\n\nexpect(await container.resolve(service)).toequal({ timestamp: 123 });\nawait container.dispose();\n```\n\n## best practices\n\n create tokens at module scope.\n declare every factory dependency in its tuple.\n keep factories focused on one service.\n use scopes for request/job owned resources.\n call `validate()` during startup.\n dispose every scope and root container.\n keep optional application fallback policy outside conduit.\n use `await using container = createcontainer()` when lexical async disposal fits application lifetime.\n",
|
|
277
277
|
"examples": " \ntitle: conduit — examples\ndescription: dependency first container recipes.\n \n\n## examples\n\n [basic setup](./examples/basic setup.md)\n [static async providers](./examples/async providers.md)\n [lifetimes](./examples/lifetimes.md)\n [named scopes](./examples/named scopes.md)\n [disposal lifecycle](./examples/dispose lifecycle.md)\n [startup validation](./examples/startup hardening.md)\n"
|
|
278
278
|
},
|
|
@@ -313,8 +313,8 @@
|
|
|
313
313
|
"category": "http",
|
|
314
314
|
"description": "a framework neutral fetch client with explicit cache keys, direct mutations, and abortable streams.",
|
|
315
315
|
"docs": {
|
|
316
|
-
"index": " \ntitle: courier — http, queries, and streaming\ndescription: a framework neutral fetch client with explicit cache keys, direct mutations, and abortable streams.\npackage: courier\ncategory: http\nkeywords: [http client, fetch, caching, queries, mutations, sse, streaming, interceptors]\nrelated: [flux, ripple, spell]\nexports:\n [\n createcourier,\n couriererror,\n courierhttperror,\n couriernetworkerror,\n couriertimeouterror,\n courieraborterror,\n courierschemavalidationerror,\n withbearerauth,\n withrequestid,\n withlogging,\n ]\nenvironments: [browser, node, ssr, deno]\n \n\n<! markdownlint disable md025 md033 md060 >\n\n<packagehero package=\"courier\" />\n\n## why courier?\n\nnative `fetch` leaves request policy, cached reads, and stream lifecycles to each application. courier keeps\nthose concerns in one client while making cache identity and fetch policy explicit at every cached read.\n\n```ts\n// before\nconst response = await fetch(`/api/users/${userid}`);\nif (!response.ok) throw new error(`http ${response.status}`);\nconst user = await response.json();\n\n// after\nawait courier.queries.fetch({\n key: ['users', userid],\n fetch: ({ signal }) => courier.get('/users/{id}', { params: { id: userid }, signal }),\n});\n```\n\n| feature | courier | tanstack query | ky |\n| | | | |\n| bundle size | <packageinfo package=\"courier\" type=\"size\" /> | framework adapter required | separate package |\n| zero runtime dependencies | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> |\n| native fetch transport | <ore icon name=\"check\" size=\"16\"></ore icon> | bring your own | <ore icon name=\"check\" size=\"16\"></ore icon> |\n| explicit cache keys | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> |\n| sse and ndjson iteration | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> |\n| external runtime dependencies | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"triangle alert\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> |\n\n<div class=\"decision callout\">\n\n**use courier when** one application client should own typed http, explicit cached reads, direct writes, and\nabortable response streams.\n\n**consider tanstack query when** you need a maintained framework adapter or advanced cache features such as\ninfinite queries. **consider ky when** you only need a compact fetch wrapper without caching or streams.\n\n</div>\n\n## installation\n\n::: code group\n\n```sh [pnpm]\npnpm add @vielzeug/courier\n```\n\n```sh [npm]\nnpm install @vielzeug/courier\n```\n\n```sh [yarn]\nyarn add @vielzeug/courier\n```\n\n:::\n\n## quick start\n\ncreate one client for an application or request scope, then fetch a cache entry by its explicit key.\n\n```ts\nimport { courierhttperror, createcourier } from '@vielzeug/courier';\n\ntype user = { id: number; name: string };\n\nconst courier = createcourier({ baseurl: 'https://api.example.com', query: { staletime: 30_000 } });\nconst key = ['users', 42] as const;\n\ntry {\n await courier.queries.fetch({\n key,\n fetch: ({ signal }) => courier.get('/users/{id}', { params: { id: 42 }, signal }),\n });\n console.log(courier.queries.getsnapshot<user>(key)?.data);\n} catch (error) {\n if (courierhttperror.is(error, 404)) console.log('user not found');\n else throw error;\n} finally {\n courier.dispose();\n}\n```\n\n## features\n\n<div class=\"features grid\">\n\n **`createcourier()`** — one lifecycle, interceptor pipeline, header store, and cancellation boundary.\n **`get()` / `post()` / `put()` / `patch()` / `delete()`** — typed paths, query strings, request bodies, validation, and structured errors.\n **`queries.fetch()`** — key based cached reads, subscriptions, invalidation with refetch, and automatic garbage collection.\n **`mutate()`** — direct write operation with `invalidatekeys` for one step cache refetch, without hidden retries or a second state store.\n **`events()` / `read()`** — abortable sse, text, and ndjson iteration with normalized request errors.\n **`withbearerauth()` / `withrequestid()` / `withlogging()`** — composable transport policies.\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 [flux](/flux/) — adapts courier cache entries and event iterators into composable streams.\n [ripple](/ripple/) — stores courier snapshots in fine grained reactive state.\n [spell](/spell/) — validates parsed http payloads through courier's schema option.\n\n</div>\n\n<! markdownlint enable md025 md033 md060 >\n",
|
|
317
|
-
"api": " \ntitle: courier — api reference\ndescription: reference for courier http, cache, mutation, interceptor, and stream apis.\n \n\n[[toc]]\n\n## api overview\n\n| symbol | purpose | execution mode | common gotcha |\n| | | | |\n| `createcourier()` | creates unified application client | sync | dispose only when whole scope ends |\n| `courier` http methods | sends and parses http requests | async | direct calls never deduplicate |\n| `queries.fetch()` | fetches one keyed cache entry | async | key must include all response identity inputs |\n| `mutate()` | runs one write operation | async | it never retries automatically |\n| `events()` / `read()` | opens abortable response iterators | async iteration | breaking iteration aborts request |\n| `withbearerauth()` | adds authorization interceptor | sync | token provider runs per request |\n| `withrequestid()` | adds request identifier interceptor | sync | default generator uses `uuid()` |\n| `withlogging()` | logs request result metadata | sync | requires explicit logger; urls may contain sensitive query values |\n\n## package entry point\n\n| import | purpose |\n| | |\n| `@vielzeug/courier` | client factory, errors, interceptors, and public types |\n\n## client\n\n### `createcourier()`\n\n```ts\ncreatecourier(options?: courieroptions): courier;\n```\n\nreturns client sharing transport configuration, headers, interceptors, cancellation, cache, mutations, and streams.\n\n| `courieroptions` field | type | default | description |\n| | | | |\n| `baseurl` | `string` | `''` | prefix for relative request paths |\n| `fetch` | `typeof globalthis.fetch` | `globalthis.fetch` | fetch implementation |\n| `headers` | `record<string, string>` | `{}` | global request headers |\n| `timeout` | `number` | `30_000` | default http timeout in milliseconds |\n| `query.staletime` | `number` | `0` | cache freshness duration |\n| `query.gctime` | `number` | `300_000` | garbage collect entries with no subscribers after this duration (ms); `infinity` disables |\n\n**returns:** `courier`.\n\n```ts\nimport { createcourier } from '@vielzeug/courier';\n\nconst courier = createcourier({ baseurl: 'https://api.example.com' });\n```\n\n| `courier` member | signature | description |\n| | | |\n| `get` / `post` / `put` / `patch` / `delete` | `<t, p>(url: p, config?) => promise<t>` | sends one http request |\n| `setheaders` | `(updates) => void` | updates global headers |\n| `getheaders` | `() => readonly<record<string, string>>` | returns header snapshot |\n| `use` | `(interceptor) => () => void` | registers interceptor |\n| `cancelall` | `() => void` | aborts active http, cache, and mutation work; a subsequent `queries.fetch()` starts a fresh request |\n| `queries` | `querycache` | owns keyed cache entries |\n| `mutate` | `<t>(options) => promise<t>` | runs one write operation |\n| `events` | `<t, p>(url, options?) => asynciterableiterator<streamevent<t>>` | opens sse iterator |\n| `read` | `<t, p>(url, options?) => asynciterableiterator<t>` | opens text or ndjson iterator |\n| `dispose` | `() => void` | final disposal; aborts work and clears cache |\n| `disposed` | `boolean` | whether final disposal occurred |\n| `disposalsignal` | `abortsignal` | aborts on final disposal |\n\n \n\n## queries\n\n### `queries.fetch()`\n\n```ts\nfetch<t>(definition: querydefinition<t>, options?: { force?: boolean }): promise<t>;\n```\n\nregisters latest definition for `definition.key`, then returns fresh cached data or runs its fetch function.\n\n| parameter | type | description |\n| | | |\n| `definition.key` | `querykey` | cache identity; include every response identity input |\n| `definition.fetch` | `(context: querycontext) => promise<t>` | request function for this key |\n| `definition.staletime` | `number` | per entry freshness duration |\n| `options.force` | `boolean` | fetch even when cached data is fresh |\n\n**returns:** cached or fetched data.\n\n```ts\nconst key = ['profile', 1] as const;\nawait courier.queries.fetch({\n key,\n fetch: ({ signal }) => courier.get('/profile/{id}', { params: { id: 1 }, signal }),\n});\n```\n\n| `querycache` method | returns | description |\n| | | |\n| `get(key)` | `t \\| undefined` | returns successful cached data |\n| `getsnapshot(key)` | `asyncstate<t> \\| null` | returns snapshot by key |\n| `set(key, data, options?)` | `void` | sets successful cache value |\n| `delete(key)` | `void` | removes one cache entry and aborts its active fetch if present |\n| `invalidate(prefix, options?)` | `void` | marks matching key prefixes stale; `options.refetch` triggers background refetch |\n| `keys()` | `querykey[]` | lists known keys |\n| `subscribe(key, listener)` | `unsubscribe` | subscribes to one key |\n| `clear()` | `void` | removes every cache entry |\n\n \n\n## mutations\n\n### `mutate()`\n\n```ts\nmutate<t>(options: mutationoptions<t>): promise<t>;\n```\n\nruns `options.request` once, then calls `onsuccess` after successful completion, then invalidates (and refetches) each key in `invalidatekeys`.\n\n| `mutationoptions<t>` field | type | description |\n| | | |\n| `request` | `(context: mutationcontext) => promise<t>` | write operation |\n| `onsuccess` | `(data, queries) => void \\| promise<void>` | cache update callback |\n| `invalidatekeys` | `readonly (readonly unknown[])[]` | key prefixes to invalidate and refetch after success |\n| `signal` | `abortsignal` | caller controlled cancellation |\n\n**returns:** request result.\n\n \n\n## streams\n\n### `events()` and `read()`\n\n```ts\nevents<t, p extends string>(url: p, options?: streamoptions<p>): asynciterableiterator<streamevent<t>>;\nread<t, p extends string>(url: p, options?: streamoptions<p> & { parse?: 'ndjson' | 'text' }): asynciterableiterator<t>;\n```\n\nboth iterators abort request when `return()` runs or `for await` loop exits. `events()` parses `event` and `data`\nfields; it does not retain event ids or reconnect.\n\n`streamoptions<p>` extends `requestconfig<p>` (typed path params) with an optional `method` field. it omits\n`responsetype` and `schema` (not applicable to streaming).\n\n**returns:** abortable async iterator.\n\n \n\n## interceptors\n\n### interceptor helpers\n\n```ts\nwithbearerauth(token: string | (() => string | promise<string>)): interceptor;\nwithrequestid(options?: { generate?: () => string; header?: string }): interceptor;\nwithlogging(options: {\n logger: (message: string, meta: { duration: number; method: string; status: number; url: string }) => void;\n}): interceptor;\n```\n\neach helper returns an `interceptor` accepted by `courier.use()`. `withlogging` requires an explicit `logger`\nfunction — no default console output.\n\n## types\n\n```ts\ntype transportoptions = {\n baseurl?: string;\n fetch?: typeof globalthis.fetch;\n headers?: record<string, string>;\n timeout?: number;\n};\n\ntype courieroptions = transportoptions & {\n query?: { gctime?: number; staletime?: number };\n};\n\ntype fetchcontext = {\n readonly headers: readonly<record<string, string>>;\n readonly init: readonly<omit<requestinit, 'headers'>>;\n readonly url: string;\n withheaders(updates: record<string, string>): fetchcontext;\n};\n\ntype interceptor = (ctx: fetchcontext, next: (ctx: fetchcontext) => promise<response>) => promise<response>;\n\ntype asyncstate<t> =\n | { data: undefined; error: null; isfetching: boolean; status: 'loading'; updatedat: undefined }\n | { data: t; error: null; isfetching: boolean; status: 'success'; updatedat: number }\n | { data: t | undefined; error: error; isfetching: false; status: 'error'; updatedat: number };\n\ntype querycontext = { readonly key: querykey; readonly signal: abortsignal };\ntype querydefinition<t> = { fetch: (context: querycontext) => promise<t>; key: querykey; staletime?: number };\ntype querykey = readonly [querykeyatom, ...querykeyatom[]];\ntype querykeyatom = string | number | boolean | null;\ntype querycache = {\n clear(): void;\n delete(key: querykey): void;\n fetch<t>(definition: querydefinition<t>, options?: { force?: boolean }): promise<t>;\n get<t>(key: querykey): t | undefined;\n getsnapshot<t>(key: querykey): asyncstate<t> | null;\n invalidate(prefix: readonly unknown[], options?: { refetch?: boolean }): void;\n keys(): querykey[];\n set<t>(key: querykey, data: t, options?: { updatedat?: number }): void;\n subscribe(key: querykey, listener: () => void): unsubscribe;\n};\ntype mutationcontext = { readonly signal: abortsignal };\ntype mutationoptions<t> = {\n invalidatekeys?: readonly (readonly unknown[])[];\n onsuccess?: (data: t, queries: querycache) => void | promise<void>;\n request: (context: mutationcontext) => promise<t>;\n signal?: abortsignal;\n};\ntype streamevent<t = unknown> = { readonly data: t; readonly event: string };\ntype streamoptions<p extends string = string> = omit<requestconfig<p>, 'responsetype' | 'schema'> & {\n method?: string;\n};\ntype unsubscribe = () => void;\n```\n\n```ts\ntype paramvalue = string | number | boolean | null | readonly (string | number | boolean | null)[] | undefined;\ntype params = record<string, paramvalue>;\ntype requestconfig<p extends string = string, t = unknown> = {\n body?: unknown;\n fetchinit?: omit<requestinit, 'body' | 'headers' | 'method' | 'signal'>;\n headers?: record<string, string>;\n params?: record<string, string | number | boolean>;\n query?: params;\n responsetype?: 'auto' | 'json' | 'text' | 'blob' | 'arraybuffer' | 'raw';\n schema?: { parse(data: unknown): t };\n signal?: abortsignal;\n timeout?: number;\n};\n```\n\n## errors\n\n| error | trigger | notable properties |\n| | | |\n| `couriererror` | base class for all courier errors | use `instanceof` to narrow |\n| `courierhttperror` | non 2xx http response | `status`, `data`, `headers`, `method`, `url`; `courierhttperror.is(e, status?)` narrows by status |\n| `couriernetworkerror` | request failure without response | `method`, `url`, `cause` |\n| `couriertimeouterror` | timeout signal aborts request | `method`, `url`, `cause` |\n| `courieraborterror` | caller, client, or iterator cancellation | `method`, `url`, `cause` |\n| `courierschemavalidationerror` | response schema fails | `data`, `cause` |\n| `courierparseerror` | response body cannot parse | — |\n| `courierdisposederror` | work starts after disposal | — |\n",
|
|
316
|
+
"index": " \ntitle: courier — http, queries, and streaming\ndescription: a framework neutral fetch client with explicit cache keys, direct mutations, and abortable streams.\npackage: courier\ncategory: http\nkeywords: [http client, fetch, caching, queries, mutations, sse, streaming, interceptors]\nrelated: [flux, ripple, spell]\nexports:\n [\n createcourier,\n couriererror,\n courierhttperror,\n couriernetworkerror,\n couriertimeouterror,\n courieraborterror,\n courierschemavalidationerror,\n withbearerauth,\n withrequestid,\n withlogging,\n ]\nenvironments: [browser, node, ssr, deno]\n \n\n<! markdownlint disable md025 md033 md060 >\n\n<packagehero package=\"courier\" />\n\n## why courier?\n\nnative `fetch` leaves request policy, cached reads, and stream lifecycles to each application. courier keeps\nthose concerns in one client while making cache identity and fetch policy explicit at every cached read.\n\n```ts\n// before\nconst response = await fetch(`/api/users/${userid}`);\nif (!response.ok) throw new error(`http ${response.status}`);\nconst user = await response.json();\n\n// after\nawait courier.queries.fetch({\n key: ['users', userid],\n fetch: ({ signal }) => courier.get('/users/{id}', { params: { id: userid }, signal }),\n});\n```\n\n| feature | courier | tanstack query | ky |\n| | | | |\n| bundle size | <packageinfo package=\"courier\" type=\"size\" /> | framework adapter required | separate package |\n| zero runtime dependencies | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> |\n| native fetch transport | <ore icon name=\"check\" size=\"16\"></ore icon> | bring your own | <ore icon name=\"check\" size=\"16\"></ore icon> |\n| explicit cache keys | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> |\n| sse and ndjson iteration | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> |\n| external runtime dependencies | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"triangle alert\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> |\n\n<div class=\"decision callout\">\n\n**use courier when** one application client should own typed http, explicit cached reads, direct writes, and\nabortable response streams.\n\n**consider tanstack query when** you need a maintained framework adapter or advanced cache features such as\ninfinite queries. **consider ky when** you only need a compact fetch wrapper without caching or streams.\n\n</div>\n\n## installation\n\n::: code group\n\n```sh [pnpm]\npnpm add @vielzeug/courier\n```\n\n```sh [npm]\nnpm install @vielzeug/courier\n```\n\n```sh [yarn]\nyarn add @vielzeug/courier\n```\n\n:::\n\n## quick start\n\ncreate one client for an application or request scope, then fetch a cache entry by its explicit key.\n\n```ts\nimport { courierhttperror, createcourier } from '@vielzeug/courier';\n\ntype user = { id: number; name: string };\n\nconst courier = createcourier({ baseurl: 'https://api.example.com', query: { staletime: 30_000 } });\nconst key = ['users', 42] as const;\n\ntry {\n await courier.queries.fetch({\n key,\n fetch: ({ signal }) => courier.get('/users/{id}', { params: { id: 42 }, signal }),\n });\n console.log(courier.queries.getsnapshot<user>(key)?.data);\n} catch (error) {\n if (courierhttperror.is(error, 404)) console.log('user not found');\n else throw error;\n} finally {\n courier.dispose();\n}\n```\n\n## features\n\n<div class=\"features grid\">\n\n **`createcourier()`** — one lifecycle, interceptor pipeline, header store, and cancellation boundary.\n **`get()` / `post()` / `put()` / `patch()` / `delete()`** — typed paths, query strings, request bodies, validation, and structured errors.\n **`queries.fetch()`** — key based cached reads, subscriptions, invalidation with refetch, and automatic garbage collection.\n **`mutate()`** — direct write operation with `invalidatekeys` for one step cache refetch, without hidden retries or a second state store.\n **`events()` / `read()`** — abortable sse, text, and ndjson iteration with normalized request errors.\n **`tap()`** — runtime observability for request lifecycle events (start, success, error).\n **`withbearerauth()` / `withrequestid()` / `withlogging()`** — composable transport policies.\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 [flux](/flux/) — adapts courier cache entries and event iterators into composable streams.\n [ripple](/ripple/) — stores courier snapshots in fine grained reactive state.\n [spell](/spell/) — validates parsed http payloads through courier's schema option.\n\n</div>\n\n<! markdownlint enable md025 md033 md060 >\n",
|
|
317
|
+
"api": " \ntitle: courier — api reference\ndescription: reference for courier http, cache, mutation, interceptor, and stream apis.\n \n\n[[toc]]\n\n## api overview\n\n| symbol | purpose | execution mode | common gotcha |\n| | | | |\n| `createcourier()` | creates unified application client | sync | dispose only when whole scope ends |\n| `courier` http methods | sends and parses http requests | async | direct calls never deduplicate |\n| `queries.fetch()` | fetches one keyed cache entry | async | key must include all response identity inputs |\n| `mutate()` | runs one write operation | async | it never retries automatically |\n| `events()` / `read()` | opens abortable response iterators | async iteration | breaking iteration aborts request |\n| `withbearerauth()` | adds authorization interceptor | sync | token provider runs per request |\n| `withrequestid()` | adds request identifier interceptor | sync | default generator uses `uuid()` |\n| `withlogging()` | logs request result metadata | sync | requires explicit logger; urls may contain sensitive query values |\n\n## package entry point\n\n| import | purpose |\n| | |\n| `@vielzeug/courier` | client factory, errors, interceptors, and public types |\n\n## client\n\n### `createcourier()`\n\n```ts\ncreatecourier(options?: courieroptions): courier;\n```\n\nreturns client sharing transport configuration, headers, interceptors, cancellation, cache, mutations, and streams.\n\n| `courieroptions` field | type | default | description |\n| | | | |\n| `baseurl` | `string` | `''` | prefix for relative request paths |\n| `fetch` | `typeof globalthis.fetch` | `globalthis.fetch` | fetch implementation |\n| `headers` | `record<string, string>` | `{}` | global request headers |\n| `timeout` | `number` | `30_000` | default http timeout in milliseconds |\n| `query.staletime` | `number` | `0` | cache freshness duration |\n| `query.gctime` | `number` | `300_000` | garbage collect entries with no subscribers after this duration (ms); `infinity` disables |\n\n**returns:** `courier`.\n\n```ts\nimport { createcourier } from '@vielzeug/courier';\n\nconst courier = createcourier({ baseurl: 'https://api.example.com' });\n```\n\n| `courier` member | signature | description |\n| | | |\n| `get` / `post` / `put` / `patch` / `delete` | `<t, p>(url: p, config?) => promise<t>` | sends one http request |\n| `setheaders` | `(updates) => void` | updates global headers |\n| `getheaders` | `() => readonly<record<string, string>>` | returns header snapshot |\n| `use` | `(interceptor) => () => void` | registers interceptor |\n| `cancelall` | `() => void` | aborts active http, cache, and mutation work; a subsequent `queries.fetch()` starts a fresh request |\n| `queries` | `querycache` | owns keyed cache entries |\n| `mutate` | `<t>(options) => promise<t>` | runs one write operation |\n| `events` | `<t, p>(url, options?) => asynciterableiterator<streamevent<t>>` | opens sse iterator |\n| `read` | `<t, p>(url, options?) => asynciterableiterator<t>` | opens text or ndjson iterator |\n| `dispose` | `() => void` | final disposal; aborts work and clears cache |\n| `disposed` | `boolean` | whether final disposal occurred |\n| `disposalsignal` | `abortsignal` | aborts on final disposal |\n\n \n\n## queries\n\n### `queries.fetch()`\n\n```ts\nfetch<t>(definition: querydefinition<t>, options?: { force?: boolean }): promise<t>;\n```\n\nregisters latest definition for `definition.key`, then returns fresh cached data or runs its fetch function.\n\n| parameter | type | description |\n| | | |\n| `definition.key` | `querykey` | cache identity; include every response identity input |\n| `definition.fetch` | `(context: querycontext) => promise<t>` | request function for this key |\n| `definition.staletime` | `number` | per entry freshness duration |\n| `options.force` | `boolean` | fetch even when cached data is fresh |\n\n**returns:** cached or fetched data.\n\n```ts\nconst key = ['profile', 1] as const;\nawait courier.queries.fetch({\n key,\n fetch: ({ signal }) => courier.get('/profile/{id}', { params: { id: 1 }, signal }),\n});\n```\n\n| `querycache` method | returns | description |\n| | | |\n| `get(key)` | `t \\| undefined` | returns successful cached data |\n| `getsnapshot(key)` | `asyncstate<t> \\| null` | returns snapshot by key |\n| `set(key, data, options?)` | `void` | sets successful cache value |\n| `delete(key)` | `void` | removes one cache entry and aborts its active fetch if present |\n| `invalidate(prefix, options?)` | `void` | marks matching key prefixes stale; `options.refetch` triggers background refetch |\n| `keys()` | `querykey[]` | lists known keys |\n| `subscribe(key, listener)` | `unsubscribe` | subscribes to one key |\n| `clear()` | `void` | removes every cache entry |\n\n \n\n## mutations\n\n### `mutate()`\n\n```ts\nmutate<t>(options: mutationoptions<t>): promise<t>;\n```\n\nruns `options.request` once, then calls `onsuccess` after successful completion, then invalidates (and refetches) each key in `invalidatekeys`.\n\n| `mutationoptions<t>` field | type | description |\n| | | |\n| `request` | `(context: mutationcontext) => promise<t>` | write operation |\n| `onsuccess` | `(data, queries) => void \\| promise<void>` | cache update callback |\n| `invalidatekeys` | `readonly (readonly unknown[])[]` | key prefixes to invalidate and refetch after success |\n| `signal` | `abortsignal` | caller controlled cancellation |\n\n**returns:** request result.\n\n \n\n## streams\n\n### `events()` and `read()`\n\n```ts\nevents<t, p extends string>(url: p, options?: streamoptions<p>): asynciterableiterator<streamevent<t>>;\nread<t, p extends string>(url: p, options?: streamoptions<p> & { parse?: 'ndjson' | 'text' }): asynciterableiterator<t>;\n```\n\nboth iterators abort request when `return()` runs or `for await` loop exits. `events()` parses `event` and `data`\nfields; it does not retain event ids or reconnect.\n\n`streamoptions<p>` extends `requestconfig<p>` (typed path params) with an optional `method` field. it omits\n`responsetype` and `schema` (not applicable to streaming).\n\n**returns:** abortable async iterator.\n\n \n\n## observability\n\n### `tap()`\n\n```ts\ntap(handler: (event: courierevent) => void, options?: { signal?: abortsignal }): () => void;\n```\n\nobserve request lifecycle events without affecting courier behavior. handler errors are swallowed. returns an unsubscribe function.\n\n```ts\ntype courierevent =\n | { type: 'request start'; method: string; url: string }\n | { type: 'request success'; method: string; url: string; status: number; duration: number }\n | { type: 'request error'; method: string; url: string; error: unknown }\n | { type: 'dispose' };\n```\n\n**example:**\n\n```ts\nconst courier = createcourier({ baseurl: '/api' });\ncourier.tap((event) => {\n if (event.type === 'request error') console.error(event.method, event.url, event.error);\n if (event.type === 'request success') console.debug(event.method, event.url, event.duration);\n});\n```\n\nfor structured logging, route tap events to rune:\n\n```ts\nimport { createlogger } from '@vielzeug/rune';\nconst log = createlogger({ name: 'courier' });\ncourier.tap((event) => log.debug(event, `courier:${event.type}`));\n```\n\n## interceptors\n\n### interceptor helpers\n\n```ts\nwithbearerauth(token: string | (() => string | promise<string>)): interceptor;\nwithrequestid(options?: { generate?: () => string; header?: string }): interceptor;\nwithlogging(options: {\n logger: (message: string, meta: { duration: number; method: string; status: number; url: string }) => void;\n}): interceptor;\n```\n\neach helper returns an `interceptor` accepted by `courier.use()`. `withlogging` requires an explicit `logger`\nfunction — no default console output.\n\n## types\n\n```ts\ntype transportoptions = {\n baseurl?: string;\n fetch?: typeof globalthis.fetch;\n headers?: record<string, string>;\n timeout?: number;\n};\n\ntype courieroptions = transportoptions & {\n query?: { gctime?: number; staletime?: number };\n};\n\ntype fetchcontext = {\n readonly headers: readonly<record<string, string>>;\n readonly init: readonly<omit<requestinit, 'headers'>>;\n readonly url: string;\n withheaders(updates: record<string, string>): fetchcontext;\n};\n\ntype interceptor = (ctx: fetchcontext, next: (ctx: fetchcontext) => promise<response>) => promise<response>;\n\ntype asyncstate<t> =\n | { data: undefined; error: null; isfetching: boolean; status: 'loading'; updatedat: undefined }\n | { data: t; error: null; isfetching: boolean; status: 'success'; updatedat: number }\n | { data: t | undefined; error: error; isfetching: false; status: 'error'; updatedat: number };\n\ntype querycontext = { readonly key: querykey; readonly signal: abortsignal };\ntype querydefinition<t> = { fetch: (context: querycontext) => promise<t>; key: querykey; staletime?: number };\ntype querykey = readonly [querykeyatom, ...querykeyatom[]];\ntype querykeyatom = string | number | boolean | null;\ntype querycache = {\n clear(): void;\n delete(key: querykey): void;\n fetch<t>(definition: querydefinition<t>, options?: { force?: boolean }): promise<t>;\n get<t>(key: querykey): t | undefined;\n getsnapshot<t>(key: querykey): asyncstate<t> | null;\n invalidate(prefix: readonly unknown[], options?: { refetch?: boolean }): void;\n keys(): querykey[];\n set<t>(key: querykey, data: t, options?: { updatedat?: number }): void;\n subscribe(key: querykey, listener: () => void): unsubscribe;\n};\ntype mutationcontext = { readonly signal: abortsignal };\ntype mutationoptions<t> = {\n invalidatekeys?: readonly (readonly unknown[])[];\n onsuccess?: (data: t, queries: querycache) => void | promise<void>;\n request: (context: mutationcontext) => promise<t>;\n signal?: abortsignal;\n};\ntype streamevent<t = unknown> = { readonly data: t; readonly event: string };\ntype streamoptions<p extends string = string> = omit<requestconfig<p>, 'responsetype' | 'schema'> & {\n method?: string;\n};\ntype unsubscribe = () => void;\n```\n\n```ts\ntype paramvalue = string | number | boolean | null | readonly (string | number | boolean | null)[] | undefined;\ntype params = record<string, paramvalue>;\ntype requestconfig<p extends string = string, t = unknown> = {\n body?: unknown;\n fetchinit?: omit<requestinit, 'body' | 'headers' | 'method' | 'signal'>;\n headers?: record<string, string>;\n params?: record<string, string | number | boolean>;\n query?: params;\n responsetype?: 'auto' | 'json' | 'text' | 'blob' | 'arraybuffer' | 'raw';\n schema?: { parse(data: unknown): t };\n signal?: abortsignal;\n timeout?: number;\n};\n```\n\n## errors\n\n| error | trigger | notable properties |\n| | | |\n| `couriererror` | base class for all courier errors | use `instanceof` to narrow |\n| `courierhttperror` | non 2xx http response | `status`, `data`, `headers`, `method`, `url`; `courierhttperror.is(e, status?)` narrows by status |\n| `couriernetworkerror` | request failure without response | `method`, `url`, `cause` |\n| `couriertimeouterror` | timeout signal aborts request | `method`, `url`, `cause` |\n| `courieraborterror` | caller, client, or iterator cancellation | `method`, `url`, `cause` |\n| `courierschemavalidationerror` | response schema fails | `data`, `cause` |\n| `courierparseerror` | response body cannot parse | — |\n| `courierdisposederror` | work starts after disposal | — |\n",
|
|
318
318
|
"usage": " \ntitle: courier — usage guide\ndescription: use one courier client for http, explicit cached reads, direct mutations, and abortable streams.\n \n\n[[toc]]\n\n## basic usage\n\ncreate one courier client for an application or request scope. its transport policy and disposal lifecycle apply\nto every request, cache entry, mutation, and stream.\n\n```ts\nimport { createcourier } from '@vielzeug/courier';\n\ntype user = { id: number; name: string };\n\nconst courier = createcourier({ baseurl: 'https://api.example.com', query: { staletime: 30_000 } });\nconst key = ['users', 1] as const;\n\nawait courier.queries.fetch({\n key,\n fetch: ({ signal }) => courier.get<user>('/users/{id}', { params: { id: 1 }, signal }),\n});\nconsole.log(courier.queries.get<user>(key)?.name);\n```\n\n## http requests\n\nuse root methods for rest requests. courier encodes path parameters, serializes plain object bodies, and parses\nsuccessful response bodies. each direct http call is independent; use a query key when concurrent cached reads\nshould share work.\n\n```ts\nconst posts = await courier.get<{ id: number; title: string }[]>('/users/{id}/posts', {\n params: { id: 1 },\n query: { limit: 20, status: 'published' },\n});\n\nawait courier.patch('/posts/{id}', {\n body: { title: 'updated title' },\n params: { id: posts[0].id },\n});\n```\n\ncall `courier.setheaders({ authorization: 'bearer token' })` to update subsequent calls.\n\n## interceptors\n\ninterceptors apply to http and streaming requests. register a policy once, then remove it when its containing\nscope ends.\n\n```ts\nimport { withbearerauth, withrequestid } from '@vielzeug/courier';\n\nconst removeauth = courier.use(withbearerauth(async () => sessionstorage.getitem('access token') ?? ''));\nconst removerequestid = courier.use(withrequestid());\n\nremoverequestid();\nremoveauth();\n```\n\nuse `withlogging()` with an explicit `logger` function to log requests during local development. `withlogging()`\nincludes full urls, so sanitize query values before persistent logging.\n\n```ts\nimport { withlogging } from '@vielzeug/courier';\n\ncourier.use(withlogging({ logger: (msg) => console.log(msg) }));\n```\n\n## cached queries\n\npass a stable key and fetch definition to `queries.fetch()`. the cache owns data, snapshots, subscriptions, and\nin flight deduplication for that key. entries with no subscribers are garbage collected after `gctime` (default\n5 min; `infinity` disables).\n\n```ts\nconst key = ['profile', 1] as const;\nconst definition = {\n key,\n fetch: ({ signal }) => courier.get<{ id: number; name: string }>('/profile/{id}', { params: { id: 1 }, signal }),\n staletime: 60_000,\n};\n\nconst stop = courier.queries.subscribe(key, () => {\n const state = courier.queries.getsnapshot<{ id: number; name: string }>(key);\n if (state?.status === 'success') console.log(state.data.name);\n if (state?.status === 'error') console.error(state.error);\n});\n\nawait courier.queries.fetch(definition);\nstop();\n```\n\n`queries.fetch(definition)` reuses fresh data. pass `{ force: true }` to fetch regardless of freshness.\n`invalidate(prefix, { refetch: true })` marks matching key prefixes stale and refetches them in the background\nin a single call.\n\n## direct mutations\n\nuse `mutate()` for a write operation. pass `invalidatekeys` to invalidate and refetch cache entries after a\nsuccessful write — no manual `invalidate()` + refetch boilerplate. use `onsuccess` for custom cache writes\n(e.g. seeding a created entity). courier never retries writes: retry only operations your application can prove\nidempotent.\n\n```ts\ntype user = { id: number; name: string };\n\nconst created = await courier.mutate<user>({\n request: ({ signal }) => courier.post<user>('/users', { body: { name: 'ada' }, signal }),\n onsuccess: (user, queries) => queries.set(['users', user.id], user),\n invalidatekeys: [['users']],\n});\n\nconsole.log(created.id);\n```\n\npass an external `signal` when caller owns cancellation. keep pending and error ui state in framework that owns\nthat ui.\n\n## server sent events\n\n`events()` returns an abortable `asynciterableiterator`. breaking loop, calling `return()`, aborting a provided\nsignal, or disposing client stops its request immediately. courier sends `accept: text/event stream` and\n`cache control: no cache` by default; pass headers to override either value.\n\n```ts\ntype notification = { text: string };\n\nfor await (const event of courier.events<notification>('/events')) {\n if (event.event !== 'message') continue;\n console.log(event.data.text);\n break;\n}\n```\n\ncourier parses valid json event data and otherwise returns text. it does not reconnect automatically or retain\nsse event ids; application owns reconnect policy.\n\n## http streaming\n\nuse `read()` for text chunks or ndjson records.\n\n```ts\ntype chatchunk = { done: boolean; delta: string };\n\nfor await (const chunk of courier.read<chatchunk>('/chat', {\n body: { prompt: 'explain cached queries.' },\n method: 'post',\n parse: 'ndjson',\n})) {\n console.log(chunk.delta);\n if (chunk.done) break;\n}\n```\n\nstreams have no timeout unless `timeout` is supplied. http, network, timeout, and cancellation failures use\ncourier error classes; starting a stream after disposal throws `courierdisposederror`.\n\n## framework integration\n\ncreate courier at application or route boundary. views read a key snapshot synchronously, subscribe during\ntheir lifecycle, and let framework own rendering state.\n\n::: code group\n\n```tsx [react]\nimport { useeffect, usesyncexternalstore } from 'react';\nimport { createcourier } from '@vielzeug/courier';\nimport type { asyncstate, querydefinition } from '@vielzeug/courier';\n\ntype user = { id: number; name: string };\n\nexport function profile({ courier, definition }: { courier: returntype<typeof createcourier>; definition: querydefinition<user> }) {\n const state = usesyncexternalstore(\n (listener) => courier.queries.subscribe(definition.key, listener),\n () => courier.queries.getsnapshot<user>(definition.key),\n () => courier.queries.getsnapshot<user>(definition.key),\n ) as asyncstate<user> | null;\n\n useeffect(() => void courier.queries.fetch(definition), [courier, definition]);\n\n if (!state || state.status === 'loading') return <p>loading...</p>;\n if (state.status === 'error') return <p role=\"alert\">{state.error.message}</p>;\n return <p>{state.data.name}</p>;\n}\n```\n\n```ts [vue 3]\nimport { onmounted, onunmounted, ref } from 'vue';\nimport { createcourier } from '@vielzeug/courier';\nimport type { asyncstate, querydefinition } from '@vielzeug/courier';\n\ntype user = { id: number; name: string };\n\nexport function useprofile(courier: returntype<typeof createcourier>, definition: querydefinition<user>) {\n const state = ref<asyncstate<user> | null>(courier.queries.getsnapshot(definition.key));\n const unsubscribe = courier.queries.subscribe(definition.key, () => {\n state.value = courier.queries.getsnapshot(definition.key);\n });\n\n onmounted(() => void courier.queries.fetch(definition));\n onunmounted(unsubscribe);\n\n return { state };\n}\n```\n\n```svelte [svelte]\n<script lang=\"ts\">\n import { onmount } from 'svelte';\n import { createcourier } from '@vielzeug/courier';\n import type { asyncstate, querydefinition } from '@vielzeug/courier';\n\n type user = { id: number; name: string };\n\n export let courier: returntype<typeof createcourier>;\n export let definition: querydefinition<user>;\n let state: asyncstate<user> | null = courier.queries.getsnapshot(definition.key);\n\n onmount(() => {\n const unsubscribe = courier.queries.subscribe(definition.key, () => (state = courier.queries.getsnapshot(definition.key)));\n void courier.queries.fetch(definition);\n return unsubscribe;\n });\n</script>\n\n{#if state?.status === 'success'}\n <p>{state.data.name}</p>\n{/if}\n```\n\n:::\n\ncourier exposes no framework specific loading or error store. render `asyncstate` in framework that owns view.\n\n## working with other vielzeug libraries\n\n### flux\n\nuse flux when cache snapshots or sse events need filtering, composition, or subscription lifecycle separate from\nui framework. pass cache and query definition to `fromquery()`.\n\n```ts\nimport { fromquery } from '@vielzeug/flux/courier';\n\nconst profile = {\n key: ['profile'] as const,\n fetch: ({ signal }: { signal: abortsignal }) => courier.get<{ id: number; name: string }>('/profile', { signal }),\n};\nconst profile$ = fromquery(courier.queries, profile);\n\nvoid courier.queries.fetch(profile);\n\nconst profilesubscription = profile$.subscribe((state) => console.log(state?.status));\n\nprofilesubscription.unsubscribe();\n```\n\n### ripple\n\nuse a ripple signal when courier data must participate in fine grained reactive state outside a component. mirror\nonly cache snapshot into signal.\n\n```ts\nimport { signal } from '@vielzeug/ripple';\n\nconst key = ['profile', 1] as const;\nconst profilestate = signal(courier.queries.getsnapshot<{ id: number; name: string }>(key));\nconst unsubscribe = courier.queries.subscribe(key, () => (profilestate.value = courier.queries.getsnapshot(key)));\n\nawait courier.queries.fetch({\n key,\n fetch: ({ signal }) => courier.get('/profile/{id}', { params: { id: 1 }, signal }),\n});\n\nunsubscribe();\n```\n\n## gotchas\n\n### `mutate()` runs `onsuccess` before `invalidatekeys`\n\n`mutate()` executes in order: `request` → `onsuccess` → `invalidatekeys`. if `onsuccess` calls `queries.set()` for a key also in `invalidatekeys`, the seeded data is overwritten by the background refetch. this is correct — `invalidatekeys` means \"refetch to confirm\" — but the order matters when `onsuccess` seeds optimistic data that `invalidatekeys` then replaces.\n\n### optimistic rollback should use `getsnapshot()`, not `get()`\n\n`queries.get()` only returns successful data and drops snapshot metadata (`status`, `error`, `updatedat`). it also cannot distinguish a missing entry from a successful cached value of `undefined`. for optimistic rollback, capture `getsnapshot()` first so you can restore prior success data with `updatedat`, or `delete(key)` when no previous success snapshot existed.\n\n### `baseurl` should not include query parameters\n\n`buildurl` joins `baseurl` and path with `/`. a base url like `https://api.example.com?token=abc` produces broken urls (`https://api.example.com?token=abc/users`). pass query parameters per request via `config.query` instead.\n\n### background refetch after error transitions through `loading`\n\nwhen `invalidate({ refetch: true })` triggers a background refetch on an error state entry, the snapshot transitions to `loading` (losing the previous error). consumers building \"error + retrying\" ui should track retry state separately — `asyncstate` has no `error` with `isfetching: true` variant.\n\n## best practices\n\n create one courier client per application or ssr request scope.\n use stable, complete cache keys for every cached response identity.\n fetch through `queries.fetch()` when work should deduplicate and cache.\n use `invalidatekeys` on mutations to refetch affected cache entries in one step.\n keep retries outside mutations until operation idempotency is proven.\n dispose only at final application or request boundary.\n keep credentials out of urls when using logging interceptors.\n",
|
|
319
319
|
"examples": " \ntitle: courier — examples\ndescription: practical examples and recipes for courier.\n \n\n## examples\n\n [authentication](./examples/authentication.md)\n [crud operations](./examples/crud operations.md)\n [disposal](./examples/disposal.md)\n [error handling patterns](./examples/error handling patterns.md)\n [file uploads](./examples/file uploads.md)\n [optimistic updates](./examples/optimistic updates.md)\n [polling](./examples/polling.md)\n [real time events](./examples/sse events.md)\n [ai token stream](./examples/ai token stream.md)\n"
|
|
320
320
|
},
|
|
@@ -337,14 +337,14 @@
|
|
|
337
337
|
"name": "@vielzeug/courier",
|
|
338
338
|
"related": "flux ripple spell",
|
|
339
339
|
"slug": "courier",
|
|
340
|
-
"source": "export { type courier, type courieroptions, createcourier } from './courier';\nexport {\n courieraborterror,\n courierdisposederror,\n couriererror,\n courierhttperror,\n couriernetworkerror,\n courierparseerror,\n courierschemavalidationerror,\n couriertimeouterror,\n} from './errors';\nexport { withbearerauth, withlogging, withrequestid } from './interceptors';\nexport type { streamevent, streamoptions } from './stream';\nexport type { fetchcontext, interceptor, transportoptions } from './transport';\nexport type {\n asyncstate,\n mutationcontext,\n mutationoptions,\n querycache,\n querycontext,\n querydefinition,\n querykey,\n querykeyatom,\n unsubscribe,\n} from './types';\nexport type { httprequestconfig as requestconfig, params } from './url';\n"
|
|
340
|
+
"source": "export { type courier, type courierevent, type courieroptions, createcourier } from './courier';\nexport {\n courieraborterror,\n courierdisposederror,\n couriererror,\n courierhttperror,\n couriernetworkerror,\n courierparseerror,\n courierschemavalidationerror,\n couriertimeouterror,\n} from './errors';\nexport { withbearerauth, withlogging, withrequestid } from './interceptors';\nexport type { streamevent, streamoptions } from './stream';\nexport type { fetchcontext, interceptor, transportoptions } from './transport';\nexport type {\n asyncstate,\n mutationcontext,\n mutationoptions,\n querycache,\n querycontext,\n querydefinition,\n querykey,\n querykeyatom,\n unsubscribe,\n} from './types';\nexport type { httprequestconfig as requestconfig, params } from './url';\n"
|
|
341
341
|
},
|
|
342
342
|
{
|
|
343
343
|
"category": "ui interaction",
|
|
344
344
|
"description": "framework agnostic drag and drop. drop zones with mime filtering, sortable lists with drag handles, and explicit connected scopes — zero dependencies.",
|
|
345
345
|
"docs": {
|
|
346
346
|
"index": " \ntitle: dnd — drag and drop primitives for the dom\ndescription: framework agnostic drag and drop. drop zones with mime filtering, sortable lists with drag handles, and explicit connected scopes — zero dependencies.\npackage: dnd\ncategory: ui interaction\nkeywords: [drag drop, sortable, file upload, drop zone, dnd, reorder]\nrelated: [ore, scroll, refine]\nexports: [createdropzone, createsortable, createsortablescope, applyreorder, matchesaccept]\nenvironments: [browser]\n \n\n<! markdownlint disable md025 md033 md060 >\n\n<packagehero package=\"dnd\" />\n\n## why dnd?\n\nthe html5 drag & drop api requires careful counter tracking to avoid hover state flicker, has no mime type pre filtering, and provides no sortable list abstraction.\n\n```ts\n// before — raw html5 drag & drop\nlet entercount = 0;\ndropzone.addeventlistener('dragenter', () => {\n entercount++;\n dropzone.classlist.add('over');\n});\ndropzone.addeventlistener('dragleave', () => {\n if ( entercount === 0) dropzone.classlist.remove('over');\n});\ndropzone.addeventlistener('dragover', (e) => e.preventdefault());\ndropzone.addeventlistener('drop', (e) => {\n e.preventdefault();\n entercount = 0;\n const files = [...e.datatransfer!.files];\n if (!files.every((f) => f.type.startswith('image/'))) return showerror('images only');\n uploadfiles(files);\n});\n\n// after — dnd\nimport { createdropzone } from '@vielzeug/dnd';\nconst zone = createdropzone({\n element: dropzone,\n accept: ['image/*'],\n ondrop: (files) => uploadfiles(files),\n ondroprejected: (files) => showerror(`${files.length} file(s) not accepted`),\n onhoverchange: (hovered) => dropzone.classlist.toggle('over', hovered),\n});\n```\n\n| feature | dnd | sortablejs | dnd kit |\n| | | | |\n| bundle size | <packageinfo package=\"dnd\" type=\"size\" /> | ~15 kb | ~30 kb |\n| framework agnostic | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> |\n| mime type filtering | <ore icon name=\"check\" size=\"16\"></ore icon> pre validated | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> |\n| counter based hover | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> | n/a |\n| sortable lists | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> |\n| drag handles | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> |\n| `using` support | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> |\n| touch support | <ore icon name=\"check\" size=\"16\"></ore icon> scoped opt in | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> |\n| zero dependencies | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> |\n\n<div class=\"decision callout\">\n\n**use dnd when** you need reliable file drop zones with mime filtering or sortable lists in a framework agnostic environment.\n\n**consider dnd kit** if you are building a react app and need complex multi container drag interactions or accessibility first sortable trees.\n\n</div>\n\n## installation\n\n::: code group\n\n```sh [pnpm]\npnpm add @vielzeug/dnd\n```\n\n```sh [npm]\nnpm install @vielzeug/dnd\n```\n\n```sh [yarn]\nyarn add @vielzeug/dnd\n```\n\n:::\n\n## quick start\n\n```ts\nimport { createdropzone, createsortable } from '@vielzeug/dnd';\n\n// file drop zone — with async validation and paste support\nconst dropzone = document.getelementbyid('dropzone')!;\n\nusing zone = createdropzone({\n element: dropzone,\n accept: ['image/*', '.pdf'],\n paste: true,\n onvalidate: (files) => files.every((file) => file.size <= 5_000_000),\n ondrop: (files) => console.log('upload', files),\n ondroprejected: (files) => {\n console.warn(`${files.length} file(s) rejected`);\n },\n onhoverchange: (hovered) => {\n dropzone.classlist.toggle('drag over', hovered);\n },\n});\n\n// sortable list — with revert support for optimistic updates\nlet currentorder = ['a', 'b', 'c'];\n\nusing sortable = createsortable({\n element: document.getelementbyid('list')!,\n keyboard: true,\n onbeforereorder: (from, to) => {\n // record positions here before the dom commits (for flip animations)\n },\n getkey: (el) => el.dataset.sortid!,\n onreorder: ({ ids, setrevert }) => {\n const prev = currentorder;\n currentorder = ids;\n setrevert(() => {\n currentorder = prev;\n });\n },\n});\n```\n\n## features\n\n<div class=\"features grid\">\n\n **counter based hover state** — `onhoverchange` stays accurate when dragging over child elements; hover only activates when the drag payload passes the `accept` filter, with symmetric enter/leave pairing to prevent flicker\n **mime type pre validation** — queries `datatransfer.items` during drag to set `dropeffect='none'` before the drop; confirmed against `file.type` on drop\n **flexible accept patterns** — mime types (`image/png`), wildcards (`image/*`), and file extensions (`.pdf`)\n **`maxfiles` limit** — cap the number of accepted files per drop; excess files are forwarded to `ondroprejected`\n **`onvalidate` async gating** — optional cancellable async step after type filtering; `zone.validating` remains `true` until every pending validation settles\n **clipboard paste support** — `paste: true` routes pasted files through the same `accept`, `maxfiles`, and `onvalidate` pipeline; `onpaste` provides a separate callback; paste rejections are forwarded to `ondroprejected` with the same `(files: file[]) => void` signature as drop rejections\n **`ondroprejected`** — separate callback for files that didn't match `accept`, exceeded `maxfiles`, or were rejected by `onvalidate`; event type reflects whether the rejection came from a drop or a paste\n **sortable lists** — reorders dom children with a placeholder indicator; fires `onreorder` only when the order actually changes\n **drag handles** — scope dragging to a child selector via `handle`; whole item is draggable when omitted\n **custom drag preview** — pass an element or a `(id, item, event) => element | null` factory; control hotspot with `dragimageoffset`\n **`onbeforereorder` flip hook** — fires before commit for both drag and keyboard moves; pair it with [`capturelayout()`](/necromancer/api.md#capturelayout) for lifecycle owned flip animation\n **`sortable.revert()`** — register a revert function via `event.setrevert(fn)` inside `onreorder`; `sortable.revert()` invokes it and clears it for rolling back optimistic updates on server failure\n **boundary safe keyboard reordering** — arrow keys at the first/last item no longer suppress `preventdefault`, so the browser can scroll the page normally\n **transactional connected scopes** — one `onmove` callback receives each cross list transfer with both final orders\n **scoped touch support** — `createsortablescope({ touch: true })` handles only items registered to that scope and uses an inert outline preview\n **explicit dom sync** — call `sortable.sync()` after dom mutations instead of relying on hidden observers\n **`[symbol.dispose]`** — both primitives support the `using` keyword for automatic cleanup\n **reactive friendly options** — `disabled` is re read on each event (reassign `options.disabled = true` to toggle); `accept` captures the array reference, so push/splice mutations are reflected without recreating the zone\n **zero dependencies** — <packageinfo package=\"dnd\" type=\"size\" /> gzipped, <packageinfo package=\"dnd\" type=\"dependencies\" /> dependencies\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 [orbit](/orbit/) — floating element positioning; use alongside dnd to anchor drag previews and drop zone indicators to precise positions\n [ore](/ore/) — web component authoring framework; build draggable custom elements with dnd's pointer event primitives\n [refine](/refine/) — accessible web components; dnd powers the drag and drop inside refine's sortable list and kanban components\n\n</div>\n\n<! markdownlint enable md025 md033 md060 >\n",
|
|
347
|
-
"api": " \ntitle: dnd — api reference\ndescription: complete api reference for dnd.\n \n\n[[toc]]\n\n## api overview\n\n| symbol | purpose | execution mode | common gotcha |\n| | | | |\n| `createdropzone()` | create a typed drop zone controller | sync | dispose the controller during teardown |\n| `createsortable()` | add sortable drag and drop behavior to lists | sync | provide stable item identity for reorder operations |\n| `createsortablescope()` | create a shared scope for connected lists | sync | each set of connected containers needs its own scope instance |\n| `applyreorder()` | apply ordered ids to data arrays | sync | unknown ids are skipped; non mentioned items are appended |\n| `dropzoneoptions.accept` | filter file types before processing | sync | mismatch between mime and extension can reject files unexpectedly |\n| `dropzoneoptions.maxfiles` | cap accepted files per drop | sync | excess accepted files become rejected; `ondroprejected` is called |\n| `matchesaccept()` | test a single `file` against an accept list | sync | extension patterns are case insensitive; empty list accepts all |\n| `dnderror` | base class for dnd errors | sync | use `dnderror.is()` to narrow unknown errors |\n\n## package entry point\n\n| import | purpose |\n| | |\n| `@vielzeug/dnd` | main exports and types |\n\n## types\n\n### `disposable`\n\n```ts\ninterface disposable {\n readonly disposed: boolean;\n readonly disposalsignal: abortsignal;\n dispose(): void;\n [symbol.dispose](): void;\n}\n```\n\n### `dropzoneoptions`\n\n```ts\ninterface dropzoneoptions {\n element: htmlelement;\n accept?: string[];\n maxfiles?: number;\n onvalidate?: (files: file[], context: dropvalidationcontext) => boolean | promise<boolean>;\n disabled?: boolean;\n dropeffect?: datatransfer['dropeffect'];\n ondrop?: (files: file[]) => void;\n ondroprejected?: (files: file[]) => void;\n onhoverchange?: (hovered: boolean) => void;\n onvalidatingchange?: (validating: boolean) => void;\n paste?: boolean;\n onpaste?: (files: file[]) => void;\n}\n```\n\n### `dropzone`\n\n```ts\ninterface dropzone extends disposable {\n readonly hovered: boolean;\n readonly validating: boolean;\n}\n```\n\n### `dropvalidationcontext`\n\n```ts\ninterface dropvalidationcontext {\n readonly signal: abortsignal;\n}\n```\n\n### `sortableoptions`\n\n```ts\ninterface sortableoptions {\n element: htmlelement;\n getkey: (element: htmlelement) => string;\n scope?: sortablescope;\n handle?: string;\n keyboard?: boolean;\n axis?: 'vertical' | 'horizontal';\n autoscroll?: boolean | autoscrolloptions;\n dragimage?: htmlelement | ((id: string, item: htmlelement, event: dragevent) => htmlelement | null | undefined);\n dragimageoffset?: [number, number];\n placeholderclass?: string;\n disabled?: boolean;\n ondragstart?: (id: string, event: dragevent) => void;\n ondragend?: (id: string, event: dragevent) => void;\n onbeforereorder?: (from: string[], to: string[]) => void;\n onreorder?: (event: reorderevent) => void;\n}\n```\n\n### `autoscrolloptions`\n\n```ts\ninterface autoscrolloptions {\n edgethreshold?: number;\n speed?: number;\n container?: boolean;\n viewport?: boolean;\n}\n```\n\n### `reorderevent`\n\n```ts\ninterface reorderevent {\n ids: string[];\n setrevert(fn: () => void): void;\n}\n```\n\n### `sortable`\n\n```ts\ninterface sortable extends disposable {\n readonly isdragging: boolean;\n revert(): void;\n sync(): void;\n}\n```\n\n### `sortablescope`\n\n```ts\ninterface sortablescope extends disposable {\n readonly isdragging: boolean;\n revert(): void;\n}\n```\n\n### `sortablescopeoptions`\n\n```ts\ninterface sortablescopeoptions {\n onmove?: (event: sortablemoveevent) => void;\n touch?: boolean | sortabletouchoptions;\n}\n```\n\n### `sortablemoveevent`\n\n```ts\ninterface sortablemoveevent {\n readonly itemid: string;\n readonly source: htmlelement;\n readonly sourceids: string[];\n readonly target: htmlelement;\n readonly targetids: string[];\n setrevert(fn: () => void): void;\n}\n```\n\n### `sortabletouchoptions`\n\n```ts\ninterface sortabletouchoptions {\n preview?: false | ((item: htmlelement) => htmlelement | null);\n}\n```\n\n`preview` returns a template that dnd clones before mounting it as a transient touch preview, so returning an element from the sortable item does not reparent or remove caller owned dom. return `false` to disable the preview.\n\ntouch sorting tracks the initiating touch by identifier. secondary touches are ignored, and cancellation of the initiating touch restores the pre drag order without firing `onreorder`.\n\n## `createdropzone()`\n\n```ts\ndeclare function createdropzone(options: dropzoneoptions): dropzone;\n```\n\nattaches drag and drop file handling to a dom element. returns a `dropzone` handle.\n\n| option | type | default | description |\n| | | | |\n| `element` | `htmlelement` | — | **required.** the element to attach drag listeners to. |\n| `accept` | `string[]` | `[]` | accepted file types. empty array accepts everything. each entry is a mime type (`'image/png'`), mime wildcard (`'image/*'`), or file extension (`'.pdf'`). |\n| `maxfiles` | `number` | — | maximum files accepted per drop. files beyond this limit are passed to `ondroprejected`. when omitted there is no limit. |\n| `onvalidate` | `(files, { signal }) => boolean \\| promise<boolean>` | — | optional async gating step. return or resolve `false` to reject all accepted files. `validating` remains true until every operation settles; `signal` aborts on disposal. |\n| `disabled` | `boolean` | — | when `true`, all drag and paste events are ignored. a disabled zone does not call `preventdefault` on `dragenter`, `dragover`, `drop`, or `paste`, so underlying elements (text editors, etc.) receive them normally. |\n| `dropeffect` | `'copy' \\| 'move' \\| 'link' \\| 'none'` | `'copy'` | the `dropeffect` set on `datatransfer` during `dragover`. controls the cursor indicator. |\n| `ondrop` | `(files: file[]) => void` | — | called with accepted files only. not called if all dropped files are rejected. also receives paste events when `paste: true` and `onpaste` is omitted. |\n| `ondroprejected` | `(files: file[]) => void` | — | called with files that did not match `accept`, exceeded `maxfiles`, or were rejected by `onvalidate`. |\n| `onhoverchange` | `(hovered: boolean) => void` | — | called when hover state toggles. use this callback for drag over styling. |\n| `onvalidatingchange` | `(validating: boolean) => void` | — | called whenever the aggregate async validation state changes. |\n| `paste` | `boolean` | `false` | when `true`, attaches a `paste` listener to `window`. pasted files run through the same `accept`, `maxfiles`, and `onvalidate` pipeline as dropped files. |\n| `onpaste` | `(files: file[]) => void` | — | called when files are pasted from the clipboard. falls back to `ondrop` when omitted. only active when `paste: true`. |\n\n**returns:** `dropzone`\n\nnotes:\n\n extension accept patterns are approximate during pre check (`datatransferitem` has no filename); exact filtering is applied at drop time.\n hover state (`hovered`) only becomes `true` when the dragged payload passes the `accept` filter. drags carrying rejected file types enter and leave the zone without triggering `onhoverchange`.\n hover state is reset on element drop and also global `window` `drop`/`dragend` to avoid stuck hover state when drags leave the viewport.\n\n```ts\nconst zone = createdropzone({\n element: dropel,\n accept: ['image/*', '.pdf'],\n ondrop: (files) => {\n upload(files);\n },\n ondroprejected: (files) => {\n showerror(`${files.length} rejected`);\n },\n onhoverchange: (hovered) => {\n dropel.classlist.toggle('drag over', hovered);\n },\n});\n```\n\n## `dropzone` interface\n\n### `zone.hovered`\n\n`readonly hovered: boolean`\n\n`true` when a drag is currently over the zone. updated synchronously by the internal counter — safe to read at any time.\n\n### `zone.validating`\n\n`readonly validating: boolean`\n\n`true` while an `onvalidate` promise is pending. use this to render a loading indicator between file selection and the acceptance/rejection callbacks firing.\n\n```ts\nconsole.log(zone.validating); // true between drop and onvalidate resolution\n```\n\n### `zone.disposed`\n\n`readonly disposed: boolean`\n\n`true` once `dispose()` has been called. safe to read at any time.\n\n### `zone.disposalsignal`\n\n`readonly disposalsignal: abortsignal`\n\nan `abortsignal` that fires when `dispose()` is called. use it to cancel in flight requests tied to the zone's lifetime.\n\n### `zone.dispose()`\n\n`dispose(): void`\n\nremoves all event listeners from the element, resets the drag counter and hover state, and clears the `hovered` flag. idempotent — safe to call multiple times.\n\n```ts\nzone.dispose();\n```\n\n### `zone[symbol.dispose]()`\n\n`[symbol.dispose](): void`\n\nalias for `dispose()`. called automatically when used with the `using` keyword.\n\n```ts\n{\n using zone = createdropzone({ element: dropel, ondrop: handlefiles });\n} // zone.dispose() runs here\n```\n\n## `createsortable()`\n\n```ts\ndeclare function createsortable(options: sortableoptions): sortable;\n```\n\nmakes the direct children of a container element reorderable via drag. returns a `sortable` handle.\n\n`createsortable` adds drag and keyboard defaults only when callers have not already supplied semantics. every changed attribute and inline style is restored to its prior value on disposal.\n\n `element`: `htmlelement`, required. the container whose children become sortable.\n `getkey`: `(element: htmlelement) => string`, required. maps each item element to its stable string identity. children for which `getkey` returns a falsy value are skipped.\n `scope`: `sortablescope`, default private scope. connects sortable lists explicitly; containers only exchange items when they share the same scope instance.\n `handle`: `string`. css selector for a drag handle inside each item. when omitted, the whole item is draggable.\n `keyboard`: `boolean`, default `true`. enables keyboard reordering with arrow keys plus `home` and `end`.\n `axis`: `'vertical' | 'horizontal'`, default `'vertical'`. controls midpoint calculation for placeholder insertion.\n `autoscroll`: `boolean | autoscrolloptions`, default `true`. scrolls the container near its edges; enable viewport scrolling with `autoscroll.viewport`.\n `dragimage`: `htmlelement | ((id, item, event) => htmlelement | null | undefined)`. custom native drag preview passed to `datatransfer.setdragimage()`. a `null` or `undefined` return skips `setdragimage` entirely.\n `dragimageoffset`: `[number, number]`, default `[0, 0]`. the `[x, y]` hotspot offset passed to `setdragimage`. controls which point of the preview image follows the cursor.\n `placeholderclass`: `string`, default `'dnd placeholder'`. css class applied to the generated placeholder element.\n `disabled`: `boolean`. blocks drag interactions. if a list becomes disabled mid drag, dnd cancels the drag and restores the original order.\n `ondragstart`: `(id: string, event: dragevent) => void`. called when a drag starts.\n `ondragend`: `(id: string, event: dragevent) => void`. called when a drag ends, whether completed or cancelled.\n `onbeforereorder`: `(from: string[], to: string[]) => void`. called with the before/after order snapshots just before a successful reorder commits — for both drag and keyboard. items are still in their pre commit positions at the time of the call, making it ideal for [`capturelayout()`](/necromancer/api.md#capturelayout) setup.\n `onreorder`: `(event: reorderevent) => void`. called after a successful reorder (drag or keyboard), only when the order changed. use `event.setrevert(fn)` to register a revert function that `sortable.revert()` will invoke.\n\n**returns:** `sortable`\n\n```ts\nconst boardscope = createsortablescope({\n onmove: ({ itemid, sourceids, targetids }) => savemove(itemid, sourceids, targetids),\n touch: true,\n});\n\nconst sortable = createsortable({\n element: listel,\n getkey: (el) => el.dataset.id!,\n handle: '.drag handle',\n ondragstart: (id) => {\n listel.classlist.add('sorting');\n },\n ondragend: (id) => {\n listel.classlist.remove('sorting');\n },\n onreorder: ({ ids, setrevert }) => {\n const prev = currentorder;\n saveorder(ids);\n setrevert(() => saveorder(prev));\n },\n scope: boardscope,\n});\n```\n\n### `createsortablescope()`\n\n```ts\ndeclare function createsortablescope(options?: sortablescopeoptions): sortablescope;\n```\n\nuse one scope per connected set of containers. `onmove` fires once for cross list moves with both final orders; local reorders continue to call the sortable's `onreorder`.\n\n| parameter | type | description |\n| | | |\n| `options` | `sortablescopeoptions` | optional cross list move callback and scope owned touch configuration |\n\n**returns:** `sortablescope`.\n\n```ts\nimport { createsortablescope } from '@vielzeug/dnd';\n\nconst scope = createsortablescope({\n onmove: ({ itemid, sourceids, targetids }) => {\n persistmove(itemid, sourceids, targetids);\n },\n touch: true,\n});\n```\n\n## `sortable` interface\n\n### `sortable.isdragging`\n\n`readonly isdragging: boolean`\n\n`true` while an item drag is in progress.\n\n### `sortable.revert()`\n\n`revert(): void`\n\ncalls the revert function registered via `setrevert` in the last `onreorder` invocation (if any) and clears it. a no op when no revert function was registered or it has already been consumed. works for both drag based and keyboard based reorders.\n\nonly the most recent reorder can be reverted — a new reorder overwrites the stored function.\n\n```ts\nconst sortable = createsortable({\n element: listel,\n getkey: (el) => el.dataset.sortid!,\n onreorder: ({ ids, setrevert }) => {\n const prev = currentorder;\n setorder(ids);\n setrevert(() => setorder(prev)); // ← enable revert\n },\n});\n\n// on server error:\ntry {\n await api.saveorder(ids);\n} catch {\n sortable.revert();\n}\n```\n\n### `sortable.sync()`\n\n`sync(): void`\n\nre applies `draggable`, `role`, and handle attributes after dom mutations. call it after adding, removing, or replacing sortable children.\n\n### `sortable.disposed`\n\n`readonly disposed: boolean`\n\n`true` once `dispose()` has been called.\n\n### `sortable.disposalsignal`\n\n`readonly disposalsignal: abortsignal`\n\nan `abortsignal` that fires when `dispose()` is called.\n\n### `sortable.dispose()`\n\n`dispose(): void`\n\nremoves all event listeners from the container, strips sortable attributes from items and handles, and cancels any in progress drag by restoring the original order. idempotent — safe to call multiple times.\n\n### `sortable[symbol.dispose]()`\n\n`[symbol.dispose](): void`\n\nalias for `dispose()`.\n\n## `sortablescope` interface\n\n### `scope.isdragging`\n\n`readonly isdragging: boolean`\n\n`true` while any sortable registered to the scope is dragging.\n\n### `scope.revert()`\n\n`revert(): void`\n\ncalls and clears the rollback registered with `sortablemoveevent.setrevert()` for the latest cross list move. it is a no op when no rollback is registered.\n\n### `scope.dispose()`\n\n`dispose(): void`\n\ndisposes scope owned touch input and prevents registered lists from participating in future connected moves.\n\n## dom attributes\n\ndnd reads and writes the following dom attributes:\n\n `data dnd item`: internal marker applied by `createsortable` to children that return a truthy key from `getkey`. restored on `dispose()`.\n `draggable`, roles, tabindex, and `touchaction`: managed only as needed and restored to their exact prior values on `dispose()`.\n `data dragging`: set during drag, removed on `dragend` or `dispose()`. use it as your styling hook for drag state.\n `data dnd handle`: internal marker set by `createsortable` and `sortable.sync()`, removed by `dispose()`. lets dnd clean up only the handle attributes it applied.\n `aria hidden=\"true\"`: set on placeholder creation and removed with the placeholder. applied to the `.dnd placeholder` element.\n `style.touchaction = 'none'` (inline style): set by `createsortable` and `sortable.sync()` on the item (or the handle, when `handle` is set), then restored on `dispose()`.\n\n## css classes\n\n| class | applied to | when |\n| | | |\n| `dnd placeholder` | `<div>` inserted by sortable | while an item is being dragged, in the placeholder's position |\n\n## `matchesaccept()`\n\n```ts\ndeclare function matchesaccept(file: file, accept: string[]): boolean;\n```\n\ntests whether a `file` matches an accept pattern list. each pattern can be:\n\n a mime type: `'image/png'`\n a mime wildcard: `'image/*'`\n a file extension: `'.pdf'`\n\nan empty list accepts everything. extension matching is case insensitive.\n\n**returns:** `true` when the file matches at least one pattern, or when `accept` is empty.\n\n```ts\nimport { matchesaccept } from '@vielzeug/dnd';\n\nmatchesaccept(file, ['image/*', '.pdf']); // true or false\n```\n\n## `applyreorder()`\n\n```ts\ndeclare function applyreorder<t>(items: t[], ids: string[], getkey: (item: t) => string): t[];\n```\n\napplies a dom reorder result (`orderedids`) to your backing array.\n\n ids missing from `items` are ignored.\n items not listed in `ids` are appended in original order.\n duplicate ids in `ids` — first occurrence wins, later occurrences are ignored.\n\n**returns:** a new array ordered by `ids`, with omitted items appended in their original order.\n\n```ts\nconst next = applyreorder(items, orderedids, (item) => item.id);\n```\n\n## errors\n\n| error | trigger | notable property |\n| | | |\n| `dnderror` | base class for package errors | `dnderror.is(error)` |\n| `dndscopeerror` | a sortable receives a scope not created by `createsortablescope()` | — |\n",
|
|
347
|
+
"api": " \ntitle: dnd — api reference\ndescription: complete api reference for dnd.\n \n\n[[toc]]\n\n## api overview\n\n| symbol | purpose | execution mode | common gotcha |\n| | | | |\n| `createdropzone()` | create a typed drop zone controller | sync | dispose the controller during teardown |\n| `createsortable()` | add sortable drag and drop behavior to lists | sync | provide stable item identity for reorder operations |\n| `createsortablescope()` | create a shared scope for connected lists | sync | each set of connected containers needs its own scope instance |\n| `applyreorder()` | apply ordered ids to data arrays | sync | unknown ids are skipped; non mentioned items are appended |\n| `dropzoneoptions.accept` | filter file types before processing | sync | mismatch between mime and extension can reject files unexpectedly |\n| `dropzoneoptions.maxfiles` | cap accepted files per drop | sync | excess accepted files become rejected; `ondroprejected` is called |\n| `matchesaccept()` | test a single `file` against an accept list | sync | extension patterns are case insensitive; empty list accepts all |\n| `dnderror` | base class for dnd errors | sync | use `instanceof dnderror` to narrow unknown errors |\n\n## package entry point\n\n| import | purpose |\n| | |\n| `@vielzeug/dnd` | main exports and types |\n\n## types\n\n### `disposable`\n\n```ts\ninterface disposable {\n readonly disposed: boolean;\n readonly disposalsignal: abortsignal;\n dispose(): void;\n [symbol.dispose](): void;\n}\n```\n\n### `dropzoneoptions`\n\n```ts\ninterface dropzoneoptions {\n element: htmlelement;\n accept?: string[];\n maxfiles?: number;\n onvalidate?: (files: file[], context: dropvalidationcontext) => boolean | promise<boolean>;\n disabled?: boolean;\n dropeffect?: datatransfer['dropeffect'];\n ondrop?: (files: file[]) => void;\n ondroprejected?: (files: file[]) => void;\n onhoverchange?: (hovered: boolean) => void;\n onvalidatingchange?: (validating: boolean) => void;\n paste?: boolean;\n onpaste?: (files: file[]) => void;\n}\n```\n\n### `dropzone`\n\n```ts\ninterface dropzone extends disposable {\n readonly hovered: boolean;\n readonly validating: boolean;\n}\n```\n\n### `dropvalidationcontext`\n\n```ts\ninterface dropvalidationcontext {\n readonly signal: abortsignal;\n}\n```\n\n### `sortableoptions`\n\n```ts\ninterface sortableoptions {\n element: htmlelement;\n getkey: (element: htmlelement) => string;\n scope?: sortablescope;\n handle?: string;\n keyboard?: boolean;\n axis?: 'vertical' | 'horizontal';\n autoscroll?: boolean | autoscrolloptions;\n dragimage?: htmlelement | ((id: string, item: htmlelement, event: dragevent) => htmlelement | null | undefined);\n dragimageoffset?: [number, number];\n placeholderclass?: string;\n disabled?: boolean;\n ondragstart?: (id: string, event: dragevent) => void;\n ondragend?: (id: string, event: dragevent) => void;\n onbeforereorder?: (from: string[], to: string[]) => void;\n onreorder?: (event: reorderevent) => void;\n}\n```\n\n### `autoscrolloptions`\n\n```ts\ninterface autoscrolloptions {\n edgethreshold?: number;\n speed?: number;\n container?: boolean;\n viewport?: boolean;\n}\n```\n\n### `reorderevent`\n\n```ts\ninterface reorderevent {\n ids: string[];\n setrevert(fn: () => void): void;\n}\n```\n\n### `sortable`\n\n```ts\ninterface sortable extends disposable {\n readonly isdragging: boolean;\n revert(): void;\n sync(): void;\n}\n```\n\n### `sortablescope`\n\n```ts\ninterface sortablescope extends disposable {\n readonly isdragging: boolean;\n revert(): void;\n}\n```\n\n### `sortablescopeoptions`\n\n```ts\ninterface sortablescopeoptions {\n onmove?: (event: sortablemoveevent) => void;\n touch?: boolean | sortabletouchoptions;\n}\n```\n\n### `sortablemoveevent`\n\n```ts\ninterface sortablemoveevent {\n readonly itemid: string;\n readonly source: htmlelement;\n readonly sourceids: string[];\n readonly target: htmlelement;\n readonly targetids: string[];\n setrevert(fn: () => void): void;\n}\n```\n\n### `sortabletouchoptions`\n\n```ts\ninterface sortabletouchoptions {\n preview?: false | ((item: htmlelement) => htmlelement | null);\n}\n```\n\n`preview` returns a template that dnd clones before mounting it as a transient touch preview, so returning an element from the sortable item does not reparent or remove caller owned dom. return `false` to disable the preview.\n\ntouch sorting tracks the initiating touch by identifier. secondary touches are ignored, and cancellation of the initiating touch restores the pre drag order without firing `onreorder`.\n\n## `createdropzone()`\n\n```ts\ndeclare function createdropzone(options: dropzoneoptions): dropzone;\n```\n\nattaches drag and drop file handling to a dom element. returns a `dropzone` handle.\n\n| option | type | default | description |\n| | | | |\n| `element` | `htmlelement` | — | **required.** the element to attach drag listeners to. |\n| `accept` | `string[]` | `[]` | accepted file types. empty array accepts everything. each entry is a mime type (`'image/png'`), mime wildcard (`'image/*'`), or file extension (`'.pdf'`). |\n| `maxfiles` | `number` | — | maximum files accepted per drop. files beyond this limit are passed to `ondroprejected`. when omitted there is no limit. |\n| `onvalidate` | `(files, { signal }) => boolean \\| promise<boolean>` | — | optional async gating step. return or resolve `false` to reject all accepted files. `validating` remains true until every operation settles; `signal` aborts on disposal. |\n| `disabled` | `boolean` | — | when `true`, all drag and paste events are ignored. a disabled zone does not call `preventdefault` on `dragenter`, `dragover`, `drop`, or `paste`, so underlying elements (text editors, etc.) receive them normally. |\n| `dropeffect` | `'copy' \\| 'move' \\| 'link' \\| 'none'` | `'copy'` | the `dropeffect` set on `datatransfer` during `dragover`. controls the cursor indicator. |\n| `ondrop` | `(files: file[]) => void` | — | called with accepted files only. not called if all dropped files are rejected. also receives paste events when `paste: true` and `onpaste` is omitted. |\n| `ondroprejected` | `(files: file[]) => void` | — | called with files that did not match `accept`, exceeded `maxfiles`, or were rejected by `onvalidate`. |\n| `onhoverchange` | `(hovered: boolean) => void` | — | called when hover state toggles. use this callback for drag over styling. |\n| `onvalidatingchange` | `(validating: boolean) => void` | — | called whenever the aggregate async validation state changes. |\n| `paste` | `boolean` | `false` | when `true`, attaches a `paste` listener to `window`. pasted files run through the same `accept`, `maxfiles`, and `onvalidate` pipeline as dropped files. |\n| `onpaste` | `(files: file[]) => void` | — | called when files are pasted from the clipboard. falls back to `ondrop` when omitted. only active when `paste: true`. |\n\n**returns:** `dropzone`\n\nnotes:\n\n extension accept patterns are approximate during pre check (`datatransferitem` has no filename); exact filtering is applied at drop time.\n hover state (`hovered`) only becomes `true` when the dragged payload passes the `accept` filter. drags carrying rejected file types enter and leave the zone without triggering `onhoverchange`.\n hover state is reset on element drop and also global `window` `drop`/`dragend` to avoid stuck hover state when drags leave the viewport.\n\n```ts\nconst zone = createdropzone({\n element: dropel,\n accept: ['image/*', '.pdf'],\n ondrop: (files) => {\n upload(files);\n },\n ondroprejected: (files) => {\n showerror(`${files.length} rejected`);\n },\n onhoverchange: (hovered) => {\n dropel.classlist.toggle('drag over', hovered);\n },\n});\n```\n\n## `dropzone` interface\n\n### `zone.hovered`\n\n`readonly hovered: boolean`\n\n`true` when a drag is currently over the zone. updated synchronously by the internal counter — safe to read at any time.\n\n### `zone.validating`\n\n`readonly validating: boolean`\n\n`true` while an `onvalidate` promise is pending. use this to render a loading indicator between file selection and the acceptance/rejection callbacks firing.\n\n```ts\nconsole.log(zone.validating); // true between drop and onvalidate resolution\n```\n\n### `zone.disposed`\n\n`readonly disposed: boolean`\n\n`true` once `dispose()` has been called. safe to read at any time.\n\n### `zone.disposalsignal`\n\n`readonly disposalsignal: abortsignal`\n\nan `abortsignal` that fires when `dispose()` is called. use it to cancel in flight requests tied to the zone's lifetime.\n\n### `zone.dispose()`\n\n`dispose(): void`\n\nremoves all event listeners from the element, resets the drag counter and hover state, and clears the `hovered` flag. idempotent — safe to call multiple times.\n\n```ts\nzone.dispose();\n```\n\n### `zone[symbol.dispose]()`\n\n`[symbol.dispose](): void`\n\nalias for `dispose()`. called automatically when used with the `using` keyword.\n\n```ts\n{\n using zone = createdropzone({ element: dropel, ondrop: handlefiles });\n} // zone.dispose() runs here\n```\n\n## `createsortable()`\n\n```ts\ndeclare function createsortable(options: sortableoptions): sortable;\n```\n\nmakes the direct children of a container element reorderable via drag. returns a `sortable` handle.\n\n`createsortable` adds drag and keyboard defaults only when callers have not already supplied semantics. every changed attribute and inline style is restored to its prior value on disposal.\n\n `element`: `htmlelement`, required. the container whose children become sortable.\n `getkey`: `(element: htmlelement) => string`, required. maps each item element to its stable string identity. children for which `getkey` returns a falsy value are skipped.\n `scope`: `sortablescope`, default private scope. connects sortable lists explicitly; containers only exchange items when they share the same scope instance.\n `handle`: `string`. css selector for a drag handle inside each item. when omitted, the whole item is draggable.\n `keyboard`: `boolean`, default `true`. enables keyboard reordering with arrow keys plus `home` and `end`.\n `axis`: `'vertical' | 'horizontal'`, default `'vertical'`. controls midpoint calculation for placeholder insertion.\n `autoscroll`: `boolean | autoscrolloptions`, default `true`. scrolls the container near its edges; enable viewport scrolling with `autoscroll.viewport`.\n `dragimage`: `htmlelement | ((id, item, event) => htmlelement | null | undefined)`. custom native drag preview passed to `datatransfer.setdragimage()`. a `null` or `undefined` return skips `setdragimage` entirely.\n `dragimageoffset`: `[number, number]`, default `[0, 0]`. the `[x, y]` hotspot offset passed to `setdragimage`. controls which point of the preview image follows the cursor.\n `placeholderclass`: `string`, default `'dnd placeholder'`. css class applied to the generated placeholder element.\n `disabled`: `boolean`. blocks drag interactions. if a list becomes disabled mid drag, dnd cancels the drag and restores the original order.\n `ondragstart`: `(id: string, event: dragevent) => void`. called when a drag starts.\n `ondragend`: `(id: string, event: dragevent) => void`. called when a drag ends, whether completed or cancelled.\n `onbeforereorder`: `(from: string[], to: string[]) => void`. called with the before/after order snapshots just before a successful reorder commits — for both drag and keyboard. items are still in their pre commit positions at the time of the call, making it ideal for [`capturelayout()`](/necromancer/api.md#capturelayout) setup.\n `onreorder`: `(event: reorderevent) => void`. called after a successful reorder (drag or keyboard), only when the order changed. use `event.setrevert(fn)` to register a revert function that `sortable.revert()` will invoke.\n\n**returns:** `sortable`\n\n```ts\nconst boardscope = createsortablescope({\n onmove: ({ itemid, sourceids, targetids }) => savemove(itemid, sourceids, targetids),\n touch: true,\n});\n\nconst sortable = createsortable({\n element: listel,\n getkey: (el) => el.dataset.id!,\n handle: '.drag handle',\n ondragstart: (id) => {\n listel.classlist.add('sorting');\n },\n ondragend: (id) => {\n listel.classlist.remove('sorting');\n },\n onreorder: ({ ids, setrevert }) => {\n const prev = currentorder;\n saveorder(ids);\n setrevert(() => saveorder(prev));\n },\n scope: boardscope,\n});\n```\n\n### `createsortablescope()`\n\n```ts\ndeclare function createsortablescope(options?: sortablescopeoptions): sortablescope;\n```\n\nuse one scope per connected set of containers. `onmove` fires once for cross list moves with both final orders; local reorders continue to call the sortable's `onreorder`.\n\n| parameter | type | description |\n| | | |\n| `options` | `sortablescopeoptions` | optional cross list move callback and scope owned touch configuration |\n\n**returns:** `sortablescope`.\n\n```ts\nimport { createsortablescope } from '@vielzeug/dnd';\n\nconst scope = createsortablescope({\n onmove: ({ itemid, sourceids, targetids }) => {\n persistmove(itemid, sourceids, targetids);\n },\n touch: true,\n});\n```\n\n## `sortable` interface\n\n### `sortable.isdragging`\n\n`readonly isdragging: boolean`\n\n`true` while an item drag is in progress.\n\n### `sortable.revert()`\n\n`revert(): void`\n\ncalls the revert function registered via `setrevert` in the last `onreorder` invocation (if any) and clears it. a no op when no revert function was registered or it has already been consumed. works for both drag based and keyboard based reorders.\n\nonly the most recent reorder can be reverted — a new reorder overwrites the stored function.\n\n```ts\nconst sortable = createsortable({\n element: listel,\n getkey: (el) => el.dataset.sortid!,\n onreorder: ({ ids, setrevert }) => {\n const prev = currentorder;\n setorder(ids);\n setrevert(() => setorder(prev)); // ← enable revert\n },\n});\n\n// on server error:\ntry {\n await api.saveorder(ids);\n} catch {\n sortable.revert();\n}\n```\n\n### `sortable.sync()`\n\n`sync(): void`\n\nre applies `draggable`, `role`, and handle attributes after dom mutations. call it after adding, removing, or replacing sortable children.\n\n### `sortable.disposed`\n\n`readonly disposed: boolean`\n\n`true` once `dispose()` has been called.\n\n### `sortable.disposalsignal`\n\n`readonly disposalsignal: abortsignal`\n\nan `abortsignal` that fires when `dispose()` is called.\n\n### `sortable.dispose()`\n\n`dispose(): void`\n\nremoves all event listeners from the container, strips sortable attributes from items and handles, and cancels any in progress drag by restoring the original order. idempotent — safe to call multiple times.\n\n### `sortable[symbol.dispose]()`\n\n`[symbol.dispose](): void`\n\nalias for `dispose()`.\n\n## `sortablescope` interface\n\n### `scope.isdragging`\n\n`readonly isdragging: boolean`\n\n`true` while any sortable registered to the scope is dragging.\n\n### `scope.revert()`\n\n`revert(): void`\n\ncalls and clears the rollback registered with `sortablemoveevent.setrevert()` for the latest cross list move. it is a no op when no rollback is registered.\n\n### `scope.dispose()`\n\n`dispose(): void`\n\ndisposes scope owned touch input and prevents registered lists from participating in future connected moves.\n\n## dom attributes\n\ndnd reads and writes the following dom attributes:\n\n `data dnd item`: internal marker applied by `createsortable` to children that return a truthy key from `getkey`. restored on `dispose()`.\n `draggable`, roles, tabindex, and `touchaction`: managed only as needed and restored to their exact prior values on `dispose()`.\n `data dragging`: set during drag, removed on `dragend` or `dispose()`. use it as your styling hook for drag state.\n `data dnd handle`: internal marker set by `createsortable` and `sortable.sync()`, removed by `dispose()`. lets dnd clean up only the handle attributes it applied.\n `aria hidden=\"true\"`: set on placeholder creation and removed with the placeholder. applied to the `.dnd placeholder` element.\n `style.touchaction = 'none'` (inline style): set by `createsortable` and `sortable.sync()` on the item (or the handle, when `handle` is set), then restored on `dispose()`.\n\n## css classes\n\n| class | applied to | when |\n| | | |\n| `dnd placeholder` | `<div>` inserted by sortable | while an item is being dragged, in the placeholder's position |\n\n## `matchesaccept()`\n\n```ts\ndeclare function matchesaccept(file: file, accept: string[]): boolean;\n```\n\ntests whether a `file` matches an accept pattern list. each pattern can be:\n\n a mime type: `'image/png'`\n a mime wildcard: `'image/*'`\n a file extension: `'.pdf'`\n\nan empty list accepts everything. extension matching is case insensitive.\n\n**returns:** `true` when the file matches at least one pattern, or when `accept` is empty.\n\n```ts\nimport { matchesaccept } from '@vielzeug/dnd';\n\nmatchesaccept(file, ['image/*', '.pdf']); // true or false\n```\n\n## `applyreorder()`\n\n```ts\ndeclare function applyreorder<t>(items: t[], ids: string[], getkey: (item: t) => string): t[];\n```\n\napplies a dom reorder result (`orderedids`) to your backing array.\n\n ids missing from `items` are ignored.\n items not listed in `ids` are appended in original order.\n duplicate ids in `ids` — first occurrence wins, later occurrences are ignored.\n\n**returns:** a new array ordered by `ids`, with omitted items appended in their original order.\n\n```ts\nconst next = applyreorder(items, orderedids, (item) => item.id);\n```\n\n## errors\n\n| error | trigger | notable property |\n| | | |\n| `dnderror` | base class for package errors | use `instanceof dnderror` to narrow |\n| `dndscopeerror` | a sortable receives a scope not created by `createsortablescope()` | — |\n",
|
|
348
348
|
"usage": " \ntitle: dnd — usage guide\ndescription: drop zones, sortable lists, explicit connected scopes, keyboard sorting, and cleanup patterns with dnd.\n \n\n[[toc]]\n\n## basic usage\n\n`createdropzone` attaches drag and drop behavior to any dom element and keeps hover state stable with a counter.\n\n```ts\nimport { createdropzone } from '@vielzeug/dnd';\n\nconst dropzone = document.getelementbyid('dropzone')!;\n\nconst zone = createdropzone({\n element: dropzone,\n ondrop: (files) => {\n console.log('accepted files:', files);\n },\n});\n```\n\n### accept filtering\n\n```ts\nconst zone = createdropzone({\n element: dropel,\n accept: ['image/*', '.pdf', 'application/json'],\n ondrop: (files) => {\n // accepted files only\n },\n ondroprejected: (files) => {\n showtoast(`${files.length} file(s) not accepted`);\n },\n});\n```\n\nthe `accept` list is read at drop time, so mutating the array dynamically adjusts what is accepted for the next drop.\n\n### hover state\n\n```ts\nconst zone = createdropzone({\n element: dropel,\n onhoverchange: (hovered) => {\n dropel.classlist.toggle('drag over', hovered);\n },\n});\n```\n\nread zone state imperatively:\n\n```ts\nconsole.log(zone.hovered);\nconsole.log(zone.validating);\n```\n\n### drop effect\n\n```ts\ncreatedropzone({\n element: dropel,\n dropeffect: 'move',\n ondrop: (files) => {\n // ...\n },\n});\n```\n\n### disabled state\n\n```ts\nconst options = { disabled: false, element: dropel, ondrop: handlefiles };\nconst zone = createdropzone(options);\n\n// options.disabled is read live on each event — mutate to toggle:\noptions.disabled = isreadonly;\n```\n\n### file limit\n\n```ts\nconst zone = createdropzone({\n element: dropel,\n accept: ['image/*'],\n maxfiles: 5,\n ondrop: (files) => {\n // 1 5 accepted files\n },\n ondroprejected: (files) => {\n showtoast(`only 5 files at a time. ${files.length} were ignored.`);\n },\n});\n```\n\n### cleanup\n\n```ts\nzone.dispose();\n// or:\nusing zone = createdropzone({ element: dropel, ondrop: handlefiles });\n```\n\n### async validation\n\ngate drops behind an async check with `onvalidate`. the zone remains `validating: true` until every pending validation settles, and disposal aborts each validation signal.\n\n```ts\nconst zone = createdropzone({\n element: dropel,\n accept: ['image/*'],\n onvalidate: async (files, { signal }) => {\n const ok = await checkserverquota(files, { signal });\n return ok; // false → all files forwarded to ondroprejected\n },\n ondrop: (files) => uploadfiles(files),\n ondroprejected: (files) => showerror('quota exceeded'),\n});\n\n// show a spinner while checking\nconsole.log(zone.validating); // true during pending check\n```\n\na synchronous boolean return skips the microtask queue entirely:\n\n```ts\nconst zone = createdropzone({\n element: dropel,\n onvalidate: (files) => files.every((f) => f.size < 5_000_000), // sync\n ondrop: handlefiles,\n});\n```\n\n### clipboard paste\n\nset `paste: true` to accept files pasted from the clipboard. the same `accept`, `maxfiles`, and `onvalidate` pipeline applies.\n\n```ts\nconst zone = createdropzone({\n element: dropel,\n paste: true,\n accept: ['image/*'],\n onpaste: (files) => {\n uploadfiles(files);\n },\n ondroprejected: (files) => {\n showerror(`${files.length} file(s) not accepted`);\n },\n});\n```\n\nwhen `onpaste` is omitted, accepted pasted files fall through to `ondrop`.\n\n## sortable\n\n`createsortable` makes direct children of a container reorderable via drag.\n\n### setup\n\n```html\n<ul id=\"task list\">\n <li data sort id=\"task 1\">design</li>\n <li data sort id=\"task 2\">develop</li>\n <li data sort id=\"task 3\">review</li>\n</ul>\n```\n\n```ts\nconst sortable = createsortable({\n element: document.getelementbyid('task list')!,\n getkey: (el) => el.dataset.sortid!,\n axis: 'vertical',\n onreorder: ({ ids }) => {\n savetaskorder(ids);\n },\n});\n```\n\ndnd automatically sets:\n\n `draggable=\"true\"` on sortable nodes (or handles)\n `role=\"listitem\"` on each item\n `role=\"list\"` on the container\n `tabindex=\"0\"` on each item for keyboard reordering\n\n### drag handles\n\n```ts\ncreatesortable({\n element: listel,\n getkey: (el) => el.dataset.sortid!,\n handle: '.drag handle',\n onreorder: ({ ids }) => saveorder(ids),\n});\n```\n\n### keyboard reordering\n\nfocus an item and use arrow keys to move it. `home` and `end` move to the boundary positions.\n\nwhen an item is already at the first or last position, the boundary key press is not consumed — the browser handles it normally (for example, scrolling the page). only keys that actually move an item call `preventdefault`.\n\n### connected lists\n\ncreate a shared scope when items should move between containers:\n\n```ts\nconst boardscope = createsortablescope({\n onmove: ({ itemid, sourceids, targetids }) => {\n persistmove(itemid, sourceids, targetids);\n },\n touch: true,\n});\n\ncreatesortable({\n element: todoel,\n getkey: (el) => el.dataset.sortid!,\n scope: boardscope,\n});\ncreatesortable({\n element: doneel,\n getkey: (el) => el.dataset.sortid!,\n scope: boardscope,\n});\n```\n\n### auto scroll and drag preview\n\n```ts\ncreatesortable({\n element: listel,\n getkey: (el) => el.dataset.sortid!,\n autoscroll: { edgethreshold: 40, speed: 24, viewport: true },\n dragimage: (id, item) => item,\n dragimageoffset: [8, 8],\n});\n```\n\nviewport scrolling is opt in. container scrolling stays enabled by default.\n\n### lifecycle hooks\n\n```ts\ncreatesortable({\n element: listel,\n getkey: (el) => el.dataset.sortid!,\n ondragstart: (id) => {\n listel.classlist.add('sorting');\n },\n ondragend: (id) => {\n listel.classlist.remove('sorting');\n },\n onreorder: ({ ids }) => saveorder(ids),\n});\n```\n\n### custom identity function\n\n```ts\ncreatesortable({\n element: listel,\n getkey: (el) => el.getattribute('data id')!,\n onreorder: ({ ids }) => saveorder(ids),\n});\n```\n\n### dynamic lists\n\ncall `sortable.sync()` after adding, removing, or replacing sortable items.\n\n```ts\nconst item = document.createelement('li');\nitem.dataset.sortid = 'task 4';\nitem.textcontent = 'deploy';\nlistel.appendchild(item);\nsortable.sync();\n```\n\n### disabled state\n\n```ts\nimport { createsortable, type sortableoptions } from '@vielzeug/dnd';\n\nconst options: sortableoptions = {\n disabled: false,\n element: listel,\n getkey: (el) => el.dataset.sortid!,\n onreorder: ({ ids }) => saveorder(ids),\n};\nconst sortable = createsortable(options);\n\n// options.disabled is read live on each event — mutate to toggle:\noptions.disabled = islocked;\n```\n\n### placeholder styling\n\n```css\n.dnd placeholder {\n background: var( color primary 50);\n border: 2px dashed var( color primary 300);\n border radius: 4px;\n box sizing: border box;\n}\n\n[data dragging] {\n opacity: 0.35;\n box shadow: 0 4px 12px rgba(0, 0, 0, 0.15);\n}\n```\n\n### mapping dom order back to data\n\n```ts\nimport { applyreorder, createsortable } from '@vielzeug/dnd';\n\nlet items = [\n { id: 'task 1', title: 'design' },\n { id: 'task 2', title: 'develop' },\n { id: 'task 3', title: 'review' },\n];\n\ncreatesortable({\n element: listel,\n getkey: (el) => el.dataset.sortid!,\n onreorder: ({ ids }) => {\n items = applyreorder(items, ids, (item) => item.id);\n },\n});\n```\n\n### cleanup\n\n```ts\nsortable.dispose();\n// or:\nusing sortable = createsortable({\n element: listel,\n getkey: (el) => el.dataset.sortid!,\n onreorder: ({ ids }) => saveorder(ids),\n});\n```\n\n### flip animation hook\n\n`onbeforereorder` fires just before the dom reorder commits, for both drag and keyboard moves. pair it with [`capturelayout()`](/necromancer/api.md#capturelayout) to animate the resulting layout without managing rectangles, transforms, or animation frames yourself.\n\n```ts\nimport { capturelayout, type layouttransition } from '@vielzeug/necromancer';\n\nlet layout: layouttransition | undefined;\n\nconst sortable = createsortable({\n element: listel,\n getkey: (el) => el.dataset.sortid!,\n onbeforereorder: () => {\n layout = capturelayout(listel.queryselectorall('[data sort id]'), {\n getkey: (el) => el.dataset.sortid!,\n });\n },\n onreorder: ({ ids }) => {\n saveorder(ids); // commit a framework render here when needed.\n layout?.animate({\n duration: 200,\n easing: 'ease out',\n elements: listel.queryselectorall('[data sort id]'),\n });\n layout = undefined;\n },\n});\n```\n\nif `saveorder()` triggers a render that replaces list items, call `layout?.animate({ elements: committeditems })` after that render commits. when dnd's own reordered elements remain in the dom, call `layout?.animate()` directly. dnd stays dependency free: the application chooses to install and import necromancer when it wants this integration.\n\n### optimistic updates and revert\n\ncall `sortable.revert()` to roll back the most recent reorder. register a revert function via `setrevert` inside `onreorder`.\n\n```ts\nconst sortable = createsortable({\n element: listel,\n getkey: (el) => el.dataset.sortid!,\n onreorder: ({ ids, setrevert }) => {\n const prev = currentorder;\n setorder(ids); // optimistic update\n setrevert(() => setorder(prev)); // registered for sortable.revert()\n },\n});\n\n// on server error:\ntry {\n await api.saveorder(currentorder);\n} catch {\n sortable.revert();\n}\n```\n\n## touch support\n\nhtml5 drag and drop has no native touch story. enable touch on a sortable scope; it only recognizes items registered to that scope, never unrelated `draggable` elements.\n\n```ts\nimport { createsortable, createsortablescope } from '@vielzeug/dnd';\n\nusing scope = createsortablescope({ touch: true });\nusing sortable = createsortable({ element: listel, getkey: (el) => el.dataset.id!, scope });\n```\n\nthe scope tracks the touch that initiated the drag by its identifier. additional fingers cannot move, finish, or replace the active drag. if the initiating touch is cancelled, dnd restores the original item order and removes the transient preview.\n\n### touch preview\n\ntouch uses an inert outline by default, avoiding cloned application dom. provide a preview factory or opt out when your item styling supplies its own feedback.\n\n```ts\nconst scope = createsortablescope({\n touch: {\n // the returned element is cloned before dnd mounts it as a transient preview.\n preview: (item) => item.queryselector<htmlelement>('.drag preview'),\n },\n});\n```\n\n### why draggable items get `touch action: none`\n\n`createsortable` sets `touch action: none` on every element it marks as draggable (the item itself, or the handle when `handle` is set). this prevents a mobile browser from treating the initial movement as page scrolling before the scope controller can start the drag.\n\nthis has no effect on mouse/pointer input.\n\n## testing\n\ntest observable callbacks and controller state with your dom test runner. construct the zone in each test, dispatch a real `drop` event, then dispose it during teardown.\n\n```ts\nimport { aftereach, expect, it, vi } from 'vitest';\nimport { createdropzone } from '@vielzeug/dnd';\n\nconst zones: array<{ dispose(): void }> = [];\n\naftereach(() => zones.splice(0).foreach((zone) => zone.dispose()));\n\nit('forwards accepted files', async () => {\n const element = document.createelement('div');\n const ondrop = vi.fn();\n const zone = createdropzone({ element, ondrop });\n zones.push(zone);\n const file = new file(['content'], 'readme.txt', { type: 'text/plain' });\n const event = new event('drop') as dragevent;\n\n object.defineproperty(event, 'datatransfer', { value: { files: [file] } });\n element.dispatchevent(event);\n\n await promise.resolve();\n\n expect(ondrop).tohavebeencalledwith([file]);\n expect(zone.disposed).tobe(false);\n});\n```\n\n## framework integration\n\n::: code group\n\n```tsx [react]\nimport { useeffect, useref } from 'react';\nimport { createsortable, applyreorder } from '@vielzeug/dnd';\n\nfunction sortablelist({ initialitems }: { initialitems: { id: string; text: string }[] }) {\n const listref = useref<htmlulistelement>(null);\n const items = useref(initialitems);\n\n useeffect(() => {\n const sortable = createsortable({\n element: listref.current!,\n getkey: (el) => el.dataset.sortid!,\n onreorder: ({ ids }) => {\n items.current = applyreorder(items.current, ids, (i) => i.id);\n },\n });\n return () => sortable.dispose();\n }, []);\n\n return (\n <ul ref={listref}>\n {initialitems.map((item) => (\n <li key={item.id} data sort id={item.id}>\n {item.text}\n </li>\n ))}\n </ul>\n );\n}\n```\n\n```ts [vue 3]\nimport { ref, onmounted, onunmounted } from 'vue';\nimport { createsortable, applyreorder, type sortable } from '@vielzeug/dnd';\n\nfunction usesortable(items: { id: string; text: string }[]) {\n const listref = ref<htmlelement | null>(null);\n const ordereditems = ref(items);\n let sortable: sortable | null = null;\n\n onmounted(() => {\n sortable = createsortable({\n element: listref.value!,\n getkey: (el) => el.dataset.sortid!,\n onreorder: ({ ids }) => {\n ordereditems.value = applyreorder(ordereditems.value, ids, (i) => i.id);\n },\n });\n });\n\n onunmounted(() => sortable?.dispose());\n return { listref, ordereditems };\n}\n```\n\n```svelte [svelte]\n<script lang=\"ts\">\n import { onmount } from 'svelte';\n import { createsortable, applyreorder } from '@vielzeug/dnd';\n\n export let initialitems: { id: string; text: string }[] = [];\n let items = initialitems;\n let listel: htmlulistelement;\n\n onmount(() => {\n const sortable = createsortable({\n element: listel,\n getkey: (el) => el.dataset.sortid!,\n onreorder: ({ ids }) => { items = applyreorder(items, ids, (i) => i.id); },\n });\n return () => sortable.dispose();\n });\n</script>\n\n<ul bind:this={listel}>\n {#each items as item (item.id)}\n <li data sort id={item.id}>{item.text}</li>\n {/each}\n</ul>\n```\n\n:::\n\n## working with other vielzeug libraries\n\n### with ore\n\nuse dnd in custom web components by attaching behavior in component lifecycle hooks.\n\n```ts\nimport { createsortable } from '@vielzeug/dnd';\nimport { define, gethost, html, onmounted } from '@vielzeug/ore';\n\ndefine('task list', {\n setup(_props) {\n const el = gethost();\n\n onmounted(() => {\n const sortable = createsortable({\n element: el,\n getkey: (el) => el.dataset.sortid!,\n onreorder: ({ ids }) => save(ids),\n });\n return () => sortable.dispose();\n });\n\n return html`<slot></slot>`;\n },\n});\n```\n\n## best practices\n\n attach `createdropzone` and `createsortable` after the container element is in the dom — use `onmounted` in component frameworks.\n call `.dispose()` in the cleanup phase of your framework (useeffect return, onunmounted, ondestroy) to prevent memory leaks.\n use `data sort id` attributes that match your data's identity field — do not use dom index as an identifier.\n prefer `applyreorder()` over manual array splicing to keep your data array in sync with dom order.\n use `createsortablescope()` only when items should genuinely move between containers.\n use drag handles (`.handle` selector) when the full item surface area conflicts with other interactions such as text selection.\n test keyboard reordering explicitly — dnd sets `tabindex` on items and supports arrow keys by default.\n enable `touch: true` only on scopes that own touch sortable lists.\n",
|
|
349
349
|
"examples": " \ntitle: dnd — examples\ndescription: practical examples and recipes for dnd.\n \n\n## examples\n\n [sortable list](./examples/sortable list.md)\n [touch enabled sortable list](./examples/touch enabled sortable list.md)\n [file upload drop zone](./examples/file upload drop zone.md)\n [optimistic reorder with revert and flip animation](./examples/optimistic reorder with revert.md)\n [combined sortable with inline editing](./examples/combined sortable with inline editing.md)\n [connected kanban keyboard sorting](./examples/connected kanban keyboard sorting.md)\n [web component with ore](./examples/web component with craft.md)\n [using `using` for scoped cleanup](./examples/using using for scoped cleanup.md)\n"
|
|
350
350
|
},
|
|
@@ -398,7 +398,7 @@
|
|
|
398
398
|
"description": "typed es module worker pools with cancellation, priority scheduling, streaming, and test utilities.",
|
|
399
399
|
"docs": {
|
|
400
400
|
"index": " \ntitle: familiar — typed module worker pools\ndescription: typed es module worker pools with cancellation, priority scheduling, streaming, and test utilities.\npackage: familiar\ncategory: workers\nkeywords: [web workers, module workers, pool, concurrency, timeout, cancellation, streaming]\nrelated: [arsenal, ripple, herald]\nexports: [createworker, createstreamworker, batch, createtaskgroup, familiarerror, familiartimeouterror, familiartaskerror, familiarqueuefullerror, familiarterminatederror, familiarruntimeerror]\nenvironments: [browser]\n \n\n<! markdownlint disable md025 md033 md060 >\n\n<packagehero package=\"familiar\" />\n\n## why familiar?\n\nraw workers force every application to maintain its own message contract, lifecycle, cancellation, and pool scheduler. familiar provides those boundaries while keeping worker code in normal typed es modules.\n\n```ts\n// before\nconst worker = new worker(new url('./sum.worker.ts', import.meta.url), { type: 'module' });\nworker.postmessage([1, 2, 3]);\n\n// after\nconst pool = createworker<number[], number>(new url('./sum.worker.ts', import.meta.url));\nawait pool.run([1, 2, 3]);\n```\n\n| feature | familiar | raw worker | comlink |\n| | | | |\n| bundle size | <packageinfo package=\"familiar\" type=\"size\" /> | built in | ~2 kb |\n| module worker contract | <ore icon name=\"check\" size=\"16\"></ore icon> | manual | <ore icon name=\"check\" size=\"16\"></ore icon> |\n| pool scheduling | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> |\n| abortsignal cancellation | <ore icon name=\"check\" size=\"16\"></ore icon> | manual | manual |\n| versioned protocol | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> | implementation specific |\n| zero dependencies | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> |\n\n<div class=\"decision callout\">\n\n**use familiar when** worker jobs need bounded concurrency, typed errors, cancellation, or queue policy.\n\n**consider raw worker when** one isolated worker and custom messaging are enough.\n\n</div>\n\n## installation\n\n::: code group\n\n```sh [pnpm]\npnpm add @vielzeug/familiar\n```\n\n```sh [npm]\nnpm install @vielzeug/familiar\n```\n\n```sh [yarn]\nyarn add @vielzeug/familiar\n```\n\n:::\n\n## quick start\n\nregister task logic inside a worker module.\n\n```ts\n// double.worker.ts\nimport { exposetask } from '@vielzeug/familiar/protocol';\n\nexposetask((value: number) => value * 2);\n```\n\ncreate pool from module url and dispose it after use.\n\n```ts\nimport { createworker } from '@vielzeug/familiar';\n\nconst worker = createworker<number, number>(new url('./double.worker.ts', import.meta.url));\n\ntry {\n console.log(await worker.run(21));\n} finally {\n worker.dispose();\n}\n```\n\n## features\n\n<div class=\"features grid\">\n\n `createworker()` — versioned task protocol over es module workers\n `createstreamworker()` — stream only worker capability\n `run()` — priority scheduling, transferables, timeout, and cancellation\n `batch()` — ordered task composition\n `createtaskgroup()` — shared cancellation and settlement tracking\n `stats` — active, queued, completed, and failed counters\n `createtestworker()` — faithful in process task pool testing\n `dispose()` and `drain()` — immediate or draining teardown, with `using` support\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\n</div>\n\n## see also\n\n<div class=\"see also\">\n\n [arsenal](/arsenal/) — async helpers for application coordination.\n [ripple](/ripple/) — expose worker results through reactive state.\n [herald](/herald/) — publish application events after worker jobs settle.\n\n</div>\n\n<! markdownlint enable md025 md033 md060 >\n",
|
|
401
|
-
"api": " \ntitle: familiar — api reference\ndescription: api reference for module worker pools and worker side protocol registration.\n \n\n[[toc]]\n\n## api overview\n\n| symbol | purpose | execution mode | common gotcha |\n| | | | |\n| `createworker()` | create single result module worker pool | sync | worker must call `exposetask()` |\n| `createstreamworker()` | create stream only module worker pool | sync | worker must call `exposestream()` |\n| `batch()` | yield ordered task pool results | async iterator | stops remaining work on first failure |\n| `createtaskgroup()` | coordinate related task pool jobs | sync | call `abort()` to stop group work |\n| `createtestworker()` | create an in process task pool test double | sync | task modules are not executed |\n| `exposetask()` | register worker task handler | sync | worker only import |\n| `exposestream()` | register worker stream handler | sync | worker only import |\n\n## package entry point\n\n| import | purpose |\n| | |\n| `@vielzeug/familiar` | pool factories, helpers, types, errors |\n| `@vielzeug/familiar/protocol` | versioned worker protocol and registration helpers |\n| `@vielzeug/familiar/testing` | task pool testing adapter |\n\n## pool factories\n\n### `createworker()`\n\n```ts\nfunction createworker<tinput, toutput>(url: url | string, options?: workeroptions): workerpool<tinput, toutput>;\n```\n\ncreates a task pool for a worker module registered with `exposetask()`.\n\n| parameter | type | description |\n| | | |\n| `url` | `url \\| string` | module worker url, usually `new url('./task.worker.ts', import.meta.url)` |\n| `options` | `workeroptions` | pool concurrency, queue, timeout, and worker error policy |\n\n**returns:** `workerpool<tinput, toutput>`.\n\n**example:**\n\n```ts\nimport { createworker } from '@vielzeug/familiar';\n\nconst pool = createworker<number, number>(new url('./double.worker.ts', import.meta.url));\n\ntry {\n console.log(await pool.run(21));\n} finally {\n pool.dispose();\n}\n```\n\n### `createstreamworker()`\n\n```ts\nfunction createstreamworker<tinput, tchunk>(url: url | string, options?: workeroptions): streamworkerpool<tinput, tchunk>;\n```\n\ncreates a stream only pool for a worker module registered with `exposestream()`.\n\n**returns:** `streamworkerpool<tinput, tchunk>`.\n\n \n\n### `batch()`\n\n```ts\nfunction batch<tinput, toutput>(\n pool: workerpool<tinput, toutput>,\n inputs: readonly tinput[],\n options?: batchoptions,\n): asynciterable<toutput>;\n```\n\nyields results in submission order. a failure or cancellation aborts remaining batch work.\n\n**returns:** `asynciterable<toutput>`.\n\n \n\n### `createtaskgroup()`\n\n```ts\nfunction createtaskgroup<tinput, toutput>(\n pool: workerpool<tinput, toutput>,\n name?: string,\n options?: taskgroupoptions,\n): taskgroup<tinput, toutput>;\n```\n\ncreates group scoped cancellation and settlement tracking for one task pool.\n\n**returns:** `taskgroup<tinput, toutput>`.\n\n## testing\n\n### `createtestworker()`\n\n```ts\nfunction createtestworker<tinput, toutput>(\n handler: (input: tinput) => toutput | promise<toutput>,\n options?: testworkeroptions,\n): testworkerhandle<tinput, toutput>;\n```\n\ncreates an in process task pool double. it structured clones values, records settlement, and matches task pool timeout and cancellation behavior without loading a worker module.\n\n**returns:** `testworkerhandle<tinput, toutput>`.\n\n## worker protocol\n\n### `exposetask()`\n\n```ts\nfunction exposetask<tinput, toutput>(handler: taskhandler<tinput, toutput>): void;\n```\n\nregisters one single result handler in a module worker.\n\n### `exposestream()`\n\n```ts\nfunction exposestream<tinput, tchunk>(handler: streamhandler<tinput, tchunk>): void;\n```\n\nregisters one chunk producing handler in a module worker.\n\n### `protocol_version`\n\n```ts\nconst protocol_version: 1;\n```\n\nversion included in every host request and worker response.\n\n## types\n\n### `workeroptions`\n\n```ts\ntype workeroptions = {\n concurrency?: number | 'auto';\n maxqueue?: number;\n onfull?: 'reject' | 'wait';\n timeout?: number;\n onsloterror?: (error: familiarruntimeerror) => void;\n};\n```\n\n### `runoptions`\n\n```ts\ntype runoptions = {\n priority?: number;\n signal?: abortsignal;\n timeout?: number;\n transferables?: transferable[];\n};\n```\n\n`signal` cancels capacity waits, queued work, and executing work. executing cancellation terminates and replaces its worker slot.\n\n### `workerpool`\n\n```ts\ninterface workerpool<tinput, toutput> {\n [symbol.asyncdispose](): promise<void>;\n [symbol.dispose](): void;\n run(input: tinput, options?: runoptions): promise<toutput>;\n prime(): promise<void>;\n drain(options?: drainoptions): promise<void>;\n dispose(): void;\n readonly stats: workerstats;\n readonly status: workerstatus;\n readonly disposed: boolean;\n readonly disposalsignal: abortsignal;\n}\n```\n\n### `streamworkerpool`\n\n```ts\ninterface streamworkerpool<tinput, tchunk> {\n [symbol.asyncdispose](): promise<void>;\n [symbol.dispose](): void;\n runstream(input: tinput, options?: runoptions): asynciterable<tchunk>;\n prime(): promise<void>;\n drain(options?: drainoptions): promise<void>;\n dispose(): void;\n readonly disposed: boolean;\n readonly disposalsignal: abortsignal;\n readonly stats: workerstats;\n readonly status: workerstatus;\n}\n```\n\n### `workerstats`\n\n```ts\ntype workerstats = {\n readonly active: number;\n readonly completed: number;\n readonly failed: number;\n readonly queued: number;\n};\n```\n\n### `runningstream`\n\n```ts\ntype runningstream<tchunk> = {\n done: promise<void>;\n iterable: asynciterable<tchunk>;\n};\n```\n\n### `workerstatus`\n\n```ts\ntype workerstatus = 'idle' | 'running' | 'terminated';\n```\n\n### `batchoptions`\n\n```ts\ntype batchoptions = runoptions;\n```\n\n### `drainoptions`\n\n```ts\ntype drainoptions = {\n timeout?: number;\n};\n```\n\n### `taskgroup`\n\n```ts\ntype taskgroup<tinput, toutput> = {\n abort(reason?: unknown): void;\n drain(): promise<promisesettledresult<toutput>[]>;\n readonly name: string | undefined;\n readonly pending: number;\n run(input: tinput, options?: omit<runoptions, 'signal'>): promise<toutput>;\n readonly size: number;\n};\n```\n\n### `taskgroupoptions`\n\n```ts\ntype taskgroupoptions = {\n signal?: abortsignal;\n};\n```\n\n### `testworkeroptions`\n\n```ts\ntype testworkeroptions = omit<workeroptions, 'concurrency' | 'onsloterror'> & {\n concurrency?: number;\n};\n```\n\n### `testworkercall`\n\n```ts\ntype testworkercall<tinput, toutput> =\n | { input: tinput; status: 'fulfilled'; value: toutput }\n | { input: tinput; reason: unknown; status: 'rejected' };\n```\n\n### `testworkerhandle`\n\n```ts\ntype testworkerhandle<tinput, toutput> = workerpool<tinput, toutput> & {\n readonly calls: readonlyarray<testworkercall<tinput, toutput>>;\n};\n```\n\n### `serializederror`\n\n```ts\ntype serializederror = {\n message: string;\n name: string;\n stack?: string;\n};\n```\n\n### `workerrequest`\n\n```ts\ntype workerrequest<tinput> =\n | { id: number; input: tinput; kind: 'run'; version: 1 }\n | { id: number; input: tinput; kind: 'stream'; version: 1 };\n```\n\n### `workerresponse`\n\n```ts\ntype workerresponse<toutput> =\n | { id: number; kind: 'chunk'; value: toutput; version: 1 }\n | { error: serializederror; id: number; kind: 'error'; version: 1 }\n | { id: number; kind: 'result'; value: toutput; version: 1 };\n```\n\n### `taskhandler` and `streamhandler`\n\n```ts\ntype taskhandler<tinput, toutput> = (input: tinput) => toutput | promise<toutput>;\ntype streamhandler<tinput, tchunk> = (input: tinput) => asynciterable<tchunk> | promise<asynciterable<tchunk>>;\n```\n\n## errors\n\n| error | trigger | notable property |\n| | | |\n| `familiarerror` | base class for all familiar errors | `familiarerror
|
|
401
|
+
"api": " \ntitle: familiar — api reference\ndescription: api reference for module worker pools and worker side protocol registration.\n \n\n[[toc]]\n\n## api overview\n\n| symbol | purpose | execution mode | common gotcha |\n| | | | |\n| `createworker()` | create single result module worker pool | sync | worker must call `exposetask()` |\n| `createstreamworker()` | create stream only module worker pool | sync | worker must call `exposestream()` |\n| `batch()` | yield ordered task pool results | async iterator | stops remaining work on first failure |\n| `createtaskgroup()` | coordinate related task pool jobs | sync | call `abort()` to stop group work |\n| `createtestworker()` | create an in process task pool test double | sync | task modules are not executed |\n| `exposetask()` | register worker task handler | sync | worker only import |\n| `exposestream()` | register worker stream handler | sync | worker only import |\n\n## package entry point\n\n| import | purpose |\n| | |\n| `@vielzeug/familiar` | pool factories, helpers, types, errors |\n| `@vielzeug/familiar/protocol` | versioned worker protocol and registration helpers |\n| `@vielzeug/familiar/testing` | task pool testing adapter |\n\n## pool factories\n\n### `createworker()`\n\n```ts\nfunction createworker<tinput, toutput>(url: url | string, options?: workeroptions): workerpool<tinput, toutput>;\n```\n\ncreates a task pool for a worker module registered with `exposetask()`.\n\n| parameter | type | description |\n| | | |\n| `url` | `url \\| string` | module worker url, usually `new url('./task.worker.ts', import.meta.url)` |\n| `options` | `workeroptions` | pool concurrency, queue, timeout, and worker error policy |\n\n**returns:** `workerpool<tinput, toutput>`.\n\n**example:**\n\n```ts\nimport { createworker } from '@vielzeug/familiar';\n\nconst pool = createworker<number, number>(new url('./double.worker.ts', import.meta.url));\n\ntry {\n console.log(await pool.run(21));\n} finally {\n pool.dispose();\n}\n```\n\n### `createstreamworker()`\n\n```ts\nfunction createstreamworker<tinput, tchunk>(url: url | string, options?: workeroptions): streamworkerpool<tinput, tchunk>;\n```\n\ncreates a stream only pool for a worker module registered with `exposestream()`.\n\n**returns:** `streamworkerpool<tinput, tchunk>`.\n\n \n\n### `batch()`\n\n```ts\nfunction batch<tinput, toutput>(\n pool: workerpool<tinput, toutput>,\n inputs: readonly tinput[],\n options?: batchoptions,\n): asynciterable<toutput>;\n```\n\nyields results in submission order. a failure or cancellation aborts remaining batch work.\n\n**returns:** `asynciterable<toutput>`.\n\n \n\n### `createtaskgroup()`\n\n```ts\nfunction createtaskgroup<tinput, toutput>(\n pool: workerpool<tinput, toutput>,\n name?: string,\n options?: taskgroupoptions,\n): taskgroup<tinput, toutput>;\n```\n\ncreates group scoped cancellation and settlement tracking for one task pool.\n\n**returns:** `taskgroup<tinput, toutput>`.\n\n## testing\n\n### `createtestworker()`\n\n```ts\nfunction createtestworker<tinput, toutput>(\n handler: (input: tinput) => toutput | promise<toutput>,\n options?: testworkeroptions,\n): testworkerhandle<tinput, toutput>;\n```\n\ncreates an in process task pool double. it structured clones values, records settlement, and matches task pool timeout and cancellation behavior without loading a worker module.\n\n**returns:** `testworkerhandle<tinput, toutput>`.\n\n## worker protocol\n\n### `exposetask()`\n\n```ts\nfunction exposetask<tinput, toutput>(handler: taskhandler<tinput, toutput>): void;\n```\n\nregisters one single result handler in a module worker.\n\n### `exposestream()`\n\n```ts\nfunction exposestream<tinput, tchunk>(handler: streamhandler<tinput, tchunk>): void;\n```\n\nregisters one chunk producing handler in a module worker.\n\n### `protocol_version`\n\n```ts\nconst protocol_version: 1;\n```\n\nversion included in every host request and worker response.\n\n## types\n\n### `workeroptions`\n\n```ts\ntype workeroptions = {\n concurrency?: number | 'auto';\n maxqueue?: number;\n onfull?: 'reject' | 'wait';\n timeout?: number;\n onsloterror?: (error: familiarruntimeerror) => void;\n};\n```\n\n### `runoptions`\n\n```ts\ntype runoptions = {\n priority?: number;\n signal?: abortsignal;\n timeout?: number;\n transferables?: transferable[];\n};\n```\n\n`signal` cancels capacity waits, queued work, and executing work. executing cancellation terminates and replaces its worker slot.\n\n### `workerpool`\n\n```ts\ninterface workerpool<tinput, toutput> {\n [symbol.asyncdispose](): promise<void>;\n [symbol.dispose](): void;\n run(input: tinput, options?: runoptions): promise<toutput>;\n prime(): promise<void>;\n drain(options?: drainoptions): promise<void>;\n dispose(): void;\n readonly stats: workerstats;\n readonly status: workerstatus;\n readonly disposed: boolean;\n readonly disposalsignal: abortsignal;\n}\n```\n\n### `streamworkerpool`\n\n```ts\ninterface streamworkerpool<tinput, tchunk> {\n [symbol.asyncdispose](): promise<void>;\n [symbol.dispose](): void;\n runstream(input: tinput, options?: runoptions): asynciterable<tchunk>;\n prime(): promise<void>;\n drain(options?: drainoptions): promise<void>;\n dispose(): void;\n readonly disposed: boolean;\n readonly disposalsignal: abortsignal;\n readonly stats: workerstats;\n readonly status: workerstatus;\n}\n```\n\n### `workerstats`\n\n```ts\ntype workerstats = {\n readonly active: number;\n readonly completed: number;\n readonly failed: number;\n readonly queued: number;\n};\n```\n\n### `runningstream`\n\n```ts\ntype runningstream<tchunk> = {\n done: promise<void>;\n iterable: asynciterable<tchunk>;\n};\n```\n\n### `workerstatus`\n\n```ts\ntype workerstatus = 'idle' | 'running' | 'terminated';\n```\n\n### `batchoptions`\n\n```ts\ntype batchoptions = runoptions;\n```\n\n### `drainoptions`\n\n```ts\ntype drainoptions = {\n timeout?: number;\n};\n```\n\n### `taskgroup`\n\n```ts\ntype taskgroup<tinput, toutput> = {\n abort(reason?: unknown): void;\n drain(): promise<promisesettledresult<toutput>[]>;\n readonly name: string | undefined;\n readonly pending: number;\n run(input: tinput, options?: omit<runoptions, 'signal'>): promise<toutput>;\n readonly size: number;\n};\n```\n\n### `taskgroupoptions`\n\n```ts\ntype taskgroupoptions = {\n signal?: abortsignal;\n};\n```\n\n### `testworkeroptions`\n\n```ts\ntype testworkeroptions = omit<workeroptions, 'concurrency' | 'onsloterror'> & {\n concurrency?: number;\n};\n```\n\n### `testworkercall`\n\n```ts\ntype testworkercall<tinput, toutput> =\n | { input: tinput; status: 'fulfilled'; value: toutput }\n | { input: tinput; reason: unknown; status: 'rejected' };\n```\n\n### `testworkerhandle`\n\n```ts\ntype testworkerhandle<tinput, toutput> = workerpool<tinput, toutput> & {\n readonly calls: readonlyarray<testworkercall<tinput, toutput>>;\n};\n```\n\n### `serializederror`\n\n```ts\ntype serializederror = {\n message: string;\n name: string;\n stack?: string;\n};\n```\n\n### `workerrequest`\n\n```ts\ntype workerrequest<tinput> =\n | { id: number; input: tinput; kind: 'run'; version: 1 }\n | { id: number; input: tinput; kind: 'stream'; version: 1 };\n```\n\n### `workerresponse`\n\n```ts\ntype workerresponse<toutput> =\n | { id: number; kind: 'chunk'; value: toutput; version: 1 }\n | { error: serializederror; id: number; kind: 'error'; version: 1 }\n | { id: number; kind: 'result'; value: toutput; version: 1 };\n```\n\n### `taskhandler` and `streamhandler`\n\n```ts\ntype taskhandler<tinput, toutput> = (input: tinput) => toutput | promise<toutput>;\ntype streamhandler<tinput, tchunk> = (input: tinput) => asynciterable<tchunk> | promise<asynciterable<tchunk>>;\n```\n\n## errors\n\n| error | trigger | notable property |\n| | | |\n| `familiarerror` | base class for all familiar errors | use `instanceof familiarerror` to narrow |\n| `familiarinvalidoptionserror` | invalid factory or test options | — |\n| `familiarqueuefullerror` | queue limit reached with `onfull: 'reject'` | `maxqueue` |\n| `familiartaskerror` | worker handler throws or payload cannot clone | `cause` |\n| `familiartimeouterror` | task or drain deadline expires | `timeoutms` |\n| `familiarterminatederror` | pool is disposed or draining | — |\n| `familiarruntimeerror` | worker api or worker process fails | `cause` |\n",
|
|
402
402
|
"usage": " \ntitle: familiar — usage guide\ndescription: run task and stream module workers with bounded concurrency, cancellation, and test parity.\n \n\n[[toc]]\n\n## basic usage\n\nput task logic in a worker module. imports and helpers stay normal module code.\n\n```ts\n// normalize.worker.ts\nimport { exposetask } from '@vielzeug/familiar/protocol';\n\nimport { normalize } from './normalize';\n\nexposetask((text: string) => normalize(text));\n```\n\ncreate one long lived pool at its owner boundary.\n\n```ts\nimport { createworker } from '@vielzeug/familiar';\n\nconst pool = createworker<string, string>(new url('./normalize.worker.ts', import.meta.url), {\n concurrency: 2,\n timeout: 2_000,\n});\n\ntry {\n const normalized = await pool.run(' familiar ');\n console.log(normalized);\n} finally {\n pool.dispose();\n}\n```\n\n## cancellation and timeouts\n\npass one signal to stop capacity waits, queued work, or active work. cancelling active work terminates and lazily replaces its slot.\n\n```ts\nconst controller = new abortcontroller();\nconst result = pool.run('input', { signal: controller.signal, timeout: 500 });\n\ncontroller.abort();\nawait result.catch((error) => console.log(error.name)); // aborterror\n```\n\n## queue policy and priority\n\nuse `maxqueue` to bound waiting work. higher priorities dispatch first once a slot opens.\n\n```ts\nconst pool = createworker<job, result>(new url('./job.worker.ts', import.meta.url), {\n concurrency: 2,\n maxqueue: 100,\n onfull: 'wait',\n});\n\nawait pool.run(criticaljob, { priority: 10 });\n```\n\n## batch and groups\n\ncompose task pools with free helpers instead of carrying unrelated methods on every pool.\n\n```ts\nimport { batch, createtaskgroup } from '@vielzeug/familiar';\n\nfor await (const value of batch(pool, inputs)) {\n console.log(value);\n}\n\nconst group = createtaskgroup(pool, 'import');\nconst tasks = rows.map((row) => group.run(row));\nawait group.drain();\nawait promise.all(tasks);\n```\n\n## streaming\n\nstream workers have their own capability and registration helper.\n\n```ts\n// tokenize.worker.ts\nimport { exposestream } from '@vielzeug/familiar/protocol';\n\nexposestream(async function* (text: string) {\n for (const token of text.split(/\\s+/)) yield token;\n});\n```\n\n```ts\nimport { createstreamworker } from '@vielzeug/familiar';\n\nconst pool = createstreamworker<string, string>(new url('./tokenize.worker.ts', import.meta.url));\nfor await (const token of pool.runstream('typed module workers')) {\n console.log(token);\n}\npool.dispose();\n```\n\n## testing\n\nuse `createtestworker()` when testing consumer code that depends on a task pool. it clones input/output, wraps task failures, and honors cancellation and timeout behavior.\n\n```ts\nimport { createtestworker } from '@vielzeug/familiar/testing';\n\nconst pool = createtestworker((value: number) => value * 2);\nawait expect(pool.run(21)).resolves.tobe(42);\nexpect(pool.calls).toequal([{ input: 21, status: 'fulfilled', value: 42 }]);\npool.dispose();\n```\n\ntest worker module business logic directly when possible. `createtestworker()` does not run module files or support stream pools.\n\n## framework integration\n\ncreate a pool once per component lifetime. abort obsolete requests during effect cleanup and dispose the pool on unmount.\n\n::: code group\n\n```tsx [react]\nimport { useeffect, usememo } from 'react';\nimport { createworker } from '@vielzeug/familiar';\n\nconst pool = usememo(() => createworker(new url('./sort.worker.ts', import.meta.url)), []);\n\nuseeffect(() => () => pool.dispose(), [pool]);\n```\n\n```ts [vue]\nimport { onunmounted } from 'vue';\nimport { createworker } from '@vielzeug/familiar';\n\nconst pool = createworker(new url('./sort.worker.ts', import.meta.url));\n\nonunmounted(() => pool.dispose());\n```\n\n```ts [svelte]\nimport { ondestroy } from 'svelte';\nimport { createworker } from '@vielzeug/familiar';\n\nconst pool = createworker(new url('./sort.worker.ts', import.meta.url));\n\nondestroy(() => pool.dispose());\n```\n\n:::\n\n## working with other vielzeug libraries\n\nuse `@vielzeug/arsenal` async helpers in application orchestration. keep worker module protocol registration in `@vielzeug/familiar/protocol`.\n\n## best practices\n\n put every task handler in its own module worker boundary.\n reuse pools for repeated work; dispose owner scoped pools.\n abort work made obsolete by navigation or newer input.\n transfer large binary buffers instead of cloning them.\n set explicit timeouts for work with a bounded latency budget.\n keep worker handlers deterministic and data only.\n test module logic directly; test pool consumers with `createtestworker()`.\n",
|
|
403
403
|
"examples": " \ntitle: familiar — examples\ndescription: module worker recipes for familiar.\n \n\n## examples\n\n [fibonacci with pool and timeout](./examples/fibonacci with pool and timeout.md)\n [data transformation pipeline](./examples/data transformation pipeline.md)\n [image processing](./examples/image processing.md)\n [using transferables](./examples/using transferables.md)\n [cancellable batch](./examples/cancellable batch.md)\n [priority queue](./examples/priority queue.md)\n [streaming with stream worker](./examples/streaming with runstream.md)\n [module worker](./examples/module worker.md)\n [typed error handling](./examples/typed error handling.md)\n [react integration](./examples/react integration.md)\n [testing with createtestworker](./examples/testing with createtestworker.md)\n"
|
|
404
404
|
},
|
|
@@ -492,7 +492,7 @@
|
|
|
492
492
|
"description": "framework agnostic immutable form state with focused object fields and explicit validation results.",
|
|
493
493
|
"docs": {
|
|
494
494
|
"index": " \ntitle: forge — immutable form state for typescript\ndescription: framework agnostic immutable form state with focused object fields and explicit validation results.\npackage: forge\ncategory: forms\nkeywords: [form state, validation, immutable, input, submission]\nrelated: [spell, vault, courier]\nexports: [createform, bindfield, customvalidator, saveform, loadform]\nenvironments: [browser, node, ssr, deno]\n \n\n<! markdownlint disable md025 md033 md060 >\n\n<packagehero package=\"forge\" />\n\n## why forge?\n\nnative form state becomes difficult to inspect once values, validation, draft restoration, and ui bindings share mutable objects. forge owns one immutable value tree and gives you typed handles for object branches without string paths, scoped controllers, or framework state.\n\n```ts\n// before\nconst values = { email: '', password: '' };\nconst errors: record<string, string> = {};\n\nfunction submit() {\n errors.email = values.email.includes('@') ? '' : 'invalid email';\n errors.password = values.password.length >= 8 ? '' : 'use at least eight characters';\n}\n\n// after\nconst form = createform({\n initialvalues: { email: '', password: '' },\n validate: (value) => ({\n fields: {\n email: value.email.includes('@') ? undefined : 'invalid email',\n password: value.password.length >= 8 ? undefined : 'use at least eight characters',\n },\n }),\n});\n```\n\n| feature | forge | native form state | framework owned form state |\n| | | | |\n| bundle size | <packageinfo package=\"forge\" type=\"size\" /> | <ore icon name=\"check\" size=\"16\"></ore icon> | varies |\n| zero external dependencies | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> |\n| immutable nested values | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> | varies |\n| typed object field handles | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> | varies |\n| framework independent state | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> |\n\n<div class=\"decision callout\">\n\n**use forge when** form state needs framework independent immutable values, typed object fields, and one explicit validation boundary.\n\n**consider framework owned form state when** application only needs a single ui framework's native input bindings.\n\n</div>\n\n## installation\n\n::: code group\n\n```sh [pnpm]\npnpm add @vielzeug/forge\n```\n\n```sh [npm]\nnpm install @vielzeug/forge\n```\n\n```sh [yarn]\nyarn add @vielzeug/forge\n```\n\n:::\n\ninstall `@vielzeug/spell` or `@vielzeug/vault` only when importing forge's matching optional adapter.\n\n## quick start\n\ncreate a form, update a focused field, and submit only after validation passes.\n\n```ts\nimport { createform } from '@vielzeug/forge';\n\nconst form = createform({\n initialvalues: { profile: { email: '', name: '' } },\n validate: (value) => ({\n fields: { profile: { email: value.profile.email.includes('@') ? undefined : 'invalid email' } },\n }),\n});\n\nform.field('profile').field('email').set('ada@example.com');\n\nconst result = await form.submit(async (value) => {\n const response = await fetch('/api/profile', {\n body: json.stringify(value),\n headers: { 'content type': 'application/json' },\n method: 'post',\n });\n\n return response.ok;\n});\n\nif (result.status === 'invalid') console.log(result.errors);\n```\n\n## features\n\n<div class=\"features grid\">\n\n `form.value` exposes one immutable nested value tree.\n `form.field(key)` selects typed object branches without string paths.\n `field.set(updater)` replaces array values through immutable updater functions.\n `field.field(index)` selects typed array item fields by index.\n `form.validate()` returns valid, invalid, or aborted results.\n `form.submit(handler, signal?)` touches, validates, and invokes the handler when valid.\n `bindfield()` connects one dom element without owning validation timing.\n `customvalidator()` maps spell schema errors into forge fields.\n `saveform()` and `loadform()` persist explicit vault draft records.\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 [spell](/spell/) — adapt a spell schema through `customvalidator()`.\n [vault](/vault/) — save and restore explicit forge draft records.\n [courier](/courier/) — send a validated form value through a mutation.\n\n</div>\n\n<! markdownlint enable >\n",
|
|
495
|
-
"api": " \ntitle: forge — api reference\ndescription: complete reference for immutable forms, fields, validation, serialization, and optional adapters.\n \n\n[[toc]]\n\n## api overview\n\n| symbol | purpose | execution mode | common gotcha |\n| | | | |\n| `createform()` | create immutable form state | sync | `initialvalues` cannot contain mutable class instances |\n| `form.field()` | select a top level or object child field | sync | unsafe keys (`__proto__`, `constructor`, `prototype`) are rejected |\n| `form.validate()` | validate complete value | async | handle `aborted` separately |\n| `form.submit(handler, signal?)` | touch, validate, then invoke handler | async | concurrent calls reject |\n| `form.reset()` | restore or replace baseline | sync | `reset(next)` makes `next` clean |\n| `form.subscribe()` | observe form metadata | sync | throws after disposal |\n| `toformdata()` | serialize values for multipart transport | sync | `filelist` is transport only |\n| `bindfield()` | bind one dom element | sync | does not schedule validation |\n| `customvalidator()` | adapt a spell schema | async | does not transform `form.value` |\n| `saveform()` / `loadform()` | persist explicit vault records | async | formdraftcodec owns record shape |\n\n## package entry point\n\n| import | purpose |\n| | |\n| `@vielzeug/forge` | core form factory, types, and errors |\n| `@vielzeug/forge/dom` | `bindfield()` and dom binding types |\n| `@vielzeug/forge/form data` | `toformdata()` |\n| `@vielzeug/forge/spell` | `customvalidator()` |\n| `@vielzeug/forge/vault` | `saveform()`, `loadform()`, and `formdraftcodec` |\n\n## core functions\n\n### `createform(options)`\n\n```ts\nfunction createform<tvalues extends record<string, unknown>>(options: formoptions<tvalues>): form<tvalues>;\n```\n\ncreates a form with immutable initial values and an optional full form validator.\n\n| parameter | type | description |\n| | | |\n| `options.initialvalues` | `tvalues` | initial value and reset baseline. supports primitives, plain objects, arrays, `date`, `file`, and `blob`. |\n| `options.validate` | `formvalidator<tvalues>` | optional validator for the entire current value. |\n| `options.onsubscribererror` | `(error: unknown) => void` | optional subscriber failure reporter. |\n\n**returns:** `form<tvalues>`.\n\n**example:**\n\n```ts\nimport { createform } from '@vielzeug/forge';\n\nconst form = createform({ initialvalues: { email: '' } });\n```\n\n \n\n### `toformdata(values)`\n\n```ts\nfunction toformdata(values: record<string, unknown>): formdata;\n```\n\nconverts nested values into `formdata` with dot separated object keys and repeated array keys.\n\n**returns:** a populated `formdata` instance.\n\n**example:**\n\n```ts\nimport { toformdata } from '@vielzeug/forge/form data';\n\nconst body = toformdata({ profile: { email: 'ada@example.com' }, tags: ['typescript', 'forms'] });\n```\n\n## form handles\n\n### `form<tvalues>`\n\n`createform()` returns this handle.\n\n| member | signature | description |\n| | | |\n| `value` | `readonlydeep<tvalues>` | current immutable value. |\n| `state` | `formstate<tvalues>` | submission, validation, touch, and error metadata. |\n| `field(key)` | `field<tvalues[k]>` | select a top level field. |\n| `set(next)` | `void` | replace the complete value or derive a replacement. |\n| `reset(next?)` | `void` | restore baseline or make `next` the baseline. |\n| `validate(signal?)` | `promise<validationresult<tvalues>>` | run full form validation. |\n| `submit(handler, signal?)` | `promise<submitresult<tresult, tvalues>>` | touch, validate, and invoke handler when valid. |\n| `subscribe(listener, options?)` | `unsubscribe` | observe form state; throws after disposal. |\n| `dispose()` | `void` | abort validation and clear subscribers. |\n| `disposed` | `boolean` | whether the form has been disposed. |\n| `disposalsignal` | `abortsignal` | aborts on disposal. |\n\n### `field<v>`\n\n`form.field(key)` and object field `.field(key)` return this handle. array item `.field(index)` returns a per item field handle.\n\n| member | signature | description |\n| | | |\n| `value` | `readonlydeep<v>` | current immutable branch value. |\n| `error` | `string \\| undefined` | current field error. |\n| `dirty` | `boolean` | whether branch differs from baseline. |\n| `touched` | `boolean` | whether field was touched. |\n| `state` | `fieldstate<v>` | snapshot of `dirty`, `error`, `touched`, and `value` in one read. |\n| `field(key)` | `field<v[k]>` | select child object field or array item by index. |\n| `set(next)` | `void` | replace branch or derive a replacement. |\n| `reset()` | `void` | restore exact baseline branch. |\n| `touch()` | `void` | mark field touched. |\n| `subscribe(listener, options?)` | `unsubscribe` | observe field transitions; throws after disposal. |\n\n## validation results\n\n### `form.validate(signal?)`\n\n```ts\nfunction validate(signal?: abortsignal): promise<validationresult<tvalues>>;\n```\n\nruns the configured validator against the complete value. a newer validation aborts the older run.\n\n**returns:** `validationresult<tvalues>`.\n\n```ts\nconst result = await form.validate();\n\nif (result.status === 'invalid') console.log(result.errors, result.formerror);\n```\n\n### `form.submit(handler, signal?)`\n\n```ts\nfunction submit<tresult = void>(\n handler: (values: readonlydeep<tvalues>, signal: abortsignal) => maybepromise<tresult>,\n signal?: abortsignal,\n): promise<submitresult<tresult, tvalues>>;\n```\n\ntouches all fields, validates once, and invokes `handler` when validation is valid. the handler receives an `abortsignal` that is aborted when the external `signal` (or the form's disposal signal) aborts.\n\n**returns:** `submitresult<tresult, tvalues>`. handler failures reject normally unless caused by signal abort, which returns `{ status: 'aborted' }`.\n\n```ts\nconst result = await form.submit((value) => promise.resolve(value));\n```\n\n## adapters\n\n### `bindfield(element, field, options)`\n\n```ts\nfunction bindfield<element extends htmlelement, v>(\n element: element,\n field: field<v>,\n options: fieldbindingoptions<element, v>,\n): () => void;\n```\n\nbinds one field to one element, marks it touched on blur, suppresses writeback from its own input event, and returns teardown.\n\n**example:**\n\n```ts\nimport { bindfield } from '@vielzeug/forge/dom';\n\nconst stop = bindfield(input, form.field('email'), {\n read: (element) => element.value,\n write: (element, value) => {\n element.value = value;\n },\n});\n```\n\n \n\n### `customvalidator(schema)`\n\n```ts\nfunction customvalidator<tvalues extends record<string, unknown>>(\n schema: schema<unknown, tvalues, schemamode>,\n): formvalidator<tvalues>;\n```\n\nadapts a spell schema. every failing union maps its closest branch while preserving unrelated errors. array item issues map to per item array fields; duplicate paths retain the first message.\n\n**example:**\n\n```ts\nimport { customvalidator } from '@vielzeug/forge/spell';\nimport { s } from '@vielzeug/spell';\n\nconst profile = s.object({ email: s.string().email() });\nconst form = createform({ initialvalues: { email: '' }, validate: customvalidator(profile) });\n```\n\n \n\n### `saveform()` and `loadform()`\n\n```ts\nfunction saveform<tvalues extends record<string, unknown>, s extends anyschema, k extends keyof s & string>(\n form: form<tvalues>, adapter: vaultstore<s>, table: k, codec: formdraftcodec<tvalues, s, k>,\n): promise<void>;\n\nfunction loadform<tvalues extends record<string, unknown>, s extends anyschema, k extends keyof s & string>(\n form: form<tvalues>, adapter: vaultstore<s>, table: k, key: keyof<s, k>, codec: formdraftcodec<tvalues, s, k>,\n): promise<boolean>;\n```\n\npersists or restores a codec defined vault record. `loadform()` calls `form.reset()` when the codec decodes a record.\n\n**returns:** `loadform()` returns `false` for a missing or rejected record.\n\n## types\n\n```ts\ntype unsubscribe = () => void;\ntype maybepromise<t> = t | promiselike<t>;\ntype readonlydeep<t> = t extends (...args: never[]) => unknown\n ? t\n : t extends readonly (infer item)[]\n ? readonly readonlydeep<item>[]\n : t extends record<string, unknown>\n ? { readonly [k in keyof t]: readonlydeep<t[k]> }\n : t;\n\ntype formerrors<t> = t extends readonly (infer item)[]\n ? string | readonly (formerrors<item> | undefined)[]\n : t extends record<string, unknown>\n ? string | { readonly [k in keyof t]?: formerrors<t[k]> }\n : string;\n\ntype validationerrors<tvalues extends record<string, unknown>> = readonly<{\n fields?: formerrors<tvalues>;\n formerror?: string;\n}>;\n\ntype formvalidator<tvalues extends record<string, unknown>> = (\n values: readonlydeep<tvalues>, signal: abortsignal,\n) => maybepromise<validationerrors<tvalues> | undefined>;\n\ntype formoptions<tvalues extends record<string, unknown>> = readonly<{\n initialvalues: tvalues;\n onsubscribererror?: (error: unknown) => void;\n validate?: formvalidator<noinfer<tvalues>>;\n}>;\n\ntype subscribeoptions = readonly<{ immediate?: boolean }>;\n\ntype fieldstate<v> = readonly<{\n dirty: boolean;\n error: string | undefined;\n touched: boolean;\n value: readonlydeep<v>;\n}>;\n\ntype formstate<tvalues extends record<string, unknown>> = readonly<{\n errors: formerrors<tvalues> | undefined;\n formerror: string | undefined;\n haserrors: boolean;\n submitcount: number;\n submitting: boolean;\n touched: boolean;\n validity: 'invalid' | 'unknown' | 'valid';\n validating: boolean;\n}>;\n\ntype validationresult<tvalues extends record<string, unknown>> =\n | readonly<{ status: 'aborted' }>\n | readonly<{ status: 'valid' }>\n | readonly<{ errors: formerrors<tvalues> | undefined; formerror: string | undefined; status: 'invalid' }>;\n\ntype submitresult<tresult = void, tvalues extends record<string, unknown> = record<string, unknown>> =\n | readonly<{ status: 'aborted' }>\n | readonly<{ errors: formerrors<tvalues> | undefined; formerror: string | undefined; status: 'invalid' }>\n | readonly<{ status: 'ok'; value: tresult }>;\n```\n\n```ts\ntype childfield<v> =\n nonnullable<v> extends readonly (infer item)[]\n ? { field(index: number): field<item> }\n : nonnullable<v> extends record<string, unknown>\n ? { field<k extends keyof nonnullable<v> & string>(key: k): field<nonnullable<v>[k]> }\n : record<never, never>;\n\ntype field<v> = childfield<v> & {\n readonly dirty: boolean;\n readonly error: string | undefined;\n readonly state: fieldstate<v>;\n readonly touched: boolean;\n readonly value: readonlydeep<v>;\n reset(): void;\n set(next: v | ((previous: readonlydeep<v>) => v)): void;\n subscribe(listener: (state: fieldstate<v>) => void, options?: subscribeoptions): unsubscribe;\n touch(): void;\n};\n\ntype form<tvalues extends record<string, unknown>> = {\n [symbol.dispose](): void;\n readonly disposalsignal: abortsignal;\n readonly disposed: boolean;\n readonly state: formstate<tvalues>;\n readonly value: readonlydeep<tvalues>;\n dispose(): void;\n field<k extends keyof tvalues & string>(key: k): field<tvalues[k]>;\n reset(next?: tvalues): void;\n set(next: tvalues | ((previous: readonlydeep<tvalues>) => tvalues)): void;\n submit<tresult = void>(\n handler: (values: readonlydeep<tvalues>, signal: abortsignal) => maybepromise<tresult>,\n signal?: abortsignal,\n ): promise<submitresult<tresult, tvalues>>;\n subscribe(listener: (state: formstate<tvalues>) => void, options?: subscribeoptions): unsubscribe;\n validate(signal?: abortsignal): promise<validationresult<tvalues>>;\n};\n\ntype fieldbindingoptions<element extends htmlelement, v> = readonly<{\n event?: keyof htmlelementeventmap;\n read(element: element): v;\n write?: (element: element, value: readonlydeep<v>) => void;\n}>;\n\ntype formdraftcodec<tvalues extends record<string, unknown>, s extends anyschema, k extends keyof s & string> = readonly<{\n fromrecord(record: recordof<s, k>): tvalues | undefined;\n torecord(values: readonlydeep<tvalues>): recordof<s, k>;\n}>;\n```\n\n## errors\n\n| error | trigger | notable properties |\n| | | |\n| `forgeerror` | base forge error | `forgeerror.is(error)` narrows unknown values. |\n| `forgeconfigerror` | unsafe key or unsupported form value | extends `forgeerror`. |\n| `forgedisposederror` | operation or subscription after disposal | message names the attempted operation. |\n| `forgesubmiterror` | concurrent `submit()` call | extends `forgeerror`. |\n| `forgevalidationerror` | validator throws unexpectedly | preserves original error as `cause`. |\n",
|
|
495
|
+
"api": " \ntitle: forge — api reference\ndescription: complete reference for immutable forms, fields, validation, serialization, and optional adapters.\n \n\n[[toc]]\n\n## api overview\n\n| symbol | purpose | execution mode | common gotcha |\n| | | | |\n| `createform()` | create immutable form state | sync | `initialvalues` cannot contain mutable class instances |\n| `form.field()` | select a top level or object child field | sync | unsafe keys (`__proto__`, `constructor`, `prototype`) are rejected |\n| `form.validate()` | validate complete value | async | handle `aborted` separately |\n| `form.submit(handler, signal?)` | touch, validate, then invoke handler | async | concurrent calls reject |\n| `form.reset()` | restore or replace baseline | sync | `reset(next)` makes `next` clean |\n| `form.subscribe()` | observe form metadata | sync | throws after disposal |\n| `toformdata()` | serialize values for multipart transport | sync | `filelist` is transport only |\n| `bindfield()` | bind one dom element | sync | does not schedule validation |\n| `customvalidator()` | adapt a spell schema | async | does not transform `form.value` |\n| `saveform()` / `loadform()` | persist explicit vault records | async | formdraftcodec owns record shape |\n\n## package entry point\n\n| import | purpose |\n| | |\n| `@vielzeug/forge` | core form factory, types, and errors |\n| `@vielzeug/forge/dom` | `bindfield()` and dom binding types |\n| `@vielzeug/forge/form data` | `toformdata()` |\n| `@vielzeug/forge/spell` | `customvalidator()` |\n| `@vielzeug/forge/vault` | `saveform()`, `loadform()`, and `formdraftcodec` |\n\n## core functions\n\n### `createform(options)`\n\n```ts\nfunction createform<tvalues extends record<string, unknown>>(options: formoptions<tvalues>): form<tvalues>;\n```\n\ncreates a form with immutable initial values and an optional full form validator.\n\n| parameter | type | description |\n| | | |\n| `options.initialvalues` | `tvalues` | initial value and reset baseline. supports primitives, plain objects, arrays, `date`, `file`, and `blob`. |\n| `options.validate` | `formvalidator<tvalues>` | optional validator for the entire current value. |\n| `options.onsubscribererror` | `(error: unknown) => void` | optional subscriber failure reporter. |\n\n**returns:** `form<tvalues>`.\n\n**example:**\n\n```ts\nimport { createform } from '@vielzeug/forge';\n\nconst form = createform({ initialvalues: { email: '' } });\n```\n\n \n\n### `toformdata(values)`\n\n```ts\nfunction toformdata(values: record<string, unknown>): formdata;\n```\n\nconverts nested values into `formdata` with dot separated object keys and repeated array keys.\n\n**returns:** a populated `formdata` instance.\n\n**example:**\n\n```ts\nimport { toformdata } from '@vielzeug/forge/form data';\n\nconst body = toformdata({ profile: { email: 'ada@example.com' }, tags: ['typescript', 'forms'] });\n```\n\n## form handles\n\n### `form<tvalues>`\n\n`createform()` returns this handle.\n\n| member | signature | description |\n| | | |\n| `value` | `readonlydeep<tvalues>` | current immutable value. |\n| `state` | `formstate<tvalues>` | submission, validation, touch, and error metadata. |\n| `field(key)` | `field<tvalues[k]>` | select a top level field. |\n| `set(next)` | `void` | replace the complete value or derive a replacement. |\n| `reset(next?)` | `void` | restore baseline or make `next` the baseline. |\n| `validate(signal?)` | `promise<validationresult<tvalues>>` | run full form validation. |\n| `submit(handler, signal?)` | `promise<submitresult<tresult, tvalues>>` | touch, validate, and invoke handler when valid. |\n| `subscribe(listener, options?)` | `unsubscribe` | observe form state; throws after disposal. |\n| `dispose()` | `void` | abort validation and clear subscribers. |\n| `disposed` | `boolean` | whether the form has been disposed. |\n| `disposalsignal` | `abortsignal` | aborts on disposal. |\n\n### `field<v>`\n\n`form.field(key)` and object field `.field(key)` return this handle. array item `.field(index)` returns a per item field handle.\n\n| member | signature | description |\n| | | |\n| `value` | `readonlydeep<v>` | current immutable branch value. |\n| `error` | `string \\| undefined` | current field error. |\n| `dirty` | `boolean` | whether branch differs from baseline. |\n| `touched` | `boolean` | whether field was touched. |\n| `state` | `fieldstate<v>` | snapshot of `dirty`, `error`, `touched`, and `value` in one read. |\n| `field(key)` | `field<v[k]>` | select child object field or array item by index. |\n| `set(next)` | `void` | replace branch or derive a replacement. |\n| `reset()` | `void` | restore exact baseline branch. |\n| `touch()` | `void` | mark field touched. |\n| `subscribe(listener, options?)` | `unsubscribe` | observe field transitions; throws after disposal. |\n\n## validation results\n\n### `form.validate(signal?)`\n\n```ts\nfunction validate(signal?: abortsignal): promise<validationresult<tvalues>>;\n```\n\nruns the configured validator against the complete value. a newer validation aborts the older run.\n\n**returns:** `validationresult<tvalues>`.\n\n```ts\nconst result = await form.validate();\n\nif (result.status === 'invalid') console.log(result.errors, result.formerror);\n```\n\n### `form.submit(handler, signal?)`\n\n```ts\nfunction submit<tresult = void>(\n handler: (values: readonlydeep<tvalues>, signal: abortsignal) => maybepromise<tresult>,\n signal?: abortsignal,\n): promise<submitresult<tresult, tvalues>>;\n```\n\ntouches all fields, validates once, and invokes `handler` when validation is valid. the handler receives an `abortsignal` that is aborted when the external `signal` (or the form's disposal signal) aborts.\n\n**returns:** `submitresult<tresult, tvalues>`. handler failures reject normally unless caused by signal abort, which returns `{ status: 'aborted' }`.\n\n```ts\nconst result = await form.submit((value) => promise.resolve(value));\n```\n\n## adapters\n\n### `bindfield(element, field, options)`\n\n```ts\nfunction bindfield<element extends htmlelement, v>(\n element: element,\n field: field<v>,\n options: fieldbindingoptions<element, v>,\n): () => void;\n```\n\nbinds one field to one element, marks it touched on blur, suppresses writeback from its own input event, and returns teardown.\n\n**example:**\n\n```ts\nimport { bindfield } from '@vielzeug/forge/dom';\n\nconst stop = bindfield(input, form.field('email'), {\n read: (element) => element.value,\n write: (element, value) => {\n element.value = value;\n },\n});\n```\n\n \n\n### `customvalidator(schema)`\n\n```ts\nfunction customvalidator<tvalues extends record<string, unknown>>(\n schema: schema<unknown, tvalues, schemamode>,\n): formvalidator<tvalues>;\n```\n\nadapts a spell schema. every failing union maps its closest branch while preserving unrelated errors. array item issues map to per item array fields; duplicate paths retain the first message.\n\n**example:**\n\n```ts\nimport { customvalidator } from '@vielzeug/forge/spell';\nimport { s } from '@vielzeug/spell';\n\nconst profile = s.object({ email: s.string().email() });\nconst form = createform({ initialvalues: { email: '' }, validate: customvalidator(profile) });\n```\n\n \n\n### `saveform()` and `loadform()`\n\n```ts\nfunction saveform<tvalues extends record<string, unknown>, s extends anyschema, k extends keyof s & string>(\n form: form<tvalues>, adapter: vaultstore<s>, table: k, codec: formdraftcodec<tvalues, s, k>,\n): promise<void>;\n\nfunction loadform<tvalues extends record<string, unknown>, s extends anyschema, k extends keyof s & string>(\n form: form<tvalues>, adapter: vaultstore<s>, table: k, key: keyof<s, k>, codec: formdraftcodec<tvalues, s, k>,\n): promise<boolean>;\n```\n\npersists or restores a codec defined vault record. `loadform()` calls `form.reset()` when the codec decodes a record.\n\n**returns:** `loadform()` returns `false` for a missing or rejected record.\n\n## types\n\n```ts\ntype unsubscribe = () => void;\ntype maybepromise<t> = t | promiselike<t>;\ntype readonlydeep<t> = t extends (...args: never[]) => unknown\n ? t\n : t extends readonly (infer item)[]\n ? readonly readonlydeep<item>[]\n : t extends record<string, unknown>\n ? { readonly [k in keyof t]: readonlydeep<t[k]> }\n : t;\n\ntype formerrors<t> = t extends readonly (infer item)[]\n ? string | readonly (formerrors<item> | undefined)[]\n : t extends record<string, unknown>\n ? string | { readonly [k in keyof t]?: formerrors<t[k]> }\n : string;\n\ntype validationerrors<tvalues extends record<string, unknown>> = readonly<{\n fields?: formerrors<tvalues>;\n formerror?: string;\n}>;\n\ntype formvalidator<tvalues extends record<string, unknown>> = (\n values: readonlydeep<tvalues>, signal: abortsignal,\n) => maybepromise<validationerrors<tvalues> | undefined>;\n\ntype formoptions<tvalues extends record<string, unknown>> = readonly<{\n initialvalues: tvalues;\n onsubscribererror?: (error: unknown) => void;\n validate?: formvalidator<noinfer<tvalues>>;\n}>;\n\ntype subscribeoptions = readonly<{ immediate?: boolean }>;\n\ntype fieldstate<v> = readonly<{\n dirty: boolean;\n error: string | undefined;\n touched: boolean;\n value: readonlydeep<v>;\n}>;\n\ntype formstate<tvalues extends record<string, unknown>> = readonly<{\n errors: formerrors<tvalues> | undefined;\n formerror: string | undefined;\n haserrors: boolean;\n submitcount: number;\n submitting: boolean;\n touched: boolean;\n validity: 'invalid' | 'unknown' | 'valid';\n validating: boolean;\n}>;\n\ntype validationresult<tvalues extends record<string, unknown>> =\n | readonly<{ status: 'aborted' }>\n | readonly<{ status: 'valid' }>\n | readonly<{ errors: formerrors<tvalues> | undefined; formerror: string | undefined; status: 'invalid' }>;\n\ntype submitresult<tresult = void, tvalues extends record<string, unknown> = record<string, unknown>> =\n | readonly<{ status: 'aborted' }>\n | readonly<{ errors: formerrors<tvalues> | undefined; formerror: string | undefined; status: 'invalid' }>\n | readonly<{ status: 'ok'; value: tresult }>;\n```\n\n```ts\ntype childfield<v> =\n nonnullable<v> extends readonly (infer item)[]\n ? { field(index: number): field<item> }\n : nonnullable<v> extends record<string, unknown>\n ? { field<k extends keyof nonnullable<v> & string>(key: k): field<nonnullable<v>[k]> }\n : record<never, never>;\n\ntype field<v> = childfield<v> & {\n readonly dirty: boolean;\n readonly error: string | undefined;\n readonly state: fieldstate<v>;\n readonly touched: boolean;\n readonly value: readonlydeep<v>;\n reset(): void;\n set(next: v | ((previous: readonlydeep<v>) => v)): void;\n subscribe(listener: (state: fieldstate<v>) => void, options?: subscribeoptions): unsubscribe;\n touch(): void;\n};\n\ntype form<tvalues extends record<string, unknown>> = {\n [symbol.dispose](): void;\n readonly disposalsignal: abortsignal;\n readonly disposed: boolean;\n readonly state: formstate<tvalues>;\n readonly value: readonlydeep<tvalues>;\n dispose(): void;\n field<k extends keyof tvalues & string>(key: k): field<tvalues[k]>;\n reset(next?: tvalues): void;\n set(next: tvalues | ((previous: readonlydeep<tvalues>) => tvalues)): void;\n submit<tresult = void>(\n handler: (values: readonlydeep<tvalues>, signal: abortsignal) => maybepromise<tresult>,\n signal?: abortsignal,\n ): promise<submitresult<tresult, tvalues>>;\n subscribe(listener: (state: formstate<tvalues>) => void, options?: subscribeoptions): unsubscribe;\n validate(signal?: abortsignal): promise<validationresult<tvalues>>;\n};\n\ntype fieldbindingoptions<element extends htmlelement, v> = readonly<{\n event?: keyof htmlelementeventmap;\n read(element: element): v;\n write?: (element: element, value: readonlydeep<v>) => void;\n}>;\n\ntype formdraftcodec<tvalues extends record<string, unknown>, s extends anyschema, k extends keyof s & string> = readonly<{\n fromrecord(record: recordof<s, k>): tvalues | undefined;\n torecord(values: readonlydeep<tvalues>): recordof<s, k>;\n}>;\n```\n\n## errors\n\n| error | trigger | notable properties |\n| | | |\n| `forgeerror` | base forge error | use `instanceof forgeerror` to narrow unknown values. |\n| `forgeconfigerror` | unsafe key or unsupported form value | extends `forgeerror`. |\n| `forgedisposederror` | operation or subscription after disposal | message names the attempted operation. |\n| `forgesubmiterror` | concurrent `submit()` call | extends `forgeerror`. |\n| `forgevalidationerror` | validator throws unexpectedly | preserves original error as `cause`. |\n",
|
|
496
496
|
"usage": " \ntitle: forge — usage guide\ndescription: build immutable forms, validate whole values, and use optional adapters.\n \n\n[[toc]]\n\n## basic usage\n\ncreate one form value and update object branches through stable typed operations. form values support primitives, plain objects, arrays, `date`, `file`, and `blob`; mutable class instances such as `map` and `set` are rejected.\n\n```ts\nimport { createform } from '@vielzeug/forge';\n\nconst form = createform({\n initialvalues: { profile: { email: '', name: '' }, tags: [] as string[] },\n validate: (value) => ({\n fields: { profile: { email: value.profile.email.includes('@') ? undefined : 'invalid email' } },\n }),\n});\n\nconst email = form.field('profile').field('email');\nemail.set('ada@example.com');\nform.field('tags').set((tags) => [...tags, 'typescript']);\n\nconsole.log(form.value.profile.email);\n```\n\n## reset values and branches\n\nreset a field when one branch should return to its exact baseline. reset the form with a value when newly loaded data should become the clean baseline.\n\n```ts\nconst name = form.field('profile').field('name');\n\nname.set('ada');\nname.touch();\nname.reset();\n\nform.reset({ profile: { email: 'ada@example.com', name: 'ada' }, tags: [] });\n```\n\nan absent optional parent remains absent after a child reset. array items support per index field handles for reads, updates, and resets.\n\n## validate and submit\n\nreturn `fields` and an optional `formerror` from one validator. `validate()` replaces the complete validation snapshot and returns an explicit status.\n\n```ts\nconst passwordform = createform({\n initialvalues: { password: '', passwordconfirmation: '' },\n validate: (value) => ({\n fields: {\n password: value.password.length >= 8 ? undefined : 'use at least eight characters',\n passwordconfirmation: value.password === value.passwordconfirmation ? undefined : 'passwords must match',\n },\n }),\n});\n\nconst validation = await passwordform.validate();\n\nif (validation.status === 'invalid') console.log(validation.errors);\nif (validation.status === 'aborted') console.log('validation cancelled');\n\nconst result = await passwordform.submit((value) => promise.resolve(value.password.length));\n\nif (result.status === 'ok') console.log(result.value);\n```\n\nstarting another validation aborts the previous run. field edits preserve existing errors until the next validation replaces them. unexpected validator failures reject as `forgevalidationerror` with the original error as `cause`.\n\n## observe state\n\nuse form subscriptions for aggregate metadata and field subscriptions for one branch. subscribing after disposal throws `forgedisposederror`.\n\n```ts\nconst errors: unknown[] = [];\nconst observedform = createform({\n initialvalues: { email: '' },\n onsubscribererror: (error) => errors.push(error),\n});\n\nconst stopform = observedform.subscribe((state) => {\n console.log(state.validity, state.submitting);\n}, { immediate: true });\nconst stopfield = observedform.field('email').subscribe((state) => {\n console.log(state.value, state.error);\n}, { immediate: true });\n\nstopfield();\nstopform();\n```\n\nwithout `onsubscribererror`, forge rethrows subscriber failures asynchronously after completing its state transition.\n\n## testing\n\ntest the form without a dom. read its immutable value, invoke a method, then assert the resulting state or validation result.\n\n```ts\nimport { expect, test } from 'vitest';\nimport { createform } from '@vielzeug/forge';\n\ntest('requires an email address', async () => {\n const form = createform({\n initialvalues: { email: '' },\n validate: (value) => ({ fields: { email: value.email.includes('@') ? undefined : 'invalid email' } }),\n });\n\n await expect(form.validate()).resolves.toequal({\n errors: { email: 'invalid email' },\n formerror: undefined,\n status: 'invalid',\n });\n});\n```\n\n## framework integration\n\nuse `form.value` and subscriptions with any renderer. bind one dom input through `/dom`; validation scheduling remains application policy.\n\n::: code group\n\n```ts [react]\nimport { useeffect, usestate } from 'react';\nimport { createform } from '@vielzeug/forge';\n\nconst form = createform({ initialvalues: { email: '' } });\n\nexport function emailform() {\n const [, rerender] = usestate(0);\n\n useeffect(() => {\n const stop = form.subscribe(() => rerender((revision) => revision + 1));\n\n return () => stop();\n }, []);\n\n return <input value={form.field('email').value} onchange={(event) => form.field('email').set(event.target.value)} />;\n}\n```\n\n```ts [vue 3]\nimport { onunmounted, ref } from 'vue';\nimport { createform } from '@vielzeug/forge';\n\nconst form = createform({ initialvalues: { email: '' } });\nconst revision = ref(0);\nconst stop = form.subscribe(() => revision.value++);\n\nonunmounted(stop);\n```\n\n```ts [svelte]\n<script lang=\"ts\">\n import { ondestroy } from 'svelte';\n import { createform } from '@vielzeug/forge';\n\n const form = createform({ initialvalues: { email: '' } });\n let revision = 0;\n const stop = form.subscribe(() => revision++);\n\n ondestroy(stop);\n</script>\n\n<input value={form.field('email').value} on:input={(event) => form.field('email').set(event.currenttarget.value)} />\n```\n\n:::\n\n## working with other vielzeug libraries\n\nuse spell when one schema owns validation and vault when an explicit record codec owns persistence.\n\n```ts\nimport { createform } from '@vielzeug/forge';\nimport { customvalidator } from '@vielzeug/forge/spell';\nimport { s } from '@vielzeug/spell';\n\nconst profile = s.object({ email: s.string().email() });\nconst form = createform({ initialvalues: { email: '' }, validate: customvalidator(profile) });\n```\n\n`customvalidator()` preserves unrelated spell errors, maps each union to its closest branch, and maps array item failures to per item array fields. parse again at the submit boundary when a spell transform must produce the outgoing payload.\n\n```ts\nimport { loadform, saveform } from '@vielzeug/forge/vault';\n\nawait saveform(form, db, 'drafts', codec);\nconst restored = await loadform(form, db, 'drafts', 'profile', codec);\nconsole.log(restored);\n```\n\n`loadform()` uses `form.reset()`, so a restored value is clean. store a selected `file`, not `filelist`, in form state; `filelist` is transport only for `toformdata()`.\n\n## best practices\n\n keep form values to primitives, plain objects, arrays, `date`, `file`, and `blob`.\n update array fields through immutable replacement functions.\n validate complete values instead of rebuilding field validator graphs.\n handle `aborted` validation results before rendering errors.\n preserve errors through field edits until a deliberate validation refresh.\n return subscription cleanup from framework lifecycle hooks.\n provide `onsubscribererror` when application subscribers can throw.\n decode vault records before passing them to `loadform()`.\n",
|
|
497
497
|
"examples": " \ntitle: forge — examples\ndescription: practical immutable form recipes.\n \n\n## examples\n\n [login form](./examples/login form.md)\n [conditional values](./examples/form with conditional fields.md)\n [dynamic arrays](./examples/dynamic form fields.md)\n [contact form with file upload](./examples/contact form with file upload.md)\n [registration form](./examples/registration form.md)\n [multi step wizard](./examples/multi step wizard.md)\n [search form with debounce](./examples/search form with debounce.md)\n"
|
|
498
498
|
},
|
|
@@ -552,7 +552,7 @@
|
|
|
552
552
|
"index": " \ntitle: gesture — pointer pan primitives\ndescription: framework neutral one axis pointer pan recognition with lifecycle owned handles.\npackage: gesture\ncategory: input\nkeywords: [pointer, pan, swipe, gesture, touch, drag]\nexports: [createpangesture]\nrelated: [refine, dnd, keymap]\nenvironments: [browser]\n \n\n<! markdownlint disable md025 md033 md060 >\n\n<packagehero package=\"gesture\" />\n\n## why gesture?\n\npointer driven interfaces need reliable movement tracking without coupling input recognition to rendering or product specific thresholds.\n\n```ts\n// before\nelement.addeventlistener('pointermove', (event) => {\n // coordinate tracking, pointer identity, direction locking, and cleanup\n});\n\n// after\nconst pan = createpangesture(element, {\n axis: 'x',\n onmove: ({ distance }) => render(distance),\n onend: ({ distance, reason }) => finish(distance, reason),\n});\n```\n\n| feature | ad hoc pointer handling | gesture |\n| | | |\n| bundle size | n/a | <packageinfo package=\"gesture\" type=\"size\" /> |\n| zero dependencies | n/a | <ore icon name=\"check\" size=\"16\"></ore icon> |\n| axis intent recognition | manual | built in |\n| pointer ownership | manual | tracked across the document |\n| lifecycle cleanup | manual | `dispose()` + `disposalsignal` |\n\n<div class=\"decision callout\">\n\n**use gesture when** several ui surfaces need consistent one axis pointer tracking while retaining their own completion rules.\n\n**consider direct pointer handling when** the interaction is isolated and does not need reusable lifecycle or direction lock behavior.\n\n</div>\n\n## installation\n\n::: code group\n\n```sh [pnpm]\npnpm add @vielzeug/gesture\n```\n\n```sh [npm]\nnpm install @vielzeug/gesture\n```\n\n```sh [yarn]\nyarn add @vielzeug/gesture\n```\n\n:::\n\n## quick start\n\n```ts\nimport { createpangesture } from '@vielzeug/gesture';\n\nconst pan = createpangesture(element, {\n axis: 'x',\n onmove: ({ distance }) => {\n element.style.transform = `translatex(${distance}px)`;\n },\n onend: ({ distance, reason }) => {\n element.style.transform = '';\n\n if (reason === 'release' && math.abs(distance) >= 48) {\n dismiss();\n }\n },\n});\n```\n\n## features\n\n<div class=\"features grid\">\n\n `createpangesture()` — one axis pointer movement tracking\n direction locking — activates only when movement favors the configured axis\n configurable pointer capture — own the pointer by default or preserve native targeting\n consumer owned policy — thresholds, snapping, and outcomes stay in application code\n stable completion — one `onend` callback for release and cancellation\n lifecycle ownership — `dispose()`, `disposed`, and `disposalsignal`\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\n</div>\n\n## see also\n\n<div class=\"see also\">\n\n [refine](/refine/) — components that use pan recognition for carousel, drawer, toast, and list interactions.\n [dnd](/dnd/) — drag and drop behavior with drop targets and reordering.\n [keymap](/keymap/) — keyboard interaction primitives for complementary input paths.\n\n</div>\n\n<! markdownlint enable md025 md033 md060 >\n",
|
|
553
553
|
"api": " \ntitle: gesture — api reference\ndescription: api reference for @vielzeug/gesture pointer pan recognition.\n \n\n[[toc]]\n\n## api overview\n\n| symbol | purpose | execution mode | common gotcha |\n| | | | |\n| `createpangesture()` | track one axis pointer movement on an element | sync | `onstart` runs after direction intent is recognized |\n| `pangesture` | lifecycle owned pan handle | sync | `dispose()` does not emit `onend` |\n| `pangestureoptions` | configure axis, admission, capture, and callbacks | sync | completion thresholds belong in `onend` |\n\n## package entry point\n\n| import | purpose |\n| | |\n| `@vielzeug/gesture` | pan recognizer and related types. |\n\n## core functions\n\n### `createpangesture()`\n\n```ts\nfunction createpangesture(target: element, options?: pangestureoptions): pangesture;\n```\n\nattaches a one axis pointer pan recognizer to `target`.\n\n| parameter | type | description |\n| | | |\n| `target` | `element` | element that owns the pointer interaction. |\n| `options` | `pangestureoptions` | axis, disabled state, admission guard, capture policy, and lifecycle callbacks. |\n\n**returns:** a `pangesture` handle.\n\n**example**\n\n```ts\nimport { createpangesture } from '@vielzeug/gesture';\n\nconst pan = createpangesture(element, {\n axis: 'x',\n onend: ({ distance, reason }) => {\n if (reason === 'release' && math.abs(distance) >= 48) dismiss();\n },\n});\n```\n\n| member | return | contract |\n| | | |\n| `active` | `boolean` | `true` after direction intent is accepted and before the interaction ends. |\n| `cancel()` | `boolean` | cancels the pending or active pointer interaction. active pans emit `onend` with `reason: 'cancel'`. |\n| `dispose()` | `void` | detaches listeners, releases pointer ownership, and aborts `disposalsignal`. idempotent. |\n| `disposed` | `boolean` | `true` after the first `dispose()`. |\n| `disposalsignal` | `abortsignal` | aborts when the handle is disposed. |\n| `[symbol.dispose]()` | `void` | calls `dispose()`. |\n\n## types\n\n```ts\ntype panaxis = 'x' | 'y';\ntype panendreason = 'cancel' | 'release';\n\ntype pangesturedetail = {\n axis: panaxis;\n current: number;\n distance: number;\n event: pointerevent;\n pointerid: number;\n pointertype: string;\n start: number;\n target: element;\n};\n\ntype pangestureenddetail = pangesturedetail & {\n reason: panendreason;\n};\n\ntype pangestureoptions = {\n axis?: panaxis | (() => panaxis);\n disabled?: boolean | (() => boolean | undefined);\n pointercapture?: boolean;\n onend?: (detail: pangestureenddetail) => void;\n onmove?: (detail: pangesturedetail) => void;\n onstart?: (detail: pangesturedetail) => void;\n shouldstart?: (event: pointerevent) => boolean;\n};\n\ntype pangesture = {\n readonly active: boolean;\n [symbol.dispose](): void;\n cancel(): boolean;\n readonly disposalsignal: abortsignal;\n dispose(): void;\n readonly disposed: boolean;\n};\n```\n\n| option | type | default | contract |\n| | | | |\n| `axis` | `panaxis \\| (() => panaxis)` | `'x'` | axis resolved when each pointer interaction starts |\n| `disabled` | `boolean \\| (() => boolean \\| undefined)` | `false` | blocks new pans and cancels an active pan on the next pointer event |\n| `pointercapture` | `boolean` | `true` | captures the pointer on `target` after axis intent is accepted |\n| `shouldstart` | `(event: pointerevent) => boolean` | — | rejects a primary pointer start before tracking begins |\n| `onstart` | `(detail: pangesturedetail) => void` | — | runs once when axis intent is accepted |\n| `onmove` | `(detail: pangesturedetail) => void` | — | runs for the activating move and later moves |\n| `onend` | `(detail: pangestureenddetail) => void` | — | runs for active release or cancellation |\n\ngesture tracks an accepted pan with capture phase listeners on `target.ownerdocument` regardless of the pointer capture setting. set `pointercapture: false` when nested or newly revealed controls must retain native pointer up and click targeting.\n\n## errors\n\n`@vielzeug/gesture` does not export custom error classes.\n",
|
|
554
554
|
"usage": " \ntitle: gesture — usage guide\ndescription: track one axis pointer movement and apply application specific completion rules.\n \n\n[[toc]]\n\n## basic usage\n\ncreate one pan handle for the element that owns the interaction.\n\n```ts\nimport { createpangesture } from '@vielzeug/gesture';\n\nconst pan = createpangesture(row, {\n axis: 'x',\n onmove: ({ distance }) => {\n row.style.transform = `translatex(${distance}px)`;\n },\n onend: ({ distance, reason }) => {\n row.style.transform = '';\n\n if (reason === 'release' && math.abs(distance) >= 64) archive();\n },\n});\n```\n\n## completion rules\n\ngesture reports movement and terminal state but does not decide what constitutes a swipe. apply thresholds and allowed directions in `onend`.\n\n```ts\nconst pan = createpangesture(panel, {\n axis: 'x',\n onend: ({ distance, reason }) => {\n if (reason === 'release' && distance <= 80) {\n opennext();\n } else {\n resetpanel();\n }\n },\n});\n```\n\n## direction recognition\n\nthe gesture remains pending during small movement. it activates only after movement favors the configured axis. cross axis movement ends the pending interaction without invoking callbacks.\n\nuse the corresponding `touch action` value so the browser retains native scrolling on the other axis.\n\n```css\n.swipe row {\n touch action: pan y;\n}\n```\n\n```ts\nconst pan = createpangesture(row, { axis: 'x', onmove });\n```\n\n## pointer capture\n\npointer capture is enabled by default. after axis intent is accepted, gesture captures the pointer on the bound target while continuing to track movement through document level listeners. this is the reliable default for ordinary drag surfaces.\n\ndisable capture when nested or newly revealed controls must retain native pointer up and click targeting:\n\n```ts\nconst pan = createpangesture(row, {\n axis: 'x',\n pointercapture: false,\n onmove: renderreveal,\n onend: settlereveal,\n});\n```\n\ndocument level tracking still keeps the pan active outside the target. disabling capture changes event targeting, not gesture tracking.\n\n## interactive descendants\n\nuse `shouldstart` when buttons, links, or form controls inside the surface must not start a pan.\n\n```ts\nconst pan = createpangesture(notification, {\n axis: 'x',\n pointercapture: false,\n shouldstart: (event) =>\n !event\n .composedpath()\n .some((node) => node instanceof element && node.matches('button, a, input, select, textarea')),\n onmove,\n onend,\n});\n```\n\n`shouldstart` protects controls under the initial pointer. `pointercapture: false` additionally protects controls that appear beneath the pointer during a reveal interaction.\n\n## disabled state\n\na boolean disables the recognizer permanently. a getter supports state that changes while the handle is alive.\n\n```ts\nconst pan = createpangesture(row, {\n disabled: () => islocked,\n onend: ({ reason }) => {\n if (reason === 'cancel') resetrow();\n },\n});\n```\n\nwhen the getter becomes `true`, the next pointer event cancels an active pan.\n\n## lifecycle\n\ndispose the target bound handle when its owning ui scope unmounts.\n\n```ts\nconst pan = createpangesture(element, { onend, onmove });\n\noncleanup(() => pan.dispose());\n```\n\nuse `cancel()` to stop a pending or active interaction without disposing the handle. an active interaction emits `onend` with `reason: 'cancel'`.\n\n## framework integration\n\ncreate the handle after the target element exists and dispose it on unmount.\n\n::: code group\n\n```tsx [react]\nimport { useeffect, useref } from 'react';\nimport { createpangesture } from '@vielzeug/gesture';\n\nfunction swiperow({ ondismiss }: { ondismiss: () => void }) {\n const rowref = useref<htmldivelement>(null);\n\n useeffect(() => {\n const row = rowref.current;\n if (!row) return;\n\n const pan = createpangesture(row, {\n axis: 'x',\n onmove: ({ distance }) => {\n row.style.transform = `translatex(${distance}px)`;\n },\n onend: ({ distance, reason }) => {\n row.style.transform = '';\n if (reason === 'release' && math.abs(distance) >= 64) ondismiss();\n },\n });\n\n return () => pan.dispose();\n }, [ondismiss]);\n\n return <div ref={rowref}>swipe me</div>;\n}\n```\n\n```vue [vue 3]\n<script setup lang=\"ts\">\nimport { onmounted, onunmounted, ref } from 'vue';\nimport { createpangesture, type pangesture } from '@vielzeug/gesture';\n\nconst emit = defineemits<{ dismiss: [] }>();\nconst rowel = ref<htmldivelement | null>(null);\nlet pan: pangesture | undefined;\n\nonmounted(() => {\n const row = rowel.value;\n if (!row) return;\n\n pan = createpangesture(row, {\n axis: 'x',\n onend: ({ distance, reason }) => {\n if (reason === 'release' && math.abs(distance) >= 64) emit('dismiss');\n },\n });\n});\n\nonunmounted(() => pan?.dispose());\n</script>\n\n<template>\n <div ref=\"rowel\">swipe me</div>\n</template>\n```\n\n```svelte [svelte]\n<script lang=\"ts\">\n import { onmount } from 'svelte';\n import { createpangesture } from '@vielzeug/gesture';\n\n let { ondismiss = () => {} }: { ondismiss: () => void } = $props();\n let rowel: htmldivelement;\n\n onmount(() => {\n const pan = createpangesture(rowel, {\n axis: 'x',\n onend: ({ distance, reason }) => {\n if (reason === 'release' && math.abs(distance) >= 64) ondismiss();\n },\n });\n\n return () => pan.dispose();\n });\n</script>\n\n<div bind:this={rowel}>swipe me</div>\n```\n\n:::\n\n## working with other vielzeug libraries\n\n### gesture + refine\n\nrefine uses gesture internally for carousel, drawer, toast, and list item pointer interactions. custom surfaces can use the same pan lifecycle while keeping visual state local.\n\n```ts\nimport { createpangesture } from '@vielzeug/gesture';\n\nconst pan = createpangesture(panel, {\n axis: 'x',\n onmove: ({ distance }) => {\n panel.style.transform = `translatex(${distance}px)`;\n },\n onend: ({ distance, reason }) => {\n panel.style.transform = '';\n if (reason === 'release' && math.abs(distance) >= 80) revealactions();\n },\n});\n```\n\n### gesture + dnd\n\ngesture tracks a constrained pointer pan. dnd owns draggable items, sortable lists, and drop targets. keep them separate.\n\n## best practices\n\n **set** `touch action` for the axis the browser should continue scrolling.\n **use** `shouldstart` to exclude nested interactive controls.\n **disable** pointer capture when nested or newly revealed controls must keep native release targeting.\n **apply** thresholds and direction rules in `onend`.\n **treat** `reason: 'cancel'` as a reset path, never a commit path.\n **keep** `onmove` rendering lightweight.\n **dispose** the handle when its target leaves the ui.\n",
|
|
555
|
-
"examples": " \ntitle: gesture — examples\ndescription: worked examples for @vielzeug/gesture.\n \n\n## examples\n\n [carousel
|
|
555
|
+
"examples": " \ntitle: gesture — examples\ndescription: worked examples for @vielzeug/gesture.\n \n\n## examples\n\n [carousel pan navigation](./examples/carousel swipe navigation.md)\n [swipe to dismiss notifications](./examples/swipe dismiss notifications.md)\n"
|
|
556
556
|
},
|
|
557
557
|
"examples": [
|
|
558
558
|
{
|
|
@@ -571,9 +571,9 @@
|
|
|
571
571
|
"category": "events",
|
|
572
572
|
"description": "typed temporal event delivery with sync subscriptions, async waiting, streams, pipes, and abortsignal lifecycle.",
|
|
573
573
|
"docs": {
|
|
574
|
-
"index": " \ntitle: herald — typed event bus for typescript\ndescription: typed temporal event delivery with sync subscriptions, async waiting, streams, pipes, and abortsignal lifecycle.\npackage: herald\ncategory: events\nkeywords: [event bus, typed events, pub sub, async streams, abort signal]\nrelated: [ripple, wayfinder, familiar]\nexports: [createbus, pipeevents, combinesignals, heralderror, busdisposederror, heraldconfigerror]\nenvironments: [browser, node, ssr, deno]\n \n\n<! markdownlint disable md025 md033 md060 >\n\n<packagehero package=\"herald\" />\n\n## why herald?\n\nraw event emitters lose payload inference and leave waiting, streaming, cancellation, and teardown to every caller. herald keeps events temporal: use [ripple](/ripple/) when you need retained state.\n\n```ts\n// before\nconst listeners = new set<(payload: unknown) => void>();\nlisteners.add((payload) => loadprofile((payload as { id: string }).id));\n\n// after\nimport { createbus } from '@vielzeug/herald';\n\ninterface appevents {\n 'user:login': { id: string };\n}\n\nfunction loadprofile(id: string): void {\n console.log(id);\n}\n\nconst bus = createbus<appevents>();\nbus.on('user:login', ({ id }) => loadprofile(id));\n```\n\n| feature | herald | mitt | eventemitter3 |\n| | | | |\n| bundle size | <packageinfo package=\"herald\" type=\"size\" /> | ~200 b | ~1.5 kb |\n| typed payloads | <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| async wait and streams | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> |\n| abortsignal lifecycle | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> |\n| typed event pipes | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> |\n| zero dependencies | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> |\n\n<div class=\"decision callout\">\n\n**use herald when** modules need typed temporal event delivery with owned lifecycle.\n\n**consider ripple when** consumers need current state and replayed values.\n\n</div>\n\n## installation\n\n::: code group\n\n```sh [pnpm]\npnpm add @vielzeug/herald\n```\n\n```sh [npm]\nnpm install @vielzeug/herald\n```\n\n```sh [yarn]\nyarn add @vielzeug/herald\n```\n\n:::\n\n## quick start\n\n```ts\nimport { createbus } from '@vielzeug/herald';\n\ninterface appevents {\n 'user:login': { id: string };\n 'user:logout': void;\n}\n\nconst bus = createbus<appevents>();\nconst stop = bus.on('user:login', ({ id }) => console.log(id));\n\nbus.emit('user:login', { id: '42' });\nstop();\nbus.dispose();\n```\n\n## features\n\n<div class=\"features grid\">\n\n `on()` / `once()` — typed subscriptions with explicit teardown\n `onany()` — cross cutting event observation\n `wait()` / `waitany()` — one shot async coordination\n `events()` — bounded async event streams\n `pipeevents()` — compatible cross bus forwarding\n `abortsignal` — cancellation and disposal ownership\n `createtestbus()` — emitted payload recording for tests\n
|
|
575
|
-
"api": " \ntitle: herald — api reference\ndescription: reference for typed temporal event delivery, lifecycle ownership, and compatible event piping.\n \n\n[[toc]]\n\n## api overview\n\n| symbol | purpose | execution mode | common gotcha |\n| | | | |\n| `createbus()` | create typed temporal event bus | sync | `emit()` and middleware are synchronous |\n| `pipeevents()` | forward compatible source events | sync | payloads must be assignable to target event |\n| `combinesignals()` | abort when any input aborts | sync | public composition has no manual teardown |\n| `createtestbus()` | record dispatched test events | sync | available from `/testing` only |\n
|
|
576
|
-
"usage": " \ntitle: herald — usage guide\ndescription: typed event maps, lifecycle owned subscriptions, waits, streams, pipes, and testing.\n \n\n[[toc]]\n\n## basic usage\n\nuse interface or type alias event maps. events model facts that happened; use ripple for current state.\n\n```ts\nimport { createbus } from '@vielzeug/herald';\n\ninterface appevents {\n 'cart:updated': { count: number };\n 'user:logout': void;\n}\n\nconst bus = createbus<appevents>();\nconst stop = bus.on('cart:updated', ({ count }) => console.log(count));\n\nbus.emit('cart:updated', { count: 1 });\nstop();\nbus.dispose();\n```\n\n## subscriptions\n\nuse `once()` for one event and `{ signal }` for owned subscription lifetime.\n\n```ts\nconst controller = new abortcontroller();\n\nbus.on('cart:updated', rendercart, { signal: controller.signal });\nbus.once('user:logout', clearsession);\ncontroller.abort();\n```\n\n## middleware and validation\n\nmiddleware is synchronous. call `next()` once to continue; omit it to block dispatch.\n\n```ts\nconst bus = createbus<appevents>({\n middleware: [\n (event, payload, next) => {\n audit(event, payload);\n next();\n },\n ],\n validatepayload: (event, payload) => {\n if (event === 'cart:updated' && payload.count < 0) throw new rangeerror('count must be non negative');\n },\n});\n```\n\n## awaiting events\n\n```ts\nconst cart = await bus.wait('cart:updated', { signal: abortsignal.timeout(5_000) });\nconst winner = await bus.waitany(['cart:updated', 'user:logout'], { signal: abortsignal.timeout(5_000) });\n```\n\n## streaming events\n\n`events()` subscribes eagerly. bound buffers for producers faster than consumers.\n\n```ts\nawait using stream = bus.events('cart:updated', { maxbuffer: 100 });\n\nfor await (const cart of stream) {\n rendercart(cart);\n}\n```\n\n## piping events\n\n`pipeevents()` only accepts compatible payloads. stop explicitly or tie pipe to signal.\n\n```ts\nconst stoppipe = pipeevents(sourcebus, auditbus, ['cart:updated'], { signal: pagesignal });\nstoppipe();\n```\n\n## testing\n\n`createtestbus()` records dispatched payloads without mocks.\n\n```ts\nimport { createtestbus } from '@vielzeug/herald/testing';\n\nconst bus = createtestbus<appevents>();\nbus.emit('cart:updated', { count: 2 });\nexpect(bus.emitted('cart:updated')).toequal([{ count: 2 }]);\nbus.dispose();\n```\n\n## debugging\n\n```ts\nimport {
|
|
574
|
+
"index": " \ntitle: herald — typed event bus for typescript\ndescription: typed temporal event delivery with sync subscriptions, async waiting, streams, pipes, and abortsignal lifecycle.\npackage: herald\ncategory: events\nkeywords: [event bus, typed events, pub sub, async streams, abort signal]\nrelated: [ripple, wayfinder, familiar]\nexports: [createbus, pipeevents, combinesignals, heralderror, busdisposederror, heraldconfigerror]\nenvironments: [browser, node, ssr, deno]\n \n\n<! markdownlint disable md025 md033 md060 >\n\n<packagehero package=\"herald\" />\n\n## why herald?\n\nraw event emitters lose payload inference and leave waiting, streaming, cancellation, and teardown to every caller. herald keeps events temporal: use [ripple](/ripple/) when you need retained state.\n\n```ts\n// before\nconst listeners = new set<(payload: unknown) => void>();\nlisteners.add((payload) => loadprofile((payload as { id: string }).id));\n\n// after\nimport { createbus } from '@vielzeug/herald';\n\ninterface appevents {\n 'user:login': { id: string };\n}\n\nfunction loadprofile(id: string): void {\n console.log(id);\n}\n\nconst bus = createbus<appevents>();\nbus.on('user:login', ({ id }) => loadprofile(id));\n```\n\n| feature | herald | mitt | eventemitter3 |\n| | | | |\n| bundle size | <packageinfo package=\"herald\" type=\"size\" /> | ~200 b | ~1.5 kb |\n| typed payloads | <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| async wait and streams | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> |\n| abortsignal lifecycle | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> |\n| typed event pipes | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> |\n| zero dependencies | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> |\n\n<div class=\"decision callout\">\n\n**use herald when** modules need typed temporal event delivery with owned lifecycle.\n\n**consider ripple when** consumers need current state and replayed values.\n\n</div>\n\n## installation\n\n::: code group\n\n```sh [pnpm]\npnpm add @vielzeug/herald\n```\n\n```sh [npm]\nnpm install @vielzeug/herald\n```\n\n```sh [yarn]\nyarn add @vielzeug/herald\n```\n\n:::\n\n## quick start\n\n```ts\nimport { createbus } from '@vielzeug/herald';\n\ninterface appevents {\n 'user:login': { id: string };\n 'user:logout': void;\n}\n\nconst bus = createbus<appevents>();\nconst stop = bus.on('user:login', ({ id }) => console.log(id));\n\nbus.emit('user:login', { id: '42' });\nstop();\nbus.dispose();\n```\n\n## features\n\n<div class=\"features grid\">\n\n `on()` / `once()` — typed subscriptions with explicit teardown\n `onany()` — cross cutting event observation\n `tap()` — observe bus activity for logging and diagnostics\n `wait()` / `waitany()` — one shot async coordination\n `events()` — bounded async event streams\n `pipeevents()` — compatible cross bus forwarding\n `abortsignal` — cancellation and disposal ownership\n `createtestbus()` — emitted payload recording for tests\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/) — retained reactive state.\n [wayfinder](/wayfinder/) — route lifecycle events.\n [familiar](/familiar/) — worker completion events.\n\n</div>\n\n<! markdownlint enable md025 md033 md060 >\n",
|
|
575
|
+
"api": " \ntitle: herald — api reference\ndescription: reference for typed temporal event delivery, lifecycle ownership, and compatible event piping.\n \n\n[[toc]]\n\n## api overview\n\n| symbol | purpose | execution mode | common gotcha |\n| | | | |\n| `createbus()` | create typed temporal event bus | sync | `emit()` and middleware are synchronous |\n| `pipeevents()` | forward compatible source events | sync | payloads must be assignable to target event |\n| `combinesignals()` | abort when any input aborts | sync | public composition has no manual teardown |\n| `createtestbus()` | record dispatched test events | sync | available from `/testing` only |\n\n## package entry point\n\n| import | purpose |\n| | |\n| `@vielzeug/herald` | runtime bus, pipes, public types, and errors |\n| `@vielzeug/herald/testing` | `createtestbus()` and `testbus` |\n\n## core functions\n\n### `createbus()`\n\n```ts\nfunction createbus<t extends eventmap = record<string, unknown>>(\n options?: busoptions<t>,\n): bus<t>;\n```\n\ncreates a synchronous bus for future event delivery.\n\n| parameter | type | description |\n| | | |\n| `options` | `busoptions<t>` | optional middleware, validation, error handling, and listener threshold configuration. |\n\n**returns:** `bus<t>`.\n\n```ts\nimport { createbus } from '@vielzeug/herald';\n\ninterface events {\n count: number;\n ready: void;\n}\n\nconst bus = createbus<events>();\nbus.emit('count', 1);\nbus.emit('ready');\nbus.dispose();\n```\n\n \n\n### `pipeevents()`\n\n```ts\nfunction pipeevents<s extends eventmap, t extends eventmap>(\n source: bus<s>,\n target: bus<t>,\n entries: readonly [noinfer<pipeentry<s, t>>, ...noinfer<pipeentry<s, t>>[]],\n opts?: { signal?: abortsignal },\n): unsubscribe;\n```\n\nforwards listed compatible events until manually stopped, either bus disposes, or `options.signal` aborts.\n\n| parameter | type | description |\n| | | |\n| `source` | `bus<s>` | bus that emits source events. |\n| `target` | `bus<t>` | bus that receives compatible events. |\n| `entries` | non empty `pipeentry` tuple | same name keys or compatible `{ from, to }` mappings. |\n| `opts.signal` | `abortsignal` | optional pipe lifetime signal. |\n\n**returns:** idempotent `unsubscribe` function.\n\n```ts\nimport { createbus, pipeevents } from '@vielzeug/herald';\n\ninterface sourceevents {\n 'auth:login': { id: string };\n}\n\ninterface targetevents {\n 'user:authenticated': { id: string };\n}\n\nconst source = createbus<sourceevents>();\nconst target = createbus<targetevents>();\nconst stop = pipeevents(source, target, [{ from: 'auth:login', to: 'user:authenticated' }]);\n\nstop();\nsource.dispose();\ntarget.dispose();\n```\n\n \n\n### `combinesignals()`\n\n```ts\nfunction combinesignals(first: abortsignal, ...rest: abortsignal[]): abortsignal;\n```\n\nreturns a signal aborted with first input signal's reason.\n\n**returns:** `abortsignal`.\n\n```ts\nimport { combinesignals } from '@vielzeug/herald';\n\nconst signal = combinesignals(abortsignal.timeout(1_000), controller.signal);\n```\n\ninput listeners remain until an input aborts. bus apis that accept `{ signal }` clean their internal signal composition when their owned operation ends.\n\n## types\n\n### `eventmap` and `eventkey`\n\n```ts\ntype eventmap = object;\ntype eventkey<t extends eventmap> = extract<keyof t, string>;\n```\n\n`eventmap` accepts interfaces and type aliases. only string keys are event names.\n\n \n\n### `busoptions`\n\n```ts\ntype busoptions<t extends eventmap = eventmap> = {\n maxlisteners?: number;\n middleware?: readonly middleware<t>[];\n name?: string;\n onerror?: (context: emissionerrorcontext<t>) => void;\n validatepayload?: <k extends eventkey<t>>(event: k, payload: t[k]) => void;\n};\n```\n\n| field | description |\n| | |\n| `maxlisteners` | warn when one event exceeds this active listener count. |\n| `middleware` | synchronous dispatch middleware. |\n| `name` | display name in disposal errors. |\n| `onerror` | handles listener and validation errors instead of rethrowing. |\n| `validatepayload` | runs before middleware and listeners. |\n\n \n\n### `bus`\n\n```ts\ntype bus<t extends eventmap> = {\n [symbol.dispose](): void;\n readonly disposalsignal: abortsignal;\n dispose(): void;\n readonly disposed: boolean;\n emit<k extends eventkey<t>>(event: k, ...args: t[k] extends void ? [] : [payload: t[k]]): number;\n eventnames(): eventkey<t>[];\n events<k extends eventkey<t>>(event: k, opts?: { maxbuffer?: number; signal?: abortsignal }): eventstream<t[k]>;\n listenercount(event?: eventkey<t>): number;\n on<k extends eventkey<t>>(event: k, listener: listener<t[k]>, opts?: subscribeoptions): unsubscribe;\n onany(listener: (event: eventkey<t>, payload: unknown) => void, opts?: subscribeoptions): unsubscribe;\n once<k extends eventkey<t>>(event: k, listener: listener<t[k]>, opts?: { signal?: abortsignal }): unsubscribe;\n tap(handler: (event: heraldevent<t>) => void, options?: { signal?: abortsignal }): unsubscribe;\n wait<k extends eventkey<t>>(event: k, opts?: { signal?: abortsignal }): promise<t[k]>;\n waitany<const k extends readonly [eventkey<t>, eventkey<t>, ...eventkey<t>[]]>(\n events: k,\n opts?: { signal?: abortsignal },\n ): promise<waitanyresult<t, k>>;\n wildcardcount(): number;\n};\n```\n\n`emit()` returns listener count or `0` after disposal, blocked middleware, or handled validation rejection.\n\n`tap()` receives every `emit`, `subscribe`, `unsubscribe`, `listener error`, and `dispose` event as a `heraldevent`. it is the supported way to observe bus activity for logging and diagnostics. the returned `unsubscribe` stops the tap; pass `{ signal }` to bind its lifetime to an `abortsignal`.\n\n```ts\nimport { createbus } from '@vielzeug/herald';\n\nconst bus = createbus<appevents>();\nconst stop = bus.tap((event) => console.debug(`herald:${event.type}`, event));\n```\n\n \n\n### `listener`, `subscribeoptions`, and `unsubscribe`\n\n```ts\ntype listener<t> = (payload: t) => void;\ntype subscribeoptions = { once?: boolean; signal?: abortsignal };\ntype unsubscribe = () => void;\n```\n\n \n\n### `heraldevent`\n\n```ts\ntype heraldevent<t extends eventmap = eventmap> =\n | { type: 'emit'; event: eventkey<t>; payload: unknown; timestamp: number }\n | { type: 'subscribe'; event: eventkey<t>; timestamp: number }\n | { type: 'unsubscribe'; event: eventkey<t>; timestamp: number }\n | { type: 'listener error'; event: eventkey<t>; err: unknown; timestamp: number }\n | { type: 'dispose'; timestamp: number };\n```\n\ndiscriminated union delivered to `tap()` handlers. narrow on `event.type` to access type specific fields.\n\n \n\n### `emissionerrorcontext` and `middleware`\n\n```ts\ntype emissionerrorcontext<t extends eventmap = eventmap> = {\n err: unknown;\n event: eventkey<t>;\n payload: unknown;\n timestamp: number;\n};\n\ntype middleware<t extends eventmap = eventmap> = (\n event: eventkey<t>,\n payload: unknown,\n next: () => void,\n) => void;\n```\n\ncall middleware `next()` synchronously at most once. omit it to block dispatch.\n\n \n\n### `eventstream` and `waitanyresult`\n\n```ts\ntype eventstream<t> = asyncgenerator<t> & asyncdisposable;\n\ntype waitanyresult<t extends eventmap, k extends readonly eventkey<t>[]> = {\n [i in keyof k]: k[i] extends eventkey<t> ? { event: k[i]; payload: t[k[i]] } : never;\n}[number];\n```\n\n \n\n### `pipeablekey`, `renamedpipeentry`, and `pipeentry`\n\n```ts\ntype pipeablekey<s extends eventmap, t extends eventmap> = {\n [k in eventkey<s> & eventkey<t>]: s[k] extends t[k] ? k : never;\n}[eventkey<s> & eventkey<t>];\n\ntype renamedpipeentry<s extends eventmap, t extends eventmap> = {\n [from in eventkey<s>]: {\n [to in eventkey<t>]: s[from] extends t[to] ? { from: from; to: to } : never;\n }[eventkey<t>];\n}[eventkey<s>];\n\ntype pipeentry<s extends eventmap, t extends eventmap> =\n | pipeablekey<s, t>\n | renamedpipeentry<s, t>;\n```\n\n## testing\n\n### `createtestbus()`\n\n```ts\nfunction createtestbus<t extends eventmap = record<string, unknown>>(\n options?: busoptions<t>,\n): testbus<t>;\n```\n\ncreates a bus that records dispatched payloads.\n\n**returns:** `testbus<t>`.\n\n### `testbus`\n\n```ts\ntype testbus<t extends eventmap> = bus<t> & {\n allemitted(): { [k in eventkey<t>]?: t[k][] };\n emitted<k extends eventkey<t>>(event: k): t[k][];\n emittedcount<k extends eventkey<t>>(event: k): number;\n reset(): void;\n};\n```\n\n## errors\n\n| error | trigger | notable properties |\n| | | |\n| `busdisposederror` | `wait()` or `waitany()` interrupted by disposal | bus name appears when configured. |\n| `heraldconfigerror` | invalid stream buffer, empty pipe entries, or fewer than two `waitany()` events | — |\n| `heralderror` | base class for herald originated errors | `instanceof heralderror` narrows subclasses. |\n",
|
|
576
|
+
"usage": " \ntitle: herald — usage guide\ndescription: typed event maps, lifecycle owned subscriptions, waits, streams, pipes, and testing.\n \n\n[[toc]]\n\n## basic usage\n\nuse interface or type alias event maps. events model facts that happened; use ripple for current state.\n\n```ts\nimport { createbus } from '@vielzeug/herald';\n\ninterface appevents {\n 'cart:updated': { count: number };\n 'user:logout': void;\n}\n\nconst bus = createbus<appevents>();\nconst stop = bus.on('cart:updated', ({ count }) => console.log(count));\n\nbus.emit('cart:updated', { count: 1 });\nstop();\nbus.dispose();\n```\n\n## subscriptions\n\nuse `once()` for one event and `{ signal }` for owned subscription lifetime.\n\n```ts\nconst controller = new abortcontroller();\n\nbus.on('cart:updated', rendercart, { signal: controller.signal });\nbus.once('user:logout', clearsession);\ncontroller.abort();\n```\n\n## middleware and validation\n\nmiddleware is synchronous. call `next()` once to continue; omit it to block dispatch.\n\n```ts\nconst bus = createbus<appevents>({\n middleware: [\n (event, payload, next) => {\n audit(event, payload);\n next();\n },\n ],\n validatepayload: (event, payload) => {\n if (event === 'cart:updated' && payload.count < 0) throw new rangeerror('count must be non negative');\n },\n});\n```\n\n## awaiting events\n\n```ts\nconst cart = await bus.wait('cart:updated', { signal: abortsignal.timeout(5_000) });\nconst winner = await bus.waitany(['cart:updated', 'user:logout'], { signal: abortsignal.timeout(5_000) });\n```\n\n## streaming events\n\n`events()` subscribes eagerly. bound buffers for producers faster than consumers.\n\n```ts\nawait using stream = bus.events('cart:updated', { maxbuffer: 100 });\n\nfor await (const cart of stream) {\n rendercart(cart);\n}\n```\n\n## piping events\n\n`pipeevents()` only accepts compatible payloads. stop explicitly or tie pipe to signal.\n\n```ts\nconst stoppipe = pipeevents(sourcebus, auditbus, ['cart:updated'], { signal: pagesignal });\nstoppipe();\n```\n\n## testing\n\n`createtestbus()` records dispatched payloads without mocks.\n\n```ts\nimport { createtestbus } from '@vielzeug/herald/testing';\n\nconst bus = createtestbus<appevents>();\nbus.emit('cart:updated', { count: 2 });\nexpect(bus.emitted('cart:updated')).toequal([{ count: 2 }]);\nbus.dispose();\n```\n\n## debugging\n\n`tap()` observes every bus activity as a `heraldevent` — use it for logging and diagnostics.\n\n```ts\nimport { createbus } from '@vielzeug/herald';\n\nconst bus = createbus<appevents>();\nbus.tap((event) => console.debug(`herald:${event.type}`, event));\n```\n\nintegrate with the rune logger:\n\n```ts\nimport { createlogger } from '@vielzeug/rune';\n\nconst log = createlogger({ name: 'herald' });\nbus.tap((event) => log.debug(event, `herald:${event.type}`));\n```\n\n## working with other vielzeug libraries\n\nuse herald for temporal events. use ripple for retained reactive state. use familiar or courier completion handlers to emit application events.\n\n## best practices\n\n define one explicit event map per boundary.\n emit facts, not mutable application state.\n keep middleware synchronous and call `next()` once.\n pass abortsignals for component/request scoped work.\n set `maxbuffer` for long lived streams.\n use `wait()` only for one off coordination.\n use unsubscribe handles instead of global listener removal.\n dispose owner scoped buses.\n",
|
|
577
577
|
"examples": " \ntitle: herald — examples\ndescription: practical examples and recipes for herald.\n \n\n## examples\n\n [standalone entry](./examples/standalone entry.md)\n [module level bus](./examples/module level bus.md)\n [awaiting a one time event](./examples/awaiting a one time event.md)\n [inspecting listener counts](./examples/inspecting listener counts.md)\n [custom error boundary](./examples/custom error boundary.md)\n [handling disposal in async code](./examples/handling disposal in async code.md)\n [request scoping](./examples/request scoping.md)\n [streaming with events](./examples/streaming with events.md)\n [bus bridging with pipeevents](./examples/bus bridging with pipeevents.md)\n [testing with createtestbus](./examples/testing with createtestbus.md)\n"
|
|
578
578
|
},
|
|
579
579
|
"examples": [
|
|
@@ -643,7 +643,7 @@
|
|
|
643
643
|
"name": "@vielzeug/herald",
|
|
644
644
|
"related": "ripple wayfinder familiar",
|
|
645
645
|
"slug": "herald",
|
|
646
|
-
"source": "export { combinesignals, createbus } from './bus';\nexport { busdisposederror, heraldconfigerror, heralderror } from './errors';\nexport { pipeevents } from './pipe';\nexport type {\n bus,\n
|
|
646
|
+
"source": "export { combinesignals, createbus } from './bus';\nexport { busdisposederror, heraldconfigerror, heralderror } from './errors';\nexport { pipeevents } from './pipe';\nexport type {\n bus,\n busoptions,\n emissionerrorcontext,\n eventkey,\n eventmap,\n eventstream,\n heraldevent,\n listener,\n middleware,\n pipeablekey,\n pipeentry,\n subscribeoptions,\n unsubscribe,\n waitanyresult,\n} from './types';\n"
|
|
647
647
|
},
|
|
648
648
|
{
|
|
649
649
|
"category": "data",
|
|
@@ -704,7 +704,7 @@
|
|
|
704
704
|
"description": "target local keyboard shortcut manager with chords, event aware guards, modifier aliases, and terminal disposal.",
|
|
705
705
|
"docs": {
|
|
706
706
|
"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 canonicalizeshortcut,\n createkeymap,\n detectmodkey,\n findshortcutconflicts,\n formatshortcut,\n keymaperror,\n keymapparseerror,\n matchstep,\n parseshortcut,\n parsestep,\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({ 'mod+s': () => console.log('save') });\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| 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 'mod+k mod+s': () => console.log('save'),\n 'mod+shift+p': () => console.log('open palette'),\n 'g g': () => window.scrollto({ top: 0 }),\n escape: () => 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 a bindings record; 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 `bindingoptions` — per binding `{ handler, when?, trigger? }` object syntax\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 `parseshortcut()` / `parsestep()` / `matchstep()` — exposed for building custom matchers or testing\n `canonicalizeshortcut()` — convert any shortcut alias to a stable key for conflict detection\n `detectmodkey()` — platform modifier detection (`'meta'` on mac, `'ctrl'` elsewhere)\n `listbindings()` — snapshot all active bindings (shortcut and trigger) 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 to 2.0](./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",
|
|
707
|
-
"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 | 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| `chordstatechange` | type for chord state callback events | — | no 'completed' event; handler fires immediately when matched |\n\n### power user api (custom tooling)\n\nuse the power user api 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 and lifecycle errors |\n| `keymapparseerror` | strict parser error | sync | `parsestep()` never throws it |\n\n## package entry point\n\n| import | purpose |\n| | |\n| `@vielzeug/keymap` | root entry point for every runtime function, error class, and public type listed here. |\n\n## core manager\n\n### `createkeymap()`\n\n```ts\nfunction createkeymap(\n bindings?: record<string, bindingvalue>,\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` | `record<string, bindingvalue>` | initial bindings. keys must be non empty valid shortcut strings. |\n| `options` | `keymapoptions` | chord, modifier, event, and global guard configuration. |\n\n**returns:** `keymap`.\n\n```ts\nimport { createkeymap } from '@vielzeug/keymap';\n\nconst map = createkeymap({ 'ctrl+s': () => console.log('save') });\nconst unmount = map.mount(document);\n\nunmount();\nmap.dispose();\n```\n\n| `keymap` member | return | contract |\n| | | |\n| `bind(shortcut, value)` | `() => void` | adds or replaces canonical shortcut. returned callback removes that binding while active. |\n| `mount(target)` | `() => void` | adds target listener. repeat mounts of same target are reference counted. |\n| `unbind(shortcut)` | `void` | removes canonical shortcut. warns in development when unknown. |\n| `listbindings()` | `readonly bindingentry[]` | returns a detached binding snapshot. |\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## 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({ g: () => 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## parsing and matching\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';\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';\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';\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';\n\nconst step = parsestep('ctrl+k', 'ctrl')!;\nmatchstep(new keyboardevent('keydown', { ctrlkey: true, key: 'k' }), step); // true\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';\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(shortcut: string, value: bindingvalue): () => void;\n dispose(): void;\n readonly disposalsignal: abortsignal;\n readonly disposed: boolean;\n listbindings(): readonly bindingentry[];\n mount(target: eventtarget): () => void;\n unbind(shortcut: 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 preventdefault?: boolean;\n stoppropagation?: boolean;\n when?: when;\n onchordstate?: (change: chordstatechange) => void;\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 `onchordstate`: optional callback to observe chord state changes (started, progressed, or timeout). useful for debugging, testing, logging, or implementing chord ui hints. callback errors are caught and logged in development. note: when a chord completes, the binding handler fires immediately; no separate 'completed' event is emitted.\n\n### `bindingoptions`\n\nper binding handler configuration.\n\n```ts\ntype bindingoptions = {\n handler: handler;\n trigger?: 'keydown' | 'keyup';\n when?: when;\n};\n```\n\n### `bindingvalue`, `handler`, and `when`\n\naccepted values when registering a shortcut.\n\n```ts\ntype handler = (event: keyboardevent) => void;\ntype when = (event: keyboardevent) => boolean;\ntype bindingvalue = handler | bindingoptions;\n```\n\n### `bindingentry`\n\ndetached binding metadata returned by `listbindings()`.\n\n```ts\ntype bindingentry = {\n readonly shortcut: readonly shortcutstep[];\n readonly trigger: 'keydown' | 'keyup';\n};\n```\n\n### `modifierkey`, `shortcut`, and `shortcutstep`\n\nparser types used by `parseshortcut()`, `parsestep()`, `matchstep()`, and `canonicalizeshortcut()`.\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### `chordstatechange`\n\ndiscriminated union type for chord state events emitted by `onchordstate` callback. when a chord fully matches, the binding handler fires immediately; no separate 'completed' event is emitted.\n\n```ts\ntype chordstatechange =\n | { type: 'started'; target: eventtarget; step: shortcutstep; trigger: 'keydown' | 'keyup' }\n | { type: 'progressed'; target: eventtarget; steps: readonly shortcutstep[]; trigger: 'keydown' | 'keyup' }\n | { type: 'timeout'; target: eventtarget; trigger: 'keydown' | 'keyup' };\n```\n\n| event | fields | when | use case |\n| | | | |\n| `started` | `target`, `step`, `trigger` | first key of a chord is pressed. | show \"waiting for next key\" ui hint. |\n| `progressed` | `target`, `steps`, `trigger` | additional step(s) added to pending chord. | update chord hint with current progress. |\n| `timeout` | `target`, `trigger` | chord was pending but timed out without completing. | clear \"waiting\" ui state; log timeout for debugging. |\n\n```ts\nimport { createkeymap } from '@vielzeug/keymap';\n\nconst map = createkeymap(\n { 'g g': () => scrolltotop() },\n {\n onchordstate: (change) => {\n if (change.type === 'started') {\n console.log(`chord started: ${change.step.key}`);\n }\n if (change.type === 'progressed') {\n console.log(`chord progress: ${change.steps.map((s) => s.key).join(' ')}`);\n }\n if (change.type === 'timeout') {\n console.log('chord timed out');\n }\n },\n },\n);\n```\n\n## errors\n\n| error | trigger | notable properties |\n| | | |\n| `keymaperror` | lifecycle operation after disposal | `keymaperror.is(error)` narrows keymap errors. |\n| `keymapparseerror` | strict shortcut parser receives invalid input | extends `keymaperror`. |\n",
|
|
707
|
+
"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 | 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| `chordstatechange` | type for chord state callback events | — | no 'completed' event; handler fires immediately when matched |\n\n### power user api (custom tooling)\n\nuse the power user api 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 and lifecycle errors |\n| `keymapparseerror` | strict parser error | sync | `parsestep()` never throws it |\n\n## package entry point\n\n| import | purpose |\n| | |\n| `@vielzeug/keymap` | root entry point for every runtime function, error class, and public type listed here. |\n\n## core manager\n\n### `createkeymap()`\n\n```ts\nfunction createkeymap(\n bindings?: record<string, bindingvalue>,\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` | `record<string, bindingvalue>` | initial bindings. keys must be non empty valid shortcut strings. |\n| `options` | `keymapoptions` | chord, modifier, event, and global guard configuration. |\n\n**returns:** `keymap`.\n\n```ts\nimport { createkeymap } from '@vielzeug/keymap';\n\nconst map = createkeymap({ 'ctrl+s': () => console.log('save') });\nconst unmount = map.mount(document);\n\nunmount();\nmap.dispose();\n```\n\n| `keymap` member | return | contract |\n| | | |\n| `bind(shortcut, value)` | `() => void` | adds or replaces canonical shortcut. returned callback removes that binding while active. |\n| `mount(target)` | `() => void` | adds target listener. repeat mounts of same target are reference counted. |\n| `unbind(shortcut)` | `void` | removes canonical shortcut. warns in development when unknown. |\n| `listbindings()` | `readonly bindingentry[]` | returns a detached binding snapshot. |\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## 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({ g: () => 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## parsing and matching\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';\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';\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';\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';\n\nconst step = parsestep('ctrl+k', 'ctrl')!;\nmatchstep(new keyboardevent('keydown', { ctrlkey: true, key: 'k' }), step); // true\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';\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(shortcut: string, value: bindingvalue): () => void;\n dispose(): void;\n readonly disposalsignal: abortsignal;\n readonly disposed: boolean;\n listbindings(): readonly bindingentry[];\n mount(target: eventtarget): () => void;\n unbind(shortcut: 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 preventdefault?: boolean;\n stoppropagation?: boolean;\n when?: when;\n onchordstate?: (change: chordstatechange) => void;\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 `onchordstate`: optional callback to observe chord state changes (started, progressed, or timeout). useful for debugging, testing, logging, or implementing chord ui hints. callback errors are caught and logged in development. note: when a chord completes, the binding handler fires immediately; no separate 'completed' event is emitted.\n\n### `bindingoptions`\n\nper binding handler configuration.\n\n```ts\ntype bindingoptions = {\n handler: handler;\n trigger?: 'keydown' | 'keyup';\n when?: when;\n};\n```\n\n### `bindingvalue`, `handler`, and `when`\n\naccepted values when registering a shortcut.\n\n```ts\ntype handler = (event: keyboardevent) => void;\ntype when = (event: keyboardevent) => boolean;\ntype bindingvalue = handler | bindingoptions;\n```\n\n### `bindingentry`\n\ndetached binding metadata returned by `listbindings()`.\n\n```ts\ntype bindingentry = {\n readonly shortcut: readonly shortcutstep[];\n readonly trigger: 'keydown' | 'keyup';\n};\n```\n\n### `modifierkey`, `shortcut`, and `shortcutstep`\n\nparser types used by `parseshortcut()`, `parsestep()`, `matchstep()`, and `canonicalizeshortcut()`.\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### `chordstatechange`\n\ndiscriminated union type for chord state events emitted by `onchordstate` callback. when a chord fully matches, the binding handler fires immediately; no separate 'completed' event is emitted.\n\n```ts\ntype chordstatechange =\n | { type: 'started'; target: eventtarget; step: shortcutstep; trigger: 'keydown' | 'keyup' }\n | { type: 'progressed'; target: eventtarget; steps: readonly shortcutstep[]; trigger: 'keydown' | 'keyup' }\n | { type: 'timeout'; target: eventtarget; trigger: 'keydown' | 'keyup' };\n```\n\n| event | fields | when | use case |\n| | | | |\n| `started` | `target`, `step`, `trigger` | first key of a chord is pressed. | show \"waiting for next key\" ui hint. |\n| `progressed` | `target`, `steps`, `trigger` | additional step(s) added to pending chord. | update chord hint with current progress. |\n| `timeout` | `target`, `trigger` | chord was pending but timed out without completing. | clear \"waiting\" ui state; log timeout for debugging. |\n\n```ts\nimport { createkeymap } from '@vielzeug/keymap';\n\nconst map = createkeymap(\n { 'g g': () => scrolltotop() },\n {\n onchordstate: (change) => {\n if (change.type === 'started') {\n console.log(`chord started: ${change.step.key}`);\n }\n if (change.type === 'progressed') {\n console.log(`chord progress: ${change.steps.map((s) => s.key).join(' ')}`);\n }\n if (change.type === 'timeout') {\n console.log('chord timed out');\n }\n },\n },\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| `keymapparseerror` | strict shortcut parser receives invalid input | extends `keymaperror`. |\n",
|
|
708
708
|
"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 'ctrl+s': () => console.log('save'),\n 'ctrl+z': () => console.log('undo'),\n escape: () => 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 { 'mod+k': () => 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 'ctrl+k ctrl+s': () => console.log('save'),\n 'g g': () => window.scrollto({ top: 0 }),\n 'g e': () => 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\nadd a guard or choose `keyup` with `bindingoptions`.\n\n```ts\nconst map = createkeymap({\n 'ctrl+s': () => savedocument(),\n escape: { handler: closepanel, when: (event) => event.target === panel },\n space: { handler: toggleplayback, trigger: 'keyup' },\n});\n```\n\na matching binding calls `preventdefault()` by default. set `preventdefault: false` for shortcuts that must retain browser behavior.\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 escape: { handler: closepanel, when: (event) => event.target === panel },\n 'ctrl+s': () => savedocument(),\n },\n { when: (event) => !modalisopen() && event.istrusted },\n);\n```\n\nzero argument callbacks continue to work. accept `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. **chord state is tracked independently of guards.** the chord tracker progresses through steps before any guard is checked.\n2. **global guard checked first.** if it returns `false`, all bindings are skipped and the handler does not fire — but chord state events still emit.\n3. **per binding guard checked only after global passes.** enables mixing global policy (e.g., \"skip when modal open\") with binding specific checks (e.g., \"only in this panel\").\n\nthink of it as: chord tracking (independent observation) → global gate (app level policy) and per binding gate (binding level context).\n\n```ts\nconst map = createkeymap(\n {\n 'escape': { handler: closepanel, when: (event) => event.target === panel },\n 'ctrl+s': () => 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// 'ctrl+s' handler fires immediately.\n// 'escape' handler fires only if event.target is the panel.\n// but chord state events emit regardless of guards.\n```\n\n### preserve native text editing\n\nuse `event.composedpath()` to keep browser undo and redo inside inputs, textareas, and `contenteditable` elements. kanban 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 'mod+z': () => undo(),\n 'mod+shift+z': () => 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 space: { handler: confirmaction, trigger: 'keyup' },\n});\n```\n\n`keydown` and `keyup` maintain independent chord state.\n\n## replace bindings at runtime\n\nbind replaces an existing binding with same canonical shortcut and returns a targeted removal callback.\n\n```ts\nconst map = createkeymap({ 'ctrl+k': defaultaction });\nconst removepluginbinding = map.bind('ctrl+k', pluginaction);\n\nremovepluginbinding();\nmap.bind('ctrl+k', defaultaction);\n```\n\n`unbind(shortcut)` removes canonicalized aliases and warns in development when no binding exists.\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({ g: () => scrolltotop() });\nconst conflicts = findshortcutconflicts('g g', map.listbindings());\n\nif (conflicts.length === 0) map.bind('g g', () => scrolltobottom());\n```\n\nconflict detection compares only bindings with same trigger. an empty proposal returns no conflicts; other invalid proposals throw `keymapparseerror`.\n\n## observe chord state\n\ntrack chord progression for debugging, logging, testing, or implementing chord ui hints (e.g., \"you pressed 'g', press again to scroll\").\n\n**chord state tracking is independent of guards.** events emit even if the global or per binding guard would prevent the handler from firing. this allows you to show ui hints regardless of whether the binding is allowed to execute.\n\n```ts\nimport { createkeymap } from '@vielzeug/keymap';\n\nconst map = createkeymap(\n {\n 'g g': () => window.scrollto({ top: 0 }),\n 'ctrl+k ctrl+s': () => save(),\n },\n {\n onchordstate: (change) => {\n switch (change.type) {\n case 'started':\n console.log(`chord started: ${change.step.key} (${change.trigger})`);\n showhint(`press '${change.step.key}' again...`);\n break;\n case 'progressed':\n console.log(`waiting for: ${change.steps.map((s) => s.key).join(' → ?')}`);\n updatehint(`${change.steps.map((s) => s.key).join(' → ?')}`);\n break;\n case 'timeout':\n console.log('chord timed out; resetting');\n hidehint();\n break;\n }\n },\n },\n);\n```\n\n**error handling:** callback errors are caught and logged in development mode; they don't break binding execution. use error handling in your callback to prevent typos from blocking shortcuts.\n\n**per target isolation:** each mounted target maintains independent chord state. use `change.target` when mounting the same keymap on multiple targets to distinguish progress per target.\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({ 'g g': () => 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 { escape: () => closesidebar() },\n { when: () => !modalisopen() },\n);\n\nconst modalmap = createkeymap(\n { escape: () => closemodal() },\n { when: () => modalisopen() },\n);\n\nconst unmountbase = basemap.mount(document);\nconst unmountmodal = modalmap.mount(document);\n```\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({ 'ctrl+s': 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({ 'ctrl+k': () => 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({ escape: () => 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({ escape: () => 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 'mod+z': () => void ledger.undo().catch(reporthistoryerror),\n 'mod+shift+z': () => 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 'ctrl+s': () => 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",
|
|
709
709
|
"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"
|
|
710
710
|
},
|
|
@@ -723,7 +723,7 @@
|
|
|
723
723
|
},
|
|
724
724
|
{
|
|
725
725
|
"id": "parse-and-match",
|
|
726
|
-
"text": "parse & match import { keymaperror, keymapparseerror, formatshortcut, matchstep, parseshortcut } from '@vielzeug/keymap'\n\n// parse shortcut strings into structured step objects.\nconst steps = parseshortcut('ctrl+k ctrl+s', 'ctrl')\nconsole.log('steps:', steps.length)\nconsole.log('step 0 key:', steps[0].key)\nconsole.log('step 0 modifiers:', [...steps[0].modifiers])\n\n// matchstep tests a single keyboardevent against a parsed step.\nconst event = new keyboardevent('keydown', { key: 'k', ctrlkey: true })\nconsole.log('event matches ctrl+k:', matchstep(event, steps[0])) // true\nconsole.log('event matches ctrl+s:', matchstep(event, steps[1])) // false\n\n// formatshortcut turns a shortcut string into a display label.\nconst shortcuts = [\n ['mod+shift+p', 'meta'],\n ['mod+shift+p', 'ctrl'],\n ['ctrl+k ctrl+s', 'ctrl'],\n ['escape', 'ctrl'],\n ['space', 'meta'],\n]\n\nfor (const [shortcut, modkey] of shortcuts) {\n console.log(shortcut, '→', formatshortcut(shortcut, modkey))\n}\n\n// parseshortcut() throws keymapparseerror for ambiguous or invalid steps.\n// catch it with instanceof keymaperror
|
|
726
|
+
"text": "parse & match import { keymaperror, keymapparseerror, formatshortcut, matchstep, parseshortcut } from '@vielzeug/keymap'\n\n// parse shortcut strings into structured step objects.\nconst steps = parseshortcut('ctrl+k ctrl+s', 'ctrl')\nconsole.log('steps:', steps.length)\nconsole.log('step 0 key:', steps[0].key)\nconsole.log('step 0 modifiers:', [...steps[0].modifiers])\n\n// matchstep tests a single keyboardevent against a parsed step.\nconst event = new keyboardevent('keydown', { key: 'k', ctrlkey: true })\nconsole.log('event matches ctrl+k:', matchstep(event, steps[0])) // true\nconsole.log('event matches ctrl+s:', matchstep(event, steps[1])) // false\n\n// formatshortcut turns a shortcut string into a display label.\nconst shortcuts = [\n ['mod+shift+p', 'meta'],\n ['mod+shift+p', 'ctrl'],\n ['ctrl+k ctrl+s', 'ctrl'],\n ['escape', 'ctrl'],\n ['space', 'meta'],\n]\n\nfor (const [shortcut, modkey] of shortcuts) {\n console.log(shortcut, '→', formatshortcut(shortcut, modkey))\n}\n\n// parseshortcut() throws keymapparseerror for ambiguous or invalid steps.\n// catch it with instanceof keymaperror to handle any keymap error.\ntry {\n parseshortcut('ctrl+k+j', 'ctrl') // two non modifier keys in one step — ambiguous\n} catch (err) {\n console.log('caught:', err instanceof keymaperror, err instanceof keymapparseerror, err.message)\n}"
|
|
727
727
|
},
|
|
728
728
|
{
|
|
729
729
|
"id": "shortcut-utilities",
|
|
@@ -784,7 +784,7 @@
|
|
|
784
784
|
"description": "framework neutral locale catalogs, typed translations, and explicit plural messages.",
|
|
785
785
|
"docs": {
|
|
786
786
|
"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: [createcatalogtranslator, createtranslationstore, createtranslator, hydratetranslationstore, linguaerror, linguadisposederror, linguainvalidcatalogerror, linguainvalidlocaleerror, linguainvalidpluralcounterror, linguainvalidstateerror, linguamissingcatalogerror]\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 one catalog per locale, then select static or stateful api from whether locale can change.\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| declared 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 locale store with static catalogs, then dispose it when owner ends.\n\n```ts\nimport { createtranslationstore } from '@vielzeug/lingua';\n\nconst i18n = createtranslationstore({\n catalogs: {\n de: { inbox: { plural: { one: 'eine nachricht', other: '{count} nachrichten' } } },\n en: { inbox: { plural: { one: 'one message', other: '{count} messages' } } },\n },\n locale: 'en',\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 `createcatalogtranslator()` compiles one immutable fixed locale catalog.\n `createtranslator()` compiles immutable locale keyed catalogs.\n `createtranslationstore()` manages locale changes and declared catalogs.\n `translate()` renders text and plural messages through explicit catalog nodes.\n `translatedynamic()` makes runtime key lookup explicit.\n `load()` deduplicates lazy catalog loading per locale.\n `getsnapshot()` and `subscribe()` expose immutable translator revisions.\n `serialize()` and `hydratetranslationstore()` transfer resolved ssr catalogs.\n `createformatter()` and `validatecatalog()` remain isolated subpath tools.\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",
|
|
787
|
-
"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| `createcatalogtranslator()` | compile one immutable locale catalog | sync | no fallback locales |\n| `createtranslator()` | compile immutable locale catalogs | sync | locale is fixed for translator lifetime |\n| `createtranslationstore()` | create mutable locale and catalog store | sync | load lazy locale explicitly |\n| `hydratetranslationstore()` | create store from serialized loaded catalogs | sync | serialized state never includes loaders |\n| `catalogkeys()` | enumerate message keys as dotted paths | sync | accepts store (current locale) or raw catalog; traverse subtrees for group scoped keys |\n| `createformatter()` | format intl values from `/format` | sync | import from subpath |\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 `linguaerror.is()` 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` | `createformatter()` and formatter types |\n| `@vielzeug/lingua/validate` | `validatecatalog()`, `comparecatalogs()`, and `validationissue` |\n\n## translation factories\n\n### createcatalogtranslator\n\n```ts\nfunction createcatalogtranslator<c extends catalog>(\n catalog: c,\n options?: catalogtranslatoroptions,\n): 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` | `catalogtranslatoroptions` | locale and missing message handlers; fallback is unavailable |\n\n**returns:** `translator<c>`.\n\n**example:**\n\n```ts\nimport { createcatalogtranslator } from '@vielzeug/lingua';\n\nconst translator = createcatalogtranslator(\n { save: 'enregistrer' },\n { locale: 'fr' },\n);\n\ntranslator.translate('save');\n```\n\n \n\n### createtranslator\n\n```ts\nfunction createtranslator<c extends catalog>(catalogs: catalogs<c>, options?: translatoroptions): translator<c>;\n```\n\ncompiles locale catalogs and returns immutable translator.\n\n| parameter | type | description |\n| | | |\n| `catalogs` | `catalogs<c>` | locale keyed catalog objects |\n| `options` | `translatoroptions` | locale, fallback chain, and missing message handlers |\n\n**returns:** `translator<c>`.\n\n**example:**\n\n```ts\nimport { createtranslator } from '@vielzeug/lingua';\n\nconst translator = createtranslator(\n { en: { save: 'save' }, fr: { 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| `segments` | `(textkey, { values })` or `(pluralkey, { count, ordinal?, values? })` | string and typed value segments |\n| `segmentsdynamic` | `(key, options)` | segments for runtime key |\n| `locale` | `locale` | resolved active locale |\n\n \n\n### createtranslationstore\n\n```ts\nfunction createtranslationstore<c extends catalog>(options: translationstoreoptions<c>): translationstore<c>;\n```\n\ncreates catalog store, current locale state, and immutable translator snapshots.\n\n| parameter | type | description |\n| | | |\n| `options.catalogs` | `catalogsources<c>` | static catalogs or lazy locale loaders |\n| `options.locale` | `locale` | initial locale; defaults to `en` |\n| `options.fallback` | `locale \\| readonly locale[]` | fallback locale chain |\n| `options.onmissingkey` | `(key, locale) => string` | missing message handler |\n| `options.onmissingvalue` | `(name, key, locale) => string` | missing interpolation handler |\n\n**returns:** `translationstore<c>`, with every `translator<c>` method plus lifecycle methods.\n\n**example:**\n\n```ts\nimport { createtranslationstore } from '@vielzeug/lingua';\n\nconst translations = createtranslationstore({\n catalogs: { en: { title: 'home' }, fr: { title: 'accueil' } },\n locale: 'en',\n});\n\nawait translations.setlocale('fr');\ntranslations.translate('title');\n```\n\n| method or property | signature | returns |\n| | | |\n| `translate` | translator method | rendered string |\n| `segments` | translator method | string and typed value segments |\n| `load` | `({ locale? })` | `promise<void>` after catalog resolution |\n| `setlocale` | `(locale)` | `promise<void>` after locale commit; never loads implicitly |\n| `isloaded` | `({ locale? })` | `boolean` |\n| `getsnapshot` | `()` | `translationsnapshot<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### hydratetranslationstore\n\n```ts\nfunction hydratetranslationstore<c extends catalog>(\n state: translationstate<c>,\n options?: omit<translationstoreoptions<c>, 'locale' | 'catalogs'>,\n): translationstore<c>;\n```\n\ncreates translation store from ssr state payload containing resolved raw catalogs.\n\n| parameter | type | description |\n| | | |\n| `state` | `translationstate<c>` | version `3`, active locale, and loader free catalogs |\n| `options` | `omit<translationstoreoptions<c>, 'locale' \\| 'catalogs'>` | fallback and missing message handlers |\n\n**returns:** `translationstore<c>`.\n\n**example:**\n\n```ts\nimport { createtranslationstore, hydratetranslationstore } from '@vielzeug/lingua';\n\nconst server = createtranslationstore({ catalogs: { en: { title: 'home' } }, locale: 'en' });\nconst client = hydratetranslationstore(server.serialize());\n\nclient.translate('title');\n```\n\n \n\n## catalog utilities\n\n### catalogkeys\n\n```ts\nfunction catalogkeys<c extends catalog>(source: translationstore<c> | c): readonlyarray<textkey<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 a `translationstore` to read from its current locale catalog; pass a raw catalog object to enumerate directly.\n\n| parameter | type | description |\n| | | |\n| `source` | `translationstore<c> \\| c` | store (uses current locale) or raw catalog object |\n\n**returns:** `readonlyarray<textkey<c>>` — dotted paths to every text and plural message.\n\n```ts\nimport { catalogkeys, createtranslationstore } from '@vielzeug/lingua';\n\nconst i18n = createtranslationstore({\n catalogs: { en: { nav: { home: 'home', settings: 'settings' } } },\n locale: 'en',\n});\n\nconst allkeys = catalogkeys(i18n); // ['nav.home', 'nav.settings']\nconst navkeys = catalogkeys(i18n.serialize().catalogs.en.nav); // ['home', 'settings']\n```\n\n \n\n## formatting and validation\n\n### createformatter\n\n```ts\nfunction createformatter(source: string | (() => string)): formatter;\n```\n\ncreates cached intl formatters using static locale or locale getter.\n\n| parameter | type | description |\n| | | |\n| `source` | `string \\| (() => string)` | static locale or locale getter |\n\n**returns:** `formatter`.\n\n**example:**\n\n```ts\nimport { createformatter } from '@vielzeug/lingua/format';\n\nconst formatter = createformatter('en us');\nformatter.currency(19.99, 'usd');\n```\n\n| method | signature | returns |\n| | | |\n| `number` | `(value, options?)` | `string` |\n| `currency` | `(value, currency, options?)` | `string` |\n| `date` | `(value, options?)` | `string` |\n| `relative` | `(value, unit, options?)` | `string` |\n| `list` | `(value, options?)` | `string` |\n| `duration` | `(value, options?)` | `string` |\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>;\ntype catalogtranslatoroptions = omit<translatoroptions, 'fallback'>;\ntype catalogloader<c extends catalog = catalog> = () => promise<c>;\ntype catalogsource<c extends catalog = catalog> = c | catalogloader<c>;\ntype catalogsources<c extends catalog = catalog> = record<locale, catalogsource<c>>;\n\ntype translationstoreoptions<c extends catalog = catalog> = translatoroptions & {\n catalogs: catalogsources<c>;\n};\n\ntype translationstate<c extends catalog = catalog> = {\n readonly catalogs: catalogs<c>;\n readonly locale: locale;\n readonly version: 3;\n};\n\ntype translationsnapshot<c extends catalog = catalog> = {\n readonly locale: locale;\n readonly revision: number;\n readonly translator: translator<c>;\n};\n\ntype translationstore<c extends catalog = catalog> = translator<c> & {\n readonly disposalsignal: abortsignal;\n dispose(): void;\n readonly disposed: boolean;\n getsnapshot(): translationsnapshot<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: translationsnapshot<c>) => void, options?: subscribeoptions): () => void;\n [symbol.dispose](): void;\n};\n\ntype translator<c extends catalog = catalog> = {\n readonly locale: locale;\n segments<v>(key: textkey<c>, options: translateoptions & { values: record<string, v> }): array<string | v>;\n segments<v>(key: pluralkey<c>, options: pluraloptions & { values?: record<string, v> }): array<string | number | v>;\n segmentsdynamic<v>(\n key: string,\n options: (translateoptions | pluraloptions) & { values?: record<string, v> },\n ): array<string | 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 translatoroptions = {\n fallback?: locale | readonly locale[];\n locale?: locale;\n onmissingkey?: (key: string, locale: locale) => string;\n onmissingvalue?: (name: string, key: string, locale: locale) => string;\n};\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 durationvalue = partial<record<\n 'days' | 'hours' | 'microseconds' | 'milliseconds' | 'minutes' | 'months' | 'nanoseconds' | 'seconds' | 'weeks' | 'years',\n number\n>>;\n\ntype durationformatoptions = {\n hours?: '2 digit' | 'numeric';\n microseconds?: 'numeric';\n milliseconds?: 'numeric';\n minutes?: '2 digit' | 'numeric';\n nanoseconds?: 'numeric';\n seconds?: '2 digit' | 'numeric';\n style?: 'digital' | 'long' | 'narrow' | 'short';\n};\n\ntype listformatoptions = { style?: 'long' | 'narrow' | 'short'; type?: 'and' | 'or' };\n\ntype formatter = {\n currency(value: number, currency: string, options?: omit<intl.numberformatoptions, 'currency' | 'style'>): string;\n date(value: date | number, options?: intl.datetimeformatoptions): string;\n duration(value: durationvalue, options?: durationformatoptions): string;\n list(value: array<string | number>, options?: listformatoptions): string;\n number(value: number, options?: intl.numberformatoptions): string;\n relative(value: number, unit: intl.relativetimeformatunit, options?: intl.relativetimeformatoptions): string;\n};\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` | catalog has no source for requested locale |\n",
|
|
787
|
+
"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| `createcatalogtranslator()` | compile one immutable locale catalog | sync | no fallback locales |\n| `createtranslator()` | compile immutable locale catalogs | sync | locale is fixed for translator lifetime |\n| `createtranslationstore()` | create mutable locale and catalog store | sync | load lazy locale explicitly |\n| `hydratetranslationstore()` | create store from serialized loaded catalogs | sync | serialized state never includes loaders |\n| `catalogkeys()` | enumerate message keys as dotted paths | sync | accepts store (current locale) or raw catalog; traverse subtrees for group scoped keys |\n| `createformatter()` | format intl values from `/format` | sync | import from subpath |\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` | `createformatter()` and formatter types |\n| `@vielzeug/lingua/validate` | `validatecatalog()`, `comparecatalogs()`, and `validationissue` |\n\n## translation factories\n\n### createcatalogtranslator\n\n```ts\nfunction createcatalogtranslator<c extends catalog>(\n catalog: c,\n options?: catalogtranslatoroptions,\n): 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` | `catalogtranslatoroptions` | locale and missing message handlers; fallback is unavailable |\n\n**returns:** `translator<c>`.\n\n**example:**\n\n```ts\nimport { createcatalogtranslator } from '@vielzeug/lingua';\n\nconst translator = createcatalogtranslator(\n { save: 'enregistrer' },\n { locale: 'fr' },\n);\n\ntranslator.translate('save');\n```\n\n \n\n### createtranslator\n\n```ts\nfunction createtranslator<c extends catalog>(catalogs: catalogs<c>, options?: translatoroptions): translator<c>;\n```\n\ncompiles locale catalogs and returns immutable translator.\n\n| parameter | type | description |\n| | | |\n| `catalogs` | `catalogs<c>` | locale keyed catalog objects |\n| `options` | `translatoroptions` | locale, fallback chain, and missing message handlers |\n\n**returns:** `translator<c>`.\n\n**example:**\n\n```ts\nimport { createtranslator } from '@vielzeug/lingua';\n\nconst translator = createtranslator(\n { en: { save: 'save' }, fr: { 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| `segments` | `(textkey, { values })` or `(pluralkey, { count, ordinal?, values? })` | string and typed value segments |\n| `segmentsdynamic` | `(key, options)` | segments for runtime key |\n| `locale` | `locale` | resolved active locale |\n\n \n\n### createtranslationstore\n\n```ts\nfunction createtranslationstore<c extends catalog>(options: translationstoreoptions<c>): translationstore<c>;\n```\n\ncreates catalog store, current locale state, and immutable translator snapshots.\n\n| parameter | type | description |\n| | | |\n| `options.catalogs` | `catalogsources<c>` | static catalogs or lazy locale loaders |\n| `options.locale` | `locale` | initial locale; defaults to `en` |\n| `options.fallback` | `locale \\| readonly locale[]` | fallback locale chain |\n| `options.onmissingkey` | `(key, locale) => string` | missing message handler |\n| `options.onmissingvalue` | `(name, key, locale) => string` | missing interpolation handler |\n\n**returns:** `translationstore<c>`, with every `translator<c>` method plus lifecycle methods.\n\n**example:**\n\n```ts\nimport { createtranslationstore } from '@vielzeug/lingua';\n\nconst translations = createtranslationstore({\n catalogs: { en: { title: 'home' }, fr: { title: 'accueil' } },\n locale: 'en',\n});\n\nawait translations.setlocale('fr');\ntranslations.translate('title');\n```\n\n| method or property | signature | returns |\n| | | |\n| `translate` | translator method | rendered string |\n| `segments` | translator method | string and typed value segments |\n| `load` | `({ locale? })` | `promise<void>` after catalog resolution |\n| `setlocale` | `(locale)` | `promise<void>` after locale commit; never loads implicitly |\n| `isloaded` | `({ locale? })` | `boolean` |\n| `getsnapshot` | `()` | `translationsnapshot<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### hydratetranslationstore\n\n```ts\nfunction hydratetranslationstore<c extends catalog>(\n state: translationstate<c>,\n options?: omit<translationstoreoptions<c>, 'locale' | 'catalogs'>,\n): translationstore<c>;\n```\n\ncreates translation store from ssr state payload containing resolved raw catalogs.\n\n| parameter | type | description |\n| | | |\n| `state` | `translationstate<c>` | version `3`, active locale, and loader free catalogs |\n| `options` | `omit<translationstoreoptions<c>, 'locale' \\| 'catalogs'>` | fallback and missing message handlers |\n\n**returns:** `translationstore<c>`.\n\n**example:**\n\n```ts\nimport { createtranslationstore, hydratetranslationstore } from '@vielzeug/lingua';\n\nconst server = createtranslationstore({ catalogs: { en: { title: 'home' } }, locale: 'en' });\nconst client = hydratetranslationstore(server.serialize());\n\nclient.translate('title');\n```\n\n \n\n## catalog utilities\n\n### catalogkeys\n\n```ts\nfunction catalogkeys<c extends catalog>(source: translationstore<c> | c): readonlyarray<textkey<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 a `translationstore` to read from its current locale catalog; pass a raw catalog object to enumerate directly.\n\n| parameter | type | description |\n| | | |\n| `source` | `translationstore<c> \\| c` | store (uses current locale) or raw catalog object |\n\n**returns:** `readonlyarray<textkey<c>>` — dotted paths to every text and plural message.\n\n```ts\nimport { catalogkeys, createtranslationstore } from '@vielzeug/lingua';\n\nconst i18n = createtranslationstore({\n catalogs: { en: { nav: { home: 'home', settings: 'settings' } } },\n locale: 'en',\n});\n\nconst allkeys = catalogkeys(i18n); // ['nav.home', 'nav.settings']\nconst navkeys = catalogkeys(i18n.serialize().catalogs.en.nav); // ['home', 'settings']\n```\n\n \n\n## formatting and validation\n\n### createformatter\n\n```ts\nfunction createformatter(source: string | (() => string)): formatter;\n```\n\ncreates cached intl formatters using static locale or locale getter.\n\n| parameter | type | description |\n| | | |\n| `source` | `string \\| (() => string)` | static locale or locale getter |\n\n**returns:** `formatter`.\n\n**example:**\n\n```ts\nimport { createformatter } from '@vielzeug/lingua/format';\n\nconst formatter = createformatter('en us');\nformatter.currency(19.99, 'usd');\n```\n\n| method | signature | returns |\n| | | |\n| `number` | `(value, options?)` | `string` |\n| `currency` | `(value, currency, options?)` | `string` |\n| `date` | `(value, options?)` | `string` |\n| `relative` | `(value, unit, options?)` | `string` |\n| `list` | `(value, options?)` | `string` |\n| `duration` | `(value, options?)` | `string` |\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>;\ntype catalogtranslatoroptions = omit<translatoroptions, 'fallback'>;\ntype catalogloader<c extends catalog = catalog> = () => promise<c>;\ntype catalogsource<c extends catalog = catalog> = c | catalogloader<c>;\ntype catalogsources<c extends catalog = catalog> = record<locale, catalogsource<c>>;\n\ntype translationstoreoptions<c extends catalog = catalog> = translatoroptions & {\n catalogs: catalogsources<c>;\n};\n\ntype translationstate<c extends catalog = catalog> = {\n readonly catalogs: catalogs<c>;\n readonly locale: locale;\n readonly version: 3;\n};\n\ntype translationsnapshot<c extends catalog = catalog> = {\n readonly locale: locale;\n readonly revision: number;\n readonly translator: translator<c>;\n};\n\ntype translationstore<c extends catalog = catalog> = translator<c> & {\n readonly disposalsignal: abortsignal;\n dispose(): void;\n readonly disposed: boolean;\n getsnapshot(): translationsnapshot<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: translationsnapshot<c>) => void, options?: subscribeoptions): () => void;\n [symbol.dispose](): void;\n};\n\ntype translator<c extends catalog = catalog> = {\n readonly locale: locale;\n segments<v>(key: textkey<c>, options: translateoptions & { values: record<string, v> }): array<string | v>;\n segments<v>(key: pluralkey<c>, options: pluraloptions & { values?: record<string, v> }): array<string | number | v>;\n segmentsdynamic<v>(\n key: string,\n options: (translateoptions | pluraloptions) & { values?: record<string, v> },\n ): array<string | 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 translatoroptions = {\n fallback?: locale | readonly locale[];\n locale?: locale;\n onmissingkey?: (key: string, locale: locale) => string;\n onmissingvalue?: (name: string, key: string, locale: locale) => string;\n};\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 durationvalue = partial<record<\n 'days' | 'hours' | 'microseconds' | 'milliseconds' | 'minutes' | 'months' | 'nanoseconds' | 'seconds' | 'weeks' | 'years',\n number\n>>;\n\ntype durationformatoptions = {\n hours?: '2 digit' | 'numeric';\n microseconds?: 'numeric';\n milliseconds?: 'numeric';\n minutes?: '2 digit' | 'numeric';\n nanoseconds?: 'numeric';\n seconds?: '2 digit' | 'numeric';\n style?: 'digital' | 'long' | 'narrow' | 'short';\n};\n\ntype listformatoptions = { style?: 'long' | 'narrow' | 'short'; type?: 'and' | 'or' };\n\ntype formatter = {\n currency(value: number, currency: string, options?: omit<intl.numberformatoptions, 'currency' | 'style'>): string;\n date(value: date | number, options?: intl.datetimeformatoptions): string;\n duration(value: durationvalue, options?: durationformatoptions): string;\n list(value: array<string | number>, options?: listformatoptions): string;\n number(value: number, options?: intl.numberformatoptions): string;\n relative(value: number, unit: intl.relativetimeformatunit, options?: intl.relativetimeformatoptions): string;\n};\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` | catalog has no source for requested locale |\n",
|
|
788
788
|
"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 i18n store from locale keyed catalogs. strings are text messages; plural messages use `{ plural: ... }`.\n\n```ts\nimport { createtranslationstore } from '@vielzeug/lingua';\n\nconst i18n = createtranslationstore({\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 store belongs to 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. absent replacements render as `{name}` by default. `segments()` preserves an own `undefined` or `null` value; omit property to receive `{name}`.\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 { createcatalogtranslator } 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 = createcatalogtranslator(messages);\nconst statusoptions = statusdefinitions.map(({ labelkey, value }) => ({ label: translator.translate(labelkey), value }));\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 `textkey<c>` represents at the type level.\n\npass a `translationstore` to enumerate keys from its current locale catalog without specifying a locale explicitly.\n\n```ts\nimport { catalogkeys, createtranslationstore } from '@vielzeug/lingua';\n\nconst i18n = createtranslationstore({\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 allkeys = catalogkeys(i18n);\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\nuse this for random message selection, cycling, or validation without a stale parallel array.\n\n## render framework content\n\nuse `segments()` when replacements are framework nodes, links, or other values that must not be stringified.\n\n```ts\nimport { createcatalogtranslator } from '@vielzeug/lingua';\n\nconst translator = createcatalogtranslator({ error: 'try {retry} or {support}.' });\n\nconst retry = { href: '/retry', label: 'retry' };\nconst support = { href: '/support', label: 'support' };\n\nconsole.log(translator.segments('error', { values: { retry, support } }));\n```\n\nrender returned array with framework fragment or list primitive. give ui values consumer owned keys before passing them to `segments()`; lingua preserves value identity and never clones or mutates them.\n\n## use static catalogs\n\nuse `createcatalogtranslator()` when one catalog and locale stay fixed for translator 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 { createcatalogtranslator } from '@vielzeug/lingua';\n\nconst translator = createcatalogtranslator(\n { save: 'enregistrer' },\n { locale: 'fr' },\n);\n\nconsole.log(translator.translate('save'));\n```\n\nuse `createtranslator()` when fixed translation requires locale keyed catalogs and fallback resolution.\n\n```ts\nimport { createtranslator } from '@vielzeug/lingua';\n\nconst translator = createtranslator(\n { en: { save: 'save' }, fr: { save: 'enregistrer' } },\n { locale: 'fr' },\n);\n\nconsole.log(translator.translate('save'));\n```\n\n## load catalogs and switch locales\n\ndeclare one static catalog or lazy loader per locale. switch locale, then load it explicitly when source is lazy.\n\n```ts\nimport { createtranslationstore } from '@vielzeug/lingua';\n\nconst i18n = createtranslationstore({\n catalogs: {\n en: { navigation: { settings: 'settings' } },\n fr: async () => ({ navigation: { settings: 'réglages' } }),\n },\n locale: 'en',\n});\n\nawait i18n.setlocale('fr');\nawait i18n.load();\nconsole.log(i18n.translate('navigation.settings'));\n```\n\nconcurrent loads for same locale share work. `setlocale()` never triggers hidden loads.\n\n## subscribe to immutable snapshots\n\nsubscribe when ui state must change with locale or loaded active/fallback catalog. every callback receives snapshot containing 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 server, then hydrate client store from same payload. `getsnapshot()` stays referentially stable until store revision changes, so use same hydrated store throughout initial client render.\n\n```ts\nimport { createtranslationstore, hydratetranslationstore } from '@vielzeug/lingua';\n\nconst servertranslationstore = createtranslationstore({\n catalogs: { en: { title: 'server title' } },\n locale: 'en',\n});\n\nconst state = servertranslationstore.serialize();\nconst clienttranslationstore = hydratetranslationstore(state, { fallback: 'en' });\n\nconsole.log(clienttranslationstore.translate('title'));\nservertranslationstore.dispose();\nclienttranslationstore.dispose();\n```\n\nstate contains raw loaded catalogs. it never contains loader functions.\n\n## formatting and validation\n\nimport formatting and catalog validation from dedicated subpaths to keep translation state focused.\n\n```ts\nimport { createformatter } from '@vielzeug/lingua/format';\nimport { comparecatalogs, validatecatalog } from '@vielzeug/lingua/validate';\n\nconst formatter = createformatter('en us');\nconst catalog = { inbox: { plural: { one: 'one message', other: '{count} messages' } } };\n\nconsole.log(formatter.currency(19.99, 'usd'));\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 store from same serialized state used by server before calling `usesyncexternalstore`.\n\n::: code group\n\n```ts [react]\nimport { usesyncexternalstore } from 'react';\n\nimport type { translationstore } from '@vielzeug/lingua';\n\nexport function usetranslator(i18n: translationstore) {\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 { translationstore } from '@vielzeug/lingua';\n\nexport function usetranslator(i18n: translationstore) {\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 { translationstore } from '@vielzeug/lingua';\n\nexport function translatorstore(i18n: translationstore) {\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 subscriptions into ripple through flux when templates need reactive locale reads.\n\n```ts\nimport { stream } from '@vielzeug/flux';\nimport { tosignal } from '@vielzeug/flux/ripple';\nimport { computed } from '@vielzeug/ripple';\n\nconst localebinding = tosignal(\n stream<string>((observer) => {\n observer.next(i18n.locale);\n return i18n.subscribe(({ locale }) => observer.next(locale));\n }),\n { initial: i18n.locale },\n);\n\nexport const locale = computed(() => localebinding.value);\n```\n\nuse courier loaders when locale catalogs come from http rather than bundled modules; pass each loader to `catalogs`.\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 load a lazy catalog before rendering it.\n give ui values keys before passing them to `segments()`.\n keep loader functions out of ssr payloads.\n dispose temporary stores after requests, tests, and route lifetimes.\n",
|
|
789
789
|
"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"
|
|
790
790
|
},
|
|
@@ -818,7 +818,7 @@
|
|
|
818
818
|
"description": "lifecycle owned web animations api primitives for native playback, groups, and additive flip transitions.",
|
|
819
819
|
"docs": {
|
|
820
820
|
"index": " \ntitle: necromancer — lifecycle owned dom animations\ndescription: lifecycle owned web animations api primitives for native playback, groups, and additive flip transitions.\npackage: necromancer\ncategory: ui\nkeywords: [animation, web animations api, waapi, flip, stagger, reduced motion]\nrelated: [orbit, ore]\nexports: [animate, animateeach, capturelayout]\nenvironments: [browser]\n \n\n<! markdownlint disable md025 md033 md060 >\n\n<packagehero package=\"necromancer\" />\n\n## why necromancer?\n\nnative web animations api calls do not provide lifecycle ownership, reduced motion policy, grouped playback, or layout transitions. necromancer retains native keyframes and timing options while making ownership explicit for a component or dom feature. its default `180ms` duration makes the smallest call visible without hiding native timing control.\n\n```ts\n// before\nconst animation = element.animate(keyframes, { duration: 180 });\nanimation.addeventlistener('cancel', removelisteners);\n\n// after\nconst animation = animate(element, keyframes, { duration: 180 });\nanimation.dispose();\n```\n\n| feature | native waapi | necromancer | motion one |\n| | | | |\n| bundle size | 0 b | <packageinfo package=\"necromancer\" type=\"size\" /> | ~18 kb |\n| root dependencies | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> |\n| lifecycle handle | manual | `dispose()` | library specific controls |\n| reduced motion | manual | `motion: 'system'` default | configuration required |\n| layout transitions | manual flip math | `capturelayout().animate()` | separate api |\n\n<div class=\"decision callout\">\n\n**use necromancer when** you need native browser animations with explicit cancellation, reduced motion behavior, staggered groups, or positional flip transitions.\n\n**consider css transitions when** a static style change needs no playback control, cleanup, or layout measurement.\n\n</div>\n\n## installation\n\n::: code group\n\n```sh [pnpm]\npnpm add @vielzeug/necromancer\n```\n\n```sh [npm]\nnpm install @vielzeug/necromancer\n```\n\n```sh [yarn]\nyarn add @vielzeug/necromancer\n```\n\n:::\n\n## quick start\n\nstart the animation after its dom element mounts and release it when its ui owner is removed.\n\n```ts\nimport { animate } from '@vielzeug/necromancer';\n\nconst notice = document.createelement('p');\nnotice.textcontent = 'saved';\ndocument.body.append(notice);\n\nconst animation = animate(\n notice,\n [{ opacity: 0, transform: 'translatey(8px)' }, { opacity: 1, transform: 'translatey(0)' }],\n { duration: 180, easing: 'ease out' },\n);\n\nawait animation.result;\nanimation.dispose();\n```\n\n## features\n\n<div class=\"features grid\">\n\n `animate()` — native element animation with lifecycle ownership and direct native access\n `animateeach()` — group ownership with stable keyframe factories and `stagger`\n `capturelayout()` — one shot flip transition with additive `translate` (position) and `scale` (size)\n `motion` — `'system'` reduced motion support with explicit reduced outcomes\n `interrupt: 'cancel'` — replace active necromancer owned animation on an element\n `signal` — abort a handle from its parent lifecycle\n `dispose()` — idempotent cleanup with `[symbol.dispose]()`\n\n</div>\n\n## deliberate scope\n\nnecromancer owns explicit waapi keyframes. it does not generate css keyframes, observe css transitions, watch mutations, simulate springs, interpolate svg paths, or run a javascript tween loop. use css for declarative style changes and choose a dedicated tool when those capabilities are required.\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\n</div>\n\n## see also\n\n<div class=\"see also\">\n\n [orbit](/orbit/) — position floating ui before animating its appearance.\n [ore](/ore/) — own necromancer handles in a custom element's mount and disposal lifecycle.\n</div>\n\n<! markdownlint enable md025 md033 md060 >\n",
|
|
821
|
-
"api": " \ntitle: necromancer — api reference\ndescription: api reference for @vielzeug/necromancer animation ownership, groups, reduced motion, and flip transitions.\n \n\n[[toc]]\n\n## api overview\n\n| symbol | purpose | execution mode | common gotcha |\n| | | | |\n| `animate()` | animate one element | sync | defaults to a visible `180ms` duration |\n| `animateeach()` | animate a unique element group | sync | non zero `stagger` needs numeric `delay` |\n| `capturelayout()` | capture positions and create a one shot flip transition | sync | capture before changing layout |\n| `necromancererror` | base package error | sync | use `necromancererror
|
|
821
|
+
"api": " \ntitle: necromancer — api reference\ndescription: api reference for @vielzeug/necromancer animation ownership, groups, reduced motion, and flip transitions.\n \n\n[[toc]]\n\n## api overview\n\n| symbol | purpose | execution mode | common gotcha |\n| | | | |\n| `animate()` | animate one element | sync | defaults to a visible `180ms` duration |\n| `animateeach()` | animate a unique element group | sync | non zero `stagger` needs numeric `delay` |\n| `capturelayout()` | capture positions and create a one shot flip transition | sync | capture before changing layout |\n| `necromancererror` | base package error | sync | use `instanceof necromancererror` to narrow unknown errors |\n\n## package entry point\n\n| import | purpose |\n| | |\n| `@vielzeug/necromancer` | animation functions, types, and errors |\n| `@vielzeug/necromancer/testing` | jsdom test fakes for `element.animate()` and `getboundingclientrect()` |\n\n## animation functions\n\n### `animate()`\n\n```ts\nfunction animate(element: element, keyframes: keyframes, options?: animateoptions): animationhandle;\n```\n\nstarts a lifecycle owned native web animation. omitted `duration` defaults to `180` milliseconds; explicit native timing values, including `0`, are preserved. playback remains native:\n\n```ts\nconst handle = animate(element, [{ opacity: 0 }, { opacity: 1 }], { duration: 180 });\nhandle.animation.pause();\nconst result = await handle.result;\nhandle.dispose();\n```\n\n### `animateeach()`\n\n```ts\nfunction animateeach(\n elements: iterable<element>,\n keyframes: keyframes | keyframefactory,\n options?: animateeachoptions,\n): animationgroup;\n```\n\nstarts animations for unique elements in first seen order. necromancer resolves every keyframe factory before starting the first native animation. use each child handle's `animation` property for native playback control.\n\n## layout functions\n\n### `capturelayout()`\n\n```ts\nfunction capturelayout(elements: iterable<element>, options?: layoutcaptureoptions): layouttransition;\n```\n\ncaptures unique elements' positions and sizes and returns a one shot transition. rotation and other transforms are not captured or compensated. after changing layout, call `transition.animate(options)` to measure current positions and sizes and animate changed, connected elements with additive css `translate` (position) and `scale` (size). pass `getkey` when a framework replaces the captured elements during its render.\n\n```ts\nconst transition = capturelayout(beforeitems, {\n getkey: (element) => element.getattribute('data id')!,\n});\n\nrenderreordereditems();\n\nconst group = transition.animate({\n duration: 220,\n easing: 'ease out',\n elements: afteritems,\n});\n```\n\ncalling `animate()` twice on the same transition throws `necromancerconfigerror`.\n\n## types\n\n### `motionmode`\n\n```ts\ntype motionmode = 'full' | 'reduced' | 'system';\n```\n\n`'system'` is the default. reduced motion preserves the supplied keyframes while normalizing delay, duration, and end delay to `0`, and iterations to `1`.\n\n### `animationresult`\n\n```ts\ntype animationresult =\n | { readonly status: 'finished' }\n | { readonly status: 'reduced' }\n | { readonly reason?: unknown; readonly status: 'cancelled' };\n```\n\n`cancelled` describes native cancellation and includes its native rejection reason. a reason passed to `dispose()` or an abort signal takes precedence. the independent `disposed` property becomes `true` only when the lifecycle owner is explicitly disposed.\n\n### `animateoptions`\n\n```ts\ntype animateoptions = keyframeanimationoptions & {\n readonly interrupt?: 'cancel';\n readonly motion?: motionmode;\n readonly signal?: abortsignal;\n};\n```\n\nset `interrupt: 'cancel'` for rapid state changes that should replace every still active necromancer owned animation on the same element. it does not cancel animations created directly with `element.animate()`.\n\n### `animateeachoptions`\n\n```ts\ntype animateeachoptions = animateoptions & {\n readonly stagger?: number;\n};\n```\n\n`stagger` is a finite, non negative millisecond offset.\n\n### `layoutcaptureoptions`\n\n```ts\ninterface layoutcaptureoptions {\n readonly getkey?: (element: element) => string;\n}\n```\n\n`getkey` maps a captured element and its committed replacement to the same stable, non empty string. duplicate or empty keys throw `necromancerconfigerror`.\n\n### `layoutanimationoptions`\n\n```ts\ntype layoutanimationoptions = animateeachoptions & {\n readonly elements?: iterable<element>;\n};\n```\n\n`elements` is the collection in its committed layout. omit it to animate the same captured elements. with `getkey`, replacement elements animate from the positions of their captured predecessors. unmatched, removed, and newly entered elements are ignored.\n\n### `keyframes` and `keyframefactory`\n\n```ts\ntype keyframes = readonly keyframe[] | propertyindexedkeyframes;\ntype keyframefactory = (element: element, index: number, total: number) => keyframes;\n```\n\naccepts a `readonly` array so a reusable `as const` keyframe list can be passed without a cast.\n\n### `animationhandle`\n\n```ts\ninterface animationhandle {\n readonly animation: animation;\n readonly result: promise<animationresult>;\n readonly disposed: boolean;\n dispose(reason?: unknown): void;\n [symbol.dispose](): void;\n}\n```\n\n### `animationgroup`\n\n```ts\ninterface animationgroup {\n readonly handles: readonly animationhandle[];\n readonly results: promise<readonly animationresult[]>;\n readonly disposed: boolean;\n dispose(reason?: unknown): void;\n [symbol.dispose](): void;\n}\n```\n\n`results` preserves the terminal result of every child in handle order. use `handles` for native playback control.\n\n### `layouttransition`\n\n```ts\ninterface layouttransition {\n animate(options?: layoutanimationoptions): animationgroup;\n}\n```\n\n## errors\n\n| error | trigger |\n| | |\n| `necromancererror` | base class for package errors |\n| `necromancerconfigerror` | invalid stagger, incompatible delay, or reused layout transition |\n| `necromancerunsupportederror` | `element.animate()` is unavailable |\n\n## testing (`@vielzeug/necromancer/testing`)\n\njsdom (and most non browser dom environments) do not implement `element.animate()`. import these from the `/testing` sub path, not the root entry point.\n\n### `animationcall`\n\n```ts\ntype animationcall = {\n readonly animation: fakeanimation;\n readonly keyframes: keyframe[] | propertyindexedkeyframes;\n readonly options?: keyframeanimationoptions;\n};\n```\n\none recorded invocation of `element.prototype.animate` from `installfakeanimations()`.\n\n### `installfakeanimations()`\n\n```ts\nfunction installfakeanimations(): { calls: animationcall[]; restore: () => void };\n```\n\nreplaces `element.prototype.animate` with a deterministic fake for the duration of a test. `calls` records every invocation in order; call `restore()` (for example in `aftereach`) to put the original implementation back.\n\n```ts\nimport { installfakeanimations } from '@vielzeug/necromancer/testing';\n\nconst { calls, restore } = installfakeanimations();\nconst handle = animate(element, [{ opacity: 0 }, { opacity: 1 }]);\n\ncalls[0]?.animation.finish();\nawait handle.result; // { status: 'finished' }\nrestore();\n```\n\n### `fakeanimation`\n\n```ts\nclass fakeanimation {\n cancelcallcount: number;\n finishcallcount: number;\n finished: promise<void>;\n cancel(): void;\n finish(): void;\n}\n```\n\na minimal `animation` stand in. `cancel()` rejects `finished` with an `aborterror`; `finish()` resolves it. `cancelcallcount`/`finishcallcount` track how many times each was called, in place of a test runner specific spy.\n\n### `createrect()`\n\n```ts\nfunction createrect(x: number, y: number, width?: number, height?: number): domrect;\n```\n\nbuilds a `domrect` for mocking `element.getboundingclientrect()` in `capturelayout()` tests. `width`/`height` default to `20`.\n",
|
|
822
822
|
"usage": " \ntitle: necromancer — usage guide\ndescription: animate dom elements, coordinate groups, and create flip transitions with @vielzeug/necromancer.\n \n\n[[toc]]\n\n## basic usage\n\ncreate an animation after its element mounts, control playback through the native `animation`, and dispose its owner with the ui lifecycle.\n\n```ts\nimport { animate } from '@vielzeug/necromancer';\n\nconst handle = animate(\n element,\n [{ opacity: 0, transform: 'translatey(8px)' }, { opacity: 1, transform: 'translatey(0)' }],\n { duration: 180, easing: 'ease out', fill: 'both' },\n);\n\nhandle.animation.reverse();\nconst result = await handle.result;\nhandle.dispose();\n```\n\n`result` distinguishes natural completion, reduced timing, and cancellation. `disposed` reports only whether the owner was explicitly disposed.\n\nwhen `duration` is omitted, necromancer uses `180ms`; pass `duration: 0` when the caller intentionally wants an instant native animation.\n\n## replacing an active animation\n\nanimations normally run concurrently, including multiple necromancer animations on the same element. for state updates where only the newest animation should remain, set `interrupt: 'cancel'`.\n\n```ts\nconst first = animate(element, [{ opacity: 0 }, { opacity: 1 }]);\nconst latest = animate(element, [{ opacity: 1 }, { opacity: 0 }], {\n interrupt: 'cancel',\n});\n\nawait first.result; // { status: 'cancelled', ... }\n```\n\ninterruption disposes only still active animations created by necromancer for that element. it never cancels an animation that your code started directly with `element.animate()`.\n\n## motion preferences\n\nuse `motion` to select how the animation responds to the operating system preference.\n\n```ts\nconst handle = animate(element, [{ opacity: 0 }, { opacity: 1 }], {\n duration: 200,\n motion: 'system',\n});\n\nconst result = await handle.result;\n```\n\n`'system'` is the default and reduces movement when `prefers reduced motion: reduce` matches. `'full'` preserves the requested timing, while `'reduced'` always reduces it.\n\nreduced motion keeps the supplied keyframes but normalizes delay, duration, and end delay to zero and iterations to one. the result is `{ status: 'reduced' }`, and `handle.animation` still represents the requested visual transition.\n\n## parent cancellation\n\npass a parent `abortsignal` to release an animation when its owning work is cancelled.\n\n```ts\nconst controller = new abortcontroller();\nconst handle = animate(element, [{ scale: 0.96 }, { scale: 1 }], {\n duration: 160,\n signal: controller.signal,\n});\n\ncontroller.abort('route changed');\nconst result = await handle.result;\n// { status: 'cancelled', reason: 'route changed' }\n```\n\nan already aborted signal throws its reason before an animation starts.\n\n## staggering a group\n\npass an iterable of elements to `animateeach()`. duplicate elements are animated once in first seen order.\n\n```ts\nimport { animateeach } from '@vielzeug/necromancer';\n\nconst group = animateeach(\n document.queryselectorall('.card'),\n (_card, index) => [\n { opacity: 0, transform: `translatey(${12 + index * 2}px)` },\n { opacity: 1, transform: 'translatey(0)' },\n ],\n { duration: 220, easing: 'ease out', stagger: 45 },\n);\n\nconst results = await group.results;\ngroup.dispose();\n```\n\na group owns child lifecycles only. `results` preserves every child result in handle order; use `group.handles` when native playback control is required.\n\n## serial application flow\n\njavascript control flow is the clearest way to express serial, conditional, or branching animations:\n\n```ts\nfor (const step of steps) {\n const handle = animate(step.element, step.keyframes, {\n ...step.options,\n signal: controller.signal,\n });\n const result = await handle.result;\n\n if (result.status === 'cancelled') break;\n}\n```\n\none parent `abortsignal` cancels the active step without introducing a separate timeline abstraction.\n\n## animating a reorder with flip\n\ncapture positions before changing layout, then animate through the returned one shot transition.\n\n```ts\nimport { capturelayout } from '@vielzeug/necromancer';\n\nconst transition = capturelayout(items);\nlist.prepend(items[2]!);\n\nconst group = transition.animate({ duration: 220, easing: 'ease out' });\nawait group.results;\ngroup.dispose();\n```\n\nthe transition only animates changed, connected elements and can be animated once. it additively composes the individual css `translate` and `scale` properties, preserving authored `transform`, `translate`, and `scale`. a resized element (for example a list item whose content changed) animates from its captured size as well as its captured position.\n\n### replacing rendered elements\n\nwhen a framework replaces list nodes rather than reorders the captured elements, give `capturelayout()` a stable key and pass the committed nodes to `animate()`. capture before updating state, then call `animate()` only after the renderer has committed the new dom.\n\n```ts\nconst transition = capturelayout(beforeitems, {\n getkey: (element) => element.getattribute('data id')!,\n});\n\nrenderreordereditems();\n\ntransition.animate({\n duration: 220,\n easing: 'ease out',\n elements: afteritems,\n});\n```\n\nkeys must be unique, non empty strings in both collections. items with no matching predecessor are not enter animations; animate those explicitly with `animate()` or `animateeach()`.\n\nfor sortable lists, dnd exposes its pre commit layout seam through `onbeforereorder`; see the [dnd optimistic reorder recipe](/dnd/examples/optimistic reorder with revert.md).\n\n## scope\n\nnecromancer creates and owns explicit web animations api work. it does not observe css authored transitions or animations, inject `@keyframes`, watch dom mutations, generate springs, interpolate svg geometry, or provide a javascript tween fallback. keep css as the owner of declarative component styling and use a dedicated charting or tweening tool when the animation needs capabilities beyond waapi keyframes.\n\n## framework integration\n\ncreate handles in a client mount lifecycle and dispose them during unmount. the same composition works with reactive effect systems: start the animation in the effect and return `handle.dispose()` as its cleanup.\n\n```tsx\nimport { useeffect, useref } from 'react';\nimport { animate } from '@vielzeug/necromancer';\n\nexport function notice() {\n const elementref = useref<htmldivelement>(null);\n\n useeffect(() => {\n const element = elementref.current;\n if (!element) return;\n\n const handle = animate(element, [{ opacity: 0 }, { opacity: 1 }], { duration: 180 });\n return () => handle.dispose();\n }, []);\n\n return <div ref={elementref}>saved</div>;\n}\n```\n\n## testing\n\njsdom does not implement `element.animate()`, so code under test needs a fake. `@vielzeug/necromancer/testing` has no test runner import — it works the same under vitest, jest, or any other runner.\n\n```ts\nimport { installfakeanimations } from '@vielzeug/necromancer/testing';\nimport { animate } from '@vielzeug/necromancer';\n\nconst { calls, restore } = installfakeanimations();\nconst handle = animate(element, [{ opacity: 0 }, { opacity: 1 }]);\n\ncalls[0]?.animation.finish();\nawait handle.result; // { status: 'finished' }\nrestore();\n```\n\ncall `restore()` after each test (for example in `aftereach`) to put back whatever `element.prototype.animate` was before. use `createrect()` to mock `element.getboundingclientrect()` when testing code that calls `capturelayout()`.\n\n## best practices\n\n start animations only after their elements mount in the browser.\n dispose each handle or group with its ui owner.\n use native `animation` objects for playback control.\n respect the default `'system'` motion setting unless movement is essential.\n use a parent `abortsignal` for cancellable application flow.\n keep `delay` numeric when combining it with non zero `stagger`.\n capture layout before mutation and animate each transition exactly once.\n",
|
|
823
823
|
"examples": " \ntitle: necromancer — examples\ndescription: practical animation and flip layout recipes for @vielzeug/necromancer.\n \n\n## examples\n\n [animate on mount](./examples/animate on mount.md)\n [stagger a list](./examples/stagger a list.md)\n [animate a reorder](./examples/animate a reorder.md)\n\n"
|
|
824
824
|
},
|
|
@@ -894,7 +894,7 @@
|
|
|
894
894
|
"description": "functional custom element authoring with typed props, reactive templates, lifecycle helpers, and testing utilities.",
|
|
895
895
|
"docs": {
|
|
896
896
|
"index": " \ntitle: ore — web component authoring with signals\ndescription: functional custom element authoring with typed props, reactive templates, lifecycle helpers, and testing utilities.\npackage: ore\ncategory: ui primitives\nkeywords: [web components, custom elements, reactive, templates, signals, lifecycle]\nrelated: [ripple, refine, orbit]\nexports: [define, prop, html, css, ref, createcontext, inject, injectstrict, provide, onmounted, oncleanup, onevent, onelement, onformreset, watcheffect, useemit, useslots, gethost, bind, each, when, classmap, stylemap, live, unsafehtml, usefield, createid, createstableid, resetstableidcounter, oreerror, oreapierror, oreinternalerror, orelifecycleerror, bindoptions]\nenvironments: [browser]\n \n\n<! markdownlint disable md025 md033 md060 >\n\n<packagehero package=\"ore\" />\n\n## why ore?\n\nore keeps custom elements functional and signal driven while giving you direct control over templates, lifecycle hooks, host bindings, and form associated behavior.\n\n```ts\n// before — vanilla custom element boilerplate\nclass mycounter extends htmlelement {\n #count = 0;\n connectedcallback() {\n this.attachshadow({ mode: 'open' });\n this.#render();\n }\n #render() {\n this.shadowroot!.innerhtml = `<button>${this.#count}</button>`;\n this.shadowroot!.queryselector('button')!.onclick = () => {\n this.#count++;\n this.#render();\n };\n }\n}\ncustomelements.define('my counter', mycounter);\n\n// after — ore\nimport { signal } from '@vielzeug/ripple';\nimport { define, html } from '@vielzeug/ore';\n\ndefine('my counter', {\n setup() {\n const count = signal(0);\n return html`<button @click=${() => count.value++}>${count}</button>`;\n },\n});\n```\n\n| feature | ore | lit | stencil |\n| | | | |\n| bundle size | <packageinfo package=\"ore\" type=\"size\" /> | ~12 kb | ~60 kb+ toolchain |\n| signal first runtime | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> (separate signals package) | <ore icon name=\"x\" size=\"16\"></ore icon> |\n| functional component setup | <ore icon name=\"check\" size=\"16\"></ore icon> | partial | <ore icon name=\"x\" size=\"16\"></ore icon> |\n| typed prop helpers | <ore icon name=\"check\" size=\"16\"></ore icon> | partial | <ore icon name=\"check\" size=\"16\"></ore icon> |\n| host binding helpers | <ore icon name=\"check\" size=\"16\"></ore icon> | partial | partial |\n| form associated helpers | <ore icon name=\"check\" size=\"16\"></ore icon> | manual | partial |\n| zero dependencies | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> |\n\n<div class=\"decision callout\">\n\n**use ore when** you want typed, signal driven custom elements with minimal runtime overhead and no framework lock in.\n\n**consider lit when** you need a mature ecosystem with wide community adoption and don't need signal based reactivity.\n\n</div>\n\n## installation\n\n::: code group\n\n```sh [pnpm]\npnpm add @vielzeug/ore @vielzeug/ripple\n```\n\n```sh [npm]\nnpm install @vielzeug/ore @vielzeug/ripple\n```\n\n```sh [yarn]\nyarn add @vielzeug/ore @vielzeug/ripple\n```\n\n:::\n\n## quick start\n\n```ts\nimport { computed, signal } from '@vielzeug/ripple';\nimport { bind, css, define, html, onmounted, prop } from '@vielzeug/ore';\n\ndefine('my counter', {\n props: {\n label: prop.string('count'),\n step: prop.number(1),\n },\n styles: [\n css`\n :host {\n display: inline grid;\n gap: 0.5rem;\n }\n `,\n ],\n setup(props) {\n const count = signal(0);\n const doubled = computed(() => count.value * 2);\n\n bind({ class: { 'is positive': () => count.value > 0 } });\n\n onmounted(() => console.log('mounted'));\n\n return html`\n <button @click=${() => (count.value += props.step.value)}>${props.label}: ${count}</button>\n <p>doubled: ${doubled}</p>\n `;\n },\n});\n```\n\n## features\n\n<div class=\"features grid\">\n\n signal first runtime with `signal`, `computed`, `watch`, `batch` from `@vielzeug/ripple` — import them directly\n functional component authoring via `define(tag, { props, setup, styles, formassociated })`\n props via `prop.*` helpers (`prop.string`, `prop.number`, `prop.bool`, `prop.oneof`, `prop.json`, `prop.data`) or raw `propdef` objects\n `setup(props)` takes only props and returns an `htmlresult` directly: `return html\\`...\\``\n lifecycle hooks — `onmounted`, `oncleanup`, `onevent`, `onelement`, `watcheffect` — plain functions imported from `@vielzeug/ore`, called directly from `setup()` or any composable it calls\n directives: `each` (keyed reactive list rendering), `classmap`, `stylemap`, `when`, `live`, `unsafehtml`\n host bindings via `bind({ attr, class, style, on })` — pass `{ target: el }` to bind any off host element\n reactive aria sync via `bind({ aria }, { target })` — applies `aria *` attributes reactively to any element, auto cleanup on disconnect\n context via `provide(key, value)` / `inject(key)`; typed emit/slots via `useemit<emits>()` / `useslots<slotnames>()`\n form associated `usefield()` and observer helpers are root exports\n testing utilities (`@vielzeug/ore/testing`) — `mount`, `renderhook`, `flush`, `cleanup`\n generic testing utilities (scoped queries, named event dispatchers, and async waits) are exported by `@vielzeug/assay`\n debug utilities (`@vielzeug/ore/testing`) — `debugflush()` for diagnosing update timing\n\n</div>\n\n## package entry points\n\n| import | purpose |\n| | |\n| `@vielzeug/ore` | all browser runtime apis: components, directives, `usefield`, and observers |\n| `@vielzeug/ore/testing` | ore specific mounting, lifecycle flushing, hooks, cleanup, and form internals |\n| `@vielzeug/assay` | generic dom events, scoped queries, and async waiting |\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 [refine](../refine/index.md) for prebuilt accessible components powered by ore.\n [ripple](../ripple/index.md) for reactive state used inside ore components.\n [forge](../forge/index.md) for typed form state that integrates with ore.\n\n</div>\n\n<! markdownlint enable md025 md033 md060 >\n",
|
|
897
|
-
"api": " \ntitle: ore — api reference\ndescription: complete api reference for @vielzeug/ore and @vielzeug/ore/testing.\n \n\n[[toc]]\n\n## api overview\n\nall browser runtime symbols below are imported from `@vielzeug/ore`. lifecycle/context/binding functions (`onmounted`, `oncleanup`, `onevent`, `onelement`, `watcheffect`, `bind`, `provide`, `useemit`, `useslots`, `gethost`) resolve the active component through an implicit \"current component\" context — they work when called synchronously during `setup()`, or from any composable function `setup()` calls (transitively), but throw if called outside that window.\n\n> `watcheffect` is not named `watch` — `@vielzeug/ripple` already exports a `watch(source, callback)` with different semantics (explicit source + old/new value pair), and the two are frequently imported in the same file.\n\n| symbol | purpose | execution mode | common gotcha |\n| | | | |\n| `define()` | register a custom element with reactive setup | sync | tag must contain a hyphen; call before first use |\n| `html` | tagged template literal returning htmlresult | sync | expressions must be signals, functions, or primitives |\n| `prop.*` | typed prop helpers (string, bool, number, …) | sync | prop values are signals — read `.value` |\n| `provide()`/`inject()` | context api for parent to descendant sharing | setup only | must be called synchronously during `setup()` |\n| `ref()` | reactive reference to a dom element | sync | value is null until after first mount |\n| `createcontext()` | create a typed injection key | sync | context is scoped to the component tree |\n| `each()` | keyed list rendering with dom diffing | sync | duplicate keys report `ore:error`; plain `t[]` is a one time static render |\n| `when()` | conditional branch rendering | sync | getter fn computed disposed on cleanup; static bool skips subscription |\n| `live(signal)` | one way binding that skips stale writes during input | sync | use for controlled inputs alongside a manual `@input` handler |\n| `onmounted(fn)` | dom ready callback | setup only | must be called synchronously during `setup()` |\n| `oncleanup(fn)` | register teardown | setup only | called on component disconnect |\n| `onevent(target, …)` | scoped event listener with auto cleanup | setup only | no ops on null target; removed on disconnect |\n| `usefield(options)` | wire signal to form `elementinternals` | setup only | requires `formassociated: true` on the component definition |\n| `onformreset(fn)` | run work when the ancestor `<form>` resets | setup only | fires every reset (not one shot); only for `formassociated: true` components |\n| `useemit<emits>()` | typed `emit()` bound to the current host | setup only | call once per component; returns `dispatchevent`'s boolean (`false` if a listener called `preventdefault()`) |\n| `useslots<slotnames>()`| reactive slot presence/element signals | setup only | safe to call more than once — the underlying registry is created once |\n| `gethost()` | the current component's host element | setup only | prefer a higher level helper (`bind`, …) when one exists |\n\n## package entry points\n\n| import | purpose |\n| | |\n| `@vielzeug/ore` | all browser runtime apis, including directives, fields, and lifecycle helpers |\n| `@vielzeug/ore/testing` | ore specific mounting, lifecycle, hook, cleanup, and form test support |\n| `@vielzeug/assay` | generic dom events, scoped queries, and async waiting |\n\n## core component api\n\n### `define(tag, definition)`\n\n```ts\ndefine<props>(tag: string, definition: componentdefinition<props>): void;\n```\n\nthe `setup()` function receives only typed prop signals:\n\n```ts\nsetup(props) {\n return html`<div>${props.label}</div>`;\n}\n```\n\neverything else — lifecycle hooks, host bindings, context, slots, emit — is a plain function imported from `@vielzeug/ore`, called directly from `setup()` (or a composable it calls):\n\n```ts\nimport { define, html, onmounted, useemit, useslots } from '@vielzeug/ore';\n\ndefine('my card', {\n setup(_props) {\n const emit = useemit<{ close: undefined }>();\n const slots = useslots<'header' | 'footer'>();\n\n onmounted(() => console.log('mounted'));\n\n // emit() returns dispatchevent's boolean — false if a listener called preventdefault()\n const notcancelled = emit('close');\n\n return html`${when(slots.has('header'), () => html`<slot name=\"header\"></slot>`)}`;\n },\n});\n```\n\n`useemit<emits>()` and `useslots<slotnames>()` are factory hooks — call them once per setup run to get a typed\n`emit`/`slots` bound to the current host. `useslots()` is safe to call more than once within that setup run.\n\n### componentdefinition\n\n```ts\ntype componentdefinition<props> = {\n formassociated?: boolean;\n props?: propsdef<props>;\n setup: (props: inferprops<propsdef<props>>) => htmlresult | null;\n shadow?: partial<shadowrootinit> | false; // false = light dom (no shadow root)\n styles?: (string | cssstylesheet | cssresult)[];\n};\n```\n\n## runtime helpers\n\n`onmounted`, `oncleanup`, `onevent`, `onelement`, and `watcheffect` are plain functions imported from `@vielzeug/ore`. call them directly during `setup()`.\n\n```ts\nimport { html, oncleanup, onevent, onmounted } from '@vielzeug/ore';\n\nsetup(props) {\n onmounted(() => {\n // dom is ready; return a function for mount scoped cleanup\n return () => { /* cleanup on unmount */ };\n });\n\n oncleanup(() => { /* called on disconnect */ });\n\n onevent(window, 'keydown', (e) => { /* auto removed on disconnect */ });\n\n return html`...`;\n}\n```\n\nbecause these resolve the active component through an implicit context (rather than a value threaded through parameters), composable helper functions can call them directly too — no need to pass hooks in as options:\n\n```ts\nimport { oncleanup } from '@vielzeug/ore';\n\nfunction usemyhelper() {\n oncleanup(() => { /* teardown */ });\n}\n\n// in setup:\nsetup(_props) {\n usemyhelper();\n return html`...`;\n}\n```\n\n## props api\n\n| helper | signature | notes |\n| | | |\n| `prop.string(defaultvalue?)` | `propdef<string>` | reflects by default |\n| `prop.bool(defaultvalue?)` | `propdef<boolean>` | any non null attribute value other than `\"false\"` parses as `true`; `\"false\"` or absent attribute is `false` |\n| `prop.number(defaultvalue?)` | `propdef<number>` | returns default (not nan) and warns in dev when attribute is not a valid number |\n| `prop.oneof(allowed, defaultvalue)` | `propdef<t>` | restricts to provided string union |\n| `prop.json(defaultvalue)` | `propdef<t>` | json.parse from attribute; `reflect: false` |\n| `prop.data<t>(defaultvalue?)` | `propdef<t>` | js only — never reads/writes an attribute; use for objects, arrays, callbacks, or any non serialisable value |\n\n> **choosing the right prop helper:**\n>\n> **`prop.json`** — value can be declared in html (`<my el config='{\"x\":1}'>`); attribute string is `json.parse`d.\n> **`prop.data`** — value is always set from javascript (objects, arrays, callbacks, class instances); the attribute is never read. use this for both data and function props.\n\nwhen you need custom parsing or `reflect: false`, use a raw `propdef` object:\n\n```ts\nprops: {\n items: { default: [], parse: () => [], reflect: false },\n}\n```\n\nuse `prop.data` for props that hold js only values (including callbacks) that cannot be serialised through an html attribute:\n\n```ts\ndefine('data grid', {\n props: {\n getrowkey: prop.data<(row: unknown) => string>(),\n columns: prop.data<datagridcolumn[]>([]),\n onsort: prop.data<(key: string) => void>(),\n },\n setup(props) {\n // set from js: grid.getrowkey = (row) => row.id\n return html`...`;\n },\n});\n```\n\n## template and directives\n\n### `html`\n\ntagged template literal that returns an `htmlresult`. supports text interpolation, ordinary attributes (`attr=`),\nboolean attributes (`?attr=`), events (`@event=`), refs (`ref=`), and nested templates.\n\n### `css`\n\ntagged template literal that returns a `cssresult` for use in `styles`.\n\n### directives\n\n| directive | purpose |\n| | |\n| `each(source, key, render, fallback?)` | keyed reactive list; render receives `readable<t>` and `readable<number>`; plain `t[]` is a one time static snapshot |\n| `when(condition, truthy, falsy?)` | conditional rendering |\n| `classmap(record)` | reactive class string from object map |\n| `stylemap(record)` | reactive inline style string from object map |\n| `live(signal)` | one way binding that skips stale writes during active user input; use with `@input` handler |\n| `unsafehtml(value)` | html rendering sink; sanitize untrusted values before calling |\n\n### `unsafehtml`\n\n`unsafehtml()` is an explicit html injection sink. it has no global sanitizer: sanitize untrusted\ncontent before passing it to the directive, so the trust boundary remains at the call site.\n\n```ts\nimport { unsafehtml } from '@vielzeug/ore';\n\nconst safearticle = sanitize(usersuppliedarticle);\n\nreturn html`<article>${unsafehtml(safearticle)}</article>`;\n```\n\n## host bindings\n\n`bind(config, options?)` is a plain function imported from `@vielzeug/ore`:\n\n```ts\nbind({\n attr: { role: 'button', 'aria expanded': () => string(open.value) },\n class: { 'is open': open },\n style: { ' height': () => height.value + 'px' },\n on: { click: handleclick },\n});\n```\n\n`bind()` auto registers cleanup with the component scope — no manual `oncleanup` needed. returns a cleanup function for early teardown.\n\n### off host bindings\n\npass `{ target: el }` as a second argument to bind to any element other than the host:\n\n```ts\nbind(\n { attr: { 'aria expanded': () => string(isopen.value) } },\n { target: triggerel },\n);\n```\n\nevent listener options (`once`, `capture`, `passive`) are also accepted in the second argument. cleanup is auto registered with the component scope when called during setup.\n\n### reactive aria attributes\n\nfor reactive aria attribute syncing, use `bind({ aria: config }, { target })`. shorthand keys are normalised to `aria *` automatically (`expanded` → `aria expanded`; `role` is passed verbatim):\n\n```ts\n// inside setup — cleanup auto registered\nbind(\n {\n aria: {\n expanded: () => isopen.value,\n controls: panelid,\n haspopup: 'listbox',\n },\n },\n { target: triggerel },\n);\n\n// manage cleanup manually — bind() always returns a cleanup fn\nconst stoparia = bind({ aria: { expanded: () => isopen.value } }, { target: triggerel });\n// call stoparia() when the trigger is swapped out\n```\n\nstatic values (strings, numbers, booleans) are applied once. getter functions and signals create reactive effects. setting a value to `null`, `undefined`, or `false` removes the attribute.\n\n## slots\n\n `slots.has(name?)` — `readable<boolean>` — whether the named (or default) slot has assigned content\n `slots.elements(name?)` — `readable<element[]>` — the assigned elements for the slot\n\nslot signals update reactively when assigned content changes, including when slots are inserted dynamically (via `when()` or `each()`) after mount.\n\n## context api\n\n `createcontext<t>(description?)` — create a typed injection key\n `provide(key, value)` — provide a value to descendants\n `inject(key)` — resolve from nearest ancestor; returns `undefined` if not found\n `inject(key, fallback)` — resolve with a fallback value\n `injectstrict(key)` — resolve or throw if absent\n\n`provide()` and `inject()` must be called synchronously during `setup()`. calling them outside a setup context throws\n`'lifecycle hooks must be called during component setup'`. context resolution walks the ancestor chain including shadow\ndom boundaries. `inject()` resolves and caches its result once per consumer — provide a `readable` (signal/computed)\nrather than a raw value if descendants need to observe later changes; re calling `provide()` with a new raw value\nafterward is not seen by consumers that already resolved it (a dev mode warning fires when a key is provided twice on\nthe same element). `provide()` registers cleanup automatically — context keys are removed from the registry when the\nproviding component disconnects, so reconnecting the same element runs `setup()` fresh without spurious \"overwriting\"\nwarnings or stale keys leaking to descendants.\n\n## utilities\n\n `ref<t>()` — create a `signal<t | null>` element reference. set to the element via `ref=` in templates.\n `createid(prefix = 'id')` — generate a unique incremental string id (e.g. `'id 1'`, `'id 2'`). each call returns a new id — it does not deduplicate by prefix.\n `createstableid(prefix = 'id')` — generate a unique id that also embeds a short random tag shared across all ids generated in the session (e.g. `'field a3k21'`), reducing collision risk when multiple app instances run on the same page. like `createid()`, every call returns a new id.\n `resetstableidcounter()` — reset the `createstableid()` counter to 0. call in test `beforeeach` for deterministic ids. scoped to `createstableid()` only — `createid()` has no public reset (it's for uniqueness, not cross test determinism).\n\n## form associated api\n\nimport from `@vielzeug/ore`.\n\n### `usefield(options)`\n\nwire a form associated element to `elementinternals`. requires `formassociated: true` on the component definition. the `disabled` state tracking via `internals.states` (customstateset) is skipped with a dev warning if the api is unavailable in the current environment.\n\n```ts\ntype formfieldoptions<t> = {\n disabled?: readable<boolean>;\n /** defaults to the host element active during setup. */\n el?: htmlelement;\n /**\n * when true, a null/undefined value is submitted as '' instead of null,\n * keeping the field's key present in formdata even when the value is absent.\n * only applies to the default toformvalue; ignored if toformvalue is provided.\n * @default false\n */\n emptystringfornull?: boolean;\n /** called when the ancestor <form> resets (see onformreset) — restore local field state here. */\n onreset?: () => void;\n toformvalue?: (value: t) => file | formdata | string | null;\n /** recomputed reactively and passed straight to internals.setvalidity(). null = always valid. */\n validationmessage?: readable<string>;\n validity?: readable<validitystateflags | null>;\n value: signal<t> | readable<t>;\n};\n\ntype formfieldhandle = {\n checkvalidity(): boolean;\n readonly internals: elementinternals;\n reportvalidity(): boolean;\n /** set (non empty message) or clear (empty string) a custom validity error. */\n setcustomvalidity(message: string): void;\n};\n```\n\npass `validity`/`validationmessage` to make `required` style constraints participate in native constraint validation\nthrough `checkvalidity()` and `reportvalidity()`:\n\n```ts\nconst isblank = (v: string) => v.trim() === '';\n\nusefield({\n validationmessage: computed(() => (required.value && isblank(value.value) ? 'this field is required.' : '')),\n validity: computed(() => (required.value && isblank(value.value) ? { valuemissing: true } : null)),\n value,\n});\n```\n\n## testing apis\n\nimport from `@vielzeug/ore/testing`.\n\n| api | purpose |\n| | |\n| `mount(setup, options?)` | mount a component and return a test fixture |\n| `cleanup()` | remove all mounted elements and reset test state |\n| `install(aftereach, options?)` | register auto cleanup; pass `{ forminternals: true }` to also install the `elementinternals`/`formdata`/`<form>.reset()` jsdom polyfill (see below) |\n| `installforminternalspolyfill()` | installs the form internals polyfill directly (returns an `uninstall()` that restores every patched global). usually called via `install(aftereach, { forminternals: true })` |\n| `walkflattree(root, visit)` | walks the flat tree (expanding `<slot>` via `assignedelements()`) — for finding slotted content across a shadow boundary that `queryselectorall()` can't cross |\n| `flush(options?)` | drain reactive updates and animation frames |\n| `debugflush()` | run `flush()` with `console.debug` diagnostics |\n| `mock(tag, template?)` | register a no op stub custom element |\n| `renderhook(setup)` | run lifecycle hooks in isolation; overload accepts `propdefs` as first arg for typed props |\n| `resetorefortests()` | reset styles and id counters when mounting is managed manually |\n| `oretimeouterror` | error thrown when `flush()` cannot settle tracked ore work |\n\n> **test isolation:** `cleanup()` removes mounted elements and resets all cross test ore state (the stylesheet cache and id counters) via `resetorefortests()`. call it in `aftereach` (or use `install()`) to prevent state leaking between tests.\n\nimport `within`, named dispatchers such as `fireclick`, and waits such as `waituntil` or `waitforevent` from\n`@vielzeug/assay`.\n\n> **form associated component testing:** jsdom implements none of the `elementinternals` form association api — `install(aftereach, { forminternals: true })` polyfills `setformvalue`/`setvalidity`/`checkvalidity`/`reportvalidity`/`validationmessage`/`validity`/`states`, mixes `checkvalidity`/`reportvalidity`/`validity`/`validationmessage` onto the host element itself (real browsers do this for any `formassociated: true` element), makes `formdata` collect a form associated element's set value, and makes `<form>.reset()` invoke `formresetcallback()`. every patch is a guarded no op when its target already exists, and `installforminternalspolyfill()` returns an `uninstall()` that restores every patched global. the polyfill is opt in (`{ forminternals: true }`) because the patches are global — suites without form associated components shouldn't carry them. a downstream package (e.g. a component library built on `ore`) should rely on this instead of hand rolling its own copy.\n\n#### `fixture` interface\n\n```ts\ninterface fixture<t extends htmlelement = htmlelement> {\n [symbol.dispose](): void; // delegates to dispose() — enables `using` declarations\n element: t;\n readonly disposed: boolean; // true after dispose() has been called\n readonly shadow: shadowroot | null;\n get<e extends element>(selector: string): e;\n query<e extends element>(selector: string): e | null;\n queryall<e extends element>(selector: string): e[];\n getbytext<e extends element>(text: string, selector?: string): e;\n querybytext<e extends element>(text: string, selector?: string): e | null;\n queryallbytext<e extends element>(text: string, selector?: string): e[];\n getbytestid<e extends element>(testid: string): e;\n querybytestid<e extends element>(testid: string): e | null;\n queryallbytestid<e extends element>(testid: string): e[];\n attr(name: string, value: string | number | boolean): promise<void>;\n attrs(record: record<string, string | number | boolean>): promise<void>;\n flush(options?: flushoptions): promise<void>;\n act(fn: () => unknown): promise<void>;\n dispose(): void; // removes the component from the dom — idempotent\n}\n```\n\n#### `renderhook`\n\nuseful for testing composable lifecycle hooks (`onmounted`, `watcheffect`, `inject`, etc.) without a template. `onmounted`/`oncleanup`/`watcheffect`/... work exactly as inside a real `setup()`, since they resolve the same implicit current component context:\n\n```ts\n// without props\nconst { result, flush, dispose } = await renderhook(() => {\n const count = signal(0);\n onmounted(() => {\n count.value = 1;\n });\n return count;\n});\nexpect(result.value).tobe(1);\n\n// with typed props (prop defs overload)\nconst { result } = await renderhook({ label: prop.string('hello'), count: prop.number(0) }, (props) => props.label);\nexpect(result.value).tobe('hello');\n```\n\n## ripple primitives\n\nore does **not** re export reactive primitives. import them directly from `@vielzeug/ripple`:\n\n```ts\nimport { batch, computed, signal, watch } from '@vielzeug/ripple';\n```\n\nsee the [ripple documentation](/ripple/) for the full api.\n\n## lifecycle events\n\n| event | when |\n| | |\n| `ore:connect` | after every `connectedcallback` (including reconnects) |\n| `ore:disconnect` | after `disconnectedcallback`, before component state is reset |\n| `ore:error` | when a lifecycle callback fails — bubbles, composed; detail is `orelifecycleerror` |\n\n## types\n\n```ts\ntype propdef<t> = {\n readonly default: t;\n readonly parse: (value: string | null) => t;\n reflect?: boolean;\n};\n\ntype propsdef<t extends record<string, unknown>> = {\n [k in keyof required<t>]: propdef<t[k & keyof t]>;\n};\n\ntype propinputdefs = record<string, propdef<unknown>>;\n\n/**\n * infer reactive props type from a propinputdefs map.\n * each entry becomes readable<t> keyed by prop name.\n */\ntype inferprops<d extends propinputdefs> = {\n readonly [k in keyof d] ?: readable<inferpropvalue<d[k]>>;\n};\n\n// runtime hooks — all plain functions imported from '@vielzeug/ore', not fields on an object.\ntype onmountedcallback = () => cleanup | undefined;\ntype onformresetcallback = () => void;\n\ndeclare function onmounted(fn: onmountedcallback): void; // dom ready callback; runs after each connection's render\ndeclare function oncleanup(fn: cleanup): void; // register teardown; called on disconnect\ndeclare function onelement<t extends htmlelement>(\n ref: readable<t | null>,\n callback: (el: t) => cleanup | undefined,\n): () => void;\ndeclare function onevent<k extends keyof htmlelementeventmap>(\n target: eventtarget | null | undefined,\n event: k,\n listener: (e: htmlelementeventmap[k]) => void,\n options?: addeventlisteneroptions,\n): void;\ndeclare function onevent(\n target: eventtarget | null | undefined,\n event: string,\n listener: eventlistener,\n options?: addeventlisteneroptions,\n): void;\ndeclare function onformreset(fn: onformresetcallback): void; // runs on every ancestor <form> reset; formassociated only\ndeclare function watcheffect(fn: () => cleanup | undefined): () => void; // scoped reactive effect; auto cleaned on disconnect\ndeclare function bind(config: hostbindconfig, options?: bindoptions): () => void; // bindings for host or any target element\ndeclare function provide<t>(key: injectionkey<t>, value: t): void; // register a context value on the host element\ndeclare function inject<t>(key: injectionkey<t>): t | undefined;\ndeclare function inject<t>(key: injectionkey<t>, fallback: t): t;\ndeclare function gethost(): htmlelement; // the current component's host element\ndeclare function useemit<emits extends record<string, unknown> = record<string, never>>(): emitfn<emits>;\ndeclare function useslots<slotnames extends string = string>(): componentslots<slotnames>;\n\ntype componentdefinition<props extends record<string, unknown> = record<never, never>> = {\n formassociated?: boolean;\n props?: propsdef<props>;\n setup: (props: inferprops<propsdef<props>>) => htmlresult | null;\n shadow?: partial<shadowrootinit> | false; // false = light dom\n styles?: (string | cssstylesheet | cssresult)[];\n};\n\ntype hostbindingvalue =\n | (() => string | number | boolean | null | undefined)\n | readable<string | number | boolean | null | undefined>\n | string\n | number\n | boolean\n | null\n | undefined;\n\ntype reflectconfig = record<string, hostbindingvalue>;\n\ntype hostbindconfig = {\n aria?: reflectconfig;\n attr?: reflectconfig;\n class?: (() => record<string, boolean>) | record<string, readable<boolean> | (() => boolean) | boolean>;\n on?: record<string, ((event: event) => void) | undefined>;\n style?: record<string, hostbindingvalue>;\n};\n\ntype bindoptions = addeventlisteneroptions & {\n target?: element;\n};\n\ntype hostbindfn = (config: hostbindconfig, options?: bindoptions) => () => void;\n\ntype componentslots<s extends string = string> = {\n elements(name?: s): readable<element[]>;\n has(name?: s): readable<boolean>;\n};\n\ntype ref<t extends element> = signal<t | null>;\n\ntype refcallback<t extends element> = (el: t | null) => void;\n\ntype injectionkey<t> = symbol & { readonly __ore_injection_key?: t };\n\ninterface htmlresult {\n mount(\n parent: parentnode,\n anchor: node | null,\n registercleanup: (fn: () => void) => void,\n ): node[];\n}\n\ntype cssresult = {\n content: string;\n tostring(): string;\n};\n\ntype livebinding<t> = { readonly source: readable<t> };\n\ntype emitfn<t extends record<string, unknown>> = {\n <k extends keyswithoutdetail<t>>(event: k): boolean;\n <k extends exclude<keyof t, keyswithoutdetail<t>>>(event: k, detail: t[k]): boolean;\n};\n// keyswithoutdetail is an internal helper type, not exported.\n\ntype formfieldoptions<t = unknown> = {\n disabled?: readable<boolean>;\n el?: htmlelement;\n emptystringfornull?: boolean;\n onreset?: () => void;\n toformvalue?: (value: t) => file | formdata | string | null;\n validationmessage?: readable<string>;\n validity?: readable<validitystateflags | null>;\n value: signal<t> | readable<t>;\n};\n\ntype formfieldhandle = {\n checkvalidity: () => boolean;\n readonly internals: elementinternals;\n reportvalidity: () => boolean;\n setcustomvalidity: (message: string) => void;\n};\n\ntype mutationobservervalue = {\n entries: mutationrecord[];\n latest: mutationrecord | null;\n};\n\n/** phase in which a oreerror occurred. */\ntype oreerrorphase = 'each reconcile' | 'form reset' | 'mounted' | 'setup';\n```\n\n## errors\n\n`oreerror` is the base class for every ore error class — `err instanceof oreerror` catches all of them.\n`oreerror.is(err)` is the equivalent static type guard.\n\n **`oreapierror`** — thrown when the `ore` api itself is misused: calling `define()` with a duplicate tag, calling a lifecycle hook (`inject`, `onmounted`, `oncleanup`, `onevent`, …) outside of `setup()`, or passing an invalid prop definition to `define()`.\n **`oreinternalerror`** — thrown when an ore invariant fails, indicating a package bug rather than invalid application code.\n **`orelifecycleerror`** — reported in the `ore:error` event when component `setup()`, a mounted callback, a form reset callback, or `each()` reconciliation fails. extends `oreerror` with:\n `component: string` — the element's local name\n `phase: oreerrorphase` — `'setup'` | `'mounted'` | `'form reset'` | `'each reconcile'`\n `cause: error` — the original error thrown by `setup()`\n **`oretimeouterror`** — thrown by `flush()` (from `@vielzeug/ore/testing`) when pending ore work does not settle before its timeout.\n\nlifecycle failures dispatch a bubbling, composed `ore:error` event whose `detail` is the `orelifecycleerror`. setup\nfailures still rethrow their original error; mounted and form reset callback failures are reported through the same\nevent so their remaining callbacks can continue.\n",
|
|
897
|
+
"api": " \ntitle: ore — api reference\ndescription: complete api reference for @vielzeug/ore and @vielzeug/ore/testing.\n \n\n[[toc]]\n\n## api overview\n\nall browser runtime symbols below are imported from `@vielzeug/ore`. lifecycle/context/binding functions (`onmounted`, `oncleanup`, `onevent`, `onelement`, `watcheffect`, `bind`, `provide`, `useemit`, `useslots`, `gethost`) resolve the active component through an implicit \"current component\" context — they work when called synchronously during `setup()`, or from any composable function `setup()` calls (transitively), but throw if called outside that window.\n\n> `watcheffect` is not named `watch` — `@vielzeug/ripple` already exports a `watch(source, callback)` with different semantics (explicit source + old/new value pair), and the two are frequently imported in the same file.\n\n| symbol | purpose | execution mode | common gotcha |\n| | | | |\n| `define()` | register a custom element with reactive setup | sync | tag must contain a hyphen; call before first use |\n| `html` | tagged template literal returning htmlresult | sync | expressions must be signals, functions, or primitives |\n| `prop.*` | typed prop helpers (string, bool, number, …) | sync | prop values are signals — read `.value` |\n| `provide()`/`inject()` | context api for parent to descendant sharing | setup only | must be called synchronously during `setup()` |\n| `ref()` | reactive reference to a dom element | sync | value is null until after first mount |\n| `createcontext()` | create a typed injection key | sync | context is scoped to the component tree |\n| `each()` | keyed list rendering with dom diffing | sync | duplicate keys report `ore:error`; plain `t[]` is a one time static render |\n| `when()` | conditional branch rendering | sync | getter fn computed disposed on cleanup; static bool skips subscription |\n| `live(signal)` | one way binding that skips stale writes during input | sync | use for controlled inputs alongside a manual `@input` handler |\n| `onmounted(fn)` | dom ready callback | setup only | must be called synchronously during `setup()` |\n| `oncleanup(fn)` | register teardown | setup only | called on component disconnect |\n| `onevent(target, …)` | scoped event listener with auto cleanup | setup only | no ops on null target; removed on disconnect |\n| `usefield(options)` | wire signal to form `elementinternals` | setup only | requires `formassociated: true` on the component definition |\n| `onformreset(fn)` | run work when the ancestor `<form>` resets | setup only | fires every reset (not one shot); only for `formassociated: true` components |\n| `useemit<emits>()` | typed `emit()` bound to the current host | setup only | call once per component; returns `dispatchevent`'s boolean (`false` if a listener called `preventdefault()`) |\n| `useslots<slotnames>()`| reactive slot presence/element signals | setup only | safe to call more than once — the underlying registry is created once |\n| `gethost()` | the current component's host element | setup only | prefer a higher level helper (`bind`, …) when one exists |\n\n## package entry points\n\n| import | purpose |\n| | |\n| `@vielzeug/ore` | all browser runtime apis, including directives, fields, and lifecycle helpers |\n| `@vielzeug/ore/testing` | ore specific mounting, lifecycle, hook, cleanup, and form test support |\n| `@vielzeug/assay` | generic dom events, scoped queries, and async waiting |\n\n## core component api\n\n### `define(tag, definition)`\n\n```ts\ndefine<props>(tag: string, definition: componentdefinition<props>): void;\n```\n\nthe `setup()` function receives only typed prop signals:\n\n```ts\nsetup(props) {\n return html`<div>${props.label}</div>`;\n}\n```\n\neverything else — lifecycle hooks, host bindings, context, slots, emit — is a plain function imported from `@vielzeug/ore`, called directly from `setup()` (or a composable it calls):\n\n```ts\nimport { define, html, onmounted, useemit, useslots } from '@vielzeug/ore';\n\ndefine('my card', {\n setup(_props) {\n const emit = useemit<{ close: undefined }>();\n const slots = useslots<'header' | 'footer'>();\n\n onmounted(() => console.log('mounted'));\n\n // emit() returns dispatchevent's boolean — false if a listener called preventdefault()\n const notcancelled = emit('close');\n\n return html`${when(slots.has('header'), () => html`<slot name=\"header\"></slot>`)}`;\n },\n});\n```\n\n`useemit<emits>()` and `useslots<slotnames>()` are factory hooks — call them once per setup run to get a typed\n`emit`/`slots` bound to the current host. `useslots()` is safe to call more than once within that setup run.\n\n### componentdefinition\n\n```ts\ntype componentdefinition<props> = {\n formassociated?: boolean;\n props?: propsdef<props>;\n setup: (props: inferprops<propsdef<props>>) => htmlresult | null;\n shadow?: partial<shadowrootinit> | false; // false = light dom (no shadow root)\n styles?: (string | cssstylesheet | cssresult)[];\n};\n```\n\n## runtime helpers\n\n`onmounted`, `oncleanup`, `onevent`, `onelement`, and `watcheffect` are plain functions imported from `@vielzeug/ore`. call them directly during `setup()`.\n\n```ts\nimport { html, oncleanup, onevent, onmounted } from '@vielzeug/ore';\n\nsetup(props) {\n onmounted(() => {\n // dom is ready; return a function for mount scoped cleanup\n return () => { /* cleanup on unmount */ };\n });\n\n oncleanup(() => { /* called on disconnect */ });\n\n onevent(window, 'keydown', (e) => { /* auto removed on disconnect */ });\n\n return html`...`;\n}\n```\n\nbecause these resolve the active component through an implicit context (rather than a value threaded through parameters), composable helper functions can call them directly too — no need to pass hooks in as options:\n\n```ts\nimport { oncleanup } from '@vielzeug/ore';\n\nfunction usemyhelper() {\n oncleanup(() => { /* teardown */ });\n}\n\n// in setup:\nsetup(_props) {\n usemyhelper();\n return html`...`;\n}\n```\n\n## props api\n\n| helper | signature | notes |\n| | | |\n| `prop.string(defaultvalue?)` | `propdef<string>` | reflects by default |\n| `prop.bool(defaultvalue?)` | `propdef<boolean>` | any non null attribute value other than `\"false\"` parses as `true`; `\"false\"` or absent attribute is `false` |\n| `prop.number(defaultvalue?)` | `propdef<number>` | returns default (not nan) and warns in dev when attribute is not a valid number |\n| `prop.oneof(allowed, defaultvalue)` | `propdef<t>` | restricts to provided string union |\n| `prop.json(defaultvalue)` | `propdef<t>` | json.parse from attribute; `reflect: false` |\n| `prop.data<t>(defaultvalue?)` | `propdef<t>` | js only — never reads/writes an attribute; use for objects, arrays, callbacks, or any non serialisable value |\n\n> **choosing the right prop helper:**\n>\n> **`prop.json`** — value can be declared in html (`<my el config='{\"x\":1}'>`); attribute string is `json.parse`d.\n> **`prop.data`** — value is always set from javascript (objects, arrays, callbacks, class instances); the attribute is never read. use this for both data and function props.\n\nwhen you need custom parsing or `reflect: false`, use a raw `propdef` object:\n\n```ts\nprops: {\n items: { default: [], parse: () => [], reflect: false },\n}\n```\n\nuse `prop.data` for props that hold js only values (including callbacks) that cannot be serialised through an html attribute:\n\n```ts\ndefine('data grid', {\n props: {\n getrowkey: prop.data<(row: unknown) => string>(),\n columns: prop.data<datagridcolumn[]>([]),\n onsort: prop.data<(key: string) => void>(),\n },\n setup(props) {\n // set from js: grid.getrowkey = (row) => row.id\n return html`...`;\n },\n});\n```\n\n## template and directives\n\n### `html`\n\ntagged template literal that returns an `htmlresult`. supports text interpolation, ordinary attributes (`attr=`),\nboolean attributes (`?attr=`), events (`@event=`), refs (`ref=`), and nested templates.\n\n### `css`\n\ntagged template literal that returns a `cssresult` for use in `styles`.\n\n### directives\n\n| directive | purpose |\n| | |\n| `each(source, key, render, fallback?)` | keyed reactive list; render receives `readable<t>` and `readable<number>`; plain `t[]` is a one time static snapshot |\n| `when(condition, truthy, falsy?)` | conditional rendering |\n| `classmap(record)` | reactive class string from object map |\n| `stylemap(record)` | reactive inline style string from object map |\n| `live(signal)` | one way binding that skips stale writes during active user input; use with `@input` handler |\n| `unsafehtml(value)` | html rendering sink; sanitize untrusted values before calling |\n\n### `unsafehtml`\n\n`unsafehtml()` is an explicit html injection sink. it has no global sanitizer: sanitize untrusted\ncontent before passing it to the directive, so the trust boundary remains at the call site.\n\n```ts\nimport { unsafehtml } from '@vielzeug/ore';\n\nconst safearticle = sanitize(usersuppliedarticle);\n\nreturn html`<article>${unsafehtml(safearticle)}</article>`;\n```\n\n## host bindings\n\n`bind(config, options?)` is a plain function imported from `@vielzeug/ore`:\n\n```ts\nbind({\n attr: { role: 'button', 'aria expanded': () => string(open.value) },\n class: { 'is open': open },\n style: { ' height': () => height.value + 'px' },\n on: { click: handleclick },\n});\n```\n\n`bind()` auto registers cleanup with the component scope — no manual `oncleanup` needed. returns a cleanup function for early teardown.\n\n### off host bindings\n\npass `{ target: el }` as a second argument to bind to any element other than the host:\n\n```ts\nbind(\n { attr: { 'aria expanded': () => string(isopen.value) } },\n { target: triggerel },\n);\n```\n\nevent listener options (`once`, `capture`, `passive`) are also accepted in the second argument. cleanup is auto registered with the component scope when called during setup.\n\n### reactive aria attributes\n\nfor reactive aria attribute syncing, use `bind({ aria: config }, { target })`. shorthand keys are normalised to `aria *` automatically (`expanded` → `aria expanded`; `role` is passed verbatim):\n\n```ts\n// inside setup — cleanup auto registered\nbind(\n {\n aria: {\n expanded: () => isopen.value,\n controls: panelid,\n haspopup: 'listbox',\n },\n },\n { target: triggerel },\n);\n\n// manage cleanup manually — bind() always returns a cleanup fn\nconst stoparia = bind({ aria: { expanded: () => isopen.value } }, { target: triggerel });\n// call stoparia() when the trigger is swapped out\n```\n\nstatic values (strings, numbers, booleans) are applied once. getter functions and signals create reactive effects. setting a value to `null`, `undefined`, or `false` removes the attribute.\n\n## slots\n\n `slots.has(name?)` — `readable<boolean>` — whether the named (or default) slot has assigned content\n `slots.elements(name?)` — `readable<element[]>` — the assigned elements for the slot\n\nslot signals update reactively when assigned content changes, including when slots are inserted dynamically (via `when()` or `each()`) after mount.\n\n## context api\n\n `createcontext<t>(description?)` — create a typed injection key\n `provide(key, value)` — provide a value to descendants\n `inject(key)` — resolve from nearest ancestor; returns `undefined` if not found\n `inject(key, fallback)` — resolve with a fallback value\n `injectstrict(key)` — resolve or throw if absent\n\n`provide()` and `inject()` must be called synchronously during `setup()`. calling them outside a setup context throws\n`'lifecycle hooks must be called during component setup'`. context resolution walks the ancestor chain including shadow\ndom boundaries. `inject()` resolves and caches its result once per consumer — provide a `readable` (signal/computed)\nrather than a raw value if descendants need to observe later changes; re calling `provide()` with a new raw value\nafterward is not seen by consumers that already resolved it (a dev mode warning fires when a key is provided twice on\nthe same element). `provide()` registers cleanup automatically — context keys are removed from the registry when the\nproviding component disconnects, so reconnecting the same element runs `setup()` fresh without spurious \"overwriting\"\nwarnings or stale keys leaking to descendants.\n\n## utilities\n\n `ref<t>()` — create a `signal<t | null>` element reference. set to the element via `ref=` in templates.\n `createid(prefix = 'id')` — generate a unique incremental string id (e.g. `'id 1'`, `'id 2'`). each call returns a new id — it does not deduplicate by prefix.\n `createstableid(prefix = 'id')` — generate a unique id that also embeds a short random tag shared across all ids generated in the session (e.g. `'field a3k21'`), reducing collision risk when multiple app instances run on the same page. like `createid()`, every call returns a new id.\n `resetstableidcounter()` — reset the `createstableid()` counter to 0. call in test `beforeeach` for deterministic ids. scoped to `createstableid()` only — `createid()` has no public reset (it's for uniqueness, not cross test determinism).\n\n## form associated api\n\nimport from `@vielzeug/ore`.\n\n### `usefield(options)`\n\nwire a form associated element to `elementinternals`. requires `formassociated: true` on the component definition. the `disabled` state tracking via `internals.states` (customstateset) is skipped with a dev warning if the api is unavailable in the current environment.\n\n```ts\ntype formfieldoptions<t> = {\n disabled?: readable<boolean>;\n /** defaults to the host element active during setup. */\n el?: htmlelement;\n /**\n * when true, a null/undefined value is submitted as '' instead of null,\n * keeping the field's key present in formdata even when the value is absent.\n * only applies to the default toformvalue; ignored if toformvalue is provided.\n * @default false\n */\n emptystringfornull?: boolean;\n /** called when the ancestor <form> resets (see onformreset) — restore local field state here. */\n onreset?: () => void;\n toformvalue?: (value: t) => file | formdata | string | null;\n /** recomputed reactively and passed straight to internals.setvalidity(). null = always valid. */\n validationmessage?: readable<string>;\n validity?: readable<validitystateflags | null>;\n value: signal<t> | readable<t>;\n};\n\ntype formfieldhandle = {\n checkvalidity(): boolean;\n readonly internals: elementinternals;\n reportvalidity(): boolean;\n /** set (non empty message) or clear (empty string) a custom validity error. */\n setcustomvalidity(message: string): void;\n};\n```\n\npass `validity`/`validationmessage` to make `required` style constraints participate in native constraint validation\nthrough `checkvalidity()` and `reportvalidity()`:\n\n```ts\nconst isblank = (v: string) => v.trim() === '';\n\nusefield({\n validationmessage: computed(() => (required.value && isblank(value.value) ? 'this field is required.' : '')),\n validity: computed(() => (required.value && isblank(value.value) ? { valuemissing: true } : null)),\n value,\n});\n```\n\n## testing apis\n\nimport from `@vielzeug/ore/testing`.\n\n| api | purpose |\n| | |\n| `mount(setup, options?)` | mount a component and return a test fixture |\n| `cleanup()` | remove all mounted elements and reset test state |\n| `install(aftereach, options?)` | register auto cleanup; pass `{ forminternals: true }` to also install the `elementinternals`/`formdata`/`<form>.reset()` jsdom polyfill (see below) |\n| `installforminternalspolyfill()` | installs the form internals polyfill directly (returns an `uninstall()` that restores every patched global). usually called via `install(aftereach, { forminternals: true })` |\n| `walkflattree(root, visit)` | walks the flat tree (expanding `<slot>` via `assignedelements()`) — for finding slotted content across a shadow boundary that `queryselectorall()` can't cross |\n| `flush(options?)` | drain reactive updates and animation frames |\n| `debugflush()` | run `flush()` with `console.debug` diagnostics |\n| `mock(tag, template?)` | register a no op stub custom element |\n| `renderhook(setup)` | run lifecycle hooks in isolation; overload accepts `propdefs` as first arg for typed props |\n| `resetorefortests()` | reset styles and id counters when mounting is managed manually |\n| `oretimeouterror` | error thrown when `flush()` cannot settle tracked ore work |\n\n> **test isolation:** `cleanup()` removes mounted elements and resets all cross test ore state (the stylesheet cache and id counters) via `resetorefortests()`. call it in `aftereach` (or use `install()`) to prevent state leaking between tests.\n\nimport `within`, named dispatchers such as `fireclick`, and waits such as `waituntil` or `waitforevent` from\n`@vielzeug/assay`.\n\n> **form associated component testing:** jsdom implements none of the `elementinternals` form association api — `install(aftereach, { forminternals: true })` polyfills `setformvalue`/`setvalidity`/`checkvalidity`/`reportvalidity`/`validationmessage`/`validity`/`states`, mixes `checkvalidity`/`reportvalidity`/`validity`/`validationmessage` onto the host element itself (real browsers do this for any `formassociated: true` element), makes `formdata` collect a form associated element's set value, and makes `<form>.reset()` invoke `formresetcallback()`. every patch is a guarded no op when its target already exists, and `installforminternalspolyfill()` returns an `uninstall()` that restores every patched global. the polyfill is opt in (`{ forminternals: true }`) because the patches are global — suites without form associated components shouldn't carry them. a downstream package (e.g. a component library built on `ore`) should rely on this instead of hand rolling its own copy.\n\n#### `fixture` interface\n\n```ts\ninterface fixture<t extends htmlelement = htmlelement> {\n [symbol.dispose](): void; // delegates to dispose() — enables `using` declarations\n element: t;\n readonly disposed: boolean; // true after dispose() has been called\n readonly shadow: shadowroot | null;\n get<e extends element>(selector: string): e;\n query<e extends element>(selector: string): e | null;\n queryall<e extends element>(selector: string): e[];\n getbytext<e extends element>(text: string, selector?: string): e;\n querybytext<e extends element>(text: string, selector?: string): e | null;\n queryallbytext<e extends element>(text: string, selector?: string): e[];\n getbytestid<e extends element>(testid: string): e;\n querybytestid<e extends element>(testid: string): e | null;\n queryallbytestid<e extends element>(testid: string): e[];\n attr(name: string, value: string | number | boolean): promise<void>;\n attrs(record: record<string, string | number | boolean>): promise<void>;\n flush(options?: flushoptions): promise<void>;\n act(fn: () => unknown): promise<void>;\n dispose(): void; // removes the component from the dom — idempotent\n}\n```\n\n#### `renderhook`\n\nuseful for testing composable lifecycle hooks (`onmounted`, `watcheffect`, `inject`, etc.) without a template. `onmounted`/`oncleanup`/`watcheffect`/... work exactly as inside a real `setup()`, since they resolve the same implicit current component context:\n\n```ts\n// without props\nconst { result, flush, dispose } = await renderhook(() => {\n const count = signal(0);\n onmounted(() => {\n count.value = 1;\n });\n return count;\n});\nexpect(result.value).tobe(1);\n\n// with typed props (prop defs overload)\nconst { result } = await renderhook({ label: prop.string('hello'), count: prop.number(0) }, (props) => props.label);\nexpect(result.value).tobe('hello');\n```\n\n## ripple primitives\n\nore does **not** re export reactive primitives. import them directly from `@vielzeug/ripple`:\n\n```ts\nimport { batch, computed, signal, watch } from '@vielzeug/ripple';\n```\n\nsee the [ripple documentation](/ripple/) for the full api.\n\n## lifecycle events\n\n| event | when |\n| | |\n| `ore:connect` | after every `connectedcallback` (including reconnects) |\n| `ore:disconnect` | after `disconnectedcallback`, before component state is reset |\n| `ore:error` | when a lifecycle callback fails — bubbles, composed; detail is `orelifecycleerror` |\n\n## types\n\n```ts\ntype propdef<t> = {\n readonly default: t;\n readonly parse: (value: string | null) => t;\n reflect?: boolean;\n};\n\ntype propsdef<t extends record<string, unknown>> = {\n [k in keyof required<t>]: propdef<t[k & keyof t]>;\n};\n\ntype propinputdefs = record<string, propdef<unknown>>;\n\n/**\n * infer reactive props type from a propinputdefs map.\n * each entry becomes readable<t> keyed by prop name.\n */\ntype inferprops<d extends propinputdefs> = {\n readonly [k in keyof d] ?: readable<inferpropvalue<d[k]>>;\n};\n\n// runtime hooks — all plain functions imported from '@vielzeug/ore', not fields on an object.\ntype onmountedcallback = () => cleanup | undefined;\ntype onformresetcallback = () => void;\n\ndeclare function onmounted(fn: onmountedcallback): void; // dom ready callback; runs after each connection's render\ndeclare function oncleanup(fn: cleanup): void; // register teardown; called on disconnect\ndeclare function onelement<t extends htmlelement>(\n ref: readable<t | null>,\n callback: (el: t) => cleanup | undefined,\n): () => void;\ndeclare function onevent<k extends keyof htmlelementeventmap>(\n target: eventtarget | null | undefined,\n event: k,\n listener: (e: htmlelementeventmap[k]) => void,\n options?: addeventlisteneroptions,\n): void;\ndeclare function onevent(\n target: eventtarget | null | undefined,\n event: string,\n listener: eventlistener,\n options?: addeventlisteneroptions,\n): void;\ndeclare function onformreset(fn: onformresetcallback): void; // runs on every ancestor <form> reset; formassociated only\ndeclare function watcheffect(fn: () => cleanup | undefined): () => void; // scoped reactive effect; auto cleaned on disconnect\ndeclare function bind(config: hostbindconfig, options?: bindoptions): () => void; // bindings for host or any target element\ndeclare function provide<t>(key: injectionkey<t>, value: t): void; // register a context value on the host element\ndeclare function inject<t>(key: injectionkey<t>): t | undefined;\ndeclare function inject<t>(key: injectionkey<t>, fallback: t): t;\ndeclare function gethost(): htmlelement; // the current component's host element\ndeclare function useemit<emits extends record<string, unknown> = record<string, never>>(): emitfn<emits>;\ndeclare function useslots<slotnames extends string = string>(): componentslots<slotnames>;\n\ntype componentdefinition<props extends record<string, unknown> = record<never, never>> = {\n formassociated?: boolean;\n props?: propsdef<props>;\n setup: (props: inferprops<propsdef<props>>) => htmlresult | null;\n shadow?: partial<shadowrootinit> | false; // false = light dom\n styles?: (string | cssstylesheet | cssresult)[];\n};\n\ntype hostbindingvalue =\n | (() => string | number | boolean | null | undefined)\n | readable<string | number | boolean | null | undefined>\n | string\n | number\n | boolean\n | null\n | undefined;\n\ntype reflectconfig = record<string, hostbindingvalue>;\n\ntype hostbindconfig = {\n aria?: reflectconfig;\n attr?: reflectconfig;\n class?: (() => record<string, boolean>) | record<string, readable<boolean> | (() => boolean) | boolean>;\n on?: record<string, ((event: event) => void) | undefined>;\n style?: record<string, hostbindingvalue>;\n};\n\ntype bindoptions = addeventlisteneroptions & {\n target?: element;\n};\n\ntype hostbindfn = (config: hostbindconfig, options?: bindoptions) => () => void;\n\ntype componentslots<s extends string = string> = {\n elements(name?: s): readable<element[]>;\n has(name?: s): readable<boolean>;\n};\n\ntype ref<t extends element> = signal<t | null>;\n\ntype refcallback<t extends element> = (el: t | null) => void;\n\ntype injectionkey<t> = symbol & { readonly __ore_injection_key?: t };\n\ninterface htmlresult {\n mount(\n parent: parentnode,\n anchor: node | null,\n registercleanup: (fn: () => void) => void,\n ): node[];\n}\n\ntype cssresult = {\n content: string;\n tostring(): string;\n};\n\ntype livebinding<t> = { readonly source: readable<t> };\n\ntype emitfn<t extends record<string, unknown>> = {\n <k extends keyswithoutdetail<t>>(event: k): boolean;\n <k extends exclude<keyof t, keyswithoutdetail<t>>>(event: k, detail: t[k]): boolean;\n};\n// keyswithoutdetail is an internal helper type, not exported.\n\ntype formfieldoptions<t = unknown> = {\n disabled?: readable<boolean>;\n el?: htmlelement;\n emptystringfornull?: boolean;\n onreset?: () => void;\n toformvalue?: (value: t) => file | formdata | string | null;\n validationmessage?: readable<string>;\n validity?: readable<validitystateflags | null>;\n value: signal<t> | readable<t>;\n};\n\ntype formfieldhandle = {\n checkvalidity: () => boolean;\n readonly internals: elementinternals;\n reportvalidity: () => boolean;\n setcustomvalidity: (message: string) => void;\n};\n\ntype mutationobservervalue = {\n entries: mutationrecord[];\n latest: mutationrecord | null;\n};\n\n/** phase in which a oreerror occurred. */\ntype oreerrorphase = 'each reconcile' | 'form reset' | 'mounted' | 'setup';\n```\n\n## errors\n\n`oreerror` is the base class for every ore error class — `err instanceof oreerror` catches all of them.\n\n **`oreapierror`** — thrown when the `ore` api itself is misused: calling `define()` with a duplicate tag, calling a lifecycle hook (`inject`, `onmounted`, `oncleanup`, `onevent`, …) outside of `setup()`, or passing an invalid prop definition to `define()`.\n **`oreinternalerror`** — thrown when an ore invariant fails, indicating a package bug rather than invalid application code.\n **`orelifecycleerror`** — reported in the `ore:error` event when component `setup()`, a mounted callback, a form reset callback, or `each()` reconciliation fails. extends `oreerror` with:\n `component: string` — the element's local name\n `phase: oreerrorphase` — `'setup'` | `'mounted'` | `'form reset'` | `'each reconcile'`\n `cause: error` — the original error thrown by `setup()`\n **`oretimeouterror`** — thrown by `flush()` (from `@vielzeug/ore/testing`) when pending ore work does not settle before its timeout.\n\nlifecycle failures dispatch a bubbling, composed `ore:error` event whose `detail` is the `orelifecycleerror`. setup\nfailures still rethrow their original error; mounted and form reset callback failures are reported through the same\nevent so their remaining callbacks can continue.\n",
|
|
898
898
|
"usage": " \ntitle: ore — usage guide\ndescription: practical ore usage patterns for components, props, templates, slots, context, forms, sentinel integration, and tests.\n \n\n[[toc]]\n\n## basic usage\n\n`define(tag, definition)` registers a custom element.\n\nyour `setup()` function receives typed prop signals and returns an `htmlresult` directly. its state belongs to the\ncurrent connection: disconnect disposes it, and reconnecting the same element runs setup again.\n\n```ts\nimport { signal } from '@vielzeug/ripple';\nimport { define, html } from '@vielzeug/ore';\n\ndefine('status chip', {\n setup() {\n const online = signal(true);\n\n return html`\n <button @click=${() => (online.value = !online.value)}>${() => (online.value ? 'online' : 'offline')}</button>\n `;\n },\n});\n```\n\neverything besides `props` — lifecycle hooks, host bindings, context, slots, emit — is a plain function imported from `@vielzeug/ore`, called directly from `setup()` (or a composable it calls):\n\n```ts\nimport { define, gethost, html, bind, useemit, useslots } from '@vielzeug/ore';\n\ndefine('my widget', {\n setup(_props) {\n const el = gethost(); // the host htmlelement\n const emit = useemit<{ close: undefined }>(); // typed event emitter\n const slots = useslots<'header'>(); // reactive slot observation\n\n bind({ attr: { role: 'group' } }); // host binding helper (attr, class, style, on)\n\n return html`<slot></slot>`;\n },\n});\n```\n\n## signals and effects\n\nore does not re export ripple primitives — import them directly from `@vielzeug/ripple`.\n\n```ts\nimport { batch, computed, effect, signal, watch } from '@vielzeug/ripple';\n\nconst count = signal(0);\nconst doubled = computed(() => count.value * 2);\n\neffect(() => {\n console.log('doubled =', doubled.value);\n});\n\nwatch(count, (next, prev) => {\n console.log('count changed', prev, ' >', next);\n});\n\nbatch(() => {\n count.value = 1;\n count.value = 2;\n});\n```\n\n## onmounted and lifecycle\n\nuse `onmounted()` for dom dependent initialization that must run after the template is mounted. use `onelement(ref, cb)` for work tied to a specific dom node. `onevent()` attaches a listener that is automatically removed on disconnect.\n\n```ts\nimport { signal } from '@vielzeug/ripple';\nimport { define, html, onelement, onevent, onmounted, ref, useslots } from '@vielzeug/ore';\n\ndefine('deferred init', {\n setup(_props) {\n const tabindex = signal(0);\n const inputref = ref<htmlinputelement>();\n const slots = useslots<'items'>();\n\n onmounted(() => {\n const items = slots.elements('items').value;\n console.log('found', items.length, 'items');\n });\n\n onelement(inputref, (input) => {\n input.focus();\n });\n\n onevent(window, 'keydown', (e: keyboardevent) => {\n if (e.key === 'escape') tabindex.value = 0;\n });\n\n return html`<div><slot name=\"items\"></slot><input ref=${inputref} /></div>`;\n },\n});\n```\n\n## prop definitions\n\nuse `prop.*` helpers for common cases, or raw `propdef` objects for custom parsing or `reflect: false`.\n\n```ts\nimport { define, html, prop } from '@vielzeug/ore';\n\ndefine('x button', {\n props: {\n label: prop.string('button'),\n disabled: prop.bool(false),\n variant: prop.oneof(['primary', 'secondary'] as const, 'primary'),\n count: prop.number(0),\n },\n setup(props) {\n return html`\n <button ?disabled=${props.disabled} data variant=${props.variant}>${props.label} (${props.count})</button>\n `;\n },\n});\n```\n\n## template bindings\n\n`html` supports text, attributes, booleans, properties, events, refs, and nested templates.\n\n```ts\nimport { computed, signal } from '@vielzeug/ripple';\nimport { define, html, ref } from '@vielzeug/ore';\n\ndefine('profile name', {\n setup() {\n const name = signal('alice');\n const inputref = ref<htmlinputelement>();\n\n return html`\n <label title=${computed(() => 'current: ' + name.value)}>name</label>\n <input\n ref=${inputref}\n value=${name}\n aria label=${() => 'current name ' + name.value}\n @input=${(event: event) => {\n name.value = (event.target as htmlinputelement).value;\n }} />\n <p>hello ${name}</p>\n `;\n },\n});\n```\n\n## directives\n\nore exports `each`, `classmap`, `stylemap`, `when`, `live`, and `unsafehtml` from `@vielzeug/ore`. use ordinary\nattribute bindings plus native event handlers for two way input state; no special model directive is required.\n\n```ts\nimport { signal } from '@vielzeug/ripple';\nimport { classmap, define, each, html, stylemap, when } from '@vielzeug/ore';\n\ndefine('task list', {\n setup() {\n const tasks = signal([{ id: 1, text: 'write tests' }]);\n const active = signal(true);\n\n return html`\n <ul\n class=\"${classmap({ ready: () => tasks.value.length > 0 })}\"\n style=${stylemap({ opacity: () => (active.value ? 1 : 0.5) })}>\n ${when(\n () => active.value,\n () => html`<li>active</li>`,\n () => html`<li>paused</li>`,\n )}\n ${each(\n tasks,\n (task) => task.id,\n (task) => html`<li>${() => task.value.text}</li>`,\n )}\n </ul>\n `;\n },\n});\n```\n\n### each() api\n\n`each(source, key, render, fallback?)` takes positional arguments:\n\n **source** — signal, getter, or plain array\n **key** — function returning a unique key per item\n **render** — receives reactive `item` and `index` signals\n **fallback** — optional, rendered when the list is empty\n\n```ts\neach(\n items,\n (item) => item.id,\n (item, index) => html`<li>#${index}: ${() => item.value.label}</li>`,\n () => html`<li>no items</li>`,\n);\n```\n\n## live form bindings\n\nuse `live(signal)` for inputs that should preserve in progress user edits instead of overwriting the dom on stale writes.\n\n```ts\nimport { signal } from '@vielzeug/ripple';\nimport { define, html, live } from '@vielzeug/ore';\n\ndefine('live search', {\n setup() {\n const query = signal('');\n\n return html`\n <input value=${live(query)} @input=${(e: event) => (query.value = (e.target as htmlinputelement).value)} />\n `;\n },\n});\n```\n\n## host bindings\n\n`bind()` wires reactive attrs, classes, styles, and events to the host element.\n\n```ts\nimport { signal } from '@vielzeug/ripple';\nimport { bind, define, html } from '@vielzeug/ore';\n\ndefine('x toggle', {\n setup(_props) {\n const open = signal(false);\n\n bind({\n attr: { 'aria expanded': () => string(open.value), role: 'button', tabindex: 0 },\n class: { 'is open': open },\n on: { click: () => (open.value = !open.value) },\n });\n\n return html`<slot></slot>`;\n },\n});\n```\n\nthe `bind` config supports `attr`, `class`, `style`, and `on` sections.\n\n## aria bindings\n\nuse `bind({ aria: config }, { target })` to reactively sync aria attributes to any element. shorthand keys are normalised to `aria *` automatically — `expanded` becomes `aria expanded`, `role` is set verbatim.\n\n```ts\nimport { signal } from '@vielzeug/ripple';\nimport { bind, define, html, onmounted } from '@vielzeug/ore';\n\ndefine('x disclosure', {\n setup(_props) {\n const open = signal(false);\n const panelid = 'disclosure panel';\n\n bind({\n attr: { role: 'button', tabindex: 0 },\n on: { click: () => (open.value = !open.value) },\n });\n\n onmounted(() => {\n const trigger = document.queryselector('#trigger') as htmlelement;\n if (trigger) {\n // bind() registers cleanup automatically when called inside setup\n bind(\n {\n aria: {\n controls: panelid,\n expanded: () => string(open.value),\n haspopup: 'region',\n },\n },\n { target: trigger },\n );\n }\n });\n\n return html`<slot></slot>`;\n },\n});\n```\n\nstatic values are applied once. getter functions create reactive effects. setting a value to `null`, `undefined`, or `false` removes the attribute.\n\n`bind()` always returns a cleanup function. use it to stop syncing early when a trigger element can be swapped out:\n\n```ts\nonmounted(() => {\n const trigger = document.queryselector('#trigger') as htmlelement;\n const stoparia = bind({ aria: { expanded: () => string(open.value) } }, { target: trigger });\n\n // stop syncing when the trigger is replaced\n oncleanup(stoparia);\n});\n```\n\n### binding a non host element with `bind()`\n\npass `{ target: el }` as a second argument to bind attributes, classes, styles, or events to any element:\n\n```ts\nimport { signal } from '@vielzeug/ripple';\nimport { bind, define, html, onmounted, ref } from '@vielzeug/ore';\n\ndefine('button wrapper', {\n setup(_props) {\n const visible = signal(false);\n const btnref = ref<htmlbuttonelement>();\n\n onmounted(() => {\n const btn = btnref.value;\n if (!btn) return;\n\n bind(\n {\n attr: { 'aria pressed': () => string(visible.value) },\n on: { click: () => (visible.value = !visible.value) },\n },\n { target: btn },\n );\n });\n\n return html`<button ref=${btnref}>toggle</button>`;\n },\n});\n```\n\n## slots and emits\n\n```ts\nimport { define, html, useemit, useslots, when } from '@vielzeug/ore';\n\ndefine('card with footer', {\n setup(_props) {\n const slots = useslots<'header' | 'footer'>();\n const emit = useemit<{ action: undefined }>();\n\n return html`\n <div class=\"card\">\n <slot name=\"header\"></slot>\n <slot></slot>\n ${when(slots.has('footer'), () => html`<footer><slot name=\"footer\"></slot></footer>`)}\n </div>\n <button @click=${() => emit('action')}>go</button>\n `;\n },\n});\n```\n\npass a `slotnames` type parameter to `useslots<slotnames>()` to get typed `slots.has()` and `slots.elements()` calls.\n\n## context provide/inject\n\n```ts\nimport { signal } from '@vielzeug/ripple';\nimport { createcontext, define, html, injectstrict, provide } from '@vielzeug/ore';\n\nconst count_ctx = createcontext<returntype<typeof signal<number>>>('count');\n\ndefine('count provider', {\n setup(_props) {\n const count = signal(0);\n provide(count_ctx, count);\n\n return html`<button @click=${() => count.value++}><slot></slot></button>`;\n },\n});\n\ndefine('count consumer', {\n setup() {\n const count = injectstrict(count_ctx);\n\n return html`<p>count: ${count}</p>`;\n },\n});\n```\n\n`provide()` registers cleanup automatically — context keys are removed from the registry when the providing component disconnects. on reconnect, `setup()` runs fresh and `provide()` re registers without spurious \"overwriting\" warnings. provide a `readable` (signal/computed) rather than a raw value if descendants need to observe later changes — `inject()` resolves and caches the value once per consumer connection.\n\n## form associated elements\n\n```ts\nimport { signal } from '@vielzeug/ripple';\nimport { define, html, prop } from '@vielzeug/ore';\nimport { usefield } from '@vielzeug/ore';\n\ndefine('rating input', {\n formassociated: true,\n setup() {\n const value = signal(0);\n const field = usefield({ value });\n\n return html`\n <button @click=${() => (value.value = 1)}>1</button>\n <button @click=${() => (value.value = 2)}>2</button>\n <button @click=${() => (value.value = 3)}>3</button>\n <button @click=${() => field.reportvalidity()}>validate</button>\n <p>current: ${value}</p>\n `;\n },\n});\n```\n\n## sentinel observers\n\nuse `@vielzeug/sentinel` for reactive browser and dom observations. create element dependent sentinels inside `onmounted()` and dispose them with the component.\n\n```ts\nimport { define, html, oncleanup, onmounted, ref, watcheffect } from '@vielzeug/ore';\nimport { createelementsize, sentinelunavailableerror } from '@vielzeug/sentinel';\n\ndefine('x observed', {\n setup(_props) {\n const boxref = ref<htmldivelement>();\n\n onmounted(() => {\n const element = boxref.value;\n if (!element) return;\n\n try {\n const size = createelementsize(element);\n\n watcheffect(() => {\n console.log(size.value?.width);\n });\n\n oncleanup(() => size.dispose());\n } catch (error) {\n if (!(error instanceof sentinelunavailableerror)) throw error;\n }\n });\n\n return html`<div ref=${boxref}>observe me</div>`;\n },\n});\n```\n\n## testing utilities\n\nimport from `@vielzeug/ore/testing`.\n\n```ts\nimport { aftereach, describe, expect, it } from 'vitest';\nimport { signal } from '@vielzeug/ripple';\nimport { fireclick } from '@vielzeug/assay';\nimport { html } from '@vielzeug/ore';\nimport { cleanup, mount } from '@vielzeug/ore/testing';\n\ndescribe('my counter', () => {\n aftereach(cleanup);\n\n it('increments on click', async () => {\n let count!: returntype<typeof signal<number>>;\n const { query, act } = await mount(() => {\n count = signal(0);\n return html`<button @click=${() => count.value++}>${count}</button>`;\n });\n\n expect(query('button')?.textcontent).tobe('0');\n\n await act(() => fireclick(query('button')!));\n\n expect(query('button')?.textcontent).tobe('1');\n });\n});\n```\n\n## framework integration\n\nore components are standard custom elements and work natively in any framework.\n\n::: code group\n\n```tsx [react]\n// react 19+ supports custom elements natively.\nimport './x toggle'; // wherever define('x toggle', { ... }) is called\n\nfunction app() {\n return <x toggle aria label=\"open menu\" />;\n}\n```\n\n```ts [vue 3]\n<script setup lang=\"ts\">\nimport './x toggle'; // wherever define('x toggle', { ... }) is called\nimport { ref } from 'vue';\n\nconst open = ref(false);\n</script>\n\n<template>\n <x toggle :aria label=\"'open menu'\" @click=\"open = !open\" />\n</template>\n```\n\n```svelte [svelte]\n<script>\n import './x toggle'; // wherever define('x toggle', { ... }) is called\n\n function handleclick() {\n console.log('toggled');\n }\n</script>\n\n<x toggle aria label=\"open menu\" on:click={handleclick} />\n```\n\n:::\n\n## working with other vielzeug libraries\n\n### with ripple\n\nimport ripple primitives directly from `@vielzeug/ripple` for standalone reactive state outside components.\n\n```ts\nimport { signal, computed } from '@vielzeug/ripple';\nimport { define, html } from '@vielzeug/ore';\n\n// shared state created outside any component\nconst theme = signal<'light' | 'dark'>('light');\nconst isdark = computed(() => theme.value === 'dark');\n\ndefine('theme toggle', {\n setup() {\n return html`\n <button @click=${() => (theme.value = isdark.value ? 'light' : 'dark')}>\n ${() =>\n isdark.value ? '<ore icon name=\"sun\" size=\"16\"></ore icon>' : '<ore icon name=\"moon\" size=\"16\"></ore icon>'}\n </button>\n `;\n },\n});\n```\n\n### with forge\n\nuse `@vielzeug/forge` for typed form state. `usefield()` remains intentionally narrow: it connects a form associated\ncustom element to native `elementinternals` without imposing submission, validation, or dirty state policy.\n\n```ts\nimport { createform } from '@vielzeug/forge';\nimport { define, html } from '@vielzeug/ore';\n\ndefine('signup form', {\n setup(_props) {\n const form = createform({ initialvalues: { email: '' } });\n\n return html`\n <form\n @submit=${(event: submitevent) => {\n event.preventdefault();\n void form.submit(async (values) => {\n console.log(values);\n });\n }}>\n <slot></slot>\n </form>\n `;\n },\n});\n```\n\n## best practices\n\n setup returns `html\\`...\\`` directly — not a function wrapping the template.\n use `watcheffect()` for reactive subscriptions tied to component lifetime — it auto registers cleanup on disconnect.\n use `onelement(ref, cb)` instead of `onmounted` when the work is tied to a single dom node.\n bind host attributes and classes via `bind()` rather than mutating the element directly.\n provide context at the nearest ancestor — avoid global context singletons.\n call `oncleanup()` for every resource allocated in `setup()` (websockets, intervals, external subscriptions).\n use `live(signal)` for form inputs to prevent clobbering user in progress edits.\n extract composable helper functions freely — `onmounted`/`oncleanup`/`bind`/... resolve the active component through implicit context, so they work from any function called (transitively) during `setup()`, with no need to pass them in as parameters.\n test component mounting and lifecycle with `@vielzeug/ore/testing`; import generic dom events, queries, and waits\n from `@vielzeug/assay`.\n",
|
|
899
899
|
"examples": " \ntitle: ore — examples\ndescription: practical examples and recipes for ore.\n \n\n## examples\n\n [counter component](./examples/counter component.md)\n [typed props and emits](./examples/typed props and emits.md)\n [observers in onmounted()](./examples/observers in onmount.md)\n [search list with directives](./examples/search list with directives.md)\n [context provider and consumer](./examples/context provider and consumer.md)\n [prop helpers and raw propdef](./examples/propsof builder api.md)\n [form associated rating input](./examples/form associated rating input.md)\n [test example with @vielzeug/ore/testing](./examples/test example at vielzeug ore testing.md)\n"
|
|
900
900
|
},
|
|
@@ -906,6 +906,28 @@
|
|
|
906
906
|
"slug": "ore",
|
|
907
907
|
"source": "export type { componentdefinition } from './component types';\nexport { createcontext, type injectionkey, inject, injectstrict, provide } from './context';\nexport { define, prop } from './define';\n// near universal template directives — used in most non trivial components (lists,\n// conditionals, and class/style maps. kept in the main entry alongside\n// `html`/`define` rather than a separate sub path: tree shaking already means an unused export\n// costs nothing in a bundled consumer, so splitting these off only adds an extra import line\n// for functionality most components need on day one. `unsafehtml()` and `live()` remain here\n// too: their explicit names make their specialized behavior clear without a second import path.\nexport { classmap } from './directives/classmap';\nexport { each } from './directives/each';\nexport { type livebinding, live } from './directives/live';\nexport { stylemap } from './directives/stylemap';\nexport { unsafehtml } from './directives/unsafe html';\nexport { when } from './directives/when';\nexport { oreapierror, oreerror, type oreerrorphase, oreinternalerror, orelifecycleerror } from './errors';\nexport { type formfieldhandle, type formfieldoptions, usefield } from './forms/field';\nexport {\n type bindoptions,\n bind,\n type hostbindconfig,\n type hostbindfn,\n type hostbindingvalue,\n type reflectconfig,\n} from './host bind';\nexport type { inferprops, propdef, propinputdefs, propsdef } from './props';\n// lifecycle hooks — plain functions, called during setup() or a composable it invokes.\nexport {\n gethost,\n type onformresetcallback,\n type onmountedcallback,\n oncleanup,\n onelement,\n onevent,\n onformreset,\n onmounted,\n watcheffect,\n} from './runtime';\nexport { type componentslots, useslots } from './slots';\nexport { html } from './template/instantiator';\nexport { type htmlresult, type ref, type refcallback, ref } from './template/result';\nexport { type cssresult, css } from './utils/css';\nexport { type emitfn, useemit } from './utils/emit';\n\nexport { createid, createstableid, resetstableidcounter } from './utils/id';\n"
|
|
908
908
|
},
|
|
909
|
+
{
|
|
910
|
+
"category": "async",
|
|
911
|
+
"description": "typed durable job outbox with leased processing, retries, and dead letter recovery for browser applications.",
|
|
912
|
+
"docs": {
|
|
913
|
+
"index": " \ntitle: postmaster — durable job outbox\ndescription: typed durable job outbox with leased processing, retries, and dead letter recovery for browser applications.\npackage: postmaster\ncategory: async\nkeywords: [durable, outbox, jobs, retry, dead letter, idempotency, indexeddb, lease]\nrelated: [courier, vault, sentinel, familiar, ripple]\nexports: [createpostmaster, definejobs, createindexeddbpostmasterstore, creatememorypostmasterstore, postmastererror, postmasterdisposederror, postmasterjoberror]\nenvironments: [browser, node]\n \n\n<! markdownlint disable md025 md033 md060 >\n\n<packagehero package=\"postmaster\" />\n\n## why postmaster?\n\napplication jobs that touch a remote service — posting a form, syncing state, sending analytics — must survive page reloads, resume later, retry according to an explicit policy, and retain terminal failures for recovery. postmaster coordinates that delivery with typed job definitions, leased processing, and a dead letter queue, all backed by indexeddb.\n\n```ts\n// before\nasync function createtodo(payload: { id: string; title: string }) {\n // lost on reload. no retry. no recovery. silent failure.\n await fetch('/api/todos', { method: 'post', body: json.stringify(payload) });\n}\n\n// after\nimport { createpostmaster, definejobs } from '@vielzeug/postmaster';\nimport { createindexeddbpostmasterstore } from '@vielzeug/postmaster/indexeddb';\nimport { s } from '@vielzeug/spell';\n\nconst jobs = definejobs({\n createtodo: {\n version: 1,\n validate: s.object({ id: s.string(), title: s.string() }),\n key: (p) => p.id,\n execute: async (payload, { key, signal }) => {\n await fetch('/api/todos', {\n method: 'post',\n body: json.stringify(payload),\n headers: { 'idempotency key': key },\n signal,\n });\n },\n },\n});\n\nconst store = createindexeddbpostmasterstore({ name: 'my app outbox' });\nconst postmaster = createpostmaster({ jobs, store });\n\nawait postmaster.enqueue('createtodo', { id: crypto.randomuuid(), title: 'buy milk' });\nawait postmaster.start();\n```\n\n| feature | postmaster | ad hoc outbox | familiar |\n| | | | |\n| bundle size | <packageinfo package=\"postmaster\" type=\"size\" /> | application defined | <packageinfo package=\"familiar\" type=\"size\" /> |\n| zero dependencies | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> |\n| survives page reload | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> |\n| leased cross tab processing | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> |\n| dead letter recovery | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> |\n| typed job payloads | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> |\n\n<div class=\"decision callout\">\n\n**use postmaster when** application jobs must survive reloads, retry explicitly, and remain recoverable after terminal failure.\n\n**consider familiar when** jobs are cpu bound, in memory only, and never need to survive a page reload.\n\n</div>\n\n## installation\n\n::: code group\n\n```sh [pnpm]\npnpm add @vielzeug/postmaster\n```\n\n```sh [npm]\nnpm install @vielzeug/postmaster\n```\n\n```sh [yarn]\nyarn add @vielzeug/postmaster\n```\n\n:::\n\nfor browser persistence, also install `@vielzeug/vault` (a workspace peer of the indexeddb adapter):\n\n::: code group\n\n```sh [pnpm]\npnpm add @vielzeug/postmaster @vielzeug/vault\n```\n\n```sh [npm]\nnpm install @vielzeug/postmaster @vielzeug/vault\n```\n\n```sh [yarn]\nyarn add @vielzeug/postmaster @vielzeug/vault\n```\n\n:::\n\n## quick start\n\ndefine typed jobs, create a durable store, enqueue work, and start the processor. dispose both the processor and the store when the page lifetime ends.\n\n```ts\nimport { createpostmaster, definejobs } from '@vielzeug/postmaster';\nimport { createindexeddbpostmasterstore } from '@vielzeug/postmaster/indexeddb';\n\nconst jobs = definejobs({\n createtodo: {\n version: 1,\n validate: (v: unknown) => v as { id: string; title: string },\n key: (p) => p.id,\n execute: async (payload, { key, signal }) => {\n await fetch('/api/todos', {\n method: 'post',\n body: json.stringify(payload),\n headers: { 'idempotency key': key },\n signal,\n });\n },\n retry: { maxattempts: 5, shouldretry: () => true },\n },\n});\n\nconst store = createindexeddbpostmasterstore({ name: 'my app outbox' });\nconst postmaster = createpostmaster({ jobs, store });\n\nawait postmaster.enqueue('createtodo', { id: crypto.randomuuid(), title: 'buy milk' });\nawait postmaster.start();\n\n// on page unload:\nawait postmaster.dispose();\nawait store.dispose();\n```\n\n<div class=\"features grid\">\n\n## features\n\n `definejobs()` — typed job registry with payload inference and validation.\n `createpostmaster()` — processor with leased claims, heartbeat renewal, and crash recovery.\n `enqueue()` — persist a job and wake the processor.\n `flush()` — process every available job until the queue is empty.\n `retry()` / `remove()` — recover or discard dead letter jobs.\n `tap()` — typed runtime events for enqueued, started, completed, retry scheduled, dead lettered, removed, lease lost, and processor error.\n `createindexeddbpostmasterstore()` — durable browser store backed by vault indexeddb.\n `creatememorypostmasterstore()` — deterministic in memory store for tests.\n\n</div>\n\n<div class=\"doc links\">\n\n## documentation\n\n [**usage guide**](./usage.md)\n [**api reference**](./api.md)\n [**examples**](./examples.md)\n\n</div>\n\n<div class=\"see also\">\n\n## see also\n\n [@vielzeug/courier](../courier/) — perform the http requests postmaster jobs coordinate.\n [@vielzeug/vault](../vault/) — indexeddb storage primitive backing the durable store.\n [@vielzeug/sentinel](../sentinel/) — flush the outbox when the network returns.\n [@vielzeug/familiar](../familiar/) — in memory web worker pool for cpu bound tasks.\n\n</div>\n\n<! markdownlint enable md025 md033 md060 >\n",
|
|
914
|
+
"api": " \ntitle: postmaster — api reference\ndescription: job definitions, processor, store contracts, events, errors, and entry points for postmaster.\n \n\n[[toc]]\n\n## api overview\n\n| symbol | purpose | execution mode | common gotcha |\n| | | | |\n| `definejobs()` | typed job registry with validation | sync | throws on invalid version, missing fields, or bad retry config |\n| `createpostmaster()` | processor with leased claims and retry | sync | store is borrowed, not disposed with the processor |\n| `createindexeddbpostmasterstore()` | durable browser store | sync | requires `@vielzeug/vault` as a workspace peer |\n| `creatememorypostmasterstore()` | deterministic in memory store | sync | use for tests only |\n| `postmastererror` | base class for package errors | sync | catch a subtype when recovery is specific |\n\n## package entry point\n\n| import | purpose |\n| | |\n| `@vielzeug/postmaster` | job definitions, processor, store contract, events, errors |\n| `@vielzeug/postmaster/indexeddb` | durable browser store backed by vault indexeddb |\n| `@vielzeug/postmaster/testing` | deterministic in memory store and test helpers |\n\n## factories\n\n### `definejobs()`\n\n```ts\nfunction definejobs<const j extends jobdefinitions>(jobs: j): j;\n```\n\nreturns the job registry after validating each definition. rejects invalid versions, missing `execute`/`key`, and retry configurations with non positive `maxattempts`.\n\n| parameter | type | description |\n| | | |\n| `jobs` | `j extends jobdefinitions` | map of job name to definition |\n\n**returns:** `j` — the same registry, typed for payload inference.\n\n**example**\n\n```ts\nimport { definejobs } from '@vielzeug/postmaster';\n\nconst jobs = definejobs({\n createtodo: {\n version: 1,\n validate: (v) => v as { id: string; title: string },\n key: (p) => p.id,\n execute: async (payload, { key, signal }) => {\n await fetch('/api/todos', {\n method: 'post',\n body: json.stringify(payload),\n headers: { 'idempotency key': key },\n signal,\n });\n },\n },\n});\n```\n\n \n\n### `createpostmaster()`\n\n```ts\nfunction createpostmaster<j extends jobdefinitions>(options: createpostmasteroptions<j>): postmaster<j>;\n```\n\nreturns a postmaster processor that claims, executes, retries, and dead letters jobs from the borrowed store.\n\n| parameter | type | description |\n| | | |\n| `options.jobs` | `j` | job registry from `definejobs()` |\n| `options.store` | `postmasterstore` | borrowed store; not disposed with the processor |\n| `options.leaseduration` | `number` | lease duration in ms (default 30000, minimum 1000) |\n| `options.clock` | `() => number` | deterministic clock for tests (default `date.now`) |\n| `options.signal` | `abortsignal` | external signal that disposes the processor |\n\n**returns:** `postmaster<j>`.\n\n**example**\n\n```ts\nimport { createpostmaster } from '@vielzeug/postmaster';\nimport { createindexeddbpostmasterstore } from '@vielzeug/postmaster/indexeddb';\n\nconst store = createindexeddbpostmasterstore({ name: 'outbox' });\nconst postmaster = createpostmaster({ jobs, store });\n\nawait postmaster.start();\nawait postmaster.dispose();\nawait store.dispose();\n```\n\n \n\n### `createindexeddbpostmasterstore()`\n\n```ts\nfunction createindexeddbpostmasterstore(options: { name: string }): postmasterstore;\n```\n\nreturns a durable postmaster store backed by vault indexeddb. uses one internal table indexed by `status`, `availableat`, and `leaseexpiresat`. all operations run inside vault transactions.\n\n| parameter | type | description |\n| | | |\n| `options.name` | `string` | indexeddb database name |\n\n**returns:** `postmasterstore`.\n\n**example**\n\n```ts\nimport { createindexeddbpostmasterstore } from '@vielzeug/postmaster/indexeddb';\n\nconst store = createindexeddbpostmasterstore({ name: 'my app outbox' });\nawait store.dispose();\n```\n\n \n\n### `creatememorypostmasterstore()`\n\n```ts\nfunction creatememorypostmasterstore(entries?: readonly storedjob[]): postmasterstore;\n```\n\nreturns a deterministic in memory store for tests. serializes all operations through a promise chain.\n\n| parameter | type | description |\n| | | |\n| `entries` | `readonly storedjob[]` | initial records (default empty) |\n\n**returns:** `postmasterstore`.\n\n**example**\n\n```ts\nimport { creatememorypostmasterstore } from '@vielzeug/postmaster/testing';\n\nconst store = creatememorypostmasterstore();\nawait store.dispose();\n```\n\n## postmaster methods\n\n### `enqueue()`\n\n```ts\nenqueue<k extends keyof j & string>(name: k, payload: inferjobpayload<j[k]>): promise<postmasterentry>;\n```\n\nvalidates the payload (if `validate` is defined), derives the key, persists the job, and wakes the processor. throws `postmastererror` for an empty key or non json serializable payload.\n\n \n\n### `start()`\n\n```ts\nstart(): promise<void>;\n```\n\nbegins background processing. idempotent.\n\n \n\n### `flush()`\n\n```ts\nflush(options?: { signal?: abortsignal }): promise<flushresult>;\n```\n\nprocesses every available job until the queue is empty or the signal aborts. concurrent `flush()` calls join the same drain. returns counts of processed, completed, dead lettered, and retry scheduled jobs.\n\n \n\n### `list()`\n\n```ts\nlist(filter?: entryfilter): promise<postmasterentry[]>;\n```\n\nreturns entries ordered by `createdat`. filter by `status` optionally.\n\n \n\n### `stats()`\n\n```ts\nstats(): promise<postmasterstats>;\n```\n\nreturns counts of queued, running, and dead letter jobs.\n\n \n\n### `retry()`\n\n```ts\nretry(id: string): promise<retryresult>;\n```\n\nmoves a dead letter job back to queued. returns a discriminated result: `retried`, `not found`, `not dead letter`, or `running`.\n\n \n\n### `remove()`\n\n```ts\nremove(id: string): promise<removeresult>;\n```\n\ndeletes a queued or dead letter job. returns a discriminated result: `removed`, `not found`, or `running`.\n\n \n\n### `tap()`\n\n```ts\ntap(handler: (event: postmasterevent) => void, options?: { signal?: abortsignal }): () => void;\n```\n\nobserve runtime events (enqueued, started, completed, retry scheduled, dead lettered, removed, lease lost, processor error, dispose). handler errors are swallowed — observability never affects processing. returns an unsubscribe function. pass `{ signal }` to auto detach on abort.\n\n \n\n### `dispose()`\n\n```ts\ndispose(): promise<void>;\n[symbol.asyncdispose](): promise<void>;\n```\n\naborts owned work, releases all active leases, and tears down subscriptions. idempotent. does not dispose the borrowed store.\n\n## types\n\n### `jobdefinition<t>`\n\n```ts\ninterface jobdefinition<t> {\n readonly version: number;\n readonly validate?: validate<t>;\n readonly key: (payload: t) => string;\n readonly execute: (payload: t, context: jobcontext) => promise<void>;\n readonly retry?: retrypolicy;\n readonly migrate?: (payload: unknown, fromversion: number) => unknown;\n}\n```\n\n`validate` is optional. accepts a function `(value: unknown) => t` or any structural parser with `parse(value: unknown): t` (spell schemas, zod schemas, etc). called once at enqueue. if omitted, payload trusted as is.\n\n \n\n### `validate<t>`\n\n```ts\ntype validate<t> = ((value: unknown) => t) | { parse(value: unknown): t };\n```\n\naccepts either a plain validation function or any object with a `parse(value: unknown): t` method. spell's `schema` and `s.object(...)` satisfy this contract directly — no adapter needed.\n\n \n\n### `jobcontext`\n\n```ts\ninterface jobcontext {\n readonly attempt: number;\n readonly entryid: string;\n readonly key: string;\n readonly signal: abortsignal;\n}\n```\n\n \n\n### `retrypolicy`\n\n```ts\ninterface retrypolicy {\n readonly maxattempts: number;\n readonly shouldretry: (error: unknown, attempt: number) => boolean;\n readonly delay?: (attempt: number) => number;\n}\n```\n\n`maxattempts` is total executions including the first. `shouldretry` is required when retries are enabled. default delay uses arsenal's `backoff(attempt)`.\n\n \n\n### `storedjob`\n\n```ts\ninterface storedjob {\n readonly id: string;\n readonly name: string;\n readonly version: number;\n readonly payload: jsonvalue;\n readonly key: string;\n readonly status: 'queued' | 'running' | 'dead letter';\n readonly attempts: number;\n readonly createdat: number;\n readonly updatedat: number;\n readonly availableat: number;\n readonly ownerid?: string;\n readonly leaseexpiresat?: number;\n readonly failure?: storedfailure;\n}\n```\n\n \n\n### `storedfailure`\n\n```ts\ninterface storedfailure {\n readonly name: string;\n readonly message: string;\n readonly occurredat: number;\n}\n```\n\nonly a bounded error name/message/timestamp is persisted. never persist arbitrary error objects, response bodies, headers, or stacks.\n\n \n\n### `postmasterentry`\n\n```ts\ntype postmasterentry = pick<storedjob,\n 'attempts' | 'availableat' | 'createdat' | 'failure' | 'id' |\n 'key' | 'name' | 'status' | 'updatedat' | 'version'\n>;\n```\n\nthe public entry view excludes `payload`, `ownerid`, and `leaseexpiresat`.\n\n \n\n### `postmasterstore`\n\n```ts\ninterface postmasterstore {\n transact<t>(fn: (tx: storetx) => promise<t>): promise<t>;\n list(filter?: entryfilter): promise<storedjob[]>;\n subscribe(listener: () => void): () => void;\n dispose(): promise<void>;\n readonly disposed: boolean;\n readonly disposalsignal: abortsignal;\n [symbol.asyncdispose](): promise<void>;\n}\n\ninterface storetx {\n get(id: string): promise<storedjob | undefined>;\n put(entry: storedjob): promise<void>;\n delete(id: string): promise<void>;\n findclaimable(now: number): promise<storedjob | undefined>;\n findnextwake(now: number): promise<number | undefined>;\n countbystatus(): promise<postmasterstats>;\n}\n```\n\nthe store exposes transactional primitives. the processor owns all ownership and transition logic — stores implement storage, not the job state machine. `transact` wraps all operations in an atomic transaction. `findclaimable` returns the earliest eligible job (queued with `availableat <= now`, or running with expired lease). `findnextwake` returns the earliest future wake time across queued and running jobs.\n\n \n\n### `postmasterevent`\n\n```ts\ntype postmasterevent =\n | { readonly type: 'enqueued' | 'started' | 'completed' | 'retry scheduled' | 'dead lettered'; readonly entry: postmasterentry }\n | { readonly type: 'removed' | 'lease lost'; readonly id: string }\n | { readonly type: 'processor error'; readonly error: error }\n | { readonly type: 'dispose' };\n```\n\n \n\n### `flushresult`\n\n```ts\ninterface flushresult {\n readonly processed: number;\n readonly completed: number;\n readonly deadlettered: number;\n readonly retryscheduled: number;\n}\n```\n\n \n\n### `retryresult` / `removeresult`\n\n```ts\ntype retryresult =\n | { readonly status: 'not found' | 'not dead letter' | 'running' }\n | { readonly status: 'retried'; readonly entry: postmasterentry };\n\ntype removeresult =\n | { readonly status: 'not found' | 'running' }\n | { readonly status: 'removed'; readonly id: string };\n```\n\n## errors\n\n### `postmastererror`\n\n```ts\nclass postmastererror extends error {\n constructor(message: string, options?: erroroptions);\n}\n```\n\nbase class for package defined errors. use `instanceof postmastererror` to narrow to the hierarchy. covers configuration errors, serialization errors, and store failures.\n\n \n\n### `postmasterdisposederror`\n\n```ts\nclass postmasterdisposederror extends postmastererror {}\n```\n\nthrown when a public method is called after disposal.\n\n \n\n### `postmasterjoberror`\n\n```ts\nclass postmasterjoberror extends postmastererror {}\n```\n\nthrown when a job definition is missing, a version is incompatible, or a migration fails. these errors move the job to dead letter rather than rejecting the public call.\n",
|
|
915
|
+
"usage": " \ntitle: postmaster — usage guide\ndescription: define durable jobs, process them with leases, retry failures, and recover dead letter work.\n \n\n[[toc]]\n\n## basic usage\n\ndefine typed jobs, create a durable store, enqueue work, and start the processor. dispose both handles when the owner ends.\n\n```ts\nimport { createpostmaster, definejobs } from '@vielzeug/postmaster';\nimport { createindexeddbpostmasterstore } from '@vielzeug/postmaster/indexeddb';\n\nconst jobs = definejobs({\n createtodo: {\n version: 1,\n validate: (v: unknown) => v as { id: string; title: string },\n key: (p) => p.id,\n execute: async (payload, { key, signal }) => {\n await fetch('/api/todos', {\n method: 'post',\n body: json.stringify(payload),\n headers: { 'idempotency key': key },\n signal,\n });\n },\n },\n});\n\nconst store = createindexeddbpostmasterstore({ name: 'my app outbox' });\nconst postmaster = createpostmaster({ jobs, store });\n\nawait postmaster.enqueue('createtodo', { id: crypto.randomuuid(), title: 'buy milk' });\nawait postmaster.start();\n\n// on page unload:\nawait postmaster.dispose();\nawait store.dispose();\n```\n\nthe store is borrowed by `createpostmaster()` and is not disposed with the processor. dispose both explicitly.\n\n## at least once delivery and idempotency\n\npostmaster provides **at least once delivery**. a crash after the remote write but before local completion can repeat the job. every job must derive a stable idempotency key, and handlers must send or otherwise enforce that key.\n\n```ts\nconst jobs = definejobs({\n createtodo: {\n version: 1,\n validate: (v: unknown) => v as { id: string; title: string },\n key: (p) => p.id,\n execute: async (payload, { key, signal }) => {\n await fetch('/api/todos', {\n method: 'post',\n body: json.stringify(payload),\n headers: { 'idempotency key': key },\n signal,\n });\n },\n },\n});\n```\n\nnever assume exactly once execution. design handlers so a repeated delivery is safe.\n\n## postmaster jobs vs courier mutations\n\ncourier performs immediate http requests and cache reconciliation. postmaster coordinates durable delivery. use courier inside a postmaster job when the write must survive reloads.\n\n```ts\nimport { createcourier, couriernetworkerror } from '@vielzeug/courier';\nimport { createpostmaster, definejobs } from '@vielzeug/postmaster';\n\nconst courier = createcourier({ baseurl: 'https://api.example.com' });\n\nconst jobs = definejobs({\n createtodo: {\n version: 1,\n validate: (v: unknown) => v as { id: string; title: string },\n key: (p) => p.id,\n execute: async (payload, { key, signal }) => {\n await courier.mutate({\n request: () =>\n courier.post('/todos', {\n body: payload,\n headers: { 'idempotency key': key },\n signal,\n }),\n invalidatekeys: [['todos']],\n });\n },\n retry: { maxattempts: 5, shouldretry: (error) => error instanceof couriernetworkerror },\n },\n});\n```\n\npostmaster does not import courier. the integration happens in your job definitions.\n\n## payload and version migration\n\neach job declares a `version` and an optional `validate` function. when a stored job's version is older than the registered version, postmaster calls `migrate()` before validating. `validate` is called once at enqueue; omit it to accept the payload as is. `validate` accepts a plain function `(value: unknown) => t` or any structural parser with `parse(value: unknown): t` — spell schemas work directly:\n\n```ts\nimport { s } from '@vielzeug/spell';\n\nconst jobs = definejobs({\n createtodo: {\n version: 2,\n validate: s.object({ id: s.string(), title: s.string(), priority: s.number().optional() }),\n key: (p) => p.id,\n migrate: (payload, fromversion) => {\n if (fromversion === 1) return { ...(payload as { id: string; title: string }), priority: 0 };\n return payload;\n },\n execute: async (payload, { key, signal }) => {\n await fetch('/api/todos', {\n method: 'post',\n body: json.stringify(payload),\n headers: { 'idempotency key': key },\n signal,\n });\n },\n },\n});\n```\n\nunknown job names, incompatible versions, failed migrations, and invalid persisted payloads move to dead letter rather than being executed.\n\n## retry semantics\n\nretries are opt in and explicitly classified. no `retry` block means one attempt followed by dead letter.\n\n```ts\nconst jobs = definejobs({\n synctodo: {\n version: 1,\n validate: (v: unknown) => v as { id: string },\n key: (p) => p.id,\n execute: async (payload, { signal }) => {\n await fetch(`/api/todos/${payload.id}/sync`, { signal });\n },\n retry: {\n maxattempts: 5,\n shouldretry: (error) => error instanceof typeerror, // network errors only\n },\n },\n});\n```\n\n `maxattempts` means total executions, including the first.\n `shouldretry` is required when retries are enabled. postmaster never guesses whether a write is safe to repeat.\n default delay uses arsenal's deterministic `backoff(attempt)` helper. override with `delay`.\n delay must be finite and non negative.\n lifecycle aborts caused by disposal are not classified as job failures.\n\n## dead letter recovery\n\njobs that exhaust retries or hit a terminal failure move to dead letter. inspect, retry, or remove them.\n\n```ts\nconst deadlettered = await postmaster.list({ status: 'dead letter' });\n\nfor (const entry of deadlettered) {\n console.log(entry.id, entry.name, entry.failure);\n}\n\n// retry a dead letter job back into the queue.\nawait postmaster.retry(entry.id);\n\n// or remove it permanently.\nawait postmaster.remove(entry.id);\n```\n\n`retry()` and `remove()` return discriminated results so callers can distinguish `not found`, `not dead letter`, `running`, and successful outcomes without exceptions.\n\n## lifecycle and disposal\n\n`start()` begins background processing. `dispose()` stops claiming new work, aborts owned work, and is idempotent. `flush()` processes every available job synchronously.\n\n```ts\nawait postmaster.start();\n// ...on unload\nawait postmaster.dispose();\nawait store.dispose();\n```\n\ndisposal aborts owned work, releases the active lease, and is idempotent. a controlled disposal abort does not consume the attempt — the job returns to queued.\n\n## events\n\ntap runtime events for observability. handler errors are swallowed — observability never affects processing.\n\n```ts\nconst unsubscribe = postmaster.tap((event) => {\n switch (event.type) {\n case 'enqueued':\n console.log('enqueued', event.entry.id);\n break;\n case 'completed':\n console.log('completed', event.entry.id);\n break;\n case 'dead lettered':\n console.error('dead lettered', event.entry.id, event.entry.failure);\n break;\n case 'processor error':\n console.error('processor error', event.error);\n break;\n }\n});\n```\n\npass an `abortsignal` to auto detach:\n\n```ts\nconst controller = new abortcontroller();\npostmaster.tap(handler, { signal: controller.signal });\ncontroller.abort(); // stops tapping\n```\n\n## testing\n\nuse the in memory store for deterministic tests.\n\n```ts\nimport { createpostmaster, definejobs } from '@vielzeug/postmaster';\nimport { creatememorypostmasterstore } from '@vielzeug/postmaster/testing';\n\nconst store = creatememorypostmasterstore();\nconst postmaster = createpostmaster({\n jobs: definejobs({\n send: {\n version: 1,\n validate: (v: unknown) => string(v),\n key: (p) => p,\n execute: async () => {},\n },\n }),\n store,\n});\n\nawait postmaster.enqueue('send', 'hello');\nawait postmaster.flush();\nawait postmaster.dispose();\n```\n\ninject a deterministic clock to control retry scheduling.\n\n```ts\nlet now = 0;\nconst postmaster = createpostmaster({ clock: () => now, jobs, store });\n```\n\n## framework integration\n\ncreate the postmaster after the component mounts, start processing, and dispose on unmount.\n\n::: code group\n\n```tsx [react]\nimport { createindexeddbpostmasterstore } from '@vielzeug/postmaster/indexeddb';\nimport { createpostmaster, definejobs, type postmaster } from '@vielzeug/postmaster';\nimport { useeffect } from 'react';\n\nconst jobs = definejobs({\n sync: {\n version: 1,\n validate: (v: unknown) => v as { id: string },\n key: (p) => p.id,\n execute: async (payload, { signal }) => {\n await fetch(`/api/sync/${payload.id}`, { signal });\n },\n },\n});\n\nexport function outboxprovider() {\n useeffect(() => {\n const store = createindexeddbpostmasterstore({ name: 'outbox' });\n const postmaster = createpostmaster({ jobs, store });\n void postmaster.start();\n\n return () => {\n void postmaster.dispose();\n void store.dispose();\n };\n }, []);\n\n return null;\n}\n```\n\n```vue [vue 3]\n<script setup lang=\"ts\">\nimport { createindexeddbpostmasterstore } from '@vielzeug/postmaster/indexeddb';\nimport { createpostmaster, definejobs } from '@vielzeug/postmaster';\nimport { onmounted, onunmounted } from 'vue';\n\nconst jobs = definejobs({\n sync: {\n version: 1,\n validate: (v: unknown) => v as { id: string },\n key: (p) => p.id,\n execute: async (payload, { signal }) => {\n await fetch(`/api/sync/${payload.id}`, { signal });\n },\n },\n});\n\nlet postmaster: returntype<typeof createpostmaster> | undefined;\nlet store: returntype<typeof createindexeddbpostmasterstore> | undefined;\n\nonmounted(() => {\n store = createindexeddbpostmasterstore({ name: 'outbox' });\n postmaster = createpostmaster({ jobs, store });\n void postmaster.start();\n});\n\nonunmounted(() => {\n void postmaster?.dispose();\n void store?.dispose();\n});\n</script>\n\n<template>\n <slot />\n</template>\n```\n\n```svelte [svelte]\n<script lang=\"ts\">\n import { createindexeddbpostmasterstore } from '@vielzeug/postmaster/indexeddb';\n import { createpostmaster, definejobs } from '@vielzeug/postmaster';\n import { onmount } from 'svelte';\n\n const jobs = definejobs({\n sync: {\n version: 1,\n validate: (v: unknown) => v as { id: string },\n key: (p) => p.id,\n execute: async (payload, { signal }) => {\n await fetch(`/api/sync/${payload.id}`, { signal });\n },\n },\n });\n\n onmount(() => {\n const store = createindexeddbpostmasterstore({ name: 'outbox' });\n const postmaster = createpostmaster({ jobs, store });\n void postmaster.start();\n\n return () => {\n void postmaster.dispose();\n void store.dispose();\n };\n });\n</script>\n\n<slot />\n```\n\n:::\n\n## working with other vielzeug libraries\n\n### postmaster + courier\n\nuse courier inside job handlers for http transport and cache invalidation. postmaster coordinates delivery; courier performs the request.\n\n```ts\nimport { createcourier, couriernetworkerror } from '@vielzeug/courier';\nimport { createpostmaster, definejobs } from '@vielzeug/postmaster';\n\nconst courier = createcourier({ baseurl: 'https://api.example.com' });\n\nconst jobs = definejobs({\n createtodo: {\n version: 1,\n validate: (v: unknown) => v as { id: string; title: string },\n key: (p) => p.id,\n execute: async (payload, { key, signal }) => {\n await courier.mutate({\n request: () =>\n courier.post('/todos', {\n body: payload,\n headers: { 'idempotency key': key },\n signal,\n }),\n invalidatekeys: [['todos']],\n });\n },\n retry: { maxattempts: 5, shouldretry: (e) => e instanceof couriernetworkerror },\n },\n});\n```\n\n### postmaster + sentinel\n\nflush the outbox when the network returns. sentinel reports online state; postmaster does the rest.\n\n```ts\nimport { createnetwork } from '@vielzeug/sentinel';\nimport { createpostmaster } from '@vielzeug/postmaster';\n\nconst network = createnetwork();\nconst postmaster = createpostmaster({ jobs, store });\n\nconst unsubscribe = network.subscribe(() => {\n if (network.value.online) void postmaster.flush();\n});\n\n// on teardown:\nunsubscribe();\nnetwork.dispose();\nawait postmaster.dispose();\n```\n\n### postmaster + vault\n\nthe indexeddb adapter is built on vault. use vault directly for unrelated storage; the postmaster store owns its own database name.\n\n## best practices\n\n **derive** a stable idempotency key from every job payload and send it with the remote write.\n **dispose** both the processor and the store explicitly; the processor does not own the store.\n **classify** retryable errors explicitly with `shouldretry`; never let postmaster guess.\n **migrate** persisted payloads when job versions change; test migrations against stored fixtures.\n **inspect** the dead letter queue regularly and retry or remove terminal failures.\n **avoid** persisting sensitive data in payloads or failure messages; indexeddb is per origin but not encrypted.\n **flush** the outbox when sentinel reports the network returns.\n **test** with the in memory store and a deterministic clock for reproducible retry timing.\n",
|
|
916
|
+
"examples": " \ntitle: postmaster — examples\ndescription: durable outbox recipes for offline mutations, network recovery, and dead letter handling.\n \n\n## examples\n\n [queue offline courier mutations](./examples/queue offline courier mutations.md)\n [resume when network returns](./examples/resume when network returns.md)\n [recover dead letter jobs](./examples/recover dead letter jobs.md)\n [service worker background sync](./examples/service worker background sync.md)\n"
|
|
917
|
+
},
|
|
918
|
+
"examples": [
|
|
919
|
+
{
|
|
920
|
+
"id": "define-jobs",
|
|
921
|
+
"text": "definejobs basic outbox import { createpostmaster, definejobs } from '@vielzeug/postmaster'\nimport { creatememorypostmasterstore } from '@vielzeug/postmaster/testing'\n\nconst jobs = definejobs({\n send: {\n version: 1,\n validate: (v) => string(v),\n key: (p) => `send:${p}`,\n execute: async (payload, { key, attempt }) => {\n console.log(`delivering \"${payload}\" (attempt ${attempt}, key ${key})`)\n },\n },\n})\n\nconst store = creatememorypostmasterstore()\nconst postmaster = createpostmaster({ jobs, store })\n\nawait postmaster.enqueue('send', 'hello')\nconst result = await postmaster.flush()\nconsole.log('flush result:', result)\nawait postmaster.dispose()"
|
|
922
|
+
}
|
|
923
|
+
],
|
|
924
|
+
"exports": "createpostmaster definejobs createindexeddbpostmasterstore creatememorypostmasterstore postmastererror postmasterdisposederror postmasterjoberror",
|
|
925
|
+
"keywords": "durable outbox jobs retry dead letter idempotency indexeddb lease",
|
|
926
|
+
"name": "@vielzeug/postmaster",
|
|
927
|
+
"related": "courier vault sentinel familiar ripple",
|
|
928
|
+
"slug": "postmaster",
|
|
929
|
+
"source": "export { definejobs } from './definitions.ts';\nexport { postmasterdisposederror, postmastererror, postmasterjoberror } from './errors.ts';\nexport { createpostmaster } from './postmaster.ts';\nexport type {\n createpostmasteroptions,\n entryfilter,\n entrystatus,\n flushresult,\n inferjobpayload,\n jobcontext,\n jobdefinition,\n jobdefinitions,\n jsonprimitive,\n jsonvalue,\n postmaster,\n postmasterentry,\n postmasterevent,\n postmasterstats,\n postmasterstore,\n removeresult,\n retrypolicy,\n retryresult,\n storedfailure,\n storedjob,\n storetx,\n validate,\n} from './types.ts';\n"
|
|
930
|
+
},
|
|
909
931
|
{
|
|
910
932
|
"category": "ui",
|
|
911
933
|
"description": "reactive svg charting library — line, bar, and area charts. signal driven updates, css themeable, accessible.",
|
|
@@ -927,9 +949,9 @@
|
|
|
927
949
|
"category": "websockets",
|
|
928
950
|
"description": "explicitly connected, typed websocket sessions with scoped channels, ref counted rooms with reactive presence, reconnect restoration, and heartbeat.",
|
|
929
951
|
"docs": {
|
|
930
|
-
"index": " \ntitle: pulse — typed websocket sessions\ndescription: explicitly connected, typed websocket sessions with scoped channels, ref counted rooms with reactive presence, reconnect restoration, and heartbeat.\npackage: pulse\ncategory: websockets\nkeywords: [websocket, realtime, channels, presence, rooms, reconnect, heartbeat, typed messaging, ripple]\nrelated: [herald, ripple, courier, clockwork]\nexports:\n [\n createpulse,\n pulse,\n pulsechannel,\n roomscope,\n roomscopebase,\n presenceroomscope,\n pulseoptions,\n pulseschema,\n channeldefinition,\n channeldefinitions,\n roomdefinition,\n roomdefinitions,\n roomoptions,\n outgoingmessage,\n outgoingtransform,\n pulseerror,\n pulseconnectionerror,\n pulsetimeouterror,\n pulseroomtimeouterror,\n pulseaborterror,\n pulsedisposederror,\n pulseprotocolerror,\n ]\nenvironments: [browser, node]\n \n\n<! markdownlint disable md025 md033 md060 >\n\n<packagehero package=\"pulse\" />\n\n## why pulse?\n\nnative websocket leaves connection ownership, event routing, reconnect restoration, and cleanup to each application. pulse provides those boundaries while making readiness explicit: applications connect before sending, and disconnected messages never disappear silently.\n\n```ts\n// before\nconst socket = new websocket('wss://api.example.com/ws');\nsocket.addeventlistener('message', (event) => route(json.parse(event.data)));\nsocket.addeventlistener('close', () => settimeout(() => reconnect(), 1_000));\n\n// after\nconst pulse = createpulse<{ server: { 'chat:message': { text: string } }; client: { 'chat:send': { text: string } } }>(\n 'wss://api.example.com/ws',\n { reconnect: true },\n);\ntry {\n await pulse.connect();\n pulse.on('chat:message', (message) => console.log(message.text));\n pulse.send('chat:send', { text: 'hello!' });\n} catch (error) {\n console.error('pulse connection failed:', error);\n}\n```\n\n| feature | pulse | native websocket | socket.io client |\n| | | | |\n| bundle size | <packageinfo package=\"pulse\" type=\"size\" /> | 0 b | ~44 kb gzip |\n| explicit readiness | <ore icon name=\"check\" size=\"16\"></ore icon> | manual | <ore icon name=\"check\" size=\"16\"></ore icon> |\n| session restoration | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> | protocol specific |\n| typed scoped channels | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> | basic |\n| typed rooms with presence | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> |\n| zero runtime dependencies | <ore icon name=\"triangle alert\" size=\"16\"></ore icon> ripple | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> |\n\n<div class=\"decision callout\">\n\n**use pulse when** you need a typed websocket session whose reconnect and cleanup behavior must be deterministic.\n\n**consider native websocket when** a single untyped connection does not need retry, routing, or session restoration.\n\n</div>\n\n## installation\n\n::: code group\n\n```sh [pnpm]\npnpm add @vielzeug/pulse @vielzeug/ripple\n```\n\n```sh [npm]\nnpm install @vielzeug/pulse @vielzeug/ripple\n```\n\n```sh [yarn]\nyarn add @vielzeug/pulse @vielzeug/ripple\n```\n\n:::\n\n## quick start\n\ndefine the protocol schema at construction time, create scopes, then connect before sending.\n\n```ts\nimport { createpulse } from '@vielzeug/pulse';\n\ntype schema = {\n server: { 'chat:message': { text: string } };\n client: { 'chat:send': { text: string } };\n channels: {\n chat: {\n client: { send: { text: string } };\n server: { message: { text: string } };\n };\n };\n rooms: {\n lobby: { presence: { name: string } };\n };\n};\n\nconst pulse = createpulse<schema>('wss://api.example.com/ws', {\n reconnect: true,\n
|
|
931
|
-
"api": " \ntitle: api — pulse\ndescription: complete api reference for pulse, including schema types, options, scopes, and error classes.\npackage: pulse\ncategory: websockets\n \n\n<! markdownlint disable md025 >\n\n[[toc]]\n\n## api overview\n\n| symbol | purpose | execution mode | common gotcha |\n| | | | |\n| `createpulse()` | create a typed websocket session instance. | sync (returns `pulse`) | does not open the connection — call `connect()`. |\n| `pulse` | main instance: channels, rooms, messaging, lifecycle. | sync methods, async `connect()`/`wait()` | `send()` throws while disconnected. |\n| `pulsechannel` | scoped channel namespace with independent disposal. | sync methods, async `wait()` | each call returns a new scope; ref counted subscription. |\n| `roomscope` | ref counted room membership with optional presence. | sync methods, async `joined` | `joined` rejects on transport close or timeout. |\n| `pulseschema` | declares server/client events, channels, and rooms. | type only | infer all named scope types from this schema. |\n| `pulseoptions` | configuration: heartbeat, reconnect, transform, onerror. | type only | `reconnect` and `heartbeat` default to `false`. |\n| `pulseerror` | base class for all pulse errors. | runtime | check `instanceof` against subclasses. |\n\n## package entry point\n\n| import | purpose |\n| | |\n| `@vielzeug/pulse` | all public exports: `createpulse`, types, and error classes. |\n\n## `createpulse()`\n\n```ts\nfunction createpulse<s extends pulseschema = pulseschema>(url: string, options?: pulseoptions): pulse<s>\n```\n\ncreates a pulse instance. the websocket is not opened until `connect()` is called.\n\n### type parameters\n\n| parameter | constraint | description |\n| | | |\n| `s` | `pulseschema` | schema declaring server events, client events, channels, and rooms. |\n\n### parameters\n\n| parameter | type | description |\n| | | |\n| `url` | `string` | websocket url. |\n| `options` | `pulseoptions` | optional configuration. |\n\n### returns\n\n`pulse<s>` — the pulse instance.\n\n \n\n## `pulseschema`\n\n```ts\ntype pulseschema = {\n server?: messagemap;\n client?: messagemap;\n channels?: channeldefinitions;\n rooms?: roomdefinitions;\n};\n```\n\ndeclare all protocol surfaces once at construction. named scopes infer their types from this schema.\n\n| field | type | description |\n| | | |\n| `server` | `messagemap` | root events the server sends. |\n| `client` | `messagemap` | root events the client sends. |\n| `channels` | `channeldefinitions` | named channel schemas. |\n| `rooms` | `roomdefinitions` | named room schemas with optional presence. |\n\n \n\n## `pulseoptions`\n\n```ts\ntype pulseoptions = {\n heartbeat?: boolean | heartbeatoptions;\n onerror?: (error: pulseerror) => void;\n protocols?: string | string[];\n reconnect?: boolean | reconnectoptions;\n transform?: outgoingtransform;\n};\n```\n\n| option | type | default | description |\n| | | | |\n| `heartbeat` | `boolean \\| heartbeatoptions` | `false` | ping/pong keep alive. |\n| `onerror` | `(error: pulseerror) => void` | — | receives typed transport and protocol errors. |\n| `protocols` | `string \\| string[]` | — | sub protocols passed to the websocket constructor. |\n| `reconnect` | `boolean \\| reconnectoptions` | `false` | auto reconnect on unexpected close. |\n| `transform` | `outgoingtransform` | — | transform or filter outgoing application messages. |\n\n \n\n## `heartbeatoptions`\n\n```ts\ntype heartbeatoptions = {\n interval?: number;\n timeout?: number;\n};\n```\n\n| option | type | default | description |\n| | | | |\n| `interval` | `number` | `30_000` | interval between pings in ms. |\n| `timeout` | `number` | `5_000` | how long to wait for a pong before treating the connection as dead. |\n\n \n\n## `reconnectoptions`\n\n```ts\ntype reconnectoptions = {\n delay?: number | ((attempt: number) => number);\n maxattempts?: number;\n};\n```\n\n| option | type | default | description |\n| | | | |\n| `delay` | `number \\| ((attempt: number) => number)` | full jitter exponential backoff capped at 30 s | delay between reconnect attempts in ms. `attempt` is zero based. |\n| `maxattempts` | `number` | `5` | maximum number of reconnect attempts after initial failure. |\n\n \n\n## `outgoingmessage`\n\n```ts\ntype outgoingmessage = { channel?: string; event: string; payload: unknown };\n```\n\nan outgoing application message before it is serialized.\n\n \n\n## `outgoingtransform`\n\n```ts\ntype outgoingtransform = (message: readonly<outgoingmessage>) => outgoingmessage | null;\n```\n\ntransform or filter outgoing application messages. internal protocol frames (subscribe, join, leave, presence, ping) bypass this hook. return `null` to drop the message.\n\n \n\n## `pulse`\n\n```ts\ntype pulse<s extends pulseschema = pulseschema> = {\n // channels\n channel<k extends keyof channelmap<s> & string>(\n name: k,\n ): pulsechannel<channelmap<s>[k]['server'], channelmap<s>[k]['client']>;\n\n // connection\n connect(): promise<void>;\n disconnect(code?: number, reason?: string): void;\n\n // lifecycle\n readonly disposalsignal: abortsignal;\n dispose(): void;\n readonly disposed: boolean;\n\n // messaging\n on<k extends eventkey<serverevents<s>>>(event: k, handler: (payload: serverevents<s>[k]) => void): unsubscribe;\n once<k extends eventkey<serverevents<s>>>(event: k, handler: (payload: serverevents<s>[k]) => void): unsubscribe;\n send<k extends eventkey<clientevents<s>>>(event: k, payload: clientevents<s>[k]): void;\n wait<k extends eventkey<serverevents<s>>>(event: k, opts?: { signal?: abortsignal; timeout?: number }): promise<serverevents<s>[k]>;\n\n // rooms\n room<k extends keyof roommap<s> & string>(name: k, opts?: roomoptions): roomscope<roommap<s>[k]>;\n readonly rooms: readable<readonlyset<string>>;\n\n // status\n readonly status: readable<pulsestatus>;\n\n [symbol.dispose](): void;\n};\n```\n\n### `channel(name)`\n\ncreates an isolated message namespace over the shared connection. each call returns an independently disposable scope. the server subscription is reference counted.\n\n### `connect()`\n\nexplicitly opens the connection. resolves after session restoration completes. rejects if the connection closes before opening.\n\n### `disconnect(code?, reason?)`\n\ncloses the connection without triggering reconnection. default code is `1000`.\n\n### `dispose()`\n\npermanently closes the connection and releases all resources. idempotent.\n\n### `on(event, handler)`\n\nsubscribes to a typed server event. returns an unsubscribe function.\n\n### `once(event, handler)`\n\nsubscribes once — auto removes after first invocation.\n\n### `send(event, payload)`\n\nsends a typed event to the server. throws `pulseconnectionerror` unless the connection is open.\n\n### `wait(event, opts?)`\n\nresolves on the next emission of the given server event. rejects when `opts.signal` aborts, the timeout elapses, or the instance is disposed.\n\n### `room(name, opts?)`\n\ncreates a ref counted room scope. the first scope sends `join`; the last disposal sends `leave`. when the room definition includes `presence`, the scope exposes reactive presence state.\n\n### `rooms`\n\nreactive set of rooms the client is currently a confirmed member of.\n\n### `status`\n\nreactive connection status: `'connecting' | 'open' | 'reconnecting' | 'closed'`.\n\n \n\n## `pulsechannel`\n\n```ts\ntype pulsechannel<tserver extends messagemap = messagemap, tclient extends messagemap = messagemap> = {\n readonly disposalsignal: abortsignal;\n readonly disposed: boolean;\n readonly name: string;\n dispose(): void;\n on<k extends eventkey<tserver>>(event: k, handler: (payload: tserver[k]) => void): unsubscribe;\n once<k extends eventkey<tserver>>(event: k, handler: (payload: tserver[k]) => void): unsubscribe;\n send<k extends eventkey<tclient>>(event: k, payload: tclient[k]): void;\n wait<k extends eventkey<tserver>>(event: k, opts?: { signal?: abortsignal; timeout?: number }): promise<tserver[k]>;\n [symbol.dispose](): void;\n};\n```\n\n \n\n## `roomscope`\n\n```ts\ntype roomscope<r extends roomdefinition = roomdefinition> = r extends { presence: infer p }\n ? p extends undefined\n ? roomscopebase\n : presenceroomscope<p>\n : roomscopebase;\n```\n\na room scope. when the room definition includes `presence`, the scope is a `presenceroomscope`; otherwise it is a `roomscopebase`.\n\n### `roomscopebase`\n\n```ts\ntype roomscopebase = {\n readonly disposalsignal: abortsignal;\n readonly disposed: boolean;\n readonly name: string;\n readonly joined: promise<void>;\n dispose(): void;\n [symbol.dispose](): void;\n};\n```\n\n### `presenceroomscope`\n\n```ts\ntype presenceroomscope<t = unknown> = roomscopebase & {\n readonly presence: readable<readonlymap<string, t>>;\n updatepresence(state: t): void;\n onjoin(handler: (memberid: string, state: t) => void): unsubscribe;\n onleave(handler: (memberid: string) => void): unsubscribe;\n};\n```\n\n| member | type | description |\n| | | |\n| `presence` | `readable<readonlymap<string, t>>` | reactive map of `memberid → state`. |\n| `updatepresence(state)` | `(state: t) => void` | broadcast this client's presence state. throws `pulseconnectionerror` unless open. |\n| `onjoin(handler)` | `(handler) => unsubscribe` | called whenever a new member joins with their initial state. |\n| `onleave(handler)` | `(handler) => unsubscribe` | called whenever a member leaves. |\n\n### `roomoptions`\n\n```ts\ntype roomoptions = {\n signal?: abortsignal;\n timeout?: number;\n};\n```\n\n| option | type | description |\n| | | |\n| `signal` | `abortsignal` | aborts the join, rejecting `joined` with `pulseaborterror`. |\n| `timeout` | `number` | join timeout in ms. rejects `joined` with `pulseroomtimeouterror`. |\n\n \n\n## errors\n\nall errors extend `pulseerror`.\n\n### `pulseerror`\n\nbase class for all pulse errors.\n\n### `pulseconnectionerror`\n\ntransport failure, send while disconnected, or room join rejected on close.\n\n### `pulseprotocolerror`\n\nmalformed frame or server error frame.\n\n### `pulsetimeouterror`\n\n`wait()` timed out before the server event arrived.\n\n### `pulseroomtimeouterror`\n\nroom scope `joined` timed out before the server confirmed membership.\n\n### `pulseaborterror`\n\n`wait()` or room `joined` aborted via abortsignal.\n\n### `pulsedisposederror`\n\noperation attempted after disposal.\n\n \n\n## channel and room definitions\n\n### `channeldefinition`\n\n```ts\ntype channeldefinition = { client: messagemap; server: messagemap };\n```\n\n### `channeldefinitions`\n\n```ts\ntype channeldefinitions = record<string, channeldefinition>;\n```\n\n### `roomdefinition`\n\n```ts\ntype roomdefinition = { presence?: unknown };\n```\n\n### `roomdefinitions`\n\n```ts\ntype roomdefinitions = record<string, roomdefinition>;\n```\n\n \n\n## utility types\n\n### `messagemap`\n\n```ts\ntype messagemap = record<string, unknown>;\n```\n\n### `eventkey`\n\n```ts\ntype eventkey<t extends messagemap> = keyof t & string;\n```\n\n### `serverevents`\n\n```ts\ntype serverevents<s extends pulseschema> = s extends { server: infer m extends messagemap } ? m : messagemap;\n```\n\nextract server events from a schema, defaulting to an empty map.\n\n### `clientevents`\n\n```ts\ntype clientevents<s extends pulseschema> = s extends { client: infer m extends messagemap } ? m : messagemap;\n```\n\nextract client events from a schema, defaulting to an empty map.\n\n### `roommap`\n\n```ts\ntype roommap<s extends pulseschema> = s extends { rooms: infer r extends roomdefinitions } ? r : roomdefinitions;\n```\n\nextract room definitions from a schema, defaulting to an empty map.\n\n### `unsubscribe`\n\n```ts\ntype unsubscribe = () => void;\n```\n\n### `pulsestatus`\n\n```ts\ntype pulsestatus = 'connecting' | 'open' | 'reconnecting' | 'closed';\n```\n",
|
|
932
|
-
"usage": " \ntitle: usage — pulse\ndescription: practical guide for connecting, sending, subscribing, joining rooms, and managing lifecycle with pulse.\npackage: pulse\ncategory: websockets\n \n\n<! markdownlint disable md025 >\n\n[[toc]]\n\n## basic usage\n\ndeclare server events, client events, channel schemas, and room schemas once at construction. named scopes infer their types from this schema.\n\n```ts\nimport { createpulse } from '@vielzeug/pulse';\n\ntype schema = {\n // root events the server sends\n server: { 'chat:message': { text: string }; notice: string };\n // root events the client sends\n client: { 'chat:send': { text: string } };\n // named channel scopes\n channels: {\n chat: {\n client: { send: { text: string } };\n server: { message: { text: string } };\n };\n alerts: {\n client: { subscribe: { topic: string } };\n server: { alert: { topic: string; severity: 'info' | 'warn' | 'error' } };\n };\n };\n // named room scopes with optional presence state\n rooms: {\n lobby: { presence: { name: string; color: string } };\n announcements: {};\n };\n};\n```\n\n## create and connect\n\n```ts\nconst pulse = createpulse<schema>('wss://api.example.com/ws', {\n reconnect: { delay: 1_000, maxattempts: 5 },\n heartbeat: { interval: 30_000, timeout: 5_000 },\n
|
|
952
|
+
"index": " \ntitle: pulse — typed websocket sessions\ndescription: explicitly connected, typed websocket sessions with scoped channels, ref counted rooms with reactive presence, reconnect restoration, and heartbeat.\npackage: pulse\ncategory: websockets\nkeywords: [websocket, realtime, channels, presence, rooms, reconnect, heartbeat, typed messaging, ripple]\nrelated: [herald, ripple, courier, clockwork]\nexports:\n [\n createpulse,\n pulse,\n pulsechannel,\n roomscope,\n roomscopebase,\n presenceroomscope,\n pulseoptions,\n pulseschema,\n channeldefinition,\n channeldefinitions,\n roomdefinition,\n roomdefinitions,\n roomoptions,\n outgoingmessage,\n outgoingtransform,\n pulseerror,\n pulseconnectionerror,\n pulsetimeouterror,\n pulseroomtimeouterror,\n pulseaborterror,\n pulsedisposederror,\n pulseprotocolerror,\n pulseevent,\n ]\nenvironments: [browser, node]\n \n\n<! markdownlint disable md025 md033 md060 >\n\n<packagehero package=\"pulse\" />\n\n## why pulse?\n\nnative websocket leaves connection ownership, event routing, reconnect restoration, and cleanup to each application. pulse provides those boundaries while making readiness explicit: applications connect before sending, and disconnected messages never disappear silently.\n\n```ts\n// before\nconst socket = new websocket('wss://api.example.com/ws');\nsocket.addeventlistener('message', (event) => route(json.parse(event.data)));\nsocket.addeventlistener('close', () => settimeout(() => reconnect(), 1_000));\n\n// after\nconst pulse = createpulse<{ server: { 'chat:message': { text: string } }; client: { 'chat:send': { text: string } } }>(\n 'wss://api.example.com/ws',\n { reconnect: true },\n);\ntry {\n await pulse.connect();\n pulse.on('chat:message', (message) => console.log(message.text));\n pulse.send('chat:send', { text: 'hello!' });\n} catch (error) {\n console.error('pulse connection failed:', error);\n}\n```\n\n| feature | pulse | native websocket | socket.io client |\n| | | | |\n| bundle size | <packageinfo package=\"pulse\" type=\"size\" /> | 0 b | ~44 kb gzip |\n| explicit readiness | <ore icon name=\"check\" size=\"16\"></ore icon> | manual | <ore icon name=\"check\" size=\"16\"></ore icon> |\n| session restoration | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> | protocol specific |\n| typed scoped channels | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> | basic |\n| typed rooms with presence | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> |\n| zero runtime dependencies | <ore icon name=\"triangle alert\" size=\"16\"></ore icon> ripple | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> |\n\n<div class=\"decision callout\">\n\n**use pulse when** you need a typed websocket session whose reconnect and cleanup behavior must be deterministic.\n\n**consider native websocket when** a single untyped connection does not need retry, routing, or session restoration.\n\n</div>\n\n## installation\n\n::: code group\n\n```sh [pnpm]\npnpm add @vielzeug/pulse @vielzeug/ripple\n```\n\n```sh [npm]\nnpm install @vielzeug/pulse @vielzeug/ripple\n```\n\n```sh [yarn]\nyarn add @vielzeug/pulse @vielzeug/ripple\n```\n\n:::\n\n## quick start\n\ndefine the protocol schema at construction time, create scopes, then connect before sending.\n\n```ts\nimport { createpulse } from '@vielzeug/pulse';\n\ntype schema = {\n server: { 'chat:message': { text: string } };\n client: { 'chat:send': { text: string } };\n channels: {\n chat: {\n client: { send: { text: string } };\n server: { message: { text: string } };\n };\n };\n rooms: {\n lobby: { presence: { name: string } };\n };\n};\n\nconst pulse = createpulse<schema>('wss://api.example.com/ws', {\n reconnect: true,\n});\npulse.tap((event) => {\n if (event.type === 'error') console.error(event.error);\n if (event.type === 'status change') console.log('status:', event.status);\n});\nconst chat = pulse.channel('chat');\nconst lobby = pulse.room('lobby');\n\ntry {\n await pulse.connect();\n chat.send('send', { text: 'hello!' });\n await lobby.joined;\n lobby.updatepresence({ name: 'ada' });\n} catch (error) {\n console.error('pulse connection failed:', error);\n}\n\npulse.dispose();\n```\n\n## features\n\n<div class=\"features grid\">\n\n **`connect()`** — explicit readiness; application messages throw while disconnected.\n **`channel()`** — named, schema bound scopes with independent disposal and reference counted server subscriptions.\n **`room()`** — named, schema bound ref counted room scopes with optional reactive presence. the first scope sends `join`; the last disposal sends `leave`.\n **`reconnect`** — ordered restoration of channel subscriptions, room memberships, and local presence state.\n **`transform`** — one synchronous transform or filter for application messages.\n **`tap()`** — subscribe to lifecycle events (status changes, errors, disposal) via a typed `pulseevent` stream.\n **`heartbeat`** — ping/pong liveness detection that uses the same reconnect controller.\n **`status` and `rooms`** — ripple readables for transport and confirmed membership state.\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/) — provides the reactive values exposed by pulse.\n [herald](/herald/) — receives routed pulse events in an in process application bus.\n [courier](/courier/) — handles request/response traffic alongside a pulse session.\n [clockwork](/clockwork/) — models application level authentication or session workflows.\n\n</div>\n\n<! markdownlint enable md025 md033 md060 >\n",
|
|
953
|
+
"api": " \ntitle: api — pulse\ndescription: complete api reference for pulse, including schema types, options, scopes, and error classes.\npackage: pulse\ncategory: websockets\n \n\n<! markdownlint disable md025 >\n\n[[toc]]\n\n## api overview\n\n| symbol | purpose | execution mode | common gotcha |\n| | | | |\n| `createpulse()` | create a typed websocket session instance. | sync (returns `pulse`) | does not open the connection — call `connect()`. |\n| `pulse` | main instance: channels, rooms, messaging, lifecycle. | sync methods, async `connect()`/`wait()` | `send()` throws while disconnected. |\n| `pulsechannel` | scoped channel namespace with independent disposal. | sync methods, async `wait()` | each call returns a new scope; ref counted subscription. |\n| `roomscope` | ref counted room membership with optional presence. | sync methods, async `joined` | `joined` rejects on transport close or timeout. |\n| `pulseschema` | declares server/client events, channels, and rooms. | type only | infer all named scope types from this schema. |\n| `pulseoptions` | configuration: heartbeat, reconnect, transform. | type only | `reconnect` and `heartbeat` default to `false`. |\n| `pulseerror` | base class for all pulse errors. | runtime | check `instanceof` against subclasses. |\n\n## package entry point\n\n| import | purpose |\n| | |\n| `@vielzeug/pulse` | all public exports: `createpulse`, types, and error classes. |\n\n## `createpulse()`\n\n```ts\nfunction createpulse<s extends pulseschema = pulseschema>(url: string, options?: pulseoptions): pulse<s>\n```\n\ncreates a pulse instance. the websocket is not opened until `connect()` is called.\n\n### type parameters\n\n| parameter | constraint | description |\n| | | |\n| `s` | `pulseschema` | schema declaring server events, client events, channels, and rooms. |\n\n### parameters\n\n| parameter | type | description |\n| | | |\n| `url` | `string` | websocket url. |\n| `options` | `pulseoptions` | optional configuration. |\n\n### returns\n\n`pulse<s>` — the pulse instance.\n\n \n\n## `pulseschema`\n\n```ts\ntype pulseschema = {\n server?: messagemap;\n client?: messagemap;\n channels?: channeldefinitions;\n rooms?: roomdefinitions;\n};\n```\n\ndeclare all protocol surfaces once at construction. named scopes infer their types from this schema.\n\n| field | type | description |\n| | | |\n| `server` | `messagemap` | root events the server sends. |\n| `client` | `messagemap` | root events the client sends. |\n| `channels` | `channeldefinitions` | named channel schemas. |\n| `rooms` | `roomdefinitions` | named room schemas with optional presence. |\n\n \n\n## `pulseoptions`\n\n```ts\ntype pulseoptions = {\n heartbeat?: boolean | heartbeatoptions;\n protocols?: string | string[];\n reconnect?: boolean | reconnectoptions;\n transform?: outgoingtransform;\n};\n```\n\n| option | type | default | description |\n| | | | |\n| `heartbeat` | `boolean \\| heartbeatoptions` | `false` | ping/pong keep alive. |\n| `protocols` | `string \\| string[]` | — | sub protocols passed to the websocket constructor. |\n| `reconnect` | `boolean \\| reconnectoptions` | `false` | auto reconnect on unexpected close. |\n| `transform` | `outgoingtransform` | — | transform or filter outgoing application messages. |\n\n \n\n## `heartbeatoptions`\n\n```ts\ntype heartbeatoptions = {\n interval?: number;\n timeout?: number;\n};\n```\n\n| option | type | default | description |\n| | | | |\n| `interval` | `number` | `30_000` | interval between pings in ms. |\n| `timeout` | `number` | `5_000` | how long to wait for a pong before treating the connection as dead. |\n\n \n\n## `reconnectoptions`\n\n```ts\ntype reconnectoptions = {\n delay?: number | ((attempt: number) => number);\n maxattempts?: number;\n};\n```\n\n| option | type | default | description |\n| | | | |\n| `delay` | `number \\| ((attempt: number) => number)` | full jitter exponential backoff capped at 30 s | delay between reconnect attempts in ms. `attempt` is zero based. |\n| `maxattempts` | `number` | `5` | maximum number of reconnect attempts after initial failure. |\n\n \n\n## `outgoingmessage`\n\n```ts\ntype outgoingmessage = { channel?: string; event: string; payload: unknown };\n```\n\nan outgoing application message before it is serialized.\n\n \n\n## `outgoingtransform`\n\n```ts\ntype outgoingtransform = (message: readonly<outgoingmessage>) => outgoingmessage | null;\n```\n\ntransform or filter outgoing application messages. internal protocol frames (subscribe, join, leave, presence, ping) bypass this hook. return `null` to drop the message.\n\n \n\n## `pulse`\n\n```ts\ntype pulse<s extends pulseschema = pulseschema> = {\n // channels\n channel<k extends keyof channelmap<s> & string>(\n name: k,\n ): pulsechannel<channelmap<s>[k]['server'], channelmap<s>[k]['client']>;\n\n // connection\n connect(): promise<void>;\n disconnect(code?: number, reason?: string): void;\n\n // lifecycle\n readonly disposalsignal: abortsignal;\n dispose(): void;\n readonly disposed: boolean;\n\n // messaging\n on<k extends eventkey<serverevents<s>>>(event: k, handler: (payload: serverevents<s>[k]) => void): unsubscribe;\n once<k extends eventkey<serverevents<s>>>(event: k, handler: (payload: serverevents<s>[k]) => void): unsubscribe;\n send<k extends eventkey<clientevents<s>>>(event: k, payload: clientevents<s>[k]): void;\n wait<k extends eventkey<serverevents<s>>>(event: k, opts?: { signal?: abortsignal; timeout?: number }): promise<serverevents<s>[k]>;\n\n // rooms\n room<k extends keyof roommap<s> & string>(name: k, opts?: roomoptions): roomscope<roommap<s>[k]>;\n readonly rooms: readable<readonlyset<string>>;\n\n // status\n readonly status: readable<pulsestatus>;\n\n // tap\n tap(handler: (event: pulseevent) => void, options?: { signal?: abortsignal }): () => void;\n\n [symbol.dispose](): void;\n};\n```\n\n### `channel(name)`\n\ncreates an isolated message namespace over the shared connection. each call returns an independently disposable scope. the server subscription is reference counted.\n\n### `connect()`\n\nexplicitly opens the connection. resolves after session restoration completes. rejects if the connection closes before opening.\n\n### `disconnect(code?, reason?)`\n\ncloses the connection without triggering reconnection. default code is `1000`.\n\n### `dispose()`\n\npermanently closes the connection and releases all resources. idempotent.\n\n### `on(event, handler)`\n\nsubscribes to a typed server event. returns an unsubscribe function.\n\n### `once(event, handler)`\n\nsubscribes once — auto removes after first invocation.\n\n### `send(event, payload)`\n\nsends a typed event to the server. throws `pulseconnectionerror` unless the connection is open.\n\n### `wait(event, opts?)`\n\nresolves on the next emission of the given server event. rejects when `opts.signal` aborts, the timeout elapses, or the instance is disposed.\n\n### `room(name, opts?)`\n\ncreates a ref counted room scope. the first scope sends `join`; the last disposal sends `leave`. when the room definition includes `presence`, the scope exposes reactive presence state.\n\n### `rooms`\n\nreactive set of rooms the client is currently a confirmed member of.\n\n### `status`\n\nreactive connection status: `'connecting' | 'open' | 'reconnecting' | 'closed'`.\n\n### `tap(handler, options?)`\n\nsubscribes to lifecycle events emitted by the pulse instance. the handler receives a discriminated union `pulseevent`. returns an unsubscribe function.\n\n| parameter | type | description |\n| | | |\n| `handler` | `(event: pulseevent) => void` | called for each lifecycle event. |\n| `options.signal` | `abortsignal` | optional signal to stop the subscription. |\n\n```ts\nconst pulse = createpulse(url, { reconnect: true });\npulse.tap((event) => {\n if (event.type === 'error') console.error(event.error);\n if (event.type === 'status change') console.log('status:', event.status);\n});\n```\n\n \n\n## `pulseevent`\n\n```ts\ntype pulseevent =\n | { type: 'status change'; status: pulsestatus }\n | { type: 'error'; error: pulseerror }\n | { type: 'dispose' };\n```\n\na discriminated union of lifecycle events emitted by a `pulse` instance. inspect `event.type` to narrow the payload.\n\n| `type` | payload | when |\n| | | |\n| `status change` | `status: pulsestatus` | the connection status transitions. |\n| `error` | `error: pulseerror` | a typed transport or protocol error occurs. |\n| `dispose` | — | the instance is disposed. |\n\n \n\n## `pulsechannel`\n\n```ts\ntype pulsechannel<tserver extends messagemap = messagemap, tclient extends messagemap = messagemap> = {\n readonly disposalsignal: abortsignal;\n readonly disposed: boolean;\n readonly name: string;\n dispose(): void;\n on<k extends eventkey<tserver>>(event: k, handler: (payload: tserver[k]) => void): unsubscribe;\n once<k extends eventkey<tserver>>(event: k, handler: (payload: tserver[k]) => void): unsubscribe;\n send<k extends eventkey<tclient>>(event: k, payload: tclient[k]): void;\n wait<k extends eventkey<tserver>>(event: k, opts?: { signal?: abortsignal; timeout?: number }): promise<tserver[k]>;\n [symbol.dispose](): void;\n};\n```\n\n \n\n## `roomscope`\n\n```ts\ntype roomscope<r extends roomdefinition = roomdefinition> = r extends { presence: infer p }\n ? p extends undefined\n ? roomscopebase\n : presenceroomscope<p>\n : roomscopebase;\n```\n\na room scope. when the room definition includes `presence`, the scope is a `presenceroomscope`; otherwise it is a `roomscopebase`.\n\n### `roomscopebase`\n\n```ts\ntype roomscopebase = {\n readonly disposalsignal: abortsignal;\n readonly disposed: boolean;\n readonly name: string;\n readonly joined: promise<void>;\n dispose(): void;\n [symbol.dispose](): void;\n};\n```\n\n### `presenceroomscope`\n\n```ts\ntype presenceroomscope<t = unknown> = roomscopebase & {\n readonly presence: readable<readonlymap<string, t>>;\n updatepresence(state: t): void;\n onjoin(handler: (memberid: string, state: t) => void): unsubscribe;\n onleave(handler: (memberid: string) => void): unsubscribe;\n};\n```\n\n| member | type | description |\n| | | |\n| `presence` | `readable<readonlymap<string, t>>` | reactive map of `memberid → state`. |\n| `updatepresence(state)` | `(state: t) => void` | broadcast this client's presence state. throws `pulseconnectionerror` unless open. |\n| `onjoin(handler)` | `(handler) => unsubscribe` | called whenever a new member joins with their initial state. |\n| `onleave(handler)` | `(handler) => unsubscribe` | called whenever a member leaves. |\n\n### `roomoptions`\n\n```ts\ntype roomoptions = {\n signal?: abortsignal;\n timeout?: number;\n};\n```\n\n| option | type | description |\n| | | |\n| `signal` | `abortsignal` | aborts the join, rejecting `joined` with `pulseaborterror`. |\n| `timeout` | `number` | join timeout in ms. rejects `joined` with `pulseroomtimeouterror`. |\n\n \n\n## errors\n\nall errors extend `pulseerror`.\n\n### `pulseerror`\n\nbase class for all pulse errors.\n\n### `pulseconnectionerror`\n\ntransport failure, send while disconnected, or room join rejected on close.\n\n### `pulseprotocolerror`\n\nmalformed frame or server error frame.\n\n### `pulsetimeouterror`\n\n`wait()` timed out before the server event arrived.\n\n### `pulseroomtimeouterror`\n\nroom scope `joined` timed out before the server confirmed membership.\n\n### `pulseaborterror`\n\n`wait()` or room `joined` aborted via abortsignal.\n\n### `pulsedisposederror`\n\noperation attempted after disposal.\n\n \n\n## channel and room definitions\n\n### `channeldefinition`\n\n```ts\ntype channeldefinition = { client: messagemap; server: messagemap };\n```\n\n### `channeldefinitions`\n\n```ts\ntype channeldefinitions = record<string, channeldefinition>;\n```\n\n### `roomdefinition`\n\n```ts\ntype roomdefinition = { presence?: unknown };\n```\n\n### `roomdefinitions`\n\n```ts\ntype roomdefinitions = record<string, roomdefinition>;\n```\n\n \n\n## utility types\n\n### `messagemap`\n\n```ts\ntype messagemap = record<string, unknown>;\n```\n\n### `eventkey`\n\n```ts\ntype eventkey<t extends messagemap> = keyof t & string;\n```\n\n### `serverevents`\n\n```ts\ntype serverevents<s extends pulseschema> = s extends { server: infer m extends messagemap } ? m : messagemap;\n```\n\nextract server events from a schema, defaulting to an empty map.\n\n### `clientevents`\n\n```ts\ntype clientevents<s extends pulseschema> = s extends { client: infer m extends messagemap } ? m : messagemap;\n```\n\nextract client events from a schema, defaulting to an empty map.\n\n### `roommap`\n\n```ts\ntype roommap<s extends pulseschema> = s extends { rooms: infer r extends roomdefinitions } ? r : roomdefinitions;\n```\n\nextract room definitions from a schema, defaulting to an empty map.\n\n### `unsubscribe`\n\n```ts\ntype unsubscribe = () => void;\n```\n\n### `pulsestatus`\n\n```ts\ntype pulsestatus = 'connecting' | 'open' | 'reconnecting' | 'closed';\n```\n",
|
|
954
|
+
"usage": " \ntitle: usage — pulse\ndescription: practical guide for connecting, sending, subscribing, joining rooms, and managing lifecycle with pulse.\npackage: pulse\ncategory: websockets\n \n\n<! markdownlint disable md025 >\n\n[[toc]]\n\n## basic usage\n\ndeclare server events, client events, channel schemas, and room schemas once at construction. named scopes infer their types from this schema.\n\n```ts\nimport { createpulse } from '@vielzeug/pulse';\n\ntype schema = {\n // root events the server sends\n server: { 'chat:message': { text: string }; notice: string };\n // root events the client sends\n client: { 'chat:send': { text: string } };\n // named channel scopes\n channels: {\n chat: {\n client: { send: { text: string } };\n server: { message: { text: string } };\n };\n alerts: {\n client: { subscribe: { topic: string } };\n server: { alert: { topic: string; severity: 'info' | 'warn' | 'error' } };\n };\n };\n // named room scopes with optional presence state\n rooms: {\n lobby: { presence: { name: string; color: string } };\n announcements: {};\n };\n};\n```\n\n## create and connect\n\n```ts\nconst pulse = createpulse<schema>('wss://api.example.com/ws', {\n reconnect: { delay: 1_000, maxattempts: 5 },\n heartbeat: { interval: 30_000, timeout: 5_000 },\n});\n\npulse.tap((event) => {\n if (event.type === 'error') console.error(event.error);\n if (event.type === 'status change') console.log('status:', event.status);\n});\n\ntry {\n await pulse.connect();\n} catch (error) {\n console.error('connection failed:', error);\n}\n```\n\n`connect()` opens the websocket and resolves after session restoration completes. `send()` throws `pulseconnectionerror` while disconnected — pulse never silently drops or buffers application messages.\n\n## send and receive root events\n\n```ts\npulse.on('chat:message', (message) => console.log(message.text));\npulse.send('chat:send', { text: 'hello!' });\n```\n\n## channels\n\neach `channel()` call returns an independently disposable scope. the server subscription is reference counted: the first scope sends `subscribe`, the last disposal sends `unsubscribe`.\n\n```ts\nconst chat = pulse.channel('chat');\n\nchat.on('message', (message) => console.log(message.text));\nchat.send('send', { text: 'hello!' });\n\n// later\nchat.dispose();\n```\n\nuse `using` for automatic cleanup:\n\n```ts\n{\n using chat = pulse.channel('chat');\n chat.on('message', (message) => console.log(message.text));\n} // chat.dispose() called automatically\n```\n\n## rooms and presence\n\neach `room()` call returns a ref counted room scope. the first scope sends `join`; the last disposal sends `leave`. when the room definition includes `presence`, the scope exposes reactive presence state.\n\n```ts\nconst lobby = pulse.room('lobby');\n\n// joined resolves when the server confirms membership\nawait lobby.joined;\n\n// reactive presence map: memberid → state\nlobby.onjoin((memberid, state) => console.log(`${memberid} joined: ${state.name}`));\nlobby.onleave((memberid) => console.log(`${memberid} left`));\n\n// broadcast your presence\nlobby.updatepresence({ name: 'ada', color: 'blue' });\n\n// read current presence\nfor (const [memberid, state] of lobby.presence.value) {\n console.log(`${memberid}: ${state.name}`);\n}\n\n// leave\nlobby.dispose();\n```\n\nplain rooms (without presence) work the same way but don't expose presence members:\n\n```ts\nconst announcements = pulse.room('announcements');\nawait announcements.joined;\nannouncements.dispose();\n```\n\n### room scope options\n\n```ts\n// timeout if the server doesn't confirm in time\nconst lobby = pulse.room('lobby', { timeout: 5_000 });\ntry {\n await lobby.joined;\n} catch (error) {\n console.error('join failed:', error);\n}\n\n// abort via abortsignal\nconst ctrl = new abortcontroller();\nconst lobby = pulse.room('lobby', { signal: ctrl.signal });\nctrl.abort(); // joined rejects with pulseaborterror, scope auto disposes\n```\n\n### reactive rooms set\n\n`pulse.rooms` is a ripple readable that tracks confirmed room memberships:\n\n```ts\nimport { effect } from '@vielzeug/ripple';\n\neffect(() => {\n console.log('joined rooms:', [...pulse.rooms.value]);\n});\n```\n\n## reconnect\n\nwhen the connection drops unexpectedly, pulse reconnects using the configured strategy. on reconnect, it restores:\n\n1. channel subscriptions (sends `subscribe` for each active channel).\n2. room memberships (sends `join` for each active room scope).\n3. local presence state (sends `presence` with the last successfully published state).\n\n```ts\nconst pulse = createpulse<schema>('wss://api.example.com/ws', {\n reconnect: {\n delay: (attempt) => math.min(1_000 * 2 ** attempt, 30_000),\n maxattempts: 5,\n },\n});\n```\n\n`joined` rejects on transport close. for post reconnect membership, read `pulse.rooms` instead.\n\n## heartbeat\n\n```ts\nconst pulse = createpulse<schema>('wss://api.example.com/ws', {\n heartbeat: { interval: 30_000, timeout: 5_000 },\n});\n```\n\npulse sends periodic pings. if a pong doesn't arrive before the timeout, it forces a reconnect using the same reconnect controller.\n\n## transform outgoing messages\n\n```ts\nconst pulse = createpulse<schema>('wss://api.example.com/ws', {\n transform: (message) => {\n // add a timestamp to all messages\n return { ...message, payload: { ...message.payload, ts: date.now() } };\n },\n});\n```\n\nreturn `null` to drop a message:\n\n```ts\nconst pulse = createpulse<schema>('wss://api.example.com/ws', {\n transform: (message) => (message.event === 'debug' ? null : message),\n});\n```\n\n## wait for a specific event\n\n```ts\nconst notice = await pulse.wait('notice', { timeout: 10_000 });\nconsole.log(notice);\n```\n\n## dispose\n\n```ts\npulse.dispose();\n```\n\ndisposal is idempotent. it closes the connection, rejects pending room joins, clears all listeners, and aborts all scope disposal signals.\n\n## error handling\n\n```ts\nconst pulse = createpulse<schema>('wss://api.example.com/ws', {\n reconnect: true,\n});\n\npulse.tap((event) => {\n if (event.type === 'error') {\n if (event.error instanceof pulseconnectionerror) {\n console.error('connection error:', event.error);\n } else if (event.error instanceof pulseprotocolerror) {\n console.error('protocol error:', event.error);\n }\n }\n});\n```\n\n| error | when |\n| | |\n| `pulseconnectionerror` | transport failure, send while disconnected, room join rejected on close. |\n| `pulseprotocolerror` | malformed frame or server error frame. |\n| `pulsetimeouterror` | `wait()` times out. |\n| `pulseroomtimeouterror` | room scope `joined` times out. |\n| `pulseaborterror` | `wait()` or room `joined` aborted via abortsignal. |\n| `pulsedisposederror` | operation attempted after disposal. |\n\n## best practices\n\n await `connect()` before sending; never assume construction opens the transport.\n define the full schema at `createpulse()` so named scopes are type safe without per call generics.\n use `using` declarations for channel and room scopes so disposal is automatic at block exit.\n always call `dispose()` when done — it closes the connection, rejects pending joins, and clears listeners.\n call `tap()` to observe lifecycle events; pulse reports transport and protocol errors there rather than throwing asynchronously.\n read `pulse.rooms` for post reconnect membership; `joined` rejects on transport close.\n set a `timeout` on room scopes when the server may never confirm membership.\n keep `transform` synchronous; resolve async policy decisions before calling `send()`.\n",
|
|
933
955
|
"examples": " \ntitle: examples — pulse\ndescription: practical examples for common pulse usage patterns.\npackage: pulse\ncategory: websockets\n \n\n<! markdownlint disable md025 >\n\n [basic connection](./examples/basic connection.md)\n [channel multiplexing](./examples/channels.md)\n [outgoing transform](./examples/middleware.md)\n [reconnect and heartbeat](./examples/reconnect and heartbeat.md)\n [rooms and presence](./examples/rooms and presence.md)\n"
|
|
934
956
|
},
|
|
935
957
|
"examples": [
|
|
@@ -939,27 +961,27 @@
|
|
|
939
961
|
},
|
|
940
962
|
{
|
|
941
963
|
"id": "connect-and-send",
|
|
942
|
-
"text": "connect & send import { createpulse } from '@vielzeug/pulse'\n\n// typed websocket client: on(), once(), send(), wait()\nconst pulse = createpulse('wss://api.example.com/ws', {\n reconnect: { maxattempts: 5 },\n
|
|
964
|
+
"text": "connect & send import { createpulse } from '@vielzeug/pulse'\n\n// typed websocket client: on(), once(), send(), wait()\nconst pulse = createpulse('wss://api.example.com/ws', {\n reconnect: { maxattempts: 5 },\n})\n\n// observe runtime events via tap()\npulse.tap((event) => {\n if (event.type === 'error') console.log('transport error:', event.error.message)\n})\n\n// subscribe before connecting — listeners are synchronous\nconst unsub = pulse.on('chat:message', ({ from, text }) => {\n console.log('[' + from + '] ' + text)\n})\n\n// one shot listener: fires once and auto removes\npulse.once('chat:message', (msg) => {\n console.log('first message:', msg.text)\n})\n\n// connect; send when open\ntry {\n await pulse.connect()\n pulse.send('chat:send', { text: 'hello, world!' })\n} catch (err) {\n console.log('connect failed:', err.message)\n}\n\n// await next server event with a 5 s deadline\ntry {\n const msg = await pulse.wait('chat:message', { timeout: 500 })\n console.log('received:', msg.text)\n} catch (err) {\n console.log('wait ended:', err.message)\n}\n\nunsub()\npulse.dispose()"
|
|
943
965
|
},
|
|
944
966
|
{
|
|
945
967
|
"id": "lifecycle",
|
|
946
|
-
"text": "lifecycle & disposal import { createpulse, pulsedisposederror } from '@vielzeug/pulse'\n\n// status signal, disposalsignal, and error handling on dispose\nconst pulse = createpulse('wss://api.example.com/ws', {\n reconnect: { delay: 1_000, maxattempts: 3 },\n heartbeat: { interval: 30_000, timeout: 5_000 },\n
|
|
968
|
+
"text": "lifecycle & disposal import { createpulse, pulsedisposederror } from '@vielzeug/pulse'\n\n// status signal, disposalsignal, and error handling on dispose\nconst pulse = createpulse('wss://api.example.com/ws', {\n reconnect: { delay: 1_000, maxattempts: 3 },\n heartbeat: { interval: 30_000, timeout: 5_000 },\n})\n\n// observe runtime events via tap()\npulse.tap((event) => {\n if (event.type === 'error') console.log('pulse error:', event.error.message)\n})\n\n// construction is closed. connect() makes the transport available.\nconsole.log('initial status:', pulse.status.value)\n\n// disposalsignal aborts when dispose() is called\npulse.disposalsignal.addeventlistener('abort', () => {\n console.log('disposal signal fired')\n})\n\ntry {\n await pulse.connect()\n console.log('connected:', pulse.status.value)\n} catch (err) {\n console.log('connect failed:', err.message)\n}\n\n// dispose() is idempotent — safe to call multiple times\npulse.dispose()\npulse.dispose()\nconsole.log('disposed:', pulse.disposed)\n\n// methods reject with pulsedisposederror after dispose\ntry {\n await pulse.connect()\n} catch (err) {\n if (err instanceof pulsedisposederror) {\n console.log('connect() rejected with pulsedisposederror — correct')\n }\n}"
|
|
947
969
|
},
|
|
948
970
|
{
|
|
949
971
|
"id": "reconnect",
|
|
950
|
-
"text": "reconnect & restoration import { createpulse, pulseconnectionerror } from '@vielzeug/pulse'\n\n// channels, rooms, and local presence state are restored on reconnect.\nconst pulse = createpulse('wss://api.example.com/ws', {\n reconnect: { delay: 500, maxattempts: 3 },\n
|
|
972
|
+
"text": "reconnect & restoration import { createpulse, pulseconnectionerror } from '@vielzeug/pulse'\n\n// channels, rooms, and local presence state are restored on reconnect.\nconst pulse = createpulse('wss://api.example.com/ws', {\n reconnect: { delay: 500, maxattempts: 3 },\n})\n\npulse.tap((event) => {\n if (event.type === 'error') console.log('transport error:', event.error.message)\n})\n\n// channel is tracked: re subscribed automatically after every reconnect\nconst chat = pulse.channel('chat')\nchat.on('message', ({ from, text }) => console.log(from + ': ' + text))\n\n// connect explicitly to observe the status\ntry {\n await pulse.connect()\n console.log('connected, status:', pulse.status.value)\n} catch (err) {\n if (err instanceof pulseconnectionerror) {\n console.log('connection failed:', err.message)\n }\n}\n\nconsole.log('channel name:', chat.name)\nconsole.log('channel disposed?', chat.disposed)\n\n// disposing a channel removes it from re subscription tracking\nchat.dispose()\nconsole.log('channel disposed, pulse still running:', !pulse.disposed)\n\npulse.dispose()"
|
|
951
973
|
},
|
|
952
974
|
{
|
|
953
975
|
"id": "rooms-presence",
|
|
954
976
|
"text": "rooms & presence import { createpulse } from '@vielzeug/pulse'\n\n// room scopes: ref counted membership with reactive presence\nconst pulse = createpulse('wss://api.example.com/ws')\nconst lobby = pulse.room('lobby')\n\ntry {\n await pulse.connect()\n\n // wait for server confirmation\n await lobby.joined\n console.log('joined lobby, rooms:', [...pulse.rooms.value])\n\n // broadcast our own presence\n lobby.updatepresence({ avatar: '/me.png', name: 'alice', status: 'online' })\n\n // reactive presence map: memberid → state\n const printmembers = () => {\n for (const [id, state] of lobby.presence.value) {\n console.log(' ' + id + ': ' + state.name + ' (' + state.status + ')')\n }\n }\n\n // react to individual joins and leaves\n lobby.onjoin((id, state) => console.log(state.name + ' joined'))\n lobby.onleave((id) => console.log(id + ' left'))\n} catch (err) {\n console.log('connection or room operation failed:', err.message)\n}\n\n// dispose the room scope — sends leave when last scope is released\nlobby.dispose()\nconsole.log('rooms after leave:', [...pulse.rooms.value])\n\npulse.dispose()"
|
|
955
977
|
}
|
|
956
978
|
],
|
|
957
|
-
"exports": "createpulse pulse pulsechannel roomscope roomscopebase presenceroomscope pulseoptions pulseschema channeldefinition channeldefinitions roomdefinition roomdefinitions roomoptions outgoingmessage outgoingtransform pulseerror pulseconnectionerror pulsetimeouterror pulseroomtimeouterror pulseaborterror pulsedisposederror pulseprotocolerror",
|
|
979
|
+
"exports": "createpulse pulse pulsechannel roomscope roomscopebase presenceroomscope pulseoptions pulseschema channeldefinition channeldefinitions roomdefinition roomdefinitions roomoptions outgoingmessage outgoingtransform pulseerror pulseconnectionerror pulsetimeouterror pulseroomtimeouterror pulseaborterror pulsedisposederror pulseprotocolerror pulseevent",
|
|
958
980
|
"keywords": "websocket realtime channels presence rooms reconnect heartbeat typed messaging ripple",
|
|
959
981
|
"name": "@vielzeug/pulse",
|
|
960
982
|
"related": "herald ripple courier clockwork",
|
|
961
983
|
"slug": "pulse",
|
|
962
|
-
"source": "export {\n pulseaborterror,\n pulseconnectionerror,\n pulsedisposederror,\n pulseerror,\n pulseprotocolerror,\n pulseroomtimeouterror,\n pulsetimeouterror,\n} from './errors';\nexport { createpulse } from './pulse';\nexport type {\n channeldefinition,\n channeldefinitions,\n clientevents,\n eventkey,\n heartbeatoptions,\n messagemap,\n outgoingmessage,\n outgoingtransform,\n presenceroomscope,\n pulse,\n pulsechannel,\n pulseoptions,\n pulseschema,\n pulsestatus,\n reconnectoptions,\n roomdefinition,\n roomdefinitions,\n roommap,\n roomoptions,\n roomscope,\n roomscopebase,\n serverevents,\n unsubscribe,\n} from './types';\n"
|
|
984
|
+
"source": "export {\n pulseaborterror,\n pulseconnectionerror,\n pulsedisposederror,\n pulseerror,\n pulseprotocolerror,\n pulseroomtimeouterror,\n pulsetimeouterror,\n} from './errors';\nexport { createpulse } from './pulse';\nexport type {\n channeldefinition,\n channeldefinitions,\n clientevents,\n eventkey,\n heartbeatoptions,\n messagemap,\n outgoingmessage,\n outgoingtransform,\n presenceroomscope,\n pulse,\n pulsechannel,\n pulseevent,\n pulseoptions,\n pulseschema,\n pulsestatus,\n reconnectoptions,\n roomdefinition,\n roomdefinitions,\n roommap,\n roomoptions,\n roomscope,\n roomscopebase,\n serverevents,\n unsubscribe,\n} from './types';\n"
|
|
963
985
|
},
|
|
964
986
|
{
|
|
965
987
|
"category": "ui components",
|
|
@@ -1099,9 +1121,9 @@
|
|
|
1099
1121
|
"category": "utilities",
|
|
1100
1122
|
"description": "trigram indexed fuzzy search with per field weights, match highlighting, and an optional reactive layer.",
|
|
1101
1123
|
"docs": {
|
|
1102
|
-
"index": " \ntitle: scout — fast fuzzy search for typescript\ndescription: trigram indexed fuzzy search with per field weights, match highlighting, and an optional reactive layer.\npackage: scout\ncategory: utilities\nkeywords: [fuzzy search, search, trigram, full text, filter, highlight, reactive, ripple]\nexports:\n [\n createindex,\n createreactivesearch,\n createsearch,\n scoutconfigurationerror,\n scoutdisposederror,\n scouterror,\n
|
|
1103
|
-
"api": " \ntitle: scout — api reference\ndescription: complete api reference for @vielzeug/scout — createindex, createreactivesearch, createsearch, highlight, highlightfield, tosearchmatcher, tofilterpredicate.\n \n\n[[toc]]\n\n## api overview\n\n| symbol | purpose | execution mode | common gotcha |\n| | | | |\n| `createindex()` | build trigram index from an item array | sync | index is built at call time — pass all initial items |\n| `scoutindex.search()` | query the index, returns scored + highlighted results | sync | empty query returns all items with `score = 1` |\n| `scoutindex.add()` | add one item to the index | sync | no op if same reference already indexed |\n| `scoutindex.remove()` | remove one item by reference | sync | no op for unknown references |\n| `scoutindex.reindex()` | re index a mutated item in place; preserves order | sync | call after mutating item properties; no op if not in index |\n| `scoutindex.setitems()` | reconcile a refreshed corpus in one mutation | sync | uses reference identity; duplicate references collapse |\n| `scoutindex.items` | all indexed items in insertion order | sync | returns a new array snapshot each call |\n| `scoutindex.revision` | monotonic counter incremented after each mutation | sync | use as a cache busting token for external result caches |\n| `scoutindex.onmutate()` | subscribe to changed index mutations | sync | a changed `setitems()` reconciliation emits once; no ops emit nothing |\n| `createsearch()` | reactive search state backed by a `scoutindex` | sync | requires `@vielzeug/ripple` — dispose when done |\n| `createreactivesearch()` | one call index + reactive search state | sync | exposes `.index` for incremental mutations |\n| `findmatchranges()` | compute match ranges for a text + query pair | sync | returns sorted, non overlapping `[start, end]` ranges |\n| `highlight()` | split text into highlighted/unhighlighted fragments | sync | ranges must be sorted and non overlapping |\n| `highlightfield()` | highlight a named field from a `searchresult` | sync | shorthand for the `matches.find(…).ranges → highlight()` pattern |\n| `tosearchmatcher()` | adapt `scoutindex` to sourcerer's `match` callback | sync | recomputes cached query matches after index mutation |\n| `tofilterpredicate()` | snapshot predicate from a one time query | sync | re call when query or corpus changes |\n| `segmentwords()` | split unsegmented script text (cjk, thai, ...) into words | sync | uses native `intl.segmenter` — not applied inside `tokenize()` itself (see pitfalls) |\n| `debugsearch()` | log a `searchstate`'s query/results transitions | sync | import from `@vielzeug/scout/devtools`, not the main entry point |\n\n## package entry point\n\n| import | purpose |\n| | |\n| `@vielzeug/scout` | all exports — index/search/highlighting/adapters, `scoutconfigurationerror`, `scoutdisposederror`, `scouterror`, and all types |\n| `@vielzeug/scout/devtools` | `debugsearch` — reactive search state logger (dev only) |\n\n \n\n## `createindex(items, options)`\n\nbuilds a trigram inverted index from `items`. construction is o(corpus × field_length); subsequent `search()` calls are o(candidates).\n\n```ts\nfunction createindex<t>(items: t[], options: scoutindexoptions<t>): scoutindex<t>\n```\n\n**parameters**\n\n| param | type | description |\n| | | |\n| `items` | `t[]` | initial corpus to index. |\n| `options.fields` | `readonlyarray<fielddef<t>>` | fields to index. required; at least one entry. |\n| `options.threshold` | `number` | finite overlap score in `0..1` (default `0.2`). |\n| `options.limit` | `number` | finite non negative integer max results (default `50`). |\n| `options.minquerylength` | `number` | finite positive integer min chars before trigram scoring; shorter queries use o(n) containment scan (default `3`). |\n\n**example**\n\n```ts\nimport { createindex } from '@vielzeug/scout';\n\nconst products = [\n { sku: 'wgt 001', title: 'widget pro' },\n { sku: 'gad 002', title: 'gadget plus' },\n];\n\nconst index = createindex(products, {\n fields: [\n { field: 'title', weight: 2 },\n { field: 'sku' },\n ],\n threshold: 0.25,\n limit: 20,\n});\n```\n\n \n\n## `scoutindex<t>`\n\nreturned by `createindex()`.\n\n### `.search(query, options?)`\n\n```ts\nsearch(query: string, options?: searchconstraints): searchresult<t>[]\n```\n\nreturns results sorted by score descending. empty query returns all items with `score = 1`. results below `threshold` are excluded; at most `limit` results are returned.\n\n```ts\nconst results = index.search('alice');\n// [{ item, score, matches }]\n```\n\n### `.add(item)`\n\nadds `item` to the index. no op if the same reference is already indexed. o(field_length).\n\n### `.remove(item)`\n\nremoves `item` by reference equality. no op if not found. o(field_length).\n\n### `.reindex(item)`\n\nre reads the item's current field values and rebuilds its index entry in place, updating only fields whose values changed. preserves insertion order. no op if the item is not in the index.\n\n```ts\nitem.name = 'new name';\nindex.reindex(item);\n```\n\n### `.setitems(items)`\n\n```ts\nsetitems(items: readonly t[]): void\n```\n\nreconciles the index to a refreshed corpus in one mutation. existing references are reindexed, missing references are removed, added references are indexed, and incoming first occurrence order becomes index order. duplicate references collapse to one item. calls `onmutate()` once when indexed values, membership, or order changes.\n\n```ts\nindex.setitems(latestusers);\n```\n\n### `.size`\n\n`number` — current number of indexed items.\n\n### `.items`\n\n`readonly t[]` — all indexed items in insertion order. returns a new array snapshot each call.\n\n```ts\nconst all = index.items;\n```\n\n### `.onmutate(listener)`\n\n```ts\nonmutate(listener: () => void): () => void\n```\n\nsubscribes `listener` to run after every changed `add()` / `remove()` / `reindex()` / `setitems()` operation. no ops, including unchanged bulk reconciliation, do not fire it. a changed `setitems()` reconciliation fires once. `createsearch()` uses this internally to keep `results` in sync with index mutations; most callers building on `createindex()` directly will not need it.\n\n```ts\nconst unsubscribe = index.onmutate(() => {\n console.log(`index changed — now ${index.size} items`);\n});\n\nindex.add(newuser); // logs \"index changed — now 6 items\"\nunsubscribe();\n```\n\n### `.revision`\n\n`number` — monotonically increasing counter, incremented after every changed `add()` / `remove()` / `reindex()` / `setitems()` operation. use as a cache busting token when caching search results outside the index — `tosearchmatcher()` uses it for this purpose.\n\n \n\n## `createsearch(index, options?)`\n\nwraps a `scoutindex` in a reactive search state powered by `@vielzeug/ripple` signals.\n\n```ts\nfunction createsearch<t>(index: scoutindex<t>, options?: createsearchoptions): searchstate<t>\n```\n\n**parameters**\n\n| param | type | description |\n| | | |\n| `options.debounce` | `number` | finite non negative integer milliseconds before query commit (default `200`). pass `0` for immediate updates. |\n| `options.limit` | `number` | finite non negative integer override of index level limit. |\n| `options.threshold` | `number` | finite `0..1` override of index level threshold. |\n| `options.minquerylength` | `number` | finite positive integer override of index level minimum query length. |\n\n**returns `searchstate<t>`**\n\n| member | type | description |\n| | | |\n| `query` | `signal<string>` | writable search query. set `.value` to trigger search. |\n| `results` | `readable<searchresult<t>[]>` | reactive results, updated after debounce. |\n| `issearching` | `readable<boolean>` | `true` during the debounce window. |\n| `disposalsignal` | `abortsignal` | aborted when `dispose()` is called. use to tie other lifecycles to this search. |\n| `disposed` | `boolean` | `true` after `dispose()` has been called. |\n| `clear()` | `() => void` | resets query, cancels debounce, clears results synchronously. |\n| `dispose()` | `() => void` | releases all reactive subscriptions. |\n| `[symbol.dispose]()` | `() => void` | `using` compatible disposal. |\n\n**example**\n\n```ts\nimport { createindex, createsearch } from '@vielzeug/scout';\nimport { effect } from '@vielzeug/ripple';\n\nconst users = [{ name: 'ada lovelace' }, { name: 'grace hopper' }];\nconst index = createindex(users, { fields: ['name'] });\nconst search = createsearch(index, { debounce: 150 });\n\neffect(() => {\n console.log(search.results.value.map((result) => result.item.name));\n});\n\nsearch.query.value = 'ada';\n```\n\n \n\n## `createreactivesearch(items, options)`\n\ncreates a `scoutindex` and a reactive `searchstate` in one call — the shorthand for `createindex` + `createsearch`. returns a `reactivesearch<t>` which extends `searchstate<t>` with a `.index` property for incremental mutations.\n\n```ts\nfunction createreactivesearch<t>(\n items: t[],\n options: scoutindexoptions<t> & { debounce?: number },\n): reactivesearch<t>\n```\n\n**parameters**\n\n| param | type | description |\n| | | |\n| `items` | `t[]` | initial corpus to index. |\n| `options.fields` | `readonlyarray<fielddef<t>>` | fields to index. required. |\n| `options.debounce` | `number` | finite non negative integer debounce milliseconds (default `200`). |\n| `options.threshold` | `number` | finite overlap score in `0..1` (default `0.2`). |\n| `options.limit` | `number` | finite non negative integer max results (default `50`). |\n| `options.minquerylength` | `number` | finite positive integer min chars before trigram scoring (default `3`). |\n\n**returns `reactivesearch<t>`** — all `searchstate<t>` members plus:\n\n| member | type | description |\n| | | |\n| `index` | `scoutindex<t>` | the underlying index for `add`, `remove`, `reindex`. |\n\n**example**\n\n```ts\nimport { createreactivesearch } from '@vielzeug/scout';\nimport { effect } from '@vielzeug/ripple';\n\nconst users = [{ email: 'ada@example.com', name: 'ada lovelace' }];\nconst search = createreactivesearch(users, {\n fields: [{ field: 'name', weight: 2 }, 'email'],\n debounce: 150,\n});\n\neffect(() => console.log(search.results.value.map((result) => result.item.name)));\n\nsearch.index.add({ email: 'grace@example.com', name: 'grace hopper' });\nsearch.dispose();\n```\n\n \n\n## `findmatchranges(text, query)`\n\nnormalizes raw `query` with scout's tokenizer, then computes sorted, non overlapping literal ranges for each normalized token within `text`. useful when you need to apply highlighting to a different string than the indexed field value (e.g. a truncated preview or a differently formatted display string).\n\n```ts\nfunction findmatchranges(text: string, query: string): [number, number][]\n```\n\n**example**\n\n```ts\nimport { findmatchranges, highlight } from '@vielzeug/scout';\n\nconst ranges = findmatchranges('alice johnson', 'alice!');\n// [[0, 5]]\n\nconst parts = highlight('alice johnson', ranges);\n// [{ text: 'alice', highlighted: true }, { text: ' johnson', highlighted: false }]\n```\n\nreturns an empty array if either `text` or `query` is empty.\n\n \n\n## `highlight(text, ranges)`\n\nsplits `text` into `highlightpart[]` fragments based on `ranges` from `fieldmatch.ranges`.\n\n```ts\nfunction highlight(text: string, ranges: [number, number][]): highlightpart[]\n```\n\n**example**\n\n```ts\nimport { highlight } from '@vielzeug/scout';\n\nhighlight('hello world', [[0, 5]]);\n// [{ text: 'hello', highlighted: true }, { text: ' world', highlighted: false }]\n```\n\nreturns an empty array when `text` is empty. returns a single unhighlighted part when `ranges` is empty.\n\n \n\n## `highlightfield(result, field, text)`\n\nconvenience shorthand that finds the match ranges for `field` in `result.matches` and calls `highlight()` in one step. eliminates the manual `result.matches.find(m => m.field === …).ranges` lookup.\n\n```ts\nfunction highlightfield<t>(result: searchresult<t>, field: keyof t & string, text: string): highlightpart[]\n```\n\n**example**\n\n```ts\nimport { createindex, highlightfield } from '@vielzeug/scout';\n\nconst users = [{ name: 'alice johnson' }];\nconst index = createindex(users, { fields: ['name'] });\n\nfor (const result of index.search('alice')) {\n const parts = highlightfield(result, 'name', result.item.name);\n console.log(parts.map((part) => part.highlighted ? `[${part.text}]` : part.text).join(''));\n}\n```\n\nwhen the field has no match (e.g. the query matched via a different field), returns a single unhighlighted part.\n\n \n\n## `tosearchmatcher(index, options?)`\n\nreturns an `(item, query) => boolean` matcher compatible with `sourcerer`'s `match` option.\n\n```ts\nfunction tosearchmatcher<t>(index: scoutindex<t>, options?: searchconstraints): (item: t, query: string) => boolean\n```\n\none matching item set is cached per query and index revision, so filtering does not repeat index work per item and stays current after index mutation.\n\n```ts\nimport { createindex, tosearchmatcher } from '@vielzeug/scout';\nimport { createlocalsource } from '@vielzeug/sourcerer';\n\nconst users = [{ email: 'ada@example.com', name: 'ada lovelace' }];\nconst index = createindex(users, { fields: ['name', 'email'] });\nconst source = createlocalsource(users, { match: tosearchmatcher(index) });\n```\n\n \n\n## `tofilterpredicate(index, query, options?)`\n\nreturns a `(item: t) => boolean` predicate computed from a one time query. use with `array.filter` or vault's `query.filter()`.\n\n```ts\nfunction tofilterpredicate<t>(\n index: scoutindex<t>,\n query: string,\n options?: searchconstraints,\n): (item: t) => boolean\n```\n\nthe predicate is a snapshot — re call `tofilterpredicate` if the query or corpus changes.\n\n```ts\nimport { createindex, tofilterpredicate } from '@vielzeug/scout';\n\nconst products = [{ title: 'widget pro' }, { title: 'gadget plus' }];\nconst index = createindex(products, { fields: ['title'] });\nconst results = products.filter(tofilterpredicate(index, 'widget'));\n\nconst top5 = products.filter(tofilterpredicate(index, 'widget', { limit: 5 }));\n```\n\n \n\n## `segmentwords(text)`\n\nsplits `text` into whitespace joined word segments using the runtime's native `intl.segmenter` — no dependency beyond the platform api. falls back to returning `text` unchanged where `intl.segmenter` isn't available.\n\n```ts\nfunction segmentwords(text: string): string\n```\n\n`tokenize()`'s trigram based scoring already works on unsegmented scripts (chinese, japanese, thai, ...) without this — trigrams are generated per character, not per word. `segmentwords()` is for `findmatchranges()` / highlighting and the multi word query semantics on `searchconstraints`, which assume space separated words. **not applied inside `tokenize()` itself** — benchmarked at ~15x slower than the plain regex path for the common whitespace delimited case, which would regress `createindex()`'s construction cost for every caller, not just those indexing unsegmented scripts.\n\n**example**\n\n```ts\nimport { createindex, segmentwords } from '@vielzeug/scout';\n\nconst documents = [{ title: '日本語を勉強しています' }];\nconst index = createindex(documents, {\n fields: [{ field: 'title', stringify: (value) => segmentwords(string(value)) }],\n});\n```\n\n \n\n## `debugsearch(search)` <badge type=\"tip\" text=\"@vielzeug/scout/devtools\" />\n\n```ts\ndebugsearch<t>(search: searchstate<t>): () => void\n```\n\nlogs `query` → `issearching` → `results` transitions of a `searchstate` to `console.debug`. returns a function that unsubscribes all listeners installed by this call. import from the dedicated sub path so it's tree shaken from production bundles.\n\n::: warning development only\nlogs the full, literal search query string — if your queries may carry pii (names, emails, medical/financial terms typed by end users), don't enable this in production.\n:::\n\n**example**\n\n```ts\nimport { createindex, createsearch } from '@vielzeug/scout';\nimport { debugsearch } from '@vielzeug/scout/devtools';\n\nconst index = createindex([{ name: 'ada lovelace' }], { fields: ['name'] });\nconst search = createsearch(index);\nconst stopdebugging = debugsearch(search);\n\nsearch.query.value = 'alice';\n// [scout:search] query > \"alice\"\n// [scout:search] issearching > true\n// [scout:search] issearching > false\n// [scout:search] results > 1 item(s)\n\nstopdebugging();\n```\n\n \n\n## types\n\n### `searchconstraints`\n\nshared search tuning knobs used by `scoutindexoptions`, `createsearchoptions`, and all search functions.\n\n```ts\ntype searchconstraints = {\n limit?: number; // finite non negative integer; default 50\n minquerylength?: number; // finite positive integer; default 3\n threshold?: number; // finite 0..1 value; default 0.2\n};\n```\n\n### `fielddef<t>`\n\n```ts\ntype fielddef<t> =\n | (keyof t & string)\n | {\n field: keyof t & string;\n weight?: number; // default 1\n stringify?: (value: unknown) => string;\n };\n```\n\n### `scoutindexoptions<t>`\n\n```ts\ntype scoutindexoptions<t> = searchconstraints & {\n fields: readonlyarray<fielddef<t>>;\n};\n```\n\n### `createsearchoptions`\n\n```ts\ntype createsearchoptions = searchconstraints & {\n debounce?: number; // finite non negative integer; default 200\n};\n```\n\n### `searchresult<t>`\n\n```ts\ntype searchresult<t> = {\n item: t;\n matches: fieldmatch<keyof t & string>[]; // literal normalized token ranges; may be empty for fuzzy only results\n score: number; // [0, 1]; 1 when query is empty\n};\n```\n\n### `fieldmatch<f>`\n\ngeneric over the union of field names — `match.field` is typed to the actual fields of `t`.\n\n```ts\ntype fieldmatch<f extends string = string> = {\n field: f;\n ranges: [number, number][]; // literal normalized token [start, end] ranges in original field value\n};\n```\n\n### `highlightpart`\n\n```ts\ntype highlightpart = {\n highlighted: boolean;\n text: string;\n};\n```\n\n### `searchstate<t>`\n\n```ts\ntype searchstate<t> = {\n readonly query: signal<string>;\n readonly results: readable<searchresult<t>[]>;\n readonly issearching: readable<boolean>;\n readonly disposalsignal: abortsignal;\n readonly disposed: boolean;\n clear(): void;\n dispose(): void;\n [symbol.dispose](): void;\n};\n```\n\nsee `createsearch()` above for member descriptions.\n\n### `reactivesearch<t>`\n\n```ts\ntype reactivesearch<t> = searchstate<t> & {\n readonly index: scoutindex<t>;\n};\n```\n\nsee `createreactivesearch()` above.\n\n \n\n## errors\n\n### `scouterror`\n\nbase class for all scout errors. use `instanceof scouterror` to catch any scout originated error.\n\n```ts\nclass scouterror extends error {}\n```\n\n**named subclasses**\n\n| class | thrown when |\n| | |\n| `scoutconfigurationerror` | an index, search, or reactive search receives invalid fields or numeric options |\n| `scoutdisposederror` | a method is called on a disposed `searchstate` instance |\n",
|
|
1104
|
-
"usage": " \ntitle: scout — usage guide\ndescription: how to guide for @vielzeug/scout — building indexes, reactive search, highlighting, and integrating with sourcerer and vault.\n \n\n[[toc]]\n\n## basic usage\n\n### building an index\n\npass your item array and field configuration to `createindex`. all items are indexed immediately at construction time.\n\n```ts\nimport { createindex } from '@vielzeug/scout';\n\nconst users = [\n { email: 'ada@example.com', name: 'ada lovelace' },\n { email: 'grace@example.com', name: 'grace hopper' },\n];\n\nconst index = createindex(users, {\n fields: ['name', 'email'],\n});\n```\n\n### searching\n\ncall `index.search(query)` with any string. results are sorted by score descending.\n\n```ts\nconst results = index.search('alice');\n\nfor (const { item, score, matches } of results) {\n console.log(item.name, score);\n}\n```\n\nan empty `query` returns all items with `score = 1`:\n\n```ts\nindex.search(''); // all items, score = 1 each\n```\n\n### per field weights\n\ngive fields different weights to control score ranking. a match on a high weight field ranks the item higher than a match on a low weight field.\n\n```ts\nconst index = createindex(users, {\n fields: [\n { field: 'name', weight: 3 }, // name matches rank 3× higher\n { field: 'department', weight: 1 },\n { field: 'bio', weight: 0.5 },\n ],\n});\n```\n\n### non string fields\n\nuse `stringify` to convert numeric or boolean fields to searchable text.\n\n```ts\nconst index = createindex(products, {\n fields: [\n 'title',\n { field: 'price', stringify: (v) => `$${v}` },\n { field: 'instock', stringify: (v) => (v ? 'available in stock' : 'out of stock') },\n ],\n});\n```\n\n### non latin scripts (cjk, thai, ...)\n\n`tokenize()` indexes any script correctly — trigrams are generated per character, so chinese, japanese, cyrillic, and accented latin text are all searchable out of the box. what it doesn't do is insert word boundaries for scripts that don't use spaces (chinese, japanese, thai, ...), which affects `findmatchranges()` / highlighting and multi word query semantics. pre segment those fields with `segmentwords()`:\n\n```ts\nimport { createindex, segmentwords } from '@vielzeug/scout';\n\nconst docs = [{ title: '日本語を勉強しています' }, { title: '我喜欢学习中文' }];\n\nconst index = createindex(docs, {\n fields: [{ field: 'title', stringify: (v) => segmentwords(string(v)) }],\n});\n\nindex.search('日本語'); // matches the first document\n```\n\n`segmentwords()` uses the runtime's native `intl.segmenter` — no dependency. it's opt in per field rather than built into `tokenize()` because it benchmarks ~15x slower than the default regex path for ordinary whitespace delimited text.\n\n### limiting results\n\npass `limit`, `threshold`, and `minquerylength` in options to control result count and quality. `limit` must be a finite non negative integer, `threshold` a finite value in `0..1`, and `minquerylength` a finite positive integer; invalid values throw `scoutconfigurationerror`.\n\n```ts\n// at most 10 results, minimum overlap score 0.3\nconst results = index.search('widget', { limit: 10, threshold: 0.3 });\n```\n\nper call options override the index level defaults set in `createindex`.\n\nscores come from the overlap (szymkiewicz–simpson) coefficient — the fraction of the *shorter*\ntrigram set (almost always the query) found in the longer one. this is deliberate for the\nautocomplete/command palette use case `createindex` targets: a short query that's a clean prefix\nof a much longer field value (e.g. `'fin'` against `'finalize q3 budget report'`) scores on how\nmuch of the query matched, not diluted by how much longer the target field happens to be.\n\n### controlling short query behaviour\n\nqueries shorter than `minquerylength` (default `3`) fall back to an o(n) substring containment scan. short query matches return `score = 1.0`.\n\n```ts\n// use trigram scoring even for 1 char queries (good for small corpora)\nconst index = createindex(items, { fields: ['name'], minquerylength: 1 });\n\n// force containment scan for all queries up to 8 chars (good for autocomplete on large sets)\nconst results = index.search('alice', { minquerylength: 8 });\n```\n\n## reactive search\n\n### `createreactivesearch()` — recommended\n\nfor most use cases, `createreactivesearch` builds the index and reactive state together in one call. it returns a `reactivesearch<t>` — a `searchstate<t>` with an extra `.index` property for incremental mutations:\n\n```ts\nimport { createreactivesearch } from '@vielzeug/scout';\nimport { effect } from '@vielzeug/ripple';\n\nconst search = createreactivesearch(users, {\n fields: [{ field: 'name', weight: 2 }, 'email'],\n debounce: 150,\n});\n\neffect(() => {\n if (search.issearching.value) showloadingspinner();\n else renderresults(search.results.value.map(r => r.item));\n});\n\ninput.addeventlistener('input', e => {\n search.query.value = e.currenttarget.value;\n});\n\n// add items at runtime via the exposed index\nsearch.index.add(newuser);\n\n// dispose when this owner is no longer needed\nsearch.dispose();\n```\n\n### `createsearch()` — separate index and state\n\nuse `createsearch` when you need to create the index independently — for example when sharing it across multiple reactive states:\n\n```ts\nimport { createindex, createsearch } from '@vielzeug/scout';\n\nconst index = createindex(users, { fields: ['name', 'email'] });\nconst search = createsearch(index, { debounce: 150 });\n```\n\n### `using` declaration\n\n```ts\n{\n using search = createreactivesearch(users, { fields: ['name'] });\n // search.dispose() called automatically at scope exit\n}\n```\n\n### zero debounce for synchronous updates\n\npass `debounce: 0` if you want results updated synchronously (no `issearching` flash). other debounce values must be finite non negative integers; invalid values throw `scoutconfigurationerror`.\n\n```ts\nconst search = createreactivesearch(users, { fields: ['name'], debounce: 0 });\n\nsearch.query.value = 'alice';\nconsole.log(search.results.value); // already updated\n```\n\n### resetting search\n\n```ts\nsearch.clear(); // resets query + results + issearching synchronously\n```\n\n### composing with ripple signals\n\n`search.results` is a `readable` signal — compose it into other computed values:\n\n```ts\nimport { computed } from '@vielzeug/ripple';\n\nconst topresult = computed(() => search.results.value[0]?.item ?? null);\n```\n\n## incremental updates\n\nuse `add()`, `remove()`, and `reindex()` for individual reference based mutations. use `setitems()` when a refreshed collection replaces the current corpus; scout reconciles membership, current field values, and source order in one notification.\n\n```ts\nconst index = createindex(products, { fields: ['title'] });\n\n// add a newly created item\nconst newproduct = { id: 99, title: 'new widget' };\nindex.add(newproduct);\n\n// remove a deleted item (by reference)\nindex.remove(products[0]);\n\n// re index a mutated item after in place mutation\nproducts[1].title = 'updated title';\nindex.reindex(products[1]);\n```\n\n> `remove()`, `reindex()`, and `setitems()` use **reference equality** (`===`). pass retained object references from the current corpus; `setitems()` collapses duplicate references.\n\n### replacing a refreshed corpus\n\n```ts\nconst latestproducts = await loadproducts();\n\nindex.setitems(latestproducts);\n```\n\n`setitems()` removes references absent from `latestproducts`, adds new references, reindexes retained references, and adopts the incoming order. it calls `onmutate()` once only when index membership, field values, or order changes.\n\n### inspecting the corpus\n\nuse `.items` to read all currently indexed items in insertion order, or `.size` for a count:\n\n```ts\nconsole.log(index.size); // 42\nconsole.log(index.items); // [{ id: 1, title: ... }, ...]\n```\n\n### reacting to mutations directly\n\n`createsearch()` already keeps `results` in sync with `add()`/`remove()`/`reindex()`/`setitems()` internally. `tosearchmatcher()` also invalidates its query cache after index mutation. if you're building your own reactivity on top of a plain `scoutindex` (no `ripple` involved), subscribe with `onmutate()`:\n\n```ts\nconst unsubscribe = index.onmutate(() => {\n rerenderresultslist();\n});\n\nindex.add(newproduct); // triggers rerenderresultslist()\n\nunsubscribe(); // when done\n```\n\n`onmutate()` only fires for mutations that actually change the index — a duplicate `add()` or a `remove()` of an unindexed item is a no op and doesn't notify listeners.\n\n## match highlighting\n\nevery `searchresult` carries `matches` — per field literal normalized token ranges. a fuzzy trigram candidate can have `matches: []` when no literal query token appears in its field text.\n\n### `highlightfield()` — recommended\n\n`highlightfield(result, field, text)` is the shorthand that does the field lookup and fragment split in one step:\n\n```ts\nimport { highlightfield } from '@vielzeug/scout';\n\nfor (const result of index.search('alice')) {\n const parts = highlightfield(result, 'name', result.item.name);\n // [{ text: 'alice', highlighted: true }, { text: ' johnson', highlighted: false }]\n renderhighlightedtext(parts);\n}\n```\n\n::: warning `part.text` is unescaped\n`highlight()` / `highlightfield()` return the **original, unescaped** field text split into\nfragments — never concatenate `part.text` into an html string for `innerhtml`. render each\npart as text (`textcontent`, a framework's text binding) and wrap `highlighted` parts in your\nown element:\n\n```ts\nfunction renderhighlightedtext(parts: highlightpart[]): documentfragment {\n const fragment = document.createdocumentfragment();\n\n for (const part of parts) {\n if (part.highlighted) {\n const mark = document.createelement('mark');\n\n mark.textcontent = part.text; // textcontent — never innerhtml\n fragment.appendchild(mark);\n } else {\n fragment.appendchild(document.createtextnode(part.text));\n }\n }\n\n return fragment;\n}\n```\n\n:::\n\n### `findmatchranges()` + `highlight()` — manual\n\nuse `findmatchranges()` when you need to apply match ranges to a different string than the indexed field value — for example a truncated preview or a differently formatted display string:\n\n```ts\nimport { findmatchranges, highlight } from '@vielzeug/scout';\n\nconst [result] = index.search('alice');\nconst preview = result.item.bio.slice(0, 100);\nconst ranges = findmatchranges(preview, 'alice');\nconst parts = highlight(preview, ranges);\n```\n\nor use `highlight()` directly when you already have the ranges from `result.matches`:\n\n```ts\nconst [result] = index.search('alice');\nconst namematch = result.matches.find(m => m.field === 'name');\nconst parts = highlight(result.item.name, namematch?.ranges ?? []);\n```\n\n## debug logging\n\nimport `debugsearch` from the dedicated `/devtools` sub path to log a `searchstate`'s `query` → `issearching` → `results` transitions to `console.debug`. the sub path is tree shaken from production bundles when not imported.\n\n::: warning development only\n`debugsearch()` logs the full, literal search query string — if your queries may carry pii (names, emails, medical/financial terms typed by end users), don't enable this in production.\n:::\n\n```ts\nimport { debugsearch } from '@vielzeug/scout/devtools';\n\nconst search = createsearch(index, { debounce: 150 });\nconst stopdebugging = debugsearch(search);\n\nsearch.query.value = 'alice';\n// [scout:search] query > \"alice\"\n// [scout:search] issearching > true\n// [scout:search] issearching > false\n// [scout:search] results > 1 item(s)\n\nstopdebugging();\n```\n\n## framework integration\n\n::: code group\n\n```tsx [react]\nimport { createreactivesearch } from '@vielzeug/scout';\nimport { useeffect, useref, usesyncexternalstore } from 'react';\n\ntype user = { id: number; name: string; email: string };\n\nfunction usescoutsearch(items: user[]) {\n const ref = useref(\n createreactivesearch(items, {\n fields: [{ field: 'name', weight: 2 }, 'email'],\n debounce: 150,\n }),\n );\n\n const search = ref.current;\n\n const results = usesyncexternalstore(\n (cb) => search.results.subscribe(cb),\n () => search.results.value,\n );\n\n useeffect(() => () => search.dispose(), [search]);\n\n return { query: search.query, results };\n}\n```\n\n```ts [vue 3]\nimport { createreactivesearch } from '@vielzeug/scout';\nimport { onscopedispose, ref, watch } from 'vue';\n\ntype user = { id: number; name: string; email: string };\n\nfunction usescoutsearch(items: user[]) {\n const search = createreactivesearch(items, {\n fields: [{ field: 'name', weight: 2 }, 'email'],\n debounce: 150,\n });\n\n const query = ref('');\n const results = ref(search.results.value);\n\n const unsub = search.results.subscribe(() => {\n results.value = search.results.value;\n });\n\n watch(query, (q) => { search.query.value = q; });\n\n onscopedispose(() => { unsub(); search.dispose(); });\n\n return { query, results };\n}\n```\n\n```svelte [svelte]\n<script lang=\"ts\">\n import { createreactivesearch } from '@vielzeug/scout';\n import { ondestroy } from 'svelte';\n\n type user = { id: number; name: string; email: string };\n\n export let items: user[];\n\n const search = createreactivesearch(items, {\n fields: [{ field: 'name', weight: 2 }, 'email'],\n debounce: 150,\n });\n\n let query = '';\n let results = search.results.value;\n\n const unsub = search.results.subscribe(() => {\n results = search.results.value;\n });\n\n $: search.query.value = query;\n\n ondestroy(() => { unsub(); search.dispose(); });\n</script>\n\n<input bind:value={query} placeholder=\"search…\" />\n{#each results as { item }}\n <p>{item.name}</p>\n{/each}\n```\n\n:::\n\n## working with other vielzeug libraries\n\n### with sourcerer\n\n`tosearchmatcher()` adapts a `scoutindex` to `createlocalsource`'s explicit `match` callback. scout decides which items match; sourcerer keeps source query and pagination.\n\n```ts\nimport { createindex, tosearchmatcher } from '@vielzeug/scout';\nimport { createlocalsource } from '@vielzeug/sourcerer';\n\nconst index = createindex(users, {\n fields: [{ field: 'name', weight: 2 }, 'email'],\n});\n\nconst source = createlocalsource(users, {\n match: tosearchmatcher(index),\n});\n\nsource.setquery({ search: 'alice' });\n```\n\n> keep the index in sync using `index.add()` / `index.remove()` / `index.reindex()`.\n\n### with vault\n\n`tofilterpredicate()` returns an `(item: t) => boolean` snapshot predicate — pass it to vault's `query.filter()` or plain `array.filter`.\n\n```ts\nimport { createindex, tofilterpredicate } from '@vielzeug/scout';\n\nconst index = createindex(products, { fields: ['title', 'sku'] });\n\nconst matching = products.filter(tofilterpredicate(index, 'widget'));\n\nconst rows = await db.query('products')\n .filter(tofilterpredicate(index, searchterm))\n .toarray();\n```\n\ncall `tofilterpredicate` again whenever the query or corpus changes — the predicate is a snapshot, not reactive.\n\n## best practices\n\n **build the index once** — `createindex()` runs in o(corpus × field_length). create it at module level or in an effect, not inside render loops.\n **keep the index in sync** — call `index.add()` / `remove()` / `reindex()` when items mutate. stale index entries return wrong scores.\n **tune threshold before limit** — set a meaningful `threshold` (e.g. `0.25–0.4`) to suppress noise, then use `limit` to cap the list length.\n **set `minquerylength` for your corpus size** — the default `3` works well for most cases. lower it for small corpora where single char queries are expected; raise it for large corpora to avoid expensive o(n) scans.\n **dispose reactive state** — always call `search.dispose()` or use `using` when the component unmounts.\n **weight by importance** — name/title fields should have weight `2–3`; secondary fields (description, tags) stay at `1`.\n **segment cjk/thai fields explicitly** — `segmentwords()` is opt in per field, not automatic, to keep `createindex()` fast for the common whitespace delimited case.\n",
|
|
1124
|
+
"index": " \ntitle: scout — fast fuzzy search for typescript\ndescription: trigram indexed fuzzy search with per field weights, match highlighting, and an optional reactive layer.\npackage: scout\ncategory: utilities\nkeywords: [fuzzy search, search, trigram, full text, filter, highlight, reactive, ripple]\nexports:\n [\n createindex,\n createreactivesearch,\n createsearch,\n scoutconfigurationerror,\n scoutdisposederror,\n scouterror,\n scoutevent,\n findmatchranges,\n highlight,\n highlightfield,\n segmentwords,\n tofilterpredicate,\n tosearchmatcher,\n ]\nrelated: [arsenal, sourcerer, vault, ripple]\nenvironments: [browser, node, ssr, deno]\n \n\n<! markdownlint disable md025 md033 md060 >\n\n<packagehero package=\"scout\" />\n\n## why scout?\n\narsenal's `fuzzy` / `fuzzyfilter` helpers perform pairwise levenshtein distance — o(n·m) per item per query. for ≤200 items they are fine. for 500–100k items with real time keystrokes, you need an index.\n\nscout builds a **trigram inverted index** at construction time. query time scores only items sharing a trigram with the query; broad queries can still approach o(n), while selective queries avoid scoring the whole corpus.\n\n```ts\n// before\nconst matches = users.filter((user) => user.name.tolowercase().includes(query.tolowercase()));\n\n// after\nimport { createindex } from '@vielzeug/scout';\n\nconst index = createindex(users, { fields: ['name', 'email'] });\nconst matches = index.search(query);\n```\n\n| feature | arsenal `fuzzy*` | scout `createindex` | fuse.js |\n| | | | |\n| bundle size | ~3 kb | <packageinfo package=\"scout\" type=\"size\" /> | ~23 kb |\n| zero dependencies | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> `@vielzeug/ripple` runtime dependency | <ore icon name=\"check\" size=\"16\"></ore icon> |\n| algorithm | levenshtein | trigram + overlap coefficient | bitap |\n| query time | o(n·m) | o(candidates) | o(n·m) |\n| stateful index | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> |\n| match highlighting | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> |\n| reactive layer | <ore icon name=\"x\" size=\"16\"></ore icon> | ripple signals + debounce | <ore icon name=\"x\" size=\"16\"></ore icon> |\n| incremental updates | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> | partial |\n\n<div class=\"decision callout\">\n\n**use scout when** you need search over 500+ items, real time ui search boxes (combobox, command palette), or reactive query state with ripple signals.\n\n**consider `arsenal.fuzzyfilter` when** you have fewer than 200 items and don't need a persistent index.\n\n</div>\n\n## installation\n\n::: code group\n\n```sh [pnpm]\npnpm add @vielzeug/scout\n```\n\n```sh [npm]\nnpm install @vielzeug/scout\n```\n\n```sh [yarn]\nyarn add @vielzeug/scout\n```\n\n:::\n\n## quick start\n\n```ts\nimport { createindex } from '@vielzeug/scout';\n\nconst users = [\n { email: 'ada@example.com', name: 'ada lovelace' },\n { email: 'grace@example.com', name: 'grace hopper' },\n];\n\nconst index = createindex(users, {\n fields: [\n { field: 'name', weight: 2 },\n { field: 'email' },\n ],\n});\n\nconst results = index.search('ada');\nconsole.log(results[0]?.item.name); // ada lovelace\n```\n\n## features\n\n<div class=\"features grid\">\n\n `createindex()` — trigram inverted index; construction o(corpus × field_length), query o(candidates)\n per field weights — promote `name` matches over secondary fields; finite positive weights and custom `stringify` functions supported\n `createreactivesearch()` — index + reactive `searchstate` in one call; `.index` for incremental mutations\n `createsearch()` — reactive search state backed by an existing `scoutindex`; share one index across many states\n `highlight()` / `highlightfield()` — split field text into `highlightpart[]` fragments for styled rendering\n `findmatchranges()` — compute match ranges for custom display strings (truncated previews, formatted values)\n `tosearchmatcher()` — matcher adapter for sourcerer's `localsource`\n `tofilterpredicate()` — snapshot `(item: t) => boolean` predicate for `array.filter` or vault queries\n `setitems()` — reconcile a refreshed corpus by reference, preserve incoming order, and notify once\n incremental updates — `add()` / `remove()` / `reindex()` patch individual items in o(field_length)\n `onmutate()` — subscribe to index mutations; powers `createsearch()`'s reactivity and bulk reconciliation\n `segmentwords()` — split unsegmented script text (cjk, thai, ...) into words via native `intl.segmenter`\n event subscription via `search.tap()` — observe `query`/`issearching`/`results`/`dispose` transitions; returns an unsubscribe function\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 [arsenal](/arsenal/) — use `fuzzyfilter` for ad hoc filtering of small lists (< 200 items) without building an index\n [ripple](/ripple/) — `createreactivesearch()` and `createsearch()` use ripple signals for reactive query state and debounce\n [sourcerer](/sourcerer/) — use a `scoutindex` inside `createlocalsource`'s explicit `match` callback\n [vault](/vault/) — `tofilterpredicate()` wraps a one time scout query as a vault compatible `filter()` predicate\n\n</div>\n\n<! markdownlint enable md025 md033 md060 >\n",
|
|
1125
|
+
"api": " \ntitle: scout — api reference\ndescription: complete api reference for @vielzeug/scout — createindex, createreactivesearch, createsearch, highlight, highlightfield, tosearchmatcher, tofilterpredicate.\n \n\n[[toc]]\n\n## api overview\n\n| symbol | purpose | execution mode | common gotcha |\n| | | | |\n| `createindex()` | build trigram index from an item array | sync | index is built at call time — pass all initial items |\n| `scoutindex.search()` | query the index, returns scored + highlighted results | sync | empty query returns all items with `score = 1` |\n| `scoutindex.add()` | add one item to the index | sync | no op if same reference already indexed |\n| `scoutindex.remove()` | remove one item by reference | sync | no op for unknown references |\n| `scoutindex.reindex()` | re index a mutated item in place; preserves order | sync | call after mutating item properties; no op if not in index |\n| `scoutindex.setitems()` | reconcile a refreshed corpus in one mutation | sync | uses reference identity; duplicate references collapse |\n| `scoutindex.items` | all indexed items in insertion order | sync | returns a new array snapshot each call |\n| `scoutindex.revision` | monotonic counter incremented after each mutation | sync | use as a cache busting token for external result caches |\n| `scoutindex.onmutate()` | subscribe to changed index mutations | sync | a changed `setitems()` reconciliation emits once; no ops emit nothing |\n| `createsearch()` | reactive search state backed by a `scoutindex` | sync | requires `@vielzeug/ripple` — dispose when done |\n| `createreactivesearch()` | one call index + reactive search state | sync | exposes `.index` for incremental mutations |\n| `findmatchranges()` | compute match ranges for a text + query pair | sync | returns sorted, non overlapping `[start, end]` ranges |\n| `highlight()` | split text into highlighted/unhighlighted fragments | sync | ranges must be sorted and non overlapping |\n| `highlightfield()` | highlight a named field from a `searchresult` | sync | shorthand for the `matches.find(…).ranges → highlight()` pattern |\n| `tosearchmatcher()` | adapt `scoutindex` to sourcerer's `match` callback | sync | recomputes cached query matches after index mutation |\n| `tofilterpredicate()` | snapshot predicate from a one time query | sync | re call when query or corpus changes |\n| `segmentwords()` | split unsegmented script text (cjk, thai, ...) into words | sync | uses native `intl.segmenter` — not applied inside `tokenize()` itself (see pitfalls) |\n| `searchstate.tap()` | subscribe to `query`/`issearching`/`results`/`dispose` events | sync | returns an unsubscribe function; pass `{ signal }` to tie to an external lifecycle |\n\n## package entry point\n\n| import | purpose |\n| | |\n| `@vielzeug/scout` | all exports — index/search/highlighting/adapters, `scoutconfigurationerror`, `scoutdisposederror`, `scouterror`, `scoutevent`, and all types |\n\n \n\n## `createindex(items, options)`\n\nbuilds a trigram inverted index from `items`. construction is o(corpus × field_length); subsequent `search()` calls are o(candidates).\n\n```ts\nfunction createindex<t>(items: t[], options: scoutindexoptions<t>): scoutindex<t>\n```\n\n**parameters**\n\n| param | type | description |\n| | | |\n| `items` | `t[]` | initial corpus to index. |\n| `options.fields` | `readonlyarray<fielddef<t>>` | fields to index. required; at least one entry. |\n| `options.threshold` | `number` | finite overlap score in `0..1` (default `0.2`). |\n| `options.limit` | `number` | finite non negative integer max results (default `50`). |\n| `options.minquerylength` | `number` | finite positive integer min chars before trigram scoring; shorter queries use o(n) containment scan (default `3`). |\n\n**example**\n\n```ts\nimport { createindex } from '@vielzeug/scout';\n\nconst products = [\n { sku: 'wgt 001', title: 'widget pro' },\n { sku: 'gad 002', title: 'gadget plus' },\n];\n\nconst index = createindex(products, {\n fields: [\n { field: 'title', weight: 2 },\n { field: 'sku' },\n ],\n threshold: 0.25,\n limit: 20,\n});\n```\n\n \n\n## `scoutindex<t>`\n\nreturned by `createindex()`.\n\n### `.search(query, options?)`\n\n```ts\nsearch(query: string, options?: searchconstraints): searchresult<t>[]\n```\n\nreturns results sorted by score descending. empty query returns all items with `score = 1`. results below `threshold` are excluded; at most `limit` results are returned.\n\n```ts\nconst results = index.search('alice');\n// [{ item, score, matches }]\n```\n\n### `.add(item)`\n\nadds `item` to the index. no op if the same reference is already indexed. o(field_length).\n\n### `.remove(item)`\n\nremoves `item` by reference equality. no op if not found. o(field_length).\n\n### `.reindex(item)`\n\nre reads the item's current field values and rebuilds its index entry in place, updating only fields whose values changed. preserves insertion order. no op if the item is not in the index.\n\n```ts\nitem.name = 'new name';\nindex.reindex(item);\n```\n\n### `.setitems(items)`\n\n```ts\nsetitems(items: readonly t[]): void\n```\n\nreconciles the index to a refreshed corpus in one mutation. existing references are reindexed, missing references are removed, added references are indexed, and incoming first occurrence order becomes index order. duplicate references collapse to one item. calls `onmutate()` once when indexed values, membership, or order changes.\n\n```ts\nindex.setitems(latestusers);\n```\n\n### `.size`\n\n`number` — current number of indexed items.\n\n### `.items`\n\n`readonly t[]` — all indexed items in insertion order. returns a new array snapshot each call.\n\n```ts\nconst all = index.items;\n```\n\n### `.onmutate(listener)`\n\n```ts\nonmutate(listener: () => void): () => void\n```\n\nsubscribes `listener` to run after every changed `add()` / `remove()` / `reindex()` / `setitems()` operation. no ops, including unchanged bulk reconciliation, do not fire it. a changed `setitems()` reconciliation fires once. `createsearch()` uses this internally to keep `results` in sync with index mutations; most callers building on `createindex()` directly will not need it.\n\n```ts\nconst unsubscribe = index.onmutate(() => {\n console.log(`index changed — now ${index.size} items`);\n});\n\nindex.add(newuser); // logs \"index changed — now 6 items\"\nunsubscribe();\n```\n\n### `.revision`\n\n`number` — monotonically increasing counter, incremented after every changed `add()` / `remove()` / `reindex()` / `setitems()` operation. use as a cache busting token when caching search results outside the index — `tosearchmatcher()` uses it for this purpose.\n\n \n\n## `createsearch(index, options?)`\n\nwraps a `scoutindex` in a reactive search state powered by `@vielzeug/ripple` signals.\n\n```ts\nfunction createsearch<t>(index: scoutindex<t>, options?: createsearchoptions): searchstate<t>\n```\n\n**parameters**\n\n| param | type | description |\n| | | |\n| `options.debounce` | `number` | finite non negative integer milliseconds before query commit (default `200`). pass `0` for immediate updates. |\n| `options.limit` | `number` | finite non negative integer override of index level limit. |\n| `options.threshold` | `number` | finite `0..1` override of index level threshold. |\n| `options.minquerylength` | `number` | finite positive integer override of index level minimum query length. |\n\n**returns `searchstate<t>`**\n\n| member | type | description |\n| | | |\n| `query` | `signal<string>` | writable search query. set `.value` to trigger search. |\n| `results` | `readable<searchresult<t>[]>` | reactive results, updated after debounce. |\n| `issearching` | `readable<boolean>` | `true` during the debounce window. |\n| `disposalsignal` | `abortsignal` | aborted when `dispose()` is called. use to tie other lifecycles to this search. |\n| `disposed` | `boolean` | `true` after `dispose()` has been called. |\n| `clear()` | `() => void` | resets query, cancels debounce, clears results synchronously. |\n| `dispose()` | `() => void` | releases all reactive subscriptions. |\n| `tap()` | `(handler, options?) => () => void` | subscribe to `scoutevent` transitions; returns an unsubscribe function. |\n| `[symbol.dispose]()` | `() => void` | `using` compatible disposal. |\n\n**example**\n\n```ts\nimport { createindex, createsearch } from '@vielzeug/scout';\nimport { effect } from '@vielzeug/ripple';\n\nconst users = [{ name: 'ada lovelace' }, { name: 'grace hopper' }];\nconst index = createindex(users, { fields: ['name'] });\nconst search = createsearch(index, { debounce: 150 });\n\neffect(() => {\n console.log(search.results.value.map((result) => result.item.name));\n});\n\nsearch.query.value = 'ada';\n```\n\n \n\n## `createreactivesearch(items, options)`\n\ncreates a `scoutindex` and a reactive `searchstate` in one call — the shorthand for `createindex` + `createsearch`. returns a `reactivesearch<t>` which extends `searchstate<t>` with a `.index` property for incremental mutations.\n\n```ts\nfunction createreactivesearch<t>(\n items: t[],\n options: scoutindexoptions<t> & { debounce?: number },\n): reactivesearch<t>\n```\n\n**parameters**\n\n| param | type | description |\n| | | |\n| `items` | `t[]` | initial corpus to index. |\n| `options.fields` | `readonlyarray<fielddef<t>>` | fields to index. required. |\n| `options.debounce` | `number` | finite non negative integer debounce milliseconds (default `200`). |\n| `options.threshold` | `number` | finite overlap score in `0..1` (default `0.2`). |\n| `options.limit` | `number` | finite non negative integer max results (default `50`). |\n| `options.minquerylength` | `number` | finite positive integer min chars before trigram scoring (default `3`). |\n\n**returns `reactivesearch<t>`** — all `searchstate<t>` members plus:\n\n| member | type | description |\n| | | |\n| `index` | `scoutindex<t>` | the underlying index for `add`, `remove`, `reindex`. |\n\n**example**\n\n```ts\nimport { createreactivesearch } from '@vielzeug/scout';\nimport { effect } from '@vielzeug/ripple';\n\nconst users = [{ email: 'ada@example.com', name: 'ada lovelace' }];\nconst search = createreactivesearch(users, {\n fields: [{ field: 'name', weight: 2 }, 'email'],\n debounce: 150,\n});\n\neffect(() => console.log(search.results.value.map((result) => result.item.name)));\n\nsearch.index.add({ email: 'grace@example.com', name: 'grace hopper' });\nsearch.dispose();\n```\n\n \n\n## `findmatchranges(text, query)`\n\nnormalizes raw `query` with scout's tokenizer, then computes sorted, non overlapping literal ranges for each normalized token within `text`. useful when you need to apply highlighting to a different string than the indexed field value (e.g. a truncated preview or a differently formatted display string).\n\n```ts\nfunction findmatchranges(text: string, query: string): [number, number][]\n```\n\n**example**\n\n```ts\nimport { findmatchranges, highlight } from '@vielzeug/scout';\n\nconst ranges = findmatchranges('alice johnson', 'alice!');\n// [[0, 5]]\n\nconst parts = highlight('alice johnson', ranges);\n// [{ text: 'alice', highlighted: true }, { text: ' johnson', highlighted: false }]\n```\n\nreturns an empty array if either `text` or `query` is empty.\n\n \n\n## `highlight(text, ranges)`\n\nsplits `text` into `highlightpart[]` fragments based on `ranges` from `fieldmatch.ranges`.\n\n```ts\nfunction highlight(text: string, ranges: [number, number][]): highlightpart[]\n```\n\n**example**\n\n```ts\nimport { highlight } from '@vielzeug/scout';\n\nhighlight('hello world', [[0, 5]]);\n// [{ text: 'hello', highlighted: true }, { text: ' world', highlighted: false }]\n```\n\nreturns an empty array when `text` is empty. returns a single unhighlighted part when `ranges` is empty.\n\n \n\n## `highlightfield(result, field, text)`\n\nconvenience shorthand that finds the match ranges for `field` in `result.matches` and calls `highlight()` in one step. eliminates the manual `result.matches.find(m => m.field === …).ranges` lookup.\n\n```ts\nfunction highlightfield<t>(result: searchresult<t>, field: keyof t & string, text: string): highlightpart[]\n```\n\n**example**\n\n```ts\nimport { createindex, highlightfield } from '@vielzeug/scout';\n\nconst users = [{ name: 'alice johnson' }];\nconst index = createindex(users, { fields: ['name'] });\n\nfor (const result of index.search('alice')) {\n const parts = highlightfield(result, 'name', result.item.name);\n console.log(parts.map((part) => part.highlighted ? `[${part.text}]` : part.text).join(''));\n}\n```\n\nwhen the field has no match (e.g. the query matched via a different field), returns a single unhighlighted part.\n\n \n\n## `tosearchmatcher(index, options?)`\n\nreturns an `(item, query) => boolean` matcher compatible with `sourcerer`'s `match` option.\n\n```ts\nfunction tosearchmatcher<t>(index: scoutindex<t>, options?: searchconstraints): (item: t, query: string) => boolean\n```\n\none matching item set is cached per query and index revision, so filtering does not repeat index work per item and stays current after index mutation.\n\n```ts\nimport { createindex, tosearchmatcher } from '@vielzeug/scout';\nimport { createlocalsource } from '@vielzeug/sourcerer';\n\nconst users = [{ email: 'ada@example.com', name: 'ada lovelace' }];\nconst index = createindex(users, { fields: ['name', 'email'] });\nconst source = createlocalsource(users, { match: tosearchmatcher(index) });\n```\n\n \n\n## `tofilterpredicate(index, query, options?)`\n\nreturns a `(item: t) => boolean` predicate computed from a one time query. use with `array.filter` or vault's `query.filter()`.\n\n```ts\nfunction tofilterpredicate<t>(\n index: scoutindex<t>,\n query: string,\n options?: searchconstraints,\n): (item: t) => boolean\n```\n\nthe predicate is a snapshot — re call `tofilterpredicate` if the query or corpus changes.\n\n```ts\nimport { createindex, tofilterpredicate } from '@vielzeug/scout';\n\nconst products = [{ title: 'widget pro' }, { title: 'gadget plus' }];\nconst index = createindex(products, { fields: ['title'] });\nconst results = products.filter(tofilterpredicate(index, 'widget'));\n\nconst top5 = products.filter(tofilterpredicate(index, 'widget', { limit: 5 }));\n```\n\n \n\n## `segmentwords(text)`\n\nsplits `text` into whitespace joined word segments using the runtime's native `intl.segmenter` — no dependency beyond the platform api. falls back to returning `text` unchanged where `intl.segmenter` isn't available.\n\n```ts\nfunction segmentwords(text: string): string\n```\n\n`tokenize()`'s trigram based scoring already works on unsegmented scripts (chinese, japanese, thai, ...) without this — trigrams are generated per character, not per word. `segmentwords()` is for `findmatchranges()` / highlighting and the multi word query semantics on `searchconstraints`, which assume space separated words. **not applied inside `tokenize()` itself** — benchmarked at ~15x slower than the plain regex path for the common whitespace delimited case, which would regress `createindex()`'s construction cost for every caller, not just those indexing unsegmented scripts.\n\n**example**\n\n```ts\nimport { createindex, segmentwords } from '@vielzeug/scout';\n\nconst documents = [{ title: '日本語を勉強しています' }];\nconst index = createindex(documents, {\n fields: [{ field: 'title', stringify: (value) => segmentwords(string(value)) }],\n});\n```\n\n \n\n## `search.tap(handler, options?)`\n\nsubscribes `handler` to `scoutevent` transitions emitted by a `searchstate` — `query` changes, `issearching` transitions, `results` changes, and `dispose`. returns an unsubscribe function; calling it removes the handler. pass `{ signal }` to tie the subscription to an external `abortsignal` — when the signal aborts (or `dispose()` is called, which aborts `disposalsignal`) the handler is removed automatically.\n\n```ts\ntap(\n handler: (event: scoutevent<t>) => void,\n options?: { signal?: abortsignal },\n): () => void\n```\n\n**example**\n\n```ts\nimport { createindex, createsearch } from '@vielzeug/scout';\n\nconst index = createindex([{ name: 'ada lovelace' }], { fields: ['name'] });\nconst search = createsearch(index);\n\nconst unsubscribe = search.tap((event) => {\n if (event.type === 'query change') console.debug('query:', event.query);\n if (event.type === 'results change') console.debug('results:', event.results.length);\n});\n\nsearch.query.value = 'alice';\n// query: alice\n// results: 1\n\nunsubscribe();\n```\n\n::: warning development logging\nif your queries may carry pii (names, emails, medical/financial terms typed by end users), don't log `query change` events in production.\n:::\n\n \n\n## types\n\n### `searchconstraints`\n\nshared search tuning knobs used by `scoutindexoptions`, `createsearchoptions`, and all search functions.\n\n```ts\ntype searchconstraints = {\n limit?: number; // finite non negative integer; default 50\n minquerylength?: number; // finite positive integer; default 3\n threshold?: number; // finite 0..1 value; default 0.2\n};\n```\n\n### `fielddef<t>`\n\n```ts\ntype fielddef<t> =\n | (keyof t & string)\n | {\n field: keyof t & string;\n weight?: number; // default 1\n stringify?: (value: unknown) => string;\n };\n```\n\n### `scoutindexoptions<t>`\n\n```ts\ntype scoutindexoptions<t> = searchconstraints & {\n fields: readonlyarray<fielddef<t>>;\n};\n```\n\n### `createsearchoptions`\n\n```ts\ntype createsearchoptions = searchconstraints & {\n debounce?: number; // finite non negative integer; default 200\n};\n```\n\n### `searchresult<t>`\n\n```ts\ntype searchresult<t> = {\n item: t;\n matches: fieldmatch<keyof t & string>[]; // literal normalized token ranges; may be empty for fuzzy only results\n score: number; // [0, 1]; 1 when query is empty\n};\n```\n\n### `fieldmatch<f>`\n\ngeneric over the union of field names — `match.field` is typed to the actual fields of `t`.\n\n```ts\ntype fieldmatch<f extends string = string> = {\n field: f;\n ranges: [number, number][]; // literal normalized token [start, end] ranges in original field value\n};\n```\n\n### `highlightpart`\n\n```ts\ntype highlightpart = {\n highlighted: boolean;\n text: string;\n};\n```\n\n### `searchstate<t>`\n\n```ts\ntype searchstate<t> = {\n readonly query: signal<string>;\n readonly results: readable<searchresult<t>[]>;\n readonly issearching: readable<boolean>;\n readonly disposalsignal: abortsignal;\n readonly disposed: boolean;\n clear(): void;\n dispose(): void;\n tap(handler: (event: scoutevent<t>) => void, options?: { signal?: abortsignal }): () => void;\n [symbol.dispose](): void;\n};\n```\n\nsee `createsearch()` above for member descriptions.\n\n### `scoutevent<t>`\n\ndiscriminated union of events emitted by `searchstate.tap()`. each variant carries a `type` discriminant; narrow with a `switch` or `if` on `event.type`.\n\n```ts\ntype scoutevent<t> =\n | { type: 'query change'; query: string }\n | { type: 'searching change'; issearching: boolean }\n | { type: 'results change'; results: readonly searchresult<t>[] }\n | { type: 'dispose' };\n```\n\n| `type` | payload | emitted when |\n| | | |\n| `query change` | `query: string` | the writable `query` signal's value changes. |\n| `searching change` | `issearching: boolean` | the debounce window opens (`true`) or closes (`false`). |\n| `results change` | `results: readonly searchresult<t>[]` | committed results change after debounce. |\n| `dispose` | — | `dispose()` is called on the `searchstate`. |\n\n### `reactivesearch<t>`\n\n```ts\ntype reactivesearch<t> = searchstate<t> & {\n readonly index: scoutindex<t>;\n};\n```\n\nsee `createreactivesearch()` above.\n\n \n\n## errors\n\n### `scouterror`\n\nbase class for all scout errors. use `instanceof scouterror` to catch any scout originated error.\n\n```ts\nclass scouterror extends error {}\n```\n\n**named subclasses**\n\n| class | thrown when |\n| | |\n| `scoutconfigurationerror` | an index, search, or reactive search receives invalid fields or numeric options |\n| `scoutdisposederror` | a method is called on a disposed `searchstate` instance |\n",
|
|
1126
|
+
"usage": " \ntitle: scout — usage guide\ndescription: how to guide for @vielzeug/scout — building indexes, reactive search, highlighting, and integrating with sourcerer and vault.\n \n\n[[toc]]\n\n## basic usage\n\n### building an index\n\npass your item array and field configuration to `createindex`. all items are indexed immediately at construction time.\n\n```ts\nimport { createindex } from '@vielzeug/scout';\n\nconst users = [\n { email: 'ada@example.com', name: 'ada lovelace' },\n { email: 'grace@example.com', name: 'grace hopper' },\n];\n\nconst index = createindex(users, {\n fields: ['name', 'email'],\n});\n```\n\n### searching\n\ncall `index.search(query)` with any string. results are sorted by score descending.\n\n```ts\nconst results = index.search('alice');\n\nfor (const { item, score, matches } of results) {\n console.log(item.name, score);\n}\n```\n\nan empty `query` returns all items with `score = 1`:\n\n```ts\nindex.search(''); // all items, score = 1 each\n```\n\n### per field weights\n\ngive fields different weights to control score ranking. a match on a high weight field ranks the item higher than a match on a low weight field.\n\n```ts\nconst index = createindex(users, {\n fields: [\n { field: 'name', weight: 3 }, // name matches rank 3× higher\n { field: 'department', weight: 1 },\n { field: 'bio', weight: 0.5 },\n ],\n});\n```\n\n### non string fields\n\nuse `stringify` to convert numeric or boolean fields to searchable text.\n\n```ts\nconst index = createindex(products, {\n fields: [\n 'title',\n { field: 'price', stringify: (v) => `$${v}` },\n { field: 'instock', stringify: (v) => (v ? 'available in stock' : 'out of stock') },\n ],\n});\n```\n\n### non latin scripts (cjk, thai, ...)\n\n`tokenize()` indexes any script correctly — trigrams are generated per character, so chinese, japanese, cyrillic, and accented latin text are all searchable out of the box. what it doesn't do is insert word boundaries for scripts that don't use spaces (chinese, japanese, thai, ...), which affects `findmatchranges()` / highlighting and multi word query semantics. pre segment those fields with `segmentwords()`:\n\n```ts\nimport { createindex, segmentwords } from '@vielzeug/scout';\n\nconst docs = [{ title: '日本語を勉強しています' }, { title: '我喜欢学习中文' }];\n\nconst index = createindex(docs, {\n fields: [{ field: 'title', stringify: (v) => segmentwords(string(v)) }],\n});\n\nindex.search('日本語'); // matches the first document\n```\n\n`segmentwords()` uses the runtime's native `intl.segmenter` — no dependency. it's opt in per field rather than built into `tokenize()` because it benchmarks ~15x slower than the default regex path for ordinary whitespace delimited text.\n\n### limiting results\n\npass `limit`, `threshold`, and `minquerylength` in options to control result count and quality. `limit` must be a finite non negative integer, `threshold` a finite value in `0..1`, and `minquerylength` a finite positive integer; invalid values throw `scoutconfigurationerror`.\n\n```ts\n// at most 10 results, minimum overlap score 0.3\nconst results = index.search('widget', { limit: 10, threshold: 0.3 });\n```\n\nper call options override the index level defaults set in `createindex`.\n\nscores come from the overlap (szymkiewicz–simpson) coefficient — the fraction of the *shorter*\ntrigram set (almost always the query) found in the longer one. this is deliberate for the\nautocomplete/command palette use case `createindex` targets: a short query that's a clean prefix\nof a much longer field value (e.g. `'fin'` against `'finalize q3 budget report'`) scores on how\nmuch of the query matched, not diluted by how much longer the target field happens to be.\n\n### controlling short query behaviour\n\nqueries shorter than `minquerylength` (default `3`) fall back to an o(n) substring containment scan. short query matches return `score = 1.0`.\n\n```ts\n// use trigram scoring even for 1 char queries (good for small corpora)\nconst index = createindex(items, { fields: ['name'], minquerylength: 1 });\n\n// force containment scan for all queries up to 8 chars (good for autocomplete on large sets)\nconst results = index.search('alice', { minquerylength: 8 });\n```\n\n## reactive search\n\n### `createreactivesearch()` — recommended\n\nfor most use cases, `createreactivesearch` builds the index and reactive state together in one call. it returns a `reactivesearch<t>` — a `searchstate<t>` with an extra `.index` property for incremental mutations:\n\n```ts\nimport { createreactivesearch } from '@vielzeug/scout';\nimport { effect } from '@vielzeug/ripple';\n\nconst search = createreactivesearch(users, {\n fields: [{ field: 'name', weight: 2 }, 'email'],\n debounce: 150,\n});\n\neffect(() => {\n if (search.issearching.value) showloadingspinner();\n else renderresults(search.results.value.map(r => r.item));\n});\n\ninput.addeventlistener('input', e => {\n search.query.value = e.currenttarget.value;\n});\n\n// add items at runtime via the exposed index\nsearch.index.add(newuser);\n\n// dispose when this owner is no longer needed\nsearch.dispose();\n```\n\n### `createsearch()` — separate index and state\n\nuse `createsearch` when you need to create the index independently — for example when sharing it across multiple reactive states:\n\n```ts\nimport { createindex, createsearch } from '@vielzeug/scout';\n\nconst index = createindex(users, { fields: ['name', 'email'] });\nconst search = createsearch(index, { debounce: 150 });\n```\n\n### `using` declaration\n\n```ts\n{\n using search = createreactivesearch(users, { fields: ['name'] });\n // search.dispose() called automatically at scope exit\n}\n```\n\n### zero debounce for synchronous updates\n\npass `debounce: 0` if you want results updated synchronously (no `issearching` flash). other debounce values must be finite non negative integers; invalid values throw `scoutconfigurationerror`.\n\n```ts\nconst search = createreactivesearch(users, { fields: ['name'], debounce: 0 });\n\nsearch.query.value = 'alice';\nconsole.log(search.results.value); // already updated\n```\n\n### resetting search\n\n```ts\nsearch.clear(); // resets query + results + issearching synchronously\n```\n\n### composing with ripple signals\n\n`search.results` is a `readable` signal — compose it into other computed values:\n\n```ts\nimport { computed } from '@vielzeug/ripple';\n\nconst topresult = computed(() => search.results.value[0]?.item ?? null);\n```\n\n## incremental updates\n\nuse `add()`, `remove()`, and `reindex()` for individual reference based mutations. use `setitems()` when a refreshed collection replaces the current corpus; scout reconciles membership, current field values, and source order in one notification.\n\n```ts\nconst index = createindex(products, { fields: ['title'] });\n\n// add a newly created item\nconst newproduct = { id: 99, title: 'new widget' };\nindex.add(newproduct);\n\n// remove a deleted item (by reference)\nindex.remove(products[0]);\n\n// re index a mutated item after in place mutation\nproducts[1].title = 'updated title';\nindex.reindex(products[1]);\n```\n\n> `remove()`, `reindex()`, and `setitems()` use **reference equality** (`===`). pass retained object references from the current corpus; `setitems()` collapses duplicate references.\n\n### replacing a refreshed corpus\n\n```ts\nconst latestproducts = await loadproducts();\n\nindex.setitems(latestproducts);\n```\n\n`setitems()` removes references absent from `latestproducts`, adds new references, reindexes retained references, and adopts the incoming order. it calls `onmutate()` once only when index membership, field values, or order changes.\n\n### inspecting the corpus\n\nuse `.items` to read all currently indexed items in insertion order, or `.size` for a count:\n\n```ts\nconsole.log(index.size); // 42\nconsole.log(index.items); // [{ id: 1, title: ... }, ...]\n```\n\n### reacting to mutations directly\n\n`createsearch()` already keeps `results` in sync with `add()`/`remove()`/`reindex()`/`setitems()` internally. `tosearchmatcher()` also invalidates its query cache after index mutation. if you're building your own reactivity on top of a plain `scoutindex` (no `ripple` involved), subscribe with `onmutate()`:\n\n```ts\nconst unsubscribe = index.onmutate(() => {\n rerenderresultslist();\n});\n\nindex.add(newproduct); // triggers rerenderresultslist()\n\nunsubscribe(); // when done\n```\n\n`onmutate()` only fires for mutations that actually change the index — a duplicate `add()` or a `remove()` of an unindexed item is a no op and doesn't notify listeners.\n\n## match highlighting\n\nevery `searchresult` carries `matches` — per field literal normalized token ranges. a fuzzy trigram candidate can have `matches: []` when no literal query token appears in its field text.\n\n### `highlightfield()` — recommended\n\n`highlightfield(result, field, text)` is the shorthand that does the field lookup and fragment split in one step:\n\n```ts\nimport { highlightfield } from '@vielzeug/scout';\n\nfor (const result of index.search('alice')) {\n const parts = highlightfield(result, 'name', result.item.name);\n // [{ text: 'alice', highlighted: true }, { text: ' johnson', highlighted: false }]\n renderhighlightedtext(parts);\n}\n```\n\n::: warning `part.text` is unescaped\n`highlight()` / `highlightfield()` return the **original, unescaped** field text split into\nfragments — never concatenate `part.text` into an html string for `innerhtml`. render each\npart as text (`textcontent`, a framework's text binding) and wrap `highlighted` parts in your\nown element:\n\n```ts\nfunction renderhighlightedtext(parts: highlightpart[]): documentfragment {\n const fragment = document.createdocumentfragment();\n\n for (const part of parts) {\n if (part.highlighted) {\n const mark = document.createelement('mark');\n\n mark.textcontent = part.text; // textcontent — never innerhtml\n fragment.appendchild(mark);\n } else {\n fragment.appendchild(document.createtextnode(part.text));\n }\n }\n\n return fragment;\n}\n```\n\n:::\n\n### `findmatchranges()` + `highlight()` — manual\n\nuse `findmatchranges()` when you need to apply match ranges to a different string than the indexed field value — for example a truncated preview or a differently formatted display string:\n\n```ts\nimport { findmatchranges, highlight } from '@vielzeug/scout';\n\nconst [result] = index.search('alice');\nconst preview = result.item.bio.slice(0, 100);\nconst ranges = findmatchranges(preview, 'alice');\nconst parts = highlight(preview, ranges);\n```\n\nor use `highlight()` directly when you already have the ranges from `result.matches`:\n\n```ts\nconst [result] = index.search('alice');\nconst namematch = result.matches.find(m => m.field === 'name');\nconst parts = highlight(result.item.name, namematch?.ranges ?? []);\n```\n\n## debug logging\n\n`search.tap()` subscribes a handler to `scoutevent` transitions emitted by a `searchstate` — `query` changes, `issearching` transitions, `results` changes, and `dispose`. it returns an unsubscribe function. pass `{ signal }` to tie the subscription to an external `abortsignal` (or to `search.disposalsignal`, which aborts when `dispose()` is called).\n\n```ts\nimport { createindex, createsearch } from '@vielzeug/scout';\n\nconst search = createsearch(index, { debounce: 150 });\nconst unsubscribe = search.tap((event) => {\n if (event.type === 'query change') console.debug('query:', event.query);\n if (event.type === 'searching change') console.debug('issearching:', event.issearching);\n if (event.type === 'results change') console.debug('results:', event.results.length);\n});\n\nsearch.query.value = 'alice';\n// query: alice\n// issearching: true\n// issearching: false\n// results: 1\n\nunsubscribe();\n```\n\n::: warning development logging\n`query change` events carry the full, literal search query string — if your queries may carry pii (names, emails, medical/financial terms typed by end users), don't log them in production.\n:::\n\n## framework integration\n\n::: code group\n\n```tsx [react]\nimport { createreactivesearch } from '@vielzeug/scout';\nimport { useeffect, useref, usesyncexternalstore } from 'react';\n\ntype user = { id: number; name: string; email: string };\n\nfunction usescoutsearch(items: user[]) {\n const ref = useref(\n createreactivesearch(items, {\n fields: [{ field: 'name', weight: 2 }, 'email'],\n debounce: 150,\n }),\n );\n\n const search = ref.current;\n\n const results = usesyncexternalstore(\n (cb) => search.results.subscribe(cb),\n () => search.results.value,\n );\n\n useeffect(() => () => search.dispose(), [search]);\n\n return { query: search.query, results };\n}\n```\n\n```ts [vue 3]\nimport { createreactivesearch } from '@vielzeug/scout';\nimport { onscopedispose, ref, watch } from 'vue';\n\ntype user = { id: number; name: string; email: string };\n\nfunction usescoutsearch(items: user[]) {\n const search = createreactivesearch(items, {\n fields: [{ field: 'name', weight: 2 }, 'email'],\n debounce: 150,\n });\n\n const query = ref('');\n const results = ref(search.results.value);\n\n const unsub = search.results.subscribe(() => {\n results.value = search.results.value;\n });\n\n watch(query, (q) => { search.query.value = q; });\n\n onscopedispose(() => { unsub(); search.dispose(); });\n\n return { query, results };\n}\n```\n\n```svelte [svelte]\n<script lang=\"ts\">\n import { createreactivesearch } from '@vielzeug/scout';\n import { ondestroy } from 'svelte';\n\n type user = { id: number; name: string; email: string };\n\n export let items: user[];\n\n const search = createreactivesearch(items, {\n fields: [{ field: 'name', weight: 2 }, 'email'],\n debounce: 150,\n });\n\n let query = '';\n let results = search.results.value;\n\n const unsub = search.results.subscribe(() => {\n results = search.results.value;\n });\n\n $: search.query.value = query;\n\n ondestroy(() => { unsub(); search.dispose(); });\n</script>\n\n<input bind:value={query} placeholder=\"search…\" />\n{#each results as { item }}\n <p>{item.name}</p>\n{/each}\n```\n\n:::\n\n## working with other vielzeug libraries\n\n### with sourcerer\n\n`tosearchmatcher()` adapts a `scoutindex` to `createlocalsource`'s explicit `match` callback. scout decides which items match; sourcerer keeps source query and pagination.\n\n```ts\nimport { createindex, tosearchmatcher } from '@vielzeug/scout';\nimport { createlocalsource } from '@vielzeug/sourcerer';\n\nconst index = createindex(users, {\n fields: [{ field: 'name', weight: 2 }, 'email'],\n});\n\nconst source = createlocalsource(users, {\n match: tosearchmatcher(index),\n});\n\nsource.setquery({ search: 'alice' });\n```\n\n> keep the index in sync using `index.add()` / `index.remove()` / `index.reindex()`.\n\n### with vault\n\n`tofilterpredicate()` returns an `(item: t) => boolean` snapshot predicate — pass it to vault's `query.filter()` or plain `array.filter`.\n\n```ts\nimport { createindex, tofilterpredicate } from '@vielzeug/scout';\n\nconst index = createindex(products, { fields: ['title', 'sku'] });\n\nconst matching = products.filter(tofilterpredicate(index, 'widget'));\n\nconst rows = await db.query('products')\n .filter(tofilterpredicate(index, searchterm))\n .toarray();\n```\n\ncall `tofilterpredicate` again whenever the query or corpus changes — the predicate is a snapshot, not reactive.\n\n## best practices\n\n **build the index once** — `createindex()` runs in o(corpus × field_length). create it at module level or in an effect, not inside render loops.\n **keep the index in sync** — call `index.add()` / `remove()` / `reindex()` when items mutate. stale index entries return wrong scores.\n **tune threshold before limit** — set a meaningful `threshold` (e.g. `0.25–0.4`) to suppress noise, then use `limit` to cap the list length.\n **set `minquerylength` for your corpus size** — the default `3` works well for most cases. lower it for small corpora where single char queries are expected; raise it for large corpora to avoid expensive o(n) scans.\n **dispose reactive state** — always call `search.dispose()` or use `using` when the component unmounts.\n **weight by importance** — name/title fields should have weight `2–3`; secondary fields (description, tags) stay at `1`.\n **segment cjk/thai fields explicitly** — `segmentwords()` is opt in per field, not automatic, to keep `createindex()` fast for the common whitespace delimited case.\n",
|
|
1105
1127
|
"examples": " \ntitle: scout — examples\ndescription: practical examples for @vielzeug/scout — basic search, reactive combobox, and sourcerer integration.\n \n\n## examples\n\n [basic search](./examples/basic search)\n [reactive combobox](./examples/reactive combobox)\n [sourcerer integration](./examples/sourcerer integration)\n"
|
|
1106
1128
|
},
|
|
1107
1129
|
"examples": [
|
|
@@ -1126,19 +1148,19 @@
|
|
|
1126
1148
|
"text": "segmenting non latin text import { createindex, segmentwords } from '@vielzeug/scout'\n\n// cjk text has no spaces between words — segmentwords() inserts them via the\n// runtime's native intl.segmenter, so word boundary features work like they do for latin text\nconst docs = [\n { id: 1, title: '日本語を勉強しています' },\n { id: 2, title: '我喜欢学习中文' },\n { id: 3, title: 'learning japanese is fun' },\n]\n\nconsole.log('segmented:', segmentwords('日本語を勉強しています'))\n\nconst index = createindex(docs, {\n fields: [{ field: 'title', stringify: (v) => segmentwords(string(v)) }],\n})\n\nconst results = index.search('日本語')\nconsole.log('search \"日本語\":', results.map(r => r.item.title))"
|
|
1127
1149
|
}
|
|
1128
1150
|
],
|
|
1129
|
-
"exports": "createindex createreactivesearch createsearch scoutconfigurationerror scoutdisposederror scouterror
|
|
1151
|
+
"exports": "createindex createreactivesearch createsearch scoutconfigurationerror scoutdisposederror scouterror scoutevent findmatchranges highlight highlightfield segmentwords tofilterpredicate tosearchmatcher",
|
|
1130
1152
|
"keywords": "fuzzy search search trigram full text filter highlight reactive ripple",
|
|
1131
1153
|
"name": "@vielzeug/scout",
|
|
1132
1154
|
"related": "arsenal sourcerer vault ripple",
|
|
1133
1155
|
"slug": "scout",
|
|
1134
|
-
"source": "export { tofilterpredicate, tosearchmatcher } from './adapters';\nexport { scoutconfigurationerror, scoutdisposederror, scouterror } from './errors';\nexport { findmatchranges, highlight, highlightfield } from './highlight';\nexport type { reactivesearch } from './reactive';\nexport { createreactivesearch, createsearch } from './reactive';\nexport type { scoutindex } from './scout index';\nexport { createindex } from './scout index';\nexport { segmentwords } from './segment';\nexport type {\n createsearchoptions,\n fielddef,\n fieldmatch,\n highlightpart,\n scoutindexoptions,\n searchconstraints,\n searchresult,\n searchstate,\n} from './types';\n"
|
|
1156
|
+
"source": "export { tofilterpredicate, tosearchmatcher } from './adapters';\nexport { scoutconfigurationerror, scoutdisposederror, scouterror } from './errors';\nexport { findmatchranges, highlight, highlightfield } from './highlight';\nexport type { reactivesearch } from './reactive';\nexport { createreactivesearch, createsearch } from './reactive';\nexport type { scoutindex } from './scout index';\nexport { createindex } from './scout index';\nexport { segmentwords } from './segment';\nexport type {\n createsearchoptions,\n fielddef,\n fieldmatch,\n highlightpart,\n scoutevent,\n scoutindexoptions,\n searchconstraints,\n searchresult,\n searchstate,\n} from './types';\n"
|
|
1135
1157
|
},
|
|
1136
1158
|
{
|
|
1137
1159
|
"category": "ui performance",
|
|
1138
1160
|
"description": "lightweight, framework agnostic virtual list engine with variable heights, sticky headers, grid support, and reactive integration.",
|
|
1139
1161
|
"docs": {
|
|
1140
1162
|
"index": " \ntitle: scroll — virtual list engine for typescript\ndescription: lightweight, framework agnostic virtual list engine with variable heights, sticky headers, grid support, and reactive integration.\npackage: scroll\ncategory: ui performance\nkeywords: [virtual list, virtualization, windowing, scroll, performance, large lists]\nrelated: [dnd, ore, refine]\nexports:\n [\n createvirtualizer,\n createdomvirtuallist,\n createvirtualscroller,\n creategroupedvirtualizer,\n creategridvirtualizer,\n createmeasurementcache,\n scrollconfigurationerror,\n scrollerror,\n scrollrangeerror,\n default_estimate_size,\n default_overscan,\n ]\nenvironments: [browser]\n \n\n<! markdownlint disable md025 md033 md060 >\n\n<packagehero package=\"scroll\" />\n\n## why scroll?\n\nrendering thousands of items as real dom nodes freezes the browser. each node consumes layout, paint, and memory — long lists need to render only what is visible in the viewport.\n\n```ts\n// before — render all 10 000 items (browser freezes)\nlist.replacechildren();\nitems.foreach((item) => {\n const el = document.createelement('div');\n el.textcontent = item.name;\n list.appendchild(el); // 10 000 dom nodes\n});\n\n// after — scroll (only ~15 visible rows in the dom at any time)\nimport { createvirtualizer } from '@vielzeug/scroll';\nconst virtualizer = createvirtualizer(scrollel, {\n count: items.length,\n estimatesize: 36,\n onchange: ({ items: visibleitems, totalsize }) => {\n list.style.height = `${totalsize}px`;\n list.replacechildren();\n for (const { index, start } of visibleitems) {\n const el = document.createelement('div');\n el.style.csstext = `position:absolute;top:${start}px;height:36px;`;\n el.textcontent = items[index].name;\n list.appendchild(el);\n }\n },\n});\n```\n\n| feature | scroll | tanstack virtual | react window |\n| | | | |\n| bundle size | <packageinfo package=\"scroll\" type=\"size\" /> | ~5 kb | ~8 kb |\n| framework agnostic | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> | react only |\n| variable heights | <ore icon name=\"check\" size=\"16\"></ore icon> measured | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"triangle alert\" size=\"16\"></ore icon> static |\n| o(log n) lookup | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> |\n| `using` support | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> |\n| zero dependencies | <ore icon name=\"x\" size=\"16\"></ore icon> `@vielzeug/ripple` | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> |\n\n<div class=\"decision callout\">\n\n**use scroll when** you need to render large lists in a framework agnostic environment with precise control over item measurement and scroll position.\n\n**consider tanstack virtual** if you need its framework adapters and ecosystem integration.\n\n</div>\n\n## installation\n\n::: code group\n\n```sh [pnpm]\npnpm add @vielzeug/scroll\n```\n\n```sh [npm]\nnpm install @vielzeug/scroll\n```\n\n```sh [yarn]\nyarn add @vielzeug/scroll\n```\n\n:::\n\n## quick start\n\n```ts\nimport { createvirtualizer } from '@vielzeug/scroll';\n\nconst scrollel = document.queryselector<htmlelement>('.scroll container')!;\nconst spacer = document.queryselector<htmlelement>('.spacer')!;\nconst list = document.queryselector<htmlelement>('.list')!;\n\nconst virt = createvirtualizer(scrollel, {\n count: 10_000,\n estimatesize: 36,\n onchange: ({ items, totalsize }) => {\n // stretch the container so the scrollbar reflects the full list\n spacer.style.height = `${totalsize}px`;\n list.replacechildren();\n\n for (const item of items) {\n const el = document.createelement('div');\n el.style.csstext = `position:absolute;top:${item.start}px;left:0;right:0;`;\n el.textcontent = `row ${item.index}`;\n list.appendchild(el);\n }\n },\n});\n\n// clean up\nvirt.dispose();\n```\n\n### entry points\n\nall apis export from a single entry: `@vielzeug/scroll`.\n\n## features\n\n<div class=\"features grid\">\n\n **framework agnostic** — callback based `onchange` connects to any rendering layer (react, vue, svelte, lit, vanilla dom)\n **fixed and variable heights** — pass a fixed number, a per index estimator function, or call `measure()` after rendering for exact heights\n **batched measurements** — calling `measure()` many times in a single tick coalesces into one prefix sum rebuild via `queuemicrotask`\n **stable key reflow** — call `refresh()` after reorder/filter changes to rebuild offsets without discarding measured sizes\n **sticky headers** — mark items with `sticky` to pin them at the viewport top; `creategroupedvirtualizer` handles section headers automatically\n **grouped sections** — `creategroupedvirtualizer` virtualizes sectioned data with per section headers, `onchange` state, and `scrolltosection`/`scrolltoitem`\n **grid virtualization** — `creategridvirtualizer` virtualizes two dimensional data with independent row/column measurement and `scrolltocell`\n **reactive state** — provide a `signal` factory to expose current state as a ripple `signal`\n **keyboard navigation** — enable `keyboardscroll` for arrow/page/home/end key support\n **auto measurement** — enable `automeasure` to automatically measure visible items via `resizeobserver`\n **dom adapter** — `createdomvirtuallist` and `createvirtualscroller` manage virtualizer lifecycle, list height styles, and dom node pooling\n **skipped re renders** — `onchange` is not called when a scroll event doesn't move the visible window across an item boundary\n **programmatic scrolling** — `scrolltoindex()` with `start`, `end`, `center`, and `auto` alignment; `scrolltooffset()` for pixel control; `scrolltorow()`/`scrolltocolumn()` for grids; all support `behavior: 'smooth'`\n **horizontal + window targets** — supports both element and `window` scrolling, in vertical or horizontal mode\n **asymmetric overscan + gap** — tune start/end overscan independently and add inter item spacing\n **atomic updates** — `virt.update(...)` lets you change count, estimator, overscan, and more in one call\n **clamp safe** — `scrolltoindex` silently clamps out of range indices\n **scroll state events** — `onscrollingchange` fires when scrolling starts/stops; `onscrollend` fires once scrolling settles (native `scrollend` or debounce fallback); `isscrolling` getter available at any time\n **scroll anchor** — viewport position is preserved visually when `estimatesize` changes via `update()`\n **prepend support** — `prepend()` adds items at the top while keeping the viewport visually stable\n **disposable** — implements `[symbol.dispose]` for `using` declarations\n `scrollconfigurationerror` — rejects malformed static configuration before listeners attach or updates apply\n\n</div>\n\n## how it works\n\nscroll maintains a prefix sum offset array. on every scroll event it runs two binary searches — one for the first visible index, one for the last — to determine the render window in o(log n) time. only the items within that window (plus `overscan` on each side) are passed to `onchange`.\n\n```text\nitems: [0] [1] [2] [3] [4] [5] [6] ...\noffsets: 0 36 72 108 144 180 216 ...\n\nscrolltop = 90, containerheight = 120 → visible items 2–5\nwith overscan=3: render items 0–8\n```\n\nthe offset array is rebuilt (o(n)) only when layout inputs change: on `measure()` flush, `refresh()`, `update({ count })`, `update({ estimatesize })`, or `invalidate()`. scroll and resize events recompute the visible window without rebuilding offsets.\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 [refine](/refine/) — accessible web components that use scroll internally for virtualized listboxes and comboboxes\n [ore](/ore/) — web component authoring layer; use with scroll to build virtualizing custom elements\n [dnd](/dnd/) — drag and drop engine; combine with scroll to make sortable virtual lists\n\n</div>\n\n<! markdownlint enable md025 md033 md060 >\n",
|
|
1141
|
-
"api": " \ntitle: scroll — api reference\ndescription: complete api reference for the scroll virtual list engine.\n \n\n[[toc]]\n\n## api overview\n\n| symbol | purpose | execution mode | common gotcha |\n| | | | |\n| `createvirtualizer()` | core 1d virtualizer | sync | `onchange` fires on construction — wire dom first |\n| `createdomvirtuallist()` | dom adapter for dropdown/listbox uis | sync | virtualizer is created lazily on first `setitems()` |\n| `createvirtualscroller()` | self contained scroller (creates dom) | sync | `dispose()` removes the generated scroll element |\n| `creategroupedvirtualizer()` | sectioned list with sticky headers | sync | `update()` preserves measured sizes — call `invalidate()` only on font/layout changes |\n| `creategridvirtualizer()` | two dimensional grid virtualizer | sync | `onrangechange` fires even when `onchange` is omitted |\n\n## package entry point\n\neverything exports from a single entry:\n\n```ts\nimport {\n createvirtualizer,\n createdomvirtuallist,\n createvirtualscroller,\n creategroupedvirtualizer,\n creategridvirtualizer,\n createmeasurementcache,\n default_estimate_size,\n default_overscan,\n scrollerror,\n scrollconfigurationerror,\n scrollrangeerror,\n type virtualizer,\n type virtualitem,\n type virtualizerstate,\n type virtualizeroptions,\n type virtualizerupdateoptions,\n type scrolltoindexoptions,\n type overscan,\n type virtualkey,\n type measurementcache,\n type scrolltarget,\n type domvirtuallistoptions,\n type domvirtuallistcontroller,\n type domvirtuallistrenderargs,\n type recyclefn,\n type virtualrenderitem,\n type sticktobottomoptions,\n type virtualscrolleroptions,\n type groupsection,\n type groupvirtualizer,\n type groupvirtualizeroptions,\n type groupvirtualizerstate,\n type groupvirtualizerupdateoptions,\n type groupvirtualheader,\n type groupvirtualitem,\n type gridvirtualizer,\n type gridvirtualizeroptions,\n type gridvirtualizerstate,\n type gridvirtualizerupdateoptions,\n type gridrangechangeevent,\n type scrolltocelloptions,\n} from '@vielzeug/scroll';\n```\n\n## `createvirtualizer(target, options)`\n\n```ts\ncreatevirtualizer(target: scrolltarget, options: virtualizeroptions): virtualizer;\n```\n\ncreates and immediately attaches a virtualizer to the provided scroll container. `onchange` fires synchronously on construction with the initial visible window. call `dispose()` on unmount.\n\n```ts\nimport { createvirtualizer } from '@vielzeug/scroll';\n\nconst rows = [{ label: 'ada lovelace' }, { label: 'grace hopper' }];\nconst scrollel = document.queryselector<htmlelement>('.scroll container')!;\nconst listel = document.queryselector<htmlelement>('.list')!;\n\nconst virt = createvirtualizer(scrollel, {\n count: rows.length,\n estimatesize: 36,\n gap: 8,\n onchange: ({ items, totalsize }) => {\n listel.style.height = `${totalsize}px`;\n listel.replacechildren();\n\n for (const item of items) {\n const row = document.createelement('div');\n row.style.csstext = `position:absolute;top:${item.start}px;left:0;right:0;height:${item.size}px;`;\n row.textcontent = rows[item.index]?.label ?? '';\n listel.appendchild(row);\n }\n },\n});\n```\n\n### parameters\n\n| parameter | type | description |\n| | | |\n| `target` | `htmlelement \\| window` | scroll container to observe |\n| `options` | `virtualizeroptions` | initial options |\n\n### `virtualizeroptions`\n\n| option | type | default | description |\n| | | | |\n| `count` | `number` | required | total item count |\n| `estimatesize` | `number \\| (index: number) => number` | `36` | fixed size or per index estimate in pixels |\n| `gap` | `number` | `0` | gap between adjacent items in pixels |\n| `getitemkey` | `(index: number) => string \\| number` | `index => index` | stable key for the measurement cache |\n| `horizontal` | `boolean` | `false` | virtualize along the x axis instead of y |\n| `initialoffset` | `number` | — | initial scroll position; applied once on construction |\n| `keyboardscroll` | `boolean` | `false` | enable keyboard navigation (arrow/page/home/end keys) |\n| `automeasure` | `boolean` | `false` | automatically measure visible items via resizeobserver |\n| `measurementcache` | `measurementcache` | — | shared external cache for scroll restoration or ssr pre measurement |\n| `onchange` | `(state: virtualizerstate) => void` | — | called when the visible window changes; replace through `update()`. |\n| `onscrollend` | `(offset: number) => void` | — | called when scrolling settles; replace through `update()`. |\n| `onscrollingchange` | `(isscrolling: boolean) => void` | — | called when scroll activity starts or stops; replace through `update()`. |\n| `overscan` | `number \\| { start?: number; end?: number }` | `3` | extra items outside the viewport; number = symmetric on both sides |\n| `scrollenddelay` | `number` | `150` | debounce delay (ms) used to detect scroll end when native `scrollend` is unavailable |\n| `signal` | `(init: virtualizerstate) => signal<virtualizerstate>` | — | optional signal factory to expose state as a reactive signal |\n| `sticky` | `(index: number) => boolean` | — | mark an item as a sticky header (pinned at viewport top) |\n\ncallbacks and `scrollenddelay` can be replaced through `update()`; `horizontal` and `initialoffset` remain construction only.\n\n**returns:** `virtualizer`\n\n### `virtualizerstate`\n\n```ts\ninterface virtualizerstate {\n readonly items: virtualitem[];\n readonly stickyitems: virtualitem[];\n readonly totalsize: number;\n}\n```\n\n`items` contains the currently visible items plus overscan. `stickyitems` contains items marked sticky that are pinned at the viewport top.\n\n### `virtualizer` — read only properties\n\n| property | type | description |\n| | | |\n| `count` | `number` | current item count |\n| `disposalsignal` | `abortsignal` | aborted when `dispose()` is called |\n| `disposed` | `boolean` | `true` after `dispose()` is called |\n| `isscrolling` | `boolean` | `true` while the user is scrolling; `false` once settled |\n| `items` | `virtualitem[]` | currently rendered items. always populated. |\n| `scrolloffset` | `number` | current scroll position in pixels |\n| `stickyitems` | `virtualitem[]` | items pinned at the viewport top (requires `sticky` option) |\n| `totalsize` | `number` | total height (or width in horizontal mode) |\n\n### `virtualizer` — methods\n\n| method | signature | description |\n| | | |\n| `update` | `(next: virtualizerupdateoptions) => void` | atomically update live options |\n| `measure` | `(index: number, size: number) => void` | record one measured size; rebuild batched in microtask |\n| `measurebatch` | `(entries: array<{ index: number; size: number }>) => void` | record many sizes; single rebuild |\n| `measureel` | `(index: number, el: htmlelement) => () => void` | attach resizeobserver to auto measure. returns a disconnect function |\n| `refresh` | `() => void` | rebuild offset table and re emit; preserves cached measurements |\n| `prepend` | `(additionalcount: number) => void` | add items at the top; adjusts scroll offset to keep viewport stable |\n| `scrolltoindex` | `(index: number, options?: scrolltoindexoptions) => void` | scroll to an item; out of range indices are clamped |\n| `scrolltooffset` | `(offset: number, options?: { behavior?: scrollbehavior }) => void` | scroll to a raw pixel offset |\n| `scrolltotop` | `(options?: { behavior?: scrollbehavior }) => void` | scroll to offset `0` |\n| `scrolltobottom` | `(options?: { behavior?: scrollbehavior }) => void` | scroll to the end of the list |\n| `isatend` | `(threshold?: number) => boolean` | `true` when within `threshold` px (default `0`) of the end — check before appending items to decide whether to auto follow (chat \"stick to bottom\") |\n| `invalidate` | `() => void` | clear all measurements and rebuild from estimates |\n| `dispose` | `() => void` | detach listeners; idempotent |\n| `[symbol.dispose]` | `() => void` | delegates to `dispose()` — enables `using` declarations |\n\n### `update(next)`\n\natomically updates one or more live options. accepts: `automeasure`, `count`, `estimatesize`, `gap`, `getitemkey`, `keyboardscroll`, `measurementcache`, `onchange`, `onscrollend`, `onscrollingchange`, `overscan`, `scrollenddelay`, and `sticky`. `horizontal` and `initialoffset` remain construction only. invalid static numeric values throw `scrollconfigurationerror` before any update applies.\n\nwhen `estimatesize` changes, the measurement cache is cleared and a scroll anchor is applied to keep the current viewport position visually stable.\n\n```ts\nvirt.update({ count: rows.length });\nvirt.update({ estimatesize: 40 });\nvirt.update({ gap: 8, overscan: { start: 5, end: 5 } });\n```\n\n### `measure(index, size)` and `measurebatch(entries)`\n\nreport exact sizes for variable height rows. calls within one microtask tick coalesce into a single offset rebuild. `measure()` is a no op when the new size equals the current effective size.\n\n```ts\nvirt.measure(item.index, el.offsetheight);\n\n// prefer measurebatch for resizeobserver batches\nvirt.measurebatch(entries.map((e) => ({ index: number(e.target.dataset.index), size: e.contentrect.height })));\n```\n\n### `measureel(index, el)`\n\nattaches a `resizeobserver` to auto measure `el` on resize. returns a disconnect function. the\nobserver is also disconnected automatically when the virtualizer is disposed, so calling the\nreturned function is only needed to stop observing a specific element early (e.g. before it is\nrecycled or removed).\n\n```ts\nconst disconnect = virt.measureel(item.index, rowel);\n// later: disconnect();\n```\n\n### `refresh()`\n\nrebuilds the full offset table and re emits. preserves cached measurements. use after reordering, filtering, or any data change where sizes may have changed.\n\n### `prepend(additionalcount)`\n\nadds `additionalcount` items at the front while adjusting scroll offset so the viewport stays visually stable. use for \"load previous page\" patterns.\n\n### `scrolltoindex(index, options?)`\n\nscroll to an item. out of range indices are clamped silently.\n\n| `align` | behavior |\n| | |\n| `'start'` | item top at viewport top |\n| `'end'` | item bottom at viewport bottom |\n| `'center'` | item centered in the viewport |\n| `'auto'` (default) | no scroll if already fully visible; otherwise minimum scroll |\n\n```ts\nvirt.scrolltoindex(0, { align: 'start' });\nvirt.scrolltoindex(500, { align: 'center', behavior: 'smooth' });\nvirt.scrolltoindex(focusedindex, { align: 'auto' });\n```\n\n### `scrolltooffset(offset, options?)`\n\n```ts\nvirt.scrolltooffset(number(sessionstorage.getitem('scrolloffset') ?? '0'));\n```\n\n### `invalidate()`\n\nclears all measured sizes and rebuilds from estimator values.\n\n```ts\ndocument.fonts.ready.then(() => virt.invalidate());\n```\n\n### `dispose()` and `[symbol.dispose]()`\n\n`dispose()` detaches observers and event listeners. it is idempotent.\n\n```ts\n{\n using virt = createvirtualizer(scrollel, { count: rows.length, onchange: render });\n} // → dispose() called automatically\n```\n\n## `createdomvirtuallist(options)`\n\n```ts\ncreatedomvirtuallist<t>(options: domvirtuallistoptions<t>): domvirtuallistcontroller<t>;\n```\n\ndom focused adapter. manages virtualizer lifecycle, applies list height styles automatically, and provides a node pool via `recycle`. the virtualizer is created lazily on the first non empty `setitems()` call and destroyed automatically when `setitems([])` is called.\n\n```ts\nimport { createdomvirtuallist } from '@vielzeug/scroll';\n\nconst ctrl = createdomvirtuallist<row>({\n estimatesize: 36,\n getitemkey: (_, row) => row.id,\n listelement: listel,\n scrollelement: scrollel,\n render: ({ items, listel, recycle }) => {\n for (const item of items) {\n const el = recycle(item.data.id, () => document.createelement('div'));\n el.style.csstext = `position:absolute;top:0;left:0;right:0;transform:translatey(${item.start}px);height:${item.size}px;`;\n el.textcontent = item.data.label;\n listel.appendchild(el);\n }\n },\n});\n\nctrl.setitems(rows);\nctrl.scrolltoindex(focusedindex, { align: 'auto' });\nctrl.dispose();\n```\n\n### `domvirtuallistoptions<t>`\n\n| option | type | default | description |\n| | | | |\n| `scrollelement` | `htmlelement \\| window` | required | scroll container to observe |\n| `listelement` | `htmlelement` | required | element that receives height and item children |\n| `render` | `(args: domvirtuallistrenderargs<t>) => void` | required | called on every visible window change |\n| `estimatesize` | `number \\| (index, item) => number` | `36` | fixed or per item size estimate |\n| `gap` | `number` | `0` | gap between items in pixels |\n| `getitemkey` | `(index, item) => string \\| number` | — | stable key; keeps measurements across `setitems()` calls |\n| `horizontal` | `boolean` | `false` | virtualize along x axis |\n| `keyboardscroll` | `boolean` | `false` | enable keyboard navigation (arrow/page/home/end keys) |\n| `measurementcache` | `measurementcache` | — | external measurement cache |\n| `overscan` | `number \\| { start?: number; end?: number }` | `3` | extra items outside the viewport; number = symmetric |\n| `signal` | `(init: virtualizerstate) => signal<virtualizerstate>` | — | optional signal factory to expose state as a reactive signal |\n| `sticky` | `(index: number, item: t) => boolean` | — | mark items as sticky headers |\n| `clear` | `(listel: htmlelement) => void` | — | custom teardown for listel; defaults to `textcontent = ''` |\n| `sticktobottom` | `boolean \\| sticktobottomoptions` | — | auto scroll to the end after `setitems()` whenever the list was already at (or near) the end — the chat \"stick to bottom on new message\" pattern |\n\nwithout `getitemkey`, each `setitems()` call drops cached measurements.\n\n### `sticktobottomoptions`\n\n| option | type | default | description |\n| | | | |\n| `enabled` | `boolean` | `true` | enable/disable at runtime — pass the object form to toggle without removing it |\n| `threshold` | `number` | `48` | distance in pixels from the end still considered \"at the end\" |\n\n`sticktobottom` fires on **any** `setitems()` call made while the list is at the end — not just when the item count grows. this also follows a streaming last item that grows in place (same array length, bigger content) without you needing to detect that case yourself. it never fires while the user has scrolled away from the end, so reading older messages is never interrupted.\n\n```ts\nconst chat = createdomvirtuallist<message>({\n estimatesize: 48,\n getitemkey: (_, m) => m.id,\n listelement: listel,\n render: rendermessages,\n scrollelement: scrollel,\n sticktobottom: true, // or { threshold: 80 } for a larger \"still at bottom\" tolerance\n});\n\nchat.setitems(messages); // scrolls to bottom on first load\n// … later, a new message arrives (or the last one grows while streaming) …\nchat.setitems([...messages, newmessage]); // follows along only if the user was already at the bottom\n```\n\n### `domvirtuallistrenderargs<t>`\n\n```ts\ntype domvirtuallistrenderargs<t> = {\n items: array<virtualrenderitem<t>>; // visible items — each has .data + layout fields\n listel: htmlelement;\n recycle: recyclefn; // node pool — returns existing node or calls create()\n stickyitems: array<virtualrenderitem<t>>; // sticky items (requires sticky option)\n totalsize: number;\n};\n```\n\n`virtualrenderitem<t>` is `virtualitem` (`start`, `end`, `size`, `index`) enriched with `data: t`.\n\n`recycle(key, create)` returns a live node for `key` if one exists in the pool, or calls `create()` for a new one. nodes not reused in a render cycle are removed automatically. `listel.style.height` is set before `render` is called — you do not need to set it yourself.\n\n### `domvirtuallistcontroller<t>`\n\nextends `virtualizer` (minus `prepend` and `update`) with `setitems()`. all virtualizer methods and live getters are available directly.\n\n| member | description |\n| | |\n| `setitems(items)` | set the current item array. spawns virtualizer on first non empty call; destroys it on `[]` |\n| `count` | current item count (live getter) |\n| `disposalsignal` | `abortsignal` aborted on `dispose()` |\n| `isscrolling` | `true` while the user is scrolling; `false` once settled (live getter) |\n| `items` | currently rendered virtual items (live getter) |\n| `totalsize` | total list size in pixels (live getter) |\n| `scrolloffset` | current scroll position (live getter) |\n| `stickyitems` | sticky items pinned at viewport top (live getter) |\n| `measure` | delegate to underlying virtualizer; no op before first `setitems` |\n| `measurebatch` | batch measurement delegate |\n| `measureel` | attach auto measuring resizeobserver |\n| `refresh` | rebuild offset table and re emit |\n| `invalidate` | clear measurements and rebuild from estimates |\n| `scrolltoindex` | scroll to an item |\n| `scrolltooffset` | scroll to a pixel offset |\n| `scrolltotop` | scroll to offset `0` |\n| `scrolltobottom` | scroll to the end of the list |\n| `isatend` | `true` when within `threshold` px of the end |\n| `dispose` | teardown; idempotent |\n| `disposed` | `true` after `dispose()` is called (live getter) |\n| `[symbol.dispose]` | delegates to `dispose()` |\n\n## `createvirtualscroller(container, options)`\n\n```ts\ncreatevirtualscroller<t>(container: htmlelement, options: virtualscrolleroptions<t>): domvirtuallistcontroller<t>;\n```\n\ncreates a scroll container `div` and inner list `div`, appends them to `container`, and returns a fully wired `domvirtuallistcontroller`. useful when the scroll dom doesn't already exist.\n\n```ts\nconst list = createvirtualscroller<row>(document.getelementbyid('root')!, {\n estimatesize: 36,\n render: ({ items, listel, recycle }) => {\n for (const item of items) {\n const el = recycle(item.data.id, () => document.createelement('div'));\n el.textcontent = item.data.label;\n el.style.csstext = `position:absolute;top:0;left:0;right:0;transform:translatey(${item.start}px);`;\n listel.appendchild(el);\n }\n },\n});\n\nlist.setitems(rows);\nlist.dispose(); // also removes the generated scroll container\n```\n\n`virtualscrolleroptions<t>` is `domvirtuallistoptions<t>` minus `listelement`/`scrollelement`, plus:\n\n| option | type | description |\n| | | |\n| `containerclass` | `string` | css class applied to the generated scroll element |\n\n`dispose()` removes the generated scroll container from the dom.\n\n## `creategroupedvirtualizer(target, options)`\n\n```ts\ncreategroupedvirtualizer<t>(target: scrolltarget, options: groupvirtualizeroptions<t>): groupvirtualizer<t>;\n```\n\nvirtualizes a sectioned list. headers are automatically sticky (pinned at viewport top while the section is in view).\n\n```ts\nimport { creategroupedvirtualizer } from '@vielzeug/scroll';\n\ntype contact = { id: number; name: string };\n\nconst virt = creategroupedvirtualizer<contact>(scrollel, {\n estimateheadersize: 32,\n estimateitemsize: 48,\n sections: [\n { label: 'a', items: [{ id: 1, name: 'alice' }] },\n { label: 'b', items: [{ id: 2, name: 'bob' }] },\n ],\n onchange: ({ headers, items, stickyheader, totalsize }) => {\n listel.style.height = `${totalsize}px`;\n listel.replacechildren();\n\n if (stickyheader) {\n const el = document.createelement('div');\n el.classname = 'sticky header';\n el.textcontent = stickyheader.label;\n listel.appendchild(el);\n }\n\n for (const header of headers) {\n const el = document.createelement('div');\n el.style.csstext = `position:absolute;top:${header.start}px;height:${header.size}px;`;\n el.textcontent = header.label;\n listel.appendchild(el);\n }\n\n for (const item of items) {\n const el = document.createelement('div');\n el.style.csstext = `position:absolute;top:${item.start}px;height:${item.size}px;`;\n el.textcontent = item.data.name;\n listel.appendchild(el);\n }\n },\n});\n\nvirt.scrolltosection(1, { align: 'start' });\nvirt.update(nextsections);\nvirt.dispose();\n```\n\n### `groupvirtualizeroptions<t>`\n\n| option | type | default | description |\n| | | | |\n| `sections` | `array<groupsection<t>>` | required | initial sections |\n| `onchange` | `(state: groupvirtualizerstate<t>) => void` | — | called when the visible window changes; replace through `update()`. |\n| `onscrollend` | `(offset: number) => void` | — | called when scrolling settles; replace through `update()`. |\n| `onscrollingchange` | `(isscrolling: boolean) => void` | — | called when scroll activity starts or stops; replace through `update()`. |\n| `estimateheadersize` | `number \\| (section, sectionindex) => number` | `36` | header height estimate |\n| `estimateitemsize` | `number \\| (item, itemindex, sectionindex) => number` | `36` | item height estimate |\n| `getitemkey` | `(item: t, itemindex: number, sectionindex: number) => virtualkey` | — | stable key for measurement cache |\n| `horizontal` | `boolean` | `false` | virtualize along x axis |\n| `measurementcache` | `measurementcache` | — | external measurement cache |\n| `overscan` | `number \\| { start?: number; end?: number }` | `3` | overscan on each side (number = symmetric) |\n| `scrollenddelay` | `number` | `150` | debounce delay (ms) for scroll end detection |\n| `signal` | `(init: groupvirtualizerstate<t>) => signal<groupvirtualizerstate<t>>` | — | optional signal factory to expose state as a reactive signal |\n\n### `groupsection<t>`\n\n```ts\ninterface groupsection<t> {\n items: t[];\n label: string;\n}\n```\n\n### `groupvirtualizerstate<t>`\n\n```ts\ninterface groupvirtualizerstate<t> {\n readonly headers: groupvirtualheader[];\n readonly items: array<groupvirtualitem<t>>;\n readonly stickyheader: groupvirtualheader | null;\n readonly totalsize: number;\n}\n```\n\n`stickyheader` is the header of the section currently at or above the viewport top, or `null` when at the very top. render it as a floating overlay above the list.\n\n### `groupvirtualitem<t>` and `groupvirtualheader`\n\n```ts\ninterface groupvirtualitem<t> extends virtualitem {\n data: t;\n itemindex: number; // index within the section\n sectionindex: number;\n}\n\ninterface groupvirtualheader extends virtualitem {\n label: string;\n sectionindex: number;\n}\n```\n\n### `groupvirtualizer<t>` — methods\n\n`groupvirtualizer<t>` is an independent interface that exposes all core virtualizer methods directly, plus grouped specific navigation.\n\n| method / property | description |\n| | |\n| `update(sections, opts?)` | replace all sections with optional config overrides; see `groupvirtualizerupdateoptions<t>` |\n| `scrolltosection(i, options?)` | scroll to section header at index `i`. out of range is a no op |\n| `scrolltoitem(s, i, options?)` | scroll to item `i` in section `s`. out of range is a no op |\n| `scrolltoindex(i, options?)` | scroll to flat index `i` (from underlying virtualizer) |\n| `scrolltooffset(offset, options?)` | scroll to a raw pixel offset |\n| `scrolltotop(options?)` | scroll to offset `0` |\n| `scrolltobottom(options?)` | scroll to the end of the list |\n| `measure(index, size)` | record a measurement for a flat index |\n| `measurebatch(entries)` | batch record measurements for flat indices |\n| `measureel(index, el)` | attach auto measuring resizeobserver. returns disconnect function |\n| `invalidate()` | clear all measurements and rebuild |\n| `refresh()` | rebuild offset table without clearing measurements |\n| `count` | total flat item count (live getter) |\n| `disposalsignal` | `abortsignal` aborted on `dispose()` |\n| `isscrolling` | `true` while the user is scrolling; `false` once scroll settles |\n| `items` | currently rendered group items (live getter) |\n| `scrolloffset` | current scroll position in pixels (live getter) |\n| `stickyitems` | sticky items pinned at viewport top (live getter) |\n| `totalsize` | total list size in pixels (live getter) |\n| `dispose()` | teardown; idempotent |\n| `disposed` | `true` after `dispose()` is called |\n| `[symbol.dispose]()` | delegates to `dispose()` |\n\nall scroll methods accept an optional `scrolltoindexoptions` object (`{ align?, behavior?, oncomplete? }`).\n\n### `groupvirtualizerupdateoptions<t>`\n\npassed as the second argument to `groupvirtualizer.update()`. all fields are optional — omit any you don't want to change.\n\n| option | type | description |\n| | | |\n| `estimateheadersize` | `number \\| (section, sectionindex) => number` | new header size estimate, applied on next rebuild |\n| `estimateitemsize` | `number \\| (item, itemindex, sectionindex) => number` | new item size estimate, applied on next rebuild |\n| `getitemkey` | `(item, itemindex, sectionindex) => virtualkey` | new item key function |\n| `measurementcache` | `measurementcache` | hot swap the measurement cache |\n| `onchange` | `(state: groupvirtualizerstate<t>) => void` | replace the active onchange callback |\n| `onscrollend` | `(offset: number) => void` | replace the active onscrollend callback |\n| `onscrollingchange` | `(isscrolling: boolean) => void` | replace the active onscrollingchange callback |\n| `overscan` | `number \\| { start?, end? }` | new overscan count |\n| `scrollenddelay` | `number` | new debounce delay (ms) for scroll end detection |\n\n> `horizontal` remains construction only.\n\n## `creategridvirtualizer(target, options)`\n\n```ts\ncreategridvirtualizer(target: scrolltarget, options: gridvirtualizeroptions): gridvirtualizer;\n```\n\ntwo dimensional virtualizer. fires `onchange` with visible row and column descriptors. callers form the cross product `rows × cols` to render visible cells.\n\n```ts\nimport { creategridvirtualizer } from '@vielzeug/scroll';\n\nconst grid = creategridvirtualizer(scrollel, {\n rowcount: 10_000,\n colcount: 50,\n estimaterowsize: 36,\n estimatecolsize: 120,\n onchange: ({ rows, cols, totalheight, totalwidth }) => {\n containerel.style.csstext = `position:relative;height:${totalheight}px;width:${totalwidth}px;`;\n containerel.replacechildren();\n\n for (const row of rows) {\n for (const col of cols) {\n const cell = document.createelement('div');\n cell.style.csstext = `position:absolute;top:${row.start}px;left:${col.start}px;height:${row.size}px;width:${col.size}px;`;\n cell.textcontent = `${row.index},${col.index}`;\n containerel.appendchild(cell);\n }\n }\n },\n});\n\ngrid.scrolltocell(500, 10, { rowalign: 'center', colalign: 'start' });\ngrid.dispose();\n```\n\n### `gridvirtualizeroptions`\n\n| option | type | default | description |\n| | | | |\n| `rowcount` | `number` | required | total row count |\n| `colcount` | `number` | required | total column count |\n| `estimaterowsize` | `number \\| (row) => number` | `36` | row height estimate |\n| `estimatecolsize` | `number \\| (col) => number` | `36` | column width estimate |\n| `rowgap` | `number` | `0` | gap between rows |\n| `colgap` | `number` | `0` | gap between columns |\n| `overscany` | `{ start?: number; end?: number }` | `{ start: 3, end: 3 }` | row overscan |\n| `overscanx` | `{ start?: number; end?: number }` | `{ start: 3, end: 3 }` | column overscan |\n| `initialscrolltop` | `number` | — | initial vertical scroll position |\n| `initialscrollleft` | `number` | — | initial horizontal scroll position |\n| `keyboardscroll` | `boolean` | `false` | enable keyboard navigation (arrow/page/home/end keys) |\n| `onchange` | `(state: gridvirtualizerstate) => void` | — | called when the visible window changes |\n| `onrangechange` | `(range: gridrangechangeevent) => void` | — | zero allocation range callback |\n| `rowmeasurementcache` | `map<number, number>` | — | external row measurement cache |\n| `colmeasurementcache` | `map<number, number>` | — | external column measurement cache |\n| `signal` | `(init: gridvirtualizerstate) => signal<gridvirtualizerstate>` | — | optional signal factory to expose state as a reactive signal |\n\n### `gridvirtualizerstate`\n\n```ts\ninterface gridvirtualizerstate {\n readonly cols: virtualitem[];\n readonly rows: virtualitem[];\n readonly totalheight: number;\n readonly totalwidth: number;\n}\n```\n\n### `gridvirtualizer` — properties and methods\n\n**read only properties:** `rows`, `cols`, `scrolltop`, `scrollleft`, `totalheight`, `totalwidth`, `disposalsignal`, `disposed`\n\n| method | description |\n| | |\n| `update(next)` | atomically update row/col counts, estimates, gaps, and overscan |\n| `measurerow(row, size)` | record a row height |\n| `measurecolumn(col, size)` | record a column width |\n| `measurebatch(rows, cols)` | measure rows and columns in a single coordinated rebuild pass |\n| `measurerowel(row, el)` | auto measure row height via resizeobserver. returns disconnect fn |\n| `measurecolel(col, el)` | auto measure column width via resizeobserver. returns disconnect fn |\n| `refresh()` | rebuild offset tables from current measurements |\n| `invalidate()` | clear all measurements and rebuild from estimates |\n| `scrolltocell(row, col, options?)` | scroll to bring a cell into view; no op when `rowcount === 0` or `colcount === 0` |\n| `scrolltorow(row, options?)` | scroll to bring a row into view; `rowalign` controls alignment |\n| `scrolltocolumn(col, options?)` | scroll to bring a column into view; `colalign` controls alignment |\n| `prependrows(n)` | add `n` rows at the top; adjusts scroll offset to keep viewport stable |\n| `dispose()` | teardown; idempotent |\n| `[symbol.dispose]()` | delegates to `dispose()` |\n\n`measurerowel`/`measurecolel`'s `resizeobserver` is also disconnected automatically on `dispose()` —\nthe returned disconnect function is only needed to stop observing a specific element early.\n\n### `scrolltocelloptions`\n\n```ts\ninterface scrolltocelloptions {\n behavior?: scrollbehavior;\n colalign?: 'auto' | 'center' | 'end' | 'start';\n rowalign?: 'auto' | 'center' | 'end' | 'start';\n}\n```\n\n## types\n\n### `virtualitem`\n\n```ts\ninterface virtualitem {\n end: number;\n index: number;\n size: number;\n start: number;\n}\n```\n\n### `virtualizerstate`\n\n```ts\ninterface virtualizerstate {\n readonly items: virtualitem[];\n readonly stickyitems: virtualitem[];\n readonly totalsize: number;\n}\n```\n\n### `scrolltoindexoptions`\n\n```ts\ninterface scrolltoindexoptions {\n align?: 'auto' | 'center' | 'end' | 'start';\n behavior?: scrollbehavior;\n /** called when the scroll animation completes (instant scrolls: next microtask). */\n oncomplete?: () => void;\n}\n```\n\n### `overscan`\n\n```ts\ntype overscan = number | { end?: number; start?: number };\n```\n\npassing a number is shorthand for symmetric overscan on both sides.\n\n### `virtualkey`\n\n```ts\ntype virtualkey = number | string;\n```\n\n### `virtualrenderitem<t>`\n\n```ts\ntype virtualrenderitem<t> = virtualitem & { readonly data: t };\n```\n\n### `scrolltarget`\n\n```ts\ntype scrolltarget = htmlelement | window;\n```\n\n### `measurementcache`\n\n```ts\ntype measurementcache = map<virtualkey, number>;\n```\n\nuse `createmeasurementcache()` to create an empty cache:\n\n```ts\nimport { createmeasurementcache } from '@vielzeug/scroll';\n\nconst cache = createmeasurementcache();\nconst virt1 = createvirtualizer(el1, { count: 100, measurementcache: cache });\nconst virt2 = createvirtualizer(el2, { count: 100, measurementcache: cache });\n```\n\n### `recyclefn`\n\n```ts\ntype recyclefn = (key: virtualkey, create: () => htmlelement) => htmlelement;\n```\n\n### `virtualizerupdateoptions`\n\n```ts\ninterface virtualizerupdateoptions {\n automeasure?: boolean;\n count?: number;\n estimatesize?: number | ((index: number) => number);\n gap?: number;\n getitemkey?: ((index: number) => virtualkey) | undefined;\n keyboardscroll?: boolean;\n /** replace the active measurement cache. existing entries are used immediately on the next rebuild. */\n measurementcache?: measurementcache;\n onchange?: ((state: virtualizerstate) => void) | undefined;\n onscrollend?: ((offset: number) => void) | undefined;\n onscrollingchange?: ((isscrolling: boolean) => void) | undefined;\n overscan?: overscan;\n scrollenddelay?: number;\n sticky?: ((index: number) => boolean) | undefined;\n}\n```\n\n### `virtualscrolleroptions<t>`\n\n`domvirtuallistoptions<t>` minus `listelement` and `scrollelement`, plus:\n\n```ts\ntype virtualscrolleroptions<t> = omit<domvirtuallistoptions<t>, 'listelement' | 'scrollelement'> & {\n /** css class applied to the generated scroll container element. */\n containerclass?: string;\n};\n```\n\n### `gridvirtualizerupdateoptions`\n\n```ts\ninterface gridvirtualizerupdateoptions {\n colcount?: number;\n colgap?: number;\n estimatecolsize?: number | ((col: number) => number);\n estimaterowsize?: number | ((row: number) => number);\n keyboardscroll?: boolean;\n onchange?: ((state: gridvirtualizerstate) => void) | undefined;\n onrangechange?: ((range: gridrangechangeevent) => void) | undefined;\n overscanx?: overscan;\n overscany?: overscan;\n rowcount?: number;\n rowgap?: number;\n}\n```\n\n### `gridrangechangeevent`\n\nfired by `onrangechange` on `creategridvirtualizer`. zero allocation alternative to `onchange` — no `rows`/`cols` arrays are allocated.\n\n```ts\ninterface gridrangechangeevent {\n firstcol: number;\n firstrow: number;\n lastcol: number;\n lastrow: number;\n}\n```\n\n### `virtualizeroptions`\n\n```ts\ninterface virtualizeroptions {\n automeasure?: boolean;\n count: number;\n estimatesize?: number | ((index: number) => number);\n gap?: number;\n getitemkey?: (index: number) => virtualkey;\n horizontal?: boolean;\n initialoffset?: number;\n keyboardscroll?: boolean;\n measurementcache?: measurementcache;\n onchange?: (state: virtualizerstate) => void;\n onscrollend?: (offset: number) => void;\n onscrollingchange?: (isscrolling: boolean) => void;\n overscan?: overscan;\n scrollenddelay?: number;\n signal?: (init: virtualizerstate) => signal<virtualizerstate>;\n sticky?: (index: number) => boolean;\n}\n```\n\n### `virtualizer`\n\n```ts\ninterface virtualizer {\n readonly count: number;\n readonly disposalsignal: abortsignal;\n dispose: () => void;\n readonly disposed: boolean;\n invalidate: () => void;\n isatend: (threshold?: number) => boolean;\n readonly isscrolling: boolean;\n readonly items: virtualitem[];\n measure: (index: number, size: number) => void;\n measurebatch: (entries: array<{ index: number; size: number }>) => void;\n measureel: (index: number, el: htmlelement) => () => void;\n prepend: (additionalcount: number) => void;\n refresh: () => void;\n readonly scrolloffset: number;\n scrolltobottom: (options?: { behavior?: scrollbehavior }) => void;\n scrolltoindex: (index: number, options?: scrolltoindexoptions) => void;\n scrolltooffset: (offset: number, options?: { behavior?: scrollbehavior }) => void;\n scrolltotop: (options?: { behavior?: scrollbehavior }) => void;\n readonly stickyitems: virtualitem[];\n readonly totalsize: number;\n update: (next: virtualizerupdateoptions) => void;\n [symbol.dispose]: () => void;\n}\n```\n\n### `sticktobottomoptions`\n\n```ts\ntype sticktobottomoptions = {\n enabled?: boolean;\n threshold?: number;\n};\n```\n\n### `domvirtuallistoptions<t>`\n\n```ts\ntype domvirtuallistoptions<t> = {\n clear?: (listel: htmlelement) => void;\n estimatesize?: number | ((index: number, item: t) => number);\n gap?: number;\n getitemkey?: (index: number, item: t) => virtualkey;\n horizontal?: boolean;\n keyboardscroll?: boolean;\n listelement: htmlelement;\n measurementcache?: measurementcache;\n overscan?: overscan;\n render: (args: domvirtuallistrenderargs<t>) => void;\n scrollelement: htmlelement | window;\n sticktobottom?: boolean | sticktobottomoptions;\n sticky?: (index: number, item: t) => boolean;\n signal?: (init: virtualizerstate) => signal<virtualizerstate>;\n};\n```\n\n### `domvirtuallistcontroller<t>`\n\n`virtualizer` minus `prepend` and `update`, plus `setitems()`.\n\n```ts\ntype domvirtuallistcontroller<t> = omit<virtualizer, 'prepend' | 'update'> & {\n setitems: (items: t[]) => void;\n};\n```\n\n### `domvirtuallistrenderargs<t>`\n\n```ts\ntype domvirtuallistrenderargs<t> = {\n items: array<virtualrenderitem<t>>;\n listel: htmlelement;\n recycle: recyclefn;\n stickyitems: array<virtualrenderitem<t>>;\n totalsize: number;\n};\n```\n\n### `groupsection<t>`\n\n```ts\ninterface groupsection<t> {\n items: t[];\n label: string;\n}\n```\n\n### `groupvirtualizerstate<t>`\n\n```ts\ninterface groupvirtualizerstate<t> {\n readonly headers: groupvirtualheader[];\n readonly items: array<groupvirtualitem<t>>;\n readonly stickyheader: groupvirtualheader | null;\n readonly totalsize: number;\n}\n```\n\n### `groupvirtualitem<t>`\n\n```ts\ninterface groupvirtualitem<t> extends virtualitem {\n data: t;\n itemindex: number;\n sectionindex: number;\n}\n```\n\n### `groupvirtualheader`\n\n```ts\ninterface groupvirtualheader extends virtualitem {\n label: string;\n sectionindex: number;\n}\n```\n\n### `groupvirtualizeroptions<t>`\n\n```ts\ninterface groupvirtualizeroptions<t> {\n estimateheadersize?: number | ((section: groupsection<t>, sectionindex: number) => number);\n estimateitemsize?: number | ((item: t, itemindex: number, sectionindex: number) => number);\n getitemkey?: (item: t, itemindex: number, sectionindex: number) => virtualkey;\n horizontal?: boolean;\n measurementcache?: measurementcache;\n onchange?: (state: groupvirtualizerstate<t>) => void;\n onscrollend?: (offset: number) => void;\n onscrollingchange?: (isscrolling: boolean) => void;\n overscan?: overscan;\n scrollenddelay?: number;\n sections: array<groupsection<t>>;\n signal?: (init: groupvirtualizerstate<t>) => signal<groupvirtualizerstate<t>>;\n}\n```\n\n### `groupvirtualizerupdateoptions<t>`\n\n```ts\ninterface groupvirtualizerupdateoptions<t> {\n estimateheadersize?: number | ((section: groupsection<t>, sectionindex: number) => number);\n estimateitemsize?: number | ((item: t, itemindex: number, sectionindex: number) => number);\n getitemkey?: (item: t, itemindex: number, sectionindex: number) => virtualkey;\n measurementcache?: measurementcache;\n onchange?: ((state: groupvirtualizerstate<t>) => void) | undefined;\n onscrollend?: ((offset: number) => void) | undefined;\n onscrollingchange?: ((isscrolling: boolean) => void) | undefined;\n overscan?: overscan;\n scrollenddelay?: number;\n}\n```\n\n### `groupvirtualizer<t>`\n\n```ts\ninterface groupvirtualizer<t> {\n readonly count: number;\n readonly disposalsignal: abortsignal;\n dispose: () => void;\n readonly disposed: boolean;\n invalidate: () => void;\n readonly isscrolling: boolean;\n readonly items: readonlyarray<groupvirtualitem<t>>;\n measure: (index: number, size: number) => void;\n measurebatch: (entries: array<{ index: number; size: number }>) => void;\n measureel: (index: number, el: htmlelement) => () => void;\n refresh: () => void;\n readonly scrolloffset: number;\n scrolltobottom: (options?: { behavior?: scrollbehavior }) => void;\n scrolltoindex: (index: number, options?: scrolltoindexoptions) => void;\n scrolltoitem: (sectionindex: number, itemindex: number, options?: scrolltoindexoptions) => void;\n scrolltooffset: (offset: number, options?: { behavior?: scrollbehavior }) => void;\n scrolltosection: (sectionindex: number, options?: scrolltoindexoptions) => void;\n scrolltotop: (options?: { behavior?: scrollbehavior }) => void;\n readonly stickyitems: virtualitem[];\n readonly totalsize: number;\n update: (sections: array<groupsection<t>>, opts?: groupvirtualizerupdateoptions<t>) => void;\n [symbol.dispose]: () => void;\n}\n```\n\n### `gridvirtualizerstate`\n\n```ts\ninterface gridvirtualizerstate {\n readonly cols: virtualitem[];\n readonly rows: virtualitem[];\n readonly totalheight: number;\n readonly totalwidth: number;\n}\n```\n\n### `scrolltocelloptions`\n\n```ts\ninterface scrolltocelloptions {\n behavior?: scrollbehavior;\n colalign?: 'auto' | 'center' | 'end' | 'start';\n rowalign?: 'auto' | 'center' | 'end' | 'start';\n}\n```\n\n### `gridvirtualizeroptions`\n\n```ts\ninterface gridvirtualizeroptions {\n colcount: number;\n colgap?: number;\n colmeasurementcache?: map<number, number>;\n estimatecolsize?: number | ((col: number) => number);\n estimaterowsize?: number | ((row: number) => number);\n initialscrollleft?: number;\n initialscrolltop?: number;\n keyboardscroll?: boolean;\n onchange?: (state: gridvirtualizerstate) => void;\n onrangechange?: (range: gridrangechangeevent) => void;\n overscanx?: overscan;\n overscany?: overscan;\n rowcount: number;\n rowgap?: number;\n rowmeasurementcache?: map<number, number>;\n signal?: (init: gridvirtualizerstate) => signal<gridvirtualizerstate>;\n}\n```\n\n### `gridvirtualizer`\n\n```ts\ninterface gridvirtualizer {\n readonly cols: virtualitem[];\n readonly disposalsignal: abortsignal;\n dispose: () => void;\n readonly disposed: boolean;\n invalidate: () => void;\n measurebatch: (rows: array<{ index: number; size: number }>, cols: array<{ index: number; size: number }>) => void;\n measurecolel: (col: number, el: htmlelement) => () => void;\n measurecolumn: (col: number, size: number) => void;\n measurerow: (row: number, size: number) => void;\n measurerowel: (row: number, el: htmlelement) => () => void;\n prependrows: (additionalrowcount: number) => void;\n refresh: () => void;\n readonly rows: virtualitem[];\n readonly scrollleft: number;\n scrolltocell: (row: number, col: number, options?: scrolltocelloptions) => void;\n scrolltocolumn: (col: number, options?: pick<scrolltocelloptions, 'behavior' | 'colalign'>) => void;\n readonly scrolltop: number;\n scrolltorow: (row: number, options?: pick<scrolltocelloptions, 'behavior' | 'rowalign'>) => void;\n readonly totalheight: number;\n readonly totalwidth: number;\n update: (next: gridvirtualizerupdateoptions) => void;\n [symbol.dispose]: () => void;\n}\n```\n\n## errors\n\n| class | thrown when | notable properties |\n| | | |\n| `scrollerror` | base class for every scroll error. | `scrollerror.is(error)` narrows errors from this package. |\n| `scrollconfigurationerror` | a constructor or `update()` receives invalid static configuration. | extends `scrollerror`; malformed javascript values also use this class. |\n| `scrollrangeerror` | a dom virtual list render detects that a caller mutated its items array without calling `setitems()` again. | extends `scrollerror`; message includes stale index and current item count. |\n\nruntime estimator failures, stale measurements, and out of range navigation remain resilient: they fall back, no op, or clamp as documented.\n\n### constants\n\n```ts\nconst default_estimate_size = 36; // default estimatesize\nconst default_overscan = 3; // default overscan on each side\n```\n",
|
|
1163
|
+
"api": " \ntitle: scroll — api reference\ndescription: complete api reference for the scroll virtual list engine.\n \n\n[[toc]]\n\n## api overview\n\n| symbol | purpose | execution mode | common gotcha |\n| | | | |\n| `createvirtualizer()` | core 1d virtualizer | sync | `onchange` fires on construction — wire dom first |\n| `createdomvirtuallist()` | dom adapter for dropdown/listbox uis | sync | virtualizer is created lazily on first `setitems()` |\n| `createvirtualscroller()` | self contained scroller (creates dom) | sync | `dispose()` removes the generated scroll element |\n| `creategroupedvirtualizer()` | sectioned list with sticky headers | sync | `update()` preserves measured sizes — call `invalidate()` only on font/layout changes |\n| `creategridvirtualizer()` | two dimensional grid virtualizer | sync | `onrangechange` fires even when `onchange` is omitted |\n\n## package entry point\n\neverything exports from a single entry:\n\n```ts\nimport {\n createvirtualizer,\n createdomvirtuallist,\n createvirtualscroller,\n creategroupedvirtualizer,\n creategridvirtualizer,\n createmeasurementcache,\n default_estimate_size,\n default_overscan,\n scrollerror,\n scrollconfigurationerror,\n scrollrangeerror,\n type virtualizer,\n type virtualitem,\n type virtualizerstate,\n type virtualizeroptions,\n type virtualizerupdateoptions,\n type scrolltoindexoptions,\n type overscan,\n type virtualkey,\n type measurementcache,\n type scrolltarget,\n type domvirtuallistoptions,\n type domvirtuallistcontroller,\n type domvirtuallistrenderargs,\n type recyclefn,\n type virtualrenderitem,\n type sticktobottomoptions,\n type virtualscrolleroptions,\n type groupsection,\n type groupvirtualizer,\n type groupvirtualizeroptions,\n type groupvirtualizerstate,\n type groupvirtualizerupdateoptions,\n type groupvirtualheader,\n type groupvirtualitem,\n type gridvirtualizer,\n type gridvirtualizeroptions,\n type gridvirtualizerstate,\n type gridvirtualizerupdateoptions,\n type gridrangechangeevent,\n type scrolltocelloptions,\n} from '@vielzeug/scroll';\n```\n\n## `createvirtualizer(target, options)`\n\n```ts\ncreatevirtualizer(target: scrolltarget, options: virtualizeroptions): virtualizer;\n```\n\ncreates and immediately attaches a virtualizer to the provided scroll container. `onchange` fires synchronously on construction with the initial visible window. call `dispose()` on unmount.\n\n```ts\nimport { createvirtualizer } from '@vielzeug/scroll';\n\nconst rows = [{ label: 'ada lovelace' }, { label: 'grace hopper' }];\nconst scrollel = document.queryselector<htmlelement>('.scroll container')!;\nconst listel = document.queryselector<htmlelement>('.list')!;\n\nconst virt = createvirtualizer(scrollel, {\n count: rows.length,\n estimatesize: 36,\n gap: 8,\n onchange: ({ items, totalsize }) => {\n listel.style.height = `${totalsize}px`;\n listel.replacechildren();\n\n for (const item of items) {\n const row = document.createelement('div');\n row.style.csstext = `position:absolute;top:${item.start}px;left:0;right:0;height:${item.size}px;`;\n row.textcontent = rows[item.index]?.label ?? '';\n listel.appendchild(row);\n }\n },\n});\n```\n\n### parameters\n\n| parameter | type | description |\n| | | |\n| `target` | `htmlelement \\| window` | scroll container to observe |\n| `options` | `virtualizeroptions` | initial options |\n\n### `virtualizeroptions`\n\n| option | type | default | description |\n| | | | |\n| `count` | `number` | required | total item count |\n| `estimatesize` | `number \\| (index: number) => number` | `36` | fixed size or per index estimate in pixels |\n| `gap` | `number` | `0` | gap between adjacent items in pixels |\n| `getitemkey` | `(index: number) => string \\| number` | `index => index` | stable key for the measurement cache |\n| `horizontal` | `boolean` | `false` | virtualize along the x axis instead of y |\n| `initialoffset` | `number` | — | initial scroll position; applied once on construction |\n| `keyboardscroll` | `boolean` | `false` | enable keyboard navigation (arrow/page/home/end keys) |\n| `automeasure` | `boolean` | `false` | automatically measure visible items via resizeobserver |\n| `measurementcache` | `measurementcache` | — | shared external cache for scroll restoration or ssr pre measurement |\n| `onchange` | `(state: virtualizerstate) => void` | — | called when the visible window changes; replace through `update()`. |\n| `onscrollend` | `(offset: number) => void` | — | called when scrolling settles; replace through `update()`. |\n| `onscrollingchange` | `(isscrolling: boolean) => void` | — | called when scroll activity starts or stops; replace through `update()`. |\n| `overscan` | `number \\| { start?: number; end?: number }` | `3` | extra items outside the viewport; number = symmetric on both sides |\n| `scrollenddelay` | `number` | `150` | debounce delay (ms) used to detect scroll end when native `scrollend` is unavailable |\n| `signal` | `(init: virtualizerstate) => signal<virtualizerstate>` | — | optional signal factory to expose state as a reactive signal |\n| `sticky` | `(index: number) => boolean` | — | mark an item as a sticky header (pinned at viewport top) |\n\ncallbacks and `scrollenddelay` can be replaced through `update()`; `horizontal` and `initialoffset` remain construction only.\n\n**returns:** `virtualizer`\n\n### `virtualizerstate`\n\n```ts\ninterface virtualizerstate {\n readonly items: virtualitem[];\n readonly stickyitems: virtualitem[];\n readonly totalsize: number;\n}\n```\n\n`items` contains the currently visible items plus overscan. `stickyitems` contains items marked sticky that are pinned at the viewport top.\n\n### `virtualizer` — read only properties\n\n| property | type | description |\n| | | |\n| `count` | `number` | current item count |\n| `disposalsignal` | `abortsignal` | aborted when `dispose()` is called |\n| `disposed` | `boolean` | `true` after `dispose()` is called |\n| `isscrolling` | `boolean` | `true` while the user is scrolling; `false` once settled |\n| `items` | `virtualitem[]` | currently rendered items. always populated. |\n| `scrolloffset` | `number` | current scroll position in pixels |\n| `stickyitems` | `virtualitem[]` | items pinned at the viewport top (requires `sticky` option) |\n| `totalsize` | `number` | total height (or width in horizontal mode) |\n\n### `virtualizer` — methods\n\n| method | signature | description |\n| | | |\n| `update` | `(next: virtualizerupdateoptions) => void` | atomically update live options |\n| `measure` | `(index: number, size: number) => void` | record one measured size; rebuild batched in microtask |\n| `measurebatch` | `(entries: array<{ index: number; size: number }>) => void` | record many sizes; single rebuild |\n| `measureel` | `(index: number, el: htmlelement) => () => void` | attach resizeobserver to auto measure. returns a disconnect function |\n| `refresh` | `() => void` | rebuild offset table and re emit; preserves cached measurements |\n| `prepend` | `(additionalcount: number) => void` | add items at the top; adjusts scroll offset to keep viewport stable |\n| `scrolltoindex` | `(index: number, options?: scrolltoindexoptions) => void` | scroll to an item; out of range indices are clamped |\n| `scrolltooffset` | `(offset: number, options?: { behavior?: scrollbehavior }) => void` | scroll to a raw pixel offset |\n| `scrolltotop` | `(options?: { behavior?: scrollbehavior }) => void` | scroll to offset `0` |\n| `scrolltobottom` | `(options?: { behavior?: scrollbehavior }) => void` | scroll to the end of the list |\n| `isatend` | `(threshold?: number) => boolean` | `true` when within `threshold` px (default `0`) of the end — check before appending items to decide whether to auto follow (chat \"stick to bottom\") |\n| `invalidate` | `() => void` | clear all measurements and rebuild from estimates |\n| `dispose` | `() => void` | detach listeners; idempotent |\n| `[symbol.dispose]` | `() => void` | delegates to `dispose()` — enables `using` declarations |\n\n### `update(next)`\n\natomically updates one or more live options. accepts: `automeasure`, `count`, `estimatesize`, `gap`, `getitemkey`, `keyboardscroll`, `measurementcache`, `onchange`, `onscrollend`, `onscrollingchange`, `overscan`, `scrollenddelay`, and `sticky`. `horizontal` and `initialoffset` remain construction only. invalid static numeric values throw `scrollconfigurationerror` before any update applies.\n\nwhen `estimatesize` changes, the measurement cache is cleared and a scroll anchor is applied to keep the current viewport position visually stable.\n\n```ts\nvirt.update({ count: rows.length });\nvirt.update({ estimatesize: 40 });\nvirt.update({ gap: 8, overscan: { start: 5, end: 5 } });\n```\n\n### `measure(index, size)` and `measurebatch(entries)`\n\nreport exact sizes for variable height rows. calls within one microtask tick coalesce into a single offset rebuild. `measure()` is a no op when the new size equals the current effective size.\n\n```ts\nvirt.measure(item.index, el.offsetheight);\n\n// prefer measurebatch for resizeobserver batches\nvirt.measurebatch(entries.map((e) => ({ index: number(e.target.dataset.index), size: e.contentrect.height })));\n```\n\n### `measureel(index, el)`\n\nattaches a `resizeobserver` to auto measure `el` on resize. returns a disconnect function. the\nobserver is also disconnected automatically when the virtualizer is disposed, so calling the\nreturned function is only needed to stop observing a specific element early (e.g. before it is\nrecycled or removed).\n\n```ts\nconst disconnect = virt.measureel(item.index, rowel);\n// later: disconnect();\n```\n\n### `refresh()`\n\nrebuilds the full offset table and re emits. preserves cached measurements. use after reordering, filtering, or any data change where sizes may have changed.\n\n### `prepend(additionalcount)`\n\nadds `additionalcount` items at the front while adjusting scroll offset so the viewport stays visually stable. use for \"load previous page\" patterns.\n\n### `scrolltoindex(index, options?)`\n\nscroll to an item. out of range indices are clamped silently.\n\n| `align` | behavior |\n| | |\n| `'start'` | item top at viewport top |\n| `'end'` | item bottom at viewport bottom |\n| `'center'` | item centered in the viewport |\n| `'auto'` (default) | no scroll if already fully visible; otherwise minimum scroll |\n\n```ts\nvirt.scrolltoindex(0, { align: 'start' });\nvirt.scrolltoindex(500, { align: 'center', behavior: 'smooth' });\nvirt.scrolltoindex(focusedindex, { align: 'auto' });\n```\n\n### `scrolltooffset(offset, options?)`\n\n```ts\nvirt.scrolltooffset(number(sessionstorage.getitem('scrolloffset') ?? '0'));\n```\n\n### `invalidate()`\n\nclears all measured sizes and rebuilds from estimator values.\n\n```ts\ndocument.fonts.ready.then(() => virt.invalidate());\n```\n\n### `dispose()` and `[symbol.dispose]()`\n\n`dispose()` detaches observers and event listeners. it is idempotent.\n\n```ts\n{\n using virt = createvirtualizer(scrollel, { count: rows.length, onchange: render });\n} // → dispose() called automatically\n```\n\n## `createdomvirtuallist(options)`\n\n```ts\ncreatedomvirtuallist<t>(options: domvirtuallistoptions<t>): domvirtuallistcontroller<t>;\n```\n\ndom focused adapter. manages virtualizer lifecycle, applies list height styles automatically, and provides a node pool via `recycle`. the virtualizer is created lazily on the first non empty `setitems()` call and destroyed automatically when `setitems([])` is called.\n\n```ts\nimport { createdomvirtuallist } from '@vielzeug/scroll';\n\nconst ctrl = createdomvirtuallist<row>({\n estimatesize: 36,\n getitemkey: (_, row) => row.id,\n listelement: listel,\n scrollelement: scrollel,\n render: ({ items, listel, recycle }) => {\n for (const item of items) {\n const el = recycle(item.data.id, () => document.createelement('div'));\n el.style.csstext = `position:absolute;top:0;left:0;right:0;transform:translatey(${item.start}px);height:${item.size}px;`;\n el.textcontent = item.data.label;\n listel.appendchild(el);\n }\n },\n});\n\nctrl.setitems(rows);\nctrl.scrolltoindex(focusedindex, { align: 'auto' });\nctrl.dispose();\n```\n\n### `domvirtuallistoptions<t>`\n\n| option | type | default | description |\n| | | | |\n| `scrollelement` | `htmlelement \\| window` | required | scroll container to observe |\n| `listelement` | `htmlelement` | required | element that receives height and item children |\n| `render` | `(args: domvirtuallistrenderargs<t>) => void` | required | called on every visible window change |\n| `estimatesize` | `number \\| (index, item) => number` | `36` | fixed or per item size estimate |\n| `gap` | `number` | `0` | gap between items in pixels |\n| `getitemkey` | `(index, item) => string \\| number` | — | stable key; keeps measurements across `setitems()` calls |\n| `horizontal` | `boolean` | `false` | virtualize along x axis |\n| `keyboardscroll` | `boolean` | `false` | enable keyboard navigation (arrow/page/home/end keys) |\n| `measurementcache` | `measurementcache` | — | external measurement cache |\n| `overscan` | `number \\| { start?: number; end?: number }` | `3` | extra items outside the viewport; number = symmetric |\n| `signal` | `(init: virtualizerstate) => signal<virtualizerstate>` | — | optional signal factory to expose state as a reactive signal |\n| `sticky` | `(index: number, item: t) => boolean` | — | mark items as sticky headers |\n| `clear` | `(listel: htmlelement) => void` | — | custom teardown for listel; defaults to `textcontent = ''` |\n| `sticktobottom` | `boolean \\| sticktobottomoptions` | — | auto scroll to the end after `setitems()` whenever the list was already at (or near) the end — the chat \"stick to bottom on new message\" pattern |\n\nwithout `getitemkey`, each `setitems()` call drops cached measurements.\n\n### `sticktobottomoptions`\n\n| option | type | default | description |\n| | | | |\n| `enabled` | `boolean` | `true` | enable/disable at runtime — pass the object form to toggle without removing it |\n| `threshold` | `number` | `48` | distance in pixels from the end still considered \"at the end\" |\n\n`sticktobottom` fires on **any** `setitems()` call made while the list is at the end — not just when the item count grows. this also follows a streaming last item that grows in place (same array length, bigger content) without you needing to detect that case yourself. it never fires while the user has scrolled away from the end, so reading older messages is never interrupted.\n\n```ts\nconst chat = createdomvirtuallist<message>({\n estimatesize: 48,\n getitemkey: (_, m) => m.id,\n listelement: listel,\n render: rendermessages,\n scrollelement: scrollel,\n sticktobottom: true, // or { threshold: 80 } for a larger \"still at bottom\" tolerance\n});\n\nchat.setitems(messages); // scrolls to bottom on first load\n// … later, a new message arrives (or the last one grows while streaming) …\nchat.setitems([...messages, newmessage]); // follows along only if the user was already at the bottom\n```\n\n### `domvirtuallistrenderargs<t>`\n\n```ts\ntype domvirtuallistrenderargs<t> = {\n items: array<virtualrenderitem<t>>; // visible items — each has .data + layout fields\n listel: htmlelement;\n recycle: recyclefn; // node pool — returns existing node or calls create()\n stickyitems: array<virtualrenderitem<t>>; // sticky items (requires sticky option)\n totalsize: number;\n};\n```\n\n`virtualrenderitem<t>` is `virtualitem` (`start`, `end`, `size`, `index`) enriched with `data: t`.\n\n`recycle(key, create)` returns a live node for `key` if one exists in the pool, or calls `create()` for a new one. nodes not reused in a render cycle are removed automatically. `listel.style.height` is set before `render` is called — you do not need to set it yourself.\n\n### `domvirtuallistcontroller<t>`\n\nextends `virtualizer` (minus `prepend` and `update`) with `setitems()`. all virtualizer methods and live getters are available directly.\n\n| member | description |\n| | |\n| `setitems(items)` | set the current item array. spawns virtualizer on first non empty call; destroys it on `[]` |\n| `count` | current item count (live getter) |\n| `disposalsignal` | `abortsignal` aborted on `dispose()` |\n| `isscrolling` | `true` while the user is scrolling; `false` once settled (live getter) |\n| `items` | currently rendered virtual items (live getter) |\n| `totalsize` | total list size in pixels (live getter) |\n| `scrolloffset` | current scroll position (live getter) |\n| `stickyitems` | sticky items pinned at viewport top (live getter) |\n| `measure` | delegate to underlying virtualizer; no op before first `setitems` |\n| `measurebatch` | batch measurement delegate |\n| `measureel` | attach auto measuring resizeobserver |\n| `refresh` | rebuild offset table and re emit |\n| `invalidate` | clear measurements and rebuild from estimates |\n| `scrolltoindex` | scroll to an item |\n| `scrolltooffset` | scroll to a pixel offset |\n| `scrolltotop` | scroll to offset `0` |\n| `scrolltobottom` | scroll to the end of the list |\n| `isatend` | `true` when within `threshold` px of the end |\n| `dispose` | teardown; idempotent |\n| `disposed` | `true` after `dispose()` is called (live getter) |\n| `[symbol.dispose]` | delegates to `dispose()` |\n\n## `createvirtualscroller(container, options)`\n\n```ts\ncreatevirtualscroller<t>(container: htmlelement, options: virtualscrolleroptions<t>): domvirtuallistcontroller<t>;\n```\n\ncreates a scroll container `div` and inner list `div`, appends them to `container`, and returns a fully wired `domvirtuallistcontroller`. useful when the scroll dom doesn't already exist.\n\n```ts\nconst list = createvirtualscroller<row>(document.getelementbyid('root')!, {\n estimatesize: 36,\n render: ({ items, listel, recycle }) => {\n for (const item of items) {\n const el = recycle(item.data.id, () => document.createelement('div'));\n el.textcontent = item.data.label;\n el.style.csstext = `position:absolute;top:0;left:0;right:0;transform:translatey(${item.start}px);`;\n listel.appendchild(el);\n }\n },\n});\n\nlist.setitems(rows);\nlist.dispose(); // also removes the generated scroll container\n```\n\n`virtualscrolleroptions<t>` is `domvirtuallistoptions<t>` minus `listelement`/`scrollelement`, plus:\n\n| option | type | description |\n| | | |\n| `containerclass` | `string` | css class applied to the generated scroll element |\n\n`dispose()` removes the generated scroll container from the dom.\n\n## `creategroupedvirtualizer(target, options)`\n\n```ts\ncreategroupedvirtualizer<t>(target: scrolltarget, options: groupvirtualizeroptions<t>): groupvirtualizer<t>;\n```\n\nvirtualizes a sectioned list. headers are automatically sticky (pinned at viewport top while the section is in view).\n\n```ts\nimport { creategroupedvirtualizer } from '@vielzeug/scroll';\n\ntype contact = { id: number; name: string };\n\nconst virt = creategroupedvirtualizer<contact>(scrollel, {\n estimateheadersize: 32,\n estimateitemsize: 48,\n sections: [\n { label: 'a', items: [{ id: 1, name: 'alice' }] },\n { label: 'b', items: [{ id: 2, name: 'bob' }] },\n ],\n onchange: ({ headers, items, stickyheader, totalsize }) => {\n listel.style.height = `${totalsize}px`;\n listel.replacechildren();\n\n if (stickyheader) {\n const el = document.createelement('div');\n el.classname = 'sticky header';\n el.textcontent = stickyheader.label;\n listel.appendchild(el);\n }\n\n for (const header of headers) {\n const el = document.createelement('div');\n el.style.csstext = `position:absolute;top:${header.start}px;height:${header.size}px;`;\n el.textcontent = header.label;\n listel.appendchild(el);\n }\n\n for (const item of items) {\n const el = document.createelement('div');\n el.style.csstext = `position:absolute;top:${item.start}px;height:${item.size}px;`;\n el.textcontent = item.data.name;\n listel.appendchild(el);\n }\n },\n});\n\nvirt.scrolltosection(1, { align: 'start' });\nvirt.update(nextsections);\nvirt.dispose();\n```\n\n### `groupvirtualizeroptions<t>`\n\n| option | type | default | description |\n| | | | |\n| `sections` | `array<groupsection<t>>` | required | initial sections |\n| `onchange` | `(state: groupvirtualizerstate<t>) => void` | — | called when the visible window changes; replace through `update()`. |\n| `onscrollend` | `(offset: number) => void` | — | called when scrolling settles; replace through `update()`. |\n| `onscrollingchange` | `(isscrolling: boolean) => void` | — | called when scroll activity starts or stops; replace through `update()`. |\n| `estimateheadersize` | `number \\| (section, sectionindex) => number` | `36` | header height estimate |\n| `estimateitemsize` | `number \\| (item, itemindex, sectionindex) => number` | `36` | item height estimate |\n| `getitemkey` | `(item: t, itemindex: number, sectionindex: number) => virtualkey` | — | stable key for measurement cache |\n| `horizontal` | `boolean` | `false` | virtualize along x axis |\n| `measurementcache` | `measurementcache` | — | external measurement cache |\n| `overscan` | `number \\| { start?: number; end?: number }` | `3` | overscan on each side (number = symmetric) |\n| `scrollenddelay` | `number` | `150` | debounce delay (ms) for scroll end detection |\n| `signal` | `(init: groupvirtualizerstate<t>) => signal<groupvirtualizerstate<t>>` | — | optional signal factory to expose state as a reactive signal |\n\n### `groupsection<t>`\n\n```ts\ninterface groupsection<t> {\n items: t[];\n label: string;\n}\n```\n\n### `groupvirtualizerstate<t>`\n\n```ts\ninterface groupvirtualizerstate<t> {\n readonly headers: groupvirtualheader[];\n readonly items: array<groupvirtualitem<t>>;\n readonly stickyheader: groupvirtualheader | null;\n readonly totalsize: number;\n}\n```\n\n`stickyheader` is the header of the section currently at or above the viewport top, or `null` when at the very top. render it as a floating overlay above the list.\n\n### `groupvirtualitem<t>` and `groupvirtualheader`\n\n```ts\ninterface groupvirtualitem<t> extends virtualitem {\n data: t;\n itemindex: number; // index within the section\n sectionindex: number;\n}\n\ninterface groupvirtualheader extends virtualitem {\n label: string;\n sectionindex: number;\n}\n```\n\n### `groupvirtualizer<t>` — methods\n\n`groupvirtualizer<t>` is an independent interface that exposes all core virtualizer methods directly, plus grouped specific navigation.\n\n| method / property | description |\n| | |\n| `update(sections, opts?)` | replace all sections with optional config overrides; see `groupvirtualizerupdateoptions<t>` |\n| `scrolltosection(i, options?)` | scroll to section header at index `i`. out of range is a no op |\n| `scrolltoitem(s, i, options?)` | scroll to item `i` in section `s`. out of range is a no op |\n| `scrolltoindex(i, options?)` | scroll to flat index `i` (from underlying virtualizer) |\n| `scrolltooffset(offset, options?)` | scroll to a raw pixel offset |\n| `scrolltotop(options?)` | scroll to offset `0` |\n| `scrolltobottom(options?)` | scroll to the end of the list |\n| `measure(index, size)` | record a measurement for a flat index |\n| `measurebatch(entries)` | batch record measurements for flat indices |\n| `measureel(index, el)` | attach auto measuring resizeobserver. returns disconnect function |\n| `invalidate()` | clear all measurements and rebuild |\n| `refresh()` | rebuild offset table without clearing measurements |\n| `count` | total flat item count (live getter) |\n| `disposalsignal` | `abortsignal` aborted on `dispose()` |\n| `isscrolling` | `true` while the user is scrolling; `false` once scroll settles |\n| `items` | currently rendered group items (live getter) |\n| `scrolloffset` | current scroll position in pixels (live getter) |\n| `stickyitems` | sticky items pinned at viewport top (live getter) |\n| `totalsize` | total list size in pixels (live getter) |\n| `dispose()` | teardown; idempotent |\n| `disposed` | `true` after `dispose()` is called |\n| `[symbol.dispose]()` | delegates to `dispose()` |\n\nall scroll methods accept an optional `scrolltoindexoptions` object (`{ align?, behavior?, oncomplete? }`).\n\n### `groupvirtualizerupdateoptions<t>`\n\npassed as the second argument to `groupvirtualizer.update()`. all fields are optional — omit any you don't want to change.\n\n| option | type | description |\n| | | |\n| `estimateheadersize` | `number \\| (section, sectionindex) => number` | new header size estimate, applied on next rebuild |\n| `estimateitemsize` | `number \\| (item, itemindex, sectionindex) => number` | new item size estimate, applied on next rebuild |\n| `getitemkey` | `(item, itemindex, sectionindex) => virtualkey` | new item key function |\n| `measurementcache` | `measurementcache` | hot swap the measurement cache |\n| `onchange` | `(state: groupvirtualizerstate<t>) => void` | replace the active onchange callback |\n| `onscrollend` | `(offset: number) => void` | replace the active onscrollend callback |\n| `onscrollingchange` | `(isscrolling: boolean) => void` | replace the active onscrollingchange callback |\n| `overscan` | `number \\| { start?, end? }` | new overscan count |\n| `scrollenddelay` | `number` | new debounce delay (ms) for scroll end detection |\n\n> `horizontal` remains construction only.\n\n## `creategridvirtualizer(target, options)`\n\n```ts\ncreategridvirtualizer(target: scrolltarget, options: gridvirtualizeroptions): gridvirtualizer;\n```\n\ntwo dimensional virtualizer. fires `onchange` with visible row and column descriptors. callers form the cross product `rows × cols` to render visible cells.\n\n```ts\nimport { creategridvirtualizer } from '@vielzeug/scroll';\n\nconst grid = creategridvirtualizer(scrollel, {\n rowcount: 10_000,\n colcount: 50,\n estimaterowsize: 36,\n estimatecolsize: 120,\n onchange: ({ rows, cols, totalheight, totalwidth }) => {\n containerel.style.csstext = `position:relative;height:${totalheight}px;width:${totalwidth}px;`;\n containerel.replacechildren();\n\n for (const row of rows) {\n for (const col of cols) {\n const cell = document.createelement('div');\n cell.style.csstext = `position:absolute;top:${row.start}px;left:${col.start}px;height:${row.size}px;width:${col.size}px;`;\n cell.textcontent = `${row.index},${col.index}`;\n containerel.appendchild(cell);\n }\n }\n },\n});\n\ngrid.scrolltocell(500, 10, { rowalign: 'center', colalign: 'start' });\ngrid.dispose();\n```\n\n### `gridvirtualizeroptions`\n\n| option | type | default | description |\n| | | | |\n| `rowcount` | `number` | required | total row count |\n| `colcount` | `number` | required | total column count |\n| `estimaterowsize` | `number \\| (row) => number` | `36` | row height estimate |\n| `estimatecolsize` | `number \\| (col) => number` | `36` | column width estimate |\n| `rowgap` | `number` | `0` | gap between rows |\n| `colgap` | `number` | `0` | gap between columns |\n| `overscany` | `{ start?: number; end?: number }` | `{ start: 3, end: 3 }` | row overscan |\n| `overscanx` | `{ start?: number; end?: number }` | `{ start: 3, end: 3 }` | column overscan |\n| `initialscrolltop` | `number` | — | initial vertical scroll position |\n| `initialscrollleft` | `number` | — | initial horizontal scroll position |\n| `keyboardscroll` | `boolean` | `false` | enable keyboard navigation (arrow/page/home/end keys) |\n| `onchange` | `(state: gridvirtualizerstate) => void` | — | called when the visible window changes |\n| `onrangechange` | `(range: gridrangechangeevent) => void` | — | zero allocation range callback |\n| `rowmeasurementcache` | `map<number, number>` | — | external row measurement cache |\n| `colmeasurementcache` | `map<number, number>` | — | external column measurement cache |\n| `signal` | `(init: gridvirtualizerstate) => signal<gridvirtualizerstate>` | — | optional signal factory to expose state as a reactive signal |\n\n### `gridvirtualizerstate`\n\n```ts\ninterface gridvirtualizerstate {\n readonly cols: virtualitem[];\n readonly rows: virtualitem[];\n readonly totalheight: number;\n readonly totalwidth: number;\n}\n```\n\n### `gridvirtualizer` — properties and methods\n\n**read only properties:** `rows`, `cols`, `scrolltop`, `scrollleft`, `totalheight`, `totalwidth`, `disposalsignal`, `disposed`\n\n| method | description |\n| | |\n| `update(next)` | atomically update row/col counts, estimates, gaps, and overscan |\n| `measurerow(row, size)` | record a row height |\n| `measurecolumn(col, size)` | record a column width |\n| `measurebatch(rows, cols)` | measure rows and columns in a single coordinated rebuild pass |\n| `measurerowel(row, el)` | auto measure row height via resizeobserver. returns disconnect fn |\n| `measurecolel(col, el)` | auto measure column width via resizeobserver. returns disconnect fn |\n| `refresh()` | rebuild offset tables from current measurements |\n| `invalidate()` | clear all measurements and rebuild from estimates |\n| `scrolltocell(row, col, options?)` | scroll to bring a cell into view; no op when `rowcount === 0` or `colcount === 0` |\n| `scrolltorow(row, options?)` | scroll to bring a row into view; `rowalign` controls alignment |\n| `scrolltocolumn(col, options?)` | scroll to bring a column into view; `colalign` controls alignment |\n| `prependrows(n)` | add `n` rows at the top; adjusts scroll offset to keep viewport stable |\n| `dispose()` | teardown; idempotent |\n| `[symbol.dispose]()` | delegates to `dispose()` |\n\n`measurerowel`/`measurecolel`'s `resizeobserver` is also disconnected automatically on `dispose()` —\nthe returned disconnect function is only needed to stop observing a specific element early.\n\n### `scrolltocelloptions`\n\n```ts\ninterface scrolltocelloptions {\n behavior?: scrollbehavior;\n colalign?: 'auto' | 'center' | 'end' | 'start';\n rowalign?: 'auto' | 'center' | 'end' | 'start';\n}\n```\n\n## types\n\n### `virtualitem`\n\n```ts\ninterface virtualitem {\n end: number;\n index: number;\n size: number;\n start: number;\n}\n```\n\n### `virtualizerstate`\n\n```ts\ninterface virtualizerstate {\n readonly items: virtualitem[];\n readonly stickyitems: virtualitem[];\n readonly totalsize: number;\n}\n```\n\n### `scrolltoindexoptions`\n\n```ts\ninterface scrolltoindexoptions {\n align?: 'auto' | 'center' | 'end' | 'start';\n behavior?: scrollbehavior;\n /** called when the scroll animation completes (instant scrolls: next microtask). */\n oncomplete?: () => void;\n}\n```\n\n### `overscan`\n\n```ts\ntype overscan = number | { end?: number; start?: number };\n```\n\npassing a number is shorthand for symmetric overscan on both sides.\n\n### `virtualkey`\n\n```ts\ntype virtualkey = number | string;\n```\n\n### `virtualrenderitem<t>`\n\n```ts\ntype virtualrenderitem<t> = virtualitem & { readonly data: t };\n```\n\n### `scrolltarget`\n\n```ts\ntype scrolltarget = htmlelement | window;\n```\n\n### `measurementcache`\n\n```ts\ntype measurementcache = map<virtualkey, number>;\n```\n\nuse `createmeasurementcache()` to create an empty cache:\n\n```ts\nimport { createmeasurementcache } from '@vielzeug/scroll';\n\nconst cache = createmeasurementcache();\nconst virt1 = createvirtualizer(el1, { count: 100, measurementcache: cache });\nconst virt2 = createvirtualizer(el2, { count: 100, measurementcache: cache });\n```\n\n### `recyclefn`\n\n```ts\ntype recyclefn = (key: virtualkey, create: () => htmlelement) => htmlelement;\n```\n\n### `virtualizerupdateoptions`\n\n```ts\ninterface virtualizerupdateoptions {\n automeasure?: boolean;\n count?: number;\n estimatesize?: number | ((index: number) => number);\n gap?: number;\n getitemkey?: ((index: number) => virtualkey) | undefined;\n keyboardscroll?: boolean;\n /** replace the active measurement cache. existing entries are used immediately on the next rebuild. */\n measurementcache?: measurementcache;\n onchange?: ((state: virtualizerstate) => void) | undefined;\n onscrollend?: ((offset: number) => void) | undefined;\n onscrollingchange?: ((isscrolling: boolean) => void) | undefined;\n overscan?: overscan;\n scrollenddelay?: number;\n sticky?: ((index: number) => boolean) | undefined;\n}\n```\n\n### `virtualscrolleroptions<t>`\n\n`domvirtuallistoptions<t>` minus `listelement` and `scrollelement`, plus:\n\n```ts\ntype virtualscrolleroptions<t> = omit<domvirtuallistoptions<t>, 'listelement' | 'scrollelement'> & {\n /** css class applied to the generated scroll container element. */\n containerclass?: string;\n};\n```\n\n### `gridvirtualizerupdateoptions`\n\n```ts\ninterface gridvirtualizerupdateoptions {\n colcount?: number;\n colgap?: number;\n estimatecolsize?: number | ((col: number) => number);\n estimaterowsize?: number | ((row: number) => number);\n keyboardscroll?: boolean;\n onchange?: ((state: gridvirtualizerstate) => void) | undefined;\n onrangechange?: ((range: gridrangechangeevent) => void) | undefined;\n overscanx?: overscan;\n overscany?: overscan;\n rowcount?: number;\n rowgap?: number;\n}\n```\n\n### `gridrangechangeevent`\n\nfired by `onrangechange` on `creategridvirtualizer`. zero allocation alternative to `onchange` — no `rows`/`cols` arrays are allocated.\n\n```ts\ninterface gridrangechangeevent {\n firstcol: number;\n firstrow: number;\n lastcol: number;\n lastrow: number;\n}\n```\n\n### `virtualizeroptions`\n\n```ts\ninterface virtualizeroptions {\n automeasure?: boolean;\n count: number;\n estimatesize?: number | ((index: number) => number);\n gap?: number;\n getitemkey?: (index: number) => virtualkey;\n horizontal?: boolean;\n initialoffset?: number;\n keyboardscroll?: boolean;\n measurementcache?: measurementcache;\n onchange?: (state: virtualizerstate) => void;\n onscrollend?: (offset: number) => void;\n onscrollingchange?: (isscrolling: boolean) => void;\n overscan?: overscan;\n scrollenddelay?: number;\n signal?: (init: virtualizerstate) => signal<virtualizerstate>;\n sticky?: (index: number) => boolean;\n}\n```\n\n### `virtualizer`\n\n```ts\ninterface virtualizer {\n readonly count: number;\n readonly disposalsignal: abortsignal;\n dispose: () => void;\n readonly disposed: boolean;\n invalidate: () => void;\n isatend: (threshold?: number) => boolean;\n readonly isscrolling: boolean;\n readonly items: virtualitem[];\n measure: (index: number, size: number) => void;\n measurebatch: (entries: array<{ index: number; size: number }>) => void;\n measureel: (index: number, el: htmlelement) => () => void;\n prepend: (additionalcount: number) => void;\n refresh: () => void;\n readonly scrolloffset: number;\n scrolltobottom: (options?: { behavior?: scrollbehavior }) => void;\n scrolltoindex: (index: number, options?: scrolltoindexoptions) => void;\n scrolltooffset: (offset: number, options?: { behavior?: scrollbehavior }) => void;\n scrolltotop: (options?: { behavior?: scrollbehavior }) => void;\n readonly stickyitems: virtualitem[];\n readonly totalsize: number;\n update: (next: virtualizerupdateoptions) => void;\n [symbol.dispose]: () => void;\n}\n```\n\n### `sticktobottomoptions`\n\n```ts\ntype sticktobottomoptions = {\n enabled?: boolean;\n threshold?: number;\n};\n```\n\n### `domvirtuallistoptions<t>`\n\n```ts\ntype domvirtuallistoptions<t> = {\n clear?: (listel: htmlelement) => void;\n estimatesize?: number | ((index: number, item: t) => number);\n gap?: number;\n getitemkey?: (index: number, item: t) => virtualkey;\n horizontal?: boolean;\n keyboardscroll?: boolean;\n listelement: htmlelement;\n measurementcache?: measurementcache;\n overscan?: overscan;\n render: (args: domvirtuallistrenderargs<t>) => void;\n scrollelement: htmlelement | window;\n sticktobottom?: boolean | sticktobottomoptions;\n sticky?: (index: number, item: t) => boolean;\n signal?: (init: virtualizerstate) => signal<virtualizerstate>;\n};\n```\n\n### `domvirtuallistcontroller<t>`\n\n`virtualizer` minus `prepend` and `update`, plus `setitems()`.\n\n```ts\ntype domvirtuallistcontroller<t> = omit<virtualizer, 'prepend' | 'update'> & {\n setitems: (items: t[]) => void;\n};\n```\n\n### `domvirtuallistrenderargs<t>`\n\n```ts\ntype domvirtuallistrenderargs<t> = {\n items: array<virtualrenderitem<t>>;\n listel: htmlelement;\n recycle: recyclefn;\n stickyitems: array<virtualrenderitem<t>>;\n totalsize: number;\n};\n```\n\n### `groupsection<t>`\n\n```ts\ninterface groupsection<t> {\n items: t[];\n label: string;\n}\n```\n\n### `groupvirtualizerstate<t>`\n\n```ts\ninterface groupvirtualizerstate<t> {\n readonly headers: groupvirtualheader[];\n readonly items: array<groupvirtualitem<t>>;\n readonly stickyheader: groupvirtualheader | null;\n readonly totalsize: number;\n}\n```\n\n### `groupvirtualitem<t>`\n\n```ts\ninterface groupvirtualitem<t> extends virtualitem {\n data: t;\n itemindex: number;\n sectionindex: number;\n}\n```\n\n### `groupvirtualheader`\n\n```ts\ninterface groupvirtualheader extends virtualitem {\n label: string;\n sectionindex: number;\n}\n```\n\n### `groupvirtualizeroptions<t>`\n\n```ts\ninterface groupvirtualizeroptions<t> {\n estimateheadersize?: number | ((section: groupsection<t>, sectionindex: number) => number);\n estimateitemsize?: number | ((item: t, itemindex: number, sectionindex: number) => number);\n getitemkey?: (item: t, itemindex: number, sectionindex: number) => virtualkey;\n horizontal?: boolean;\n measurementcache?: measurementcache;\n onchange?: (state: groupvirtualizerstate<t>) => void;\n onscrollend?: (offset: number) => void;\n onscrollingchange?: (isscrolling: boolean) => void;\n overscan?: overscan;\n scrollenddelay?: number;\n sections: array<groupsection<t>>;\n signal?: (init: groupvirtualizerstate<t>) => signal<groupvirtualizerstate<t>>;\n}\n```\n\n### `groupvirtualizerupdateoptions<t>`\n\n```ts\ninterface groupvirtualizerupdateoptions<t> {\n estimateheadersize?: number | ((section: groupsection<t>, sectionindex: number) => number);\n estimateitemsize?: number | ((item: t, itemindex: number, sectionindex: number) => number);\n getitemkey?: (item: t, itemindex: number, sectionindex: number) => virtualkey;\n measurementcache?: measurementcache;\n onchange?: ((state: groupvirtualizerstate<t>) => void) | undefined;\n onscrollend?: ((offset: number) => void) | undefined;\n onscrollingchange?: ((isscrolling: boolean) => void) | undefined;\n overscan?: overscan;\n scrollenddelay?: number;\n}\n```\n\n### `groupvirtualizer<t>`\n\n```ts\ninterface groupvirtualizer<t> {\n readonly count: number;\n readonly disposalsignal: abortsignal;\n dispose: () => void;\n readonly disposed: boolean;\n invalidate: () => void;\n readonly isscrolling: boolean;\n readonly items: readonlyarray<groupvirtualitem<t>>;\n measure: (index: number, size: number) => void;\n measurebatch: (entries: array<{ index: number; size: number }>) => void;\n measureel: (index: number, el: htmlelement) => () => void;\n refresh: () => void;\n readonly scrolloffset: number;\n scrolltobottom: (options?: { behavior?: scrollbehavior }) => void;\n scrolltoindex: (index: number, options?: scrolltoindexoptions) => void;\n scrolltoitem: (sectionindex: number, itemindex: number, options?: scrolltoindexoptions) => void;\n scrolltooffset: (offset: number, options?: { behavior?: scrollbehavior }) => void;\n scrolltosection: (sectionindex: number, options?: scrolltoindexoptions) => void;\n scrolltotop: (options?: { behavior?: scrollbehavior }) => void;\n readonly stickyitems: virtualitem[];\n readonly totalsize: number;\n update: (sections: array<groupsection<t>>, opts?: groupvirtualizerupdateoptions<t>) => void;\n [symbol.dispose]: () => void;\n}\n```\n\n### `gridvirtualizerstate`\n\n```ts\ninterface gridvirtualizerstate {\n readonly cols: virtualitem[];\n readonly rows: virtualitem[];\n readonly totalheight: number;\n readonly totalwidth: number;\n}\n```\n\n### `scrolltocelloptions`\n\n```ts\ninterface scrolltocelloptions {\n behavior?: scrollbehavior;\n colalign?: 'auto' | 'center' | 'end' | 'start';\n rowalign?: 'auto' | 'center' | 'end' | 'start';\n}\n```\n\n### `gridvirtualizeroptions`\n\n```ts\ninterface gridvirtualizeroptions {\n colcount: number;\n colgap?: number;\n colmeasurementcache?: map<number, number>;\n estimatecolsize?: number | ((col: number) => number);\n estimaterowsize?: number | ((row: number) => number);\n initialscrollleft?: number;\n initialscrolltop?: number;\n keyboardscroll?: boolean;\n onchange?: (state: gridvirtualizerstate) => void;\n onrangechange?: (range: gridrangechangeevent) => void;\n overscanx?: overscan;\n overscany?: overscan;\n rowcount: number;\n rowgap?: number;\n rowmeasurementcache?: map<number, number>;\n signal?: (init: gridvirtualizerstate) => signal<gridvirtualizerstate>;\n}\n```\n\n### `gridvirtualizer`\n\n```ts\ninterface gridvirtualizer {\n readonly cols: virtualitem[];\n readonly disposalsignal: abortsignal;\n dispose: () => void;\n readonly disposed: boolean;\n invalidate: () => void;\n measurebatch: (rows: array<{ index: number; size: number }>, cols: array<{ index: number; size: number }>) => void;\n measurecolel: (col: number, el: htmlelement) => () => void;\n measurecolumn: (col: number, size: number) => void;\n measurerow: (row: number, size: number) => void;\n measurerowel: (row: number, el: htmlelement) => () => void;\n prependrows: (additionalrowcount: number) => void;\n refresh: () => void;\n readonly rows: virtualitem[];\n readonly scrollleft: number;\n scrolltocell: (row: number, col: number, options?: scrolltocelloptions) => void;\n scrolltocolumn: (col: number, options?: pick<scrolltocelloptions, 'behavior' | 'colalign'>) => void;\n readonly scrolltop: number;\n scrolltorow: (row: number, options?: pick<scrolltocelloptions, 'behavior' | 'rowalign'>) => void;\n readonly totalheight: number;\n readonly totalwidth: number;\n update: (next: gridvirtualizerupdateoptions) => void;\n [symbol.dispose]: () => void;\n}\n```\n\n## errors\n\n| class | thrown when | notable properties |\n| | | |\n| `scrollerror` | base class for every scroll error. | use `instanceof scrollerror` to narrow unknown errors narrows errors from this package. |\n| `scrollconfigurationerror` | a constructor or `update()` receives invalid static configuration. | extends `scrollerror`; malformed javascript values also use this class. |\n| `scrollrangeerror` | a dom virtual list render detects that a caller mutated its items array without calling `setitems()` again. | extends `scrollerror`; message includes stale index and current item count. |\n\nruntime estimator failures, stale measurements, and out of range navigation remain resilient: they fall back, no op, or clamp as documented.\n\n### constants\n\n```ts\nconst default_estimate_size = 36; // default estimatesize\nconst default_overscan = 3; // default overscan on each side\n```\n",
|
|
1142
1164
|
"usage": " \ntitle: scroll — usage guide\ndescription: fixed and variable heights, measurement, programmatic scrolling, and framework integration for scroll.\n \n\n[[toc]]\n\n## basic usage\n\nrender only visible rows by passing a scroll container, a total item count, and a size estimate. scroll calls `onchange` with the visible window whenever it changes.\n\n```ts\nimport { createvirtualizer } from '@vielzeug/scroll';\n\nconst scrollel = document.queryselector<htmlelement>('.scroll container')!;\nconst listel = document.queryselector<htmlelement>('.list')!;\n\nconst virt = createvirtualizer(scrollel, {\n count: 10_000,\n estimatesize: 36,\n onchange: ({ items, totalsize }) => {\n listel.style.height = `${totalsize}px`;\n listel.replacechildren();\n\n for (const item of items) {\n const el = document.createelement('div');\n el.style.csstext = `position:absolute;top:${item.start}px;left:0;right:0;height:36px;`;\n el.textcontent = `row ${item.index}`;\n listel.appendchild(el);\n }\n },\n});\n\n// cleanup\nvirt.dispose();\n```\n\n```html\n<div class=\"scroll container\" style=\"height:400px;overflow:auto;position:relative;\">\n <div class=\"list\" style=\"position:relative;\"></div>\n</div>\n```\n\n## dom layout requirements\n\nscroll uses **absolute positioning** for rendered items inside a relative container that stretches to the full list height. your html needs three elements:\n\n```html\n<! 1. scroll container — has a fixed height and overflow:auto/scroll >\n<div class=\"scroll container\" style=\"height:400px;overflow:auto;position:relative;\">\n <! 2. spacer — height set to totalsize so the scrollbar is correct >\n <div class=\"spacer\" style=\"position:relative;\">\n <! 3. item container — items positioned absolutely inside here >\n <div class=\"items\"></div>\n </div>\n</div>\n```\n\na common alternative is to make the spacer and item container the same element:\n\n```html\n<div class=\"scroll container\" style=\"height:400px;overflow:auto;\">\n <! single relative container; items are absolute children >\n <div class=\"list\" style=\"position:relative;\"></div>\n</div>\n```\n\n## dom adapter for dropdowns and listboxes\n\nif your component already has a dropdown scroll container and a listbox element, use `createdomvirtuallist`. it wraps the `virtualizer` lifecycle and keeps the integration surface small. items arrive as `virtualrenderitem<t>` — a `virtualitem` enriched with a `.data` field. use `recycle` for efficient dom node reuse.\n\nthe virtualizer is created lazily on the first non empty `setitems()` call and destroyed automatically when `setitems([])` is called (clearing list styles in the process).\n\n```ts\nimport { createdomvirtuallist } from '@vielzeug/scroll';\n\ntype option = { disabled?: boolean; label: string; value: string };\n\nlet options: option[] = [];\n\nconst domvirtuallist = createdomvirtuallist<option>({\n estimatesize: 36,\n gap: 6,\n getitemkey: (_index, option) => option.value,\n listelement: listboxel,\n overscan: { end: 4, start: 4 },\n render: ({ items, listel, recycle }) => {\n for (const item of items) {\n const row = recycle(item.data.value, () => document.createelement('button'));\n row.type = 'button';\n row.classname = 'option';\n row.style.csstext = `position:absolute;top:0;left:0;right:0;transform:translatey(${item.start}px);height:${item.size}px;`;\n row.textcontent = item.data.label;\n row.disabled = !!item.data.disabled;\n listel.appendchild(row);\n }\n },\n scrollelement: dropdownel,\n});\n\n// keep in sync when options change\ndomvirtuallist.setitems(options);\n\n// open: setitems populates the list\n// close: setitems([]) destroys the virtualizer and clears list styles\ndomvirtuallist.setitems(isopen ? options : []);\n\n// keyboard nav\ndomvirtuallist.scrolltoindex(focusedindex, { align: 'auto' });\n\n// component teardown\ndomvirtuallist.dispose();\n```\n\nfor variable height rows, pass `getitemkey` so measurements survive `setitems()` calls when items reorder or are filtered.\n\nwhen multiple sizes are available at once, use `measurebatch` to coalesce into a single rebuild:\n\n```ts\ndomvirtuallist.measurebatch(\n entries.map((e) => ({ index: number(e.target.dataset.index), size: e.contentrect.height })),\n);\n```\n\nuse `domvirtuallist.invalidate()` to discard all cached measurements.\n\n## fixed heights\n\npass a single number to `estimatesize` when all rows are the same height. this is the simplest and most performant case — the offset table never needs to be rebuilt during scrolling.\n\n```ts\nconst virt = createvirtualizer(scrollel, {\n count: 10_000,\n estimatesize: 36, // every row is 36px\n onchange: ({ items, totalsize }) => {\n list.style.height = `${totalsize}px`;\n list.replacechildren();\n\n for (const item of items) {\n const el = document.createelement('div');\n el.style.csstext = `position:absolute;top:${item.start}px;left:0;right:0;height:36px;`;\n el.textcontent = data[item.index].name;\n list.appendchild(el);\n }\n },\n});\n```\n\n## variable heights — estimator\n\npass a **per index function** to `estimatesize` when rows have predictable but non uniform heights (e.g. group headers vs. regular rows). the offset table is built once at attach time using these estimates.\n\n```ts\nconst virt = createvirtualizer(scrollel, {\n count: flatlist.length,\n estimatesize: (i) => (flatlist[i].type === 'header' ? 48 : 36),\n onchange: ({ items, totalsize }) => {\n // render...\n },\n});\n```\n\n## variable heights — measured\n\nfor truly dynamic heights (e.g. text wrapping, embedded images), render items at their estimated size first, then report the actual measured height with `measure()`. scroll will coalesce all measurement calls within a single microtask tick into one offset rebuild.\n\n```ts\nconst virt = createvirtualizer(scrollel, {\n count: rows.length,\n estimatesize: 60, // initial estimate\n onchange: ({ items, totalsize }) => {\n list.style.height = `${totalsize}px`;\n list.replacechildren();\n\n for (const item of items) {\n const el = document.createelement('div');\n el.dataset.index = string(item.index);\n el.style.csstext = `position:absolute;top:${item.start}px;left:0;right:0;`;\n el.textcontent = rows[item.index].body;\n list.appendchild(el);\n }\n\n // measure after the dom has painted\n requestanimationframe(() => {\n for (const item of items) {\n const el = list.queryselector<htmlelement>(`[data index=\"${item.index}\"]`);\n if (el) virt.measure(item.index, el.offsetheight);\n }\n });\n },\n});\n```\n\n::: tip measurement is idempotent\n`measure(index, height)` is a no op when the new height matches the current effective height (measured or estimated). it is safe to call on every render without triggering unnecessary rebuilds.\n:::\n\n## variable heights — batch measurement\n\nwhen a `resizeobserver` fires with multiple entries at once, use `measurebatch()` to apply all sizes in a single offset rebuild instead of triggering one rebuild per `measure()` call.\n\n```ts\nconst observer = new resizeobserver((entries) => {\n virt.measurebatch(\n entries\n .filter((e) => e.target instanceof htmlelement && e.target.dataset.index)\n .map((e) => ({\n index: number((e.target as htmlelement).dataset.index),\n size: e.contentrect.height,\n })),\n );\n});\n\n// observe each rendered row\nfor (const item of virt.items) {\n const el = listel.queryselector<htmlelement>(`[data index=\"${item.index}\"]`);\n if (el) observer.observe(el);\n}\n```\n\n## overscan\n\n`overscan` controls how many extra items render outside the visible viewport on each side. higher values reduce the chance of blank rows during fast scrolling; lower values keep the dom smaller.\n\n```ts\ncreatevirtualizer(scrollel, {\n count: 1_000,\n estimatesize: 36,\n overscan: 5, // symmetric shorthand — same as { start: 5, end: 5 } (default: 3)\n onchange: () => {\n /* ... */\n },\n});\n```\n\nasymmetric overscan:\n\n```ts\ncreatevirtualizer(scrollel, {\n count: 1_000,\n estimatesize: 36,\n overscan: { start: 8, end: 2 },\n onchange: () => {\n /* ... */\n },\n});\n```\n\n## horizontal lists\n\nset `horizontal: true` to virtualize along the x axis.\n\n```ts\nconst virt = createvirtualizer(scrollel, {\n count: chips.length,\n estimatesize: 120,\n horizontal: true,\n onchange: ({ items, totalsize }) => {\n list.style.width = `${totalsize}px`;\n\n for (const item of items) {\n const chip = document.createelement('button');\n chip.style.csstext = `position:absolute;left:${item.start}px;top:0;width:${item.size}px;`;\n chip.textcontent = chips[item.index].label;\n list.appendchild(chip);\n }\n },\n});\n```\n\n## window scroll target\n\n`createvirtualizer` accepts `window` as the scroll target.\n\n```ts\nconst virt = createvirtualizer(window, {\n count: rows.length,\n estimatesize: 40,\n initialoffset: 320,\n onchange: ({ items, totalsize }) => {\n spacer.style.height = `${totalsize}px`;\n renderrows(items);\n },\n});\n```\n\n## scroll state\n\nuse `virt.scrolloffset` to read the current scroll position at any time.\n\n```ts\nconst virt = createvirtualizer(scrollel, { count: rows.length, estimatesize: 36, onchange: render });\n\n// accessed outside onchange\nconsole.log(virt.scrolloffset);\n```\n\n## updating options\n\nwhen data or render strategy changes, call `update()` with one or more option fields. updates apply atomically and trigger re render when needed. counts, gaps, and overscan must be finite non negative integers; numeric size estimates must be finite positive values; offsets and `scrollenddelay` must be finite non negative numbers. invalid constructor or `update()` values throw `scrollconfigurationerror` before any change applies.\n\nruntime layout data stays resilient: estimator callbacks that throw or return invalid sizes fall back to the default estimate, stale measurements are ignored, and out of range navigation clamps or no ops.\n\n```ts\n// load more data\ndata.push(...newitems);\nvirt.update({ count: data.length });\n```\n\n```ts\n// change multiple options together\nvirt.update({ count: data.length, overscan: { start: 5, end: 5 } });\n\n// rebuild after reordering/filtering stable key rows\nvirt.refresh();\n```\n\n## switching row density\n\nupdating `estimatesize` clears all previously measured heights, rebuilds offsets, and re renders. this makes density switching (compact / comfortable / spacious views) straightforward.\n\n```ts\nfunction setdensity(mode: 'compact' | 'comfortable') {\n virt.update({ estimatesize: mode === 'compact' ? 32 : 48 });\n}\n```\n\n## programmatic scrolling\n\n### `scrolltoindex(index, options?)`\n\nscroll to bring a specific item into view.\n\n| `align` | behaviour |\n| | |\n| `'start'` | item top aligns with the container top |\n| `'end'` | item bottom aligns with the container bottom |\n| `'center'` | item is centered in the viewport |\n| `'auto'` (default) | no scroll if already fully visible; otherwise scrolls the minimum amount |\n\n```ts\n// jump to item 500 at the top of the viewport\nvirt.scrolltoindex(500, { align: 'start' });\n\n// smooth scroll to an item, centering it\nvirt.scrolltoindex(500, { align: 'center', behavior: 'smooth' });\n\n// scroll only if the item is not already visible\nvirt.scrolltoindex(focusedindex, { align: 'auto' });\n```\n\nout of range indices are clamped silently: negative values scroll to item `0`, values ≥ `count` scroll to the last item.\n\n### `scrolltooffset(offset, options?)`\n\nscroll to an exact pixel position, useful for restoring a previously saved scroll state.\n\n```ts\n// restore scroll position\nconst savedoffset = sessionstorage.getitem('scrolloffset');\nif (savedoffset) virt.scrolltooffset(number(savedoffset));\n\n// save on scroll\nscrollel.addeventlistener('scroll', () => {\n sessionstorage.setitem('scrolloffset', string(scrollel.scrolltop));\n});\n```\n\n### `scrolltotop(options?)` / `scrolltobottom(options?)`\n\nconvenience wrappers to jump directly to the start or end of the list.\n\n```ts\n// jump to the top\nvirt.scrolltotop();\n\n// jump to the bottom with smooth scroll\nvirt.scrolltobottom({ behavior: 'smooth' });\n```\n\n### chat \"stick to bottom on new message\"\n\n`createdomvirtuallist`'s `sticktobottom` option automates the common chat/log pattern: follow new messages while the user is at the bottom, but never yank them away from history they scrolled up to read.\n\n```ts\nimport { createdomvirtuallist } from '@vielzeug/scroll';\n\nconst chat = createdomvirtuallist<message>({\n estimatesize: 48,\n getitemkey: (_, m) => m.id,\n listelement: listel,\n render: rendermessages,\n scrollelement: scrollel,\n sticktobottom: true, // or { threshold: 80 } to widen the \"still at bottom\" tolerance\n});\n\nchat.setitems(messages);\n\n// new message arrives — follows only if the user hasn't scrolled up.\nsocket.on('message', (msg) => {\n messages = [...messages, msg];\n chat.setitems(messages);\n});\n```\n\nit also follows a **streaming** last message that grows in place (tokens appended to the same message object, array length unchanged) — every `setitems()` call re checks \"was the list at the end before this update?\", not just count changes. build `isatend()` from `createvirtualizer` directly for custom cases (e.g. showing a \"jump to latest\" button only while scrolled away):\n\n```ts\nconst showjumpbutton = !virt.isatend();\n```\n\n## infinite scroll — loading more at the end\n\nuse `isatend(threshold)` to fetch the next page as the user nears the bottom. `isatend()` reports scroll position only — it keeps returning `true` while a fetch is in flight — so guard it with your own `loading` flag to avoid firing the same request twice.\n\n```ts\nimport { createvirtualizer, type virtualizer } from '@vielzeug/scroll';\n\nlet rows = await fetchpage(0);\nlet loading = false;\n\nlet virt: virtualizer;\nvirt = createvirtualizer(scrollel, {\n count: rows.length,\n estimatesize: 36,\n onchange: ({ items, totalsize }) => {\n listel.style.height = `${totalsize}px`;\n listel.replacechildren();\n\n for (const item of items) {\n const el = document.createelement('div');\n el.style.csstext = `position:absolute;top:${item.start}px;left:0;right:0;height:36px;`;\n el.textcontent = rows[item.index]?.label ?? '';\n listel.appendchild(el);\n }\n\n if (!loading && virt.isatend(200)) {\n loading = true;\n fetchpage(rows.length).then((nextrows) => {\n rows = [...rows, ...nextrows];\n virt.update({ count: rows.length });\n loading = false;\n });\n }\n },\n});\n```\n\n`isatend(200)` fires once the viewport is within 200px of the bottom — tune the threshold to your row height and fetch latency. `loading` is the only guard needed: it's cleared once the new page lands, and `update({ count })` re triggers `onchange`, which re checks `isatend()` against the new total on the next scroll.\n\n## shared measurement cache\n\nwhen the same items are displayed across multiple virtualizer instances (e.g. a list and a detail panel that share row heights), pass a shared `measurementcache` created by `createmeasurementcache()`. measurements recorded by one virtualizer are immediately available to all others using the same cache.\n\n```ts\nimport { createmeasurementcache, createvirtualizer } from '@vielzeug/scroll';\n\nconst cache = createmeasurementcache();\n\nconst listvirt = createvirtualizer(listscrollel, {\n count: rows.length,\n estimatesize: 36,\n measurementcache: cache,\n onchange: renderlist,\n});\n\nconst previewvirt = createvirtualizer(previewscrollel, {\n count: rows.length,\n estimatesize: 36,\n measurementcache: cache,\n onchange: renderpreview,\n});\n\n// a measurement on listvirt is reflected in previewvirt immediately.\nlistvirt.measure(0, 72);\n```\n\nthe cache is a plain `map<virtualkey, number>` — you can pre populate it from server data or persist it across sessions.\n\n```ts\n// pre populate from server sent sizes\nconst cache = createmeasurementcache();\nfor (const { id, height } of serversizes) cache.set(id, height);\n```\n\n## invalidating measurements\n\ncall `invalidate()` after an event that changes item heights without a data change — for example, a font load, a viewport width change that causes text to reflow, or toggling between a grid and list layout.\n\n```ts\ndocument.fonts.ready.then(() => virt.invalidate());\n```\n\non variable height lists, `scrolltoindex()` uses the current estimate/measured cache. if you need an exact post layout position after heights change, call `invalidate()` before scrolling again.\n\nfor same length updates, call `setitems()` (dom adapter) or `update()` (core). if the rendered height of rows changed, call `invalidate()` before scrolling again.\n\n## lifecycle — create and dispose\n\n`createvirtualizer(el, options)` attaches immediately to the provided scroll container. if your container is replaced, dispose the old instance and create a new one.\n\n```ts\nlet virt = createvirtualizer(scrollcontainerel, {\n count: rows.length,\n estimatesize: 36,\n onchange: render,\n});\n\nfunction remount(nextscrollcontainerel: htmlelement) {\n virt.dispose();\n virt = createvirtualizer(nextscrollcontainerel, {\n count: rows.length,\n estimatesize: 36,\n onchange: render,\n });\n}\n```\n\n`dispose()` is idempotent and safe to call multiple times.\n\n### explicit resource management\n\n```ts\n// the `using` keyword calls virt.dispose() automatically at block exit\n{\n using virt = createvirtualizer(scrollel, { count: rows.length, onchange: render });\n // ... use virt ...\n} // → virt.dispose() called here\n```\n\n## keyboard navigation\n\nenable keyboard based scrolling with the `keyboardscroll` option. users can navigate lists using arrow keys, page up/down, home, and end.\n\n```ts\nconst virt = createvirtualizer(scrollel, {\n count: 1000,\n estimatesize: 36,\n keyboardscroll: true, // enable keyboard navigation\n onchange: render,\n});\n```\n\n**supported keys:**\n **arrow up/down** (or left/right for horizontal lists) — scroll by one estimated item height\n **page up/down** — scroll by ~80% of viewport height\n **home** — jump to the start of the list\n **end** — jump to the end of the list\n\n**requirements:**\n the scroll container (or a descendant) must have keyboard focus for events to fire\n works with all factories: `createvirtualizer`, `createdomvirtuallist`, `creategroupedvirtualizer`, `creategridvirtualizer`\n arrow key step size is automatically calculated from your `estimatesize` (or `estimaterowsize`/`estimatecolsize` for grids)\n\n## auto measurement\n\nenable automatic item measurement for dynamic or user generated content that changes size. when `automeasure` is enabled, the virtualizer measures visible items via `resizeobserver` and updates layout in real time.\n\n```ts\nconst virt = createvirtualizer(scrollel, {\n count: messages.length,\n estimatesize: 36, // initial guess; will be measured\n automeasure: true, // automatically measure visible items\n onchange: ({ items, totalsize }) => {\n listel.style.height = `${totalsize}px`;\n listel.replacechildren();\n\n for (const item of items) {\n const el = document.createelement('div');\n // important: set data vz key for auto measure to find the element\n el.setattribute('data vz key', string(item.index));\n el.textcontent = messages[item.index]?.text ?? '';\n listel.appendchild(el);\n }\n },\n});\n```\n\n**requirements:**\n every rendered item must have a `data vz key` attribute with a unique value\n must use a dom scroll target (not `window`)\n elements must be in the dom by the time `resizeobserver` fires (usually the next microtask)\n\n**use cases:**\n chat lists where messages expand on load\n expandable sections with collapsing text\n lazy loaded thumbnails that arrive with unknown heights\n user resizable rows or dynamic content (videos, iframes)\n\n**performance notes:**\n auto measurement queries the dom every render cycle — avoid with very large visible windows (100+ items)\n for finer control, use the manual `measureel()` method instead\n enable only on lists with truly variable height items\n\n## reactive integration\n\nexpose virtualizer state to a reactive `signal` from `@vielzeug/ripple` using the `signal` option. this works on all factories and pairs with your existing `onchange` callback.\n\n```ts\nimport { createvirtualizer } from '@vielzeug/scroll';\nimport { signal, effect } from '@vielzeug/ripple';\n\n// create an empty signal with the initial state shape\nconst scrollstate = signal({ items: [], stickyitems: [], totalsize: 0 });\n\nconst virt = createvirtualizer(scrollel, {\n count: 1000,\n estimatesize: 36,\n signal: () => scrollstate, // return the signal on each init\n onchange: render, // both signal and callback get the state\n});\n\n// react to state changes\neffect(() => {\n const { totalsize, items } = scrollstate.value;\n console.log(`visible: ${items.length} items, total height: ${totalsize}px`);\n});\n```\n\n**why a signal factory instead of a direct signal?**\nthe `signal` option receives a factory function so that if your component mounts/unmounts and recreates the virtualizer, the signal is also recreated with a fresh initial state. if you want to share state across multiple virtualizers or preserve it across disposal, create the signal in outer scope and return it from the factory:\n\n```ts\n// shared signal across remounts\nconst scrollstate = signal({ items: [], stickyitems: [], totalsize: 0 });\n\nfunction createlist() {\n return createvirtualizer(scrollel, {\n count: 1000,\n signal: () => scrollstate, // always return the same instance\n });\n}\n```\n\n## framework integration\n\nscroll is rendering layer agnostic. the pattern is always the same: create the virtualizer when your scroll container is mounted, re render your dom in `onchange`, and call `dispose()` on unmount.\n\n::: code group\n\n```tsx [react]\nimport { createvirtualizer, type virtualizer } from '@vielzeug/scroll';\nimport { useeffect, uselayouteffect, useref } from 'react';\n\ninterface row {\n id: number;\n label: string;\n}\n\nfunction virtuallist({ rows }: { rows: row[] }) {\n const scrollref = useref<htmldivelement>(null);\n const listref = useref<htmldivelement>(null);\n const virtref = useref<virtualizer | null>(null);\n\n useeffect(() => {\n const scrollel = scrollref.current;\n const listel = listref.current;\n if (!scrollel || !listel) return;\n\n const virt = createvirtualizer(scrollel, {\n count: rows.length,\n estimatesize: 36,\n onchange: ({ items, totalsize }) => {\n listel.style.height = `${totalsize}px`;\n listel.replacechildren();\n for (const item of items) {\n const el = document.createelement('div');\n el.style.csstext = `position:absolute;top:${item.start}px;left:0;right:0;height:36px;`;\n el.textcontent = rows[item.index]?.label ?? '';\n listel.appendchild(el);\n }\n },\n });\n virtref.current = virt;\n return () => virt.dispose();\n }, []); // attach once\n\n // uselayouteffect, not useeffect: syncs count before paint. with useeffect,\n // the dom (and anything reading `rows`) paints once with the new length before\n // the virtualizer's internal count catches up, which can render stale/out of bounds indices.\n uselayouteffect(() => {\n virtref.current?.update({ count: rows.length });\n }, [rows.length]);\n\n return (\n <div ref={scrollref} style={{ height: 400, overflow: 'auto', position: 'relative' }}>\n <div ref={listref} style={{ position: 'relative' }} />\n </div>\n );\n}\n```\n\n```vue [vue 3]\n<script setup lang=\"ts\">\nimport { createvirtualizer, type virtualizer } from '@vielzeug/scroll';\nimport { onmounted, onunmounted, ref, watch } from 'vue';\n\nconst props = defineprops<{ rows: { id: number; label: string }[] }>();\nconst scrollref = ref<htmlelement | null>(null);\nconst listref = ref<htmlelement | null>(null);\nlet virt: virtualizer | null = null;\n\nonmounted(() => {\n if (!scrollref.value || !listref.value) return;\n const listel = listref.value;\n virt = createvirtualizer(scrollref.value, {\n count: props.rows.length,\n estimatesize: 36,\n onchange: ({ items, totalsize }) => {\n listel.style.height = `${totalsize}px`;\n listel.replacechildren();\n for (const item of items) {\n const el = document.createelement('div');\n el.style.csstext = `position:absolute;top:${item.start}px;left:0;right:0;height:36px;`;\n el.textcontent = props.rows[item.index]?.label ?? '';\n listel.appendchild(el);\n }\n },\n });\n});\nwatch(\n () => props.rows.length,\n (n) => {\n virt?.update({ count: n });\n },\n);\nonunmounted(() => virt?.dispose());\n</script>\n\n<template>\n <div ref=\"scrollref\" style=\"height:400px;overflow:auto;position:relative;\">\n <div ref=\"listref\" style=\"position:relative;\" />\n </div>\n</template>\n```\n\n```svelte [svelte]\n<script lang=\"ts\">\n import { createvirtualizer, type virtualizer } from '@vielzeug/scroll';\n\n let { rows }: { rows: { id: number; label: string }[] } = $props();\n let scrollel: htmlelement;\n let listel: htmlelement;\n let virt: virtualizer;\n\n $effect(() => {\n virt = createvirtualizer(scrollel, {\n count: rows.length,\n estimatesize: 36,\n onchange: ({ items, totalsize }) => {\n listel.style.height = `${totalsize}px`;\n listel.replacechildren();\n for (const item of items) {\n const el = document.createelement('div');\n el.style.csstext = `position:absolute;top:${item.start}px;left:0;right:0;height:36px;`;\n el.textcontent = rows[item.index]?.label ?? '';\n listel.appendchild(el);\n }\n },\n });\n return () => virt.dispose();\n });\n\n $effect(() => { virt?.update({ count: rows.length }); });\n</script>\n\n<div bind:this={scrollel} style=\"height:400px;overflow:auto;position:relative;\">\n <div bind:this={listel} style=\"position:relative;\" />\n</div>\n```\n\n```ts [web components]\nimport { litelement, html, css } from 'lit';\nimport { customelement, property } from 'lit/decorators.js';\nimport { createvirtualizer, type virtualizer } from '@vielzeug/scroll';\n\n@customelement('virtual list')\nclass virtuallist extends litelement {\n static styles = css`\n .scroll {\n height: 400px;\n overflow: auto;\n position: relative;\n }\n .list {\n position: relative;\n }\n `;\n\n @property({ type: array }) rows: { label: string }[] = [];\n #virt: virtualizer | null = null;\n\n firstupdated() {\n const scrollel = this.renderroot.queryselector<htmlelement>('.scroll')!;\n const listel = this.renderroot.queryselector<htmlelement>('.list')!;\n this.#virt = createvirtualizer(scrollel, {\n count: this.rows.length,\n estimatesize: 36,\n onchange: ({ items, totalsize }) => {\n listel.style.height = `${totalsize}px`;\n listel.replacechildren();\n for (const item of items) {\n const el = document.createelement('div');\n el.style.csstext = `position:absolute;top:${item.start}px;left:0;right:0;height:36px;`;\n el.textcontent = this.rows[item.index]?.label ?? '';\n listel.appendchild(el);\n }\n },\n });\n }\n\n updated() {\n this.#virt?.update({ count: this.rows.length });\n }\n disconnectedcallback() {\n this.#virt?.dispose();\n super.disconnectedcallback();\n }\n render() {\n return html`<div class=\"scroll\"><div class=\"list\"></div></div>`;\n }\n}\n```\n\n:::\n\n### pitfalls\n\n **react:** putting `rows` in the `useeffect` dependency array causes the virtualizer to be destroyed and recreated on every data update. only include the scroll element reference. call `virt.update({ count })` from a separate `useeffect` for data changes.\n **react:** use `uselayouteffect`, not `useeffect`, for the `count` sync effect. `useeffect` fires after paint — a new `count` can reach the dom (e.g. via other state derived from `rows`) before `update({ count })` runs, rendering stale or out of bounds indices for one frame.\n **vue 3:** `ref.value` is `null` inside `setup()` — the dom doesn't exist yet. always create the virtualizer inside `onmounted`, not in `setup()`.\n **svelte:** in svelte 5, `$effect` with `bind:this` runs after the dom is painted. the `bind:this` variable is available when the `$effect` runs — no extra tick needed.\n **web components:** `firstupdated` fires once after the first render. use `updated()` for subsequent prop changes — lit calls it every time `rows` changes.\n\n## working with other vielzeug libraries\n\n### with ore\n\nbuild a virtualizing custom element using ore for the component shell and scroll for the rendering engine.\n\n```ts\nimport { define, html, onmounted, ref } from '@vielzeug/ore';\nimport { createvirtualizer } from '@vielzeug/scroll';\n\ndefine('virtual list', {\n setup() {\n const scrollref = ref<htmlelement>();\n const listref = ref<htmlelement>();\n\n onmounted(() => {\n if (!scrollref.value || !listref.value) return;\n const listel = listref.value;\n const virt = createvirtualizer(scrollref.value, {\n count: 1000,\n estimatesize: 40,\n onchange: ({ items, totalsize }) => {\n listel.style.height = `${totalsize}px`;\n listel.replacechildren();\n\n for (const item of items) {\n const row = document.createelement('div');\n\n row.style.csstext = `position:absolute;top:${item.start}px;height:40px;`;\n row.textcontent = `row ${item.index}`;\n listel.appendchild(row);\n }\n },\n },\n },\n });\n return () => virt.dispose();\n });\n\n return () => html`\n <div ref=${scrollref} style=\"height:400px;overflow:auto;position:relative\">\n <div ref=${listref} style=\"position:relative\"></div>\n </div>\n `;\n },\n});\n```\n\n## best practices\n\n always provide `count` and `estimatesize` as a starting point, even for variable height lists — measurements refine the estimates.\n call `dispose()` in the framework cleanup callback (useeffect return, onunmounted, ondestroy) to free resize observers.\n use `overscan` to pre render rows above and below the visible area to reduce blank flicker during fast scrolling.\n prefer `scrolltoindex()` with `align: 'start'` for programmatic navigation; use `align: 'center'` for focus management.\n use `createdomvirtuallist()` for comboboxes, listboxes, and selects — it manages the virtualizer lifecycle and dom node pooling for you.\n invalidate measurements with `invalidate()` when item content changes size (e.g., after expanding an accordion row).\n for very large lists (>100k items), set a narrower `overscan` to limit dom node count at any one time.\n use `refresh()` when item data or sizes may have changed; it rebuilds the offset table and re emits.\n",
|
|
1143
1165
|
"examples": " \ntitle: scroll — examples\ndescription: practical examples and recipes for scroll.\n \n\n## examples\n\n [basic fixed height list](./examples/basic fixed height list.md)\n [variable height with measurement](./examples/variable height with measurement.md)\n [grouped list headers plus rows](./examples/grouped list headers plus rows.md)\n [infinite scroll load more](./examples/infinite scroll load more.md)\n [keyboard navigation](./examples/keyboard navigation.md)\n [restore scroll position](./examples/restore scroll position.md)\n [density toggle compact comfortable](./examples/density toggle compact comfortable.md)\n [dom virtual list combobox pattern](./examples/dom virtual list combobox pattern.md)\n [grid virtualizer](./examples/grid virtualizer.md)\n [reactive virtualizer](./examples/reactive virtualizer.md)\n [infinite scroll with analytics and prefetch](./examples/on range change.md)\n [sticky items in dom virtual list](./examples/dom virtual list sticky.md)\n [recreate on remount](./examples/using virtualizer directly without createvirtualizer.md)\n [explicit resource management (`using`)](./examples/explicit resource management using.md)\n"
|
|
1144
1166
|
},
|
|
@@ -1199,7 +1221,7 @@
|
|
|
1199
1221
|
"category": "environment",
|
|
1200
1222
|
"description": "reactive browser and dom observations for viewport, network, media query, element size, and intersection state.",
|
|
1201
1223
|
"docs": {
|
|
1202
|
-
"index": " \ntitle: sentinel — reactive environment state\ndescription: reactive browser and dom observations for viewport, network, media query, element size, and intersection state.\npackage: sentinel\ncategory: environment\nkeywords: [reactive, browser, viewport, network, media query, resize observer, intersection observer]\nrelated: [ripple, ore, focus, gesture]\nexports: [createviewport, createnetwork, createmediaquery, createelementsize, createintersection, sentinelerror, sentinelunavailableerror, sentinel]\nenvironments: [browser]\n \n\n<! markdownlint disable md025 md033 md060 >\n\n<packagehero package=\"sentinel\" />\n\n## why sentinel?\n\nbrowser environment apis use different events, observer callbacks, initial states, and cleanup methods. sentinel gives them one explicit handle shape and exposes current values as ripple `readable<t>` signals.\n\n```ts\n// before\n{\n const panel = document.queryselector<htmlelement>('[data panel]');\n if (!panel) throw new error('panel not found');\n\n const observer = new resizeobserver(([entry]) => {\n console.log(entry?.contentrect.width);\n });\n observer.observe(panel);\n\n // later\n observer.disconnect();\n}\n\n// after\nimport { createelementsize } from '@vielzeug/sentinel';\n\n{\n const panel = document.queryselector<htmlelement>('[data panel]');\n if (!panel) throw new error('panel not found');\n\n const size = createelementsize(panel);\n const unsubscribe = size.subscribe(() => {\n console.log(size.value?.width);\n });\n\n // later\n unsubscribe();\n size.dispose();\n}\n```\n\n| feature | sentinel | native observer apis | ad hoc event listeners |\n| | | | |\n| bundle size | <packageinfo package=\"sentinel\" type=\"size\" /> | built in | application defined |\n| zero dependencies | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> |\n| reactive current state | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> |\n| consistent disposable handle | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"triangle alert\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> |\n| shared abort ownership | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"triangle alert\" size=\"16\"></ore icon> |\n| ripple composition | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> |\n\n<div class=\"decision callout\">\n\n**use sentinel when** browser or dom observations need reactive state, consistent ownership, and composition with ripple.\n\n**consider native apis when** one isolated observer is sufficient and adding ripple as a peer dependency is not justified.\n\n</div>\n\n## installation\n\n::: code group\n\n```sh [pnpm]\npnpm add @vielzeug/sentinel @vielzeug/ripple\n```\n\n```sh [npm]\nnpm install @vielzeug/sentinel @vielzeug/ripple\n```\n\n```sh [yarn]\nyarn add @vielzeug/sentinel @vielzeug/ripple\n```\n\n:::\n\n## quick start\n\ncreate a viewport sentinel, render its initial state, then react to changes until the page lifetime ends.\n\n```ts\nimport { createviewport } from '@vielzeug/sentinel';\n\nfunction observeviewport(): () => void {\n const viewport = createviewport();\n const render = () => {\n const { dpr, height, width } = viewport.value;\n console.log(`${width}×${height} at ${dpr}dpr`);\n };\n\n render();\n const unsubscribe = viewport.subscribe(render);\n\n return () => {\n unsubscribe();\n viewport.dispose();\n };\n}\n\nconst stopobserving = observeviewport();\n// call stopobserving() when the owning view unmounts.\n```\n\n<div class=\"features grid\">\n\n## features\n\n `createviewport()` — observe viewport dimensions and device pixel ratio.\n `createnetwork()` — track online status and optional connection details.\n `createmediaquery()` — observe one media query.\n `createelementsize()` — read content box dimensions from `resizeobserver`.\n `createintersection()` — track normalized intersection state.\n `dispose()` — release owned browser observers and listeners.\n `sentineloptions.signal` — abort several sentinels through one external lifetime.\n\n</div>\n\n<div class=\"doc links\">\n\n## documentation\n\n [**usage guide**](./usage.md)
|
|
1224
|
+
"index": " \ntitle: sentinel — reactive environment state\ndescription: reactive browser and dom observations for viewport, network, media query, element size, and intersection state.\npackage: sentinel\ncategory: environment\nkeywords: [reactive, browser, viewport, network, media query, resize observer, intersection observer]\nrelated: [ripple, ore, focus, gesture]\nexports: [createviewport, createnetwork, createmediaquery, createelementsize, createintersection, sentinelerror, sentinelunavailableerror, sentinel]\nenvironments: [browser]\n \n\n<! markdownlint disable md025 md033 md060 >\n\n<packagehero package=\"sentinel\" />\n\n## why sentinel?\n\nbrowser environment apis use different events, observer callbacks, initial states, and cleanup methods. sentinel gives them one explicit handle shape and exposes current values as ripple `readable<t>` signals.\n\n```ts\n// before\n{\n const panel = document.queryselector<htmlelement>('[data panel]');\n if (!panel) throw new error('panel not found');\n\n const observer = new resizeobserver(([entry]) => {\n console.log(entry?.contentrect.width);\n });\n observer.observe(panel);\n\n // later\n observer.disconnect();\n}\n\n// after\nimport { createelementsize } from '@vielzeug/sentinel';\n\n{\n const panel = document.queryselector<htmlelement>('[data panel]');\n if (!panel) throw new error('panel not found');\n\n const size = createelementsize(panel);\n const unsubscribe = size.subscribe(() => {\n console.log(size.value?.width);\n });\n\n // later\n unsubscribe();\n size.dispose();\n}\n```\n\n| feature | sentinel | native observer apis | ad hoc event listeners |\n| | | | |\n| bundle size | <packageinfo package=\"sentinel\" type=\"size\" /> | built in | application defined |\n| zero dependencies | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> |\n| reactive current state | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> |\n| consistent disposable handle | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"triangle alert\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> |\n| shared abort ownership | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"triangle alert\" size=\"16\"></ore icon> |\n| ripple composition | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> |\n\n<div class=\"decision callout\">\n\n**use sentinel when** browser or dom observations need reactive state, consistent ownership, and composition with ripple.\n\n**consider native apis when** one isolated observer is sufficient and adding ripple as a peer dependency is not justified.\n\n</div>\n\n## installation\n\n::: code group\n\n```sh [pnpm]\npnpm add @vielzeug/sentinel @vielzeug/ripple\n```\n\n```sh [npm]\nnpm install @vielzeug/sentinel @vielzeug/ripple\n```\n\n```sh [yarn]\nyarn add @vielzeug/sentinel @vielzeug/ripple\n```\n\n:::\n\n## quick start\n\ncreate a viewport sentinel, render its initial state, then react to changes until the page lifetime ends.\n\n```ts\nimport { createviewport } from '@vielzeug/sentinel';\n\nfunction observeviewport(): () => void {\n const viewport = createviewport();\n const render = () => {\n const { dpr, height, width } = viewport.value;\n console.log(`${width}×${height} at ${dpr}dpr`);\n };\n\n render();\n const unsubscribe = viewport.subscribe(render);\n\n return () => {\n unsubscribe();\n viewport.dispose();\n };\n}\n\nconst stopobserving = observeviewport();\n// call stopobserving() when the owning view unmounts.\n```\n\n<div class=\"features grid\">\n\n## features\n\n `createviewport()` — observe viewport dimensions and device pixel ratio.\n `createnetwork()` — track online status and optional connection details.\n `createmediaquery()` — observe one media query.\n `createelementsize()` — read content box dimensions from `resizeobserver`.\n `createintersection()` — track normalized intersection state.\n `dispose()` — release owned browser observers and listeners.\n `sentineloptions.signal` — abort several sentinels through one external lifetime.\n\n</div>\n\n<div class=\"doc links\">\n\n## documentation\n\n [**usage guide**](./usage.md)\n [**api reference**](./api.md)\n [**examples**](./examples.md)\n\n</div>\n\n<div class=\"see also\">\n\n## see also\n\n [@vielzeug/ripple](../ripple/) — derive and watch values from sentinel state.\n [@vielzeug/ore](../ore/) — bind sentinels to web component mount and cleanup lifecycles.\n [@vielzeug/focus](../focus/) — manage keyboard focus alongside observed ui state.\n [@vielzeug/gesture](../gesture/) — handle pointer gestures alongside environmental observations.\n\n</div>\n\n<! markdownlint enable md025 md033 md060 >\n",
|
|
1203
1225
|
"api": " \ntitle: sentinel — api reference\ndescription: factory signatures, options, state types, lifecycle handles, and errors for sentinel.\n \n\n[[toc]]\n\n## api overview\n\n| symbol | purpose | execution mode | common gotcha |\n| | | | |\n| `createviewport()` | observe layout viewport dimensions and device pixel ratio | sync | requires a browser window |\n| `createnetwork()` | observe online status and optional connection information | sync | `connection` is often `null` |\n| `createmediaquery()` | observe one media query | sync | throws when `matchmedia` is unavailable |\n| `createelementsize()` | observe element content box dimensions | sync | value is `null` before the first delivery |\n| `createintersection()` | observe element intersection state | sync | value is `null` before the first delivery |\n| `sentinel<t>` | combine a ripple readable with explicit browser resource ownership | sync | subscriptions and the sentinel have separate cleanup |\n| `sentinelerror` | base class for package defined errors | sync | catch a subtype when recovery is specific |\n| `sentinelunavailableerror` | report an unavailable browser api | sync | invalid observer inputs retain their native errors |\n\n## package entry point\n\n| import | purpose |\n| | |\n| `@vielzeug/sentinel` | all factories, state types, option types, and error classes |\n\n## factories\n\n### `createviewport()`\n\n```ts\nfunction createviewport(options?: windowsentineloptions): sentinel<viewportstate>;\n```\n\nreturns a sentinel initialized from the layout viewport's `innerwidth`, `innerheight`, and `devicepixelratio`.\n\n| parameter | type | description |\n| | | |\n| `options.target` | `window` | window to observe instead of the global browser window |\n| `options.runtime` | `pick<ripple, 'signal'>` | ripple runtime that owns the internal signal |\n| `options.signal` | `abortsignal` | external signal that disposes the sentinel |\n\n**returns:** `sentinel<viewportstate>`.\n\n**example**\n\n```ts\nimport { createviewport } from '@vielzeug/sentinel';\n\nconst viewport = createviewport();\nconsole.log(viewport.value.width);\nviewport.dispose();\n```\n\n \n\n### `createnetwork()`\n\n```ts\nfunction createnetwork(options?: windowsentineloptions): sentinel<networkstate>;\n```\n\nreturns a sentinel initialized from `navigator.online` and the optional network information api.\n\n| parameter | type | description |\n| | | |\n| `options.target` | `window` | window whose navigator and events are observed |\n| `options.runtime` | `pick<ripple, 'signal'>` | ripple runtime that owns the internal signal |\n| `options.signal` | `abortsignal` | external signal that disposes the sentinel |\n\n**returns:** `sentinel<networkstate>`.\n\n**example**\n\n```ts\nimport { createnetwork } from '@vielzeug/sentinel';\n\nconst network = createnetwork();\nconsole.log(network.value.online);\nnetwork.dispose();\n```\n\n \n\n### `createmediaquery()`\n\n```ts\nfunction createmediaquery(query: string, options?: windowsentineloptions): sentinel<mediaquerystate>;\n```\n\nreturns a sentinel initialized from `matchmedia(query).matches`.\n\n| parameter | type | description |\n| | | |\n| `query` | `string` | css media query to observe |\n| `options.target` | `window` | window whose `matchmedia` method is used |\n| `options.runtime` | `pick<ripple, 'signal'>` | ripple runtime that owns the internal signal |\n| `options.signal` | `abortsignal` | external signal that disposes the sentinel |\n\n**returns:** `sentinel<mediaquerystate>`.\n\n**example**\n\n```ts\nimport { createmediaquery } from '@vielzeug/sentinel';\n\nconst darkmode = createmediaquery('(prefers color scheme: dark)');\nconsole.log(darkmode.value.matches);\ndarkmode.dispose();\n```\n\n \n\n### `createelementsize()`\n\n```ts\nfunction createelementsize(element: element, options?: sentineloptions): sentinel<elementsizestate | null>;\n```\n\nreturns a sentinel containing the latest `resizeobserverentry.contentrect` dimensions.\n\n| parameter | type | description |\n| | | |\n| `element` | `element` | element to observe |\n| `options.runtime` | `pick<ripple, 'signal'>` | ripple runtime that owns the internal signal |\n| `options.signal` | `abortsignal` | external signal that disposes the sentinel |\n\n**returns:** `sentinel<elementsizestate | null>`. the initial value is `null`.\n\n**example**\n\n```ts\nimport { createelementsize } from '@vielzeug/sentinel';\n\nconst size = createelementsize(document.body);\nconst unsubscribe = size.subscribe(() => {\n console.log(size.value?.width);\n});\n\nunsubscribe();\nsize.dispose();\n```\n\n \n\n### `createintersection()`\n\n```ts\nfunction createintersection(\n element: element,\n options?: createintersectionoptions,\n): sentinel<intersectionstate | null>;\n```\n\nreturns a sentinel containing normalized fields from the latest intersectionobserver entry.\n\n| parameter | type | description |\n| | | |\n| `element` | `element` | element to observe |\n| `options.root` | `element \\| document \\| null` | intersection root |\n| `options.rootmargin` | `string` | margin applied to the root |\n| `options.scrollmargin` | `string` | margin applied to nested scroll containers |\n| `options.threshold` | `number \\| number[]` | intersection ratio threshold or thresholds |\n| `options.runtime` | `pick<ripple, 'signal'>` | ripple runtime that owns the internal signal |\n| `options.signal` | `abortsignal` | external signal that disposes the sentinel |\n\n**returns:** `sentinel<intersectionstate | null>`. the initial value is `null`.\n\n**example**\n\n```ts\nimport { createintersection } from '@vielzeug/sentinel';\n\nconst intersection = createintersection(document.body, { threshold: 0.5 });\nconst unsubscribe = intersection.subscribe(() => {\n console.log(intersection.value?.isintersecting);\n});\n\nunsubscribe();\nintersection.dispose();\n```\n\n## types\n\n### `sentinel<t>`\n\n```ts\ninterface sentinel<t> extends readable<t> {\n readonly disposalsignal: abortsignal;\n dispose(): void;\n readonly disposed: boolean;\n [symbol.dispose](): void;\n}\n```\n\n`value`, `peek()`, and `subscribe()` follow ripple's `readable<t>` contract. `dispose()` stops the underlying browser observation. a subscription's returned function remains independently owned by the subscriber.\n\n| member | type | description |\n| | | |\n| `value` | `t` | current reactive snapshot |\n| `peek()` | `() => t` | read the snapshot without reactive tracking |\n| `subscribe(listener)` | `(listener: () => void) => () => void` | subscribe to invalidations and return an independent unsubscribe function |\n| `disposed` | `boolean` | whether observation has ended |\n| `disposalsignal` | `abortsignal` | aborts when observation ends |\n| `dispose()` | `() => void` | stop observation and release owned browser resources |\n| `[symbol.dispose]()` | `() => void` | dispose through the explicit resource management protocol |\n\n \n\n### `sentineloptions`\n\n```ts\ninterface sentineloptions {\n readonly runtime?: pick<ripple, 'signal'>;\n readonly signal?: abortsignal;\n}\n```\n\n \n\n### `windowsentineloptions`\n\n```ts\ninterface windowsentineloptions extends sentineloptions {\n readonly target?: window;\n}\n```\n\n \n\n### `createintersectionoptions`\n\n```ts\ninterface createintersectionoptions extends sentineloptions {\n readonly root?: element | document | null;\n readonly rootmargin?: string;\n readonly scrollmargin?: string;\n readonly threshold?: number | number[];\n}\n```\n\n \n\n### `viewportstate`\n\n```ts\ninterface viewportstate {\n readonly dpr: number;\n readonly height: number;\n readonly width: number;\n}\n```\n\n \n\n### `networkconnectionsnapshot`\n\n```ts\ninterface networkconnectionsnapshot {\n readonly downlink?: number;\n readonly effectivetype?: 'slow 2g' | '2g' | '3g' | '4g';\n readonly rtt?: number;\n readonly savedata?: boolean;\n}\n```\n\n \n\n### `networkstate`\n\n```ts\ninterface networkstate {\n readonly connection: networkconnectionsnapshot | null;\n readonly online: boolean;\n}\n```\n\n \n\n### `mediaquerystate`\n\n```ts\ninterface mediaquerystate {\n readonly matches: boolean;\n}\n```\n\n \n\n### `elementsizestate`\n\n```ts\ninterface elementsizestate {\n readonly height: number;\n readonly width: number;\n}\n```\n\n \n\n### `intersectionstate`\n\n```ts\ninterface intersectionstate {\n readonly intersectionratio: number;\n readonly isintersecting: boolean;\n}\n```\n\n## errors\n\n### `sentinelerror`\n\n```ts\nclass sentinelerror extends error {\n constructor(message: string, options?: erroroptions);\n}\n```\n\nbase class for package defined errors.\n\n \n\n### `sentinelunavailableerror`\n\n```ts\nclass sentinelunavailableerror extends sentinelerror {}\n```\n\nthrown when a required browser api or window is unavailable:\n\n `createviewport()` and `createnetwork()` when no browser window is available.\n `createmediaquery()` when `matchmedia` is unavailable.\n `createelementsize()` when the element has no window or `resizeobserver` is unavailable.\n `createintersection()` when the element has no window or `intersectionobserver` is unavailable.\n\nnative setup errors remain unchanged, including invalid observer options or targets.\n",
|
|
1204
1226
|
"usage": " \ntitle: sentinel — usage guide\ndescription: observe browser and dom state with explicit reactive lifecycles.\n \n\n[[toc]]\n\n## basic usage\n\ncreate a sentinel, read its current state, subscribe to invalidations, and release both resources when the owner ends.\n\n```ts\nimport { createviewport } from '@vielzeug/sentinel';\n\nfunction observeviewport(): () => void {\n const viewport = createviewport();\n\n const render = () => {\n const { dpr, height, width } = viewport.value;\n console.log(`${width}×${height} at ${dpr}dpr`);\n };\n\n render();\n const unsubscribe = viewport.subscribe(render);\n\n return () => {\n unsubscribe();\n viewport.dispose();\n };\n}\n\nconst stopobserving = observeviewport();\n// call stopobserving() when the owning view unmounts.\n```\n\n`subscribe()` notifies you that the value changed; read the new snapshot from `.value` inside the listener. disposing a sentinel stops its browser observer or event listeners. it does not unsubscribe consumers from the ripple readable.\n\n## observe window state\n\nuse `createviewport()` for viewport dimensions and device pixel ratio.\n\n```ts\nimport { createviewport } from '@vielzeug/sentinel';\n\nconst viewport = createviewport();\nconsole.log(viewport.value.width);\nconsole.log(viewport.value.height);\nconsole.log(viewport.value.dpr);\n```\n\nuse `createnetwork()` for online status and the optional network information api snapshot.\n\n```ts\nimport { createnetwork } from '@vielzeug/sentinel';\n\nconst network = createnetwork();\nconsole.log(network.value.online);\nconsole.log(network.value.connection);\n```\n\n`connection` is `null` when `navigator.connection` is unavailable.\n\n## observe media queries\n\nuse `createmediaquery()` to react to a browser media query.\n\n```ts\nimport { createmediaquery, sentinelunavailableerror } from '@vielzeug/sentinel';\n\nfunction observereducedmotion(): () => void {\n try {\n const reducedmotion = createmediaquery('(prefers reduced motion: reduce)');\n\n const applypreference = () => {\n document.documentelement.classlist.toggle('reduce motion', reducedmotion.value.matches);\n };\n\n applypreference();\n const unsubscribe = reducedmotion.subscribe(applypreference);\n\n return () => {\n unsubscribe();\n reducedmotion.dispose();\n };\n } catch (error) {\n if (!(error instanceof sentinelunavailableerror)) throw error;\n return () => {};\n }\n}\n\nconst stopobserving = observereducedmotion();\n// call stopobserving() when the owning view unmounts.\n```\n\n`createmediaquery()` throws `sentinelunavailableerror` when `matchmedia` is unavailable.\n\n## observe elements\n\n### element size\n\nuse `createelementsize()` after the target element exists.\n\n```ts\nimport { createelementsize } from '@vielzeug/sentinel';\n\nconst panel = document.queryselector<htmlelement>('[data panel]');\nif (!panel) throw new error('panel not found');\n\nconst size = createelementsize(panel);\nconst unsubscribe = size.subscribe(() => {\n const current = size.value;\n if (current) panel.dataset.width = string(current.width);\n});\n```\n\nthe initial state is `null` until `resizeobserver` reports its first measurement.\n\n### intersection\n\nuse `createintersection()` to observe visibility relative to the viewport or a custom root.\n\n```ts\nimport { createintersection } from '@vielzeug/sentinel';\n\nconst target = document.queryselector<htmlelement>('[data lazy section]');\nif (!target) throw new error('section not found');\n\nconst intersection = createintersection(target, {\n rootmargin: '100px',\n threshold: [0, 0.5, 1],\n});\n\nconst unsubscribe = intersection.subscribe(() => {\n target.hidden = !intersection.value?.isintersecting;\n});\n```\n\nthe initial state is `null` until `intersectionobserver` reports its first entry.\n\n## control ownership\n\ncall `dispose()` to stop observation. disposal is idempotent.\n\n```ts\nconst viewport = createviewport();\n\nviewport.dispose();\nviewport.dispose();\n```\n\npass an `abortsignal` when several sentinels share one lifetime.\n\n```ts\nconst controller = new abortcontroller();\nconst viewport = createviewport({ signal: controller.signal });\nconst network = createnetwork({ signal: controller.signal });\n\ncontroller.abort();\n```\n\nan injected ripple runtime creates the state signal. runtime disposal and sentinel disposal remain separate responsibilities.\n\n```ts\nimport { createripple } from '@vielzeug/ripple';\nimport { createviewport } from '@vielzeug/sentinel';\n\nconst ripple = createripple();\nconst viewport = createviewport({ runtime: ripple });\n\nviewport.dispose();\nripple.dispose();\n```\n\n## handle unavailable apis\n\n`createmediaquery()`, `createelementsize()`, and `createintersection()` report unavailable platform apis with `sentinelunavailableerror`.\n\n```ts\nimport { createelementsize, sentinelunavailableerror } from '@vielzeug/sentinel';\n\ntry {\n const size = createelementsize(document.body);\n size.dispose();\n} catch (error) {\n if (error instanceof sentinelunavailableerror) {\n console.warn(error.message);\n } else {\n throw error;\n }\n}\n```\n\ninvoke all factories only in a browser client lifecycle. package imports are safe during ssr, but factories require browser or dom apis.\n\n## framework integration\n\ncreate the sentinel after the component mounts, mirror its current value into framework state, and unsubscribe and dispose on unmount.\n\n::: code group\n\n```tsx [react]\nimport { createviewport, type viewportstate } from '@vielzeug/sentinel';\nimport { useeffect, usestate } from 'react';\n\nexport function viewportsize() {\n const [viewportstate, setviewportstate] = usestate<viewportstate | null>(null);\n\n useeffect(() => {\n const viewport = createviewport();\n const update = () => setviewportstate(viewport.value);\n\n update();\n const unsubscribe = viewport.subscribe(update);\n\n return () => {\n unsubscribe();\n viewport.dispose();\n };\n }, []);\n\n return <output>{viewportstate ? `${viewportstate.width}×${viewportstate.height}` : 'measuring…'}</output>;\n}\n```\n\n```vue [vue 3]\n<script setup lang=\"ts\">\nimport { createviewport, type sentinel, type viewportstate } from '@vielzeug/sentinel';\nimport { onmounted, onunmounted, ref } from 'vue';\n\nconst viewportstate = ref<viewportstate | null>(null);\nlet viewport: sentinel<viewportstate> | undefined;\nlet unsubscribe: (() => void) | undefined;\n\nonmounted(() => {\n viewport = createviewport();\n const update = () => {\n viewportstate.value = viewport?.value ?? null;\n };\n\n update();\n unsubscribe = viewport.subscribe(update);\n});\n\nonunmounted(() => {\n unsubscribe?.();\n viewport?.dispose();\n});\n</script>\n\n<template>\n <output>\n {{ viewportstate ? `${viewportstate.width}×${viewportstate.height}` : 'measuring…' }}\n </output>\n</template>\n```\n\n```svelte [svelte]\n<script lang=\"ts\">\n import { createviewport, type viewportstate } from '@vielzeug/sentinel';\n import { onmount } from 'svelte';\n\n let viewportstate: viewportstate | null = null;\n\n onmount(() => {\n const viewport = createviewport();\n const update = () => {\n viewportstate = viewport.value;\n };\n\n update();\n const unsubscribe = viewport.subscribe(update);\n\n return () => {\n unsubscribe();\n viewport.dispose();\n };\n });\n</script>\n\n<output>\n {viewportstate ? `${viewportstate.width}×${viewportstate.height}` : 'measuring…'}\n</output>\n```\n\n:::\n\n## working with other vielzeug libraries\n\n### sentinel + ripple\n\nuse ripple to derive values from one or more sentinel states. dispose the watcher separately from the sentinels.\n\n```ts\nimport { computed, watch } from '@vielzeug/ripple';\nimport { createmediaquery, createviewport } from '@vielzeug/sentinel';\n\nconst viewport = createviewport();\nconst mobilequery = createmediaquery('(max width: 768px)');\nconst compact = computed(() => mobilequery.value.matches || viewport.value.width < 400);\nconst compactwatcher = watch(compact, (value) => console.log('compact layout:', value), { immediate: true });\n\ncompactwatcher.dispose();\nmobilequery.dispose();\nviewport.dispose();\n```\n\n### sentinel + ore\n\ncreate dom dependent sentinels in `onmounted()` and register both subscription and sentinel cleanup with the component.\n\n```ts\nimport { define, html, oncleanup, onmounted, ref } from '@vielzeug/ore';\nimport { createelementsize } from '@vielzeug/sentinel';\n\ndefine('measured panel', {\n setup() {\n const panel = ref<htmlelement>();\n\n onmounted(() => {\n const element = panel.value;\n if (!element) return;\n\n const size = createelementsize(element);\n const update = () => {\n element.dataset.width = string(size.value?.width ?? 0);\n };\n const unsubscribe = size.subscribe(update);\n\n oncleanup(() => {\n unsubscribe();\n size.dispose();\n });\n });\n\n return html`<section ref=${panel}>measured panel</section>`;\n },\n});\n```\n\n## best practices\n\n **create** dom dependent sentinels only after their target elements exist.\n **read** the latest snapshot from `.value` inside subscription listeners.\n **unsubscribe** ripple listeners when their owner ends.\n **dispose** every sentinel to release browser observers and event listeners.\n **share** an `abortsignal` when multiple sentinels have the same lifetime.\n **guard** apis that can throw `sentinelunavailableerror`.\n **treat** `networkstate.connection` as optional browser enhancement data.\n **invoke** factories only in browser client lifecycles.\n",
|
|
1205
1227
|
"examples": " \ntitle: sentinel — examples\ndescription: focused browser and dom observation examples for sentinel.\n \n\n## examples\n\n [responsive viewport tracking](./examples/responsive viewport tracking.md)\n [monitor network condition](./examples/monitor network condition.md)\n [respect reduced motion preference](./examples/respect reduced motion preference.md)\n [responsive column layout](./examples/responsive column layout.md)\n [lazy load images on intersection](./examples/lazy load images on intersection.md)\n"
|
|
@@ -1264,7 +1286,7 @@
|
|
|
1264
1286
|
"description": "schema validation with explicit sync/async checks, portable definitions, json schema export, and tree shakeable entry points.",
|
|
1265
1287
|
"docs": {
|
|
1266
1288
|
"index": " \ntitle: spell — schema validation for typescript\ndescription: schema validation with explicit sync/async checks, portable definitions, json schema export, and tree shakeable entry points.\npackage: spell\ncategory: validation\nkeywords: [schema, validation, parsing, json schema, locale, typescript, descriptors]\nrelated: [forge, courier, vault]\nexports:\n [s, schema, pipeschema, spellvalidationerror, spelldefinitionerror, errorcode, diagnostics, './json', './predicates']\nenvironments: [browser, node, ssr, deno]\n \n\n<! markdownlint disable md025 md033 md060 >\n\n<packagehero package=\"spell\" />\n\n## why spell?\n\nspell keeps runtime validation, static inference, and portable definitions in one api. use `s` for schema construction; import json conversion and predicates from dedicated subpaths.\n\nthis example shows the difference between manual branching and a single reusable schema.\n\n```ts\n// before\nfunction parseuserbefore(value: unknown) {\n if (typeof value !== 'object' || value === null) throw new error('expected object');\n\n const candidate = value as record<string, unknown>;\n\n if (typeof candidate.email !== 'string' || !candidate.email.includes('@')) {\n throw new error('expected valid email');\n }\n\n if (typeof candidate.role !== 'string' || !['admin', 'editor', 'viewer'].includes(candidate.role)) {\n throw new error('expected valid role');\n }\n\n return {\n email: candidate.email,\n role: candidate.role,\n };\n}\n\n// after\nimport { s } from '@vielzeug/spell';\n\nconst user = s.object({\n email: s.string().email(),\n role: s.enum(['admin', 'editor', 'viewer'] as const),\n});\n\nconst user = user.parse({ email: 'ada@example.com', role: 'admin' });\n```\n\n| feature | spell | zod | yup |\n| | | | |\n| bundle size | <packageinfo package=\"spell\" type=\"size\" /> | ~62 kb | ~14 kb |\n| type inference | <ore icon name=\"check\" size=\"16\"></ore icon> `infer<t>` | <ore icon name=\"check\" size=\"16\"></ore icon> | partial |\n| coercion api | <ore icon name=\"check\" size=\"16\"></ore icon> `s.coerce.*` | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> |\n| async validation | <ore icon name=\"check\" size=\"16\"></ore icon> `.checkasync()` | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> |\n| error flattening | <ore icon name=\"check\" size=\"16\"></ore icon> `flatten()` + `flattenfirst()` | <ore icon name=\"check\" size=\"16\"></ore icon> | partial |\n| zero dependencies | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> |\n\n<div class=\"decision callout\">\n\n**use spell when** you want a fluent schema api with strong typescript inference, structured errors, and no third party runtime dependencies.\n\n**consider alternatives when** you are already standardized on another validator ecosystem and migration cost outweighs the api benefits.\n\n</div>\n\n## installation\n\nuse your workspace package manager to add spell.\n\n::: code group\n\n```sh [pnpm]\npnpm add @vielzeug/spell\n```\n\n```sh [npm]\nnpm install @vielzeug/spell\n```\n\n```sh [yarn]\nyarn add @vielzeug/spell\n```\n\n:::\n\n## quick start\n\nstart with a schema, then parse unknown input and use the inferred output type everywhere else.\n\n```ts\nimport { s, type infer } from '@vielzeug/spell';\n\nconst user = s\n .object({\n email: s.string().email(),\n name: s.string().min(1),\n role: s.enum(['admin', 'editor', 'viewer'] as const),\n })\n .relaxed(); // allow extra keys — omit for strict mode (default)\n\ntype user = infer<typeof user>;\n\nconst payload: unknown = {\n email: 'ada@example.com',\n name: 'ada',\n role: 'admin',\n team: 'platform',\n};\n\nconst result = user.safeparse(payload);\n\nif (!result.success) throw result.error;\nconst user = result.data;\n```\n\n## features\n\n<div class=\"features grid\">\n\n namespace and tree shakeable schema builders.\n sync and async parsing with `parse()`, `safeparse()`, `parseasync()`, and `safeparseasync()`.\n explicit `check()` and `checkasync()` rules; sync parsing never skips an async check.\n wrapper modes for `optional`, `nullable`, `nullish`, `default`, `catch`, and `required`.\n frozen declarative definitions through `definition()` and json schema export via `fromdefinition()` from `@vielzeug/spell/json`.\n grouped `diagnostics` and `predicates` utilities keep schema construction focused.\n ordered union parsing produces the same selected branch in sync and async modes.\n structured errors with direct path lookup, flattened views, and best match union diagnostics.\n object parsing is hardened against prototype pollution style keys.\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 [forge](/forge/) — typed form state that uses spell schemas as its validation layer\n [courier](/courier/) — http client for validating request and response payloads at service boundaries\n [vault](/vault/) — unified storage api that accepts spell schemas to type gate persisted data\n\n</div>\n\n<! markdownlint enable md025 md033 md060 >\n",
|
|
1267
|
-
"api": " \ntitle: spell — api reference\ndescription: reference for spell schema builders, parsing, diagnostics, and tooling exports.\n \n\n[[toc]]\n\n## api overview\n\n| symbol | purpose | execution mode | common gotcha |\n| | | | |\n| `s` | creates schemas | sync or async, depending on checks | `checkasync()` requires async parsing |\n| `schema` / `pipeschema` | base schema abstractions | sync or async | use `infer` rather than assuming input equals output |\n| `diagnostics` | parse context and error helpers | sync | context is per parse/request, not global |\n| `spellvalidationerror` | validation failure details | sync/async parse failures | use `safeparse()` to handle it as a result |\n\n## package entry point\n\n| import | purpose |\n| | |\n| `@vielzeug/spell` | schema builders, errors, types, and diagnostics |\n| `@vielzeug/spell/json` | convert portable definitions to json schema |\n| `@vielzeug/spell/predicates` | standalone format and type predicates |\n\n```ts\nimport { diagnostics, s, type infer } from '@vielzeug/spell';\nimport { fromdefinition } from '@vielzeug/spell/json';\nimport { isemail } from '@vielzeug/spell/predicates';\n```\n\n## `s`\n\nall builders live under `s`.\n\n| builder | purpose |\n| | |\n| `string`, `number`, `boolean`, `bigint`, `date` | primitive values |\n| `literal`, `enum`, `null`, `undefined`, `unknown`, `any`, `never` | exact and universal values |\n| `array`, `tuple`, `set`, `map`, `record`, `object` | collections |\n| `union`, `intersect`, `discriminatedunion`, `lazy` | composition |\n| `coerce.*` | coercing primitive schemas |\n\n```ts\nconst user = s.object({\n email: s.string().email(),\n id: s.string().uuid(),\n role: s.enum(['admin', 'member'] as const),\n});\n\ntype user = infer<typeof user>;\n```\n\nobject schemas reject unknown keys. use `.relaxed()` to retain extras.\n\n## parsing\n\nevery schema provides:\n\n```ts\nschema.parse(value, context?); // output or spellvalidationerror\nschema.safeparse(value, context?); // parseresult<output>\nschema.parseasync(value, context?); // promise<output>\nschema.safeparseasync(value, context?); // promise<parseresult<output>>\nschema.is(value); // value is output\nschema.assert(value, label?); // assertion\n```\n\n`parse()` and `safeparse()` are available on synchronous schemas. calling `checkasync()` returns an async only schema, where typescript exposes only `parseasync()` and `safeparseasync()`. that async only mode propagates through compositional schemas when a child is asynchronous.\n\n## custom checks\n\n`check()` is synchronous. `checkasync()` is asynchronous. do not return a promise from `check()`.\n\n```ts\nconst signup = s.object({ confirm: s.string(), password: s.string() }).check((value, context) => {\n if (value.password !== value.confirm) {\n context.addissue({ code: 'custom', message: 'passwords must match', path: ['confirm'] });\n }\n});\n\nconst availableemail = s\n .string()\n .email()\n .checkasync(async (value) => {\n return (await emailavailable(value)) || 'email is already registered';\n });\n```\n\n`checkcontext.addissue()` takes `{ code, message, params?, path? }`. paths are relative to current schema.\n\n## modifiers and transforms\n\n```ts\ns.string().optional();\ns.string().nullable();\ns.string().nullish();\ns.string().required();\ns.string().default('guest');\ns.string().catch('guest');\ns.string()\n .trim()\n .transform((value) => value.tolowercase());\ns.string().pipe(s.string().slug());\ns.string().label('user name');\n```\n\n`default()`, `catch()`, preprocessors, transforms, and checks are runtime behavior. they cannot become portable definitions.\n\n## definitions and json schema\n\n`definition()` is only for schemas containing declarative structure. it returns frozen data and throws `spelldefinitionerror` when runtime behavior is present.\n\n```ts\nimport { s } from '@vielzeug/spell';\nimport { fromdefinition } from '@vielzeug/spell/json';\n\nconst product = s.object({\n id: s.string().uuid(),\n name: s.string().min(1),\n});\n\nconst definition = product.definition();\nconst jsonschema = fromdefinition(definition);\n```\n\nno implicit schema to json conversion exists. make definition boundary explicit.\n\n## diagnostics\n\n`diagnostics` contains pure helpers and immutable parse context creation.\n\n```ts\nimport { diagnostics, s } from '@vielzeug/spell';\n\nconst context = diagnostics.createparsecontext({\n object: { invalidkeys: () => 'unsupported field' },\n});\n\nconst result = s.object({ email: s.string().email() }).safeparse({ email: 'ada@example.com', extra: true }, context);\n\nif (!result.success) {\n const messages = result.error.messagesat('email');\n console.log(messages);\n}\n```\n\n`diagnostics.fail(code, message, params?)` and `diagnostics.prependissuepath(issues, segment)` support custom parser implementations.\n\n## errors\n\n `spellerror` — base class. use `spellerror.is(error)` for cross boundary narrowing.\n `spellvalidationerror` — validation failure with `issues`, `bestmatch()`, `messagesat()`, `flatten()`, and `flattenfirst()`.\n `spelldefinitionerror` — schema cannot create portable definition.\n\n```ts\nconst result = s.object({ email: s.string().email() }).safeparse({ email: 'invalid' });\n\nif (!result.success) {\n const { fielderrors, formerrors } = result.error.flatten();\n console.log(fielderrors, formerrors);\n}\n```\n\n## types\n\n### core schema types\n\n```ts\ntype schemamode = 'async' | 'sync';\n\ntype anyschema<output = unknown, input = output, mode extends schemamode = schemamode> = schemasurface<\n output,\n input,\n mode\n>;\n\ntype schemasurface<output = unknown, input = output, mode extends schemamode = schemamode> = {\n _parsefullasync(value: unknown, ctx?: parsecontext): promise<{ data: unknown; issues: issue[] }>;\n _parsefullsync(value: unknown, ctx?: parsecontext): { data: unknown; issues: issue[] };\n definition(): schemadescriptor;\n isoptional: boolean;\n optional(): schemasurface<output | undefined, input | undefined, mode>;\n required(): schemasurface<exclude<output, undefined>, exclude<input, undefined>, mode>;\n readonly [schemainput]: input;\n readonly [schemamode]: mode;\n readonly [schemaoutput]: output;\n walk<r>(visitor: schemawalker<r>): r | null;\n};\n```\n\n`schemamode` is the public symbol marking a schema's parsing capability.\n\n### inference types\n\n```ts\ntype inferoutput<t> =\n t extends schema<infer output, unknown, schemamode>\n ? output\n : t extends { readonly [schemaoutput]: infer output }\n ? output\n : never;\ntype inferinput<t> = t extends { readonly [schemainput]: infer input } ? input : unknown;\ntype infer<t> = inferoutput<t>;\ntype inferschemamode<t> = t extends { readonly [schemamode]: infer mode extends schemamode } ? mode : never;\ntype mergeschemamodes<modes extends schemamode> = 'async' extends modes ? 'async' : 'sync';\n```\n\n### parse result and issues\n\n```ts\ntype parseresult<t> = { data: t; success: true } | { error: spellvalidationerror; success: false };\n\ntype issue =\n | { code: 'custom'; message: string; params?: record<string, unknown>; path: (string | number)[] }\n | { code: 'invalid_base64'; message: string; params: { format: string }; path: (string | number)[] }\n | { code: 'invalid_date'; message: string; params?: undefined; path: (string | number)[] }\n | { code: 'invalid_duration'; message: string; params: { format: string }; path: (string | number)[] }\n | { code: 'invalid_enum'; message: string; params: { values: readonly unknown[] }; path: (string | number)[] }\n | { code: 'invalid_finite'; message: string; params?: undefined; path: (string | number)[] }\n | { code: 'invalid_integer'; message: string; params?: undefined; path: (string | number)[] }\n | { code: 'invalid_keys'; message: string; params: { keys: string[] }; path: (string | number)[] }\n | { code: 'invalid_length'; message: string; params: { exact: number }; path: (string | number)[] }\n | { code: 'invalid_literal'; message: string; params: { expected: unknown }; path: (string | number)[] }\n | { code: 'invalid_multiple_of'; message: string; params: { step: number | bigint }; path: (string | number)[] }\n | { code: 'invalid_safe'; message: string; params?: undefined; path: (string | number)[] }\n | {\n code: 'invalid_string';\n message: string;\n params: { format?: string; includes?: string; pattern?: string; prefix?: string; suffix?: string };\n path: (string | number)[];\n }\n | { code: 'invalid_type'; message: string; params?: undefined; path: (string | number)[] }\n | { code: 'invalid_union'; message: string; params: { errors: issue[][] }; path: (string | number)[] }\n | { code: 'invalid_unique'; message: string; params: { unique: true }; path: (string | number)[] }\n | { code: 'invalid_url'; message: string; params: { format: string }; path: (string | number)[] }\n | {\n code: 'invalid_variant';\n message: string;\n params: { discriminator: string; expected: string[] };\n path: (string | number)[];\n }\n | {\n code: 'too_big';\n message: string;\n params: { exclusive?: boolean; max: number | bigint | date };\n path: (string | number)[];\n }\n | {\n code: 'too_small';\n message: string;\n params: { exclusive?: boolean; min: number | bigint | date };\n path: (string | number)[];\n }\n | { code: string & {}; message: string; params?: record<string, unknown>; path: (string | number)[] };\n```\n\n`errorcode` is a const object mapping each issue code to its string literal.\n\n### validation contracts\n\n```ts\ntype parsecontext = { messages: messages };\n\ntype validatefn = (value: unknown, ctx?: parsecontext) => issue[] | null | promise<issue[] | null>;\n\ntype checkcontext = {\n addissue: (issue: {\n code: string;\n message: string;\n params?: record<string, unknown>;\n path?: (string | number)[];\n }) => void;\n};\n\ntype validateresult = boolean | null | undefined | string;\n```\n\n### messages\n\n```ts\ntype messagefn<ctx extends record<string, unknown> = record<string, unknown>> = string | ((ctx: ctx) => string);\n\ntype messages = {\n array: { length: (ctx: { exact: number; value: unknown[] }) => string; max: (ctx: { max: number; value: unknown[] }) => string; min: (ctx: { min: number; value: unknown[] }) => string; nonempty: () => string; type: () => string; unique: () => string };\n bigint: { max: (ctx: { max: bigint; value: bigint }) => string; min: (ctx: { min: bigint; value: bigint }) => string; multipleof: (ctx: { step: bigint; value: bigint }) => string; negative: () => string; nonnegative: () => string; nonpositive: () => string; positive: () => string; type: () => string };\n boolean: { type: () => string };\n check: { default: () => string };\n date: { max: (ctx: { max: date; value: date }) => string; min: (ctx: { min: date; value: date }) => string; type: () => string };\n enum: { invalid: (ctx: { values: readonly unknown[] }) => string };\n instanceof: { type: (ctx: { classname: string }) => string };\n literal: { expected: (ctx: { expected: unknown }) => string };\n map: { max: (ctx: { max: number; value: map<unknown, unknown> }) => string; min: (ctx: { min: number; value: map<unknown, unknown> }) => string; nonempty: () => string; size: (ctx: { exact: number; value: map<unknown, unknown> }) => string; type: () => string };\n never: { invalid: () => string };\n number: { finite: () => string; int: () => string; max: (ctx: { max: number; value: number }) => string; min: (ctx: { min: number; value: number }) => string; multipleof: (ctx: { step: number; value: number }) => string; negative: () => string; nonnegative: () => string; nonpositive: () => string; positive: () => string; safe: () => string; type: () => string };\n object: { invalidkeys: (ctx: { keys: string[] }) => string; type: () => string };\n set: { max: (ctx: { max: number; value: set<unknown> }) => string; min: (ctx: { min: number; value: set<unknown> }) => string; nonempty: () => string; size: (ctx: { exact: number; value: set<unknown> }) => string; type: () => string };\n string: { base64: () => string; base64url: () => string; cuid: () => string; cuid2: () => string; date: () => string; datetime: () => string; duration: () => string; email: () => string; emoji: () => string; endswith: (ctx: { suffix: string; value: string }) => string; hex: () => string; hexcolor: () => string; includes: (ctx: { substr: string; value: string }) => string; ip: () => string; jwt: () => string; length: (ctx: { exact: number; value: string }) => string; max: (ctx: { max: number; value: string }) => string; min: (ctx: { min: number; value: string }) => string; nanoid: () => string; nonempty: () => string; numeric: () => string; regex: (ctx: { value: string }) => string; semver: () => string; slug: () => string; startswith: (ctx: { prefix: string; value: string }) => string; time: () => string; type: () => string; ulid: () => string; url: () => string; uuid: () => string };\n tuple: { length: (ctx: { exact: number }) => string; min: (ctx: { min: number }) => string; type: () => string };\n union: { invalid: () => string };\n variant: { invaliddiscriminator: (ctx: { discriminator: string; expected: string[] }) => string; type: () => string };\n};\n\ntype deeppartial<t> = {\n [k in keyof t]?: t[k] extends record<string, unknown> ? deeppartial<t[k]> : t[k];\n};\n```\n\n### descriptor and json schema\n\n```ts\ntype schemadescriptor = basedescriptor &\n (\n | { kind: 'any' | 'unknown' | 'never' | 'boolean' | 'bigint' | 'date' | 'lazy' }\n | { classname: string; kind: 'instanceof' }\n | { contentencoding?: string; format?: string; kind: 'string'; maxlength?: number; minlength?: number; pattern?: string | null }\n | { exclusivemaximum?: number; exclusiveminimum?: number; kind: 'number'; maximum?: number; minimum?: number; multipleof?: number; typehint?: 'integer' }\n | { kind: 'literal'; value: string | number | boolean | null | undefined }\n | { kind: 'enum'; values: readonly (string | number)[] }\n | { items: schemadescriptor; kind: 'array'; maxitems?: number; minitems?: number }\n | { items: schemadescriptor[]; kind: 'tuple'; rest: schemadescriptor | null }\n | { fields: record<string, schemadescriptor>; kind: 'object'; strict: boolean }\n | { key: schemadescriptor; kind: 'record'; value: schemadescriptor }\n | { items: schemadescriptor; kind: 'set' }\n | { key: schemadescriptor; kind: 'map'; value: schemadescriptor }\n | { branches: schemadescriptor[]; kind: 'union' | 'intersect' }\n | { branches: record<string, schemadescriptor>; discriminator: string; kind: 'variant' }\n | { from: schemadescriptor; kind: 'pipe'; to: schemadescriptor }\n );\n\ntype jsonschema = record<string, unknown>;\n```\n\n### schema walker\n\n```ts\ntype schemawalker<r> = {\n array?: <t extends anyschema, mode extends schemamode>(schema: arrayschema<t, mode>, item: r | null) => r;\n bigint?: <input, mode extends schemamode>(schema: bigintschema<input, mode>) => r;\n boolean?: <input, mode extends schemamode>(schema: booleanschema<input, mode>) => r;\n date?: <input, mode extends schemamode>(schema: dateschema<input, mode>) => r;\n enum?: <t extends enumvalues, mode extends schemamode>(schema: enumschema<t, mode>) => r;\n instanceof?: <t, mode extends schemamode>(schema: instanceofschema<t, mode>) => r;\n intersect?: <t extends readonly anyschema[], mode extends schemamode>(schema: intersectschema<t, mode>, branches: (r | null)[]) => r;\n lazy?: <t, input, mode extends schemamode>(schema: lazyschema<t, input, mode>) => r;\n literal?: <t extends string | number | boolean | null | undefined, mode extends schemamode>(schema: literalschema<t, mode>) => r;\n map?: <k extends anyschema, v extends anyschema, mode extends schemamode>(schema: mapschema<k, v, mode>, key: r | null, value: r | null) => r;\n never?: <mode extends schemamode>(schema: neverschema<mode>) => r;\n number?: <input, mode extends schemamode>(schema: numberschema<input, mode>) => r;\n object?: <t extends objectshape, mode extends schemamode>(schema: objectschema<t, mode>, fields: record<string, r | null>) => r;\n pipe?: <to extends anyschema, from extends anyschema, mode extends schemamode>(schema: pipeschema<to, from, mode>, from: r | null, to: r | null) => r;\n record?: <k extends anyschema, v extends anyschema, mode extends schemamode>(schema: recordschema<k, v, mode>, key: r | null, value: r | null) => r;\n set?: <t extends anyschema, mode extends schemamode>(schema: setschema<t, mode>, item: r | null) => r;\n string?: <input, mode extends schemamode>(schema: stringschema<input, mode>) => r;\n tuple?: <t extends tupleschemas, rest extends anyschema | null, mode extends schemamode>(schema: tupleschema<t, rest, mode>, items: (r | null)[], rest: r | null) => r;\n union?: <t extends readonly anyschema[], mode extends schemamode>(schema: unionschema<t, mode>, branches: (r | null)[]) => r;\n unknown?: (schema: anyschema) => r;\n variant?: <k extends string, m extends record<string, objectschema<any, any>>, mode extends schemamode>(schema: variantschema<k, m, mode>, branches: record<string, r | null>) => r;\n};\n```\n\n### error helpers\n\n```ts\ntype flaterror = { messages: string[]; path: (string | number)[] };\ntype flaterrorfirst = { message: string; path: (string | number)[] };\n```\n",
|
|
1289
|
+
"api": " \ntitle: spell — api reference\ndescription: reference for spell schema builders, parsing, diagnostics, and tooling exports.\n \n\n[[toc]]\n\n## api overview\n\n| symbol | purpose | execution mode | common gotcha |\n| | | | |\n| `s` | creates schemas | sync or async, depending on checks | `checkasync()` requires async parsing |\n| `schema` / `pipeschema` | base schema abstractions | sync or async | use `infer` rather than assuming input equals output |\n| `diagnostics` | parse context and error helpers | sync | context is per parse/request, not global |\n| `spellvalidationerror` | validation failure details | sync/async parse failures | use `safeparse()` to handle it as a result |\n\n## package entry point\n\n| import | purpose |\n| | |\n| `@vielzeug/spell` | schema builders, errors, types, and diagnostics |\n| `@vielzeug/spell/json` | convert portable definitions to json schema |\n| `@vielzeug/spell/predicates` | standalone format and type predicates |\n\n```ts\nimport { diagnostics, s, type infer } from '@vielzeug/spell';\nimport { fromdefinition } from '@vielzeug/spell/json';\nimport { isemail } from '@vielzeug/spell/predicates';\n```\n\n## `s`\n\nall builders live under `s`.\n\n| builder | purpose |\n| | |\n| `string`, `number`, `boolean`, `bigint`, `date` | primitive values |\n| `literal`, `enum`, `null`, `undefined`, `unknown`, `any`, `never` | exact and universal values |\n| `array`, `tuple`, `set`, `map`, `record`, `object` | collections |\n| `union`, `intersect`, `discriminatedunion`, `lazy` | composition |\n| `coerce.*` | coercing primitive schemas |\n\n```ts\nconst user = s.object({\n email: s.string().email(),\n id: s.string().uuid(),\n role: s.enum(['admin', 'member'] as const),\n});\n\ntype user = infer<typeof user>;\n```\n\nobject schemas reject unknown keys. use `.relaxed()` to retain extras.\n\n## parsing\n\nevery schema provides:\n\n```ts\nschema.parse(value, context?); // output or spellvalidationerror\nschema.safeparse(value, context?); // parseresult<output>\nschema.parseasync(value, context?); // promise<output>\nschema.safeparseasync(value, context?); // promise<parseresult<output>>\nschema.is(value); // value is output\nschema.assert(value, label?); // assertion\n```\n\n`parse()` and `safeparse()` are available on synchronous schemas. calling `checkasync()` returns an async only schema, where typescript exposes only `parseasync()` and `safeparseasync()`. that async only mode propagates through compositional schemas when a child is asynchronous.\n\n## custom checks\n\n`check()` is synchronous. `checkasync()` is asynchronous. do not return a promise from `check()`.\n\n```ts\nconst signup = s.object({ confirm: s.string(), password: s.string() }).check((value, context) => {\n if (value.password !== value.confirm) {\n context.addissue({ code: 'custom', message: 'passwords must match', path: ['confirm'] });\n }\n});\n\nconst availableemail = s\n .string()\n .email()\n .checkasync(async (value) => {\n return (await emailavailable(value)) || 'email is already registered';\n });\n```\n\n`checkcontext.addissue()` takes `{ code, message, params?, path? }`. paths are relative to current schema.\n\n## modifiers and transforms\n\n```ts\ns.string().optional();\ns.string().nullable();\ns.string().nullish();\ns.string().required();\ns.string().default('guest');\ns.string().catch('guest');\ns.string()\n .trim()\n .transform((value) => value.tolowercase());\ns.string().pipe(s.string().slug());\ns.string().label('user name');\n```\n\n`default()`, `catch()`, preprocessors, transforms, and checks are runtime behavior. they cannot become portable definitions.\n\n## definitions and json schema\n\n`definition()` is only for schemas containing declarative structure. it returns frozen data and throws `spelldefinitionerror` when runtime behavior is present.\n\n```ts\nimport { s } from '@vielzeug/spell';\nimport { fromdefinition } from '@vielzeug/spell/json';\n\nconst product = s.object({\n id: s.string().uuid(),\n name: s.string().min(1),\n});\n\nconst definition = product.definition();\nconst jsonschema = fromdefinition(definition);\n```\n\nno implicit schema to json conversion exists. make definition boundary explicit.\n\n## diagnostics\n\n`diagnostics` contains pure helpers and immutable parse context creation.\n\n```ts\nimport { diagnostics, s } from '@vielzeug/spell';\n\nconst context = diagnostics.createparsecontext({\n object: { invalidkeys: () => 'unsupported field' },\n});\n\nconst result = s.object({ email: s.string().email() }).safeparse({ email: 'ada@example.com', extra: true }, context);\n\nif (!result.success) {\n const messages = result.error.messagesat('email');\n console.log(messages);\n}\n```\n\n`diagnostics.fail(code, message, params?)` and `diagnostics.prependissuepath(issues, segment)` support custom parser implementations.\n\n## errors\n\n `spellerror` — base class. use `instanceof spellerror` for cross boundary narrowing.\n `spellvalidationerror` — validation failure with `issues`, `bestmatch()`, `messagesat()`, `flatten()`, and `flattenfirst()`.\n `spelldefinitionerror` — schema cannot create portable definition.\n\n```ts\nconst result = s.object({ email: s.string().email() }).safeparse({ email: 'invalid' });\n\nif (!result.success) {\n const { fielderrors, formerrors } = result.error.flatten();\n console.log(fielderrors, formerrors);\n}\n```\n\n## types\n\n### core schema types\n\n```ts\ntype schemamode = 'async' | 'sync';\n\ntype anyschema<output = unknown, input = output, mode extends schemamode = schemamode> = schemasurface<\n output,\n input,\n mode\n>;\n\ntype schemasurface<output = unknown, input = output, mode extends schemamode = schemamode> = {\n _parsefullasync(value: unknown, ctx?: parsecontext): promise<{ data: unknown; issues: issue[] }>;\n _parsefullsync(value: unknown, ctx?: parsecontext): { data: unknown; issues: issue[] };\n definition(): schemadescriptor;\n isoptional: boolean;\n optional(): schemasurface<output | undefined, input | undefined, mode>;\n required(): schemasurface<exclude<output, undefined>, exclude<input, undefined>, mode>;\n readonly [schemainput]: input;\n readonly [schemamode]: mode;\n readonly [schemaoutput]: output;\n walk<r>(visitor: schemawalker<r>): r | null;\n};\n```\n\n`schemamode` is the public symbol marking a schema's parsing capability.\n\n### inference types\n\n```ts\ntype inferoutput<t> =\n t extends schema<infer output, unknown, schemamode>\n ? output\n : t extends { readonly [schemaoutput]: infer output }\n ? output\n : never;\ntype inferinput<t> = t extends { readonly [schemainput]: infer input } ? input : unknown;\ntype infer<t> = inferoutput<t>;\ntype inferschemamode<t> = t extends { readonly [schemamode]: infer mode extends schemamode } ? mode : never;\ntype mergeschemamodes<modes extends schemamode> = 'async' extends modes ? 'async' : 'sync';\n```\n\n### parse result and issues\n\n```ts\ntype parseresult<t> = { data: t; success: true } | { error: spellvalidationerror; success: false };\n\ntype issue =\n | { code: 'custom'; message: string; params?: record<string, unknown>; path: (string | number)[] }\n | { code: 'invalid_base64'; message: string; params: { format: string }; path: (string | number)[] }\n | { code: 'invalid_date'; message: string; params?: undefined; path: (string | number)[] }\n | { code: 'invalid_duration'; message: string; params: { format: string }; path: (string | number)[] }\n | { code: 'invalid_enum'; message: string; params: { values: readonly unknown[] }; path: (string | number)[] }\n | { code: 'invalid_finite'; message: string; params?: undefined; path: (string | number)[] }\n | { code: 'invalid_integer'; message: string; params?: undefined; path: (string | number)[] }\n | { code: 'invalid_keys'; message: string; params: { keys: string[] }; path: (string | number)[] }\n | { code: 'invalid_length'; message: string; params: { exact: number }; path: (string | number)[] }\n | { code: 'invalid_literal'; message: string; params: { expected: unknown }; path: (string | number)[] }\n | { code: 'invalid_multiple_of'; message: string; params: { step: number | bigint }; path: (string | number)[] }\n | { code: 'invalid_safe'; message: string; params?: undefined; path: (string | number)[] }\n | {\n code: 'invalid_string';\n message: string;\n params: { format?: string; includes?: string; pattern?: string; prefix?: string; suffix?: string };\n path: (string | number)[];\n }\n | { code: 'invalid_type'; message: string; params?: undefined; path: (string | number)[] }\n | { code: 'invalid_union'; message: string; params: { errors: issue[][] }; path: (string | number)[] }\n | { code: 'invalid_unique'; message: string; params: { unique: true }; path: (string | number)[] }\n | { code: 'invalid_url'; message: string; params: { format: string }; path: (string | number)[] }\n | {\n code: 'invalid_variant';\n message: string;\n params: { discriminator: string; expected: string[] };\n path: (string | number)[];\n }\n | {\n code: 'too_big';\n message: string;\n params: { exclusive?: boolean; max: number | bigint | date };\n path: (string | number)[];\n }\n | {\n code: 'too_small';\n message: string;\n params: { exclusive?: boolean; min: number | bigint | date };\n path: (string | number)[];\n }\n | { code: string & {}; message: string; params?: record<string, unknown>; path: (string | number)[] };\n```\n\n`errorcode` is a const object mapping each issue code to its string literal.\n\n### validation contracts\n\n```ts\ntype parsecontext = { messages: messages };\n\ntype validatefn = (value: unknown, ctx?: parsecontext) => issue[] | null | promise<issue[] | null>;\n\ntype checkcontext = {\n addissue: (issue: {\n code: string;\n message: string;\n params?: record<string, unknown>;\n path?: (string | number)[];\n }) => void;\n};\n\ntype validateresult = boolean | null | undefined | string;\n```\n\n### messages\n\n```ts\ntype messagefn<ctx extends record<string, unknown> = record<string, unknown>> = string | ((ctx: ctx) => string);\n\ntype messages = {\n array: { length: (ctx: { exact: number; value: unknown[] }) => string; max: (ctx: { max: number; value: unknown[] }) => string; min: (ctx: { min: number; value: unknown[] }) => string; nonempty: () => string; type: () => string; unique: () => string };\n bigint: { max: (ctx: { max: bigint; value: bigint }) => string; min: (ctx: { min: bigint; value: bigint }) => string; multipleof: (ctx: { step: bigint; value: bigint }) => string; negative: () => string; nonnegative: () => string; nonpositive: () => string; positive: () => string; type: () => string };\n boolean: { type: () => string };\n check: { default: () => string };\n date: { max: (ctx: { max: date; value: date }) => string; min: (ctx: { min: date; value: date }) => string; type: () => string };\n enum: { invalid: (ctx: { values: readonly unknown[] }) => string };\n instanceof: { type: (ctx: { classname: string }) => string };\n literal: { expected: (ctx: { expected: unknown }) => string };\n map: { max: (ctx: { max: number; value: map<unknown, unknown> }) => string; min: (ctx: { min: number; value: map<unknown, unknown> }) => string; nonempty: () => string; size: (ctx: { exact: number; value: map<unknown, unknown> }) => string; type: () => string };\n never: { invalid: () => string };\n number: { finite: () => string; int: () => string; max: (ctx: { max: number; value: number }) => string; min: (ctx: { min: number; value: number }) => string; multipleof: (ctx: { step: number; value: number }) => string; negative: () => string; nonnegative: () => string; nonpositive: () => string; positive: () => string; safe: () => string; type: () => string };\n object: { invalidkeys: (ctx: { keys: string[] }) => string; type: () => string };\n set: { max: (ctx: { max: number; value: set<unknown> }) => string; min: (ctx: { min: number; value: set<unknown> }) => string; nonempty: () => string; size: (ctx: { exact: number; value: set<unknown> }) => string; type: () => string };\n string: { base64: () => string; base64url: () => string; cuid: () => string; cuid2: () => string; date: () => string; datetime: () => string; duration: () => string; email: () => string; emoji: () => string; endswith: (ctx: { suffix: string; value: string }) => string; hex: () => string; hexcolor: () => string; includes: (ctx: { substr: string; value: string }) => string; ip: () => string; jwt: () => string; length: (ctx: { exact: number; value: string }) => string; max: (ctx: { max: number; value: string }) => string; min: (ctx: { min: number; value: string }) => string; nanoid: () => string; nonempty: () => string; numeric: () => string; regex: (ctx: { value: string }) => string; semver: () => string; slug: () => string; startswith: (ctx: { prefix: string; value: string }) => string; time: () => string; type: () => string; ulid: () => string; url: () => string; uuid: () => string };\n tuple: { length: (ctx: { exact: number }) => string; min: (ctx: { min: number }) => string; type: () => string };\n union: { invalid: () => string };\n variant: { invaliddiscriminator: (ctx: { discriminator: string; expected: string[] }) => string; type: () => string };\n};\n\ntype deeppartial<t> = {\n [k in keyof t]?: t[k] extends record<string, unknown> ? deeppartial<t[k]> : t[k];\n};\n```\n\n### descriptor and json schema\n\n```ts\ntype schemadescriptor = basedescriptor &\n (\n | { kind: 'any' | 'unknown' | 'never' | 'boolean' | 'bigint' | 'date' | 'lazy' }\n | { classname: string; kind: 'instanceof' }\n | { contentencoding?: string; format?: string; kind: 'string'; maxlength?: number; minlength?: number; pattern?: string | null }\n | { exclusivemaximum?: number; exclusiveminimum?: number; kind: 'number'; maximum?: number; minimum?: number; multipleof?: number; typehint?: 'integer' }\n | { kind: 'literal'; value: string | number | boolean | null | undefined }\n | { kind: 'enum'; values: readonly (string | number)[] }\n | { items: schemadescriptor; kind: 'array'; maxitems?: number; minitems?: number }\n | { items: schemadescriptor[]; kind: 'tuple'; rest: schemadescriptor | null }\n | { fields: record<string, schemadescriptor>; kind: 'object'; strict: boolean }\n | { key: schemadescriptor; kind: 'record'; value: schemadescriptor }\n | { items: schemadescriptor; kind: 'set' }\n | { key: schemadescriptor; kind: 'map'; value: schemadescriptor }\n | { branches: schemadescriptor[]; kind: 'union' | 'intersect' }\n | { branches: record<string, schemadescriptor>; discriminator: string; kind: 'variant' }\n | { from: schemadescriptor; kind: 'pipe'; to: schemadescriptor }\n );\n\ntype jsonschema = record<string, unknown>;\n```\n\n### schema walker\n\n```ts\ntype schemawalker<r> = {\n array?: <t extends anyschema, mode extends schemamode>(schema: arrayschema<t, mode>, item: r | null) => r;\n bigint?: <input, mode extends schemamode>(schema: bigintschema<input, mode>) => r;\n boolean?: <input, mode extends schemamode>(schema: booleanschema<input, mode>) => r;\n date?: <input, mode extends schemamode>(schema: dateschema<input, mode>) => r;\n enum?: <t extends enumvalues, mode extends schemamode>(schema: enumschema<t, mode>) => r;\n instanceof?: <t, mode extends schemamode>(schema: instanceofschema<t, mode>) => r;\n intersect?: <t extends readonly anyschema[], mode extends schemamode>(schema: intersectschema<t, mode>, branches: (r | null)[]) => r;\n lazy?: <t, input, mode extends schemamode>(schema: lazyschema<t, input, mode>) => r;\n literal?: <t extends string | number | boolean | null | undefined, mode extends schemamode>(schema: literalschema<t, mode>) => r;\n map?: <k extends anyschema, v extends anyschema, mode extends schemamode>(schema: mapschema<k, v, mode>, key: r | null, value: r | null) => r;\n never?: <mode extends schemamode>(schema: neverschema<mode>) => r;\n number?: <input, mode extends schemamode>(schema: numberschema<input, mode>) => r;\n object?: <t extends objectshape, mode extends schemamode>(schema: objectschema<t, mode>, fields: record<string, r | null>) => r;\n pipe?: <to extends anyschema, from extends anyschema, mode extends schemamode>(schema: pipeschema<to, from, mode>, from: r | null, to: r | null) => r;\n record?: <k extends anyschema, v extends anyschema, mode extends schemamode>(schema: recordschema<k, v, mode>, key: r | null, value: r | null) => r;\n set?: <t extends anyschema, mode extends schemamode>(schema: setschema<t, mode>, item: r | null) => r;\n string?: <input, mode extends schemamode>(schema: stringschema<input, mode>) => r;\n tuple?: <t extends tupleschemas, rest extends anyschema | null, mode extends schemamode>(schema: tupleschema<t, rest, mode>, items: (r | null)[], rest: r | null) => r;\n union?: <t extends readonly anyschema[], mode extends schemamode>(schema: unionschema<t, mode>, branches: (r | null)[]) => r;\n unknown?: (schema: anyschema) => r;\n variant?: <k extends string, m extends record<string, objectschema<any, any>>, mode extends schemamode>(schema: variantschema<k, m, mode>, branches: record<string, r | null>) => r;\n};\n```\n\n### error helpers\n\n```ts\ntype flaterror = { messages: string[]; path: (string | number)[] };\ntype flaterrorfirst = { message: string; path: (string | number)[] };\n```\n",
|
|
1268
1290
|
"usage": " \ntitle: spell — usage guide\ndescription: learn how to build schemas, compose wrappers, customize locales, and integrate spell with other vielzeug packages.\n \n\n[[toc]]\n\n## basic usage\n\nstart with `safeparse()` when you want explicit success and failure branches.\n\n```ts\nimport { s } from '@vielzeug/spell';\n\nconst signup = s.object({\n email: s.string().email(),\n password: s.string().min(12),\n referralcode: s.string().optional(),\n});\n\nconst result = signup.safeparse({\n email: 'ada@example.com',\n password: 'horse battery staple',\n});\n\nif (!result.success) {\n console.error(result.error.issues);\n} else {\n console.log(result.data.email);\n}\n```\n\nuse `parse()` when invalid input should throw immediately. use `safeparse()` when invalid input is part of normal control flow.\n\n## building schemas\n\nuse the namespace form when readability matters more than bundle trimming.\n\n```ts\nimport { s } from '@vielzeug/spell';\n\nconst article = s.object({\n id: s.string().uuid(),\n title: s.string().trim().min(1).max(120),\n slug: s.string().slug(),\n tags: s.array(s.string().min(1)).default(() => []),\n meta: s\n .object({\n published: s.boolean(),\n publishedat: s.date().nullable(),\n })\n .relaxed(),\n});\n```\n\n```ts\nimport { s } from '@vielzeug/spell';\n\nconst todo = s.object({\n done: s.boolean(),\n tags: s.array(s.string().min(1)).default(() => []),\n title: s.string().min(1),\n});\n```\n\nobject schemas reject unknown keys by default. call `.relaxed()` when you need to preserve extra properties.\n\ncall `.defaults()` to get a fully default filled object without providing any input. every required field must have a `.default()` set, or a `spellvalidationerror` is thrown. call `.partialdefaults()` when only some fields have defaults — fields without a default are silently omitted instead of throwing.\n\n```ts\nconst config = s.object({\n host: s.string().default('localhost'),\n port: s.number().default(3000),\n});\n\nconfig.defaults(); // { host: 'localhost', port: 3000 }\n\nconst form = s.object({ name: s.string(), role: s.string().default('viewer') });\nform.partialdefaults(); // { role: 'viewer' }\n```\n\n## wrapper modes, defaults, and fallbacks\n\nchain wrappers to describe missing values and recovery rules without losing schema metadata.\n\n```ts\nimport { s } from '@vielzeug/spell';\n\nconst displayname = s.string().trim().min(2).label('display name').optional().default('guest').nullable();\n\ndisplayname.parse(undefined); // 'guest'\ndisplayname.parse(null); // null\ndisplayname.description; // 'display name'\n```\n\ncall `.required()` to remove `undefined` without removing `null`.\n\n```ts\nimport { s } from '@vielzeug/spell';\n\nconst nullablebutrequired = s.string().optional().nullable().required();\n\nnullablebutrequired.parse('ada');\nnullablebutrequired.parse(null);\n// nullablebutrequired.parse(undefined); // throws\n```\n\nuse `.catch()` when you want a fallback output after validation fails.\n\n```ts\nimport { s } from '@vielzeug/spell';\n\nconst port = s.number().int().min(1).max(65535).catch(3000);\n\nport.parse('not a number'); // 3000\n```\n\n## custom validation\n\nuse `check()` for synchronous domain rules and `checkasync()` for asynchronous rules. sync parsing rejects schemas with asynchronous checks.\n\n```ts\nimport { s } from '@vielzeug/spell';\n\n// boolean shorthand: return false to fail with default message\nconst evennumber = s.number().check((n) => n % 2 === 0);\n\n// string shorthand: return the message as a string\nconst username = s\n .string()\n .min(3)\n .check((v) => !v.startswith('_') || 'cannot start with underscore');\n\n// multiple issues via ctx.addissue()\nconst signup = s.object({ confirm: s.string(), password: s.string() }).check((v, ctx) => {\n if (v.password !== v.confirm) {\n ctx.addissue({ code: 'custom', message: 'passwords must match', path: ['confirm'] });\n }\n});\n```\n\n`checkasync()` returns an async only schema: typescript exposes `parseasync()` and `safeparseasync()` but not `parse()` or `safeparse()`. this mode survives fluent modifiers and propagates through nested arrays, objects, unions, intersections, tuples, maps, records, sets, lazy schemas, pipelines, and `s.discriminatedunion(...)` branches. sync parsing also fails at runtime instead of accepting an unchecked value.\n\n```ts\nimport { s } from '@vielzeug/spell';\n\nconst takenemails = new set(['ada@example.com']);\n\nconst accountemail = s\n .string()\n .email()\n .checkasync(async (value, ctx) => {\n if (takenemails.has(value)) {\n ctx.addissue({ code: 'custom', message: 'email is already taken', path: [] });\n }\n });\n\n// async checks require parseasync\nawait accountemail.parseasync('grace@example.com');\n```\n\nuse `check()` for predicate only rules too. return `true` on success or message on failure.\n\n```ts\nimport { s } from '@vielzeug/spell';\n\nconst positiveprice = s.number().check((value) => value > 0 || 'must be positive');\npositiveprice.parse(9.99);\n```\n\n## strings, numbers, and safe regex usage\n\nuse schema helpers for common string and number constraints instead of hand written predicates.\n\n```ts\nimport { s } from '@vielzeug/spell';\n\nconst password = s.string().min(12).regex(/[a z]/).regex(/[0 9]/);\nconst price = s.number().nonnegative().multipleof(0.01);\nconst launchwindow = s.date().min(new date('2025 01 01t00:00:00.000z'));\n```\n\nspell strips stateful `/g` and `/y` flags from `regex()` patterns before validation. repeated parses stay deterministic even when the original regular expression is reused.\n\n## coercion and transforms\n\nuse coercion when input arrives as strings, query parameters, or form values.\n\n```ts\nimport { s } from '@vielzeug/spell';\n\nconst query = s.object({\n draft: s.coerce.boolean().default(false),\n limit: s.coerce.number().int().positive().default(20),\n publishedat: s.coerce.date().nullable(),\n search: s.coerce.string().trim().min(1).optional(),\n});\n\nconst parsed = query.parse({\n draft: 'true',\n limit: '50',\n publishedat: '2025 04 01t12:00:00.000z',\n search: ' vielzeug ',\n});\n```\n\nuse `transform()` or `pipe()` after validation when downstream code needs a different output shape.\n\n```ts\nimport { s } from '@vielzeug/spell';\n\nconst trimmedtags = s.array(s.string().trim().min(1)).transform((tags) => tags.map((tag) => tag.tolowercase()));\nconst slug = s.string().trim().min(1).pipe(s.string().slug());\n```\n\n## introspection, round trips, and json schema\n\nuse declarative definitions when schemas need to cross process boundaries or feed tooling.\n\n```ts\nimport { s } from '@vielzeug/spell';\nimport { fromdefinition } from '@vielzeug/spell/json';\n\nconst product = s\n .object({\n id: s.string().uuid(),\n name: s.string().min(1),\n price: s.number().positive().multipleof(0.01),\n })\n .label('product');\n\nconst definition = product.definition();\nconst jsonschema = fromdefinition(definition);\n\nproduct.parse({ id: '550e8400 e29b 41d4 a716 446655440000', name: 'keyboard', price: 129.99 });\nconsole.log(jsonschema.title);\n```\n\ndefinitions are frozen serializable snapshots of declarative schema structure. use `definition()` and `fromdefinition()` for external tooling. schemas with runtime checks, transforms, defaults, catches, or preprocessors intentionally have no definition.\n\n## messages\n\nspell has no mutable process wide configuration. build one parse context per request, locale, or form, then pass it explicitly.\n\n```ts\nimport { diagnostics, s } from '@vielzeug/spell';\n\nconst user = s.object({ email: s.string().email() });\nconst german = diagnostics.createparsecontext({\n object: { invalidkeys: () => 'keine unbekannten felder erlaubt' },\n});\n\nuser.safeparse({ email: 'ada@example.com', extra: true }, german);\n```\n\ninternal development warnings always use `console.warn` in development builds. route application diagnostics in application code instead of mutating library wide logger state.\n\n## working with validation errors\n\nuse `spellvalidationerror` helpers when you need ui ready error structures.\n\n```ts\nimport { s, spellvalidationerror } from '@vielzeug/spell';\n\nconst user = s.object({\n email: s.string().email(),\n profile: s.object({\n name: s.string().min(2),\n }),\n});\n\nconst result = user.safeparse({ email: 'nope', profile: { name: '' } });\n\nif (!result.success && result.error instanceof spellvalidationerror) {\n const profileerrors = result.error.messagesat('profile', 'name');\n console.log(profileerrors);\n}\n```\n\nuse `bestmatch()` on a union failure when you want the branch that came closest to succeeding. pass a specific `invalid_union` issue when one validation produced multiple union failures.\n\n## schema traversal with walk()\n\nuse `walk()` to inspect or transform a schema tree without importing internal implementation classes.\n\n```ts\nimport { s, type schemawalker } from '@vielzeug/spell';\n\nconst fields: string[] = [];\n\nconst collectfields: schemawalker<void> = {\n object(schema) {\n for (const [key, child] of object.entries(schema.shape)) {\n fields.push(key);\n child.walk(collectfields);\n }\n },\n unknown() {},\n};\n\nconst user = s.object({\n email: s.string().email(),\n profile: s.object({ name: s.string() }),\n});\n\nuser.walk(collectfields);\nconsole.log(fields); // ['email', 'profile', 'name']\n```\n\n`walk()` dispatches by `schema.kind`. if no handler matches and no `unknown` fallback is provided, `walk()` returns `null`. add an `unknown` handler to capture any kind not explicitly listed in your visitor.\n\n## framework integration\n\nspell works anywhere you can call a function before state enters your app.\n\n::: code group\n\n```tsx [react]\nimport { s } from '@vielzeug/spell';\n\nconst searchparams = s\n .object({\n page: s.coerce.number().int().positive().default(1),\n q: s.string().trim().optional(),\n })\n .relaxed();\n\nexport function searchpage({ rawparams }: { rawparams: unknown }) {\n const params = searchparams.parse(rawparams);\n\n return (\n <div>\n {params.q ?? 'all results'} — page {params.page}\n </div>\n );\n}\n```\n\n```ts [vue]\nimport { computed, ref } from 'vue';\nimport { s } from '@vielzeug/spell';\n\nconst settings = s.object({\n locale: s.string().min(2),\n compact: s.coerce.boolean().default(false),\n});\n\nconst raw = ref<unknown>({ locale: 'en', compact: 'true' });\nconst settings = computed(() => settings.parse(raw.value));\n```\n\n:::\n\nuse `safeparse()` at event boundaries and `parse()` inside trusted data flows.\n\n## working with other vielzeug libraries\n\nuse spell as the validation layer and let other packages focus on transport, forms, or storage.\n\n```ts\nimport { createform } from '@vielzeug/forge';\nimport { customvalidator } from '@vielzeug/forge/spell';\nimport { createcourier } from '@vielzeug/courier';\nimport { s } from '@vielzeug/spell';\n\nconst profile = s.object({\n displayname: s.string().min(2),\n newsletter: s.boolean(),\n});\n\nconst form = createform({\n initialvalues: {\n displayname: '',\n newsletter: false,\n },\n validate: customvalidator(profile),\n});\n\nconst courier = createcourier({ baseurl: '/api' });\nconst profile = profile.parse(await courier.get('/profile'));\n```\n\nuse spell definitions with `@vielzeug/codex` or other tooling when you need generated docs or external schema consumers.\n\n## best practices\n\n keep schemas close to the boundary where unknown data enters your app.\n use `s` consistently for construction; use explicit `/json` and `/predicates` subpaths for tooling.\n use `.default(() => value)` for mutable defaults such as arrays, objects, `map`, and `set`.\n call `.required()` when you want to remove `undefined` but keep `null` semantics intact.\n use `check()` with a `ctx` argument when you need `ctx.addissue()`; return a message for simple predicate failures.\n use `checkasync()` and `parseasync()` for every asynchronous domain rule.\n build a parse context per request or test; never rely on mutable process wide configuration.\n use `definition()` with `fromdefinition()` from `@vielzeug/spell/json` for external tooling.\n",
|
|
1269
1291
|
"examples": " \ntitle: spell — examples\ndescription: practical examples and recipes for spell.\n \n\n## examples\n\n [validating api payloads](./examples/api.md)\n [form safe parsing](./examples/forms.md)\n [async business rules](./examples/async.md)\n [schema introspection and round trips](./examples/introspection.md)\n [unions, intersections, and variants](./examples/unions.md)\n [schema traversal with walk()](./examples/walk.md)\n"
|
|
1270
1292
|
},
|
|
@@ -1375,9 +1397,9 @@
|
|
|
1375
1397
|
"category": "storage",
|
|
1376
1398
|
"description": "typed browser storage and opt in driver neutral sqlite with portable keys, ttl, observation, and transactions.",
|
|
1377
1399
|
"docs": {
|
|
1378
|
-
"index": " \ntitle: vault — typed storage\ndescription: typed browser storage and opt in driver neutral sqlite with portable keys, ttl, observation, and transactions.\npackage: vault\ncategory: storage\nkeywords: [storage, indexeddb, localstorage, sessionstorage, sqlite, ttl, browser, node, deno]\nrelated: [courier, forge, ripple]\nexports: [table, ttl,
|
|
1379
|
-
"api": " \ntitle: vault — api reference\ndescription: reference for vault schemas, adapter entry points, storage capabilities, sqlite drivers, and errors.\n \n\n[[toc]]\n\n## api overview\n\n| symbol | purpose | execution mode | common gotcha |\n| | | | |\n| `creatememory()` | in memory portable store | async api | import from `/memory` |\n| `createlocalstorage()` / `createsessionstorage()` | web storage backed portable stores | async api | available only where the corresponding web api exists |\n| `createindexeddb()` | browser transactions and cursor iteration | async api | import from `/indexeddb` |\n| `createsqlite()` | driver neutral sqlite store | async api over a synchronous driver | import from `/sqlite` |\n| `table()` | typed record schema | sync | the key field must be a string or finite number |\n| `ttl` | valid expiration durations | sync | durations must be positive |\n| `scheduleexpiredprune()` | periodic ttl cleanup | sync setup, async work | pass `disposalsignal` to auto cancel |\n\n## package entry points\n\n| import | purpose |\n| | |\n| `@vielzeug/vault` | adapter free schemas, ttl, errors, pruning, queries, and shared types |\n| `@vielzeug/vault/memory` | `creatememory` |\n| `@vielzeug/vault/local storage` | `createlocalstorage` |\n| `@vielzeug/vault/session storage` | `createsessionstorage` |\n| `@vielzeug/vault/indexeddb` | `createindexeddb`, `definemigration`, migrations, and indexeddb only types |\n| `@vielzeug/vault/sqlite` | `createsqlite`, the sqlite driver protocol types, and `transactioncontext` |\n\n## schemas and ttl\n\n### `table()`\n\n```ts\nfunction table<t extends object, key extends keyof t & string = keyof t & string>(\n key: key & (t[key] extends vaultkey ? unknown : never),\n options?: { defaultttl?: number; indexes?: readonly (keyof t & string)[] },\n): schemaentry<t, key>;\n```\n\ndefines a typed table and its primary key field.\n\n| parameter | description |\n| | |\n| `key` | a record field whose values are `string` or finite `number` keys |\n| `options.defaultttl` | per table default ttl in milliseconds |\n| `options.indexes` | indexeddb secondary index fields |\n\n**returns:** a `schemaentry` describing the table.\n\n```ts\nimport { table, ttl } from '@vielzeug/vault';\n\nconst users = table<{ id: number; email: string }>('id', {\n indexes: ['email'],\n defaultttl: ttl.days(7),\n});\n```\n\n \n\n### `ttl`\n\n```ts\nconst ttl: {\n days(n: number): number;\n hours(n: number): number;\n minutes(n: number): number;\n ms(n: number): number;\n seconds(n: number): number;\n};\n```\n\ncreates a finite, positive duration in milliseconds for writes and table defaults.\n\n**returns:** `number`.\n\n```ts\nimport { ttl } from '@vielzeug/vault';\n\nconst cachelifetime = ttl.minutes(5);\n```\n\n \n\n### `isexpired()`\n\n```ts\nfunction isexpired(expiresat: number | undefined): boolean;\n```\n\nreports whether an expiration timestamp has passed.\n\n**returns:** `true` when `expiresat` is defined and no later than the current time.\n\n```ts\nimport { isexpired } from '@vielzeug/vault';\n\nif (isexpired(record.expiresat)) console.log('expired');\n```\n\n## factories\n\nall factory options accept `schema`, plus optional `validators`, `logger`, and `onmetrics`. the root entry does not export any factory.\n\n### `creatememory()`\n\n```ts\nfunction creatememory<s extends anyschema>(options: {\n name?: string;\n schema: s;\n} & baseadapteroptions<s>): vaultstore<s>;\n```\n\ncreates an in memory portable store. a `name` enables same origin `broadcastchannel` observation between memory stores when the platform provides it.\n\n| parameter | description |\n| | |\n| `schema` | tables created by `table()` |\n| `name` | optional shared memory store namespace |\n\n**returns:** `vaultstore<s>`.\n\n```ts\nimport { table } from '@vielzeug/vault';\nimport { creatememory } from '@vielzeug/vault/memory';\n\nconst store = creatememory({ schema: { users: table<{ id: number; name: string }>('id') } });\n```\n\n \n\n### `createlocalstorage()`\n\n```ts\nfunction createlocalstorage<s extends anyschema>(options: {\n name: string;\n onquotaexceeded?: (table: keyof s, error: vaultquotaerror) => 'ignore' | 'throw';\n schema: s;\n} & baseadapteroptions<s>): vaultstore<s>;\n```\n\ncreates a namespaced `localstorage` store.\n\n| parameter | description |\n| | |\n| `name` | required storage namespace |\n| `onquotaexceeded` | handles a web storage quota error; returning `'ignore'` drops that write |\n| `schema` | tables created by `table()` |\n\n**returns:** `vaultstore<s>`.\n\n```ts\nimport { table } from '@vielzeug/vault';\nimport { createlocalstorage } from '@vielzeug/vault/local storage';\n\nconst store = createlocalstorage({ name: 'app', schema: { settings: table<{ id: string }>('id') } });\n```\n\n \n\n### `createsessionstorage()`\n\n```ts\nfunction createsessionstorage<s extends anyschema>(options: {\n name: string;\n onquotaexceeded?: (table: keyof s, error: vaultquotaerror) => 'ignore' | 'throw';\n schema: s;\n} & baseadapteroptions<s>): vaultstore<s>;\n```\n\ncreates a namespaced `sessionstorage` store. its options and return type match `createlocalstorage()`.\n\n**returns:** `vaultstore<s>`.\n\n```ts\nimport { table } from '@vielzeug/vault';\nimport { createsessionstorage } from '@vielzeug/vault/session storage';\n\nconst store = createsessionstorage({ name: 'checkout', schema: { cart: table<{ id: string }>('id') } });\n```\n\n \n\n### `createindexeddb()`\n\n```ts\nfunction createindexeddb<s extends anyschema>(options: {\n migrate?: migrationfn;\n name: string;\n schema: s;\n version?: number;\n} & baseadapteroptions<s>): indexeddbvaultstore<s>;\n```\n\ncreates an indexeddb store with atomic batches, lazy cursor iteration, and optional schema migrations.\n\n| parameter | description |\n| | |\n| `name` | required database name |\n| `schema` | tables and indexeddb secondary indexes |\n| `version` | positive schema version; defaults to `1` |\n| `migrate` | synchronous upgrade callback for version changes |\n\n**returns:** `indexeddbvaultstore<s>`.\n\n```ts\nimport { table } from '@vielzeug/vault';\nimport { createindexeddb } from '@vielzeug/vault/indexeddb';\n\nconst store = createindexeddb({ name: 'app', schema: { users: table<{ id: number }>('id') } });\n```\n\n \n\n### `createsqlite()`\n\n```ts\nfunction createsqlite<s extends anyschema>(options: sqlitevaultoptions<s>): sqlitevaultstore<s>;\n```\n\ncreates a namespaced sqlite store with atomic batches and keyset paginated iteration. it accepts an application provided positional parameter driver and never opens or imports a runtime driver.\n\n| parameter | description |\n| | |\n| `database` | caller provided `sqlitedatabase` connection |\n| `name` | namespace within the connection |\n| `schema`, `validators`, `logger`, `onmetrics` | shared factory options |\n| `closeondispose` | closes the connection during disposal; defaults to `false` |\n\n**returns:** `sqlitevaultstore<s>`.\n\n```ts\nimport { databasesync } from 'node:sqlite';\n\nimport { table } from '@vielzeug/vault';\nimport { createsqlite } from '@vielzeug/vault/sqlite';\n\nconst store = createsqlite({\n database: new databasesync(':memory:'),\n name: 'tests',\n schema: { users: table<{ id: number; name: string }>('id') },\n});\n```\n\nnode `databasesync`, bun `database`, and deno `jsr:@db/sqlite` `database` satisfy the protocol. values must be json compatible plain objects. during a `batch()` callback, calls on every vault store sharing that connection reject; use `tx.*` instead.\n\n## store capabilities\n\n### `vaultstore`\n\n```ts\ninterface vaultstore<s extends anyschema> {\n clear<k extends keyof s & string>(table: k): promise<void>;\n count<k extends keyof s & string>(table: k): promise<number>;\n delete<k extends keyof s & string>(table: k, key: keyof<s, k>): promise<boolean>;\n deletemany<k extends keyof s & string>(table: k, keys: keyof<s, k>[]): promise<number>;\n entries<k extends keyof s & string>(table: k): promise<array<[keyof<s, k>, recordof<s, k>]>>;\n get<k extends keyof s & string>(table: k, key: keyof<s, k>): promise<recordof<s, k> | undefined>;\n getall<k extends keyof s & string>(table: k): promise<recordof<s, k>[]>;\n getmany<k extends keyof s & string>(table: k, keys: keyof<s, k>[]): promise<array<recordof<s, k> | undefined>>;\n getordefault<k extends keyof s & string>(table: k, key: keyof<s, k>, defaultfn: () => recordof<s, k>, ttl?: number): promise<recordof<s, k>>;\n has<k extends keyof s & string>(table: k, key: keyof<s, k>): promise<boolean>;\n isempty<k extends keyof s & string>(table: k): promise<boolean>;\n keys<k extends keyof s & string>(table: k, filter?: (record: recordof<s, k>) => boolean): promise<keyof<s, k>[]>;\n put<k extends keyof s & string>(table: k, value: recordof<s, k>, ttl?: number): promise<void>;\n putall<k extends keyof s & string>(table: k, values: recordof<s, k>[], ttl?: number): promise<void>;\n query<k extends keyof s & string>(table: k): querybuilder<recordof<s, k>>;\n update<k extends keyof s & string>(table: k, key: keyof<s, k>, changes: partial<recordof<s, k>>, ttl?: number): promise<recordof<s, k> | undefined>;\n upsert<k extends keyof s & string>(table: k, key: keyof<s, k>, fn: (existing: recordof<s, k> | undefined) => recordof<s, k>, ttl?: number): promise<recordof<s, k>>;\n pruneexpired(): promise<record<keyof s & string, number>>;\n debug(): promise<debuginfo<s>>;\n observe<k extends keyof s & string>(table: k, listener: observer<recordof<s, k>>, options?: { immediate?: boolean; signal?: abortsignal }): unsubscribe;\n dispose(): promise<void>;\n readonly disposed: boolean;\n readonly disposalsignal: abortsignal;\n [symbol.asyncdispose](): promise<void>;\n}\n```\n\nthe portable store api is returned by every factory. `observe()` emits the current table snapshot by default and then emits after mutations.\n\n \n\n### `batch()`\n\n```ts\ninterface transactionalvaultstore<s extends anyschema> extends vaultstore<s> {\n batch<k extends keyof s & string, r>(\n tables: readonly k[],\n fn: (tx: transactioncontext<s, k>) => promise<r>,\n ): promise<r>;\n}\n```\n\nruns a scoped atomic callback. `indexeddbvaultstore` and `sqlitevaultstore` provide it.\n\n| parameter | description |\n| | |\n| `tables` | tables the transaction may access |\n| `fn` | async callback that uses only the supplied `tx` context |\n\n**returns:** the callback result after commit.\n\n```ts\nawait store.batch(['users'], async (tx) => {\n await tx.put('users', { id: 1, name: 'ada' });\n});\n```\n\n \n\n### `iterate()`\n\n```ts\ninterface iterablevaultstore<s extends anyschema> extends vaultstore<s> {\n iterate<k extends keyof s & string>(table: k): asynciterable<recordof<s, k>>;\n}\n```\n\nlazily yields table records. `indexeddbvaultstore` uses a cursor; `sqlitevaultstore` uses keyset pagination.\n\n**returns:** an `asynciterable` of records.\n\n```ts\nfor await (const user of store.iterate('users')) console.log(user);\n```\n\n## queries, pruning, and migrations\n\n### `querybuilder`\n\n```ts\ninterface querybuilder<t extends object, n extends t = t> {\n between(field: string, lower: number | string, upper: number | string): querybuilder<t, n>;\n count(): promise<number>;\n delete(): promise<number>;\n equals<k extends keyof t & string, v extends t[k]>(field: k, value: v): querybuilder<t & record<k, v>>;\n exists(): promise<boolean>;\n filter(fn: (value: n, index: number, array: n[]) => boolean): querybuilder<t, n>;\n first(): promise<n | undefined>;\n limit(n: number): querybuilder<t, n>;\n offset(n: number): querybuilder<t, n>;\n orderby<k extends keyof t>(field: k, direction?: 'asc' | 'desc'): querybuilder<t, n>;\n startswith(field: keyof t, prefix: string, options?: { ignorecase?: boolean }): querybuilder<t, n>;\n toarray(): promise<n[]>;\n}\n```\n\nbuilds a lazy table query. `count()` ignores `limit()`, `offset()`, and `orderby()` — it always returns the full filtered set size.\n\n```ts\nconst page = await store.query('users').startswith('name', 'a').orderby('name').limit(20).toarray();\n```\n\n \n\n### `scheduleexpiredprune()`\n\n```ts\nfunction scheduleexpiredprune<s extends anyschema>(\n adapter: pick<vaultstore<s>, 'pruneexpired'>,\n options: {\n interval: number;\n onerror?: (error: unknown) => void;\n signal?: abortsignal;\n },\n): () => void;\n```\n\nschedules `pruneexpired()` at a finite, positive interval. pass `signal: store.disposalsignal` to auto cancel when the store is torn down.\n\n**returns:** a stop function.\n\n```ts\nimport { scheduleexpiredprune, ttl } from '@vielzeug/vault';\n\nconst stop = scheduleexpiredprune(store, {\n interval: ttl.hours(1),\n signal: store.disposalsignal,\n});\nstop();\n```\n\n \n\n### `definemigration()`\n\n```ts\nfunction definemigration(steps: migrationstep[]): migrationfn;\n```\n\nbuilds an idempotent indexeddb migration callback from schema change steps.\n\n**returns:** an indexeddb `migrationfn`.\n\n```ts\nimport { definemigration } from '@vielzeug/vault/indexeddb';\n\nconst migrate = definemigration([{ field: 'email', table: 'users', type: 'addindex' }]);\n```\n\n## types\n\n```ts\ntype vaultkey = number | string;\ntype unsubscribe = () => void;\ntype observer<t> = (records: t[]) => void;\ntype anyschema = record<string, {\n defaultttl?: number;\n indexes?: readonly string[];\n key: string;\n}>;\ntype schemaentry<t extends object, key extends keyof t & string = keyof t & string> =\n t[key] extends vaultkey ? {\n defaultttl?: number;\n indexes?: readonly (keyof t & string)[];\n key: key;\n } : never;\ntype recordof<s extends anyschema, k extends keyof s> =\n s[k] extends schemaentry<infer r, infer _key> ? r : never;\ntype keyof<s extends anyschema, k extends keyof s> =\n extract<s[k] extends schemaentry<infer r, infer key> ? r[key] : never, vaultkey>;\n```\n\n```ts\ntype baseadapteroptions<s extends anyschema> = {\n logger?: vaultlogger;\n onmetrics?: (event: metricsevent) => void;\n schema: s;\n validators?: tablevalidators<s>;\n};\n\ntype vaultlogger = {\n error(message: string, context?: error | record<string, unknown>): void;\n};\n\ntype recordvalidator<t> = {\n parse(value: unknown): t;\n};\n\ntype tablevalidators<s extends anyschema> = {\n [k in keyof s]?: recordvalidator<recordof<s, k>>;\n};\n\ntype metricsevent = {\n duration: number;\n operation: 'batch' | 'clear' | 'count' | 'delete' | 'deletemany' | 'entries' | 'get' | 'getall' |\n 'getmany' | 'getordefault' | 'has' | 'isempty' | 'keys' | 'put' | 'putall' | 'query' |\n 'querydelete' | 'update' | 'upsert';\n table: string;\n};\n\ntype debugstats = { expiredcount: number; recordcount: number };\ntype debuginfo<s extends anyschema> = { tables: array<{ name: keyof s & string } & debugstats> };\n```\n\n```ts\ninterface indexeddbvaultstore<s extends anyschema>\n extends transactionalvaultstore<s>, iterablevaultstore<s> {}\n\ntype migrationcontext = {\n db: idbdatabase;\n newversion: number | null;\n oldversion: number;\n tx: idbtransaction;\n};\n\ntype migrationfn = (ctx: migrationcontext) => void;\n\ntype migrationstep =\n | { field: string; table: string; type: 'addindex' }\n | { field: string; table: string; type: 'removeindex' }\n | { name: string; type: 'addtable' }\n | { name: string; type: 'removetable' };\n```\n\nimport `migrationcontext`, `migrationfn`, and `migrationstep` from `@vielzeug/vault/indexeddb`.\n\n```ts\ntype sqliteparameter = null | number | string;\n\ninterface sqlitestatement {\n all(...parameters: sqliteparameter[]): readonly record<string, unknown>[];\n finalize?(): void;\n get(...parameters: sqliteparameter[]): record<string, unknown> | undefined;\n run(...parameters: sqliteparameter[]): unknown;\n}\n\ninterface sqlitedatabase {\n close?(): void;\n exec(sql: string): void;\n prepare(sql: string): sqlitestatement;\n}\n\ntype sqlitevaultoptions<s extends anyschema> = baseadapteroptions<s> & {\n closeondispose?: boolean;\n database: sqlitedatabase;\n name: string;\n};\n\ninterface sqlitevaultstore<s extends anyschema>\n extends transactionalvaultstore<s>, iterablevaultstore<s> {}\n```\n\n`transactioncontext` has the same crud, query, and ttl methods as `vaultstore`, narrowed to the tables declared in `batch()`. import it from `@vielzeug/vault/indexeddb` or `@vielzeug/vault/sqlite`.\n\n## errors\n\n| error | trigger |\n| | |\n| `vaulterror` | any vault originated validation, serialization, storage, or query error |\n| `vaultdisposederror` | an operation after the store or observer hub is disposed |\n| `vaultscopeerror` | an indexeddb transaction accesses a table outside its declared batch scope |\n| `vaultquotaerror` | a localstorage or sessionstorage write exceeds the browser quota |\n| `vaultmigrationerror` | an indexeddb migration callback throws |\n\nevery listed error extends `vaulterror`.\n",
|
|
1380
|
-
"usage": " \ntitle: vault — usage guide\ndescription: persist typed browser or sqlite data, observe table snapshots, and use atomic transactions.\n \n\n[[toc]]\n\n## basic usage\n\ncreate a portable store with one schema and write a typed row.\n\n```ts\nimport { table } from '@vielzeug/vault';\nimport { createlocalstorage } from '@vielzeug/vault/local storage';\n\ninterface preference {\n id: string;\n theme: 'dark' | 'light';\n}\n\nconst store = createlocalstorage({\n name: 'app v2',\n schema: { preferences: table<preference>('id') },\n});\n\nawait store.put('preferences', { id: 'theme', theme: 'dark' });\nconsole.log(await store.get('preferences', 'theme'));\n```\n\n## create a portable store\n\nmemory, localstorage, and sessionstorage return `vaultstore`. they share portable string/number keys, crud methods, queries, ttl, and `observe()`. vault keeps record values and expiry metadata separate; the physical storage layout is adapter specific.\n\nthe root entry is adapter free. import `creatememory` from `@vielzeug/vault/memory`, `createlocalstorage` from `@vielzeug/vault/local storage`, or `createsessionstorage` from `@vielzeug/vault/session storage`. import each adapter from its focused subpath so unused backends stay out of the bundle.\n\nuse a new storage name when upgrading from vault 1. old key and envelope formats are not read by vault 2.\n\n```ts\nconst store = createlocalstorage({\n name: 'app v2',\n schema: { preferences: table<preference>('id') },\n});\n```\n\n## read and change records\n\nuse `update()` for an existing row and `upsert()` when the row may not exist.\n\n```ts\nconst updated = await store.update('preferences', 'theme', { theme: 'light' });\n\nawait store.upsert('preferences', 'locale', (current) => ({\n id: 'locale',\n theme: current?.theme ?? 'dark',\n}));\n\nconsole.log(updated);\n```\n\n`update()` returns `undefined` for a missing key. `upsert()` always writes the record returned by its callback.\n\n## query records\n\nbuild a query from a table, then finish it with a terminal method. `count()` ignores pagination, which makes it suitable for page controls.\n\n```ts\nconst query = store.query('preferences').startswith('
|
|
1400
|
+
"index": " \ntitle: vault — typed storage\ndescription: typed browser storage and opt in driver neutral sqlite with portable keys, ttl, observation, and transactions.\npackage: vault\ncategory: storage\nkeywords: [storage, indexeddb, localstorage, sessionstorage, sqlite, ttl, browser, node, deno]\nrelated: [courier, forge, ripple]\nexports: [table, ttl, isexpired, creatememory, createlocalstorage, createsessionstorage, createindexeddb, createsqlite, definemigration]\nenvironments: [browser, node, deno]\n \n\n<! markdownlint disable md025 md033 md060 >\n\n<packagehero package=\"vault\" />\n\n## why vault?\n\nvault gives browser and sqlite persistence one typed schema while keeping backend guarantees explicit. use `vaultstore` for portable crud and observation; choose indexeddb or the opt in sqlite subpath when you need atomic transactions or lazy iteration.\n\n```ts\n// before\nlocalstorage.setitem('theme', json.stringify({ value: 'dark' }));\nconst theme = json.parse(localstorage.getitem('theme') ?? '{}').value;\n\n// after\nawait store.put('preferences', { id: 'theme', value: 'dark' });\nconst theme = await store.get('preferences', 'theme');\n```\n\n| feature | vault | raw web storage | dexie |\n| | | | |\n| bundle size | <packageinfo package=\"vault\" type=\"size\" /> | browser built in | extra dependency |\n| runtime dependencies | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> |\n| typed schema and keys | <ore icon name=\"check\" size=\"16\"></ore icon> | application defined | <ore icon name=\"check\" size=\"16\"></ore icon> |\n| portable memory/web storage api | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"triangle alert\" size=\"16\"></ore icon> | indexeddb only |\n| explicit atomic transactions | indexeddb capability | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> |\n| driver neutral sqlite | opt in subpath | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> |\n\n<div class=\"decision callout\">\n\n**use vault when** you need typed browser persistence or application owned sqlite with one portable crud api and explicit storage capabilities.\n\n**consider raw web storage when** you only persist one or two unstructured values. **consider dexie when** you need a broader indexeddb ecosystem.\n\n</div>\n\n## installation\n\n::: code group\n\n```sh [pnpm]\npnpm add @vielzeug/vault\n```\n\n```sh [npm]\nnpm install @vielzeug/vault\n```\n\n```sh [yarn]\nyarn add @vielzeug/vault\n```\n\n:::\n\n## quick start\n\ndefine a schema, create a portable store, and dispose it with its owner.\n\n```ts\nimport { table } from '@vielzeug/vault';\nimport { createlocalstorage } from '@vielzeug/vault/local storage';\n\nconst store = createlocalstorage({\n name: 'app v2',\n schema: { preferences: table<{ id: string; theme: 'dark' | 'light' }>('id') },\n});\n\ntry {\n await store.put('preferences', { id: 'theme', theme: 'dark' });\n console.log(await store.get('preferences', 'theme'));\n} finally {\n await store.dispose();\n}\n```\n\n## features\n\n<div class=\"features grid\">\n\n `table()` defines typed records with portable string or number keys.\n `/memory`, `/local storage`, and `/session storage` return portable `vaultstore` instances without loading other adapters.\n `observe()` emits current and changed table snapshots.\n `ttl` creates validated expiration durations.\n `/indexeddb` returns `transactionalvaultstore` with `batch()` and `iterate()`.\n `createsqlite()` is an opt in, driver neutral subpath for node, bun, and deno sqlite drivers.\n `/indexeddb` also exports `definemigration()` for schema upgrades.\n `pruneexpired()` removes stale ttl entries on demand.\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 [forge](../forge/index.md) saves and restores form drafts through vault stores.\n [ripple](../ripple/index.md) owns application state that can persist through vault.\n [courier](../courier/index.md) can populate persistent cache data.\n\n</div>\n\n<! markdownlint enable md025 md033 md060 >\n",
|
|
1401
|
+
"api": " \ntitle: vault — api reference\ndescription: reference for vault schemas, adapter entry points, storage capabilities, sqlite drivers, and errors.\n \n\n[[toc]]\n\n## api overview\n\n| symbol | purpose | execution mode | common gotcha |\n| | | | |\n| `creatememory()` | in memory portable store | async api | import from `/memory` |\n| `createlocalstorage()` / `createsessionstorage()` | web storage backed portable stores | async api | available only where the corresponding web api exists |\n| `createindexeddb()` | browser transactions and cursor iteration | async api | import from `/indexeddb` |\n| `createsqlite()` | driver neutral sqlite store | async api over a synchronous driver | import from `/sqlite` |\n| `definemigration()` | declarative indexeddb schema upgrade | sync | import from `/indexeddb` |\n| `table()` | typed record schema | sync | the key field must be a string or finite number |\n| `ttl` | valid expiration durations | sync | durations must be positive |\n| `isexpired()` | check an expiration timestamp | sync | returns `false` when no expiry is set |\n\n## package entry points\n\n| import | purpose |\n| | |\n| `@vielzeug/vault` | adapter free schemas, ttl, errors, pruning, queries, and shared types |\n| `@vielzeug/vault/memory` | `creatememory` |\n| `@vielzeug/vault/local storage` | `createlocalstorage` |\n| `@vielzeug/vault/session storage` | `createsessionstorage` |\n| `@vielzeug/vault/indexeddb` | `createindexeddb`, `definemigration`, migrations, and indexeddb only types |\n| `@vielzeug/vault/sqlite` | `createsqlite`, the sqlite driver protocol types, and `transactioncontext` |\n\n## schemas and ttl\n\n### `table()`\n\n```ts\nfunction table<t extends object, key extends keyof t & string = keyof t & string>(\n key: key & (t[key] extends vaultkey ? unknown : never),\n options?: { defaultttl?: number; indexes?: readonly (keyof t & string)[] },\n): schemaentry<t, key>;\n```\n\ndefines a typed table and its primary key field.\n\n| parameter | description |\n| | |\n| `key` | a record field whose values are `string` or finite `number` keys |\n| `options.defaultttl` | per table default ttl in milliseconds |\n| `options.indexes` | indexeddb secondary index fields |\n\n**returns:** a `schemaentry` describing the table.\n\n```ts\nimport { table, ttl } from '@vielzeug/vault';\n\nconst users = table<{ id: number; email: string }>('id', {\n indexes: ['email'],\n defaultttl: ttl.days(7),\n});\n```\n\n \n\n### `ttl`\n\n```ts\nconst ttl: {\n days(n: number): number;\n hours(n: number): number;\n minutes(n: number): number;\n ms(n: number): number;\n seconds(n: number): number;\n};\n```\n\ncreates a finite, positive duration in milliseconds for writes and table defaults.\n\n**returns:** `number`.\n\n```ts\nimport { ttl } from '@vielzeug/vault';\n\nconst cachelifetime = ttl.minutes(5);\n```\n\n \n\n### `isexpired()`\n\n```ts\nfunction isexpired(expiresat: number | undefined): boolean;\n```\n\nreports whether an expiration timestamp has passed.\n\n**returns:** `true` when `expiresat` is defined and no later than the current time.\n\n```ts\nimport { isexpired } from '@vielzeug/vault';\n\nif (isexpired(record.expiresat)) console.log('expired');\n```\n\n## factories\n\nall factory options accept `schema` and optional `validators`. the root entry does not export any factory.\n\n### `creatememory()`\n\n```ts\nfunction creatememory<s extends anyschema>(options: baseadapteroptions<s>): vaultstore<s>;\n```\n\ncreates an in memory portable store.\n\n| parameter | description |\n| | |\n| `schema` | tables created by `table()` |\n| `validators` | optional per table validators with a `parse(value): t` method |\n\n**returns:** `vaultstore<s>`.\n\n```ts\nimport { table } from '@vielzeug/vault';\nimport { creatememory } from '@vielzeug/vault/memory';\n\nconst store = creatememory({ schema: { users: table<{ id: number; name: string }>('id') } });\n```\n\n \n\n### `createlocalstorage()`\n\n```ts\nfunction createlocalstorage<s extends anyschema>(options: baseadapteroptions<s> & {\n name: string;\n onquotaexceeded?: (table: keyof s, error: vaultquotaerror) => 'ignore' | 'throw';\n}): vaultstore<s>;\n```\n\ncreates a namespaced `localstorage` store.\n\n| parameter | description |\n| | |\n| `schema` | tables created by `table()` |\n| `validators` | optional per table validators |\n| `name` | required storage namespace |\n| `onquotaexceeded` | handles a web storage quota error; returning `'ignore'` drops that write |\n\n**returns:** `vaultstore<s>`.\n\n```ts\nimport { table } from '@vielzeug/vault';\nimport { createlocalstorage } from '@vielzeug/vault/local storage';\n\nconst store = createlocalstorage({ name: 'app', schema: { settings: table<{ id: string }>('id') } });\n```\n\n \n\n### `createsessionstorage()`\n\n```ts\nfunction createsessionstorage<s extends anyschema>(options: baseadapteroptions<s> & {\n name: string;\n onquotaexceeded?: (table: keyof s, error: vaultquotaerror) => 'ignore' | 'throw';\n}): vaultstore<s>;\n```\n\ncreates a namespaced `sessionstorage` store. its options and return type match `createlocalstorage()`.\n\n**returns:** `vaultstore<s>`.\n\n```ts\nimport { table } from '@vielzeug/vault';\nimport { createsessionstorage } from '@vielzeug/vault/session storage';\n\nconst store = createsessionstorage({ name: 'checkout', schema: { cart: table<{ id: string }>('id') } });\n```\n\n \n\n### `createindexeddb()`\n\n```ts\nfunction createindexeddb<s extends anyschema>(options: baseadapteroptions<s> & {\n migrate?: migrationfn;\n name: string;\n version?: number;\n}): transactionalvaultstore<s>;\n```\n\ncreates an indexeddb store with atomic batches, lazy cursor iteration, and optional schema migrations.\n\n| parameter | description |\n| | |\n| `schema` | tables and indexeddb secondary indexes |\n| `validators` | optional per table validators |\n| `name` | required database name |\n| `version` | positive schema version; defaults to `1` |\n| `migrate` | synchronous upgrade callback for version changes |\n\n**returns:** `transactionalvaultstore<s>`.\n\n```ts\nimport { table } from '@vielzeug/vault';\nimport { createindexeddb } from '@vielzeug/vault/indexeddb';\n\nconst store = createindexeddb({ name: 'app', schema: { users: table<{ id: number }>('id') } });\n```\n\n \n\n### `createsqlite()`\n\n```ts\nfunction createsqlite<s extends anyschema>(options: sqlitevaultoptions<s>): transactionalvaultstore<s>;\n```\n\ncreates a namespaced sqlite store with atomic batches and keyset paginated iteration. it accepts an application provided positional parameter driver and never opens or imports a runtime driver.\n\n| parameter | description |\n| | |\n| `schema` | tables created by `table()` |\n| `validators` | optional per table validators |\n| `database` | caller provided `sqlitedatabase` connection |\n| `name` | namespace within the connection |\n| `closeondispose` | closes the connection during disposal; defaults to `false` |\n\n**returns:** `transactionalvaultstore<s>`.\n\n```ts\nimport { databasesync } from 'node:sqlite';\n\nimport { table } from '@vielzeug/vault';\nimport { createsqlite } from '@vielzeug/vault/sqlite';\n\nconst store = createsqlite({\n database: new databasesync(':memory:'),\n name: 'tests',\n schema: { users: table<{ id: number; name: string }>('id') },\n});\n```\n\nnode `databasesync`, bun `database`, and deno `jsr:@db/sqlite` `database` satisfy the protocol. values must be json compatible plain objects. during a `batch()` callback, calls on every vault store sharing that connection reject; use `tx.*` instead.\n\n## store capabilities\n\n### `vaultstore`\n\n```ts\ninterface vaultstore<s extends anyschema> {\n clear<k extends keyof s & string>(table: k): promise<void>;\n count<k extends keyof s & string>(table: k): promise<number>;\n delete<k extends keyof s & string>(table: k, key: keyof<s, k>): promise<boolean>;\n deletemany<k extends keyof s & string>(table: k, keys: keyof<s, k>[]): promise<number>;\n get<k extends keyof s & string>(table: k, key: keyof<s, k>): promise<recordof<s, k> | undefined>;\n getall<k extends keyof s & string>(table: k): promise<recordof<s, k>[]>;\n getmany<k extends keyof s & string>(table: k, keys: keyof<s, k>[]): promise<array<recordof<s, k> | undefined>>;\n has<k extends keyof s & string>(table: k, key: keyof<s, k>): promise<boolean>;\n isempty<k extends keyof s & string>(table: k): promise<boolean>;\n keys<k extends keyof s & string>(table: k, filter?: (record: recordof<s, k>) => boolean): promise<keyof<s, k>[]>;\n put<k extends keyof s & string>(table: k, value: recordof<s, k>, ttl?: number): promise<void>;\n putall<k extends keyof s & string>(table: k, values: recordof<s, k>[], ttl?: number): promise<void>;\n query<k extends keyof s & string>(table: k): querybuilder<recordof<s, k>>;\n update<k extends keyof s & string>(table: k, key: keyof<s, k>, changes: partial<recordof<s, k>>, ttl?: number): promise<recordof<s, k> | undefined>;\n upsert<k extends keyof s & string>(table: k, key: keyof<s, k>, fn: (existing: recordof<s, k> | undefined) => recordof<s, k>, ttl?: number): promise<recordof<s, k>>;\n pruneexpired(): promise<record<keyof s & string, number>>;\n observe<k extends keyof s & string>(table: k, listener: observer<recordof<s, k>>, options?: { immediate?: boolean; signal?: abortsignal }): unsubscribe;\n dispose(): promise<void>;\n readonly disposed: boolean;\n readonly disposalsignal: abortsignal;\n [symbol.asyncdispose](): promise<void>;\n}\n```\n\nthe portable store api is returned by every factory. `observe()` emits the current table snapshot by default and then emits after mutations.\n\n \n\n### `batch()` and `iterate()`\n\n```ts\ninterface transactionalvaultstore<s extends anyschema> extends vaultstore<s> {\n batch<k extends keyof s & string, r>(\n tables: readonly k[],\n fn: (tx: transactioncontext<s, k>) => promise<r>,\n ): promise<r>;\n iterate<k extends keyof s & string>(table: k): asynciterable<recordof<s, k>>;\n}\n```\n\n`batch()` runs a scoped atomic callback. `iterate()` lazily yields table records — indexeddb uses a cursor, sqlite uses keyset pagination. both are provided by `createindexeddb()` and `createsqlite()`.\n\n| parameter | description |\n| | |\n| `tables` | tables the transaction may access |\n| `fn` | async callback that uses only the supplied `tx` context |\n\n**returns:** the callback result after commit.\n\n```ts\nawait store.batch(['users'], async (tx) => {\n await tx.put('users', { id: 1, name: 'ada' });\n});\n\nfor await (const user of store.iterate('users')) console.log(user);\n```\n\n## queries and migrations\n\n### `querybuilder`\n\n```ts\ninterface querybuilder<t extends object> {\n count(): promise<number>;\n delete(): promise<number>;\n equals<k extends keyof t & string, v extends t[k]>(field: k, value: v): querybuilder<t>;\n filter(fn: (value: t, index: number, array: t[]) => boolean): querybuilder<t>;\n first(): promise<t | undefined>;\n limit(n: number): querybuilder<t>;\n offset(n: number): querybuilder<t>;\n orderby<k extends keyof t>(field: k, direction?: 'asc' | 'desc'): querybuilder<t>;\n toarray(): promise<t[]>;\n}\n```\n\nbuilds a lazy table query. `count()` ignores `limit()`, `offset()`, and `orderby()` — it always returns the full filtered set size.\n\n```ts\nconst page = await store.query('users').equals('role', 'admin').orderby('name').limit(20).toarray();\n```\n\n \n\n### `definemigration()`\n\n```ts\nfunction definemigration(steps: migrationstep[]): migrationfn;\n```\n\nbuilds an idempotent indexeddb migration callback from schema change steps.\n\n**returns:** an indexeddb `migrationfn`.\n\n```ts\nimport { definemigration } from '@vielzeug/vault/indexeddb';\n\nconst migrate = definemigration([{ field: 'email', table: 'users', type: 'addindex' }]);\n```\n\n## types\n\n```ts\ntype vaultkey = number | string;\ntype unsubscribe = () => void;\ntype observer<t> = (records: t[]) => void;\ntype anyschema = record<string, {\n defaultttl?: number;\n indexes?: readonly string[];\n key: string;\n}>;\ntype schemaentry<t extends object, key extends keyof t & string = keyof t & string> =\n t[key] extends vaultkey ? {\n defaultttl?: number;\n indexes?: readonly (keyof t & string)[];\n key: key;\n } : never;\ntype recordof<s extends anyschema, k extends keyof s> =\n s[k] extends schemaentry<infer r, infer _key> ? r : never;\ntype keyof<s extends anyschema, k extends keyof s> =\n extract<s[k] extends schemaentry<infer r, infer key> ? r[key] : never, vaultkey>;\n```\n\n```ts\ntype baseadapteroptions<s extends anyschema> = {\n schema: s;\n validators?: tablevalidators<s>;\n};\n\ntype recordvalidator<t> = {\n parse(value: unknown): t;\n};\n\ntype tablevalidators<s extends anyschema> = {\n [k in keyof s]?: recordvalidator<recordof<s, k>>;\n};\n```\n\n```ts\ntype migrationcontext = {\n db: idbdatabase;\n newversion: number | null;\n oldversion: number;\n tx: idbtransaction;\n};\n\ntype migrationfn = (ctx: migrationcontext) => void;\n\ntype migrationstep =\n | { field: string; table: string; type: 'addindex' }\n | { field: string; table: string; type: 'removeindex' }\n | { name: string; type: 'addtable' }\n | { name: string; type: 'removetable' };\n```\n\nimport `migrationcontext`, `migrationfn`, and `migrationstep` from `@vielzeug/vault/indexeddb`.\n\n```ts\ntype sqliteparameter = null | number | string;\n\ninterface sqlitestatement {\n all(...parameters: sqliteparameter[]): readonly record<string, unknown>[];\n finalize?(): void;\n get(...parameters: sqliteparameter[]): record<string, unknown> | undefined;\n run(...parameters: sqliteparameter[]): unknown;\n}\n\ninterface sqlitedatabase {\n close?(): void;\n exec(sql: string): void;\n prepare(sql: string): sqlitestatement;\n}\n\ntype sqlitevaultoptions<s extends anyschema> = baseadapteroptions<s> & {\n closeondispose?: boolean;\n database: sqlitedatabase;\n name: string;\n};\n```\n\n```ts\ninterface transactionalvaultstore<s extends anyschema> extends vaultstore<s> {\n batch<k extends keyof s & string, r>(\n tables: readonly k[],\n fn: (tx: transactioncontext<s, k>) => promise<r>,\n ): promise<r>;\n iterate<k extends keyof s & string>(table: k): asynciterable<recordof<s, k>>;\n}\n```\n\nimport `transactionalvaultstore` from `@vielzeug/vault`.\n\n```ts\ninterface transactioncontext<s extends anyschema, k extends keyof s & string = keyof s & string> {\n clear<t extends k>(table: t): promise<void>;\n count<t extends k>(table: t): promise<number>;\n delete<t extends k>(table: t, key: keyof<s, t>): promise<boolean>;\n deletemany<t extends k>(table: t, keys: keyof<s, t>[]): promise<number>;\n get<t extends k>(table: t, key: keyof<s, t>): promise<recordof<s, t> | undefined>;\n getall<t extends k>(table: t): promise<recordof<s, t>[]>;\n getmany<t extends k>(table: t, keys: keyof<s, t>[]): promise<array<recordof<s, t> | undefined>>;\n has<t extends k>(table: t, key: keyof<s, t>): promise<boolean>;\n isempty<t extends k>(table: t): promise<boolean>;\n keys<t extends k>(table: t, filter?: (record: recordof<s, t>) => boolean): promise<keyof<s, t>[]>;\n put<t extends k>(table: t, value: recordof<s, t>, ttl?: number): promise<void>;\n putall<t extends k>(table: t, values: recordof<s, t>[], ttl?: number): promise<void>;\n query<t extends k>(table: t): querybuilder<recordof<s, t>>;\n update<t extends k>(table: t, key: keyof<s, t>, changes: partial<recordof<s, t>>, ttl?: number): promise<recordof<s, t> | undefined>;\n upsert<t extends k>(table: t, key: keyof<s, t>, fn: (existing: recordof<s, t> | undefined) => recordof<s, t>, ttl?: number): promise<recordof<s, t>>;\n}\n```\n\n`transactioncontext` has the same crud, query, and ttl methods as `vaultstore`, narrowed to the tables declared in `batch()`. import it from `@vielzeug/vault/indexeddb` or `@vielzeug/vault/sqlite`.\n\n```ts\n// adapter specific type aliases — both resolve to transactionalvaultstore.\ntype sqlitevaultstore<s extends anyschema> = transactionalvaultstore<s>;\ntype indexeddbvaultstore<s extends anyschema> = transactionalvaultstore<s>;\n```\n\n`sqlitevaultstore` is exported from `@vielzeug/vault/sqlite`. `indexeddbvaultstore` is exported from `@vielzeug/vault/indexeddb`.\n\n## errors\n\n| error | trigger |\n| | |\n| `vaulterror` | any vault originated validation, serialization, storage, or query error |\n| `vaultdisposederror` | an operation after the store or observer hub is disposed |\n| `vaultscopeerror` | a `batch()` callback accesses a table outside its declared scope |\n| `vaultquotaerror` | a localstorage or sessionstorage write exceeds the browser quota |\n| `vaultmigrationerror` | an indexeddb migration callback throws |\n\nevery listed error extends `vaulterror`.\n",
|
|
1402
|
+
"usage": " \ntitle: vault — usage guide\ndescription: persist typed browser or sqlite data, observe table snapshots, and use atomic transactions.\n \n\n[[toc]]\n\n## basic usage\n\ncreate a portable store with one schema and write a typed row.\n\n```ts\nimport { table } from '@vielzeug/vault';\nimport { createlocalstorage } from '@vielzeug/vault/local storage';\n\ninterface preference {\n id: string;\n theme: 'dark' | 'light';\n}\n\nconst store = createlocalstorage({\n name: 'app v2',\n schema: { preferences: table<preference>('id') },\n});\n\nawait store.put('preferences', { id: 'theme', theme: 'dark' });\nconsole.log(await store.get('preferences', 'theme'));\n```\n\n## create a portable store\n\nmemory, localstorage, and sessionstorage return `vaultstore`. they share portable string/number keys, crud methods, queries, ttl, and `observe()`. vault keeps record values and expiry metadata separate; the physical storage layout is adapter specific.\n\nthe root entry is adapter free. import `creatememory` from `@vielzeug/vault/memory`, `createlocalstorage` from `@vielzeug/vault/local storage`, or `createsessionstorage` from `@vielzeug/vault/session storage`. import each adapter from its focused subpath so unused backends stay out of the bundle.\n\nuse a new storage name when upgrading from vault 1. old key and envelope formats are not read by vault 2.\n\n```ts\nconst store = createlocalstorage({\n name: 'app v2',\n schema: { preferences: table<preference>('id') },\n});\n```\n\n## read and change records\n\nuse `update()` for an existing row and `upsert()` when the row may not exist.\n\n```ts\nconst updated = await store.update('preferences', 'theme', { theme: 'light' });\n\nawait store.upsert('preferences', 'locale', (current) => ({\n id: 'locale',\n theme: current?.theme ?? 'dark',\n}));\n\nconsole.log(updated);\n```\n\n`update()` returns `undefined` for a missing key. `upsert()` always writes the record returned by its callback.\n\n## query records\n\nbuild a query from a table, then finish it with a terminal method. `count()` ignores pagination, which makes it suitable for page controls.\n\n```ts\nconst query = store.query('preferences').filter((p) => p.id.startswith('theme'));\nconst preferences = await query.orderby('id').limit(10).toarray();\nconst total = await query.count();\n\nconsole.log({ preferences, total });\n```\n\nqueries scan the table in memory. use `equals()` for exact field matches and `filter()` for custom predicates. for large tables, prefer `iterate()` on indexeddb or sqlite instead of materializing every record.\n\n## use ttl and pruning\n\nuse `ttl.*` helpers for expiring rows. call `pruneexpired()` to reclaim storage from stale rows that accumulate without reads.\n\n```ts\nimport { ttl } from '@vielzeug/vault';\n\nawait store.put('preferences', { id: 'temporary', theme: 'dark' }, ttl.hours(1));\n\n// reclaim expired rows on a schedule owned by the application.\nconst pruneinterval = setinterval(() => store.pruneexpired(), ttl.hours(6));\nstore.disposalsignal.addeventlistener('abort', () => clearinterval(pruneinterval));\n```\n\n## observe a table\n\nuse `observe()` for current and future snapshots. tie subscription lifetime to an `abortsignal` when a component or request owns it.\n\n```ts\nconst controller = new abortcontroller();\n\nstore.observe('preferences', (preferences) => {\n console.log(preferences);\n}, { signal: controller.signal });\n\ncontroller.abort();\n```\n\n## use indexeddb for browser transactions\n\nchoose indexeddb when browser storage needs multiple writes to commit together or cursor iteration.\n\n```ts\nimport { table } from '@vielzeug/vault';\nimport { createindexeddb } from '@vielzeug/vault/indexeddb';\n\nconst db = createindexeddb({\n name: 'app v2',\n schema: { events: table<{ id: number; type: string }>('id') },\n});\n\nawait db.batch(['events'], async (tx) => {\n await tx.put('events', { id: 1, type: 'opened' });\n await tx.put('events', { id: 2, type: 'saved' });\n});\n```\n\nonly await `tx.*` operations inside a batch callback. do not await timers, fetches, or other external asynchronous work; indexeddb can commit an inactive transaction.\n\n## use sqlite outside the browser\n\nimport sqlite from the opt in subpath so the browser root stays free of runtime drivers. vault never opens a connection or configures its sqlite process behavior for you.\n\n```ts\nimport { databasesync } from 'node:sqlite';\n\nimport { table } from '@vielzeug/vault';\nimport { createsqlite } from '@vielzeug/vault/sqlite';\n\nconst database = new databasesync('app.db', { timeout: 5_000 });\nconst store = createsqlite({\n database,\n name: 'app v2',\n schema: { events: table<{ id: number; type: string }>('id') },\n});\n\nawait store.batch(['events'], async (tx) => {\n await tx.put('events', { id: 1, type: 'opened' });\n await tx.put('events', { id: 2, type: 'saved' });\n});\n```\n\nnode's `node:sqlite` api is experimental. bun's `bun:sqlite` `database` satisfies the same positional `exec()` and `prepare()` contract; configure wal from your application when the deployment needs it. deno does not include sqlite, but `jsr:@db/sqlite`'s `database` satisfies the same contract when its ffi, filesystem, and environment permissions are granted.\n\nsqlite stores serialize all access through the injected connection. `batch()` starts `begin immediate` and rolls back callback failures. while its callback runs, calls on any store sharing that connection reject rather than waiting behind the transaction; use `tx.*` instead. the underlying drivers are synchronous, so move large scans and writes to a worker or isolate when event loop latency matters.\n\n## store sqlite values and observe changes\n\nsqlite accepts json compatible plain object records only. circular values, `bigint`, dates, class instances, functions, and non finite numbers are rejected before writing. number and string primary keys remain distinct.\n\n`observe()` sees mutations written through vault stores sharing the same injected connection after a commit. it cannot detect direct sql changes, writes from another process, or writes through another connection. the connection belongs to the caller by default; use `closeondispose: true` only when the store owns it.\n\n## handle indexeddb schema migrations\n\ndeclare indexeddb indexes in the schema. use `migrate` only for indexeddb version upgrades and mirror vault’s fixed `value.<field>` index path.\n\n```ts\nimport { table } from '@vielzeug/vault';\nimport { createindexeddb, type migrationfn } from '@vielzeug/vault/indexeddb';\n\nconst schema = { users: table<{ id: number; name: string }>('id', { indexes: ['name'] }) };\nconst migrate: migrationfn = ({ db, oldversion, tx }) => {\n if (oldversion < 2 && db.objectstorenames.contains('users')) {\n tx.objectstore('users').createindex('name', 'value.name');\n }\n};\n\ncreateindexeddb({ name: 'app v2', migrate, schema, version: 2 });\n```\n\n## framework integration\n\n::: code group\n\n```ts [react]\nimport { useeffect, usestate } from 'react';\n\nimport type { anyschema, recordof, vaultstore } from '@vielzeug/vault';\n\nexport function usetable<s extends anyschema, k extends keyof s & string>(store: vaultstore<s>, table: k) {\n const [rows, setrows] = usestate<recordof<s, k>[]>([]);\n\n useeffect(() => store.observe(table, setrows), [store, table]);\n return rows;\n}\n```\n\n```ts [vue 3]\nimport { onunmounted, shallowref } from 'vue';\n\nimport type { anyschema, recordof, vaultstore } from '@vielzeug/vault';\n\nexport function usetable<s extends anyschema, k extends keyof s & string>(store: vaultstore<s>, table: k) {\n const rows = shallowref<recordof<s, k>[]>([]);\n const stop = store.observe(table, (next) => (rows.value = next));\n\n onunmounted(stop);\n return rows;\n}\n```\n\n```ts [svelte]\nimport { readable } from 'svelte/store';\n\nimport type { anyschema, recordof, vaultstore } from '@vielzeug/vault';\n\nexport function tablestore<s extends anyschema, k extends keyof s & string>(store: vaultstore<s>, table: k) {\n return readable<recordof<s, k>[]>([], (set) => store.observe(table, set));\n}\n```\n\n:::\n\n## working with other vielzeug libraries\n\nuse forge’s vault helpers for explicit form draft persistence. keep ripple signals as application state and persist selected changes through vault writes.\n\n## best practices\n\n define one schema per storage namespace.\n use string or finite number primary keys only.\n choose a new namespace for vault 1 storage unless you migrate it yourself.\n use `observe()` for table snapshots.\n use indexeddb or sqlite for atomic work.\n keep external asynchronous work outside `batch()` callbacks.\n use `ttl.*` instead of raw durations.\n keep sqlite scans and writes off latency sensitive event loops.\n dispose stores when their owner ends.\n",
|
|
1381
1403
|
"examples": " \ntitle: vault — examples\ndescription: portable storage, observation, transactions, iteration, and sqlite.\n \n\n [crud](./examples/crud.md)\n [ttl](./examples/ttl.md)\n [querying](./examples/querying.md)\n [reactive observation](./examples/reactive.md)\n [indexeddb iteration](./examples/iterate.md)\n [indexeddb batch transactions](./examples/batch.md)\n [sqlite transactions and iteration](./examples/sqlite.md)\n [plugin validation](./examples/plugins.md)\n"
|
|
1382
1404
|
},
|
|
1383
1405
|
"examples": [
|
|
@@ -1391,7 +1413,7 @@
|
|
|
1391
1413
|
},
|
|
1392
1414
|
{
|
|
1393
1415
|
"id": "cache-first",
|
|
1394
|
-
"text": "cache first with
|
|
1416
|
+
"text": "cache first with get + put import { table, ttl } from '@vielzeug/vault'\nimport { createlocalstorage } from '@vielzeug/vault/local storage'\n\nconst db = createlocalstorage({ name: 'cache demo', schema: { cache: table('id') } })\n\nasync function getorcomputeconfig() {\n const existing = await db.get('cache', 'config')\n if (existing) return existing\n\n const record = {\n id: 'config',\n data: 'computed value',\n fetchedat: date.now(),\n }\n await db.put('cache', record, ttl.minutes(5))\n return record\n}\n\nconst first = await getorcomputeconfig()\nconst second = await getorcomputeconfig()\nconsole.log('same cached record:', first.fetchedat === second.fetchedat)"
|
|
1395
1417
|
},
|
|
1396
1418
|
{
|
|
1397
1419
|
"id": "crud-operations",
|
|
@@ -1399,15 +1421,15 @@
|
|
|
1399
1421
|
},
|
|
1400
1422
|
{
|
|
1401
1423
|
"id": "indexed-db",
|
|
1402
|
-
"text": "indexeddb — atomic batch & iterate() import { table, ttl } from '@vielzeug/vault'\nimport { createindexeddb } from '@vielzeug/vault/indexeddb'\n\nconst schema = {\n logs: table('id'),\n}\n\n// createindexeddb returns
|
|
1424
|
+
"text": "indexeddb — atomic batch & iterate() import { table, ttl } from '@vielzeug/vault'\nimport { createindexeddb } from '@vielzeug/vault/indexeddb'\n\nconst schema = {\n logs: table('id'),\n}\n\n// createindexeddb returns transactionalvaultstore with transactions and cursor iteration\nconst db = createindexeddb({\n name: 'app logs',\n schema,\n version: 1,\n})\n\nawait db.putall('logs', [\n { id: 1, level: 'info', message: 'app started', ts: date.now() 3000 },\n { id: 2, level: 'warn', message: 'slow query detected', ts: date.now() 2000 },\n { id: 3, level: 'error', message: 'request failed', ts: date.now() 1000 },\n { id: 4, level: 'info', message: 'request succeeded', ts: date.now() },\n], ttl.hours(1))\n\n// batch() is atomic on indexeddb — all writes commit or none do\nawait db.batch(['logs'], async (tx) => {\n await tx.put('logs', { id: 5, level: 'info', message: 'batch committed', ts: date.now() })\n await tx.deletemany('logs', [1, 2]) // remove old entries in the same transaction\n})\n\n// iterate() — cursor based streaming, only on transactionalvaultstore\n// the full table is never loaded into memory at once\nconst messages = []\nfor await (const entry of db.iterate('logs')) {\n messages.push(entry.message)\n}\nconsole.log('streamed via iterate():', messages)\n\nconst errors = await db.query('logs').equals('level', 'error').toarray()\nconsole.log('errors:', errors.map((e) => e.message))\nconsole.log('total logs:', await db.query('logs').count())\n\n// pruneexpired() reclaims storage from ttl expired records that haven't been read\nconst pruned = await db.pruneexpired()\nconsole.log('pruned:', pruned)\n\nawait db.dispose()"
|
|
1403
1425
|
},
|
|
1404
1426
|
{
|
|
1405
1427
|
"id": "prune-schedule",
|
|
1406
|
-
"text": "ttl —
|
|
1428
|
+
"text": "ttl — pruneexpired with disposalsignal import { table, ttl } from '@vielzeug/vault'\nimport { creatememory } from '@vielzeug/vault/memory'\n\n// pruneexpired() sweeps all tables and removes expired records.\n// schedule it with setinterval and cancel on disposalsignal.\n\nconst schema = { sessions: table('token') }\nconst db = creatememory({ schema })\n\nconst pruneinterval = setinterval(() => db.pruneexpired(), ttl.minutes(15))\ndb.disposalsignal.addeventlistener('abort', () => clearinterval(pruneinterval))\n\n// write a session that expires in 1 ms\nawait db.put('sessions', { token: 'abc', user: 1 }, ttl.ms(1))\nawait db.put('sessions', { token: 'def', user: 2 }) // no ttl — permanent\n\nconsole.log('before prune:', await db.count('sessions')) // 2 (lazy eviction: both exist physically)\n\n// manual prune to demonstrate the api\nawait new promise((resolve) => settimeout(resolve, 5))\nconst pruned = await db.pruneexpired()\nconsole.log('pruned:', pruned.sessions) // 1 (the expired session)\nconsole.log('after prune:', await db.count('sessions')) // 1\n\nawait db.dispose()"
|
|
1407
1429
|
},
|
|
1408
1430
|
{
|
|
1409
1431
|
"id": "query-builder",
|
|
1410
|
-
"text": "query builder — filters, pagination, count import { table } from '@vielzeug/vault'\nimport { createlocalstorage } from '@vielzeug/vault/local storage'\n\nconst schema = {\n products: table('id'),\n}\n\nconst db = createlocalstorage({ name: 'shop', schema })\n\nawait db.putall('products', [\n { id: 1, name: 'laptop', price: 999, category: 'electronics', instock: true },\n { id: 2, name: 'mouse', price: 29, category: 'electronics', instock: true },\n { id: 3, name: 'desk', price: 299, category: 'furniture', instock: false },\n { id: 4, name: 'chair', price: 199, category: 'furniture', instock: true },\n { id: 5, name: 'monitor', price: 399, category: 'electronics', instock: true },\n])\n\nconst pagesize = 2\nconst pageindex = 0\n\n// build a base query — reuse it for both the page slice and the total count\nconst q = db\n .query('products')\n .equals('category', 'electronics')\n .filter((p) => p.instock)\n .orderby('price', 'asc')\n\n// count() ignores limit/offset/orderby — returns the full filtered set size\nconst page = await q.limit(pagesize).offset(pageindex * pagesize).toarray()\nconst total = await q.count()\n\nconsole.log('page:', page.map((p) => p.name))\nconsole.log('total matching:', total)\nconsole.log('page 1 of', math.ceil(total / pagesize))\n\n//
|
|
1432
|
+
"text": "query builder — filters, pagination, count import { table } from '@vielzeug/vault'\nimport { createlocalstorage } from '@vielzeug/vault/local storage'\n\nconst schema = {\n products: table('id'),\n}\n\nconst db = createlocalstorage({ name: 'shop', schema })\n\nawait db.putall('products', [\n { id: 1, name: 'laptop', price: 999, category: 'electronics', instock: true },\n { id: 2, name: 'mouse', price: 29, category: 'electronics', instock: true },\n { id: 3, name: 'desk', price: 299, category: 'furniture', instock: false },\n { id: 4, name: 'chair', price: 199, category: 'furniture', instock: true },\n { id: 5, name: 'monitor', price: 399, category: 'electronics', instock: true },\n])\n\nconst pagesize = 2\nconst pageindex = 0\n\n// build a base query — reuse it for both the page slice and the total count\nconst q = db\n .query('products')\n .equals('category', 'electronics')\n .filter((p) => p.instock)\n .orderby('price', 'asc')\n\n// count() ignores limit/offset/orderby — returns the full filtered set size\nconst page = await q.limit(pagesize).offset(pageindex * pagesize).toarray()\nconst total = await q.count()\n\nconsole.log('page:', page.map((p) => p.name))\nconsole.log('total matching:', total)\nconsole.log('page 1 of', math.ceil(total / pagesize))\n\n// prefix match via filter()\nconst mice = await db\n .query('products')\n .filter((p) => p.name.tolowercase().startswith('m'))\n .toarray()\nconsole.log('starts with m:', mice.map((p) => p.name))\n\n// predicate delete\nconst removed = await db.query('products').filter((p) => !p.instock).delete()\nconsole.log('removed out of stock:', removed)\n\n// first()\nconst cheapest = await db.query('products').orderby('price', 'asc').first()\nconsole.log('cheapest:', cheapest?.name, cheapest?.price)"
|
|
1411
1433
|
},
|
|
1412
1434
|
{
|
|
1413
1435
|
"id": "reactive-observe",
|
|
@@ -1418,21 +1440,21 @@
|
|
|
1418
1440
|
"text": "ttl & expiration import { table, ttl } from '@vielzeug/vault'\nimport { createlocalstorage } from '@vielzeug/vault/local storage'\n\nconst schema = {\n cache: table('id'),\n}\n\nconst db = createlocalstorage({ name: 'cache demo', schema })\n\n// ttl helpers produce finite, positive millisecond durations\nawait db.put('cache', { id: 'short', data: 'expires in 1 second' }, ttl.seconds(1))\nawait db.put('cache', { id: 'long', data: 'expires in 5 minutes' }, ttl.minutes(5))\nconsole.log('stored records with ttl')\nconsole.log('immediate read:', await db.get('cache', 'short'))\n\nawait new promise((resolve) => settimeout(resolve, 1500))\nconsole.log('after 1.5s:', await db.get('cache', 'short')) // expired — undefined\nconsole.log('long lived still here:', await db.get('cache', 'long'))\n\nconsole.log('ttl helpers:', {\n '100ms': ttl.ms(100),\n '5 minutes': ttl.minutes(5),\n '2 hours': ttl.hours(2),\n '7 days': ttl.days(7),\n})"
|
|
1419
1441
|
}
|
|
1420
1442
|
],
|
|
1421
|
-
"exports": "table ttl
|
|
1443
|
+
"exports": "table ttl isexpired creatememory createlocalstorage createsessionstorage createindexeddb createsqlite definemigration",
|
|
1422
1444
|
"keywords": "storage indexeddb localstorage sessionstorage sqlite ttl browser node deno",
|
|
1423
1445
|
"name": "@vielzeug/vault",
|
|
1424
1446
|
"related": "courier forge ripple",
|
|
1425
1447
|
"slug": "vault",
|
|
1426
|
-
"source": "export { vaultdisposederror, vaulterror, vaultmigrationerror, vaultquotaerror, vaultscopeerror } from './errors';\nexport
|
|
1448
|
+
"source": "export { vaultdisposederror, vaulterror, vaultmigrationerror, vaultquotaerror, vaultscopeerror } from './errors';\nexport type { querybuilder } from './query';\nexport { isexpired, ttl } from './ttl';\nexport type {\n anyschema,\n baseadapteroptions,\n keyof,\n observer,\n recordof,\n recordvalidator,\n schemaentry,\n tablevalidators,\n transactionalvaultstore,\n unsubscribe,\n vaultkey,\n vaultstore,\n} from './types';\nexport { table } from './types';\n"
|
|
1427
1449
|
},
|
|
1428
1450
|
{
|
|
1429
1451
|
"category": "auth",
|
|
1430
1452
|
"description": "typed authorization policies with wildcard matching, deterministic precedence, and decision tracing.",
|
|
1431
1453
|
"docs": {
|
|
1432
|
-
"index": " \ntitle: ward — deterministic authorization for typescript\ndescription: typed authorization policies with wildcard matching, deterministic precedence, and decision tracing.\npackage: ward\ncategory: auth\nkeywords: [authorization, rbac, permissions, policy, roles, wildcard, predicates]\nrelated: [wayfinder, conduit, herald]\nexports: [createward, allow, deny, rulefor, owns, predicate, anonymous, wildcard, warderror, wardconfigerror, wardpredicateerror, normalizedwardrule, matchespattern, patterncovers]\nenvironments: [browser, node, ssr, deno]\n \n\n<! markdownlint disable md025 md033 md060 >\n\n<packagehero package=\"ward\" />\n\n## why ward?\n\nward keeps authorization policies declarative and decision ordering deterministic. define rules once, then explain or trace every permission decision without embedding role checks across handlers.\n\n```ts\n// before\nconst canupdate = user.roles.includes('editor') && post.authorid === user.id;\n\n// after\nimport { allow, createward, owns } from '@vielzeug/ward';\n\nconst ward = createward([\n allow('editor', 'posts', ['update'], { when: owns('authorid') }),\n]);\n\nconst decision = ward.explain({ principal: user, resource: 'posts', action: 'update', data: post });\nconst canupdate = decision.allowed;\n```\n\n| feature | ward | casl | accesscontrol |\n| | | | |\n| bundle size | <packageinfo package=\"ward\" type=\"size\" /> | larger policy engine | larger policy engine |\n| zero dependencies | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> |\n| deterministic precedence | priority, specificity, deny, order | rule dependent | role grant dependent |\n| decision tracing | `trace()` candidates and winner | manual inspection | manual inspection |\n\n<div class=\"decision callout\">\n\n**use ward when** your application needs typed role/resource/action policies with explainable, deterministic outcomes.\n\n**consider framework specific authorization when** your application only needs one framework's built in route or component guard layer.\n\n</div>\n\n## installation\n\n::: code group\n\n```sh [pnpm]\npnpm add @vielzeug/ward\n```\n\n```sh [npm]\nnpm install @vielzeug/ward\n```\n\n```sh [yarn]\nyarn add @vielzeug/ward\n```\n\n:::\n\n## quick start\n\ncreate a small policy and handle both allowed and denied decisions at the request boundary.\n\n```ts\nimport { allow, createward } from '@vielzeug/ward';\n\nconst ward = createward([\n allow('viewer', 'posts', ['read']),\n allow('editor', 'posts', ['update']),\n]);\n\nconst decision = ward.explain({\n principal: { id: 'u1', roles: ['editor'] },\n resource: 'posts',\n action: 'update',\n});\n\nif (decision.allowed) console.log('update post');\nelse console.log(decision.reason);\n```\n\n## features\n\n<div class=\"features grid\">\n\n `createward()` creates immutable typed policy instances. accepts `allow()`/`deny()` results directly — no spread needed.\n `allow()`, `deny()`, and `rulefor()` build role/resource/action rules.\n `wildcard` and `anonymous` model broad or unauthenticated access explicitly.\n `owns()` and `predicate` constrain rules with synchronous request data.\n `explain()`, `trace()`, and `detectconflicts()` make policy decisions diagnosable.\n `foruser()` creates a principal bound view for repeated checks.\n `checkall()` evaluates multiple resource/action pairs in one call.\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 [wayfinder](/wayfinder/) — route middleware can enforce ward decisions during navigation.\n [conduit](/conduit/) — inject a ward policy into application services.\n [herald](/herald/) — publish authorization outcomes as typed application events.\n\n</div>\n\n<! markdownlint enable md025 md033 md060 >\n",
|
|
1433
|
-
"api": " \ntitle: ward — api reference\ndescription: complete api reference for @vielzeug/ward.\n \n\n[[toc]]\n\n## api overview\n\n| symbol | purpose | execution mode | common gotcha |\n| | | | |\n| `createward` | creates immutable policy | sync | rules cannot be mutated after creation |\n| `allow` / `deny` / `rulefor` | builds policy rules | sync | priority wins before specificity |\n| `ward.explain` | returns one decision | sync | pass resource data for predicate rules |\n| `ward.trace` | inspects decision candidates | sync | does not invoke the logger |\n| `ward.foruser` | binds a principal | sync | rebind when identity or roles change |\n| `ward.checkall` | batch permission checks | sync | pass resource data for predicate rules |\n| `ward.allowedactions` | filters known actions to allowed set | sync | does not invoke the logger |\n| `ward.rulesinscope` | lists rules matching a principal/resource | sync | pass data to evaluate predicates |\n| `ward.detectconflicts` | detects duplicate/shadowed rules | sync | o(n²) — use `maxconflicts` for large policies |\n| `predicate.owns` / `owns` | ownership predicate on resource data | sync | skipped for anonymous principals |\n| `predicate.and` / `or` / `not` | combine predicates | sync | all inputs must be synchronous |\n| `matchespattern` / `patterncovers` | test resource pattern coverage | sync | `'*'` is the only wildcard |\n\n## package entry point\n\n| import | purpose |\n| | |\n| `@vielzeug/ward` | rules, factory, predicates, pattern helpers, errors, and public types |\n| `@vielzeug/ward/devtools` | `debugward()` diagnostic factory |\n\n## core factory\n\n### `createward(rules, options?)`\n\n```ts\ncreateward<taction extends string = string, tdata = unknown>(\n rules: readonly (wardrule<taction, tdata> | readonly wardrule<taction, tdata>[])[] = [],\n options?: wardoptions<taction, tdata>,\n): ward<taction, tdata>;\n```\n\ncreates an immutable ward instance. `rules` accepts a flat mix of single rules and rule arrays — `allow()`/`deny()`/`rulefor()` results can be passed directly without spread. validates `logger`, `onconflict`, and `maxconflicts` options before compiling rules; invalid values throw `wardconfigerror`.\n\n**parameters:**\n\n| name | type | description |\n| | | |\n| `rules` | `readonly (wardrule \\| readonly wardrule[])[]` | rule list. single rules and rule arrays can be mixed. |\n| `options.logger` | `(ctx: wardloggercontext) => void` | called for `explain()` and `checkall()` decisions. |\n| `options.onconflict` | `(conflict: wardconflict) => void` | called synchronously per conflict at creation time. |\n| `options.strict` | `boolean` | throws `wardconfigerror` on the first conflict. |\n| `options.maxconflicts` | `number` | caps the number of conflicts returned by `detectconflicts()`. |\n\n**returns:** `ward<taction, tdata>` — an immutable policy instance.\n\n**example:**\n\n```ts\nimport { allow, createward, deny, wildcard } from '@vielzeug/ward';\n\nconst ward = createward([\n allow('viewer', 'posts', ['read']),\n allow('editor', 'posts', ['update']),\n deny('blocked', wildcard, [wildcard], { priority: 100 }),\n]);\n```\n\n \n\n## rule builders\n\n### `allow(role, resource, actions, options?)`\n\n```ts\nallow<taction extends string = string, tdata = unknown>(\n role: string | readonly string[],\n resource: string | typeof wildcard,\n actions: readonly (taction | typeof wildcard)[],\n options?: { priority?: number; when?: wardpredicate<tdata> },\n): wardrule<taction, tdata>[];\n```\n\ncreates one `wardrule` per action with `effect: 'allow'`. reads naturally: \"allow editor to read/update posts\".\n\n**returns:** `wardrule[]` — one rule per action.\n\n \n\n### `deny(role, resource, actions, options?)`\n\n```ts\ndeny<taction extends string = string, tdata = unknown>(\n role: string | readonly string[],\n resource: string | typeof wildcard,\n actions: readonly (taction | typeof wildcard)[],\n options?: { priority?: number; when?: wardpredicate<tdata> },\n): wardrule<taction, tdata>[];\n```\n\ncreates one `wardrule` per action with `effect: 'deny'`. reads naturally: \"deny blocked from reading posts\".\n\n**returns:** `wardrule[]` — one rule per action.\n\n \n\n### `rulefor(effect, role, resource, actions, options?)`\n\n```ts\nrulefor<taction extends string = string, tdata = unknown>(\n effect: 'allow' | 'deny',\n role: string | readonly string[],\n resource: string | typeof wildcard,\n actions: readonly (taction | typeof wildcard)[],\n options?: { priority?: number; when?: wardpredicate<tdata> },\n): wardrule<taction, tdata>[];\n```\n\nlow level factory. prefer `allow()` or `deny()` for ergonomic rule authoring.\n\n**returns:** `wardrule[]` — one rule per action.\n\n \n\n## ward methods\n\n### `checkall(principal, checks)`\n\n```ts\ncheckall(\n principal: principal,\n checks: readonly wardcheck<taction, tdata>[],\n): warddecisionresult<taction, tdata>[];\n```\n\nevaluates multiple resource/action pairs for one principal. invokes the logger for each decision.\n\n**returns:** `warddecisionresult[]` — each entry carries `action`, `resource`, and the decision.\n\n \n\n### `explain(input)`\n\n```ts\nexplain(input: warddecisioninput<taction, tdata>): warddecision<taction, tdata>;\n```\n\n`warddecisioninput`:\n\n```ts\n{\n principal: principal;\n resource: string;\n action: taction;\n data?: tdata;\n}\n```\n\nreturns one decision. invokes the logger.\n\n**returns:** `warddecision` — `{ allowed: true; rule }` or `{ allowed: false; reason: 'explicit deny'; rule }` or `{ allowed: false; reason: 'no matching rule' }`.\n\n \n\n### `trace(input)`\n\n```ts\ntrace(input: warddecisioninput<taction, tdata>): wardtrace<taction, tdata>;\n```\n\nsame request shape as `explain()`. returns winner + candidate list. does not fire the logger.\n\n**returns:** `wardtrace` — `{ candidates: wardtracecandidate[]; decision: warddecision }`.\n\n \n\n### `allowedactions(input)`\n\n```ts\nallowedactions(input: wardallowedactionsinput<taction, tdata>): taction[];\n```\n\ninput shape:\n\n```ts\n{\n principal: principal;\n resource: string;\n knownactions: readonly taction[];\n data?: tdata;\n}\n```\n\nfilters the provided `knownactions` list to those the principal may perform. does not invoke the logger.\n\n**returns:** `taction[]` — the subset of `knownactions` that `explain()` would allow.\n\n \n\n### `rulesinscope(input)`\n\n```ts\nrulesinscope(input: wardrulesinscopeinput<tdata>): readonlyarray<readonly<normalizedwardrule<taction, tdata>>>;\n```\n\ninput shape:\n\n```ts\n{\n principal: principal;\n resource: string;\n data?: tdata;\n}\n```\n\nlists rules matching the principal/resource pair. pass `data` to evaluate predicate gated matches; without it, predicate rules are skipped.\n\n**returns:** `readonlyarray<readonly<normalizedwardrule>>` — rules in their normalized form (`role` always array, `priority` always number).\n\n \n\n### `detectconflicts()`\n\n```ts\ndetectconflicts(): readonly wardconflict<taction, tdata>[];\n```\n\nlazily computes and caches duplicate/shadowed rule conflicts. o(n²) — use `maxconflicts` for large policies.\n\n**returns:** `readonly wardconflict[]` — `{ kind: 'duplicate'; indexa; indexb; rulea; ruleb }` or `{ kind: 'shadowed'; shadowedindex; shadowedrule; shadowingindex; shadowingrule }`.\n\n \n\n### `foruser(principal)`\n\n```ts\nforuser(principal: userprincipal): boundward<taction, tdata>;\n```\n\nreturns a principal bound view. `userprincipal` (not nullable — use `null` directly with `explain()` for anonymous).\n\n**returns:** `boundward` — same methods without the `principal` argument.\n\n \n\n## `boundward` methods\n\n```ts\ntype boundward<taction extends string = string, tdata = unknown> = {\n allowedactions(input: boundwardallowedactionsinput<taction, tdata>): taction[];\n checkall(checks: readonly wardcheck<taction, tdata>[]): warddecisionresult<taction, tdata>[];\n explain(input: boundwarddecisioninput<taction, tdata>): warddecision<taction, tdata>;\n rulesinscope(input: boundwardrulesinscopeinput<tdata>): readonlyarray<readonly<normalizedwardrule<taction, tdata>>>;\n trace(input: boundwarddecisioninput<taction, tdata>): wardtrace<taction, tdata>;\n};\n```\n\nbound input shapes remove `principal`:\n\n```ts\n{ resource: string; action: taction; data?: tdata } // explain/trace\n{ resource: string; knownactions: readonly taction[]; data?: tdata } // allowedactions\n{ resource: string; data?: tdata } // rulesinscope\n```\n\n \n\n## predicate helpers\n\n### `predicate.owns(attributekey)`\n\n```ts\npredicate.owns<tdata = unknown>(\n attributekey: [keyof tdata] extends [never] ? string : keyof tdata & string,\n): wardpredicate<tdata>;\n```\n\nreturns a `wardpredicate` that checks whether `data[attributekey]` matches `principal.id`. skipped for anonymous principals — pairing `owns` with an `anonymous` role rule produces a rule that can never match.\n\n**returns:** `wardpredicate<tdata>`.\n\n \n\n### `predicate.and(...predicates)`\n\n```ts\npredicate.and<tdata = unknown>(...preds: wardpredicate<tdata>[]): wardpredicate<tdata>;\n```\n\nall predicates must return `true`.\n\n \n\n### `predicate.or(...predicates)`\n\n```ts\npredicate.or<tdata = unknown>(...preds: wardpredicate<tdata>[]): wardpredicate<tdata>;\n```\n\nat least one predicate must return `true`.\n\n \n\n### `predicate.not(predicate)`\n\n```ts\npredicate.not<tdata = unknown>(pred: wardpredicate<tdata>): wardpredicate<tdata>;\n```\n\ninverts the given predicate.\n\n \n\n### `owns(attributekey)` (alias)\n\n```ts\nowns<tdata = unknown>(\n attributekey: [keyof tdata] extends [never] ? string : keyof tdata & string,\n): wardpredicate<tdata>;\n```\n\ntop level re export of `predicate.owns`.\n\npredicates run synchronously. returning a promise throws `wardpredicateerror`.\n\n \n\n## pattern helpers\n\n### `matchespattern(pattern, value): boolean`\n\n```ts\nmatchespattern(pattern: string, value: string): boolean;\n```\n\ntests whether `value` matches a `'*'` wildcard `pattern`. `'*'` matches any value; an exact string matches only itself.\n\n \n\n### `patterncovers(broad, narrow): boolean`\n\n```ts\npatterncovers(broad: string, narrow: string): boolean;\n```\n\ntests whether the `broad` pattern covers the `narrow` pattern. `'*'` covers everything; an exact string covers only itself.\n\n \n\n## devtools\n\n### `debugward(rules, options?)`\n\nsub path import: `@vielzeug/ward/devtools`.\n\n```ts\nimport { debugward } from '@vielzeug/ward/devtools';\n```\n\ndiagnostic factory for development inspection.\n\n \n\n## types\n\n```ts\nexport type userprincipal = {\n attributes?: record<string, unknown>;\n id: string;\n roles: readonly string[];\n};\n\nexport type principal = userprincipal | null;\n\nexport type rulecontext<tdata = unknown> = {\n data?: tdata;\n principal: userprincipal;\n};\n\nexport type wardpredicate<tdata = unknown> = (ctx: rulecontext<tdata>) => boolean;\n\nexport type wardrule<taction extends string = string, tdata = unknown> = {\n action: taction | typeof wildcard;\n effect: 'allow' | 'deny';\n priority?: number;\n resource: string | typeof wildcard;\n role: string | readonly string[];\n when?: wardpredicate<tdata>;\n};\n\nexport type normalizedwardrule<taction extends string = string, tdata = unknown> = readonly<{\n action: taction | typeof wildcard;\n effect: 'allow' | 'deny';\n priority: number;\n resource: string | typeof wildcard;\n role: readonly string[];\n when?: wardpredicate<tdata>;\n}>;\n\nexport type warddecision<taction extends string = string, tdata = unknown> =\n | { allowed: true; rule: readonly<normalizedwardrule<taction, tdata>> }\n | { allowed: false; reason: 'explicit deny'; rule: readonly<normalizedwardrule<taction, tdata>> }\n | { allowed: false; reason: 'no matching rule' };\n\nexport type wardcheck<taction extends string = string, tdata = unknown> = {\n action: taction;\n data?: tdata;\n resource: string;\n};\n\nexport type warddecisionresult<taction extends string = string, tdata = unknown> = warddecision<taction, tdata> & {\n action: taction;\n resource: string;\n};\n\nexport type warddecisioninput<taction extends string = string, tdata = unknown> = {\n action: taction;\n data?: tdata;\n principal: principal;\n resource: string;\n};\n\nexport type wardallowedactionsinput<taction extends string = string, tdata = unknown> = {\n data?: tdata;\n knownactions: readonly taction[];\n principal: principal;\n resource: string;\n};\n\nexport type wardrulesinscopeinput<tdata = unknown> = {\n data?: tdata;\n principal: principal;\n resource: string;\n};\n\nexport type boundwarddecisioninput<taction extends string = string, tdata = unknown> = {\n action: taction;\n data?: tdata;\n resource: string;\n};\n\nexport type boundwardallowedactionsinput<taction extends string = string, tdata = unknown> = {\n data?: tdata;\n knownactions: readonly taction[];\n resource: string;\n};\n\nexport type boundwardrulesinscopeinput<tdata = unknown> = {\n data?: tdata;\n resource: string;\n};\n\nexport type conflictkind = 'duplicate' | 'shadowed';\n\nexport type wardconflict<taction extends string = string, tdata = unknown> =\n | {\n indexa: number;\n indexb: number;\n kind: 'duplicate';\n rulea: readonly<normalizedwardrule<taction, tdata>>;\n ruleb: readonly<normalizedwardrule<taction, tdata>>;\n }\n | {\n kind: 'shadowed';\n shadowedindex: number;\n shadowedrule: readonly<normalizedwardrule<taction, tdata>>;\n shadowingindex: number;\n shadowingrule: readonly<normalizedwardrule<taction, tdata>>;\n };\n\nexport type wardtracecandidate<taction extends string = string, tdata = unknown> = {\n index: number;\n priority: number;\n rule: readonly<normalizedwardrule<taction, tdata>>;\n score: number;\n won: boolean;\n};\n\nexport type wardtrace<taction extends string = string, tdata = unknown> = {\n candidates: wardtracecandidate<taction, tdata>[];\n decision: warddecision<taction, tdata>;\n};\n\nexport type ward<taction extends string = string, tdata = unknown> = {\n allowedactions(input: wardallowedactionsinput<taction, tdata>): taction[];\n checkall(principal: principal, checks: readonly wardcheck<taction, tdata>[]): warddecisionresult<taction, tdata>[];\n detectconflicts(): readonly wardconflict<taction, tdata>[];\n explain(input: warddecisioninput<taction, tdata>): warddecision<taction, tdata>;\n foruser(principal: userprincipal): boundward<taction, tdata>;\n rulesinscope(input: wardrulesinscopeinput<tdata>): readonlyarray<readonly<normalizedwardrule<taction, tdata>>>;\n trace(input: warddecisioninput<taction, tdata>): wardtrace<taction, tdata>;\n};\n\nexport type boundward<taction extends string = string, tdata = unknown> = {\n allowedactions(input: boundwardallowedactionsinput<taction, tdata>): taction[];\n checkall(checks: readonly wardcheck<taction, tdata>[]): warddecisionresult<taction, tdata>[];\n explain(input: boundwarddecisioninput<taction, tdata>): warddecision<taction, tdata>;\n rulesinscope(input: boundwardrulesinscopeinput<tdata>): readonlyarray<readonly<normalizedwardrule<taction, tdata>>>;\n trace(input: boundwarddecisioninput<taction, tdata>): wardtrace<taction, tdata>;\n};\n\nexport type wardloggercontext<taction extends string = string, tdata = unknown> = warddecision<taction, tdata> & {\n action: taction;\n data?: tdata;\n principal: principal;\n resource: string;\n};\n\nexport type wardoptions<taction extends string = string, tdata = unknown> = {\n logger?: (context: wardloggercontext<taction, tdata>) => void;\n maxconflicts?: number;\n onconflict?: (conflict: wardconflict<taction, tdata>) => void;\n strict?: boolean;\n};\n```\n\n`warddecision`, `warddecisionresult`, `wardtrace`, `wardtracecandidate`, and `wardconflict` reference `normalizedwardrule` (always array `role`, always number `priority`).\n\n`ward`, `boundward`, `warddecision`, `warddecisionresult`, `wardtrace`, `wardtracecandidate`, `wardconflict`,\n`normalizedwardrule`, `wardoptions`, `wardcheck`, `wardallowedactionsinput`, `wardrulesinscopeinput`, `rulecontext`,\n`wardloggercontext`, `wardpredicate`, and `conflictkind` are exported from the root entry point.\n\n## errors\n\n `warderror` is the base error class; use `warderror.is(value)` for narrowing.\n `wardconfigerror` reports malformed rules, invalid `createward` options (`logger`, `onconflict`, `maxconflicts`), invalid principals, and strict conflict initialization.\n `wardpredicateerror` reports a throwing synchronous predicate and includes its `ruleindex` and cause.\n",
|
|
1434
|
-
"usage": " \ntitle: ward — usage guide\ndescription: build deterministic authorization policies with immutable rule sets, wildcard support, and runtime predicates.\n \n\n[[toc]]\n\n## basic usage\n\n```ts\nimport { wildcard, allow, createward, deny } from '@vielzeug/ward';\n\nconst ward = createward([\n allow('viewer', 'posts', ['read']),\n allow('editor', 'posts', ['update']),\n deny('blocked', 'posts', [wildcard], { priority: 100 }),\n]);\n```\n\n`allow()`, `deny()`, and `rulefor()` return `wardrule[]` (one rule per action). pass them directly to `createward` — no spread needed. rules are immutable after creation. create a new ward to update policy.\n\n## explain a decision\n\n```ts\nconst decision = ward.explain({\n principal: { id: 'u1', roles: ['editor'] },\n resource: 'posts',\n action: 'update',\n data: { authorid: 'u1' },\n});\n\nif (decision.allowed) {\n console.log(decision.rule);\n} else {\n console.log(decision.reason); // 'no matching rule' | 'explicit deny'\n}\n```\n\n## batch decisions\n\n```ts\nconst results = ward.checkall({ id: 'u1', roles: ['editor'] }, [\n { resource: 'posts', action: 'read' },\n { resource: 'posts', action: 'update', data: { authorid: 'u1' } },\n]);\n```\n\n## bound ward (`foruser`)\n\n```ts\nconst bound = ward.foruser({ id: 'u1', roles: ['editor'] });\n\nbound.explain({ resource: 'posts', action: 'read' });\nbound.trace({ resource: 'posts', action: 'update', data: { authorid: 'u1' } });\nbound.rulesinscope({ resource: 'posts' });\nbound.allowedactions({ resource: 'posts', knownactions: ['read', 'update', 'delete'] as const });\n```\n\n`foruser()` snapshots the principal. re bind when roles/identity change.\n\n## allowed actions\n\n`allowedactions()` evaluates a provided action set:\n\n```ts\nconst actions = ward.allowedactions({\n principal: { id: 'u1', roles: ['admin'] },\n resource: 'posts',\n knownactions: ['read', 'update', 'delete'] as const,\n});\n```\n\nit does not fire
|
|
1435
|
-
"examples": " \ntitle: ward — examples\ndescription: practical examples and recipes for ward.\n \n\n## examples\n\n [blog roles](./examples/blog roles.md)\n [multi role rules](./examples/multi role rules.md)\n [wildcard action](./examples/wildcard action.md)\n [priority and overrides](./examples/inheritance and overrides.md)\n [bound guard in ui layer](./examples/bound guard in ui layer.md)\n [rule specificity](./examples/disabling wildcard fallback.md)\n [
|
|
1454
|
+
"index": " \ntitle: ward — deterministic authorization for typescript\ndescription: typed authorization policies with wildcard matching, deterministic precedence, and decision tracing.\npackage: ward\ncategory: auth\nkeywords: [authorization, rbac, permissions, policy, roles, wildcard, predicates]\nrelated: [wayfinder, conduit, herald]\nexports: [createward, allow, deny, rulefor, owns, predicate, anonymous, wildcard, warderror, wardconfigerror, wardpredicateerror, normalizedwardrule, matchespattern, patterncovers]\nenvironments: [browser, node, ssr, deno]\n \n\n<! markdownlint disable md025 md033 md060 >\n\n<packagehero package=\"ward\" />\n\n## why ward?\n\nward keeps authorization policies declarative and decision ordering deterministic. define rules once, then explain or trace every permission decision without embedding role checks across handlers.\n\n```ts\n// before\nconst canupdate = user.roles.includes('editor') && post.authorid === user.id;\n\n// after\nimport { allow, createward, owns } from '@vielzeug/ward';\n\nconst ward = createward([\n allow('editor', 'posts', ['update'], { when: owns('authorid') }),\n]);\n\nconst decision = ward.explain({ principal: user, resource: 'posts', action: 'update', data: post });\nconst canupdate = decision.allowed;\n```\n\n| feature | ward | casl | accesscontrol |\n| | | | |\n| bundle size | <packageinfo package=\"ward\" type=\"size\" /> | larger policy engine | larger policy engine |\n| zero dependencies | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> |\n| deterministic precedence | priority, specificity, deny, order | rule dependent | role grant dependent |\n| decision tracing | `trace()` candidates and winner | manual inspection | manual inspection |\n\n<div class=\"decision callout\">\n\n**use ward when** your application needs typed role/resource/action policies with explainable, deterministic outcomes.\n\n**consider framework specific authorization when** your application only needs one framework's built in route or component guard layer.\n\n</div>\n\n## installation\n\n::: code group\n\n```sh [pnpm]\npnpm add @vielzeug/ward\n```\n\n```sh [npm]\nnpm install @vielzeug/ward\n```\n\n```sh [yarn]\nyarn add @vielzeug/ward\n```\n\n:::\n\n## quick start\n\ncreate a small policy and handle both allowed and denied decisions at the request boundary.\n\n```ts\nimport { allow, createward } from '@vielzeug/ward';\n\nconst ward = createward([\n allow('viewer', 'posts', ['read']),\n allow('editor', 'posts', ['update']),\n]);\n\nconst decision = ward.explain({\n principal: { id: 'u1', roles: ['editor'] },\n resource: 'posts',\n action: 'update',\n});\n\nif (decision.allowed) console.log('update post');\nelse console.log(decision.reason);\n```\n\n## features\n\n<div class=\"features grid\">\n\n `createward()` creates immutable typed policy instances. accepts `allow()`/`deny()` results directly — no spread needed.\n `allow()`, `deny()`, and `rulefor()` build role/resource/action rules.\n `wildcard` and `anonymous` model broad or unauthenticated access explicitly.\n `owns()` and `predicate` constrain rules with synchronous request data.\n `explain()`, `trace()`, and `detectconflicts()` make policy decisions diagnosable.\n `tap()` subscribes to decision events for logging and diagnostics.\n `foruser()` creates a principal bound view for repeated checks.\n `checkall()` evaluates multiple resource/action pairs in one call.\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 [wayfinder](/wayfinder/) — route middleware can enforce ward decisions during navigation.\n [conduit](/conduit/) — inject a ward policy into application services.\n [herald](/herald/) — publish authorization outcomes as typed application events.\n\n</div>\n\n<! markdownlint enable md025 md033 md060 >\n",
|
|
1455
|
+
"api": " \ntitle: ward — api reference\ndescription: complete api reference for @vielzeug/ward.\n \n\n[[toc]]\n\n## api overview\n\n| symbol | purpose | execution mode | common gotcha |\n| | | | |\n| `createward` | creates immutable policy | sync | rules cannot be mutated after creation |\n| `allow` / `deny` / `rulefor` | builds policy rules | sync | priority wins before specificity |\n| `ward.explain` | returns one decision | sync | pass resource data for predicate rules |\n| `ward.trace` | inspects decision candidates | sync | does not fire a `decision` event |\n| `ward.foruser` | binds a principal | sync | rebind when identity or roles change |\n| `ward.checkall` | batch permission checks | sync | pass resource data for predicate rules |\n| `ward.allowedactions` | filters known actions to allowed set | sync | does not fire a `decision` event |\n| `ward.rulesinscope` | lists rules matching a principal/resource | sync | pass data to evaluate predicates |\n| `ward.detectconflicts` | detects duplicate/shadowed rules | sync | o(n²) — use `maxconflicts` for large policies |\n| `predicate.owns` / `owns` | ownership predicate on resource data | sync | skipped for anonymous principals |\n| `predicate.and` / `or` / `not` | combine predicates | sync | all inputs must be synchronous |\n| `matchespattern` / `patterncovers` | test resource pattern coverage | sync | `'*'` is the only wildcard |\n\n## package entry point\n\n| import | purpose |\n| | |\n| `@vielzeug/ward` | rules, factory, predicates, pattern helpers, errors, and public types |\n\n## core factory\n\n### `createward(rules, options?)`\n\n```ts\ncreateward<taction extends string = string, tdata = unknown>(\n rules: readonly (wardrule<taction, tdata> | readonly wardrule<taction, tdata>[])[] = [],\n options?: wardoptions<taction, tdata>,\n): ward<taction, tdata>;\n```\n\ncreates an immutable ward instance. `rules` accepts a flat mix of single rules and rule arrays — `allow()`/`deny()`/`rulefor()` results can be passed directly without spread. validates `onconflict` and `maxconflicts` options before compiling rules; invalid values throw `wardconfigerror`.\n\n**parameters:**\n\n| name | type | description |\n| | | |\n| `rules` | `readonly (wardrule \\| readonly wardrule[])[]` | rule list. single rules and rule arrays can be mixed. |\n| `options.onconflict` | `(conflict: wardconflict) => void` | called synchronously per conflict at creation time. |\n| `options.strict` | `boolean` | throws `wardconfigerror` on the first conflict. |\n| `options.maxconflicts` | `number` | caps the number of conflicts returned by `detectconflicts()`. |\n\n**returns:** `ward<taction, tdata>` — an immutable policy instance.\n\n**example:**\n\n```ts\nimport { allow, createward, deny, wildcard } from '@vielzeug/ward';\n\nconst ward = createward([\n allow('viewer', 'posts', ['read']),\n allow('editor', 'posts', ['update']),\n deny('blocked', wildcard, [wildcard], { priority: 100 }),\n]);\n```\n\n \n\n## rule builders\n\n### `allow(role, resource, actions, options?)`\n\n```ts\nallow<taction extends string = string, tdata = unknown>(\n role: string | readonly string[],\n resource: string | typeof wildcard,\n actions: readonly (taction | typeof wildcard)[],\n options?: { priority?: number; when?: wardpredicate<tdata> },\n): wardrule<taction, tdata>[];\n```\n\ncreates one `wardrule` per action with `effect: 'allow'`. reads naturally: \"allow editor to read/update posts\".\n\n**returns:** `wardrule[]` — one rule per action.\n\n \n\n### `deny(role, resource, actions, options?)`\n\n```ts\ndeny<taction extends string = string, tdata = unknown>(\n role: string | readonly string[],\n resource: string | typeof wildcard,\n actions: readonly (taction | typeof wildcard)[],\n options?: { priority?: number; when?: wardpredicate<tdata> },\n): wardrule<taction, tdata>[];\n```\n\ncreates one `wardrule` per action with `effect: 'deny'`. reads naturally: \"deny blocked from reading posts\".\n\n**returns:** `wardrule[]` — one rule per action.\n\n \n\n### `rulefor(effect, role, resource, actions, options?)`\n\n```ts\nrulefor<taction extends string = string, tdata = unknown>(\n effect: 'allow' | 'deny',\n role: string | readonly string[],\n resource: string | typeof wildcard,\n actions: readonly (taction | typeof wildcard)[],\n options?: { priority?: number; when?: wardpredicate<tdata> },\n): wardrule<taction, tdata>[];\n```\n\nlow level factory. prefer `allow()` or `deny()` for ergonomic rule authoring.\n\n**returns:** `wardrule[]` — one rule per action.\n\n \n\n## ward methods\n\n### `checkall(principal, checks)`\n\n```ts\ncheckall(\n principal: principal,\n checks: readonly wardcheck<taction, tdata>[],\n): warddecisionresult<taction, tdata>[];\n```\n\nevaluates multiple resource/action pairs for one principal. fires a `decision` event for each result via `tap()`.\n\n**returns:** `warddecisionresult[]` — each entry carries `action`, `resource`, and the decision.\n\n \n\n### `explain(input)`\n\n```ts\nexplain(input: warddecisioninput<taction, tdata>): warddecision<taction, tdata>;\n```\n\n`warddecisioninput`:\n\n```ts\n{\n principal: principal;\n resource: string;\n action: taction;\n data?: tdata;\n}\n```\n\nreturns one decision. fires a `decision` event via `tap()`.\n\n**returns:** `warddecision` — `{ allowed: true; rule }` or `{ allowed: false; reason: 'explicit deny'; rule }` or `{ allowed: false; reason: 'no matching rule' }`.\n\n \n\n### `trace(input)`\n\n```ts\ntrace(input: warddecisioninput<taction, tdata>): wardtrace<taction, tdata>;\n```\n\nsame request shape as `explain()`. returns winner + candidate list. does not fire a `decision` event.\n\n**returns:** `wardtrace` — `{ candidates: wardtracecandidate[]; decision: warddecision }`.\n\n \n\n### `allowedactions(input)`\n\n```ts\nallowedactions(input: wardallowedactionsinput<taction, tdata>): taction[];\n```\n\ninput shape:\n\n```ts\n{\n principal: principal;\n resource: string;\n knownactions: readonly taction[];\n data?: tdata;\n}\n```\n\nfilters the provided `knownactions` list to those the principal may perform. does not fire a `decision` event.\n\n**returns:** `taction[]` — the subset of `knownactions` that `explain()` would allow.\n\n \n\n### `rulesinscope(input)`\n\n```ts\nrulesinscope(input: wardrulesinscopeinput<tdata>): readonlyarray<readonly<normalizedwardrule<taction, tdata>>>;\n```\n\ninput shape:\n\n```ts\n{\n principal: principal;\n resource: string;\n data?: tdata;\n}\n```\n\nlists rules matching the principal/resource pair. pass `data` to evaluate predicate gated matches; without it, predicate rules are skipped.\n\n**returns:** `readonlyarray<readonly<normalizedwardrule>>` — rules in their normalized form (`role` always array, `priority` always number).\n\n \n\n### `detectconflicts()`\n\n```ts\ndetectconflicts(): readonly wardconflict<taction, tdata>[];\n```\n\nlazily computes and caches duplicate/shadowed rule conflicts. o(n²) — use `maxconflicts` for large policies.\n\n**returns:** `readonly wardconflict[]` — `{ kind: 'duplicate'; indexa; indexb; rulea; ruleb }` or `{ kind: 'shadowed'; shadowedindex; shadowedrule; shadowingindex; shadowingrule }`.\n\n \n\n### `foruser(principal)`\n\n```ts\nforuser(principal: userprincipal): boundward<taction, tdata>;\n```\n\nreturns a principal bound view. `userprincipal` (not nullable — use `null` directly with `explain()` for anonymous).\n\n**returns:** `boundward` — same methods without the `principal` argument.\n\n \n\n## `boundward` methods\n\n```ts\ntype boundward<taction extends string = string, tdata = unknown> = {\n allowedactions(input: boundwardallowedactionsinput<taction, tdata>): taction[];\n checkall(checks: readonly wardcheck<taction, tdata>[]): warddecisionresult<taction, tdata>[];\n explain(input: boundwarddecisioninput<taction, tdata>): warddecision<taction, tdata>;\n rulesinscope(input: boundwardrulesinscopeinput<tdata>): readonlyarray<readonly<normalizedwardrule<taction, tdata>>>;\n trace(input: boundwarddecisioninput<taction, tdata>): wardtrace<taction, tdata>;\n};\n```\n\nbound input shapes remove `principal`:\n\n```ts\n{ resource: string; action: taction; data?: tdata } // explain/trace\n{ resource: string; knownactions: readonly taction[]; data?: tdata } // allowedactions\n{ resource: string; data?: tdata } // rulesinscope\n```\n\n \n\n## predicate helpers\n\n### `predicate.owns(attributekey)`\n\n```ts\npredicate.owns<tdata = unknown>(\n attributekey: [keyof tdata] extends [never] ? string : keyof tdata & string,\n): wardpredicate<tdata>;\n```\n\nreturns a `wardpredicate` that checks whether `data[attributekey]` matches `principal.id`. skipped for anonymous principals — pairing `owns` with an `anonymous` role rule produces a rule that can never match.\n\n**returns:** `wardpredicate<tdata>`.\n\n \n\n### `predicate.and(...predicates)`\n\n```ts\npredicate.and<tdata = unknown>(...preds: wardpredicate<tdata>[]): wardpredicate<tdata>;\n```\n\nall predicates must return `true`.\n\n \n\n### `predicate.or(...predicates)`\n\n```ts\npredicate.or<tdata = unknown>(...preds: wardpredicate<tdata>[]): wardpredicate<tdata>;\n```\n\nat least one predicate must return `true`.\n\n \n\n### `predicate.not(predicate)`\n\n```ts\npredicate.not<tdata = unknown>(pred: wardpredicate<tdata>): wardpredicate<tdata>;\n```\n\ninverts the given predicate.\n\n \n\n### `owns(attributekey)` (alias)\n\n```ts\nowns<tdata = unknown>(\n attributekey: [keyof tdata] extends [never] ? string : keyof tdata & string,\n): wardpredicate<tdata>;\n```\n\ntop level re export of `predicate.owns`.\n\npredicates run synchronously. returning a promise throws `wardpredicateerror`.\n\n \n\n## pattern helpers\n\n### `matchespattern(pattern, value): boolean`\n\n```ts\nmatchespattern(pattern: string, value: string): boolean;\n```\n\ntests whether `value` matches a `'*'` wildcard `pattern`. `'*'` matches any value; an exact string matches only itself.\n\n \n\n### `patterncovers(broad, narrow): boolean`\n\n```ts\npatterncovers(broad: string, narrow: string): boolean;\n```\n\ntests whether the `broad` pattern covers the `narrow` pattern. `'*'` covers everything; an exact string covers only itself.\n\n \n\n## observability\n\n### `tap(handler, options?)`\n\n```ts\ntap(\n handler: (event: wardevent<taction, tdata>) => void,\n options?: { signal?: abortsignal },\n): () => void;\n```\n\nsubscribes a handler to ward events. each `explain()` and `checkall()` decision fires a `decision` event. `trace()` and `allowedactions()` do not fire events.\n\npass an `abortsignal` to unsubscribe automatically; the returned function unsubscribes manually.\n\n**returns:** `() => void` — call to unsubscribe the handler.\n\n**example:**\n\n```ts\nconst ward = createward(rules);\nward.tap((event) => console.debug(`ward:${event.type}`, event.decision));\n```\n\nwith a logger from `@vielzeug/rune`:\n\n```ts\nimport { createlogger } from '@vielzeug/rune';\nconst log = createlogger({ name: 'ward' });\nward.tap((event) => log.debug(event, 'ward:decision'));\n```\n\n \n\n## types\n\n```ts\nexport type userprincipal = {\n attributes?: record<string, unknown>;\n id: string;\n roles: readonly string[];\n};\n\nexport type principal = userprincipal | null;\n\nexport type rulecontext<tdata = unknown> = {\n data?: tdata;\n principal: userprincipal;\n};\n\nexport type wardpredicate<tdata = unknown> = (ctx: rulecontext<tdata>) => boolean;\n\nexport type wardrule<taction extends string = string, tdata = unknown> = {\n action: taction | typeof wildcard;\n effect: 'allow' | 'deny';\n priority?: number;\n resource: string | typeof wildcard;\n role: string | readonly string[];\n when?: wardpredicate<tdata>;\n};\n\nexport type normalizedwardrule<taction extends string = string, tdata = unknown> = readonly<{\n action: taction | typeof wildcard;\n effect: 'allow' | 'deny';\n priority: number;\n resource: string | typeof wildcard;\n role: readonly string[];\n when?: wardpredicate<tdata>;\n}>;\n\nexport type warddecision<taction extends string = string, tdata = unknown> =\n | { allowed: true; rule: readonly<normalizedwardrule<taction, tdata>> }\n | { allowed: false; reason: 'explicit deny'; rule: readonly<normalizedwardrule<taction, tdata>> }\n | { allowed: false; reason: 'no matching rule' };\n\nexport type wardcheck<taction extends string = string, tdata = unknown> = {\n action: taction;\n data?: tdata;\n resource: string;\n};\n\nexport type warddecisionresult<taction extends string = string, tdata = unknown> = warddecision<taction, tdata> & {\n action: taction;\n resource: string;\n};\n\nexport type warddecisioninput<taction extends string = string, tdata = unknown> = {\n action: taction;\n data?: tdata;\n principal: principal;\n resource: string;\n};\n\nexport type wardallowedactionsinput<taction extends string = string, tdata = unknown> = {\n data?: tdata;\n knownactions: readonly taction[];\n principal: principal;\n resource: string;\n};\n\nexport type wardrulesinscopeinput<tdata = unknown> = {\n data?: tdata;\n principal: principal;\n resource: string;\n};\n\nexport type boundwarddecisioninput<taction extends string = string, tdata = unknown> = {\n action: taction;\n data?: tdata;\n resource: string;\n};\n\nexport type boundwardallowedactionsinput<taction extends string = string, tdata = unknown> = {\n data?: tdata;\n knownactions: readonly taction[];\n resource: string;\n};\n\nexport type boundwardrulesinscopeinput<tdata = unknown> = {\n data?: tdata;\n resource: string;\n};\n\nexport type conflictkind = 'duplicate' | 'shadowed';\n\nexport type wardconflict<taction extends string = string, tdata = unknown> =\n | {\n indexa: number;\n indexb: number;\n kind: 'duplicate';\n rulea: readonly<normalizedwardrule<taction, tdata>>;\n ruleb: readonly<normalizedwardrule<taction, tdata>>;\n }\n | {\n kind: 'shadowed';\n shadowedindex: number;\n shadowedrule: readonly<normalizedwardrule<taction, tdata>>;\n shadowingindex: number;\n shadowingrule: readonly<normalizedwardrule<taction, tdata>>;\n };\n\nexport type wardtracecandidate<taction extends string = string, tdata = unknown> = {\n index: number;\n priority: number;\n rule: readonly<normalizedwardrule<taction, tdata>>;\n score: number;\n won: boolean;\n};\n\nexport type wardtrace<taction extends string = string, tdata = unknown> = {\n candidates: wardtracecandidate<taction, tdata>[];\n decision: warddecision<taction, tdata>;\n};\n\nexport type ward<taction extends string = string, tdata = unknown> = {\n allowedactions(input: wardallowedactionsinput<taction, tdata>): taction[];\n checkall(principal: principal, checks: readonly wardcheck<taction, tdata>[]): warddecisionresult<taction, tdata>[];\n detectconflicts(): readonly wardconflict<taction, tdata>[];\n explain(input: warddecisioninput<taction, tdata>): warddecision<taction, tdata>;\n foruser(principal: userprincipal): boundward<taction, tdata>;\n rulesinscope(input: wardrulesinscopeinput<tdata>): readonlyarray<readonly<normalizedwardrule<taction, tdata>>>;\n tap(handler: (event: wardevent<taction, tdata>) => void, options?: { signal?: abortsignal }): () => void;\n trace(input: warddecisioninput<taction, tdata>): wardtrace<taction, tdata>;\n};\n\nexport type boundward<taction extends string = string, tdata = unknown> = {\n allowedactions(input: boundwardallowedactionsinput<taction, tdata>): taction[];\n checkall(checks: readonly wardcheck<taction, tdata>[]): warddecisionresult<taction, tdata>[];\n explain(input: boundwarddecisioninput<taction, tdata>): warddecision<taction, tdata>;\n rulesinscope(input: boundwardrulesinscopeinput<tdata>): readonlyarray<readonly<normalizedwardrule<taction, tdata>>>;\n trace(input: boundwarddecisioninput<taction, tdata>): wardtrace<taction, tdata>;\n};\n\nexport type wardevent<taction extends string = string, tdata = unknown> = {\n type: 'decision';\n decision: warddecision<taction, tdata>;\n action: taction;\n data?: tdata;\n principal: principal;\n resource: string;\n};\n\nexport type wardoptions<taction extends string = string, tdata = unknown> = {\n maxconflicts?: number;\n onconflict?: (conflict: wardconflict<taction, tdata>) => void;\n strict?: boolean;\n};\n```\n\n`warddecision`, `warddecisionresult`, `wardtrace`, `wardtracecandidate`, and `wardconflict` reference `normalizedwardrule` (always array `role`, always number `priority`).\n\n`ward`, `boundward`, `warddecision`, `warddecisionresult`, `wardtrace`, `wardtracecandidate`, `wardconflict`,\n`normalizedwardrule`, `wardoptions`, `wardcheck`, `wardallowedactionsinput`, `wardrulesinscopeinput`, `rulecontext`,\n`wardevent`, `wardpredicate`, and `conflictkind` are exported from the root entry point.\n\n## errors\n\n `warderror` is the base error class; use `instanceof warderror` for narrowing.\n `wardconfigerror` reports malformed rules, invalid `createward` options (`onconflict`, `maxconflicts`), invalid principals, and strict conflict initialization.\n `wardpredicateerror` reports a throwing synchronous predicate and includes its `ruleindex` and cause.\n",
|
|
1456
|
+
"usage": " \ntitle: ward — usage guide\ndescription: build deterministic authorization policies with immutable rule sets, wildcard support, and runtime predicates.\n \n\n[[toc]]\n\n## basic usage\n\n```ts\nimport { wildcard, allow, createward, deny } from '@vielzeug/ward';\n\nconst ward = createward([\n allow('viewer', 'posts', ['read']),\n allow('editor', 'posts', ['update']),\n deny('blocked', 'posts', [wildcard], { priority: 100 }),\n]);\n```\n\n`allow()`, `deny()`, and `rulefor()` return `wardrule[]` (one rule per action). pass them directly to `createward` — no spread needed. rules are immutable after creation. create a new ward to update policy.\n\n## explain a decision\n\n```ts\nconst decision = ward.explain({\n principal: { id: 'u1', roles: ['editor'] },\n resource: 'posts',\n action: 'update',\n data: { authorid: 'u1' },\n});\n\nif (decision.allowed) {\n console.log(decision.rule);\n} else {\n console.log(decision.reason); // 'no matching rule' | 'explicit deny'\n}\n```\n\n## batch decisions\n\n```ts\nconst results = ward.checkall({ id: 'u1', roles: ['editor'] }, [\n { resource: 'posts', action: 'read' },\n { resource: 'posts', action: 'update', data: { authorid: 'u1' } },\n]);\n```\n\n## bound ward (`foruser`)\n\n```ts\nconst bound = ward.foruser({ id: 'u1', roles: ['editor'] });\n\nbound.explain({ resource: 'posts', action: 'read' });\nbound.trace({ resource: 'posts', action: 'update', data: { authorid: 'u1' } });\nbound.rulesinscope({ resource: 'posts' });\nbound.allowedactions({ resource: 'posts', knownactions: ['read', 'update', 'delete'] as const });\n```\n\n`foruser()` snapshots the principal. re bind when roles/identity change.\n\n## allowed actions\n\n`allowedactions()` evaluates a provided action set:\n\n```ts\nconst actions = ward.allowedactions({\n principal: { id: 'u1', roles: ['admin'] },\n resource: 'posts',\n knownactions: ['read', 'update', 'delete'] as const,\n});\n```\n\nit does not fire a `decision` event.\n\n## rule introspection\n\n```ts\nconst scoped = ward.rulesinscope({\n principal: { id: 'u1', roles: ['editor'] },\n resource: 'posts',\n});\n```\n\nuse optional `data` to filter predicate gated matches.\n\n## trace candidates\n\n```ts\nconst trace = ward.trace({\n principal: { id: 'u1', roles: ['editor', 'blocked'] },\n resource: 'posts',\n action: 'read',\n});\n\ntrace.candidates.foreach((c) => {\n console.log(c.index, c.priority, c.score, c.won);\n});\n```\n\n`trace()` does not fire a `decision` event.\n\n## observing decisions\n\n`tap()` subscribes a handler to ward events. each `explain()` and `checkall()` decision fires a `decision` event; `trace()` and `allowedactions()` do not.\n\n```ts\nconst ward = createward(rules);\nward.tap((event) => console.debug(`ward:${event.type}`, event.decision));\n```\n\npass an `abortsignal` to unsubscribe automatically, or call the returned function to unsubscribe manually:\n\n```ts\nconst controller = new abortcontroller();\nconst unsubscribe = ward.tap((event) => console.debug(event), { signal: controller.signal });\n\n// later\nunsubscribe(); // or controller.abort();\n```\n\nfor structured logging, forward events to a `@vielzeug/rune` logger:\n\n```ts\nimport { createlogger } from '@vielzeug/rune';\nconst log = createlogger({ name: 'ward' });\nward.tap((event) => log.debug(event, 'ward:decision'));\n```\n\n## predicate helpers\n\n```ts\nimport { owns, predicate } from '@vielzeug/ward';\n\nconst isowner = owns('authorid');\nconst canedit = predicate.and(isowner, ({ principal }) => principal.id !== '');\n```\n\nasync predicates are rejected at runtime with `wardpredicateerror`.\n\n## request guards\n\nuse `explain()` directly at request boundaries. extract the principal from your framework's request object and pass it to ward:\n\n```ts\nconst principal = await extractprincipal(req);\nconst decision = ward.explain({ principal, resource: 'posts', action: 'read' });\n\nif (!decision.allowed) {\n return res.status(403).json({ error: decision.reason });\n}\n```\n\n## testing\n\ntest policy outcomes through `explain()` so each test captures an allowed, explicit deny, or no match result.\n\n```ts\nimport { expect, it } from 'vitest';\n\nit('denies an action with no matching rule', () => {\n expect(\n ward.explain({ principal: { id: 'u1', roles: ['viewer'] }, resource: 'posts', action: 'delete' }).allowed,\n ).tobe(false);\n});\n```\n\n## framework integration\n\nkeep ward independent from rendering frameworks. obtain a current principal from framework state, bind it with `foruser()`, and rebind whenever identity or roles change.\n\n::: code group\n\n```tsx [react]\nconst actions = ward.foruser(user).allowedactions({ resource: 'posts', knownactions: ['read', 'update'] as const });\n```\n\n```vue [vue 3]\n<script setup lang=\"ts\">\nconst actions = ward\n .foruser(user.value)\n .allowedactions({ resource: 'posts', knownactions: ['read', 'update'] as const });\n</script>\n```\n\n```ts [svelte]\nconst actions = ward.foruser(user).allowedactions({ resource: 'posts', knownactions: ['read', 'update'] as const });\n```\n\n:::\n\n## working with other vielzeug libraries\n\n### with wayfinder\n\nenforce ward decisions in wayfinder route guards by calling `explain()` inside the guard callback:\n\n```ts\nconst decision = ward.explain({ principal, resource: route.meta.resource, action: 'read' });\n\nif (!decision.allowed) return '/forbidden';\n```\n\n### with conduit\n\ninject a ward instance into conduit managed services so authorization checks share a single compiled policy:\n\n```ts\nconst ward = createward(rules);\ncontainer.register('ward', ward);\n```\n\n## best practices\n\n model default deny by adding only explicit allow rules.\n keep predicates synchronous and provide required resource data.\n assign priority deliberately before relying on specificity.\n rebind `foruser()` when identity or roles change.\n use `trace()` and `detectconflicts()` to diagnose policy behavior.\n enforce authorization again at request and mutation boundaries.\n",
|
|
1457
|
+
"examples": " \ntitle: ward — examples\ndescription: practical examples and recipes for ward.\n \n\n## examples\n\n [blog roles](./examples/blog roles.md)\n [multi role rules](./examples/multi role rules.md)\n [wildcard action](./examples/wildcard action.md)\n [priority and overrides](./examples/inheritance and overrides.md)\n [bound guard in ui layer](./examples/bound guard in ui layer.md)\n [rule specificity](./examples/disabling wildcard fallback.md)\n [auditing decisions](./examples/logger for auditing.md)\n [fresh ward per test](./examples/snapshot restore for test isolation.md)\n [conflict detection](./examples/conflict detection.md)\n [trace a decision](./examples/trace decision.md)\n"
|
|
1436
1458
|
},
|
|
1437
1459
|
"examples": [
|
|
1438
1460
|
{
|
|
@@ -1493,15 +1515,15 @@
|
|
|
1493
1515
|
"name": "@vielzeug/ward",
|
|
1494
1516
|
"related": "wayfinder conduit herald",
|
|
1495
1517
|
"slug": "ward",
|
|
1496
|
-
"source": "export { allow, deny, owns, predicate, rulefor } from './builder';\nexport { anonymous, wildcard } from './constants';\nexport { wardconfigerror, warderror, wardpredicateerror } from './errors';\nexport { createward } from './factory';\nexport { matchespattern, patterncovers } from './resource';\nexport type {\n boundward,\n boundwardallowedactionsinput,\n boundwarddecisioninput,\n boundwardrulesinscopeinput,\n conflictkind,\n normalizedwardrule,\n principal,\n rulecontext,\n userprincipal,\n ward,\n wardallowedactionsinput,\n wardcheck,\n wardconflict,\n warddecision,\n warddecisioninput,\n warddecisionresult,\n
|
|
1518
|
+
"source": "export { allow, deny, owns, predicate, rulefor } from './builder';\nexport { anonymous, wildcard } from './constants';\nexport { wardconfigerror, warderror, wardpredicateerror } from './errors';\nexport { createward } from './factory';\nexport { matchespattern, patterncovers } from './resource';\nexport type {\n boundward,\n boundwardallowedactionsinput,\n boundwarddecisioninput,\n boundwardrulesinscopeinput,\n conflictkind,\n normalizedwardrule,\n principal,\n rulecontext,\n userprincipal,\n ward,\n wardallowedactionsinput,\n wardcheck,\n wardconflict,\n warddecision,\n warddecisioninput,\n warddecisionresult,\n wardevent,\n wardoptions,\n wardpredicate,\n wardrule,\n wardrulesinscopeinput,\n wardtrace,\n wardtracecandidate,\n} from './types';\n"
|
|
1497
1519
|
},
|
|
1498
1520
|
{
|
|
1499
1521
|
"category": "routing",
|
|
1500
1522
|
"description": "framework agnostic client side router with typed params, async data loading, middleware, leave guards, and view transitions support.",
|
|
1501
1523
|
"docs": {
|
|
1502
|
-
"index": " \ntitle: wayfinder — client side router for typescript\ndescription: framework agnostic client side router with typed params, async data loading, middleware, leave guards, and view transitions support.\npackage: wayfinder\ncategory: routing\nkeywords: [router, client side, middleware, guards, navigation, history, spa, typed routes]\nrelated: [ripple, ward, herald]\nexports: [createrouter, createbrowserhistory, creatememoryhistory, redirectto, wayfindererror, wayfinderapierror, wayfinderdisposederror, wayfinderredirectlooperror, wayfinderrouteerror
|
|
1503
|
-
"api": " \ntitle: wayfinder — api reference\ndescription: complete api reference for wayfinder.\n \n\n[[toc]]\n\n## api overview\n\n| symbol | purpose | execution mode | common gotcha |\n| | | | |\n| `createrouter(options)` | create a router from a route table | sync | initial navigation starts asynchronously in the constructor |\n| `createbrowserhistory()` | create the default browser history driver | sync | — |\n| `creatememoryhistory(initialpath?)` | create an in memory history driver | sync | — |\n| `redirectto(target, options?)` | build redirect middleware | sync (returns fn) | does not call `next()` — always short circuits the chain |\n| `router.navigate(target, options?)` | navigate to a named route, raw path object, or string path | async | no op when destination equals current url unless `force: true` |\n| `router.getsnapshot()` | return the current immutable route state | sync | does not subscribe — call `subscribe()` to react to changes |\n| `router.subscribe(listener)` | register a listener for state changes | sync (returns unsub) | listener is **not** called immediately with current state |\n| `router.url(name, params?, query?)` | build a url for a named route | sync | throws if the route name is unknown |\n| `router.isactive(name, options?)` | check if a named route matches the current url | sync | compares against the current snapshot pathname, not `history.location` directly |\n| `router.match(pathname)` | inspect a pathname as a branch without side effects | sync | returns `null` for redirect routes |\n| `router.load(url, options?)` | load a url into a full state including data loaders | async | middleware is not executed; lazy modules are resolved as a side effect |\n| `router.ready` | await the initial navigation | async | rejects when initial loading fails |\n| `router.preload(name, params?, query?)` | eagerly run data loaders without navigating | async | pass `query` to match the navigation cache key; rejects with `wayfinderdisposederror` if the router is disposed |\n| `router.waitfor(name)` | wait for the router to settle on a named route | async | rejects immediately if `status === 'error'`; rejects with `wayfinderdisposederror` if disposed while pending |\n| `router.beforeleave(blocker, options?)` | register a global leave guard | sync (returns unsub) | scoped to specific routes via `options.routes` |\n| `router.dispose()` | remove listeners and shut down the router | sync | idempotent — safe to call multiple times |\n\n## package entry points\n\n| import | purpose |\n| | |\n| `@vielzeug/wayfinder` | main exports and types |\n| `@vielzeug/wayfinder/devtools` | `debugrouter` — navigation logger (dev only) |\n\n## `createrouter(options)`\n\n```ts\nimport { createrouter } from '@vielzeug/wayfinder';\n\nconst router = createrouter({\n base: '/app',\n routes: {\n home: { path: '/' },\n dashboard: {\n path: '/dashboard',\n children: {\n index: { index: true },\n settings: { path: 'settings', data: () => fetchsettings() },\n },\n },\n },\n notfound: { component: notfoundpage },\n});\n```\n\n| option | type | default | description |\n| | | | |\n| `base` | `string` | `'/'` | base path prefix for all routes |\n| `coercesearch` | `coercesearchfn` | — | global search param coercion applied to every route that does not define its own `coercesearch`. throwing falls back to raw strings and is reported via `onerror`. |\n| `history` | `historydriver` | `createbrowserhistory()` | history source used for reading locations and writing navigations |\n| `middleware` | `middleware[]` | `[]` | global middleware prepended to every route |\n| `notfound` | `{ component?, data?, meta?, middleware? }` | — | synthetic route used when no path matches. global middleware runs first, then `notfound.middleware` and `notfound.data`. `ctx.pathname` is the unmatched path. |\n| `onerror` | `(error, context: routererrorcontext) => void` | — | optional sink for non awaited/background router errors |\n| `routes` | `routetable` | required | declarative route table. object key order defines match precedence. |\n| `scroll` | `(to, from) => scrolldecision` | — | called after each navigation. return `'top'` to scroll to top, `'preserve'` to keep the current position, or `{ x, y }` for a specific position. |\n| `viewtransition` | `boolean` | `false` | wrap navigations in the view transition api when available |\n\n**returns:** `router`\n\n## route table\n\ndefine routes as a plain object where keys become route names. typescript will infer route params from literal `path` strings.\n\n```ts\nconst routes = {\n home: { path: '/' },\n userdetail: { path: '/users/:id' },\n files: { path: '/files/:rest*' },\n};\n```\n\nnested routes are declared with `children`, and child names become compound names with dot notation.\n\n## route definition\n\n```ts\nconst routes = {\n home: { path: '/' },\n dashboard: {\n path: '/dashboard',\n middleware: [requireauth],\n children: {\n index: { index: true },\n settings: {\n path: 'settings',\n data: async () => fetchsettings(),\n },\n },\n },\n userdetail: {\n path: '/users/:id',\n meta: { section: 'users' },\n data: async ({ params }) => fetchuser(params.id),\n onerror: (error) => ({ error, user: null }),\n },\n};\n```\n\neach route definition supports these fields:\n\n| field | type | description |\n| | | |\n| `path` | `string` | wayfinder pattern. supports static paths, `:param`, `:param*`, and `*`. child paths are relative unless they start with `/`. |\n| `children` | `record<string, routedefinition>` | nested child routes. child names are appended to the parent route name. |\n| `index` | `boolean` | default child route that inherits the parent path. |\n| `component` | `unknown` | optional framework view payload exposed on the leaf `routematch`. |\n| `data` | `datafn` | data loader. runs after middleware; result available as `match.data`. supports streaming via `asyncgenerator`. |\n| `lazy` | `() => promise<{ data?, component?, meta? }>` | lazy load the route module. called once on first navigation; result overrides static fields in the hydration cache. |\n| `meta` | `unknown` | static metadata exposed on each `routematch` in the branch. |\n| `middleware` | `middleware[]` | optional route specific middleware |\n| `onerror` | `(error, context: datacontext) => maybepromise<unknown>` | per route error boundary for data loader failures. return value becomes `match.data` for degraded rendering. |\n| `redirect` | `navigationtarget` | declarative redirect. resolved before middleware runs; uses `replacestate` so the original url is never added to history. |\n| `coercesearch` | `(raw: queryparams) => resolvedqueryparams` | coerce raw url string values into typed values. return value replaces `ctx.query`. throwing leaves the parsed query unchanged. |\n\n## `createbrowserhistory()`\n\n```ts\nimport { createbrowserhistory } from '@vielzeug/wayfinder';\n\nconst history = createbrowserhistory();\n```\n\ncreate the default `historydriver` backed by the browser history api.\n\n## `creatememoryhistory(initialpath?)`\n\n```ts\nimport { creatememoryhistory } from '@vielzeug/wayfinder';\n\n// tests\nconst router = createrouter({\n history: creatememoryhistory('/dashboard'),\n routes,\n});\n\n// controlled non browser runtime\nconst router = createrouter({\n history: creatememoryhistory('/request path'),\n routes,\n});\n```\n\ncreate an in memory `historydriver`. no browser history globals required — suitable for unit tests and controlled non browser runtimes. the optional `initialpath` defaults to `'/'`.\n\n## `router`\n\n### lifecycle\n\n#### `router.dispose()`\n\nremove listeners, clear subscribers, and reject future router interaction. idempotent — safe to call multiple times.\n\n**returns:** `void`\n\n**throws:** never.\n\n \n\n#### `router.disposed`\n\n`boolean` — `true` after `dispose()` has been called.\n\n \n\n#### `router.disposalsignal`\n\n`abortsignal` that is aborted (with a `wayfinderdisposederror` reason) when the router is disposed. use this to tie external resource lifetimes to the router's lifetime.\n\n```ts\nsource.on('update', syncrouteparams, { signal: router.disposalsignal });\n```\n\n \n\n### navigation\n\n#### `router.navigate(target, options?)`\n\n```ts\nawait router.navigate({ name: 'userdetail', params: { id: '42' } });\nawait router.navigate({ name: 'userdetail', params: { id: '42' } }, { replace: true });\nawait router.navigate({ name: 'search', query: { q: 'wayfinder' }, hash: 'results' });\n```\n\n| option | type | default | description |\n| | | | |\n| `replace` | `boolean` | `false` | use `replacestate` instead of `pushstate` |\n| `state` | `unknown` | — | history state payload |\n| `viewtransition` | `boolean` | — | override the router level setting for this navigation |\n| `force` | `boolean` | `false` | re run even when the destination url is already current |\n\n**returns:** `promise<void>`\n\nhistory is written only after middleware reaches the terminal stage. returning from middleware without `next()` cancels the programmatic navigation without changing history or the route snapshot.\n\nnamed routes stay the primary api, but `navigate()` also accepts raw path objects or a plain string:\n\n```ts\nawait router.navigate({ path: '/marketing?utm_source=campaign' });\nawait router.navigate({ path: '/checkout#payment' }, { replace: true });\n\n// plain string — most concise for direct paths\nawait router.navigate('/about');\nawait router.navigate('/search?q=hello');\n```\n\n \n\n### route helpers\n\n#### `router.url(name, params?, query?)`\n\n```ts\nrouter.url('userdetail', { id: '42' });\nrouter.url('userdetail', { id: '42' }, { tab: 'profile' });\n```\n\nbuild a base aware url for a named route.\n\n**returns:** `string`\n\n#### `router.isactive(name, options?)`\n\n```ts\nrouter.isactive('userdetail');\nrouter.isactive('users');\nrouter.isactive('users', { exact: true });\n```\n\ncheck whether the current pathname matches a named route exactly or by prefix.\n\n**returns:** `boolean`\n\n#### `router.match(pathname)`\n\n```ts\nrouter.match('/app/dashboard/settings');\n// => [\n// { name: 'dashboard', ... },\n// { name: 'dashboard.settings', ... },\n// ]\n```\n\ninspect a pathname without running middleware, data loaders, or subscribers. strips the configured `base` automatically. returns the matched branch from root to leaf, or `null` for redirect routes and no match.\n\n**returns:** `routematchbranch | null`\n\n \n\n#### `router.load(url, options?)`\n\n```ts\n// ssr data prefetch\nconst state = await router.load('/users/42');\n\n// with cancellation\nconst controller = new abortcontroller();\nconst state = await router.load('/dashboard', { signal: controller.signal });\n```\n\nload a full url into a `routestate` including data loader results, without modifying router state or history. follows declarative redirects (up to five hops) and resolves lazy modules as a side effect. returns `null` for unmatched urls.\n\nmiddleware is **not** executed — `load` is a data only prefetch for ssr and pre rendering where middleware side effects are not wanted. if your data loaders depend on `ctx.locals` set by middleware, use `navigate()` instead.\n\nwhen a `data()` function throws, the returned state has `status: 'error'` and `error` set to the thrown value.\n\n**returns:** `promise<routestate | null>`\n\n \n\n#### `router.waitfor(name)`\n\n```ts\n// navigate and wait for data to settle\nawait router.navigate({ name: 'userdetail', params: { id: '42' } });\nconst state = await router.waitfor('userdetail');\nconst user = state.matches.at( 1)?.data;\n\n// useful in tests with memory history:\nconst history = creatememoryhistory('/dashboard');\nconst router = createrouter({ history, routes });\nconst state = await router.waitfor('dashboard');\n```\n\nwaits for the router to reach `status: 'idle'` with the named route active in the matched branch. rejects immediately if `status === 'error'`. resolves immediately if the router is already idle on the target route. also rejects if `router.dispose()` is called while the promise is pending.\n\n> **note:** `waitfor` skips intermediate `'streaming'` states — it only resolves once the status reaches `'idle'`. it does not resolve while the route is still streaming partial data.\n\n**returns:** `promise<routestate>`\n\n \n\n#### `router.preload(name, params?, query?)`\n\n```ts\n// hover prefetch without query\nanchor.addeventlistener('mouseenter', () => {\n router.preload('userdetail', { id: '42' });\n});\n\n// hover prefetch with matching query to avoid a cache miss\nanchor.addeventlistener('mouseenter', () => {\n router.preload('search', undefined, { q: 'hello' });\n});\n```\n\neagerly runs the data loaders for a named route without navigating. useful for hover prefetch. concurrent calls for the same `name + params + query` combination are deduplicated. results are consumed on the next navigation to the same route with the same cache key.\n\npass the same `query` you intend to navigate with to ensure the preloaded result hits the cache. without `query`, the key is the bare path — a navigation with a query string will produce a cache miss and re run the loader.\n\nin flight preloads are aborted automatically via the router's disposal signal when `router.dispose()` is called. calling `preload()` on an already disposed router throws `wayfinderdisposederror` immediately, without running the data loader — consistent with `navigate()`, `subscribe()`, `beforeleave()`, and `waitfor()`.\n\n**returns:** `promise<void>`\n\n \n\n#### `router.beforeleave(blocker, options?)`\n\n```ts\n// guard unsaved changes forms\nconst remove = router.beforeleave(async (destination) => {\n if (!form.isdirty) return true;\n return confirm(`leave without saving? (going to ${destination.pathname})`);\n});\n\n// remove the guard when the form unmounts\nremove();\n```\n\nregister a global leave guard called before user triggered navigation attempts. return `true` to allow, `false` to cancel. multiple guards can be registered; navigation is blocked if any guard returns `false`.\n\nscope a guard to fire only when leaving specific routes using the `routes` option:\n\n```ts\nrouter.beforeleave(async () => confirm('discard changes?'), { routes: ['editor'] });\n```\n\nthe guard fires when the router is leaving any route whose name appears in the `routes` array (any node in the active branch, not just the leaf). declarative `redirect` routes bypass all leave guards.\n\n**returns:** `() => void`\n\n## `redirectto(target, options?)`\n\n```ts\nimport { redirectto } from '@vielzeug/wayfinder';\n\nconst requireauth = redirectto({ name: 'login' }, { replace: true });\n```\n\ncreates middleware that navigates to `target` and short circuits the middleware chain (does not call `next()`). useful for auth guards and route aliases in middleware.\n\nfor permanent declarative redirects (url aliases), use the `redirect` field on the route definition instead.\n\n> **note:** `redirectto()` internally calls `ctx.navigate()`, which runs `beforeleave` guards. if a guard blocks navigation, the redirect will not complete. declarative `redirect` on a route definition bypasses guards entirely.\n\n**returns:** `middleware`\n\n \n\n### state\n\n#### `router.ready`\n\na `promise<void>` for the constructor triggered navigation. it resolves after initial middleware, redirects, lazy modules, and data loaders settle. it resolves after a blocked or unmatched initial navigation, and rejects if initial navigation fails.\n\n```ts\nconst router = createrouter({ routes });\nawait router.ready;\n```\n\n \n\n#### `router.getsnapshot()`\n\nreturns the current immutable route state snapshot. use this to read state synchronously. compatible with react's `usesyncexternalstore`:\n\n```ts\nconst state = usesyncexternalstore(\n (cb) => router.subscribe(cb),\n () => router.getsnapshot(),\n);\n```\n\n```ts\nconst { location, matches, status, error } = router.getsnapshot();\n\nlocation.pathname;\nlocation.query; // raw parsed query (queryparams) — always string values\nlocation.hash;\nlocation.historystate; // value passed to navigate({ ... }, { state: ... })\n\n// when status === 'error':\nconsole.error(error);\n```\n\n`error` is only set when `status === 'error'`. it holds the exact value thrown by the failing `data()` function.\n\n**returns:** `routestate`\n\n#### `router.subscribe(listener)`\n\n```ts\nconst unsubscribe = router.subscribe((state) => {\n const leaf = state.matches.at( 1);\n document.title = (leaf?.meta as { title?: string } | undefined)?.title ?? 'app';\n});\n```\n\nregister a listener for future state changes, including loading and streaming updates. the listener is **not** called with the current snapshot — call `router.getsnapshot()` when you subscribe if you need it.\n\n**returns:** `() => void`\n\n## types\n\n### `routecontext<params, troutes>`\n\ncontext passed to middleware and data loader functions.\n\n```ts\ntype routecontext<params extends routeparams = routeparams, troutes extends routetable = routetable> = {\n readonly hash: string;\n /** state stored on the history entry that triggered this navigation. */\n readonly historystate: unknown;\n locals: record<string, unknown>;\n readonly matches: routematchbranch;\n readonly navigate: (\n target: namednavigationtarget<troutes> | rawnavigationtarget | string,\n options?: navigateoptions,\n ) => promise<void>;\n readonly params: params;\n readonly pathname: string;\n readonly query: resolvedqueryparams;\n};\n```\n\nread route metadata from the leaf match: `ctx.matches.at( 1)?.meta`.\n\n`ctx.locals` is mutable and shared across the entire middleware chain for one navigation. use it to pass values from middleware to data loaders.\n\n`ctx.query` is the coerced query (after `coercesearch`). `router.getsnapshot().location.query` always contains raw string values from url parsing.\n\n### `datafn<params, troutes>`\n\n```ts\ntype datafn<params extends routeparams = routeparams, troutes extends routetable = routetable> = (\n context: datacontext<params, troutes>,\n) => datastream | maybepromise<unknown>;\n```\n\nreturn an `asyncgenerator` to stream partial results (see `datastream`).\n\n### `datacontext<params, troutes>`\n\n```ts\ntype datacontext<params extends routeparams = routeparams, troutes extends routetable = routetable> = routecontext<\n params,\n troutes\n> & {\n readonly signal: abortsignal;\n};\n```\n\n### `datastream<t>`\n\n```ts\ntype datastream<t = unknown> = asyncgenerator<t, t>;\n```\n\nreturn a `datastream` from a `data()` function to stream partial results. each `yield` updates `match.data` immediately with `match.status: 'streaming'`. the `return` value is the final settled data with `match.status: 'idle'`.\n\n```ts\ndata: async function* ({ signal }) {\n const items: item[] = [];\n for await (const batch of streambatches({ signal })) {\n items.push(...batch);\n yield items; // partial — status: 'streaming'\n }\n return items; // final — status: 'idle'\n},\n```\n\n### `middleware<troutes>`\n\n```ts\ntype middleware<troutes extends routetable = routetable> = (\n context: routecontext<routeparams, troutes>,\n next: () => promise<void>,\n) => void | promise<void>;\n```\n\nmiddleware ordering is simple: global middleware first, then route middleware, then `data()`.\n\n### `untypednamednavigationtarget`\n\n```ts\ntype untypednamednavigationtarget = {\n hash?: string;\n name: string;\n params?: routeparams;\n query?: resolvedqueryparams;\n};\n```\n\n### `navigationtarget`\n\n```ts\ntype navigationtarget =\n | {\n path: string;\n }\n | {\n hash?: string;\n name: string;\n params?: routeparams;\n query?: resolvedqueryparams;\n };\n```\n\n### `navigateoptions`\n\n```ts\ntype navigateoptions = {\n force?: boolean;\n replace?: boolean;\n state?: unknown;\n viewtransition?: boolean;\n};\n```\n\n### `routestate`\n\n```ts\ntype routestate = {\n /** the value thrown by a `data()` function. only set when `status === 'error'`. */\n readonly error?: unknown;\n readonly location: routelocation;\n readonly matches: readonly routematch[];\n readonly status: navigationstatus;\n};\n\ntype routelocation = {\n readonly hash: string;\n /** state stored on the history entry that triggered this navigation. */\n readonly historystate: unknown;\n readonly pathname: string;\n /** raw parsed query params — always string values from url parsing.\n * for coerced values (numbers, booleans), read `ctx.query` inside middleware or data loaders.\n */\n readonly query: queryparams;\n};\n```\n\n### `routematch`\n\n```ts\ntype routematch = {\n readonly component: unknown;\n readonly data: unknown;\n readonly meta: unknown;\n readonly name: string;\n readonly params: routeparams;\n readonly pathname: string;\n /** per node loading status. reflects individual loader state in nested layouts. */\n readonly status: navigationstatus;\n};\n```\n\n### `routematchbranch`\n\n```ts\ntype routematchbranch = readonly routematch[];\n```\n\n### `pathparams<t>`\n\n```ts\ntype userparams = pathparams<'/users/:id'>;\n// => { readonly id: string }\n\ntype fileparams = pathparams<'/files/:rest*'>;\n// => { readonly rest: string }\n```\n\n### `queryparams`\n\n```ts\ntype queryparams = record<string, string | string[]>;\n```\n\nrepresents parsed url query values before route level coercion.\n\n### `resolvedqueryparams`\n\n```ts\ntype resolvedqueryvalue = string | number | boolean;\ntype resolvedqueryparams = record<string, resolvedqueryvalue | resolvedqueryvalue[]>;\n```\n\nrepresents the query object after optional `coercesearch` normalization.\n\n### `navigationstatus`\n\n```ts\ntype navigationstatus = 'idle' | 'loading' | 'streaming' | 'error';\n```\n\ntop level status of the router. `'streaming'` means at least one active data loader is an async generator and has yielded at least one value but has not yet returned.\n\neach `routematch` also carries a `status: navigationstatus` for per node loading state in nested layouts.\n\n### `routemiddleware<path, troutes>`\n\n```ts\ntype routemiddleware<path extends string = string, troutes extends routetable = routetable> = (\n context: routecontext<pathparams<path>, troutes>,\n next: () => promise<void>,\n) => void | promise<void>;\n```\n\ntyped variant of `middleware` scoped to a route path. provides typed `ctx.params` matching the path pattern.\n\n```ts\nconst guard: routemiddleware<'/users/:id'> = (ctx, next) => {\n console.log(ctx.params.id); // string\n return next();\n};\n```\n\n### `coercesearchfn<q>`\n\n```ts\ntype coercesearchfn<q extends resolvedqueryparams = resolvedqueryparams> = (\n raw: queryparams,\n) => q;\n```\n\nfunction signature for both the per route `coercesearch` field and the global `routeroptions.coercesearch` option. receives raw url strings and returns typed values. throwing inside the function falls back to the original raw query.\n\n### `beforeleaveoptions<troutes>`\n\n```ts\ntype beforeleaveoptions<troutes extends routetable = routetable> = {\n /** route names that trigger this guard. omit for a global guard. */\n routes?: routename<troutes>[];\n};\n```\n\npassed as the second argument to `router.beforeleave()`. when `routes` is provided, the guard only fires when the router leaves a route whose name is in the array.\n\n### `beforeleaveblocker`\n\n```ts\n// return true to allow navigation, false to cancel.\ntype beforeleaveblocker = (destination: navigationdestination) => maybepromise<boolean>;\n```\n\n### `navigationdestination`\n\n```ts\ntype navigationdestination = {\n readonly name?: string; // route name if navigating to a named route\n readonly params: routeparams;\n readonly pathname: string;\n readonly query: queryparams;\n};\n```\n\npassed to every `beforeleave` blocker. use `destination.pathname` and `destination.query` to make context aware allow/block decisions.\n\n### `isactiveoptions`\n\n```ts\ntype isactiveoptions = {\n /** require an exact pathname match. defaults to prefix matching. */\n exact?: boolean;\n};\n```\n\n### `scrolldecision`\n\n```ts\ntype scrollposition = { x: number; y: number };\ntype scrolldecision = scrollposition | 'preserve' | 'top';\n```\n\n### `routererrorcontext`\n\n```ts\ntype routererrorcontext =\n | { routename: string; source: 'data loader' } // data() threw\n | { routename: string; source: 'middleware' } // middleware threw\n | { source: 'coerce search' | 'history listener' | 'initial navigation' | 'preload' };\n```\n\npassed to the `onerror` callback in `createrouter` options. the `routename` is present when the error originates from a named route's `data()` or `middleware`.\n\n### `historydriver`\n\n```ts\ninterface historydriver {\n readonly location: {\n readonly hash: string;\n readonly pathname: string;\n readonly search: string;\n readonly state: unknown;\n };\n /** navigate one entry back in history, equivalent to the browser back button. */\n back(): void;\n push(url: string, state?: unknown): void;\n replace(url: string, state?: unknown): void;\n /**\n * subscribe to backwards/forwards navigation (popstate equivalent).\n * `push()` and `replace()` are silent — they do not notify subscribers.\n * only `back()` (and browser popstate events) trigger notifications.\n * returns an unsubscribe function.\n */\n onpopstate(listener: () => void): () => void;\n}\n```\n\n### `routedefinition<path>`\n\n```ts\ntype routedefinition<path extends string = string> =\n | contentroutedefinition<path> // path + data/component/meta/middleware/coercesearch/lazy/onerror\n | redirectroutedefinition<path>; // path + redirect\n```\n\nthe union type for a single entry in the route table. use this to type externally defined route objects:\n\n```ts\nimport type { routedefinition } from '@vielzeug/wayfinder';\n\nconst userdetail: routedefinition<'/users/:id'> = {\n path: '/users/:id',\n data: async ({ params }) => fetchuser(params.id),\n};\n```\n\n### `routeroptions<troutes>`\n\nthe options object accepted by `createrouter()`. see the [`createrouter(options)`](#createrouter options) options table above for the full field reference.\n\n```ts\nimport type { routeroptions } from '@vielzeug/wayfinder';\n\nconst options: routeroptions<typeof routes> = {\n routes,\n base: '/app',\n};\n```\n\n### `unsubscribe`\n\n```ts\ntype unsubscribe = () => void;\n```\n\n## errors\n\n### `wayfindererror`\n\nbase class for every error wayfinder throws. catch this to handle any router originated error without enumerating subclasses.\n\n```ts\nimport { wayfindererror } from '@vielzeug/wayfinder';\n\ntry {\n await router.navigate({ name: 'home' });\n} catch (e) {\n if (e instanceof wayfindererror) {\n // any router originated error — check e.name or `instanceof` a subclass for detail\n }\n}\n```\n\n### `wayfinderdisposederror`\n\nthrown when `navigate()`, `subscribe()`, `beforeleave()`, `waitfor()`, or `preload()` is called after `dispose()`. also used as the `abortsignal.reason` on `disposalsignal`.\n\n```ts\nimport { wayfinderdisposederror } from '@vielzeug/wayfinder';\n\ntry {\n await router.navigate({ name: 'home' });\n} catch (e) {\n if (e instanceof wayfinderdisposederror) {\n // router was disposed\n }\n}\n```\n\n### `wayfinderrouteerror`\n\nthrown for malformed route definitions — at `createrouter()` time for config errors, or when a `url()`/`navigate()` call references an unknown route name or a missing path param.\n\n### `wayfinderredirectlooperror`\n\nthrown when a chain of declarative `redirect`s (or a mix of declarative redirects and `ctx.navigate()` calls inside route middleware) exceeds 5 hops.\n\n### `wayfinderapierror`\n\nthrown on middleware misuse — currently only when a middleware function calls its `next()` more than once.\n\n### runtime error messages\n\n| message | class | when |\n| | | |\n| `router is disposed` | `wayfinderdisposederror` | calling a guarded method (see above) after `dispose()` |\n| `unknown route name: x. available routes: y` | `wayfinderrouteerror` | navigating to, resolving, or building a url for an unregistered route |\n| `route \"x\" cannot define both index and path` | `wayfinderrouteerror` | a route sets `index: true` and `path` at the same time |\n| `route \"x\" must define path or set index: true` | `wayfinderrouteerror` | a route defines neither `index: true` nor `path` |\n| `duplicate route name: \"x\"` | `wayfinderrouteerror` | two routes resolve to the same compound name during `createrouter()` |\n| `missing path param: x` | `wayfinderrouteerror` | `url()`/`navigate()`/`preload()` omits a param the path pattern requires |\n| `invalid param name \":x\" in path \"y\"` | `wayfinderrouteerror` | a param name contains non word characters (e.g., `:user id`) |\n| `wildcard \"*\" must be the final segment in path: x` | `wayfinderrouteerror` | a `*` segment appears before the last segment |\n| `wildcard param must be final segment in path: x` | `wayfinderrouteerror` | a `:param*` greedy param appears before the last segment |\n| `redirect loop detected` | `wayfinderredirectlooperror` | a declarative `redirect` chain (or mixed redirect + `navigate()`) exceeds 5 hops |\n| `next() called multiple times` | `wayfinderapierror` | middleware calls its `next()` callback more than once |\n\n## pattern rules\n\n| pattern | example | meaning |\n| | | |\n| `/about` | `/about` | exact static path |\n| `/users/:id` | `/users/42` | single named param |\n| `/users/:userid/posts/:postid` | `/users/1/posts/2` | multiple named params |\n| `/docs/*` | `/docs/guide/intro` | wildcard suffix without a named capture |\n| `/files/:rest*` | `/files/a/b/c` | wildcard suffix captured as one named param |\n| `*` | anything | global catch all |\n\n## `debugrouter(options)` <badge type=\"tip\" text=\"@vielzeug/wayfinder/devtools\" />\n\n```ts\nimport { debugrouter } from '@vielzeug/wayfinder/devtools';\n\nconst router = debugrouter({ routes });\n// [wayfinder:nav] idle / [home] ← logged when initial navigation settles\n// [wayfinder:nav] loading /dashboard\n// [wayfinder:nav] idle /dashboard [dashboard.index]\n```\n\nwraps `createrouter()` and attaches a `subscribe` listener that logs every navigation state change to `console.debug`. returns the same `router` instance — all methods are identical to `createrouter()`. the first logged entry appears when the initial navigation completes (not synchronously at construction).\n\nimport from the dedicated sub path so the `console.debug` reference is tree shaken from production bundles when not imported.\n\n### `debugrouteroptions`\n\nextends `routeroptions` with one additional field:\n\n| option | type | default | description |\n| | | | |\n| `label` | `string` | `'nav'` | label used in log prefixes. produces `[wayfinder:<label>]`. useful when running multiple routers simultaneously. |\n\n```ts\n// multi router setup — distinguish logs by label:\nconst main = debugrouter({ routes, label: 'main' });\nconst modal = debugrouter({ routes: modalroutes, label: 'modal' });\n// [wayfinder:main] idle /dashboard\n// [wayfinder:modal] loading /confirm\n```\n\n| log format | when |\n| | |\n| `[wayfinder:nav] idle /path [routename]` | navigation settled |\n| `[wayfinder:nav] loading /path` | data loaders in flight |\n| `[wayfinder:nav] streaming /path [routename]` | streaming loader emitting partial data |\n| `[wayfinder:nav] error /path [routename] <error>` | navigation error |\n\n## design notes\n\n wayfinder no longer exposes imperative registration methods like `on()`, `group()`, or `use()`.\n wayfinder names come from the route table object keys.\n `data()` is the terminal action. its return value becomes `match.data`. there is no separate `handler` step.\n for unmatched urls, use the `notfound` router option rather than `path: '*'` in the route table.\n error handling is middleware that wraps `await next()`. the thrown error is also stored on `router.getsnapshot().error`.\n declarative `redirect` on a route definition is for permanent alias redirects. the `redirectto()` middleware helper is for conditional guards.\n `lazy` factories are called at most once per `routerecord`. the loaded `data`/`component`/`meta` are stored in the router's internal hydration cache. `handler` is not accepted in the lazy resolved module.\n `onerror` in a route definition is a per route data loader boundary. if `onerror` itself throws, the router falls through to `status: 'error'` as usual.\n",
|
|
1504
|
-
"usage": " \ntitle: wayfinder — usage guide\ndescription: router setup, middleware, data loading, nested routes, and state patterns for wayfinder.\n \n\n[[toc]]\n\n::: tip new to wayfinder?\nstart with the [overview](./index.md), then use this page for the day to day api.\n:::\n\n## basic usage\n\ncreate a deterministic router with memory history, wait for startup, and navigate by route name.\n\n```ts\nimport { creatememoryhistory, createrouter } from '@vielzeug/wayfinder';\n\nconst router = createrouter({\n history: creatememoryhistory('/'),\n routes: {\n home: { path: '/' },\n settings: {\n data: async () => ({ section: 'settings' }),\n path: '/settings',\n },\n },\n});\n\nawait router.ready;\nawait router.navigate({ name: 'settings' });\nconsole.log(router.getsnapshot().matches.at( 1)?.data);\nrouter.dispose();\n```\n\n`routes` is required. route keys become names, and object key order controls match precedence.\n\n## define routes\n\neach route can provide these fields:\n\n| field | purpose |\n| | |\n| `path` | match pattern |\n| `children` | nested child routes |\n| `index` | default child route that inherits the parent path |\n| `component` | optional view payload exposed on `match.component` |\n| `data` | abortable route data function. result available as `match.data`. supports streaming via `asyncgenerator`. |\n| `lazy` | lazy load the module. called once; result fills `data`, `component`, and `meta`. |\n| `meta` | static metadata exposed on `match.meta` |\n| `middleware` | route specific middleware |\n| `onerror` | per route error boundary. called when this route's `data()` throws; its return value becomes `match.data`. |\n| `redirect` | declarative permanent redirect. resolved before middleware runs. |\n| `coercesearch` | coerce raw url search strings into typed values. return value replaces `ctx.query`. throw to leave the raw query unchanged. |\n\nuse wildcard routes for fallback behavior:\n\n```ts\nconst routes = {\n docs: { path: '/docs/*' },\n};\n```\n\nfor a catch all not found page, use the `notfound` option in router options instead of a `path: '*'` route:\n\n```ts\nconst router = createrouter({\n routes,\n notfound: {\n component: notfoundpage,\n data: async ({ pathname }) => ({ requestedpath: pathname }),\n },\n});\n```\n\nalternatively, `path: '*'` still works as a named route when you need to navigate to it explicitly.\n\nnested routes compose naturally and create compound route names:\n\n```ts\nconst routes = {\n dashboard: {\n path: '/dashboard',\n children: {\n index: { index: true },\n settings: { path: 'settings' },\n },\n },\n};\n\nawait router.navigate({ name: 'dashboard.settings' });\n```\n\n## route context\n\nmiddleware and data loaders receive a `routecontext`:\n\n```ts\nuserdetail: {\n path: '/users/:id',\n middleware: [\n (ctx, next) => {\n ctx.params.id; // typed to path params\n ctx.query.tab; // resolved query (after coercesearch)\n ctx.pathname;\n ctx.hash;\n ctx.historystate; // value from navigate({ ... }, { state: ... })\n ctx.locals; // mutable bag shared across the middleware chain\n ctx.navigate; // programmatic navigation\n return next();\n },\n ],\n data: async (ctx) => {\n ctx.signal; // abortsignal — cancelled when navigation is superseded\n return fetchuser(ctx.params.id, { signal: ctx.signal });\n },\n}\n```\n\n`ctx.locals` is mutable and shared through the entire middleware chain for one navigation. use it to pass values from middleware to data loaders.\n\n## middleware\n\nmiddleware wraps the navigation using the familiar `async (ctx, next) => { ... }` shape.\n\n```ts\nconst requireauth = redirectto({ name: 'login' }, { replace: true });\n\nconst loadcurrentuser = async (ctx, next) => {\n ctx.locals.user = await fetchcurrentuser();\n await next();\n};\n```\n\norder is fixed and simple:\n\n```text\nglobal middleware\n ↓\nroute middleware\n ↓\ndata()\n```\n\n### guards\n\nuse middleware for auth checks, redirects, analytics, and boundaries.\n\n```ts\nconst requireauth = async (ctx, next) => {\n if (!session.currentuser) {\n await ctx.navigate({ name: 'login' }, { replace: true });\n return; // do not call next()\n }\n ctx.locals.user = session.currentuser;\n await next();\n};\n```\n\nfor unconditional redirects, use the `redirectto()` helper:\n\n```ts\nimport { redirectto } from '@vielzeug/wayfinder';\n\nconst requireauth = redirectto({ name: 'login' }, { replace: true });\n```\n\nfor permanent url aliases, use the declarative `redirect` field instead of middleware:\n\n```ts\nconst routes = {\n profile: { path: '/profile', redirect: { name: 'userdetail' } },\n userdetail: { path: '/users/:id' },\n};\n```\n\n> **note:** `redirectto()` calls `ctx.navigate()` internally, so `beforeleave` guards will run and can block it. declarative `redirect` on a route definition bypasses all leave guards.\n\n### leave guards\n\nregister a global leave guard with `router.beforeleave()`. return `false` to cancel navigation.\n\n```ts\nconst removeguard = router.beforeleave(async (destination) => {\n if (!form.isdirty) return true;\n return confirm(`discard changes? (navigating to ${destination.pathname})`);\n});\n\n// remove when no longer needed:\nremoveguard();\n```\n\nscope a guard to fire only when leaving specific routes:\n\n```ts\nrouter.beforeleave(async () => confirm('discard changes?'), { routes: ['editor'] });\n```\n\ndeclarative `redirect` routes bypass all leave guards.\n\n### data loading\n\nuse `data()` for route local data acquisition. it receives the same route context plus an `abortsignal`.\n\n```ts\nconst routes = {\n userdetail: {\n path: '/users/:id',\n data: async ({ params, signal }) => fetchuser(params.id, { signal }),\n },\n};\n```\n\naccess the result via the matched branch:\n\n```ts\nrouter.subscribe((state) => {\n const user = state.matches.at( 1)?.data;\n renderuser(user);\n});\n```\n\n#### per route error boundaries\n\nuse `onerror` to handle data loader failures per route. the returned value becomes `match.data`, allowing the route to render a degraded state:\n\n```ts\nconst routes = {\n userdetail: {\n path: '/users/:id',\n data: async ({ params, signal }) => fetchuser(params.id, { signal }),\n onerror: (error) => ({ error, user: null }),\n },\n};\n```\n\nif `onerror` itself throws, the router falls through to `status: 'error'` as usual.\n\n#### streaming data loaders\n\nreturn an `asyncgenerator` from `data()` to stream partial results. each `yield` updates `match.status` to `'streaming'` and `match.data` to the yielded value. the `return` value is the final settled data.\n\n```ts\nconst routes = {\n feed: {\n path: '/feed',\n data: async function* ({ signal }) {\n const items: feeditem[] = [];\n for await (const batch of streamfeedbatches({ signal })) {\n items.push(...batch);\n yield items; // stream partial results\n }\n return items; // final settled value\n },\n },\n};\n```\n\nduring streaming, `state.status` is `'streaming'` and each `match.status` reflects the loading state of that individual branch node.\n\n### lazy routes\n\ndefer loading a route module until first navigation. the factory is called at most once.\n\n```ts\nconst routes = {\n settings: {\n path: '/settings',\n lazy: () => import('./pages/settings'),\n },\n};\n```\n\nthe resolved object may contain `data`, `component`, and/or `meta`. any present field overwrites the static definition.\n\n### search param validation\n\nvalidate and coerce `ctx.query` per route. the function receives raw url strings (`queryparams`). throw to leave the parsed query unchanged.\n\n```ts\nconst routes = {\n search: {\n path: '/search',\n coercesearch: (raw) => ({\n q: string(raw.q ?? ''),\n page: math.max(1, number(raw.page ?? 1)),\n }),\n data: async ({ query }) => searchposts(query.q, query.page),\n },\n};\n```\n\nto apply the same coercion to every route, set `coercesearch` on the router options instead. per route `coercesearch` takes precedence over the global one.\n\n```ts\nconst router = createrouter({\n coercesearch: (raw) => ({ page: number(raw.page ?? 1) }),\n routes,\n});\n```\n\n### error boundaries\n\nwrap `await next()` in middleware for route wide error handling. the thrown error is also stored on `router.getsnapshot().error`.\n\n```ts\nconst boundary = async (ctx, next) => {\n try {\n await next();\n } catch (error) {\n reportrouteerror(ctx.pathname, error);\n await ctx.navigate({ path: '/error' }, { replace: true });\n }\n};\n\nconst router = createrouter({\n middleware: [boundary],\n routes,\n});\n\n// check after navigation:\nconst { status, error } = router.getsnapshot();\nif (status === 'error') {\n console.error(error);\n}\n```\n\n## navigation\n\n### named navigation\n\n```ts\nawait router.navigate({ name: 'userdetail', params: { id: '42' } });\nawait router.navigate({ name: 'userdetail', params: { id: '42' } }, { replace: true });\nawait router.navigate({ name: 'search', query: { q: 'wayfinder' }, hash: 'results' });\nawait router.navigate({ name: 'dashboard.settings' });\n```\n\n### raw path targets\n\n```ts\nawait router.navigate({ path: '/marketing?utm_source=campaign' });\nawait router.navigate({ path: '/checkout#payment' }, { replace: true });\n```\n\nuse these when a destination does not belong in the route table. the same `navigate()` method covers named routes and raw path targets.\n\n### history state\n\nattach arbitrary state to a history entry and read it back via `ctx.historystate` or `router.getsnapshot().location.historystate`.\n\n```ts\nawait router.navigate({ name: 'userdetail', params: { id: '42' } }, { state: { from: 'search' } });\n\n// in data():\ndata: async (ctx) => {\n console.log(ctx.historystate); // { from: 'search' }\n return fetchuser(ctx.params.id);\n},\n```\n\n### same url deduplication\n\n```ts\nawait router.navigate({ name: 'dashboard' });\nawait router.navigate({ name: 'dashboard' }); // no op\nawait router.navigate({ name: 'dashboard' }, { force: true }); // re runs\n```\n\n### prefetching\n\neagerly run data loaders without navigating — useful for hover prefetch:\n\n```ts\n// preload a parameterised route\nanchor.addeventlistener('mouseenter', () => {\n router.preload('userdetail', { id: '42' });\n});\n\n// preload with a query string to avoid a cache miss on navigation\nsearchinput.addeventlistener('focus', () => {\n router.preload('search', undefined, { q: searchinput.value });\n});\n```\n\nconcurrent calls for the same `name + params + query` combination are deduplicated. results are consumed on the next navigation to the same route with the same cache key. pass the same `query` you intend to navigate with — without it, the preload key is the bare path and any navigation with a query string will re run the loaders.\n\nin flight preloads are aborted automatically when `router.dispose()` is called.\n\n### leave guards\n\nguard navigation until the user confirms — useful for unsaved changes forms:\n\n```ts\nconst removeguard = router.beforeleave(async (destination) => {\n if (!form.isdirty) return true;\n return confirm('discard changes?');\n});\n\n// remove when the component unmounts:\nremoveguard();\n```\n\nscope a guard to a specific route so it only fires when leaving that route:\n\n```ts\nrouter.beforeleave(async () => confirm('discard changes?'), { routes: ['editor'] });\n```\n\n## urls and active state\n\n```ts\nrouter.url('userdetail', { id: '42' });\nrouter.url('userdetail', { id: '42' }, { tab: 'profile' });\n\nrouter.isactive('userdetail');\nrouter.isactive('users');\nrouter.isactive('users', { exact: true });\n```\n\n`isactive(name)` reads the current router snapshot and is useful for parent navigation items.\n\n## match a path without navigating\n\n```ts\nconst branch = router.match('/app/dashboard/settings');\n\nif (branch?.at( 1)?.name === 'dashboard.settings') {\n warmsettingspanel();\n}\n```\n\n`match()` strips the configured base automatically and returns the full matched branch (root to leaf). data loaders are not executed.\n\n## load a path for ssr\n\nuse `router.load(url)` to load a full route state including data loader results without modifying router state or history. this is useful for server side data prefetching.\n\n```ts\nconst state = await router.load('/users/42');\n\nif (state) {\n const data = state.matches.at( 1)?.data;\n // serialize and send to the client\n}\n```\n\npass an `abortsignal` via the options object to cancel in flight loaders:\n\n```ts\nconst controller = new abortcontroller();\nconst state = await router.load('/users/42', { signal: controller.signal });\n```\n\n`load()` follows declarative redirects (up to five hops) and resolves lazy modules as a side effect.\n\n## state and subscriptions\n\n```ts\nrouter.subscribe((state) => {\n const leaf = state.matches.at( 1);\n document.title = (leaf?.meta as { title?: string } | undefined)?.title ?? 'app';\n});\n```\n\nuse `router.getsnapshot()` to read the current state synchronously:\n\n```ts\nconst { location, matches, status, error } = router.getsnapshot();\n\nlocation.pathname;\nlocation.query; // raw parsed query strings (queryparams)\nlocation.hash;\nlocation.historystate; // state from the current history entry\n\nmatches; // matched branch from root to leaf\nstatus; // 'idle' | 'loading' | 'streaming' | 'error'\nerror; // only set when status === 'error'\n```\n\neach match node also carries its own `status`:\n\n```ts\nmatches.at( 1)?.status; // 'idle' | 'loading' | 'streaming' | 'error'\n```\n\nthis lets nested layouts show per slot loading indicators without polling the top level status.\n\nthe state object is immutable. a successful navigation replaces it with a new snapshot.\n\n### `waitfor(name)`\n\nwait for the router to reach `status: 'idle'` with a specific route active. useful in tests and lifecycle coordination:\n\n```ts\n// navigate and wait for data to settle\nawait router.navigate({ name: 'userdetail', params: { id: '42' } });\nconst state = await router.waitfor('userdetail');\nconst user = state.matches.at( 1)?.data;\n```\n\n`waitfor` rejects immediately if the router is already in `status: 'error'`, and also rejects if `router.dispose()` is called while the promise is pending. resolves immediately if the named route is already active and idle.\n\n## scroll restoration\n\nprovide a `scroll` callback to control scroll position after each navigation:\n\n```ts\nconst router = createrouter({\n routes,\n scroll: (to, from) => {\n // return 'top' to scroll to top\n // return { x, y } for a specific position\n // return 'preserve' to do nothing\n return 'top';\n },\n});\n```\n\nthe callback receives the incoming state and the previous state, making it possible to implement saved position restore:\n\n```ts\nconst scrollpositions = new map<string, { x: number; y: number }>();\n\nrouter.subscribe((state) => {\n scrollpositions.set(state.location.pathname, { x: window.scrollx, y: window.scrolly });\n});\n\nconst router = createrouter({\n routes,\n scroll: (to, _from) => scrollpositions.get(to.location.pathname) ?? 'top',\n});\n```\n\n## testing\n\nuse `creatememoryhistory` to test routers without a browser:\n\n```ts\nimport { creatememoryhistory, createrouter } from '@vielzeug/wayfinder';\n\nconst history = creatememoryhistory('/dashboard');\nconst router = createrouter({ history, routes });\n\n// use waitfor to avoid manual timing:\nconst state = await router.waitfor('dashboard');\nassert(state.location.pathname === '/dashboard');\n\nrouter.dispose();\n```\n\n## cleanup\n\n```ts\nrouter.dispose();\n```\n\nremove listeners, clear subscribers, and prevent future router usage.\n\n## framework integration\n\nroute exposes `getsnapshot()` and `subscribe()`, which map directly to each framework's external store primitives. create the router once at module scope and bind actions outside the component lifecycle so references stay stable.\n\n::: code group\n\n```tsx [react]\nimport { createrouter } from '@vielzeug/wayfinder';\nimport { usesyncexternalstore } from 'react';\n\nconst router = createrouter({\n routes: {\n home: { component: homepage, path: '/' },\n settings: { component: settingspage, path: '/settings' },\n },\n notfound: { component: notfoundpage },\n});\n\n// stable router actions are safe to destructure outside the hook.\nconst { getsnapshot, isactive, navigate, subscribe, url } = router;\n\nexport function userouter() {\n const state = usesyncexternalstore(subscribe, getsnapshot);\n return { isactive, navigate, state, url };\n}\n\n// routerview.tsx\nexport function routerview() {\n const { state } = userouter();\n const component = state.matches.at( 1)?.component as react.componenttype | undefined;\n return component ? <component /> : null;\n}\n```\n\n```ts [vue 3]\nimport { createrouter } from '@vielzeug/wayfinder';\nimport { readonly, shallowref } from 'vue';\n\nconst router = createrouter({\n routes: {\n home: { component: homepage, path: '/' },\n settings: { component: settingspage, path: '/settings' },\n },\n notfound: { component: notfoundpage },\n});\n\n// shallowref — no need to deep track immutable route state.\nconst state = shallowref(router.getsnapshot());\nrouter.subscribe((next) => {\n state.value = next;\n});\n\nexport function userouter() {\n const { isactive, navigate, url } = router;\n\n return { isactive, navigate, state: readonly(state), url };\n}\n```\n\n```svelte [svelte]\n<! router.ts >\n<script lang=\"ts\" context=\"module\">\n import { createrouter } from '@vielzeug/wayfinder';\n import { readable } from 'svelte/store';\n\n const router = createrouter({\n routes: {\n home: { component: homepage, path: '/' },\n settings: { component: settingspage, path: '/settings' },\n },\n notfound: { component: notfoundpage },\n });\n\n // readable injects the initial value; subscribe() drives updates.\n export const routerstate = readable(router.getsnapshot(), (set) => router.subscribe(set));\n export const { isactive, navigate, url } = router;\n</script>\n```\n\n:::\n\nfor full routerview and routerlink patterns, see [react integration](./examples/react integration.md), [vue integration](./examples/vue integration.md), and [svelte integration](./examples/svelte integration.md).\n\n## debug mode\n\nimport `debugrouter` from the dedicated sub path to create a router with navigation logging pre enabled. the sub path is tree shaken from production bundles when not imported.\n\n```ts\nimport { debugrouter } from '@vielzeug/wayfinder/devtools';\n\nconst router = debugrouter({\n routes: {\n home: { path: '/' },\n dashboard: { path: '/dashboard', data: () => fetchdashboard() },\n },\n});\n\n// logged once the initial navigation completes:\n// [wayfinder:nav] idle / [home]\n\n// on navigate({ name: 'dashboard' }):\n// [wayfinder:nav] loading /dashboard\n// [wayfinder:nav] idle /dashboard [dashboard]\n```\n\nthe router returned is identical to `createrouter()` — all methods (`navigate`, `subscribe`, `waitfor`, etc.) work the same way.\n\nerrors are logged with the error object appended:\n\n```ts\n// [wayfinder:nav] error /dashboard [dashboard] error: fetch failed\n```\n\nuse the `label` option when running multiple routers to distinguish their log output:\n\n```ts\nconst main = debugrouter({ routes, label: 'main' });\nconst modal = debugrouter({ routes: modalroutes, label: 'modal' });\n// [wayfinder:main] loading /products\n// [wayfinder:modal] loading /confirm\n```\n\ndebug logging has no effect on behavior and should not be enabled in production.\n\n::: tip unhandled router errors\nif a route's data loader throws and no `onerror` callback is set on the router, the error is surfaced via `console.error` in development and silenced in production (`__wayfinder_prod__` set). always provide an `onerror` callback in production to handle errors explicitly.\n:::\n\n## working with other vielzeug libraries\n\n### with ward\n\nuse ward inside wayfinder middleware to guard protected routes.\n\n```ts\nimport { createrouter } from '@vielzeug/wayfinder';\nimport { createward } from '@vielzeug/ward';\n\ntype user = { id: string; roles: string[] };\n\nconst ward = createward([{ role: 'admin', resource: 'settings', action: 'view', effect: 'allow' }]);\n\nconst router = createrouter({\n middleware: [\n (ctx, next) => {\n const user: user = getsessionuser();\n if (!ward.can(user, 'settings', 'view')) return ctx.navigate({ path: '/login' }, { replace: true });\n return next();\n },\n ],\n routes: {\n settings: { path: '/settings' },\n },\n});\n```\n\n### with ripple\n\nsync router state to a ripple signal for reactive ui.\n\n```ts\nimport { createrouter } from '@vielzeug/wayfinder';\nimport { signal } from '@vielzeug/ripple';\n\nconst router = createrouter({\n /* ... */\n});\nconst currentroute = signal(router.getsnapshot().matches.at( 1)?.name ?? '');\n\nrouter.subscribe((state) => {\n currentroute.value = state.matches.at( 1)?.name ?? '';\n});\n```\n\n## best practices\n\n define the route table once at app startup and import it where needed.\n prefer named navigation (`router.navigate({ name: 'settings' })`) over raw paths.\n put auth and permission checks in middleware, not in data loaders.\n use `data()` loaders for route data and honor the provided `abortsignal`.\n use `onerror` on a route for degraded state rendering rather than a full redirect to an error page.\n use `notfound` in router options for the not found page rather than `path: '*'` in the route table.\n call `router.dispose()` when tearing down apps/tests to release listeners.\n use `creatememoryhistory()` for tests and non browser runtimes; avoid touching `window.history` directly.\n use `router.preload()` on hover for routes likely to be visited next.\n",
|
|
1524
|
+
"index": " \ntitle: wayfinder — client side router for typescript\ndescription: framework agnostic client side router with typed params, async data loading, middleware, leave guards, and view transitions support.\npackage: wayfinder\ncategory: routing\nkeywords: [router, client side, middleware, guards, navigation, history, spa, typed routes]\nrelated: [ripple, ward, herald]\nexports: [createrouter, createbrowserhistory, creatememoryhistory, redirectto, wayfindererror, wayfinderapierror, wayfinderdisposederror, wayfinderredirectlooperror, wayfinderrouteerror]\nenvironments: [browser, node, ssr, deno]\n \n\n<! markdownlint disable md025 md033 md060 >\n\n<packagehero package=\"wayfinder\" />\n\n## why wayfinder?\n\nmanaging navigation by hand means scattered `popstate` listeners, duplicated path checks, and no shared abstraction for loading data or blocking navigation. wayfinder moves all of that into one declarative table.\n\n```ts\n// before — manual navigation with popstate\nwindow.addeventlistener('popstate', () => {\n const path = window.location.pathname;\n if (path === '/') renderhome();\n else if (path.startswith('/dashboard')) renderdashboard();\n else rendernotfound();\n});\ndocument.queryselectorall('a[data route]').foreach((a) => {\n a.addeventlistener('click', (e) => {\n e.preventdefault();\n history.pushstate({}, '', (e.currenttarget as htmlanchorelement).href);\n dispatchevent(new popstateevent('popstate'));\n });\n});\n\n// after — with wayfinder\nimport { createrouter } from '@vielzeug/wayfinder';\n\nconst router = createrouter({\n routes: {\n home: { path: '/' },\n dashboard: { path: '/dashboard' },\n },\n notfound: { component: notfoundpage },\n});\n\nrouter.subscribe((state) => {\n render(state.matches.at( 1)?.component);\n});\n```\n\n<div class=\"decision callout\">\n\n**use wayfinder when** you need named navigation, route level data loading with cancellation, middleware, or leave guards in a framework agnostic setup.\n\n**consider a framework's built in router when** you are deep in a single framework ecosystem (react router, vue router) and want first class component binding with no adapter layer.\n\n</div>\n\n| feature | wayfinder | page.js | navigo |\n| | | | |\n| bundle size | <packageinfo package=\"wayfinder\" type=\"size\" /> | ~1 kb | ~5 kb |\n| history mode | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> |\n| memory history (tests / non browser) | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> |\n| typed path params | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> |\n| named navigation | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> | partial |\n| middleware | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> |\n| data loaders with abortsignal | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> |\n| lazy route loading | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> |\n| declarative redirects | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> |\n| search param validation | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> |\n| error in state | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> |\n| history state in context | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> |\n| leave guards | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> |\n| hover prefetching (`preload()`) | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> |\n| scroll restoration | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> |\n| view transition api | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> |\n| zero dependencies | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> |\n\n## installation\n\n::: code group\n\n```sh [pnpm]\npnpm add @vielzeug/wayfinder\n```\n\n```sh [npm]\nnpm install @vielzeug/wayfinder\n```\n\n```sh [yarn]\nyarn add @vielzeug/wayfinder\n```\n\n:::\n\n## quick start\n\ncreate a memory backed router, wait for initial routing, then navigate by name.\n\n```ts\nimport { creatememoryhistory, createrouter } from '@vielzeug/wayfinder';\n\nconst router = createrouter({\n history: creatememoryhistory('/'),\n routes: {\n home: { path: '/' },\n settings: { path: '/settings' },\n },\n});\n\nawait router.ready;\nawait router.navigate({ name: 'settings' });\nconsole.log(router.getsnapshot().location.pathname); // /settings\nrouter.dispose();\n```\n\n## features\n\n<div class=\"features grid\">\n\n `createrouter()` — compiles named, nested route tables.\n `navigate()` — commits route changes after middleware reaches its terminal stage.\n `ready` — signals that initial routing has settled.\n `data()` — receives cancellation through `abortsignal` and can stream async generator updates.\n `beforeleave()` — blocks route exits before history changes.\n `match()` / `load()` — inspect routes synchronously or load route data without navigation.\n `preload()` — warms route data for a later matching navigation.\n `creatememoryhistory()` — runs routers in tests and non browser environments.\n `subscribe()` — reactive subscription to navigation state changes.\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/) — reactive signals; sync router state to a signal for framework agnostic reactivity\n [ward](/ward/) — permission guards; use inside wayfinder middleware to protect routes\n [herald](/herald/) — event bus; dispatch route change events to decouple navigation side effects\n\n</div>\n\n<! markdownlint enable md025 md033 md060 >\n",
|
|
1525
|
+
"api": " \ntitle: wayfinder — api reference\ndescription: complete api reference for wayfinder.\n \n\n[[toc]]\n\n## api overview\n\n| symbol | purpose | execution mode | common gotcha |\n| | | | |\n| `createrouter(options)` | create a router from a route table | sync | initial navigation starts asynchronously in the constructor |\n| `createbrowserhistory()` | create the default browser history driver | sync | — |\n| `creatememoryhistory(initialpath?)` | create an in memory history driver | sync | — |\n| `redirectto(target, options?)` | build redirect middleware | sync (returns fn) | does not call `next()` — always short circuits the chain |\n| `router.navigate(target, options?)` | navigate to a named route, raw path object, or string path | async | no op when destination equals current url unless `force: true` |\n| `router.getsnapshot()` | return the current immutable route state | sync | does not subscribe — call `subscribe()` to react to changes |\n| `router.subscribe(listener)` | register a listener for state changes | sync (returns unsub) | listener is **not** called immediately with current state |\n| `router.url(name, params?, query?)` | build a url for a named route | sync | throws if the route name is unknown |\n| `router.isactive(name, options?)` | check if a named route matches the current url | sync | compares against the current snapshot pathname, not `history.location` directly |\n| `router.match(pathname)` | inspect a pathname as a branch without side effects | sync | returns `null` for redirect routes |\n| `router.load(url, options?)` | load a url into a full state including data loaders | async | middleware is not executed; lazy modules are resolved as a side effect |\n| `router.ready` | await the initial navigation | async | rejects when initial loading fails |\n| `router.preload(name, params?, query?)` | eagerly run data loaders without navigating | async | pass `query` to match the navigation cache key; rejects with `wayfinderdisposederror` if the router is disposed |\n| `router.waitfor(name)` | wait for the router to settle on a named route | async | rejects immediately if `status === 'error'`; rejects with `wayfinderdisposederror` if disposed while pending |\n| `router.beforeleave(blocker, options?)` | register a global leave guard | sync (returns unsub) | scoped to specific routes via `options.routes` |\n| `router.dispose()` | remove listeners and shut down the router | sync | idempotent — safe to call multiple times |\n\n## package entry points\n\n| import | purpose |\n| | |\n| `@vielzeug/wayfinder` | main exports and types |\n\n## `createrouter(options)`\n\n```ts\nimport { createrouter } from '@vielzeug/wayfinder';\n\nconst router = createrouter({\n base: '/app',\n routes: {\n home: { path: '/' },\n dashboard: {\n path: '/dashboard',\n children: {\n index: { index: true },\n settings: { path: 'settings', data: () => fetchsettings() },\n },\n },\n },\n notfound: { component: notfoundpage },\n});\n```\n\n| option | type | default | description |\n| | | | |\n| `base` | `string` | `'/'` | base path prefix for all routes |\n| `coercesearch` | `coercesearchfn` | — | global search param coercion applied to every route that does not define its own `coercesearch`. throwing falls back to raw strings and is reported via `onerror`. |\n| `history` | `historydriver` | `createbrowserhistory()` | history source used for reading locations and writing navigations |\n| `middleware` | `middleware[]` | `[]` | global middleware prepended to every route |\n| `notfound` | `{ component?, data?, meta?, middleware? }` | — | synthetic route used when no path matches. global middleware runs first, then `notfound.middleware` and `notfound.data`. `ctx.pathname` is the unmatched path. |\n| `onerror` | `(error, context: routererrorcontext) => void` | — | optional sink for non awaited/background router errors |\n| `routes` | `routetable` | required | declarative route table. object key order defines match precedence. |\n| `scroll` | `(to, from) => scrolldecision` | — | called after each navigation. return `'top'` to scroll to top, `'preserve'` to keep the current position, or `{ x, y }` for a specific position. |\n| `viewtransition` | `boolean` | `false` | wrap navigations in the view transition api when available |\n\n**returns:** `router`\n\n## route table\n\ndefine routes as a plain object where keys become route names. typescript will infer route params from literal `path` strings.\n\n```ts\nconst routes = {\n home: { path: '/' },\n userdetail: { path: '/users/:id' },\n files: { path: '/files/:rest*' },\n};\n```\n\nnested routes are declared with `children`, and child names become compound names with dot notation.\n\n## route definition\n\n```ts\nconst routes = {\n home: { path: '/' },\n dashboard: {\n path: '/dashboard',\n middleware: [requireauth],\n children: {\n index: { index: true },\n settings: {\n path: 'settings',\n data: async () => fetchsettings(),\n },\n },\n },\n userdetail: {\n path: '/users/:id',\n meta: { section: 'users' },\n data: async ({ params }) => fetchuser(params.id),\n onerror: (error) => ({ error, user: null }),\n },\n};\n```\n\neach route definition supports these fields:\n\n| field | type | description |\n| | | |\n| `path` | `string` | wayfinder pattern. supports static paths, `:param`, `:param*`, and `*`. child paths are relative unless they start with `/`. |\n| `children` | `record<string, routedefinition>` | nested child routes. child names are appended to the parent route name. |\n| `index` | `boolean` | default child route that inherits the parent path. |\n| `component` | `unknown` | optional framework view payload exposed on the leaf `routematch`. |\n| `data` | `datafn` | data loader. runs after middleware; result available as `match.data`. supports streaming via `asyncgenerator`. |\n| `lazy` | `() => promise<{ data?, component?, meta? }>` | lazy load the route module. called once on first navigation; result overrides static fields in the hydration cache. |\n| `meta` | `unknown` | static metadata exposed on each `routematch` in the branch. |\n| `middleware` | `middleware[]` | optional route specific middleware |\n| `onerror` | `(error, context: datacontext) => maybepromise<unknown>` | per route error boundary for data loader failures. return value becomes `match.data` for degraded rendering. |\n| `redirect` | `navigationtarget` | declarative redirect. resolved before middleware runs; uses `replacestate` so the original url is never added to history. |\n| `coercesearch` | `(raw: queryparams) => resolvedqueryparams` | coerce raw url string values into typed values. return value replaces `ctx.query`. throwing leaves the parsed query unchanged. |\n\n## `createbrowserhistory()`\n\n```ts\nimport { createbrowserhistory } from '@vielzeug/wayfinder';\n\nconst history = createbrowserhistory();\n```\n\ncreate the default `historydriver` backed by the browser history api.\n\n## `creatememoryhistory(initialpath?)`\n\n```ts\nimport { creatememoryhistory } from '@vielzeug/wayfinder';\n\n// tests\nconst router = createrouter({\n history: creatememoryhistory('/dashboard'),\n routes,\n});\n\n// controlled non browser runtime\nconst router = createrouter({\n history: creatememoryhistory('/request path'),\n routes,\n});\n```\n\ncreate an in memory `historydriver`. no browser history globals required — suitable for unit tests and controlled non browser runtimes. the optional `initialpath` defaults to `'/'`.\n\n## `router`\n\n### lifecycle\n\n#### `router.dispose()`\n\nremove listeners, clear subscribers, and reject future router interaction. idempotent — safe to call multiple times.\n\n**returns:** `void`\n\n**throws:** never.\n\n \n\n#### `router.disposed`\n\n`boolean` — `true` after `dispose()` has been called.\n\n \n\n#### `router.disposalsignal`\n\n`abortsignal` that is aborted (with a `wayfinderdisposederror` reason) when the router is disposed. use this to tie external resource lifetimes to the router's lifetime.\n\n```ts\nsource.on('update', syncrouteparams, { signal: router.disposalsignal });\n```\n\n \n\n### navigation\n\n#### `router.navigate(target, options?)`\n\n```ts\nawait router.navigate({ name: 'userdetail', params: { id: '42' } });\nawait router.navigate({ name: 'userdetail', params: { id: '42' } }, { replace: true });\nawait router.navigate({ name: 'search', query: { q: 'wayfinder' }, hash: 'results' });\n```\n\n| option | type | default | description |\n| | | | |\n| `replace` | `boolean` | `false` | use `replacestate` instead of `pushstate` |\n| `state` | `unknown` | — | history state payload |\n| `viewtransition` | `boolean` | — | override the router level setting for this navigation |\n| `force` | `boolean` | `false` | re run even when the destination url is already current |\n\n**returns:** `promise<void>`\n\nhistory is written only after middleware reaches the terminal stage. returning from middleware without `next()` cancels the programmatic navigation without changing history or the route snapshot.\n\nnamed routes stay the primary api, but `navigate()` also accepts raw path objects or a plain string:\n\n```ts\nawait router.navigate({ path: '/marketing?utm_source=campaign' });\nawait router.navigate({ path: '/checkout#payment' }, { replace: true });\n\n// plain string — most concise for direct paths\nawait router.navigate('/about');\nawait router.navigate('/search?q=hello');\n```\n\n \n\n### route helpers\n\n#### `router.url(name, params?, query?)`\n\n```ts\nrouter.url('userdetail', { id: '42' });\nrouter.url('userdetail', { id: '42' }, { tab: 'profile' });\n```\n\nbuild a base aware url for a named route.\n\n**returns:** `string`\n\n#### `router.isactive(name, options?)`\n\n```ts\nrouter.isactive('userdetail');\nrouter.isactive('users');\nrouter.isactive('users', { exact: true });\n```\n\ncheck whether the current pathname matches a named route exactly or by prefix.\n\n**returns:** `boolean`\n\n#### `router.match(pathname)`\n\n```ts\nrouter.match('/app/dashboard/settings');\n// => [\n// { name: 'dashboard', ... },\n// { name: 'dashboard.settings', ... },\n// ]\n```\n\ninspect a pathname without running middleware, data loaders, or subscribers. strips the configured `base` automatically. returns the matched branch from root to leaf, or `null` for redirect routes and no match.\n\n**returns:** `routematchbranch | null`\n\n \n\n#### `router.load(url, options?)`\n\n```ts\n// ssr data prefetch\nconst state = await router.load('/users/42');\n\n// with cancellation\nconst controller = new abortcontroller();\nconst state = await router.load('/dashboard', { signal: controller.signal });\n```\n\nload a full url into a `routestate` including data loader results, without modifying router state or history. follows declarative redirects (up to five hops) and resolves lazy modules as a side effect. returns `null` for unmatched urls.\n\nmiddleware is **not** executed — `load` is a data only prefetch for ssr and pre rendering where middleware side effects are not wanted. if your data loaders depend on `ctx.locals` set by middleware, use `navigate()` instead.\n\nwhen a `data()` function throws, the returned state has `status: 'error'` and `error` set to the thrown value.\n\n**returns:** `promise<routestate | null>`\n\n \n\n#### `router.waitfor(name)`\n\n```ts\n// navigate and wait for data to settle\nawait router.navigate({ name: 'userdetail', params: { id: '42' } });\nconst state = await router.waitfor('userdetail');\nconst user = state.matches.at( 1)?.data;\n\n// useful in tests with memory history:\nconst history = creatememoryhistory('/dashboard');\nconst router = createrouter({ history, routes });\nconst state = await router.waitfor('dashboard');\n```\n\nwaits for the router to reach `status: 'idle'` with the named route active in the matched branch. rejects immediately if `status === 'error'`. resolves immediately if the router is already idle on the target route. also rejects if `router.dispose()` is called while the promise is pending.\n\n> **note:** `waitfor` skips intermediate `'streaming'` states — it only resolves once the status reaches `'idle'`. it does not resolve while the route is still streaming partial data.\n\n**returns:** `promise<routestate>`\n\n \n\n#### `router.preload(name, params?, query?)`\n\n```ts\n// hover prefetch without query\nanchor.addeventlistener('mouseenter', () => {\n router.preload('userdetail', { id: '42' });\n});\n\n// hover prefetch with matching query to avoid a cache miss\nanchor.addeventlistener('mouseenter', () => {\n router.preload('search', undefined, { q: 'hello' });\n});\n```\n\neagerly runs the data loaders for a named route without navigating. useful for hover prefetch. concurrent calls for the same `name + params + query` combination are deduplicated. results are consumed on the next navigation to the same route with the same cache key.\n\npass the same `query` you intend to navigate with to ensure the preloaded result hits the cache. without `query`, the key is the bare path — a navigation with a query string will produce a cache miss and re run the loader.\n\nin flight preloads are aborted automatically via the router's disposal signal when `router.dispose()` is called. calling `preload()` on an already disposed router throws `wayfinderdisposederror` immediately, without running the data loader — consistent with `navigate()`, `subscribe()`, `beforeleave()`, and `waitfor()`.\n\n**returns:** `promise<void>`\n\n \n\n#### `router.beforeleave(blocker, options?)`\n\n```ts\n// guard unsaved changes forms\nconst remove = router.beforeleave(async (destination) => {\n if (!form.isdirty) return true;\n return confirm(`leave without saving? (going to ${destination.pathname})`);\n});\n\n// remove the guard when the form unmounts\nremove();\n```\n\nregister a global leave guard called before user triggered navigation attempts. return `true` to allow, `false` to cancel. multiple guards can be registered; navigation is blocked if any guard returns `false`.\n\nscope a guard to fire only when leaving specific routes using the `routes` option:\n\n```ts\nrouter.beforeleave(async () => confirm('discard changes?'), { routes: ['editor'] });\n```\n\nthe guard fires when the router is leaving any route whose name appears in the `routes` array (any node in the active branch, not just the leaf). declarative `redirect` routes bypass all leave guards.\n\n**returns:** `() => void`\n\n## `redirectto(target, options?)`\n\n```ts\nimport { redirectto } from '@vielzeug/wayfinder';\n\nconst requireauth = redirectto({ name: 'login' }, { replace: true });\n```\n\ncreates middleware that navigates to `target` and short circuits the middleware chain (does not call `next()`). useful for auth guards and route aliases in middleware.\n\nfor permanent declarative redirects (url aliases), use the `redirect` field on the route definition instead.\n\n> **note:** `redirectto()` internally calls `ctx.navigate()`, which runs `beforeleave` guards. if a guard blocks navigation, the redirect will not complete. declarative `redirect` on a route definition bypasses guards entirely.\n\n**returns:** `middleware`\n\n \n\n### state\n\n#### `router.ready`\n\na `promise<void>` for the constructor triggered navigation. it resolves after initial middleware, redirects, lazy modules, and data loaders settle. it resolves after a blocked or unmatched initial navigation, and rejects if initial navigation fails.\n\n```ts\nconst router = createrouter({ routes });\nawait router.ready;\n```\n\n \n\n#### `router.getsnapshot()`\n\nreturns the current immutable route state snapshot. use this to read state synchronously. compatible with react's `usesyncexternalstore`:\n\n```ts\nconst state = usesyncexternalstore(\n (cb) => router.subscribe(cb),\n () => router.getsnapshot(),\n);\n```\n\n```ts\nconst { location, matches, status, error } = router.getsnapshot();\n\nlocation.pathname;\nlocation.query; // raw parsed query (queryparams) — always string values\nlocation.hash;\nlocation.historystate; // value passed to navigate({ ... }, { state: ... })\n\n// when status === 'error':\nconsole.error(error);\n```\n\n`error` is only set when `status === 'error'`. it holds the exact value thrown by the failing `data()` function.\n\n**returns:** `routestate`\n\n#### `router.subscribe(listener)`\n\n```ts\nconst unsubscribe = router.subscribe((state) => {\n const leaf = state.matches.at( 1);\n document.title = (leaf?.meta as { title?: string } | undefined)?.title ?? 'app';\n});\n```\n\nregister a listener for future state changes, including loading and streaming updates. the listener is **not** called with the current snapshot — call `router.getsnapshot()` when you subscribe if you need it.\n\n**returns:** `() => void`\n\n## types\n\n### `routecontext<params, troutes>`\n\ncontext passed to middleware and data loader functions.\n\n```ts\ntype routecontext<params extends routeparams = routeparams, troutes extends routetable = routetable> = {\n readonly hash: string;\n /** state stored on the history entry that triggered this navigation. */\n readonly historystate: unknown;\n locals: record<string, unknown>;\n readonly matches: routematchbranch;\n readonly navigate: (\n target: namednavigationtarget<troutes> | rawnavigationtarget | string,\n options?: navigateoptions,\n ) => promise<void>;\n readonly params: params;\n readonly pathname: string;\n readonly query: resolvedqueryparams;\n};\n```\n\nread route metadata from the leaf match: `ctx.matches.at( 1)?.meta`.\n\n`ctx.locals` is mutable and shared across the entire middleware chain for one navigation. use it to pass values from middleware to data loaders.\n\n`ctx.query` is the coerced query (after `coercesearch`). `router.getsnapshot().location.query` always contains raw string values from url parsing.\n\n### `datafn<params, troutes>`\n\n```ts\ntype datafn<params extends routeparams = routeparams, troutes extends routetable = routetable> = (\n context: datacontext<params, troutes>,\n) => datastream | maybepromise<unknown>;\n```\n\nreturn an `asyncgenerator` to stream partial results (see `datastream`).\n\n### `datacontext<params, troutes>`\n\n```ts\ntype datacontext<params extends routeparams = routeparams, troutes extends routetable = routetable> = routecontext<\n params,\n troutes\n> & {\n readonly signal: abortsignal;\n};\n```\n\n### `datastream<t>`\n\n```ts\ntype datastream<t = unknown> = asyncgenerator<t, t>;\n```\n\nreturn a `datastream` from a `data()` function to stream partial results. each `yield` updates `match.data` immediately with `match.status: 'streaming'`. the `return` value is the final settled data with `match.status: 'idle'`.\n\n```ts\ndata: async function* ({ signal }) {\n const items: item[] = [];\n for await (const batch of streambatches({ signal })) {\n items.push(...batch);\n yield items; // partial — status: 'streaming'\n }\n return items; // final — status: 'idle'\n},\n```\n\n### `middleware<troutes>`\n\n```ts\ntype middleware<troutes extends routetable = routetable> = (\n context: routecontext<routeparams, troutes>,\n next: () => promise<void>,\n) => void | promise<void>;\n```\n\nmiddleware ordering is simple: global middleware first, then route middleware, then `data()`.\n\n### `untypednamednavigationtarget`\n\n```ts\ntype untypednamednavigationtarget = {\n hash?: string;\n name: string;\n params?: routeparams;\n query?: resolvedqueryparams;\n};\n```\n\n### `navigationtarget`\n\n```ts\ntype navigationtarget =\n | {\n path: string;\n }\n | {\n hash?: string;\n name: string;\n params?: routeparams;\n query?: resolvedqueryparams;\n };\n```\n\n### `navigateoptions`\n\n```ts\ntype navigateoptions = {\n force?: boolean;\n replace?: boolean;\n state?: unknown;\n viewtransition?: boolean;\n};\n```\n\n### `routestate`\n\n```ts\ntype routestate = {\n /** the value thrown by a `data()` function. only set when `status === 'error'`. */\n readonly error?: unknown;\n readonly location: routelocation;\n readonly matches: readonly routematch[];\n readonly status: navigationstatus;\n};\n\ntype routelocation = {\n readonly hash: string;\n /** state stored on the history entry that triggered this navigation. */\n readonly historystate: unknown;\n readonly pathname: string;\n /** raw parsed query params — always string values from url parsing.\n * for coerced values (numbers, booleans), read `ctx.query` inside middleware or data loaders.\n */\n readonly query: queryparams;\n};\n```\n\n### `routematch`\n\n```ts\ntype routematch = {\n readonly component: unknown;\n readonly data: unknown;\n readonly meta: unknown;\n readonly name: string;\n readonly params: routeparams;\n readonly pathname: string;\n /** per node loading status. reflects individual loader state in nested layouts. */\n readonly status: navigationstatus;\n};\n```\n\n### `routematchbranch`\n\n```ts\ntype routematchbranch = readonly routematch[];\n```\n\n### `pathparams<t>`\n\n```ts\ntype userparams = pathparams<'/users/:id'>;\n// => { readonly id: string }\n\ntype fileparams = pathparams<'/files/:rest*'>;\n// => { readonly rest: string }\n```\n\n### `queryparams`\n\n```ts\ntype queryparams = record<string, string | string[]>;\n```\n\nrepresents parsed url query values before route level coercion.\n\n### `resolvedqueryparams`\n\n```ts\ntype resolvedqueryvalue = string | number | boolean;\ntype resolvedqueryparams = record<string, resolvedqueryvalue | resolvedqueryvalue[]>;\n```\n\nrepresents the query object after optional `coercesearch` normalization.\n\n### `navigationstatus`\n\n```ts\ntype navigationstatus = 'idle' | 'loading' | 'streaming' | 'error';\n```\n\ntop level status of the router. `'streaming'` means at least one active data loader is an async generator and has yielded at least one value but has not yet returned.\n\neach `routematch` also carries a `status: navigationstatus` for per node loading state in nested layouts.\n\n### `routemiddleware<path, troutes>`\n\n```ts\ntype routemiddleware<path extends string = string, troutes extends routetable = routetable> = (\n context: routecontext<pathparams<path>, troutes>,\n next: () => promise<void>,\n) => void | promise<void>;\n```\n\ntyped variant of `middleware` scoped to a route path. provides typed `ctx.params` matching the path pattern.\n\n```ts\nconst guard: routemiddleware<'/users/:id'> = (ctx, next) => {\n console.log(ctx.params.id); // string\n return next();\n};\n```\n\n### `coercesearchfn<q>`\n\n```ts\ntype coercesearchfn<q extends resolvedqueryparams = resolvedqueryparams> = (\n raw: queryparams,\n) => q;\n```\n\nfunction signature for both the per route `coercesearch` field and the global `routeroptions.coercesearch` option. receives raw url strings and returns typed values. throwing inside the function falls back to the original raw query.\n\n### `beforeleaveoptions<troutes>`\n\n```ts\ntype beforeleaveoptions<troutes extends routetable = routetable> = {\n /** route names that trigger this guard. omit for a global guard. */\n routes?: routename<troutes>[];\n};\n```\n\npassed as the second argument to `router.beforeleave()`. when `routes` is provided, the guard only fires when the router leaves a route whose name is in the array.\n\n### `beforeleaveblocker`\n\n```ts\n// return true to allow navigation, false to cancel.\ntype beforeleaveblocker = (destination: navigationdestination) => maybepromise<boolean>;\n```\n\n### `navigationdestination`\n\n```ts\ntype navigationdestination = {\n readonly name?: string; // route name if navigating to a named route\n readonly params: routeparams;\n readonly pathname: string;\n readonly query: queryparams;\n};\n```\n\npassed to every `beforeleave` blocker. use `destination.pathname` and `destination.query` to make context aware allow/block decisions.\n\n### `isactiveoptions`\n\n```ts\ntype isactiveoptions = {\n /** require an exact pathname match. defaults to prefix matching. */\n exact?: boolean;\n};\n```\n\n### `scrolldecision`\n\n```ts\ntype scrollposition = { x: number; y: number };\ntype scrolldecision = scrollposition | 'preserve' | 'top';\n```\n\n### `routererrorcontext`\n\n```ts\ntype routererrorcontext =\n | { routename: string; source: 'data loader' } // data() threw\n | { routename: string; source: 'middleware' } // middleware threw\n | { source: 'coerce search' | 'history listener' | 'initial navigation' | 'preload' };\n```\n\npassed to the `onerror` callback in `createrouter` options. the `routename` is present when the error originates from a named route's `data()` or `middleware`.\n\n### `historydriver`\n\n```ts\ninterface historydriver {\n readonly location: {\n readonly hash: string;\n readonly pathname: string;\n readonly search: string;\n readonly state: unknown;\n };\n /** navigate one entry back in history, equivalent to the browser back button. */\n back(): void;\n push(url: string, state?: unknown): void;\n replace(url: string, state?: unknown): void;\n /**\n * subscribe to backwards/forwards navigation (popstate equivalent).\n * `push()` and `replace()` are silent — they do not notify subscribers.\n * only `back()` (and browser popstate events) trigger notifications.\n * returns an unsubscribe function.\n */\n onpopstate(listener: () => void): () => void;\n}\n```\n\n### `routedefinition<path>`\n\n```ts\ntype routedefinition<path extends string = string> =\n | contentroutedefinition<path> // path + data/component/meta/middleware/coercesearch/lazy/onerror\n | redirectroutedefinition<path>; // path + redirect\n```\n\nthe union type for a single entry in the route table. use this to type externally defined route objects:\n\n```ts\nimport type { routedefinition } from '@vielzeug/wayfinder';\n\nconst userdetail: routedefinition<'/users/:id'> = {\n path: '/users/:id',\n data: async ({ params }) => fetchuser(params.id),\n};\n```\n\n### `routeroptions<troutes>`\n\nthe options object accepted by `createrouter()`. see the [`createrouter(options)`](#createrouter options) options table above for the full field reference.\n\n```ts\nimport type { routeroptions } from '@vielzeug/wayfinder';\n\nconst options: routeroptions<typeof routes> = {\n routes,\n base: '/app',\n};\n```\n\n### `unsubscribe`\n\n```ts\ntype unsubscribe = () => void;\n```\n\n## errors\n\n### `wayfindererror`\n\nbase class for every error wayfinder throws. catch this to handle any router originated error without enumerating subclasses.\n\n```ts\nimport { wayfindererror } from '@vielzeug/wayfinder';\n\ntry {\n await router.navigate({ name: 'home' });\n} catch (e) {\n if (e instanceof wayfindererror) {\n // any router originated error — check e.name or `instanceof` a subclass for detail\n }\n}\n```\n\n### `wayfinderdisposederror`\n\nthrown when `navigate()`, `subscribe()`, `beforeleave()`, `waitfor()`, or `preload()` is called after `dispose()`. also used as the `abortsignal.reason` on `disposalsignal`.\n\n```ts\nimport { wayfinderdisposederror } from '@vielzeug/wayfinder';\n\ntry {\n await router.navigate({ name: 'home' });\n} catch (e) {\n if (e instanceof wayfinderdisposederror) {\n // router was disposed\n }\n}\n```\n\n### `wayfinderrouteerror`\n\nthrown for malformed route definitions — at `createrouter()` time for config errors, or when a `url()`/`navigate()` call references an unknown route name or a missing path param.\n\n### `wayfinderredirectlooperror`\n\nthrown when a chain of declarative `redirect`s (or a mix of declarative redirects and `ctx.navigate()` calls inside route middleware) exceeds 5 hops.\n\n### `wayfinderapierror`\n\nthrown on middleware misuse — currently only when a middleware function calls its `next()` more than once.\n\n### runtime error messages\n\n| message | class | when |\n| | | |\n| `router is disposed` | `wayfinderdisposederror` | calling a guarded method (see above) after `dispose()` |\n| `unknown route name: x. available routes: y` | `wayfinderrouteerror` | navigating to, resolving, or building a url for an unregistered route |\n| `route \"x\" cannot define both index and path` | `wayfinderrouteerror` | a route sets `index: true` and `path` at the same time |\n| `route \"x\" must define path or set index: true` | `wayfinderrouteerror` | a route defines neither `index: true` nor `path` |\n| `duplicate route name: \"x\"` | `wayfinderrouteerror` | two routes resolve to the same compound name during `createrouter()` |\n| `missing path param: x` | `wayfinderrouteerror` | `url()`/`navigate()`/`preload()` omits a param the path pattern requires |\n| `invalid param name \":x\" in path \"y\"` | `wayfinderrouteerror` | a param name contains non word characters (e.g., `:user id`) |\n| `wildcard \"*\" must be the final segment in path: x` | `wayfinderrouteerror` | a `*` segment appears before the last segment |\n| `wildcard param must be final segment in path: x` | `wayfinderrouteerror` | a `:param*` greedy param appears before the last segment |\n| `redirect loop detected` | `wayfinderredirectlooperror` | a declarative `redirect` chain (or mixed redirect + `navigate()`) exceeds 5 hops |\n| `next() called multiple times` | `wayfinderapierror` | middleware calls its `next()` callback more than once |\n\n## pattern rules\n\n| pattern | example | meaning |\n| | | |\n| `/about` | `/about` | exact static path |\n| `/users/:id` | `/users/42` | single named param |\n| `/users/:userid/posts/:postid` | `/users/1/posts/2` | multiple named params |\n| `/docs/*` | `/docs/guide/intro` | wildcard suffix without a named capture |\n| `/files/:rest*` | `/files/a/b/c` | wildcard suffix captured as one named param |\n| `*` | anything | global catch all |\n\n## design notes\n\n wayfinder no longer exposes imperative registration methods like `on()`, `group()`, or `use()`.\n wayfinder names come from the route table object keys.\n `data()` is the terminal action. its return value becomes `match.data`. there is no separate `handler` step.\n for unmatched urls, use the `notfound` router option rather than `path: '*'` in the route table.\n error handling is middleware that wraps `await next()`. the thrown error is also stored on `router.getsnapshot().error`.\n declarative `redirect` on a route definition is for permanent alias redirects. the `redirectto()` middleware helper is for conditional guards.\n `lazy` factories are called at most once per `routerecord`. the loaded `data`/`component`/`meta` are stored in the router's internal hydration cache. `handler` is not accepted in the lazy resolved module.\n `onerror` in a route definition is a per route data loader boundary. if `onerror` itself throws, the router falls through to `status: 'error'` as usual.\n",
|
|
1526
|
+
"usage": " \ntitle: wayfinder — usage guide\ndescription: router setup, middleware, data loading, nested routes, and state patterns for wayfinder.\n \n\n[[toc]]\n\n::: tip new to wayfinder?\nstart with the [overview](./index.md), then use this page for the day to day api.\n:::\n\n## basic usage\n\ncreate a deterministic router with memory history, wait for startup, and navigate by route name.\n\n```ts\nimport { creatememoryhistory, createrouter } from '@vielzeug/wayfinder';\n\nconst router = createrouter({\n history: creatememoryhistory('/'),\n routes: {\n home: { path: '/' },\n settings: {\n data: async () => ({ section: 'settings' }),\n path: '/settings',\n },\n },\n});\n\nawait router.ready;\nawait router.navigate({ name: 'settings' });\nconsole.log(router.getsnapshot().matches.at( 1)?.data);\nrouter.dispose();\n```\n\n`routes` is required. route keys become names, and object key order controls match precedence.\n\n## define routes\n\neach route can provide these fields:\n\n| field | purpose |\n| | |\n| `path` | match pattern |\n| `children` | nested child routes |\n| `index` | default child route that inherits the parent path |\n| `component` | optional view payload exposed on `match.component` |\n| `data` | abortable route data function. result available as `match.data`. supports streaming via `asyncgenerator`. |\n| `lazy` | lazy load the module. called once; result fills `data`, `component`, and `meta`. |\n| `meta` | static metadata exposed on `match.meta` |\n| `middleware` | route specific middleware |\n| `onerror` | per route error boundary. called when this route's `data()` throws; its return value becomes `match.data`. |\n| `redirect` | declarative permanent redirect. resolved before middleware runs. |\n| `coercesearch` | coerce raw url search strings into typed values. return value replaces `ctx.query`. throw to leave the raw query unchanged. |\n\nuse wildcard routes for fallback behavior:\n\n```ts\nconst routes = {\n docs: { path: '/docs/*' },\n};\n```\n\nfor a catch all not found page, use the `notfound` option in router options instead of a `path: '*'` route:\n\n```ts\nconst router = createrouter({\n routes,\n notfound: {\n component: notfoundpage,\n data: async ({ pathname }) => ({ requestedpath: pathname }),\n },\n});\n```\n\nalternatively, `path: '*'` still works as a named route when you need to navigate to it explicitly.\n\nnested routes compose naturally and create compound route names:\n\n```ts\nconst routes = {\n dashboard: {\n path: '/dashboard',\n children: {\n index: { index: true },\n settings: { path: 'settings' },\n },\n },\n};\n\nawait router.navigate({ name: 'dashboard.settings' });\n```\n\n## route context\n\nmiddleware and data loaders receive a `routecontext`:\n\n```ts\nuserdetail: {\n path: '/users/:id',\n middleware: [\n (ctx, next) => {\n ctx.params.id; // typed to path params\n ctx.query.tab; // resolved query (after coercesearch)\n ctx.pathname;\n ctx.hash;\n ctx.historystate; // value from navigate({ ... }, { state: ... })\n ctx.locals; // mutable bag shared across the middleware chain\n ctx.navigate; // programmatic navigation\n return next();\n },\n ],\n data: async (ctx) => {\n ctx.signal; // abortsignal — cancelled when navigation is superseded\n return fetchuser(ctx.params.id, { signal: ctx.signal });\n },\n}\n```\n\n`ctx.locals` is mutable and shared through the entire middleware chain for one navigation. use it to pass values from middleware to data loaders.\n\n## middleware\n\nmiddleware wraps the navigation using the familiar `async (ctx, next) => { ... }` shape.\n\n```ts\nconst requireauth = redirectto({ name: 'login' }, { replace: true });\n\nconst loadcurrentuser = async (ctx, next) => {\n ctx.locals.user = await fetchcurrentuser();\n await next();\n};\n```\n\norder is fixed and simple:\n\n```text\nglobal middleware\n ↓\nroute middleware\n ↓\ndata()\n```\n\n### guards\n\nuse middleware for auth checks, redirects, analytics, and boundaries.\n\n```ts\nconst requireauth = async (ctx, next) => {\n if (!session.currentuser) {\n await ctx.navigate({ name: 'login' }, { replace: true });\n return; // do not call next()\n }\n ctx.locals.user = session.currentuser;\n await next();\n};\n```\n\nfor unconditional redirects, use the `redirectto()` helper:\n\n```ts\nimport { redirectto } from '@vielzeug/wayfinder';\n\nconst requireauth = redirectto({ name: 'login' }, { replace: true });\n```\n\nfor permanent url aliases, use the declarative `redirect` field instead of middleware:\n\n```ts\nconst routes = {\n profile: { path: '/profile', redirect: { name: 'userdetail' } },\n userdetail: { path: '/users/:id' },\n};\n```\n\n> **note:** `redirectto()` calls `ctx.navigate()` internally, so `beforeleave` guards will run and can block it. declarative `redirect` on a route definition bypasses all leave guards.\n\n### leave guards\n\nregister a global leave guard with `router.beforeleave()`. return `false` to cancel navigation.\n\n```ts\nconst removeguard = router.beforeleave(async (destination) => {\n if (!form.isdirty) return true;\n return confirm(`discard changes? (navigating to ${destination.pathname})`);\n});\n\n// remove when no longer needed:\nremoveguard();\n```\n\nscope a guard to fire only when leaving specific routes:\n\n```ts\nrouter.beforeleave(async () => confirm('discard changes?'), { routes: ['editor'] });\n```\n\ndeclarative `redirect` routes bypass all leave guards.\n\n### data loading\n\nuse `data()` for route local data acquisition. it receives the same route context plus an `abortsignal`.\n\n```ts\nconst routes = {\n userdetail: {\n path: '/users/:id',\n data: async ({ params, signal }) => fetchuser(params.id, { signal }),\n },\n};\n```\n\naccess the result via the matched branch:\n\n```ts\nrouter.subscribe((state) => {\n const user = state.matches.at( 1)?.data;\n renderuser(user);\n});\n```\n\n#### per route error boundaries\n\nuse `onerror` to handle data loader failures per route. the returned value becomes `match.data`, allowing the route to render a degraded state:\n\n```ts\nconst routes = {\n userdetail: {\n path: '/users/:id',\n data: async ({ params, signal }) => fetchuser(params.id, { signal }),\n onerror: (error) => ({ error, user: null }),\n },\n};\n```\n\nif `onerror` itself throws, the router falls through to `status: 'error'` as usual.\n\n#### streaming data loaders\n\nreturn an `asyncgenerator` from `data()` to stream partial results. each `yield` updates `match.status` to `'streaming'` and `match.data` to the yielded value. the `return` value is the final settled data.\n\n```ts\nconst routes = {\n feed: {\n path: '/feed',\n data: async function* ({ signal }) {\n const items: feeditem[] = [];\n for await (const batch of streamfeedbatches({ signal })) {\n items.push(...batch);\n yield items; // stream partial results\n }\n return items; // final settled value\n },\n },\n};\n```\n\nduring streaming, `state.status` is `'streaming'` and each `match.status` reflects the loading state of that individual branch node.\n\n### lazy routes\n\ndefer loading a route module until first navigation. the factory is called at most once.\n\n```ts\nconst routes = {\n settings: {\n path: '/settings',\n lazy: () => import('./pages/settings'),\n },\n};\n```\n\nthe resolved object may contain `data`, `component`, and/or `meta`. any present field overwrites the static definition.\n\n### search param validation\n\nvalidate and coerce `ctx.query` per route. the function receives raw url strings (`queryparams`). throw to leave the parsed query unchanged.\n\n```ts\nconst routes = {\n search: {\n path: '/search',\n coercesearch: (raw) => ({\n q: string(raw.q ?? ''),\n page: math.max(1, number(raw.page ?? 1)),\n }),\n data: async ({ query }) => searchposts(query.q, query.page),\n },\n};\n```\n\nto apply the same coercion to every route, set `coercesearch` on the router options instead. per route `coercesearch` takes precedence over the global one.\n\n```ts\nconst router = createrouter({\n coercesearch: (raw) => ({ page: number(raw.page ?? 1) }),\n routes,\n});\n```\n\n### error boundaries\n\nwrap `await next()` in middleware for route wide error handling. the thrown error is also stored on `router.getsnapshot().error`.\n\n```ts\nconst boundary = async (ctx, next) => {\n try {\n await next();\n } catch (error) {\n reportrouteerror(ctx.pathname, error);\n await ctx.navigate({ path: '/error' }, { replace: true });\n }\n};\n\nconst router = createrouter({\n middleware: [boundary],\n routes,\n});\n\n// check after navigation:\nconst { status, error } = router.getsnapshot();\nif (status === 'error') {\n console.error(error);\n}\n```\n\n## navigation\n\n### named navigation\n\n```ts\nawait router.navigate({ name: 'userdetail', params: { id: '42' } });\nawait router.navigate({ name: 'userdetail', params: { id: '42' } }, { replace: true });\nawait router.navigate({ name: 'search', query: { q: 'wayfinder' }, hash: 'results' });\nawait router.navigate({ name: 'dashboard.settings' });\n```\n\n### raw path targets\n\n```ts\nawait router.navigate({ path: '/marketing?utm_source=campaign' });\nawait router.navigate({ path: '/checkout#payment' }, { replace: true });\n```\n\nuse these when a destination does not belong in the route table. the same `navigate()` method covers named routes and raw path targets.\n\n### history state\n\nattach arbitrary state to a history entry and read it back via `ctx.historystate` or `router.getsnapshot().location.historystate`.\n\n```ts\nawait router.navigate({ name: 'userdetail', params: { id: '42' } }, { state: { from: 'search' } });\n\n// in data():\ndata: async (ctx) => {\n console.log(ctx.historystate); // { from: 'search' }\n return fetchuser(ctx.params.id);\n},\n```\n\n### same url deduplication\n\n```ts\nawait router.navigate({ name: 'dashboard' });\nawait router.navigate({ name: 'dashboard' }); // no op\nawait router.navigate({ name: 'dashboard' }, { force: true }); // re runs\n```\n\n### prefetching\n\neagerly run data loaders without navigating — useful for hover prefetch:\n\n```ts\n// preload a parameterised route\nanchor.addeventlistener('mouseenter', () => {\n router.preload('userdetail', { id: '42' });\n});\n\n// preload with a query string to avoid a cache miss on navigation\nsearchinput.addeventlistener('focus', () => {\n router.preload('search', undefined, { q: searchinput.value });\n});\n```\n\nconcurrent calls for the same `name + params + query` combination are deduplicated. results are consumed on the next navigation to the same route with the same cache key. pass the same `query` you intend to navigate with — without it, the preload key is the bare path and any navigation with a query string will re run the loaders.\n\nin flight preloads are aborted automatically when `router.dispose()` is called.\n\n### leave guards\n\nguard navigation until the user confirms — useful for unsaved changes forms:\n\n```ts\nconst removeguard = router.beforeleave(async (destination) => {\n if (!form.isdirty) return true;\n return confirm('discard changes?');\n});\n\n// remove when the component unmounts:\nremoveguard();\n```\n\nscope a guard to a specific route so it only fires when leaving that route:\n\n```ts\nrouter.beforeleave(async () => confirm('discard changes?'), { routes: ['editor'] });\n```\n\n## urls and active state\n\n```ts\nrouter.url('userdetail', { id: '42' });\nrouter.url('userdetail', { id: '42' }, { tab: 'profile' });\n\nrouter.isactive('userdetail');\nrouter.isactive('users');\nrouter.isactive('users', { exact: true });\n```\n\n`isactive(name)` reads the current router snapshot and is useful for parent navigation items.\n\n## match a path without navigating\n\n```ts\nconst branch = router.match('/app/dashboard/settings');\n\nif (branch?.at( 1)?.name === 'dashboard.settings') {\n warmsettingspanel();\n}\n```\n\n`match()` strips the configured base automatically and returns the full matched branch (root to leaf). data loaders are not executed.\n\n## load a path for ssr\n\nuse `router.load(url)` to load a full route state including data loader results without modifying router state or history. this is useful for server side data prefetching.\n\n```ts\nconst state = await router.load('/users/42');\n\nif (state) {\n const data = state.matches.at( 1)?.data;\n // serialize and send to the client\n}\n```\n\npass an `abortsignal` via the options object to cancel in flight loaders:\n\n```ts\nconst controller = new abortcontroller();\nconst state = await router.load('/users/42', { signal: controller.signal });\n```\n\n`load()` follows declarative redirects (up to five hops) and resolves lazy modules as a side effect.\n\n## state and subscriptions\n\n```ts\nrouter.subscribe((state) => {\n const leaf = state.matches.at( 1);\n document.title = (leaf?.meta as { title?: string } | undefined)?.title ?? 'app';\n});\n```\n\nuse `router.getsnapshot()` to read the current state synchronously:\n\n```ts\nconst { location, matches, status, error } = router.getsnapshot();\n\nlocation.pathname;\nlocation.query; // raw parsed query strings (queryparams)\nlocation.hash;\nlocation.historystate; // state from the current history entry\n\nmatches; // matched branch from root to leaf\nstatus; // 'idle' | 'loading' | 'streaming' | 'error'\nerror; // only set when status === 'error'\n```\n\neach match node also carries its own `status`:\n\n```ts\nmatches.at( 1)?.status; // 'idle' | 'loading' | 'streaming' | 'error'\n```\n\nthis lets nested layouts show per slot loading indicators without polling the top level status.\n\nthe state object is immutable. a successful navigation replaces it with a new snapshot.\n\n### `waitfor(name)`\n\nwait for the router to reach `status: 'idle'` with a specific route active. useful in tests and lifecycle coordination:\n\n```ts\n// navigate and wait for data to settle\nawait router.navigate({ name: 'userdetail', params: { id: '42' } });\nconst state = await router.waitfor('userdetail');\nconst user = state.matches.at( 1)?.data;\n```\n\n`waitfor` rejects immediately if the router is already in `status: 'error'`, and also rejects if `router.dispose()` is called while the promise is pending. resolves immediately if the named route is already active and idle.\n\n## scroll restoration\n\nprovide a `scroll` callback to control scroll position after each navigation:\n\n```ts\nconst router = createrouter({\n routes,\n scroll: (to, from) => {\n // return 'top' to scroll to top\n // return { x, y } for a specific position\n // return 'preserve' to do nothing\n return 'top';\n },\n});\n```\n\nthe callback receives the incoming state and the previous state, making it possible to implement saved position restore:\n\n```ts\nconst scrollpositions = new map<string, { x: number; y: number }>();\n\nrouter.subscribe((state) => {\n scrollpositions.set(state.location.pathname, { x: window.scrollx, y: window.scrolly });\n});\n\nconst router = createrouter({\n routes,\n scroll: (to, _from) => scrollpositions.get(to.location.pathname) ?? 'top',\n});\n```\n\n## testing\n\nuse `creatememoryhistory` to test routers without a browser:\n\n```ts\nimport { creatememoryhistory, createrouter } from '@vielzeug/wayfinder';\n\nconst history = creatememoryhistory('/dashboard');\nconst router = createrouter({ history, routes });\n\n// use waitfor to avoid manual timing:\nconst state = await router.waitfor('dashboard');\nassert(state.location.pathname === '/dashboard');\n\nrouter.dispose();\n```\n\n## cleanup\n\n```ts\nrouter.dispose();\n```\n\nremove listeners, clear subscribers, and prevent future router usage.\n\n## framework integration\n\nroute exposes `getsnapshot()` and `subscribe()`, which map directly to each framework's external store primitives. create the router once at module scope and bind actions outside the component lifecycle so references stay stable.\n\n::: code group\n\n```tsx [react]\nimport { createrouter } from '@vielzeug/wayfinder';\nimport { usesyncexternalstore } from 'react';\n\nconst router = createrouter({\n routes: {\n home: { component: homepage, path: '/' },\n settings: { component: settingspage, path: '/settings' },\n },\n notfound: { component: notfoundpage },\n});\n\n// stable router actions are safe to destructure outside the hook.\nconst { getsnapshot, isactive, navigate, subscribe, url } = router;\n\nexport function userouter() {\n const state = usesyncexternalstore(subscribe, getsnapshot);\n return { isactive, navigate, state, url };\n}\n\n// routerview.tsx\nexport function routerview() {\n const { state } = userouter();\n const component = state.matches.at( 1)?.component as react.componenttype | undefined;\n return component ? <component /> : null;\n}\n```\n\n```ts [vue 3]\nimport { createrouter } from '@vielzeug/wayfinder';\nimport { readonly, shallowref } from 'vue';\n\nconst router = createrouter({\n routes: {\n home: { component: homepage, path: '/' },\n settings: { component: settingspage, path: '/settings' },\n },\n notfound: { component: notfoundpage },\n});\n\n// shallowref — no need to deep track immutable route state.\nconst state = shallowref(router.getsnapshot());\nrouter.subscribe((next) => {\n state.value = next;\n});\n\nexport function userouter() {\n const { isactive, navigate, url } = router;\n\n return { isactive, navigate, state: readonly(state), url };\n}\n```\n\n```svelte [svelte]\n<! router.ts >\n<script lang=\"ts\" context=\"module\">\n import { createrouter } from '@vielzeug/wayfinder';\n import { readable } from 'svelte/store';\n\n const router = createrouter({\n routes: {\n home: { component: homepage, path: '/' },\n settings: { component: settingspage, path: '/settings' },\n },\n notfound: { component: notfoundpage },\n });\n\n // readable injects the initial value; subscribe() drives updates.\n export const routerstate = readable(router.getsnapshot(), (set) => router.subscribe(set));\n export const { isactive, navigate, url } = router;\n</script>\n```\n\n:::\n\nfor full routerview and routerlink patterns, see [react integration](./examples/react integration.md), [vue integration](./examples/vue integration.md), and [svelte integration](./examples/svelte integration.md).\n\n## debug logging\n\n`router.subscribe()` is the reactive subscription api — it receives every state change, including `loading`, `streaming`, and `error` transitions. attach a listener that logs to `console.debug` to inspect navigation without any dedicated debug tooling.\n\n```ts\nimport { createrouter } from '@vielzeug/wayfinder';\n\nconst router = createrouter({ routes });\nconst stop = router.subscribe((state) => {\n console.debug(`[wayfinder] ${state.status} ${state.location.pathname}`);\n});\n\n// logged once the initial navigation completes:\n// [wayfinder] idle /\n\n// on navigate({ name: 'dashboard' }):\n// [wayfinder] loading /dashboard\n// [wayfinder] idle /dashboard\n```\n\nthe returned function unsubscribes the listener — call it when the logger is no longer needed (e.g. on teardown):\n\n```ts\nstop();\n```\n\nerrors are surfaced on the state object, so you can log them explicitly:\n\n```ts\nrouter.subscribe((state) => {\n if (state.status === 'error') {\n console.error(`[wayfinder] ${state.location.pathname}`, state.error);\n }\n});\n```\n\nuse a label when running multiple routers to distinguish their log output:\n\n```ts\nconst main = createrouter({ routes });\nmain.subscribe((state) => console.debug(`[wayfinder:main] ${state.status} ${state.location.pathname}`));\n\nconst modal = createrouter({ routes: modalroutes });\nmodal.subscribe((state) => console.debug(`[wayfinder:modal] ${state.status} ${state.location.pathname}`));\n```\n\ndebug logging has no effect on behavior and should not be enabled in production.\n\n::: tip unhandled router errors\nif a route's data loader throws and no `onerror` callback is set on the router, the error is surfaced via `console.error` in development and silenced in production (`__wayfinder_prod__` set). always provide an `onerror` callback in production to handle errors explicitly.\n:::\n\n## working with other vielzeug libraries\n\n### with ward\n\nuse ward inside wayfinder middleware to guard protected routes.\n\n```ts\nimport { createrouter } from '@vielzeug/wayfinder';\nimport { createward } from '@vielzeug/ward';\n\ntype user = { id: string; roles: string[] };\n\nconst ward = createward([{ role: 'admin', resource: 'settings', action: 'view', effect: 'allow' }]);\n\nconst router = createrouter({\n middleware: [\n (ctx, next) => {\n const user: user = getsessionuser();\n if (!ward.can(user, 'settings', 'view')) return ctx.navigate({ path: '/login' }, { replace: true });\n return next();\n },\n ],\n routes: {\n settings: { path: '/settings' },\n },\n});\n```\n\n### with ripple\n\nsync router state to a ripple signal for reactive ui.\n\n```ts\nimport { createrouter } from '@vielzeug/wayfinder';\nimport { signal } from '@vielzeug/ripple';\n\nconst router = createrouter({\n /* ... */\n});\nconst currentroute = signal(router.getsnapshot().matches.at( 1)?.name ?? '');\n\nrouter.subscribe((state) => {\n currentroute.value = state.matches.at( 1)?.name ?? '';\n});\n```\n\n## best practices\n\n define the route table once at app startup and import it where needed.\n prefer named navigation (`router.navigate({ name: 'settings' })`) over raw paths.\n put auth and permission checks in middleware, not in data loaders.\n use `data()` loaders for route data and honor the provided `abortsignal`.\n use `onerror` on a route for degraded state rendering rather than a full redirect to an error page.\n use `notfound` in router options for the not found page rather than `path: '*'` in the route table.\n call `router.dispose()` when tearing down apps/tests to release listeners.\n use `creatememoryhistory()` for tests and non browser runtimes; avoid touching `window.history` directly.\n use `router.preload()` on hover for routes likely to be visited next.\n",
|
|
1505
1527
|
"examples": " \ntitle: wayfinder — examples\ndescription: practical examples and recipes for wayfinder.\n \n\n## examples\n\n [route table basics](./examples/route table basics.md)\n [not found and error boundary](./examples/not found and error boundary.md)\n [auth and guards](./examples/auth and guards.md)\n [page titles from meta](./examples/page titles from meta.md)\n [same url deduplication](./examples/same url deduplication.md)\n [base path deployment](./examples/base path deployment.md)\n [raw path targets](./examples/raw path targets.md)\n [view transitions](./examples/view transitions.md)\n [react integration](./examples/react integration.md)\n [vue integration](./examples/vue integration.md)\n [svelte integration](./examples/svelte integration.md)\n"
|
|
1506
1528
|
},
|
|
1507
1529
|
"examples": [
|
|
@@ -1511,7 +1533,7 @@
|
|
|
1511
1533
|
},
|
|
1512
1534
|
{
|
|
1513
1535
|
"id": "debug-router",
|
|
1514
|
-
"text": "
|
|
1536
|
+
"text": "navigation logging import { creatememoryhistory, createrouter } from '@vielzeug/wayfinder'\n\nconst router = createrouter({\n history: creatememoryhistory('/'),\n routes: {\n home: { path: '/' },\n userdetail: { path: '/users/:id', data: async ({ params }) => ({ id: params.id }) },\n settings: { path: '/settings' },\n },\n})\n\n// observe navigation state changes via subscribe()\nrouter.subscribe((state) => {\n const names = state.matches.map((m) => m.name).filter(boolean).join(', ')\n console.debug(`[wayfinder] ${state.status} ${state.location.pathname} [${names}]`)\n})\n\nawait router.ready\nawait router.navigate({ name: 'userdetail', params: { id: '42' } })\nawait router.navigate({ name: 'settings' })\n\nconsole.log('active route:', router.getsnapshot().matches.at( 1)?.name)\nrouter.dispose()"
|
|
1515
1537
|
},
|
|
1516
1538
|
{
|
|
1517
1539
|
"id": "middleware-auth",
|
|
@@ -1546,7 +1568,7 @@
|
|
|
1546
1568
|
"text": "url building — path matching and active state import { creatememoryhistory, createrouter } from '@vielzeug/wayfinder'\n\n// url(), match(), and isactive() are synchronous and do not modify router state.\nconst router = createrouter({\n base: '/app',\n history: creatememoryhistory('/app/users/123'),\n routes: {\n users: { path: '/users' },\n user: { path: '/users/:id' },\n comment: { path: '/posts/:postid/comments/:commentid' },\n search: { path: '/search' },\n },\n})\n\n// wait for the initial navigation to settle before reading active state.\nawait router.waitfor('user')\n\nconsole.log(' url() ')\nconsole.log('user: ', router.url('user', { id: '42' }))\nconsole.log('search: ', router.url('search', undefined, { q: 'typescript', page: 2 }))\nconsole.log('comment:', router.url('comment', { postid: '10', commentid: '25' }))\n\nconsole.log(' match() ')\nconst branch = router.match('/app/users/99')\nconsole.log('matched:', branch?.map((n) => n.name + ' params=' + json.stringify(n.params)))\nconsole.log('no match:', router.match('/app/does not exist'))\n\nconsole.log(' isactive() ')\nconsole.log('user (prefix):', router.isactive('user'))\nconsole.log('users (prefix):', router.isactive('users')) // true — /users prefix matches /users/123\nconsole.log('users (exact):', router.isactive('users', { exact: true })) // false\n\nrouter.dispose()"
|
|
1547
1569
|
}
|
|
1548
1570
|
],
|
|
1549
|
-
"exports": "createrouter createbrowserhistory creatememoryhistory redirectto wayfindererror wayfinderapierror wayfinderdisposederror wayfinderredirectlooperror wayfinderrouteerror
|
|
1571
|
+
"exports": "createrouter createbrowserhistory creatememoryhistory redirectto wayfindererror wayfinderapierror wayfinderdisposederror wayfinderredirectlooperror wayfinderrouteerror",
|
|
1550
1572
|
"keywords": "router client side middleware guards navigation history spa typed routes",
|
|
1551
1573
|
"name": "@vielzeug/wayfinder",
|
|
1552
1574
|
"related": "ripple ward herald",
|