@vielzeug/codex 2.0.0 → 2.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/data/catalog.json +139 -130
  2. package/data/llms-full.txt +13091 -17593
  3. package/data/llms.txt +12 -11
  4. package/data/manifest.json +1 -1
  5. package/data/packages/arsenal.json +1 -1
  6. package/data/packages/assay.json +1 -1
  7. package/data/packages/clockwork.json +2 -2
  8. package/data/packages/codex.json +1 -1
  9. package/data/packages/coins.json +1 -1
  10. package/data/packages/conduit.json +1 -1
  11. package/data/packages/courier.json +1 -1
  12. package/data/packages/dnd.json +14 -12
  13. package/data/packages/familiar.json +26 -16
  14. package/data/packages/flux.json +1 -1
  15. package/data/packages/forge.json +1 -1
  16. package/data/packages/herald.json +19 -33
  17. package/data/packages/keymap.json +13 -19
  18. package/data/packages/ledger.json +28 -25
  19. package/data/packages/lingua.json +30 -28
  20. package/data/packages/necromancer.json +50 -0
  21. package/data/packages/orbit.json +34 -39
  22. package/data/packages/ore.json +1 -1
  23. package/data/packages/prism.json +37 -40
  24. package/data/packages/pulse.json +26 -24
  25. package/data/packages/refine.json +1 -1
  26. package/data/packages/ripple.json +1 -1
  27. package/data/packages/rune.json +6 -7
  28. package/data/packages/sandbox.json +7 -6
  29. package/data/packages/scout.json +10 -10
  30. package/data/packages/scroll.json +18 -17
  31. package/data/packages/sourcerer.json +1 -1
  32. package/data/packages/spell.json +1 -1
  33. package/data/packages/tempo.json +49 -81
  34. package/data/packages/vault.json +37 -40
  35. package/data/packages/ward.json +5 -17
  36. package/data/packages/wayfinder.json +9 -9
  37. package/data/refine.json +4914 -4914
  38. package/data/search.json +210 -211
  39. package/dist/cli.js +1 -1
  40. package/dist/cli.js.map +1 -1
  41. package/dist/http.js +46 -6
  42. package/dist/http.js.map +1 -1
  43. package/dist/server.js +1 -1
  44. package/dist/server.js.map +1 -1
  45. package/dist/tools/index.js +13 -5
  46. package/dist/tools/index.js.map +1 -1
  47. package/package.json +4 -4
@@ -1,9 +1,9 @@
1
1
  {
2
- "apiSource": "export { buildCsp, buildDocument, createSandbox } from './_sandbox.js';\nexport { SandboxError, SandboxTimeoutError } from './errors.js';\nexport type {\n SandboxBridge,\n SandboxHandle,\n SandboxMessage,\n SandboxOptions,\n SandboxStateUpdateDetail,\n Unsubscribe,\n} from './types.js';\n",
2
+ "apiSource": "export { buildCsp, buildDocument, createSandbox } from './_sandbox.js';\nexport { SandboxConfigurationError, SandboxError, SandboxTimeoutError } from './errors.js';\nexport type {\n SandboxBridge,\n SandboxHandle,\n SandboxMessage,\n SandboxOptions,\n SandboxStateUpdateDetail,\n Unsubscribe,\n} from './types.js';\n",
3
3
  "docs": {
4
- "index": "---\ntitle: Sandbox — Sandboxed iframe runtime\ndescription: Isolated iframe runtime with a typed postMessage bridge for safe execution of untrusted HTML — component previews, playgrounds, plugin sandboxes, and more.\npackage: sandbox\ncategory: ui-primitives\nkeywords: [sandbox, iframe, isolation, playground, csp, postmessage, security, components]\nexports:\n [\n createSandbox,\n buildCsp,\n buildDocument,\n SandboxError,\n SandboxTimeoutError,\n SandboxHandle,\n SandboxOptions,\n SandboxBridge,\n SandboxMessage,\n SandboxStateUpdateDetail,\n Unsubscribe,\n ]\nrelated: [codex, refine]\nenvironments: [browser]\n---\n\n<!-- markdownlint-disable MD025 MD033 MD060 -->\n\n<PackageHero package=\"sandbox\" />\n\n## Why Sandbox?\n\nRunning untrusted HTML in the main window is unsafe — arbitrary code can access the DOM, cookies, and user data. Sandbox creates an isolated `<iframe sandbox=\"allow-scripts\">` that receives content over a typed postMessage bridge. The sandbox cannot reach the host page.\n\n```ts\n// Before\ncontainer.innerHTML = untrustedHtml;\n\n// After\nconst sandbox = createSandbox(container);\nawait sandbox.render(untrustedHtml);\n```\n\nCommon use cases:\n\n- **Component previews** — render isolated HTML/CSS examples in documentation or design tools\n- **Code playgrounds** — execute user-provided code with full error forwarding and state injection\n- **Plugin sandboxes** — host third-party or user-authored plugin UI without granting host access\n- **User-generated content** — display untrusted HTML (emails, form output, external widgets) safely\n- **Widget embedding** — wrap third-party widgets with strict CSP and bidirectional messaging\n- **AI-generated UI** — render LLM-produced HTML components with guaranteed isolation\n\n| Feature | Raw `<iframe>` | Sandbox |\n| -------------------------- | -------------------------------------------- | --------------------------------------------- |\n| Bundle size | 0 B (built-in) | <PackageInfo package=\"sandbox\" type=\"size\" /> |\n| Zero dependencies | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"check\" size=\"16\"></ore-icon> |\n| Content-Security-Policy | Manual | Auto-generated, strict by default |\n| Typed postMessage protocol | <ore-icon name=\"x\" size=\"16\"></ore-icon> | `setState()` / `SandboxMessage` union |\n| Error forwarding | <ore-icon name=\"x\" size=\"16\"></ore-icon> | `onerror` + `unhandledrejection` → host |\n| Dispose / `using` | Manual `remove()` | `dispose()` + `[Symbol.dispose]` |\n\n<div class=\"decision-callout\">\n\n**Use Sandbox when** you need to render untrusted or user-provided HTML in the browser with guaranteed isolation, CSP enforcement, and a typed event bridge.\n\n**Consider a raw `<iframe>` when** you only need to embed a known third-party URL — Sandbox is for programmatic `srcdoc` content, not URL-based embedding.\n\n</div>\n\n## Installation\n\n::: code-group\n\n```sh [pnpm]\npnpm add @vielzeug/sandbox\n```\n\n```sh [npm]\nnpm install @vielzeug/sandbox\n```\n\n```sh [yarn]\nyarn add @vielzeug/sandbox\n```\n\n:::\n\n## Quick Start\n\n```ts\nimport { createSandbox } from '@vielzeug/sandbox';\n\nconst container = document.getElementById('preview')!;\nconst sandbox = createSandbox(container);\n\ntry {\n // render() resolves when the document is ready\n await sandbox.render('<ore-button variant=\"primary\">Click me</ore-button>');\n\n // Push state into the sandbox\n sandbox.setState('theme', 'dark');\n} catch (error) {\n console.error('Sandbox render failed', error);\n}\n\n// Receive events from sandbox code (ready is not forwarded — internal use only)\nsandbox.onMessage((msg) => {\n if (msg.type === 'custom') console.log(msg.event, msg.detail);\n if (msg.type === 'error') console.error(msg.message);\n if (msg.type === 'resize') console.log('height:', msg.height);\n});\n\n// Re-render: await the returned Promise\nawait sandbox.render(newHtml);\n\n// Clean up — removes iframe, clears listeners\nsandbox.dispose();\n// or: using sandbox = createSandbox(container);\n```\n\n## Features\n\n<div class=\"features-grid\">\n\n- `createSandbox()` — Creates an isolated `<iframe sandbox=\"allow-scripts\">` in the given container\n- `SandboxHandle.ready` — Promise resolving on first render's ready signal (also resolves on dispose; check `sandbox.disposed` to distinguish)\n- `SandboxHandle.disposalSignal` — `AbortSignal` aborted when the sandbox is disposed; tie async work to sandbox lifetime\n- `SandboxHandle.disposed` — Observable disposed state; check before deferred calls\n- `render(html, { signal? })` — Lazy iframe creation; returns `Promise<void>` resolving when ready, or rejecting with `SandboxTimeoutError` if the bridge never signals ready; pass `AbortSignal` to skip cancelled renders\n- `patch(html)` — Incremental body update without page reset; preserves scripts, listeners, and CSS state; ideal for streaming content\n- `updateStyle(id, css)` — Hot-patch a named `<style id=\"…\">` block live without re-rendering; also updates baseline for next render\n- `setState(key, value)` — Push state into the sandbox; received as `sandbox:state-update` CustomEvent\n- `setStateAll(record)` — Push multiple state values in a single postMessage; more efficient than repeated `setState()` calls for initial setup\n- `namedStyles` option — Named `<style id=\"key\">` blocks in document `<head>`; individually patchable via `updateStyle()`\n- `lang` / `title` options — Set `<html lang=\"…\">` and `<title>` on the generated document for screen-reader correctness\n- `SandboxBridge` type — Ambient type for `window.__sandbox__` in sandbox-side TypeScript; `onState(key, handler)` subscribes to state pushed via `setState()`/`setStateAll()`\n- `custom` messages — Sandbox code emits `window.__sandbox__.emit(event, detail)` to the host\n- `resize` messages — Auto-emitted by the bridge's built-in `ResizeObserver`; no manual wiring needed\n- Strict CSP — `default-src 'none'`, inline scripts only, no network by default\n- `nonce` option — Cryptographic nonce for bridge `<script>` tag and `script-src` CSP\n- `scripts` option — Inject CDN scripts with `crossorigin=\"anonymous\"`; origins auto-added to `script-src`\n- `buildCsp()` — Build a standalone CSP string using the same `SandboxOptions`\n- `buildDocument()` — Build a complete sandbox HTML document for server-side or offline use\n- Error forwarding — `onerror` + `unhandledrejection` forwarded as `{ type: 'error' }` messages\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\n</div>\n\n## See Also\n\n<div class=\"see-also\">\n\n- [Codex](/codex/) — MCP server with `generate-sandbox-document` and `get-state-bridge-spec` tools; generates document templates for use with Sandbox\n- [Refine](/refine/) — Web component library; renders correctly inside the sandbox via `<script>` injection and `allowedScriptOrigins`\n\n</div>\n\n<!-- markdownlint-enable MD025 MD033 MD060 -->\n",
5
- "api": "---\ntitle: Sandbox — API Reference\ndescription: Full API reference for @vielzeug/sandbox — createSandbox, buildCsp, buildDocument, SandboxHandle, and all types.\n---\n\n[[toc]]\n\n## API Overview\n\n| Symbol | Purpose | Execution mode | Common gotcha |\n| ------ | ------- | -------------- | ------------- |\n| `createSandbox()` | Create an isolated sandboxed iframe runtime | Sync (returns handle); `render()` is async | Iframe DOM is created lazily — nothing exists until the first `render()` call |\n| `buildCsp()` | Build a CSP string from `SandboxOptions` | Sync | Origins and the `nonce` are sanitized — characters that could break out of the policy are silently stripped |\n| `buildDocument()` | Build a complete standalone sandbox HTML document | Sync | `lang`/`title` are HTML-escaped automatically; don't pre-escape them yourself |\n| `SandboxHandle` | Object returned by `createSandbox()` | — | `setState()`/`setStateAll()` warn in dev if called before `render()` resolves |\n| `SandboxOptions` | Unified options for `createSandbox`, `buildCsp`, `buildDocument` | — | All fields are optional; defaults documented per field below |\n| `SandboxBridge` | Bridge API at `window.__sandbox__` inside sandbox documents | — | `emit()` sends events to the host; `onState()` only receives — there is no way to call host functions directly |\n| `SandboxMessage` | Application messages the sandbox sends to the host | — | `'ready'` is not part of this union — it resolves `render()` internally instead |\n| `SandboxError` | Base error class for `@vielzeug/sandbox` | — | Use `SandboxError.is(err)` to narrow — catches `SandboxTimeoutError` and any future subclasses |\n| `SandboxTimeoutError` | Thrown by `render()` when no `'ready'` signal arrives in time | — | Extends `SandboxError`; the document is likely missing the bridge script |\n| `SandboxStateUpdateDetail` | Detail payload of the sandbox-side `sandbox:state-update` CustomEvent | — | Only relevant inside sandbox documents, not on the host |\n| `Unsubscribe` | Return type of `onMessage()` and `SandboxBridge.onState()` | — | Calling it more than once is a safe no-op |\n\n## Package Entry Points\n\n| Import | Purpose |\n| ------ | ------- |\n| `@vielzeug/sandbox` | Main exports and types |\n| `@vielzeug/sandbox/testing` | `createSandboxTestHelpers` — postMessage simulation helpers for tests |\n\n```ts\nimport { buildCsp, buildDocument, createSandbox, SandboxError, SandboxTimeoutError } from '@vielzeug/sandbox';\nimport type {\n SandboxBridge,\n SandboxHandle,\n SandboxMessage,\n SandboxOptions,\n SandboxStateUpdateDetail,\n Unsubscribe,\n} from '@vielzeug/sandbox';\n\nimport { createSandboxTestHelpers } from '@vielzeug/sandbox/testing';\n```\n\n## `createSandbox(container, options?)`\n\nCreates a sandboxed `<iframe>` inside `container` and returns a `SandboxHandle`.\n\n```ts\nfunction createSandbox(container: HTMLElement, options?: SandboxOptions): SandboxHandle\n```\n\nThe iframe is created lazily on the first `render()` call — `createSandbox()` is a cheap factory with no DOM work until content is ready. The iframe uses `sandbox=\"allow-scripts\"` and `referrerpolicy=\"no-referrer\"`. Content is loaded via `srcdoc` with an auto-generated CSP meta tag. The sandbox cannot access host cookies, storage, or the DOM.\n\n**Parameters**\n\n- `container` — The DOM element to append the iframe to.\n- `options` — Optional `SandboxOptions`.\n\n**Returns** a `SandboxHandle`.\n\n**Example**\n\n```ts\nconst sandbox = createSandbox(document.getElementById('preview')!);\nawait sandbox.render('<p>Hello from the sandbox</p>');\n```\n\n## `SandboxHandle`\n\n```ts\ninterface SandboxHandle {\n readonly disposalSignal: AbortSignal;\n readonly disposed: boolean;\n readonly ready: Promise<void>;\n dispose(): void;\n onMessage(handler: (msg: SandboxMessage) => void): Unsubscribe;\n patch(html: string): void;\n render(html: string, options?: { signal?: AbortSignal }): Promise<void>;\n setState(key: string, value: unknown): void;\n setStateAll(record: Record<string, unknown>): void;\n updateStyle(id: string, css: string): void;\n [Symbol.dispose](): void;\n}\n```\n\n| Member | Description |\n| ------ | ----------- |\n| `disposalSignal` | `AbortSignal` that is aborted when `dispose()` is called. Pass to `fetch` and other async operations to tie their lifetime to the sandbox. |\n| `disposed` | `true` once `dispose()` has been called. |\n| `ready` | Promise that resolves when the **first** sandbox document signals it has loaded. Also resolves if the sandbox is disposed before the first render — check `sandbox.disposed` after awaiting to distinguish the two cases. Does **not** reset on re-renders — use the Promise returned by `render()` for subsequent renders. |\n| `patch(html)` | Incrementally update the sandbox body without a full page reset. Replaces `document.body.innerHTML` via postMessage — scripts, event listeners, and `namedStyles` CSS are preserved. Must be called after `render()` resolves. Warns in dev if the bridge is not yet ready. |\n| `render(html, options?)` | Replace the entire sandboxed document (full page reset). Creates the iframe lazily. Returns a `Promise<void>` that resolves when the new document signals ready, or **rejects with `SandboxTimeoutError`** if no `'ready'` signal arrives within 5s. If a second `render()` starts before the first resolves, the first Promise resolves (not rejects) immediately — the document simply navigated away. Pass `options.signal` to skip if already aborted. Emits a dev warning when `html` is empty or whitespace-only. |\n| `updateStyle(id, css)` | Hot-patch a named `<style id=\"…\">` block in the live iframe via postMessage, and update the baseline for the next `render()`. No-ops if the sandbox is disposed. Safe to call before the first render (baseline only). Warns in dev if `id` is not a known key in `namedStyles`. |\n| `setState(key, value)` | Push a state value into the sandbox. Dispatches a `sandbox:state-update` CustomEvent inside the iframe. Warns in dev if called before `render()` resolves. |\n| `setStateAll(record)` | Push multiple state values in a single postMessage. Dispatches one `sandbox:state-update` CustomEvent per key inside the iframe. More efficient than calling `setState()` repeatedly for initial state setup. Warns in dev if called before `render()` resolves. |\n| `onMessage(handler)` | Subscribe to `SandboxMessage` events (`error`, `custom`, and `resize`). The `ready` lifecycle signal is not forwarded. Returns an `Unsubscribe` function. |\n| `dispose()` | Remove the iframe from the DOM and clear all listeners. Resolves any pending `ready` Promise and aborts `disposalSignal`. |\n| `[Symbol.dispose]()` | Alias for `dispose()` — enables `using sandbox = createSandbox(…)`. |\n\n::: warning Dev warnings\nCalling `render()`, `setState()`, `setStateAll()`, `updateStyle()`, or `onMessage()` on a disposed sandbox emits a warning in development (when `import.meta.env.PROD` is not `true`).\n\nCalling `setState()` or `setStateAll()` before `render()` resolves emits a dev warning — the bridge may not have set up its listener yet and the state update may be silently dropped. Always await the Promise returned by `render()` before calling either.\n\nIn production all guard paths are silent no-ops (no warnings).\n:::\n\n::: warning render() can reject\nUnlike the other guard paths above, the `SandboxTimeoutError` rejection from `render()` is **not** a dev-only warning — it fires in every build. Always attach a `.catch()` or wrap `await sandbox.render(...)` in `try`/`catch`:\n\n```ts\ntry {\n await sandbox.render(html);\n} catch (err) {\n if (SandboxError.is(err)) {\n console.error('Sandbox failed to load:', err.message);\n }\n}\n```\n:::\n\n## `SandboxOptions`\n\nUnified options for `createSandbox`, `buildCsp`, and `buildDocument`. All fields are optional.\n\n```ts\ninterface SandboxOptions {\n allowedFontOrigins?: string[];\n allowedImageOrigins?: string[];\n allowedScriptOrigins?: string[];\n allowedStyleOrigins?: string[];\n lang?: string;\n namedStyles?: Record<string, string>;\n nonce?: string;\n scripts?: string[];\n title?: string;\n}\n```\n\n| Option | Type | Default | Description |\n| ------ | ---- | ------- | ----------- |\n| `allowedFontOrigins` | `string[]` | `[]` | Origins added to `font-src`. Default directive value: `'none'`. |\n| `allowedImageOrigins` | `string[]` | `[]` | Origins added to `img-src`. `data:` is always included. |\n| `allowedScriptOrigins` | `string[]` | `[]` | Extra origins added to `script-src`. Merged with origins auto-extracted from `scripts`. |\n| `allowedStyleOrigins` | `string[]` | `[]` | Origins added to `style-src`. `'unsafe-inline'` is always included. |\n| `lang` | `string` | `'en'` | BCP 47 language tag for the generated document's `<html lang=\"…\">` attribute. Pass the primary language of the sandbox content for correct screen-reader behaviour. |\n| `namedStyles` | `Record<string, string>` | `{}` | Named CSS blocks injected as `<style id=\"key\">` elements in the document `<head>`. Each block is individually patchable via `updateStyle(id, css)` without re-rendering. |\n| `nonce` | `string` | `undefined` | Cryptographic nonce added to the bridge `<script>` tag and to `script-src`. In CSP Level 3 browsers the nonce suppresses `'unsafe-inline'`; `'unsafe-inline'` is retained for CSP Level 2 fallback only. |\n| `scripts` | `string[]` | `[]` | External script URLs injected before user content with `crossorigin=\"anonymous\"`. Origins are automatically added to `script-src`. |\n| `title` | `string` | `''` | Title for the generated document, placed in `<title>` in `<head>`. Providing a title improves screen reader compatibility. |\n\n::: warning Security\n`lang`, `title`, `namedStyles` keys, script URLs, and `nonce` are all HTML-escaped or sanitized before interpolation into the generated document — they cannot be used to break out of their attribute or inject markup. CSP origins and `nonce` are stripped of characters (`;`, `\"`, `'`, newlines) that could inject a new CSP directive.\n:::\n\n## `buildCsp(options?)`\n\nBuilds a strict Content-Security-Policy string for sandboxed iframe documents.\n\n```ts\nfunction buildCsp(options?: SandboxOptions): string\n```\n\nAccepts `SandboxOptions` directly. Origins from `scripts` URLs are extracted and merged with `allowedScriptOrigins` automatically. Returns a semicolon-separated CSP string with eight directives. `base-uri 'none'` is always included to block `<base>`-tag injection, and `connect-src 'none'` / `form-action 'none'` block network requests and form submission by default.\n\n**Default output (no options)**\n\n```\ndefault-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data:; font-src 'none'; connect-src 'none'; form-action 'none'; base-uri 'none'\n```\n\n**Example**\n\n```ts\nconst csp = buildCsp({\n allowedStyleOrigins: ['https://fonts.googleapis.com'],\n allowedFontOrigins: ['https://fonts.gstatic.com'],\n scripts: ['https://cdn.example.com/refine.iife.js'],\n});\n// script-src includes 'unsafe-inline' + https://cdn.example.com automatically\n```\n\n## `buildDocument(html, options?)`\n\nBuilds a complete, standalone sandbox HTML document.\n\n```ts\nfunction buildDocument(html: string, options?: SandboxOptions): string\n```\n\nIncludes the `<html lang=\"…\">` attribute, `<title>`, CSP meta tag, injected scripts, `namedStyles` rendered as `<style id=\"key\">` blocks, user content, and the bridge script. Suitable as an `iframe` `srcdoc` value or for server-side sandbox document generation (e.g., via `@vielzeug/codex`).\n\nExternal scripts are placed **before** user content with `crossorigin=\"anonymous\"`, so the bridge's error handler receives full error details for cross-origin script errors. The bridge fires the `ready` message after all preceding parser-blocking scripts have executed, then sets up a `ResizeObserver` on `document.body` that automatically emits `resize` messages as content height changes.\n\n`lang` defaults to `'en'` and `title` defaults to `''` — both are HTML-escaped before interpolation.\n\n**Example**\n\n```ts\nimport { buildDocument } from '@vielzeug/sandbox';\n\nconst html = buildDocument('<p>Hello</p>', {\n lang: 'de',\n title: 'Component Preview',\n namedStyles: {\n base: 'body { font-family: sans-serif; }',\n theme: ':root { --bg: #fff; }',\n },\n});\n\niframe.srcdoc = html;\n```\n\n## Bridge Protocol\n\n### `SandboxMessage`\n\nApplication-level messages the sandbox sends to the host, received via `sandbox.onMessage(handler)`. The `ready` lifecycle signal is **intentionally excluded** — it resolves `sandbox.ready` and the Promise returned by `render()` internally and is not forwarded to subscribers.\n\n```ts\ntype SandboxMessage =\n | { type: 'error'; message: string; stack?: string }\n | { type: 'custom'; event: string; detail: unknown }\n | { type: 'resize'; height: number };\n```\n\n| Type | Fields | Description |\n| ---- | ------ | ----------- |\n| `error` | `message: string`, `stack?: string` | Fired on uncaught errors or unhandled promise rejections inside the sandbox. |\n| `custom` | `event: string`, `detail: unknown` | User-defined events emitted from sandbox code via `window.__sandbox__.emit(event, detail)`. |\n| `resize` | `height: number` | Emitted automatically when sandbox content height changes. The bridge script sets up a `ResizeObserver` on `document.body` — no manual wiring needed. |\n\n### `SandboxStateUpdateDetail`\n\nDetail payload of the `sandbox:state-update` CustomEvent dispatched **inside** sandbox documents by `setState()`/`setStateAll()`. Only relevant to sandbox-side code — the host never sees this type directly.\n\n```ts\ninterface SandboxStateUpdateDetail {\n key: string;\n value: unknown;\n}\n```\n\n**Emitting custom events from inside the sandbox:**\n\n```js\nwindow.__sandbox__.emit('button:click', { label: 'Save', timestamp: Date.now() });\n```\n\n**Receiving on the host:**\n\n```ts\nsandbox.onMessage((msg) => {\n if (msg.type === 'custom' && msg.event === 'button:click') {\n console.log('Button clicked:', msg.detail);\n }\n if (msg.type === 'error') {\n console.error('[sandbox]', msg.message, msg.stack);\n }\n if (msg.type === 'resize') {\n container.style.height = `${msg.height}px`;\n }\n});\n```\n\n### `SandboxBridge`\n\nThe bridge API available as `window.__sandbox__` inside sandbox documents. Export this type to add TypeScript support for sandbox-side code:\n\n```ts\ninterface SandboxBridge {\n emit(event: string, detail?: unknown): void;\n onState(key: string, handler: (value: unknown) => void): Unsubscribe;\n}\n```\n\nAdd an ambient declaration in your sandbox-side TypeScript project:\n\n```ts\n// sandbox-env.d.ts\ndeclare interface Window {\n __sandbox__: import('@vielzeug/sandbox').SandboxBridge;\n}\n```\n\n`onState(key, handler)` subscribes to state pushed via `sandbox.setState()`/`setStateAll()` for a specific key — it wraps the raw `sandbox:state-update` CustomEvent so sandbox-side code doesn't need to filter by key manually. Returns an `Unsubscribe` function:\n\n```ts\nconst off = window.__sandbox__.onState('theme', (value) => {\n document.body.dataset.theme = String(value);\n});\n\n// Later, stop listening:\noff();\n```\n\n### State updates\n\n`sandbox.setState(key, value)` sends a single state value into the sandbox; `sandbox.setStateAll(record)` sends multiple values in one postMessage. Both dispatch a `sandbox:state-update` CustomEvent per key, described by `SandboxStateUpdateDetail`. Inside the sandbox, either listen via the DOM directly or use `window.__sandbox__.onState()`:\n\n```js\ndocument.addEventListener('sandbox:state-update', (e) => {\n const { key, value } = e.detail;\n if (key === 'theme') document.body.dataset.theme = value;\n});\n```\n\n```ts\n// Single value\nsandbox.setState('theme', 'dark');\n\n// Multiple values in one postMessage — fires 'sandbox:state-update' twice, once per key\nsandbox.setStateAll({ theme: 'dark', locale: 'en' });\n```\n\n::: warning Security\nTreat all `SandboxMessage` data as untrusted. The sandbox controls what `custom` event payloads contain — do not execute or evaluate any message field.\n:::\n\n## Types\n\n### `Unsubscribe`\n\n```ts\ntype Unsubscribe = () => void;\n```\n\nReturn type of `onMessage()` and `SandboxBridge.onState()`. Calling it more than once is a safe no-op.\n\n## Errors\n\n### `SandboxError`\n\nBase class for all `@vielzeug/sandbox` errors. Extends `Error`.\n\n```ts\nclass SandboxError extends Error {\n static is(err: unknown): err is SandboxError;\n}\n```\n\n`SandboxError.is()` is a type-safe static predicate — prefer it over `instanceof` in catch blocks that may receive unknown values. It also matches subclasses like `SandboxTimeoutError`:\n\n```ts\nimport { SandboxError } from '@vielzeug/sandbox';\n\ntry {\n await sandbox.render(html);\n} catch (err) {\n if (SandboxError.is(err)) {\n console.error(err.message);\n }\n}\n```\n\n### `SandboxTimeoutError`\n\nThrown as a rejection from `render()` when no `'ready'` signal arrives within 5 seconds, in every build (not a dev-only warning). Extends `SandboxError`. The sandbox document is most likely missing the bridge script — use `buildDocument()` to generate documents that include it, rather than hand-writing the `srcdoc` HTML.\n\n```ts\nimport { SandboxTimeoutError } from '@vielzeug/sandbox';\n\ntry {\n await sandbox.render(customHtmlMissingBridge);\n} catch (err) {\n if (err instanceof SandboxTimeoutError) {\n console.error('Sandbox never signaled ready:', err.message);\n }\n}\n```\n\n## Test Utilities\n\n`@vielzeug/sandbox/testing` exports helpers for code that integrates with the sandbox:\n\n```ts\nimport { createSandboxTestHelpers } from '@vielzeug/sandbox/testing';\n\nconst helpers = createSandboxTestHelpers(container);\n\nsandbox.render('<p>test</p>');\nhelpers.fireReady(); // simulate bridge ready signal\nhelpers.fireCustom('click', { x: 1 }); // simulate window.__sandbox__.emit()\nhelpers.fireResize(420); // simulate ResizeObserver callback\nhelpers.fireError('TypeError: x is not defined', 'at eval:1');\n```\n\nThese helpers encapsulate the internal postMessage protocol so test code doesn't need to know message shapes.\n",
6
- "usage": "---\ntitle: Sandbox — Usage Guide\ndescription: How to render untrusted HTML, pass state, handle errors, configure CSP, and integrate the sandbox with your application.\n---\n\n[[toc]]\n\n::: tip New to Sandbox?\nStart with the [Overview](./index.md) for installation and a quick example, then come back here for in-depth usage patterns.\n:::\n\n## Basic Usage\n\nCreate a sandbox by passing a container element. The returned `SandboxHandle` is your entire interface to the iframe.\n\n```ts\nimport { createSandbox } from '@vielzeug/sandbox';\n\nconst container = document.getElementById('preview')!;\nconst sandbox = createSandbox(container);\n\nawait sandbox.render('<p>Hello from the sandbox</p>');\n```\n\n`render()` returns a `Promise<void>` that resolves when the sandbox document signals it is ready. No DOM is created until `render()` is called — `createSandbox()` is a cheap factory.\n\nFor reactive frameworks, subscribe via `onMessage` to receive `error`, `custom`, and `resize` events.\n\n## Rendering HTML\n\n`render(html)` replaces the entire sandboxed document with a new one containing your HTML in the body.\n\n```ts\nawait sandbox.render(`\n <style>body { font-family: sans-serif; }</style>\n <h1>Component Preview</h1>\n <ore-button variant=\"primary\">Click me</ore-button>\n`);\n```\n\nEach call to `render()` is a full page reset — scripts reinitialise, CSS is re-applied, and any DOM state is lost. For incremental updates, push state via `setState()` or patch styles via `updateStyle()` rather than re-rendering.\n\n## Incremental Updates with patch()\n\n`patch(html)` replaces `document.body.innerHTML` in the live document without a full page reset. Scripts, event listeners, `namedStyles` CSS blocks, and any injected global state are all preserved.\n\nUse it for streaming AI-generated output, live editor previews, or any scenario where you want to push new content without reinitialising the page.\n\n```ts\n// Initial render — sets up the document, scripts, and styles\nawait sandbox.render(`\n <script>\n document.addEventListener('sandbox:state-update', (e) => {\n document.body.dataset.theme = e.detail.value;\n });\n </script>\n <p>Loading…</p>\n`);\n\n// Subsequent updates — body swapped, script listener preserved\nsandbox.patch('<p>First chunk arrived</p>');\nsandbox.patch('<p>First chunk arrived</p><p>Second chunk…</p>');\nsandbox.patch('<p>Complete response</p>');\n```\n\n**`patch()` vs `render()`:**\n\n| | `render()` | `patch()` |\n|---|---|---|\n| Full page reset | Yes | No |\n| Returns a Promise | Yes | No |\n| Scripts re-run | Yes | No |\n| `namedStyles` preserved | Re-injected | Yes |\n| State listeners preserved | No (must re-register) | Yes |\n| When to use | Initial load, major content change | Streaming, live updates |\n\n**`patch()` must be called after `render()` resolves.** The bridge must be initialized before patches can be received. A dev warning fires if called before the document is ready.\n\n## Passing State\n\n`setState(key, value)` pushes data into the sandbox without re-rendering.\n\nAlways call `setState()` after `render()` resolves — calling it before the bridge finishes initializing will silently drop the update in a real browser, and a dev warning will fire.\n\n```ts\n// Correct: await render() before pushing state\nawait sandbox.render('<div id=\"root\"></div>');\nsandbox.setState('theme', 'dark');\nsandbox.setState('user', { name: 'Alice' });\n```\n\nInside the sandbox document, listen for the `sandbox:state-update` custom event on `document`:\n\n```html\n<script>\ndocument.addEventListener('sandbox:state-update', (e) => {\n const { key, value } = e.detail;\n if (key === 'theme') document.body.dataset.theme = value;\n if (key === 'user') document.querySelector('#name').textContent = value.name;\n});\n</script>\n```\n\n## Batch State Updates\n\n`setStateAll(record)` pushes multiple state values in a single postMessage — one call instead of one `setState()` per key. Use it for initial state setup where several values become available at the same time.\n\n```ts\nawait sandbox.render('<div id=\"root\"></div>');\n\n// One postMessage instead of two setState() calls\nsandbox.setStateAll({\n theme: 'dark',\n user: { name: 'Alice' },\n});\n```\n\nThe sandbox side listens the same way as for `setState()` — each key in the record fires its own `sandbox:state-update` event.\n\n## Handling Errors\n\nSubscribe to `onMessage` before calling `render()` to catch runtime errors in sandbox content.\n\n```ts\nsandbox.onMessage((msg) => {\n if (msg.type === 'error') {\n console.error('[sandbox error]', msg.message);\n if (msg.stack) console.debug(msg.stack);\n }\n});\n```\n\nBoth synchronous errors (`window.onerror`) and unhandled promise rejections (`unhandledrejection`) are forwarded as `{ type: 'error' }` messages.\n\n### `render()` rejection\n\n`render()` rejects with a `SandboxTimeoutError` if the document never signals `'ready'` within 5 seconds — this happens in every build, not just dev. It usually means the document is missing the bridge script (custom `srcdoc` HTML built by hand instead of via `buildDocument()`). Always handle it:\n\n```ts\nimport { SandboxError } from '@vielzeug/sandbox';\n\ntry {\n await sandbox.render(html);\n} catch (err) {\n if (SandboxError.is(err)) {\n console.error('Sandbox failed to load:', err.message);\n }\n}\n```\n\nA second `render()` call superseding the first does **not** trigger this — the superseded Promise resolves, not rejects.\n\n## Injecting Scripts and Styles\n\nUse `SandboxOptions` to inject external scripts and styles into every rendered document.\n\n```ts\nconst sandbox = createSandbox(container, {\n scripts: [\n 'https://cdn.example.com/ore.js',\n 'https://cdn.example.com/refine.js',\n ],\n namedStyles: {\n base: `\n :root { --color-primary: #0066cc; }\n body { margin: 0; font-family: var(--font-sans); }\n `,\n },\n});\n```\n\nScript URLs are injected before user content. Their origins are automatically added to `script-src` in the CSP — you do not need to configure `buildCsp` separately.\n\n## Setting Document Language and Title\n\nUse `lang` and `title` to set the generated document's `<html lang=\"…\">` attribute and `<title>`. Both improve screen-reader behaviour for sandboxed content.\n\n```ts\nconst sandbox = createSandbox(container, {\n lang: 'de',\n title: 'Component Preview',\n});\n```\n\n`lang` defaults to `'en'`, `title` defaults to `''`. Both values are HTML-escaped automatically before being written into the document.\n\n## Hot-patching Named Styles\n\n`namedStyles` injects named `<style id=\"key\">` blocks into the document `<head>`. Named blocks can be updated live without a full re-render using `updateStyle(id, css)`.\n\n```ts\nconst sandbox = createSandbox(container, {\n namedStyles: {\n theme: ':root { --color-primary: #0066cc; --bg: #fff; }',\n },\n});\n\nawait sandbox.render('<ore-button variant=\"primary\">Click me</ore-button>');\n\n// Switch theme live — no re-render\nsandbox.updateStyle('theme', ':root { --color-primary: #bb33ff; --bg: #111; }');\n```\n\n`updateStyle()` sends a postMessage to the iframe, patching `<style id=\"theme\">` in place. It also updates the baseline so the next `render()` starts with the patched CSS. Safe to call before the first render (baseline only — no postMessage sent to an uninitialized iframe).\n\n## Resize Notifications\n\nThe bridge script automatically emits `resize` messages via a `ResizeObserver` on `document.body`. No manual wiring is needed in your sandbox content.\n\n```ts\nsandbox.onMessage((msg) => {\n if (msg.type === 'resize') {\n container.style.height = `${msg.height}px`;\n }\n});\n```\n\nThe `resize` message fires whenever the `document.body` height changes — on initial load, after content updates via `setState()`, and after style patches via `updateStyle()`.\n\n## Tying Async Work to Sandbox Lifetime\n\n`disposalSignal` is an `AbortSignal` that is aborted when the sandbox is disposed. Pass it to any async operation that should stop when the sandbox is torn down.\n\n```ts\nconst sandbox = createSandbox(container);\n\n// Polling loop tied to sandbox lifetime\nasync function poll() {\n while (!sandbox.disposalSignal.aborted) {\n const data = await fetch('/api/data', { signal: sandbox.disposalSignal }).then(r => r.json()).catch(() => null);\n if (data) sandbox.setState('data', data);\n await new Promise(resolve => setTimeout(resolve, 5000));\n }\n}\n\npoll();\n```\n\nWhen `sandbox.dispose()` is called, `disposalSignal` aborts, cancelling in-flight fetches and stopping the loop.\n\n## Configuring CSP\n\nUse `allowedStyleOrigins`, `allowedFontOrigins`, and `allowedImageOrigins` to allow CDN resources.\n\n```ts\nconst sandbox = createSandbox(container, {\n allowedStyleOrigins: ['https://fonts.googleapis.com'],\n allowedFontOrigins: ['https://fonts.gstatic.com'],\n allowedImageOrigins: ['https://images.example.com'],\n});\n```\n\nThen render HTML that uses those resources:\n\n```ts\nawait sandbox.render(`\n <link rel=\"stylesheet\" href=\"https://fonts.googleapis.com/css2?family=Inter\">\n <p style=\"font-family: Inter, sans-serif\">Hello</p>\n`);\n```\n\nOrigin values and the `nonce` are sanitized before being written into the policy, and the generated CSP always includes `base-uri 'none'` to block `<base>`-tag injection — you do not need to strip untrusted characters yourself.\n\n## Disposal\n\nDispose the sandbox when it is no longer needed. This removes the iframe from the DOM and clears all message listeners.\n\n```ts\n// Explicit\nsandbox.dispose();\n\n// Using explicit resource management (TypeScript 5.2+)\n{\n using sandbox = createSandbox(container);\n await sandbox.render('<p>Temporary preview</p>');\n} // sandbox.dispose() called automatically\n```\n\n## Multiple Listeners\n\n`onMessage` supports multiple independent subscriptions. Each call returns its own unsubscribe function.\n\n```ts\nconst unsubErrors = sandbox.onMessage((msg) => {\n if (msg.type === 'error') logError(msg);\n});\n\nconst unsubEvents = sandbox.onMessage((msg) => {\n if (msg.type === 'custom') handleCustomEvent(msg);\n});\n\n// Remove a single subscription\nunsubErrors();\n\n// Remove all — dispose() clears all listeners at once\nsandbox.dispose();\n```\n\n## Receiving Events from the Sandbox\n\nSandbox code calls `window.__sandbox__.emit(event, detail)` to send events to the host. Receive them via `onMessage` with `msg.type === 'custom'`.\n\n```html\n<!-- Inside sandbox content -->\n<button onclick=\"window.__sandbox__.emit('button:click', { label: 'Save' })\">Save</button>\n```\n\n```ts\n// Host\nsandbox.onMessage((msg) => {\n if (msg.type === 'custom' && msg.event === 'button:click') {\n console.log('Sandbox button clicked:', msg.detail);\n }\n});\n```\n\n**TypeScript support for sandbox-side code** — add an ambient declaration referencing `SandboxBridge`:\n\n```ts\n// sandbox-env.d.ts\ndeclare interface Window {\n __sandbox__: import('@vielzeug/sandbox').SandboxBridge;\n}\n```\n\n## Awaiting Subsequent Renders\n\n`render()` returns a `Promise<void>` that resolves when the new document signals ready. Await it directly for each render:\n\n```ts\nawait sandbox.render(firstHtml); // first render complete\nawait sandbox.render(secondHtml); // second render complete\n```\n\nIf a second `render()` starts before the first resolves, the first Promise resolves immediately (superseded). Multiple concurrent callers can each await their own returned Promise.\n\n## Cancelling Renders with AbortSignal\n\nPass an `AbortSignal` to `render()` to skip the render if it has already been cancelled. Useful in streaming or queued workflows:\n\n```ts\nlet controller = new AbortController();\n\nasync function streamRender(html: string) {\n controller.abort(); // cancel previous pending render\n controller = new AbortController();\n await sandbox.render(html, { signal: controller.signal });\n}\n```\n\nIf the signal is already aborted when `render()` is called, the render is skipped with no warning and no DOM change.\n\n## Building Sandbox Documents Directly\n\nTo generate a complete sandbox HTML document outside of `createSandbox` (for example in a server context or `@vielzeug/codex`), use `buildDocument`.\n\n```ts\nimport { buildDocument } from '@vielzeug/sandbox';\n\nconst html = buildDocument('<p>Hello</p>', {\n allowedStyleOrigins: ['https://fonts.googleapis.com'],\n allowedFontOrigins: ['https://fonts.gstatic.com'],\n namedStyles: {\n theme: ':root { --bg: #fff; }',\n },\n});\n\n// html is a complete <!doctype html> document — assign directly to srcdoc\niframe.srcdoc = html;\n```\n\nUse `buildCsp` if you only need the CSP string for an existing document template:\n\n```ts\nimport { buildCsp } from '@vielzeug/sandbox';\n\nconst csp = buildCsp({ allowedFontOrigins: ['https://fonts.gstatic.com'] });\n// → \"default-src 'none'; ... font-src https://fonts.gstatic.com; ...\"\n```\n\n## Testing\n\nUse `createSandboxTestHelpers` from the `/testing` subpath to simulate sandbox→host messages without a real `srcdoc` script execution (jsdom does not execute iframe `srcdoc` scripts).\n\n```ts\nimport { createSandbox } from '@vielzeug/sandbox';\nimport { createSandboxTestHelpers } from '@vielzeug/sandbox/testing';\nimport { describe, expect, it } from 'vitest';\n\ndescribe('preview panel', () => {\n it('forwards a custom event from the sandbox', async () => {\n const container = document.createElement('div');\n const sandbox = createSandbox(container);\n const helpers = createSandboxTestHelpers(container);\n\n const received: unknown[] = [];\n\n sandbox.onMessage((msg) => received.push(msg));\n\n const renderPromise = sandbox.render('<button>Save</button>');\n\n helpers.fireReady(); // simulate the bridge script's initial postMessage\n await renderPromise;\n\n helpers.fireCustom('button:click', { label: 'Save' });\n expect(received).toEqual([{ type: 'custom', event: 'button:click', detail: { label: 'Save' } }]);\n\n sandbox.dispose();\n });\n});\n```\n\n`SandboxTestHelpers` also exposes `fireResize(height)` and `fireError(message, stack?)` for testing resize and error handling without a live browser.\n\n## Framework Integration\n\nCreate the sandbox once per mount and dispose it on unmount — the container element is stable for the component's lifetime.\n\n::: code-group\n\n```tsx [React]\nimport { useEffect, useRef } from 'react';\nimport { createSandbox } from '@vielzeug/sandbox';\n\nfunction SandboxPreview({ html }: { html: string }) {\n const containerRef = useRef<HTMLDivElement>(null);\n\n useEffect(() => {\n if (!containerRef.current) return;\n\n const sandbox = createSandbox(containerRef.current);\n\n sandbox.render(html);\n\n return () => sandbox.dispose();\n }, [html]);\n\n return <div ref={containerRef} />;\n}\n```\n\n```vue [Vue 3]\n<script setup lang=\"ts\">\nimport { onMounted, onUnmounted, ref } from 'vue';\nimport { createSandbox, type SandboxHandle } from '@vielzeug/sandbox';\n\nconst props = defineProps<{ html: string }>();\nconst containerRef = ref<HTMLDivElement>();\nlet sandbox: SandboxHandle | undefined;\n\nonMounted(() => {\n if (!containerRef.value) return;\n sandbox = createSandbox(containerRef.value);\n sandbox.render(props.html);\n});\n\nonUnmounted(() => sandbox?.dispose());\n</script>\n\n<template>\n <div ref=\"containerRef\" />\n</template>\n```\n\n```svelte [Svelte]\n<script lang=\"ts\">\n import { onMount } from 'svelte';\n import { createSandbox } from '@vielzeug/sandbox';\n\n export let html: string;\n let container: HTMLDivElement;\n\n onMount(() => {\n const sandbox = createSandbox(container);\n\n sandbox.render(html);\n\n return () => sandbox.dispose();\n });\n</script>\n\n<div bind:this={container}></div>\n```\n\n:::\n\n## Working with Other Vielzeug Libraries\n\n**With Codex:**\nThe `generate-sandbox-document` and `get-state-bridge-spec` MCP tools in `@vielzeug/codex` are designed to work with Sandbox. They generate complete sandbox-ready document templates and document the bridge protocol.\n\n```ts\n// After codex generates an HTML document:\nawait sandbox.render(generatedDocument);\n```\n\n**With Refine:**\nInject the Refine/Ore runtime into the sandbox via `scripts`:\n\n```ts\nconst sandbox = createSandbox(container, {\n scripts: ['https://cdn.example.com/refine.iife.js'],\n namedStyles: {\n theme: '/* refine theme tokens */',\n },\n});\n\nawait sandbox.render('<ore-card><ore-button>Save</ore-button></ore-card>');\n```\n\n## Best Practices\n\n- **Await `render()` before calling `setState()`/`setStateAll()`** — both warn in dev if called before the bridge is ready. Use `setStateAll()` to bootstrap several values in one postMessage instead of calling `setState()` repeatedly.\n- **Use `await sandbox.render(html)` for each render** — `render()` returns a `Promise<void>` that resolves when the document is ready. No separate readiness API is needed.\n- **Use `updateStyle()` for theme switching** — patching a named style is faster than a full `render()` and preserves all script and DOM state.\n- **Check `disposed` before deferred calls** — across async operations, check `sandbox.disposed` before calling any method to avoid spurious dev warnings.\n- **Tie async work to `disposalSignal`** — pass `disposalSignal` to `fetch` and other async operations so they cancel automatically on dispose.\n- **Treat all messages as untrusted** — sandbox code controls `SandboxMessage` payloads. Do not `eval()` or execute any message field.\n- **One sandbox per preview** — `createSandbox()` is a cheap factory; create a new sandbox per user session or component rather than reusing across unrelated renders.\n- **Use `using` in functions** — in TypeScript 5.2+ contexts, `using` guarantees cleanup even on exceptions.\n- **Prefer `patch()` or `setState()` over re-renders for incremental updates** — `render()` resets all script state. Use `patch()` to swap body content and `setState()` to push data without losing listeners or CSS state.\n",
4
+ "index": "---\ntitle: Sandbox — Sandboxed iframe runtime\ndescription: Isolated iframe runtime with a typed postMessage bridge for safe execution of untrusted HTML — component previews, playgrounds, plugin sandboxes, and more.\npackage: sandbox\ncategory: ui-primitives\nkeywords: [sandbox, iframe, isolation, playground, csp, postmessage, security, components]\nexports:\n [\n createSandbox,\n buildCsp,\n buildDocument,\n SandboxConfigurationError,\n SandboxError,\n SandboxTimeoutError,\n SandboxHandle,\n SandboxOptions,\n SandboxBridge,\n SandboxMessage,\n SandboxStateUpdateDetail,\n Unsubscribe,\n ]\nrelated: [codex, refine]\nenvironments: [browser]\n---\n\n<!-- markdownlint-disable MD025 MD033 MD060 -->\n\n<PackageHero package=\"sandbox\" />\n\n## Why Sandbox?\n\nRunning untrusted HTML in the main window is unsafe — arbitrary code can access the DOM, cookies, and user data. Sandbox creates an isolated `<iframe sandbox=\"allow-scripts\">` that receives content over a typed postMessage bridge. The sandbox cannot reach the host page.\n\n```ts\n// Before\ncontainer.innerHTML = untrustedHtml;\n\n// After\nconst sandbox = createSandbox(container);\nawait sandbox.render(untrustedHtml);\n```\n\nCommon use cases:\n\n- **Component previews** — render isolated HTML/CSS examples in documentation or design tools\n- **Code playgrounds** — execute user-provided code with full error forwarding and state injection\n- **Plugin sandboxes** — host third-party or user-authored plugin UI without granting host access\n- **User-generated content** — display untrusted HTML (emails, form output, external widgets) safely\n- **Widget embedding** — wrap third-party widgets with strict CSP and bidirectional messaging\n- **AI-generated UI** — render LLM-produced HTML components with guaranteed isolation\n\n| Feature | Raw `<iframe>` | Sandbox |\n| -------------------------- | -------------------------------------------- | --------------------------------------------- |\n| Bundle size | 0 B (built-in) | <PackageInfo package=\"sandbox\" type=\"size\" /> |\n| Zero dependencies | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"check\" size=\"16\"></ore-icon> |\n| Content-Security-Policy | Manual | Auto-generated, strict by default |\n| Typed postMessage protocol | <ore-icon name=\"x\" size=\"16\"></ore-icon> | `setState()` / `SandboxMessage` union |\n| Error forwarding | <ore-icon name=\"x\" size=\"16\"></ore-icon> | `onerror` + `unhandledrejection` → host |\n| Dispose / `using` | Manual `remove()` | `dispose()` + `[Symbol.dispose]` |\n\n<div class=\"decision-callout\">\n\n**Use Sandbox when** you need to render untrusted or user-provided HTML in the browser with guaranteed isolation, CSP enforcement, and a typed event bridge.\n\n**Consider a raw `<iframe>` when** you only need to embed a known third-party URL — Sandbox is for programmatic `srcdoc` content, not URL-based embedding.\n\n</div>\n\n## Installation\n\n::: code-group\n\n```sh [pnpm]\npnpm add @vielzeug/sandbox\n```\n\n```sh [npm]\nnpm install @vielzeug/sandbox\n```\n\n```sh [yarn]\nyarn add @vielzeug/sandbox\n```\n\n:::\n\n## Quick Start\n\n```ts\nimport { createSandbox } from '@vielzeug/sandbox';\n\nconst container = document.getElementById('preview')!;\nconst sandbox = createSandbox(container);\n\ntry {\n // render() resolves when the document is ready\n await sandbox.render('<ore-button variant=\"primary\">Click me</ore-button>');\n\n // Push state into the sandbox\n sandbox.setState('theme', 'dark');\n} catch (error) {\n console.error('Sandbox render failed', error);\n}\n\n// Receive events from sandbox code (ready is not forwarded — internal use only)\nsandbox.onMessage((msg) => {\n if (msg.type === 'custom') console.log(msg.event, msg.detail);\n if (msg.type === 'error') console.error(msg.message);\n if (msg.type === 'resize') console.log('height:', msg.height);\n});\n\n// Re-render: await the returned Promise\nawait sandbox.render(newHtml);\n\n// Clean up — removes iframe, clears listeners\nsandbox.dispose();\n// or: using sandbox = createSandbox(container);\n```\n\n## Features\n\n<div class=\"features-grid\">\n\n- `createSandbox()` — Creates an isolated `<iframe sandbox=\"allow-scripts\">` in the given container\n- `SandboxHandle.ready` — Promise resolving on first render's ready signal (also resolves on dispose; check `sandbox.disposed` to distinguish)\n- `SandboxHandle.disposalSignal` — `AbortSignal` aborted when the sandbox is disposed; tie async work to sandbox lifetime\n- `SandboxHandle.disposed` — Observable disposed state; check before deferred calls\n- `render(html, { signal? })` — Lazy iframe creation; returns `Promise<void>` resolving when ready, or rejecting with `SandboxTimeoutError` if the bridge never signals ready; pass `AbortSignal` to skip cancelled renders\n- `replaceBody(html)` — Replace body descendants without navigating; head scripts/styles survive while descendant state is replaced; suited to host-owned streaming markup\n- `updateStyle(id, css)` — Hot-patch a named `<style id=\"…\">` block live without re-rendering; also updates baseline for next render\n- `setState(key, value)` — Push state into the sandbox; received as `sandbox:state-update` CustomEvent\n- `setStateAll(record)` — Push multiple state values in a single postMessage; more efficient than repeated `setState()` calls for initial setup\n- `namedStyles` option — Named `<style id=\"key\">` blocks in document `<head>`; individually patchable via `updateStyle()`\n- `lang` / `title` options — Set basic language tag and `<title>` on generated documents for screen-reader correctness\n- `SandboxBridge` type — Ambient type for `window.__sandbox__` in sandbox-side TypeScript; `onState(key, handler)` subscribes to state pushed via `setState()`/`setStateAll()`\n- `custom` messages — Sandbox code emits `window.__sandbox__.emit(event, detail)` to the host\n- `resize` messages — Auto-emitted by the bridge's built-in `ResizeObserver`; no manual wiring needed\n- Strict CSP — `default-src 'none'`, inline scripts only, no network by default\n- `nonce` option — Cryptographic nonce for bridge `<script>` tag and `script-src` CSP\n- `scripts` option — Inject CDN scripts with `crossorigin=\"anonymous\"`; origins auto-added to `script-src`\n- `buildCsp()` — Build a standalone CSP string using the same `SandboxOptions`\n- `buildDocument()` — Build static isolated sandbox markup for server-side or offline use; use `createSandbox()` for host-managed runtime controls\n- Error forwarding — `onerror` + `unhandledrejection` forwarded as `{ type: 'error' }` messages\n- Disposable — `dispose()` + `[Symbol.dispose]` for `using` declarations\n\n</div>\n\n## Documentation\n\n<div class=\"doc-links\">\n\n- [Usage Guide](./usage.md)\n- [API Reference](./api.md)\n- [Examples](./examples.md)\n- [Migration Guide](./migration.md)\n\n</div>\n\n## See Also\n\n<div class=\"see-also\">\n\n- [Codex](/codex/) — MCP server with `generate-sandbox-document` and `get-state-bridge-spec` tools; generates document templates for use with Sandbox\n- [Refine](/refine/) — Web component library; renders correctly inside the sandbox via `<script>` injection and `allowedScriptOrigins`\n\n</div>\n\n<!-- markdownlint-enable MD025 MD033 MD060 -->\n",
5
+ "api": "---\ntitle: Sandbox — API Reference\ndescription: Full API reference for @vielzeug/sandbox — createSandbox, buildCsp, buildDocument, SandboxHandle, and all types.\n---\n\n[[toc]]\n\n## API Overview\n\n| Symbol | Purpose | Execution mode | Common gotcha |\n| ------ | ------- | -------------- | ------------- |\n| `createSandbox()` | Create an isolated sandboxed iframe runtime | Sync (returns handle); `render()` is async | Iframe DOM is created lazily — nothing exists until the first `render()` call |\n| `buildCsp()` | Build a CSP string from `SandboxOptions` | Sync | Invalid configuration throws `SandboxConfigurationError` |\n| `buildDocument()` | Build a complete standalone sandbox HTML document | Sync | Returns markup, not a host runtime handle; use `createSandbox()` for host-managed state or lifecycle |\n| `SandboxHandle` | Object returned by `createSandbox()` | — | `setState()`/`setStateAll()` warn in dev if called before `render()` resolves |\n| `SandboxOptions` | Unified options for `createSandbox`, `buildCsp`, `buildDocument` | — | All fields are optional; defaults documented per field below |\n| `SandboxBridge` | Bridge API at `window.__sandbox__` inside sandbox documents | — | `emit()` sends events to the host; `onState()` only receives — there is no way to call host functions directly |\n| `SandboxMessage` | Application messages the sandbox sends to the host | — | `'ready'` is not part of this union — it resolves `render()` internally instead |\n| `SandboxError` | Base error class for `@vielzeug/sandbox` | — | Use `SandboxError.is(err)` to narrow package errors |\n| `SandboxConfigurationError` | Thrown for invalid origins, URLs, nonces, language tags, or style IDs | — | Fix configuration rather than relying on sanitization |\n| `SandboxTimeoutError` | Thrown by `render()` when no `'ready'` signal arrives in time | — | Extends `SandboxError`; the document is likely missing the bridge script |\n| `SandboxStateUpdateDetail` | Detail payload of the sandbox-side `sandbox:state-update` CustomEvent | — | Only relevant inside sandbox documents, not on the host |\n| `Unsubscribe` | Return type of `onMessage()` and `SandboxBridge.onState()` | — | Calling it more than once is a safe no-op |\n\n## Package Entry Points\n\n| Import | Purpose |\n| ------ | ------- |\n| `@vielzeug/sandbox` | Main exports and types |\n| `@vielzeug/sandbox/testing` | `createSandboxTestHelpers` — postMessage simulation helpers for tests |\n\n```ts\nimport {\n buildCsp,\n buildDocument,\n createSandbox,\n SandboxConfigurationError,\n SandboxError,\n SandboxTimeoutError,\n} from '@vielzeug/sandbox';\nimport type {\n SandboxBridge,\n SandboxHandle,\n SandboxMessage,\n SandboxOptions,\n SandboxStateUpdateDetail,\n Unsubscribe,\n} from '@vielzeug/sandbox';\n\nimport { createSandboxTestHelpers } from '@vielzeug/sandbox/testing';\n```\n\n## `createSandbox(container, options?)`\n\nCreates a sandboxed `<iframe>` inside `container` and returns a `SandboxHandle`.\n\n```ts\nfunction createSandbox(container: HTMLElement, options?: SandboxOptions): SandboxHandle\n```\n\nThe iframe is created lazily on the first `render()` call — `createSandbox()` is a cheap factory with no DOM work until content is ready. The iframe uses `sandbox=\"allow-scripts\"` and `referrerpolicy=\"no-referrer\"`. Content is loaded via `srcdoc` with an auto-generated CSP meta tag. The sandbox cannot access host cookies, storage, or the DOM.\n\n**Parameters**\n\n- `container` — The DOM element to append the iframe to.\n- `options` — Optional `SandboxOptions`.\n\n**Returns** a `SandboxHandle`.\n\n**Example**\n\n```ts\nconst sandbox = createSandbox(document.getElementById('preview')!);\nawait sandbox.render('<p>Hello from the sandbox</p>');\n```\n\n## `SandboxHandle`\n\n```ts\ninterface SandboxHandle {\n readonly disposalSignal: AbortSignal;\n readonly disposed: boolean;\n readonly ready: Promise<void>;\n dispose(): void;\n onMessage(handler: (msg: SandboxMessage) => void): Unsubscribe;\n replaceBody(html: string): void;\n render(html: string, options?: { signal?: AbortSignal }): Promise<void>;\n setState(key: string, value: unknown): void;\n setStateAll(record: Record<string, unknown>): void;\n updateStyle(id: string, css: string): void;\n [Symbol.dispose](): void;\n}\n```\n\n| Member | Description |\n| ------ | ----------- |\n| `disposalSignal` | `AbortSignal` that is aborted when `dispose()` is called. Pass to `fetch` and other async operations to tie their lifetime to the sandbox. |\n| `disposed` | `true` once `dispose()` has been called. |\n| `ready` | Promise that resolves when the **first** sandbox document signals it has loaded. Also resolves if the sandbox is disposed before the first render — check `sandbox.disposed` after awaiting to distinguish the two cases. Does **not** reset on re-renders — use the Promise returned by `render()` for subsequent renders. |\n| `replaceBody(html)` | Replace `document.body.innerHTML` without navigating. Head scripts, document/window listeners, and `namedStyles` survive; body descendants, their listeners, references, form state, and scripts in replacement HTML do not. Call after `render()` resolves. |\n| `render(html, options?)` | Replace the entire sandboxed document (full page reset). Creates the iframe lazily. Returns a `Promise<void>` that resolves when the new document signals ready, or **rejects with `SandboxTimeoutError`** if no `'ready'` signal arrives within 5s. If a second `render()` starts before the first resolves, the first Promise resolves (not rejects) immediately — the document simply navigated away. Pass `options.signal` to skip if already aborted. Emits a dev warning when `html` is empty or whitespace-only. |\n| `updateStyle(id, css)` | Hot-patch a named `<style id=\"…\">` block in the live iframe via postMessage, and update the baseline for the next `render()`. No-ops if the sandbox is disposed. Safe to call before the first render (baseline only). Warns in dev if `id` is not a known key in `namedStyles`. |\n| `setState(key, value)` | Push a state value into the sandbox. Dispatches a `sandbox:state-update` CustomEvent inside the iframe. Warns in dev if called before `render()` resolves. |\n| `setStateAll(record)` | Push multiple state values in a single postMessage. Dispatches one `sandbox:state-update` CustomEvent per key inside the iframe. More efficient than calling `setState()` repeatedly for initial state setup. Warns in dev if called before `render()` resolves. |\n| `onMessage(handler)` | Subscribe to `SandboxMessage` events (`error`, `custom`, and `resize`). The `ready` lifecycle signal is not forwarded. Returns an `Unsubscribe` function. |\n| `dispose()` | Remove the iframe from the DOM and clear all listeners. Resolves any pending `ready` Promise and aborts `disposalSignal`. |\n| `[Symbol.dispose]()` | Alias for `dispose()` — enables `using sandbox = createSandbox(…)`. |\n\n::: warning Dev warnings\nCalling `render()`, `setState()`, `setStateAll()`, `updateStyle()`, or `onMessage()` on a disposed sandbox emits a warning in development (when `import.meta.env.PROD` is not `true`).\n\nCalling `setState()` or `setStateAll()` before `render()` resolves emits a dev warning — the bridge may not have set up its listener yet and the state update may be silently dropped. Always await the Promise returned by `render()` before calling either.\n\nIn production all guard paths are silent no-ops (no warnings).\n:::\n\n::: warning render() can reject\nUnlike the other guard paths above, the `SandboxTimeoutError` rejection from `render()` is **not** a dev-only warning — it fires in every build. Always attach a `.catch()` or wrap `await sandbox.render(...)` in `try`/`catch`:\n\n```ts\ntry {\n await sandbox.render(html);\n} catch (err) {\n if (SandboxError.is(err)) {\n console.error('Sandbox failed to load:', err.message);\n }\n}\n```\n:::\n\n## `SandboxOptions`\n\nUnified options for `createSandbox`, `buildCsp`, and `buildDocument`. All fields are optional.\n\n```ts\ninterface SandboxOptions {\n allowedFontOrigins?: string[];\n allowedImageOrigins?: string[];\n allowedScriptOrigins?: string[];\n allowedStyleOrigins?: string[];\n lang?: string;\n namedStyles?: Record<string, string>;\n nonce?: string;\n scripts?: string[];\n title?: string;\n}\n```\n\n| Option | Type | Default | Description |\n| ------ | ---- | ------- | ----------- |\n| `allowedFontOrigins` | `string[]` | `[]` | Absolute `http:` or `https:` origins added to `font-src`; paths, query strings, fragments, and credentials are rejected. Default directive value: `'none'`. |\n| `allowedImageOrigins` | `string[]` | `[]` | Absolute `http:` or `https:` origins added to `img-src`. `data:` is always included. |\n| `allowedScriptOrigins` | `string[]` | `[]` | Absolute `http:` or `https:` origins added to `script-src`. Merged with origins extracted from `scripts`. |\n| `allowedStyleOrigins` | `string[]` | `[]` | Absolute `http:` or `https:` origins added to `style-src`. `'unsafe-inline'` is always included. |\n| `lang` | `string` | `'en'` | Basic language tag: 2–3 letter primary language followed by optional 2–8 character subtags, such as `en`, `de`, or `zh-Hant`. |\n| `namedStyles` | `Record<string, string>` | `{}` | Named `<style id=\"key\">` blocks in document `<head>`. Keys start with a letter and contain only letters, digits, `_`, or `-`; each block is patchable via `updateStyle(id, css)`. |\n| `nonce` | `string` | `undefined` | Non-empty base64/base64url-style token added to both bridge scripts and `script-src`. In CSP Level 3 browsers the nonce suppresses `'unsafe-inline'`; `'unsafe-inline'` remains for CSP Level 2 fallback. |\n| `scripts` | `string[]` | `[]` | Absolute `http:` or `https:` script URLs injected before user content with `crossorigin=\"anonymous\"`. Their origins are added to `script-src`. |\n| `title` | `string` | `''` | Title for generated document, placed in `<title>`. Providing a title improves screen reader compatibility. |\n\n::: warning Security\n`title` and CSS content are escaped before interpolation. Origins, script URLs, `nonce`, `lang`, and `namedStyles` IDs are validated before document generation; invalid configuration throws `SandboxConfigurationError` instead of being rewritten.\n:::\n\n## `buildCsp(options?)`\n\nBuilds a strict Content-Security-Policy string for sandboxed iframe documents.\n\n```ts\nfunction buildCsp(options?: SandboxOptions): string\n```\n\nAccepts `SandboxOptions` directly. Origins from `scripts` URLs are extracted and merged with `allowedScriptOrigins` automatically. Returns a semicolon-separated CSP string with eight directives. `base-uri 'none'` is always included to block `<base>`-tag injection, and `connect-src 'none'` / `form-action 'none'` block network requests and form submission by default.\n\n**Default output (no options)**\n\n```\ndefault-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data:; font-src 'none'; connect-src 'none'; form-action 'none'; base-uri 'none'\n```\n\n**Example**\n\n```ts\nconst csp = buildCsp({\n allowedStyleOrigins: ['https://fonts.googleapis.com'],\n allowedFontOrigins: ['https://fonts.gstatic.com'],\n scripts: ['https://cdn.example.com/refine.iife.js'],\n});\n// script-src includes 'unsafe-inline' + https://cdn.example.com automatically\n```\n\n## `buildDocument(html, options?)`\n\nBuilds a complete, standalone sandbox HTML document.\n\n```ts\nfunction buildDocument(html: string, options?: SandboxOptions): string\n```\n\nIncludes the `<html lang=\"…\">` attribute, `<title>`, CSP meta tag, injected scripts, `namedStyles` rendered as `<style id=\"key\">` blocks, user content, and bridge script. Returns isolated markup for `iframe.srcdoc` or server generation (for example, through `@vielzeug/codex`).\n\n`buildDocument()` does not return a `SandboxHandle`. Use `createSandbox()` when the host must push state, replace body content, update styles, await readiness, or manage disposal.\n\nExternal scripts are placed **before** user content with `crossorigin=\"anonymous\"`, so the bridge's error handler receives full error details for cross-origin script errors. The bridge emits `ready` after preceding parser-blocking scripts execute, then observes `document.body` for resize messages.\n\n`lang` defaults to `'en'` and `title` defaults to `''` — both are HTML-escaped before interpolation.\n\n**Example**\n\n```ts\nimport { buildDocument } from '@vielzeug/sandbox';\n\nconst html = buildDocument('<p>Hello</p>', {\n lang: 'de',\n title: 'Component Preview',\n namedStyles: {\n base: 'body { font-family: sans-serif; }',\n theme: ':root { --bg: #fff; }',\n },\n});\n\niframe.srcdoc = html;\n```\n\n## Bridge Protocol\n\n### `SandboxMessage`\n\nApplication-level messages the sandbox sends to the host, received via `sandbox.onMessage(handler)`. The `ready` lifecycle signal is **intentionally excluded** — it resolves `sandbox.ready` and the Promise returned by `render()` internally and is not forwarded to subscribers.\n\n```ts\ntype SandboxMessage =\n | { type: 'error'; message: string; stack?: string }\n | { type: 'custom'; event: string; detail: unknown }\n | { type: 'resize'; height: number };\n```\n\n| Type | Fields | Description |\n| ---- | ------ | ----------- |\n| `error` | `message: string`, `stack?: string` | Fired on uncaught errors or unhandled promise rejections inside the sandbox. |\n| `custom` | `event: string`, `detail: unknown` | User-defined events emitted from sandbox code via `window.__sandbox__.emit(event, detail)`. |\n| `resize` | `height: number` | Emitted automatically when sandbox content height changes. The bridge script sets up a `ResizeObserver` on `document.body` — no manual wiring needed. |\n\n### `SandboxStateUpdateDetail`\n\nDetail payload of the `sandbox:state-update` CustomEvent dispatched **inside** sandbox documents by `setState()`/`setStateAll()`. Only relevant to sandbox-side code — the host never sees this type directly.\n\n```ts\ninterface SandboxStateUpdateDetail {\n key: string;\n value: unknown;\n}\n```\n\n**Emitting custom events from inside the sandbox:**\n\n```js\nwindow.__sandbox__.emit('button:click', { label: 'Save', timestamp: Date.now() });\n```\n\n**Receiving on the host:**\n\n```ts\nsandbox.onMessage((msg) => {\n if (msg.type === 'custom' && msg.event === 'button:click') {\n console.log('Button clicked:', msg.detail);\n }\n if (msg.type === 'error') {\n console.error('[sandbox]', msg.message, msg.stack);\n }\n if (msg.type === 'resize') {\n container.style.height = `${msg.height}px`;\n }\n});\n```\n\n### `SandboxBridge`\n\nThe bridge API available as `window.__sandbox__` inside sandbox documents. Export this type to add TypeScript support for sandbox-side code:\n\n```ts\ninterface SandboxBridge {\n emit(event: string, detail?: unknown): void;\n onState(key: string, handler: (value: unknown) => void): Unsubscribe;\n}\n```\n\nAdd an ambient declaration in your sandbox-side TypeScript project:\n\n```ts\n// sandbox-env.d.ts\ndeclare interface Window {\n __sandbox__: import('@vielzeug/sandbox').SandboxBridge;\n}\n```\n\n`onState(key, handler)` subscribes to state pushed via `sandbox.setState()`/`setStateAll()` for a specific key — it wraps the raw `sandbox:state-update` CustomEvent so sandbox-side code doesn't need to filter by key manually. Returns an `Unsubscribe` function:\n\n```ts\nconst off = window.__sandbox__.onState('theme', (value) => {\n document.body.dataset.theme = String(value);\n});\n\n// Later, stop listening:\noff();\n```\n\n### State updates\n\n`sandbox.setState(key, value)` sends a single state value into the sandbox; `sandbox.setStateAll(record)` sends multiple values in one postMessage. Both dispatch a `sandbox:state-update` CustomEvent per key, described by `SandboxStateUpdateDetail`. Inside the sandbox, either listen via the DOM directly or use `window.__sandbox__.onState()`:\n\n```js\ndocument.addEventListener('sandbox:state-update', (e) => {\n const { key, value } = e.detail;\n if (key === 'theme') document.body.dataset.theme = value;\n});\n```\n\n```ts\n// Single value\nsandbox.setState('theme', 'dark');\n\n// Multiple values in one postMessage — fires 'sandbox:state-update' twice, once per key\nsandbox.setStateAll({ theme: 'dark', locale: 'en' });\n```\n\n::: warning Security\nTreat all `SandboxMessage` data as untrusted. The sandbox controls what `custom` event payloads contain — do not execute or evaluate any message field.\n:::\n\n## Types\n\n### `Unsubscribe`\n\n```ts\ntype Unsubscribe = () => void;\n```\n\nReturn type of `onMessage()` and `SandboxBridge.onState()`. Calling it more than once is a safe no-op.\n\n## Errors\n\n### `SandboxError`\n\nBase class for all `@vielzeug/sandbox` errors. Extends `Error`.\n\n```ts\nclass SandboxError extends Error {\n static is(err: unknown): err is SandboxError;\n}\n```\n\n`SandboxError.is()` is a type-safe static predicate — prefer it over `instanceof` in catch blocks that may receive unknown values. It also matches subclasses like `SandboxTimeoutError`:\n\n```ts\nimport { SandboxError } from '@vielzeug/sandbox';\n\ntry {\n await sandbox.render(html);\n} catch (err) {\n if (SandboxError.is(err)) {\n console.error(err.message);\n }\n}\n```\n\n### `SandboxConfigurationError`\n\nThrown when Sandbox configuration cannot produce a valid CSP or document. Origins must be absolute `http:` or `https:` origins without paths, query strings, fragments, or credentials. Scripts must be absolute `http:` or `https:` URLs. Nonces, basic language tags, and named style IDs must match their documented syntax.\n\n```ts\nimport { SandboxConfigurationError } from '@vielzeug/sandbox';\n\ntry {\n buildCsp({ allowedScriptOrigins: ['cdn.example.com/path'] });\n} catch (error) {\n if (error instanceof SandboxConfigurationError) console.error(error.message);\n}\n```\n\n### `SandboxTimeoutError`\n\nThrown as a rejection from `render()` when no `'ready'` signal arrives within 5 seconds, in every build (not a dev-only warning). Extends `SandboxError`. The sandbox document is most likely missing the bridge script — use `buildDocument()` to generate documents that include it, rather than hand-writing the `srcdoc` HTML.\n\n```ts\nimport { SandboxTimeoutError } from '@vielzeug/sandbox';\n\ntry {\n await sandbox.render(customHtmlMissingBridge);\n} catch (err) {\n if (err instanceof SandboxTimeoutError) {\n console.error('Sandbox never signaled ready:', err.message);\n }\n}\n```\n\n## Test Utilities\n\n`@vielzeug/sandbox/testing` exports helpers for code that integrates with the sandbox:\n\n```ts\nimport { createSandboxTestHelpers } from '@vielzeug/sandbox/testing';\n\nconst helpers = createSandboxTestHelpers(container);\n\nsandbox.render('<p>test</p>');\nhelpers.fireReady(); // simulate bridge ready signal\nhelpers.fireCustom('click', { x: 1 }); // simulate window.__sandbox__.emit()\nhelpers.fireResize(420); // simulate ResizeObserver callback\nhelpers.fireError('TypeError: x is not defined', 'at eval:1');\n```\n\nThese helpers encapsulate the internal postMessage protocol so test code doesn't need to know message shapes.\n",
6
+ "usage": "---\ntitle: Sandbox — Usage Guide\ndescription: How to render untrusted HTML, pass state, handle errors, configure CSP, and integrate the sandbox with your application.\n---\n\n[[toc]]\n\n::: tip New to Sandbox?\nStart with the [Overview](./index.md) for installation and a quick example, then come back here for in-depth usage patterns.\n:::\n\n## Basic Usage\n\nCreate a sandbox by passing a container element. The returned `SandboxHandle` is your entire interface to the iframe.\n\n```ts\nimport { createSandbox } from '@vielzeug/sandbox';\n\nconst container = document.getElementById('preview')!;\nconst sandbox = createSandbox(container);\n\nawait sandbox.render('<p>Hello from the sandbox</p>');\n```\n\n`render()` returns a `Promise<void>` that resolves when the sandbox document signals it is ready. No DOM is created until `render()` is called — `createSandbox()` is a cheap factory.\n\nFor reactive frameworks, subscribe via `onMessage` to receive `error`, `custom`, and `resize` events.\n\n## Rendering HTML\n\n`render(html)` replaces the entire sandboxed document with a new one containing your HTML in the body.\n\n```ts\nawait sandbox.render(`\n <style>body { font-family: sans-serif; }</style>\n <h1>Component Preview</h1>\n <ore-button variant=\"primary\">Click me</ore-button>\n`);\n```\n\nEach call to `render()` is a full page reset — scripts reinitialise, CSS is re-applied, and any DOM state is lost. For incremental updates, push state via `setState()` or patch styles via `updateStyle()` rather than re-rendering.\n\n## Incremental Updates with replaceBody()\n\n`replaceBody(html)` replaces `document.body.innerHTML` in the live document without navigating the iframe. Head scripts, document/window listeners, named styles, and global state survive. Body descendants, their listeners, references, form state, and scripts inside replacement HTML do not survive.\n\nUse it for streaming AI-generated output or live previews when the host owns accumulated markup.\n\n```ts\n// Initial render — sets up the document, scripts, and styles\nawait sandbox.render(`\n <script>\n document.addEventListener('sandbox:state-update', (e) => {\n document.body.dataset.theme = e.detail.value;\n });\n </script>\n <p>Loading…</p>\n`);\n\n// Subsequent updates replace body descendants\nsandbox.replaceBody('<p>First chunk arrived</p>');\nsandbox.replaceBody('<p>First chunk arrived</p><p>Second chunk…</p>');\nsandbox.replaceBody('<p>Complete response</p>');\n```\n\n**`replaceBody()` vs `render()`:**\n\n| | `render()` | `replaceBody()` |\n|---|---|---|\n| Full page reset | Yes | No |\n| Returns a Promise | Yes | No |\n| Head scripts re-run | Yes | No |\n| `namedStyles` preserved | Re-injected | Yes |\n| Body descendants/listeners | Recreated | Replaced |\n| When to use | Initial load, structural reset | Streaming markup, live preview |\n\n**`replaceBody()` must be called after `render()` resolves.** The bridge must be initialized before it can receive the replacement.\n\n## Passing State\n\n`setState(key, value)` pushes data into the sandbox without re-rendering.\n\nAlways call `setState()` after `render()` resolves — calling it before the bridge finishes initializing will silently drop the update in a real browser, and a dev warning will fire.\n\n```ts\n// Correct: await render() before pushing state\nawait sandbox.render('<div id=\"root\"></div>');\nsandbox.setState('theme', 'dark');\nsandbox.setState('user', { name: 'Alice' });\n```\n\nInside the sandbox document, listen for the `sandbox:state-update` custom event on `document`:\n\n```html\n<script>\ndocument.addEventListener('sandbox:state-update', (e) => {\n const { key, value } = e.detail;\n if (key === 'theme') document.body.dataset.theme = value;\n if (key === 'user') document.querySelector('#name').textContent = value.name;\n});\n</script>\n```\n\n## Batch State Updates\n\n`setStateAll(record)` pushes multiple state values in a single postMessage — one call instead of one `setState()` per key. Use it for initial state setup where several values become available at the same time.\n\n```ts\nawait sandbox.render('<div id=\"root\"></div>');\n\n// One postMessage instead of two setState() calls\nsandbox.setStateAll({\n theme: 'dark',\n user: { name: 'Alice' },\n});\n```\n\nThe sandbox side listens the same way as for `setState()` — each key in the record fires its own `sandbox:state-update` event.\n\n## Handling Errors\n\nSubscribe to `onMessage` before calling `render()` to catch runtime errors in sandbox content.\n\n```ts\nsandbox.onMessage((msg) => {\n if (msg.type === 'error') {\n console.error('[sandbox error]', msg.message);\n if (msg.stack) console.debug(msg.stack);\n }\n});\n```\n\nBoth synchronous errors (`window.onerror`) and unhandled promise rejections (`unhandledrejection`) are forwarded as `{ type: 'error' }` messages.\n\n### `render()` rejection\n\n`render()` rejects with a `SandboxTimeoutError` if the document never signals `'ready'` within 5 seconds — this happens in every build, not just dev. It usually means the document is missing the bridge script (custom `srcdoc` HTML built by hand instead of via `buildDocument()`). Always handle it:\n\n```ts\nimport { SandboxError } from '@vielzeug/sandbox';\n\ntry {\n await sandbox.render(html);\n} catch (err) {\n if (SandboxError.is(err)) {\n console.error('Sandbox failed to load:', err.message);\n }\n}\n```\n\nA second `render()` call superseding the first does **not** trigger this — the superseded Promise resolves, not rejects.\n\n## Injecting Scripts and Styles\n\nUse `SandboxOptions` to inject external scripts and styles into every rendered document.\n\n```ts\nconst sandbox = createSandbox(container, {\n scripts: [\n 'https://cdn.example.com/ore.js',\n 'https://cdn.example.com/refine.js',\n ],\n namedStyles: {\n base: `\n :root { --color-primary: #0066cc; }\n body { margin: 0; font-family: var(--font-sans); }\n `,\n },\n});\n```\n\nScript URLs are injected before user content. Their origins are automatically added to `script-src` in the CSP — you do not need to configure `buildCsp` separately.\n\n## Setting Document Language and Title\n\nUse `lang` and `title` to set the generated document's `<html lang=\"…\">` attribute and `<title>`. Both improve screen-reader behaviour for sandboxed content.\n\n```ts\nconst sandbox = createSandbox(container, {\n lang: 'de',\n title: 'Component Preview',\n});\n```\n\n`lang` defaults to `'en'`; use a 2–3 letter primary language with optional 2–8 character subtags, such as `de` or `zh-Hant`. `title` defaults to `''` and is HTML-escaped before document generation. Invalid language tags throw `SandboxConfigurationError`.\n\n## Hot-patching Named Styles\n\n`namedStyles` injects named `<style id=\"key\">` blocks into the document `<head>`. Named blocks can be updated live without a full re-render using `updateStyle(id, css)`.\n\n```ts\nconst sandbox = createSandbox(container, {\n namedStyles: {\n theme: ':root { --color-primary: #0066cc; --bg: #fff; }',\n },\n});\n\nawait sandbox.render('<ore-button variant=\"primary\">Click me</ore-button>');\n\n// Switch theme live — no re-render\nsandbox.updateStyle('theme', ':root { --color-primary: #bb33ff; --bg: #111; }');\n```\n\n`updateStyle()` sends a postMessage to the iframe, patching `<style id=\"theme\">` in place. It also updates the baseline so the next `render()` starts with the patched CSS. Safe to call before the first render (baseline only — no postMessage sent to an uninitialized iframe).\n\n## Resize Notifications\n\nThe bridge script automatically emits `resize` messages via a `ResizeObserver` on `document.body`. No manual wiring is needed in your sandbox content.\n\n```ts\nsandbox.onMessage((msg) => {\n if (msg.type === 'resize') {\n container.style.height = `${msg.height}px`;\n }\n});\n```\n\nThe `resize` message fires whenever the `document.body` height changes — on initial load, after content updates via `setState()`, and after style patches via `updateStyle()`.\n\n## Tying Async Work to Sandbox Lifetime\n\n`disposalSignal` is an `AbortSignal` that is aborted when the sandbox is disposed. Pass it to any async operation that should stop when the sandbox is torn down.\n\n```ts\nconst sandbox = createSandbox(container);\n\n// Polling loop tied to sandbox lifetime\nasync function poll() {\n while (!sandbox.disposalSignal.aborted) {\n const data = await fetch('/api/data', { signal: sandbox.disposalSignal }).then(r => r.json()).catch(() => null);\n if (data) sandbox.setState('data', data);\n await new Promise(resolve => setTimeout(resolve, 5000));\n }\n}\n\npoll();\n```\n\nWhen `sandbox.dispose()` is called, `disposalSignal` aborts, cancelling in-flight fetches and stopping the loop.\n\n## Configuring CSP\n\nUse `allowedStyleOrigins`, `allowedFontOrigins`, and `allowedImageOrigins` to allow CDN resources.\n\n```ts\nconst sandbox = createSandbox(container, {\n allowedStyleOrigins: ['https://fonts.googleapis.com'],\n allowedFontOrigins: ['https://fonts.gstatic.com'],\n allowedImageOrigins: ['https://images.example.com'],\n});\n```\n\nThen render HTML that uses those resources:\n\n```ts\nawait sandbox.render(`\n <link rel=\"stylesheet\" href=\"https://fonts.googleapis.com/css2?family=Inter\">\n <p style=\"font-family: Inter, sans-serif\">Hello</p>\n`);\n```\n\nOrigins must be absolute `http:` or `https:` origins without paths, query strings, fragments, or credentials. Script URLs must be absolute `http:` or `https:` URLs. Nonces must be non-empty base64/base64url-style tokens. Invalid configuration throws `SandboxConfigurationError`; generated CSP always includes `base-uri 'none'` to block `<base>`-tag injection.\n\n## Disposal\n\nDispose the sandbox when it is no longer needed. This removes the iframe from the DOM and clears all message listeners.\n\n```ts\n// Explicit\nsandbox.dispose();\n\n// Using explicit resource management (TypeScript 5.2+)\n{\n using sandbox = createSandbox(container);\n await sandbox.render('<p>Temporary preview</p>');\n} // sandbox.dispose() called automatically\n```\n\n## Multiple Listeners\n\n`onMessage` supports multiple independent subscriptions. Each call returns its own unsubscribe function.\n\n```ts\nconst unsubErrors = sandbox.onMessage((msg) => {\n if (msg.type === 'error') logError(msg);\n});\n\nconst unsubEvents = sandbox.onMessage((msg) => {\n if (msg.type === 'custom') handleCustomEvent(msg);\n});\n\n// Remove a single subscription\nunsubErrors();\n\n// Remove all — dispose() clears all listeners at once\nsandbox.dispose();\n```\n\n## Receiving Events from the Sandbox\n\nSandbox code calls `window.__sandbox__.emit(event, detail)` to send events to the host. Receive them via `onMessage` with `msg.type === 'custom'`.\n\n```html\n<!-- Inside sandbox content -->\n<button onclick=\"window.__sandbox__.emit('button:click', { label: 'Save' })\">Save</button>\n```\n\n```ts\n// Host\nsandbox.onMessage((msg) => {\n if (msg.type === 'custom' && msg.event === 'button:click') {\n console.log('Sandbox button clicked:', msg.detail);\n }\n});\n```\n\n**TypeScript support for sandbox-side code** — add an ambient declaration referencing `SandboxBridge`:\n\n```ts\n// sandbox-env.d.ts\ndeclare interface Window {\n __sandbox__: import('@vielzeug/sandbox').SandboxBridge;\n}\n```\n\n## Awaiting Subsequent Renders\n\n`render()` returns a `Promise<void>` that resolves when the new document signals ready. Await it directly for each render:\n\n```ts\nawait sandbox.render(firstHtml); // first render complete\nawait sandbox.render(secondHtml); // second render complete\n```\n\nIf a second `render()` starts before the first resolves, the first Promise resolves immediately (superseded). Multiple concurrent callers can each await their own returned Promise.\n\n## Cancelling Renders with AbortSignal\n\nPass an `AbortSignal` to `render()` to skip the render if it has already been cancelled. Useful in streaming or queued workflows:\n\n```ts\nlet controller = new AbortController();\n\nasync function streamRender(html: string) {\n controller.abort(); // cancel previous pending render\n controller = new AbortController();\n await sandbox.render(html, { signal: controller.signal });\n}\n```\n\nIf the signal is already aborted when `render()` is called, the render is skipped with no warning and no DOM change.\n\n## Building Sandbox Documents Directly\n\nUse `buildDocument` when you need static isolated markup outside `createSandbox`, such as server-generated HTML or a Codex template. Use `createSandbox` instead when the host needs state updates, body replacement, style updates, readiness, or disposal.\n\n```ts\nimport { buildDocument } from '@vielzeug/sandbox';\n\nconst html = buildDocument('<p>Hello</p>', {\n allowedStyleOrigins: ['https://fonts.googleapis.com'],\n allowedFontOrigins: ['https://fonts.gstatic.com'],\n namedStyles: {\n theme: ':root { --bg: #fff; }',\n },\n});\n\n// html is a complete <!doctype html> document — assign directly to srcdoc\niframe.srcdoc = html;\n```\n\nUse `buildCsp` if you only need the CSP string for an existing document template:\n\n```ts\nimport { buildCsp } from '@vielzeug/sandbox';\n\nconst csp = buildCsp({ allowedFontOrigins: ['https://fonts.gstatic.com'] });\n// → \"default-src 'none'; ... font-src https://fonts.gstatic.com; ...\"\n```\n\n## Testing\n\nUse `createSandboxTestHelpers` from the `/testing` subpath to simulate sandbox→host messages without a real `srcdoc` script execution (jsdom does not execute iframe `srcdoc` scripts).\n\n```ts\nimport { createSandbox } from '@vielzeug/sandbox';\nimport { createSandboxTestHelpers } from '@vielzeug/sandbox/testing';\nimport { describe, expect, it } from 'vitest';\n\ndescribe('preview panel', () => {\n it('forwards a custom event from the sandbox', async () => {\n const container = document.createElement('div');\n const sandbox = createSandbox(container);\n const helpers = createSandboxTestHelpers(container);\n\n const received: unknown[] = [];\n\n sandbox.onMessage((msg) => received.push(msg));\n\n const renderPromise = sandbox.render('<button>Save</button>');\n\n helpers.fireReady(); // simulate the bridge script's initial postMessage\n await renderPromise;\n\n helpers.fireCustom('button:click', { label: 'Save' });\n expect(received).toEqual([{ type: 'custom', event: 'button:click', detail: { label: 'Save' } }]);\n\n sandbox.dispose();\n });\n});\n```\n\n`SandboxTestHelpers` also exposes `fireResize(height)` and `fireError(message, stack?)` for testing resize and error handling without a live browser.\n\n## Framework Integration\n\nCreate the sandbox once per mount and dispose it on unmount — the container element is stable for the component's lifetime.\n\n::: code-group\n\n```tsx [React]\nimport { useEffect, useRef } from 'react';\nimport { createSandbox } from '@vielzeug/sandbox';\n\nfunction SandboxPreview({ html }: { html: string }) {\n const containerRef = useRef<HTMLDivElement>(null);\n\n useEffect(() => {\n if (!containerRef.current) return;\n\n const sandbox = createSandbox(containerRef.current);\n\n sandbox.render(html);\n\n return () => sandbox.dispose();\n }, [html]);\n\n return <div ref={containerRef} />;\n}\n```\n\n```vue [Vue 3]\n<script setup lang=\"ts\">\nimport { onMounted, onUnmounted, ref } from 'vue';\nimport { createSandbox, type SandboxHandle } from '@vielzeug/sandbox';\n\nconst props = defineProps<{ html: string }>();\nconst containerRef = ref<HTMLDivElement>();\nlet sandbox: SandboxHandle | undefined;\n\nonMounted(() => {\n if (!containerRef.value) return;\n sandbox = createSandbox(containerRef.value);\n sandbox.render(props.html);\n});\n\nonUnmounted(() => sandbox?.dispose());\n</script>\n\n<template>\n <div ref=\"containerRef\" />\n</template>\n```\n\n```svelte [Svelte]\n<script lang=\"ts\">\n import { onMount } from 'svelte';\n import { createSandbox } from '@vielzeug/sandbox';\n\n export let html: string;\n let container: HTMLDivElement;\n\n onMount(() => {\n const sandbox = createSandbox(container);\n\n sandbox.render(html);\n\n return () => sandbox.dispose();\n });\n</script>\n\n<div bind:this={container}></div>\n```\n\n:::\n\n## Working with Other Vielzeug Libraries\n\n**With Codex:**\nThe `generate-sandbox-document` and `get-state-bridge-spec` MCP tools in `@vielzeug/codex` are designed to work with Sandbox. They generate complete sandbox-ready document templates and document the bridge protocol.\n\n```ts\n// After codex generates an HTML document:\nawait sandbox.render(generatedDocument);\n```\n\n**With Refine:**\nInject the Refine/Ore runtime into the sandbox via `scripts`:\n\n```ts\nconst sandbox = createSandbox(container, {\n scripts: ['https://cdn.example.com/refine.iife.js'],\n namedStyles: {\n theme: '/* refine theme tokens */',\n },\n});\n\nawait sandbox.render('<ore-card><ore-button>Save</ore-button></ore-card>');\n```\n\n## Best Practices\n\n- **Await `render()` before calling `setState()`/`setStateAll()`** — both warn in dev if called before the bridge is ready. Use `setStateAll()` to bootstrap several values in one postMessage instead of calling `setState()` repeatedly.\n- **Use `await sandbox.render(html)` for each render** — `render()` returns a `Promise<void>` that resolves when the document is ready. No separate readiness API is needed.\n- **Use `updateStyle()` for theme switching** — updating a named style avoids a full `render()` and preserves current document state.\n- **Check `disposed` before deferred calls** — across async operations, check `sandbox.disposed` before calling any method to avoid spurious dev warnings.\n- **Tie async work to `disposalSignal`** — pass `disposalSignal` to `fetch` and other async operations so they cancel automatically on dispose.\n- **Treat all messages as untrusted** — sandbox code controls `SandboxMessage` payloads. Do not `eval()` or execute any message field.\n- **One sandbox per preview** — `createSandbox()` is a cheap factory; create a new sandbox per user session or component rather than reusing across unrelated renders.\n- **Use `using` in functions** — in TypeScript 5.2+ contexts, `using` guarantees cleanup even on exceptions.\n- **Use `replaceBody()` or `setState()` for incremental updates** — `render()` resets document state. `replaceBody()` replaces body descendants; `setState()` updates live code without replacing DOM.\n",
7
7
  "examples": "---\ntitle: Sandbox — Examples\ndescription: Recipes for common Sandbox use cases — component previews, user script sandboxes, and embedded widgets.\n---\n\n## Examples\n\n- [Component Preview](./examples/component-preview.md)\n- [User Script Sandbox](./examples/user-script-sandbox.md)\n- [Embedded Widget](./examples/embedded-widget.md)\n- [AI UI Renderer](./examples/ai-ui-renderer.md)\n"
8
8
  },
9
9
  "examples": [
@@ -27,8 +27,9 @@
27
27
  "buildCsp": "export { buildCsp, buildDocument, createSandbox } from './_sandbox.js';",
28
28
  "buildDocument": "export { buildCsp, buildDocument, createSandbox } from './_sandbox.js';",
29
29
  "createSandbox": "export { buildCsp, buildDocument, createSandbox } from './_sandbox.js';",
30
- "SandboxError": "export { SandboxError, SandboxTimeoutError } from './errors.js';",
31
- "SandboxTimeoutError": "export { SandboxError, SandboxTimeoutError } from './errors.js';",
30
+ "SandboxConfigurationError": "export { SandboxConfigurationError, SandboxError, SandboxTimeoutError } from './errors.js';",
31
+ "SandboxError": "export { SandboxConfigurationError, SandboxError, SandboxTimeoutError } from './errors.js';",
32
+ "SandboxTimeoutError": "export { SandboxConfigurationError, SandboxError, SandboxTimeoutError } from './errors.js';",
32
33
  "SandboxBridge": "export type {\n SandboxBridge,\n SandboxHandle,\n SandboxMessage,\n SandboxOptions,\n SandboxStateUpdateDetail,\n Unsubscribe,\n} from './types.js';",
33
34
  "SandboxHandle": "export type {\n SandboxBridge,\n SandboxHandle,\n SandboxMessage,\n SandboxOptions,\n SandboxStateUpdateDetail,\n Unsubscribe,\n} from './types.js';",
34
35
  "SandboxMessage": "export type {\n SandboxBridge,\n SandboxHandle,\n SandboxMessage,\n SandboxOptions,\n SandboxStateUpdateDetail,\n Unsubscribe,\n} from './types.js';",
@@ -1,10 +1,10 @@
1
1
  {
2
- "apiSource": "export { toFilterPredicate, toSearchMatcher } from './adapters';\nexport { ScoutDisposedError, ScoutError, ScoutIndexError } from './errors';\nexport { findMatchRanges, highlight, highlightField } from './highlight';\nexport { createReactiveSearch, createSearch } from './reactive';\nexport type { ReactiveSearch } 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",
2
+ "apiSource": "export { toFilterPredicate, toSearchMatcher } from './adapters';\nexport { ScoutConfigurationError, ScoutDisposedError, ScoutError } from './errors';\nexport { findMatchRanges, highlight, highlightField } from './highlight';\nexport { createReactiveSearch, createSearch } from './reactive';\nexport type { ReactiveSearch } 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",
3
3
  "docs": {
4
- "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 debugSearch,\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 is O(candidates) only items that share at least one trigram with the query are scored, so performance stays flat as the corpus grows.\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=\"triangle-alert\" size=\"16\"></ore-icon> `@vielzeug/ripple` peer (reactive layer only) | <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 index = createIndex(users, {\n fields: [\n { field: 'name', weight: 2 }, // name ranks higher\n { field: 'email' },\n ],\n});\n\nconst results = index.search('alice');\n// [{ item: User, score: 0.85, matches: [{ field: 'name', ranges: [[0, 5]] }] }]\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; any field accepts a custom `stringify`\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- Incremental updates — `add()` / `remove()` / `reindex()` patch the index in O(field_length); no full rebuild\n- `onMutate()` — Subscribe to index mutations; powers `createSearch()`'s reactivity to `add`/`remove`/`reindex`\n- `segmentWords()` — Split unsegmented-script text (CJK, Thai, ...) into words via native `Intl.Segmenter`\n- Debug logging via `debugSearch()` (`@vielzeug/scout/devtools`) — logs query/results transitions, tree-shaken from production bundles\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/) — 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",
5
- "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.items` | All indexed items in insertion order | Sync | Returns a new array snapshot each call |\n| `ScoutIndex.onMutate()` | Subscribe to `add`/`remove`/`reindex` mutations | Sync | Only fires on mutations that actually change the index — not on no-ops |\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 | Caches one match set per query |\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 — `createIndex`, `createReactiveSearch`, `createSearch`, `findMatchRanges`, `highlight`, `highlightField`, `segmentWords`, `toSearchMatcher`, `toFilterPredicate`, 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` | Min overlap score for a result (default `0.2`). |\n| `options.limit` | `number` | Max results returned by `search()` (default `50`). |\n| `options.minQueryLength` | `number` | Min chars before trigram scoring; shorter queries use O(n) containment scan (default `3`). |\n\n**Example**\n\n```ts\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### `.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 `add()` / `remove()` / `reindex()` call that actually changes the index — no-ops (e.g. removing an item that isn't indexed) don't fire it. Returns an unsubscribe function. `createSearch()` uses this internally to keep `results` in sync with index mutations; most callers building on `createIndex()` directly won't need to call it themselves.\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---\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` | ms to wait before committing a query change (default `200`). Pass `0` for immediate updates. |\n| `options.limit` | `number` | Override index-level limit. |\n| `options.threshold` | `number` | Override index-level threshold. |\n| `options.minQueryLength` | `number` | Override 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` | `Computed<SearchResult<T>[]>` | Reactive results, updated after debounce. |\n| `isSearching` | `Computed<boolean>` | `true` during the debounce window. |\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\nconst search = createSearch(index, { debounce: 150 });\n\neffect(() => {\n if (search.isSearching.value) showSpinner();\n else renderList(search.results.value);\n});\n\nsearch.query.value = 'alice';\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` | Debounce ms (default `200`). |\n| `options.threshold` | `number` | Min overlap score (default `0.2`). |\n| `options.limit` | `number` | Max results (default `50`). |\n| `options.minQueryLength` | `number` | 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\nconst search = createReactiveSearch(users, {\n fields: [{ field: 'name', weight: 2 }, 'email'],\n debounce: 150,\n});\n\neffect(() => renderList(search.results.value.map(r => r.item)));\n\n// Add a new item at runtime\nsearch.index.add(newUser);\n\nsearch.dispose();\n```\n\n---\n\n## `findMatchRanges(text, query)`\n\nComputes sorted, non-overlapping match ranges for each word in `query` 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\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\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\nfor (const result of index.search('alice')) {\n const parts = highlightField(result, 'name', result.item.name);\n console.log(parts.map(p => p.highlighted ? `[${p.text}]` : p.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, so filtering does not repeat index work per item.\n\n```ts\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\nconst results = products.filter(toFilterPredicate(index, 'widget'));\n\n// Cap results via limit\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\nconst index = createIndex(documents, {\n fields: [{ field: 'title', stringify: (v) => segmentWords(String(v)) }],\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 { debugSearch } from '@vielzeug/scout/devtools';\n\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; // default 50\n minQueryLength?: number; // default 3\n threshold?: number; // 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; // default 200\n};\n```\n\n### `SearchResult<T>`\n\n```ts\ntype SearchResult<T> = {\n item: T;\n matches: FieldMatch<keyof T & string>[]; // field is narrowed to indexed field names\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][]; // [start, end] 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\nSee `createSearch()` above.\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` or `ScoutError.is()` to catch any scout-originated error.\n\n```ts\nclass ScoutError extends Error {\n static is(err: unknown): err is ScoutError;\n}\n```\n\n**Named subclasses**\n\n| Class | Thrown when |\n| ------------------- | ---------------------------------------------------------------------- |\n| `ScoutDisposedError` | A method is called on a disposed `SearchState` instance |\n| `ScoutIndexError` | An index is built or queried with an invalid configuration (e.g. zero fields) |\n",
6
- "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 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.\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\nonUnmount(() => search.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).\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 `Computed` 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\n`add()`, `remove()`, and `reindex()` patch the inverted index in O(field_length) — no full rebuild needed.\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()` and `reindex()` use **reference equality** (`===`). Pass the same object reference that was originally added.\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()` internally. 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 character ranges where the query was found.\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",
7
- "examples": "---\ntitle: Scout — Examples\ndescription: Practical examples for @vielzeug/scout — basic search, reactive combobox, and sourcerer integration.\n---\n\nBrowse runnable examples of `@vielzeug/scout`:\n\n- [Basic Search](./examples/basic-search) — `createIndex` + `search()` + highlighting\n- [Reactive Combobox](./examples/reactive-combobox) — `createSearch` signal wiring with debounce\n- [Sourcerer Integration](./examples/sourcerer-integration) — `toSearchMatcher` with `createLocalSource`\n"
4
+ "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 debugSearch,\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- Debug logging via `debugSearch()` (`@vielzeug/scout/devtools`) — logs query/results transitions, tree-shaken from production bundles\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",
5
+ "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.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---\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` | `Computed<SearchResult<T>[]>` | Reactive results, updated after debounce. |\n| `isSearching` | `Computed<boolean>` | `true` during the debounce window. |\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\nSee `createSearch()` above.\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` or `ScoutError.is()` to catch any scout-originated error.\n\n```ts\nclass ScoutError extends Error {\n static is(err: unknown): err is ScoutError;\n}\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",
6
+ "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 `Computed` 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",
7
+ "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"
8
8
  },
9
9
  "examples": [
10
10
  {
@@ -19,12 +19,12 @@
19
19
  },
20
20
  {
21
21
  "id": "incremental-updates",
22
- "code": "import { createIndex } from '@vielzeug/scout'\n\nconst products = [\n { id: 1, title: 'Wireless Mouse', price: 25 },\n { id: 2, title: 'Mechanical Keyboard', price: 80 },\n { id: 3, title: 'USB-C Hub', price: 35 },\n]\n\nconst index = createIndex(products, { fields: ['title'] })\n\n// onMutate() fires after add()/remove()/reindex() actually change the index —\n// not on no-ops like removing an item that isn't indexed\nconst unsubscribe = index.onMutate(() => {\n console.log(` (index changed — now ${index.size} items)`)\n})\n\nconsole.log('Search \"keyboard\":', index.search('keyboard').map(r => r.item.title))\n\n// Add a newly created item\nindex.add({ id: 4, title: 'Gaming Keyboard', price: 120 })\nconsole.log('After add():', index.search('keyboard').map(r => r.item.title))\n\n// Re-index a mutated item — reference equality, so mutate in place first\nproducts[0].title = 'Wireless Trackball'\nindex.reindex(products[0])\nconsole.log('After reindex():', index.search('trackball').map(r => r.item.title))\n\n// Remove an item by reference\nindex.remove(products[2])\nconsole.log('After remove():', index.search('usb').map(r => r.item.title))\n\nunsubscribe()",
22
+ "code": "import { createIndex } from '@vielzeug/scout'\n\nconst products = [\n { id: 1, title: 'Wireless Mouse', price: 25 },\n { id: 2, title: 'Mechanical Keyboard', price: 80 },\n { id: 3, title: 'USB-C Hub', price: 35 },\n]\n\nconst index = createIndex(products, { fields: ['title'] })\n\n// onMutate() fires after changed add()/remove()/reindex()/setItems() operations —\n// not on no-ops like removing an item that isn't indexed\nconst unsubscribe = index.onMutate(() => {\n console.log(` (index changed — now ${index.size} items)`)\n})\n\nconsole.log('Search \"keyboard\":', index.search('keyboard').map(r => r.item.title))\n\n// Add a newly created item\nindex.add({ id: 4, title: 'Gaming Keyboard', price: 120 })\nconsole.log('After add():', index.search('keyboard').map(r => r.item.title))\n\n// Re-index a mutated item — reference equality, so mutate in place first\nproducts[0].title = 'Wireless Trackball'\nindex.reindex(products[0])\nconsole.log('After reindex():', index.search('trackball').map(r => r.item.title))\n\n// Reconcile a refreshed corpus in one mutation — removes missing references,\n// adds new ones, reindexes retained values, and preserves this incoming order\nindex.setItems([products[0], { id: 4, title: 'Portable SSD', price: 95 }])\nconsole.log('After setItems():', index.items.map(item => item.title))\n\nunsubscribe()",
23
23
  "name": "Incremental Updates"
24
24
  },
25
25
  {
26
26
  "id": "reactive-search",
27
- "code": "import { createReactiveSearch } from '@vielzeug/scout'\n\nconst users = [\n { name: 'Alice Johnson', email: 'alice@example.com' },\n { name: 'Bob Smith', email: 'bob@example.com' },\n { name: 'Charlie Brown', email: 'charlie@example.com' },\n { name: 'Alicia Keys', email: 'alicia@example.com' },\n]\n\n// One call creates the index and the reactive search state together\nconst search = createReactiveSearch(users, { fields: ['name', 'email'], debounce: 0 })\n\nconst show = (label) => {\n console.log(label, '\\u2192', search.results.value.map(r => r.item.name).join(', ') || '(none)')\n}\n\nshow('Empty query') // all 4 users\n\nsearch.query.value = 'ali'\nshow('Query: \"ali\"') // Alice Johnson, Alicia Keys, Dave Alison\n\nsearch.query.value = 'alice'\nshow('Query: \"alice\"') // Alice Johnson\n\n// Add a new user at runtime via the exposed index\nsearch.index.add({ name: 'Alice Cooper', email: 'cooper@example.com' })\nshow('After add()') // now includes Alice Cooper\n\nsearch.clear()\nshow('After clear()') // all 5 users\n\nsearch.dispose()\nconsole.log('Disposed:', search.query.disposed)",
27
+ "code": "import { createReactiveSearch } from '@vielzeug/scout'\n\nconst users = [\n { name: 'Alice Johnson', email: 'alice@example.com' },\n { name: 'Bob Smith', email: 'bob@example.com' },\n { name: 'Charlie Brown', email: 'charlie@example.com' },\n { name: 'Alicia Keys', email: 'alicia@example.com' },\n]\n\n// One call creates the index and the reactive search state together\nconst search = createReactiveSearch(users, { fields: ['name', 'email'], debounce: 0 })\n\nconst show = (label) => {\n console.log(label, '\\u2192', search.results.value.map(r => r.item.name).join(', ') || '(none)')\n}\n\nshow('Empty query') // all 4 users\n\nsearch.query.value = 'ali'\nshow('Query: \"ali\"') // Alice Johnson, Alicia Keys, Dave Alison\n\nsearch.query.value = 'alice'\nshow('Query: \"alice\"') // Alice Johnson\n\n// Add a new user at runtime via the exposed index\nsearch.index.add({ name: 'Alice Cooper', email: 'cooper@example.com' })\nshow('After add()') // now includes Alice Cooper\n\nsearch.clear()\nshow('After clear()') // all 5 users\n\nsearch.dispose()\nconsole.log('Disposed:', search.disposed)",
28
28
  "name": "Reactive Search"
29
29
  },
30
30
  {
@@ -36,9 +36,9 @@
36
36
  "typeSignatures": {
37
37
  "toFilterPredicate": "export { toFilterPredicate, toSearchMatcher } from './adapters';",
38
38
  "toSearchMatcher": "export { toFilterPredicate, toSearchMatcher } from './adapters';",
39
- "ScoutDisposedError": "export { ScoutDisposedError, ScoutError, ScoutIndexError } from './errors';",
40
- "ScoutError": "export { ScoutDisposedError, ScoutError, ScoutIndexError } from './errors';",
41
- "ScoutIndexError": "export { ScoutDisposedError, ScoutError, ScoutIndexError } from './errors';",
39
+ "ScoutConfigurationError": "export { ScoutConfigurationError, ScoutDisposedError, ScoutError } from './errors';",
40
+ "ScoutDisposedError": "export { ScoutConfigurationError, ScoutDisposedError, ScoutError } from './errors';",
41
+ "ScoutError": "export { ScoutConfigurationError, ScoutDisposedError, ScoutError } from './errors';",
42
42
  "findMatchRanges": "export { findMatchRanges, highlight, highlightField } from './highlight';",
43
43
  "highlight": "export { findMatchRanges, highlight, highlightField } from './highlight';",
44
44
  "highlightField": "export { findMatchRanges, highlight, highlightField } from './highlight';",