@vielzeug/codex 3.1.1 → 26.10.1

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 (63) hide show
  1. package/README.md +1 -1
  2. package/data/catalog.json +166 -84
  3. package/data/llms-full.txt +3492 -1583
  4. package/data/llms.txt +9 -7
  5. package/data/manifest.json +1 -1
  6. package/data/packages/arsenal.json +40 -16
  7. package/data/packages/assay.json +12 -20
  8. package/data/packages/clockwork.json +31 -28
  9. package/data/packages/codex.json +5 -5
  10. package/data/packages/coins.json +4 -4
  11. package/data/packages/conduit.json +33 -34
  12. package/data/packages/courier.json +18 -17
  13. package/data/packages/dnd.json +11 -11
  14. package/data/packages/familiar.json +27 -6
  15. package/data/packages/flux.json +11 -8
  16. package/data/packages/focus.json +17 -7
  17. package/data/packages/forge.json +4 -4
  18. package/data/packages/gesture.json +11 -5
  19. package/data/packages/herald.json +16 -17
  20. package/data/packages/illusionist.json +5 -5
  21. package/data/packages/keymap.json +16 -12
  22. package/data/packages/ledger.json +26 -26
  23. package/data/packages/lingua.json +4 -4
  24. package/data/packages/mesh.json +30 -32
  25. package/data/packages/necromancer.json +7 -6
  26. package/data/packages/orbit.json +11 -10
  27. package/data/packages/ore.json +6 -5
  28. package/data/packages/postmaster.json +26 -26
  29. package/data/packages/prism.json +50 -42
  30. package/data/packages/pulse.json +34 -34
  31. package/data/packages/refine.json +7 -5
  32. package/data/packages/ripple.json +4 -4
  33. package/data/packages/rune.json +9 -9
  34. package/data/packages/sandbox.json +4 -4
  35. package/data/packages/scout.json +17 -11
  36. package/data/packages/scroll.json +14 -14
  37. package/data/packages/sentinel.json +26 -15
  38. package/data/packages/sigil.json +6 -6
  39. package/data/packages/sourcerer.json +4 -4
  40. package/data/packages/spell.json +31 -36
  41. package/data/packages/tandem.json +29 -0
  42. package/data/packages/tavern.json +23 -0
  43. package/data/packages/tempo.json +45 -43
  44. package/data/packages/vault.json +15 -25
  45. package/data/packages/ward.json +27 -30
  46. package/data/packages/wayfinder.json +34 -27
  47. package/data/refine.json +987 -134
  48. package/data/search.json +341 -306
  49. package/dist/http.js +2 -2
  50. package/dist/http.js.map +1 -1
  51. package/dist/index.d.ts +3 -4
  52. package/dist/index.js +3 -4
  53. package/dist/index.js.map +1 -1
  54. package/dist/refine.d.ts +1 -1
  55. package/dist/refine.js +1 -1
  56. package/dist/server.d.ts +1 -1
  57. package/dist/server.js +1 -1
  58. package/dist/tools/refine.js +1 -1
  59. package/package.json +5 -12
  60. package/skills/vielzeug/SKILL.md +57 -55
  61. package/dist/advanced.d.ts +0 -12
  62. package/dist/advanced.js +0 -13
  63. package/dist/advanced.js.map +0 -1
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "apiSource": "export { createSandbox } from './_runtime.js';\nexport { SandboxConfigurationError, SandboxError, SandboxTimeoutError } from './errors.js';\nexport type { SandboxBridge, SandboxHandle, SandboxMessage, SandboxOptions } from './types.js';\n",
3
3
  "docs": {
4
- "index": "---\ntitle: Sandbox — Sandboxed iframe runtime\ndescription: Isolated iframe runtime with a typed state bridge and explicit message trust boundary 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 SandboxConfigurationError,\n SandboxError,\n SandboxTimeoutError,\n SandboxHandle,\n SandboxOptions,\n SandboxBridge,\n SandboxMessage,\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 state bridge and explicit message trust boundary. 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| Managed message protocol | <ore-icon name=\"x\" size=\"16\"></ore-icon> | Typed host state; untrusted inbound details |\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 iframe isolation, CSP enforcement, and explicit host/sandbox messaging.\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<{ theme: 'dark' | 'light' }>(container);\n\nsandbox.onMessage((message) => {\n if (message.type === 'custom') console.log(message.event, message.detail);\n if (message.type === 'error') console.error(message.message);\n if (message.type === 'resize') console.log('height:', message.height);\n});\n\ntry {\n await sandbox.render('<ore-button variant=\"primary\">Click me</ore-button>');\n sandbox.setState({ theme: 'dark' });\n} catch (error) {\n console.error('Sandbox render failed', error);\n} finally {\n sandbox.dispose();\n}\n```\n\n## Features\n\n<div class=\"features-grid\">\n\n- `createSandbox<State>()` — Creates an isolated `<iframe sandbox=\"allow-scripts\">` with typed outbound state\n- `render(html)` — Creates or replaces the document and resolves when its bridge reports ready\n- `setState(update)` — Pushes one or more typed state values in one message\n- `replaceBody(html)` — Updates streamed body content without resetting head scripts and styles\n- `updateStyle(id, css)` — Patches a named style without replacing the document\n- `SandboxMessage` — Keeps sandbox-controlled custom details typed as `unknown` on the host\n- `SandboxBridge<State, Events>` — Types authored sandbox-side state subscriptions and event emission\n- `readyTimeout` — Rejects blocked document initialization with `SandboxTimeoutError`\n- Strict CSP — Uses `default-src 'none'` and blocks network requests by default\n- Error forwarding — Installs before user scripts and forwards uncaught errors and rejections\n- Stale-message protection — Rejects messages from superseded render generations\n- Disposable — Supports `dispose()`, `disposalSignal`, and `[Symbol.dispose]`\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: API reference for the @vielzeug/sandbox managed iframe runtime, bridge, errors, and testing utilities.\n---\n\n[[toc]]\n\n## API Overview\n\n| Symbol | Purpose | Execution mode | Common gotcha |\n| --- | --- | --- | --- |\n| `createSandbox()` | Create a managed sandbox iframe | Sync | The iframe is created lazily by `render()` |\n| `SandboxHandle.render()` | Replace the document and await readiness | Async | Injected scripts can trigger a timeout |\n| `SandboxHandle` | Update, observe, and dispose the iframe | Sync methods | State/body updates require a ready document |\n| `SandboxOptions` | Configure metadata, CSP sources, scripts, styles, and timeout | Sync validation | Invalid values throw during creation |\n| `SandboxMessage` | Host-side message union | Sync callback | Custom details are always `unknown` |\n| `SandboxBridge` | Typed sandbox-side `window.__sandbox__` API | Sync | Its generics do not validate host-side input |\n| `SandboxError` | Base class for package errors | Thrown/rejected | Narrow caught values with `instanceof` |\n\n## Package Entry Point\n\n| Import | Exports |\n| --- | --- |\n| `@vielzeug/sandbox` | Runtime, errors, and public types |\n| `@vielzeug/sandbox/testing` | `createSandboxTestHelpers()` |\n\n```ts\nimport {\n createSandbox,\n SandboxConfigurationError,\n SandboxError,\n SandboxTimeoutError,\n} from '@vielzeug/sandbox';\nimport type {\n SandboxBridge,\n SandboxHandle,\n SandboxMessage,\n SandboxOptions,\n} from '@vielzeug/sandbox';\n```\n\n## Runtime\n\n### `createSandbox<State>(container, options?)`\n\n```ts\nfunction createSandbox<State extends object = Record<string, unknown>>(\n container: HTMLElement,\n options?: SandboxOptions,\n): SandboxHandle<State>;\n```\n\nCreates a managed `<iframe sandbox=\"allow-scripts\">` in `container`. Construction validates options but does not create DOM. The first `render()` creates the iframe with `referrerpolicy=\"no-referrer\"` and a generated CSP.\n\n| Parameter | Type | Description |\n| --- | --- | --- |\n| `container` | `HTMLElement` | Host element that owns the iframe |\n| `options` | `SandboxOptions` | Optional document and runtime configuration |\n\n**Returns:** `SandboxHandle<State>`.\n\n`State` describes values sent from the trusted host to the sandbox through `setState()`. Ordinary interfaces are supported.\n\n```ts\ninterface PreviewState {\n locale: string;\n theme: 'dark' | 'light';\n}\n\nconst sandbox = createSandbox<PreviewState>(container, {\n styles: { theme: 'body { color-scheme: light; }' },\n title: 'Preview',\n});\n\nawait sandbox.render('<main>Ready</main>');\nsandbox.setState({ locale: 'en', theme: 'dark' });\n```\n\n### `SandboxHandle<State>`\n\n```ts\ninterface SandboxHandle<State extends object = Record<string, unknown>> {\n readonly disposalSignal: AbortSignal;\n readonly disposed: boolean;\n dispose(): void;\n onMessage(handler: (message: SandboxMessage) => void): () => void;\n render(html: string): Promise<void>;\n replaceBody(html: string): void;\n setState(update: Partial<State>): void;\n updateStyle(id: string, css: string): void;\n [Symbol.dispose](): void;\n}\n```\n\n| Method/property | Description |\n| --- | --- |\n| `render(html)` | Replace the complete document and await readiness |\n| `replaceBody(html)` | Replace body descendants without navigation |\n| `setState(update)` | Push typed host state |\n| `updateStyle(id, css)` | Patch a named style |\n| `onMessage(handler)` | Subscribe to untrusted iframe messages |\n| `disposed` / `disposalSignal` | Observe lifecycle state |\n| `dispose()` / `[Symbol.dispose]()` | Release the iframe and listeners |\n\n#### `render(html)`\n\nReplaces the complete `srcdoc` document. The returned Promise resolves when that document emits its internal ready message and rejects with `SandboxTimeoutError` after `readyTimeout`.\n\nA newer render supersedes and resolves the previous pending render. Disposal also resolves a pending render. Messages from superseded generations are ignored.\n\nThe generated bridge is installed in `<head>` before injected scripts and user HTML, so it can capture script errors during document initialization. Its ready message is sent on `DOMContentLoaded`.\n\n#### `replaceBody(html)`\n\nReplaces `document.body.innerHTML` without navigating. Head scripts, document/window listeners, and named styles survive. Body descendants, descendant listeners, references, form state, and scripts in replacement HTML do not.\n\nCall only after `render()` resolves. The method warns in development and does nothing when no ready document exists.\n\n#### `setState(update)`\n\nSends one object containing one or more state values. Inside the iframe, each key dispatches its own `sandbox:state-update` `CustomEvent` and reaches matching `SandboxBridge.onState()` subscribers.\n\n```ts\nsandbox.setState({ locale: 'en', theme: 'dark' });\n```\n\nCalling before `render()` resolves can lose the update and emits a development warning.\n\n#### `updateStyle(id, css)`\n\nUpdates a style declared in `options.styles`. Before the first render it updates only the render baseline. After readiness it also patches the live `<style>` element. Unknown IDs emit a development warning.\n\n#### `onMessage(handler)`\n\nSubscribes to application messages and returns an idempotent unsubscribe function. Internal ready messages are not forwarded.\n\n```ts\nsandbox.onMessage((message) => {\n if (message.type === 'resize') container.style.height = `${message.height}px`;\n});\n```\n\n#### Lifecycle\n\n`dispose()` removes the iframe, removes the host `message` listener, clears subscribers, resolves a pending render, and aborts `disposalSignal`. It is idempotent. `[Symbol.dispose]()` provides the same behavior for `using` declarations.\n\n## Types\n\n### `SandboxOptions`\n\n```ts\ninterface SandboxOptions {\n allowedFontOrigins?: string[];\n allowedImageOrigins?: string[];\n allowedScriptOrigins?: string[];\n allowedStyleOrigins?: string[];\n lang?: string;\n nonce?: string;\n readyTimeout?: number;\n scripts?: string[];\n styles?: Record<string, string>;\n title?: string;\n}\n```\n\n| Option | Default | Description |\n| --- | --- | --- |\n| `allowedFontOrigins` | `[]` | HTTP(S) origins added to `font-src`; otherwise `'none'` |\n| `allowedImageOrigins` | `[]` | HTTP(S) origins added to `img-src`; `data:` is always included |\n| `allowedScriptOrigins` | `[]` | HTTP(S) origins added to `script-src` |\n| `allowedStyleOrigins` | `[]` | HTTP(S) origins added to `style-src` |\n| `lang` | `'en'` | Basic BCP 47 language tag for `<html lang>` |\n| `nonce` | `undefined` | Base64/base64url-style nonce for the bridge script and CSP |\n| `readyTimeout` | `5000` | Positive finite timeout in milliseconds, at most `2^31 - 1` |\n| `scripts` | `[]` | Absolute HTTP(S) scripts injected before user HTML |\n| `styles` | `{}` | Named style blocks; IDs start with a letter and contain letters, digits, `_`, or `-` |\n| `title` | `''` | Escaped document title |\n\nAllowed origins reject paths, credentials, query strings, fragments, unsupported schemes, and CSP syntax. Script URLs must be absolute HTTP(S) URLs.\n\n### `SandboxMessage`\n\n```ts\ntype SandboxMessage =\n | { detail: unknown; event: string; type: 'custom' }\n | { message: string; stack?: string; type: 'error' }\n | { height: number; type: 'resize' };\n```\n\nAll fields originate in sandboxed code and are untrusted. Validate custom `detail`, and render text through escaping APIs rather than HTML injection.\n\n### `SandboxBridge<State, Events>`\n\nThe generated document exposes `window.__sandbox__`:\n\n```ts\ninterface SandboxBridge<\n State extends object = Record<string, unknown>,\n Events extends object = Record<string, unknown>,\n> {\n emit<K extends keyof Events & string>(\n event: K,\n ...detail: undefined extends Events[K] ? [detail?: Events[K]] : [detail: Events[K]]\n ): void;\n onState<K extends keyof State & string>(\n key: K,\n handler: (value: State[K]) => void,\n ): () => void;\n}\n```\n\nUse an ambient declaration in authored sandbox-side TypeScript:\n\n```ts\ninterface State {\n theme: 'dark' | 'light';\n}\n\ninterface Events {\n saved: { id: string };\n}\n\ndeclare interface Window {\n __sandbox__: import('@vielzeug/sandbox').SandboxBridge<State, Events>;\n}\n```\n\nThese generics check authored sandbox code only. They do not make host-side messages trustworthy.\n\n## Errors\n\n### `SandboxError`\n\nBase class for package errors. Accepts `opts?: ErrorOptions` for cause chaining.\n\n### `SandboxConfigurationError`\n\nThrown synchronously by `createSandbox()` when an option cannot produce a valid document or CSP.\n\n### `SandboxTimeoutError`\n\nRejected by `render()` when no ready message arrives before `readyTimeout`. An injected script may have blocked document initialization.\n\n## Testing\n\n```ts\nimport { createSandboxTestHelpers } from '@vielzeug/sandbox/testing';\n```\n\n`createSandboxTestHelpers(container)` returns `fireReady()`, `fireCustom()`, `fireError()`, and `fireResize()` helpers. Call `render()` first so the helper can read the iframe channel and generation metadata.\n",
6
- "usage": "---\ntitle: Sandbox — Usage Guide\ndescription: Render untrusted HTML, push typed state, receive sandbox messages, update live content, and manage iframe lifecycle.\n---\n\n[[toc]]\n\n## Basic Usage\n\nCreate a sandbox, subscribe before rendering, await readiness, and dispose it when finished.\n\n```ts\nimport { createSandbox } from '@vielzeug/sandbox';\n\nconst container = document.getElementById('preview')!;\nconst sandbox = createSandbox(container);\n\nsandbox.onMessage((message) => {\n if (message.type === 'error') console.error(message.message);\n if (message.type === 'resize') container.style.height = `${message.height}px`;\n});\n\ntry {\n await sandbox.render('<main><h1>Preview</h1></main>');\n} finally {\n sandbox.dispose();\n}\n```\n\nThe iframe is created by the first `render()` call. Each render creates a fresh document and resolves after its generated bridge reports ready.\n\n## Rendering HTML\n\nUse `render()` when scripts, styles, globals, and DOM state should reset together.\n\n```ts\nawait sandbox.render(`\n <style>body { font-family: system-ui; }</style>\n <button id=\"save\">Save</button>\n <script>\n document.querySelector('#save').addEventListener('click', () => {\n window.__sandbox__.emit('save');\n });\n </script>\n`);\n```\n\nA newer render supersedes a pending one. Messages from the old document are ignored through the per-render generation marker.\n\n## Pushing Typed State\n\nPass a state interface to `createSandbox()` and send partial updates with one object.\n\n```ts\ninterface PreviewState {\n locale: string;\n theme: 'dark' | 'light';\n user: { name: string };\n}\n\nconst sandbox = createSandbox<PreviewState>(container);\nawait sandbox.render('<main id=\"app\"></main>');\n\nsandbox.setState({\n locale: 'en',\n theme: 'dark',\n user: { name: 'Ada' },\n});\n```\n\nSubscribe inside the iframe through the typed bridge.\n\n```ts\ninterface PreviewState {\n locale: string;\n theme: 'dark' | 'light';\n}\n\ndeclare interface Window {\n __sandbox__: import('@vielzeug/sandbox').SandboxBridge<PreviewState>;\n}\n\nwindow.__sandbox__.onState('theme', (theme) => {\n document.documentElement.dataset.theme = theme;\n});\n```\n\nCall `setState()` only after `render()` resolves. An early update can arrive before the generated bridge installs its listener and is dropped with a development warning.\n\n## Receiving Sandbox Messages\n\nSubscribe with `onMessage()` before rendering. The unsubscribe function detaches only that handler.\n\n```ts\nconst unsubscribe = sandbox.onMessage((message) => {\n if (message.type === 'custom' && message.event === 'saved') {\n if (isSavedDetail(message.detail)) persist(message.detail.id);\n }\n});\n\nfunction isSavedDetail(value: unknown): value is { id: string } {\n return typeof value === 'object' && value !== null && 'id' in value && typeof value.id === 'string';\n}\n```\n\nCustom event details remain `unknown` on the host. The iframe is isolated, but its code still controls message payloads.\n\nUse `SandboxBridge<State, Events>` only to check authored sandbox-side calls.\n\n```ts\ninterface Events {\n saved: { id: string };\n}\n\ndeclare interface Window {\n __sandbox__: import('@vielzeug/sandbox').SandboxBridge<Record<string, never>, Events>;\n}\n\nwindow.__sandbox__.emit('saved', { id: '42' });\n```\n\n## Updating Live Content\n\n### Replace the body\n\nUse `replaceBody()` when streamed HTML should preserve head scripts, styles, and global listeners.\n\n```ts\nawait sandbox.render('<main>Waiting…</main>');\n\nlet html = '';\nfor await (const chunk of stream) {\n html += chunk;\n sandbox.replaceBody(html);\n}\n```\n\nReplacement uses `document.body.innerHTML`. Scripts in replacement HTML do not execute, and body descendant state and listeners are discarded.\n\n### Update a named style\n\nDeclare styles at construction and patch them without replacing the document.\n\n```ts\nconst sandbox = createSandbox(container, {\n styles: { theme: 'body { color-scheme: light; }' },\n});\n\nawait sandbox.render('<main>Preview</main>');\nsandbox.updateStyle('theme', 'body { color-scheme: dark; }');\n```\n\nCalling `updateStyle()` before the first render updates the render baseline. Unknown IDs emit a development warning.\n\n## Injecting Scripts and Configuring CSP\n\nList absolute external scripts in `scripts`. Their origins are added to `script-src` automatically.\n\n```ts\nconst sandbox = createSandbox(container, {\n allowedFontOrigins: ['https://fonts.gstatic.com'],\n allowedStyleOrigins: ['https://fonts.googleapis.com'],\n scripts: ['https://cdn.example.com/widget.js'],\n});\n```\n\nNetwork requests remain blocked by `connect-src 'none'`. The package does not expose arbitrary CSP directives.\n\nA nonce authorizes the generated bridge while preventing un-nonced inline user scripts in CSP Level 3 browsers.\n\n```ts\nconst sandbox = createSandbox(container, {\n nonce: crypto.randomUUID(),\n});\n```\n\n## Handling Errors and Timeouts\n\n`render()` rejects if the document does not become ready before `readyTimeout`.\n\n```ts\nimport { SandboxError, SandboxTimeoutError } from '@vielzeug/sandbox';\n\ntry {\n await sandbox.render(html);\n} catch (error) {\n if (error instanceof SandboxTimeoutError) {\n showError('A script blocked preview initialization.');\n } else if (error instanceof SandboxError) {\n showError(error.message);\n }\n}\n```\n\nUncaught iframe errors and unhandled rejections arrive through `onMessage()` as untrusted `error` messages.\n\n## Disposal\n\nUse `dispose()` or a `using` declaration to remove the iframe and host listeners.\n\n```ts\nusing sandbox = createSandbox(container);\nawait sandbox.render('<p>Temporary preview</p>');\n```\n\nTie other asynchronous work to `disposalSignal`.\n\n```ts\nconst response = await fetch('/preview-data', {\n signal: sandbox.disposalSignal,\n});\n```\n\n## Testing\n\nUse the testing subpath to complete `render()` and simulate messages in jsdom.\n\n```ts\nimport { createSandbox } from '@vielzeug/sandbox';\nimport { createSandboxTestHelpers } from '@vielzeug/sandbox/testing';\nimport { expect, it } from 'vitest';\n\nit('receives resize messages', async () => {\n const container = document.createElement('div');\n const sandbox = createSandbox(container);\n const helpers = createSandboxTestHelpers(container);\n const heights: number[] = [];\n\n sandbox.onMessage((message) => {\n if (message.type === 'resize') heights.push(message.height);\n });\n\n const render = sandbox.render('<p>Preview</p>');\n helpers.fireReady();\n await render;\n helpers.fireResize(240);\n\n expect(heights).toEqual([240]);\n sandbox.dispose();\n});\n```\n\n## Framework Integration\n\nOwn one sandbox per mounted component and dispose it during unmount.\n\n::: code-group\n\n```tsx [React]\nimport { createSandbox, type SandboxHandle } from '@vielzeug/sandbox';\nimport { useEffect, useRef } from 'react';\n\nexport function Preview({ html }: { html: string }) {\n const container = useRef<HTMLDivElement>(null);\n const sandbox = useRef<SandboxHandle>();\n\n useEffect(() => {\n sandbox.current = createSandbox(container.current!);\n return () => sandbox.current?.dispose();\n }, []);\n\n useEffect(() => {\n void sandbox.current?.render(html).catch(console.error);\n }, [html]);\n\n return <div ref={container} />;\n}\n```\n\n```vue [Vue]\n<script setup lang=\"ts\">\nimport { createSandbox, type SandboxHandle } from '@vielzeug/sandbox';\nimport { onMounted, onUnmounted, ref, watch } from 'vue';\n\nconst props = defineProps<{ html: string }>();\nconst container = ref<HTMLElement>();\nlet sandbox: SandboxHandle | undefined;\n\nonMounted(() => {\n sandbox = createSandbox(container.value!);\n void sandbox.render(props.html).catch(console.error);\n});\nwatch(() => props.html, (html) => void sandbox?.render(html).catch(console.error));\nonUnmounted(() => sandbox?.dispose());\n</script>\n\n<template><div ref=\"container\" /></template>\n```\n\n```svelte [Svelte]\n<script lang=\"ts\">\n import { createSandbox, type SandboxHandle } from '@vielzeug/sandbox';\n import { onMount } from 'svelte';\n\n export let html: string;\n let container: HTMLElement;\n let sandbox: SandboxHandle | undefined;\n\n onMount(() => {\n sandbox = createSandbox(container);\n return () => sandbox?.dispose();\n });\n\n $: if (sandbox) void sandbox.render(html).catch(console.error);\n</script>\n\n<div bind:this={container}></div>\n```\n\n:::\n\n## Working with Other Vielzeug Libraries\n\nUse Sandbox to isolate generated markup that loads browser bundles of DOM-output packages such as Refine or Ore. Use Rune in the host to record validated message details; do not pass untrusted payloads directly to structured logs.\n\n## Best Practices\n\n- Await `render()` before sending state or replacing body content.\n- Narrow every custom message detail at the host boundary.\n- Prefer `setState()` for data changes and `updateStyle()` for CSS changes.\n- Use `render()` when scripts or global state must reset.\n- Handle `SandboxTimeoutError` at every non-test render boundary.\n- Dispose one sandbox with each owning component.\n- Tie related asynchronous work to `disposalSignal`.\n- Keep network access blocked unless the application requires a different isolation model.\n",
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"
4
+ "index": "---\ntitle: 'Sandbox: Sandboxed iframe runtime'\ndescription: 'Isolated iframe runtime with a typed state bridge and explicit message trust boundary 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 SandboxConfigurationError,\n SandboxError,\n SandboxTimeoutError,\n SandboxHandle,\n SandboxOptions,\n SandboxBridge,\n SandboxMessage,\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 state bridge and explicit message trust boundary. 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| Managed message protocol | <ore-icon name=\"x\" size=\"16\"></ore-icon> | Typed host state; untrusted inbound details |\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 iframe isolation, CSP enforcement, and explicit host/sandbox messaging.\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<{ theme: 'dark' | 'light' }>(container);\n\nsandbox.onMessage((message) => {\n if (message.type === 'custom') console.log(message.event, message.detail);\n if (message.type === 'error') console.error(message.message);\n if (message.type === 'resize') console.log('height:', message.height);\n});\n\ntry {\n await sandbox.render('<ore-button variant=\"primary\">Click me</ore-button>');\n sandbox.setState({ theme: 'dark' });\n} catch (error) {\n console.error('Sandbox render failed', error);\n} finally {\n sandbox.dispose();\n}\n```\n\n## Features\n\n<div class=\"features-grid\">\n\n- `createSandbox<State>()`: Creates an isolated `<iframe sandbox=\"allow-scripts\">` with typed outbound state\n- `render(html)`: Creates or replaces the document and resolves when its bridge reports ready\n- `setState(update)`: Pushes one or more typed state values in one message\n- `replaceBody(html)`: Updates streamed body content without resetting head scripts and styles\n- `updateStyle(id, css)`: Patches a named style without replacing the document\n- `SandboxMessage`: Keeps sandbox-controlled custom details typed as `unknown` on the host\n- `SandboxBridge<State, Events>`: Types authored sandbox-side state subscriptions and event emission\n- `readyTimeout`: Rejects blocked document initialization with `SandboxTimeoutError`\n- Strict CSP: Uses `default-src 'none'` and blocks network requests by default\n- Error forwarding: Installs before user scripts and forwards uncaught errors and rejections\n- Stale-message protection: Rejects messages from superseded render generations\n- Disposable: Supports `dispose()`, `disposalSignal`, and `[Symbol.dispose]`\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: API reference for the @vielzeug/sandbox managed iframe runtime, bridge, errors, and testing utilities.\n---\n\n[[toc]]\n\n## API Overview\n\n| Symbol | Purpose | Execution mode | Common gotcha |\n| --- | --- | --- | --- |\n| `createSandbox()` | Create a managed sandbox iframe | Sync | The iframe is created lazily by `render()` |\n| `SandboxHandle.render()` | Replace the document and await readiness | Async | Injected scripts can trigger a timeout |\n| `SandboxHandle` | Update, observe, and dispose the iframe | Sync methods | State/body updates require a ready document |\n| `SandboxOptions` | Configure metadata, CSP sources, scripts, styles, and timeout | Sync validation | Invalid values throw during creation |\n| `SandboxMessage` | Host-side message union | Sync callback | Custom details are always `unknown` |\n| `SandboxBridge` | Typed sandbox-side `window.__sandbox__` API | Sync | Its generics do not validate host-side input |\n| `SandboxError` | Base class for package errors | Thrown/rejected | Narrow caught values with `instanceof` |\n\n## Package Entry Point\n\n| Import | Exports |\n| --- | --- |\n| `@vielzeug/sandbox` | Runtime, errors, and public types |\n| `@vielzeug/sandbox/testing` | `createSandboxTestHelpers()` |\n\n```ts\nimport {\n createSandbox,\n SandboxConfigurationError,\n SandboxError,\n SandboxTimeoutError,\n} from '@vielzeug/sandbox';\nimport type {\n SandboxBridge,\n SandboxHandle,\n SandboxMessage,\n SandboxOptions,\n} from '@vielzeug/sandbox';\n```\n\n## Runtime\n\n### `createSandbox<State>(container, options?)`\n\n```ts\nfunction createSandbox<State extends object = Record<string, unknown>>(\n container: HTMLElement,\n options?: SandboxOptions,\n): SandboxHandle<State>;\n```\n\nCreates a managed `<iframe sandbox=\"allow-scripts\">` in `container`. Construction validates options but does not create DOM. The first `render()` creates the iframe with `referrerpolicy=\"no-referrer\"` and a generated CSP.\n\n| Parameter | Type | Description |\n| --- | --- | --- |\n| `container` | `HTMLElement` | Host element that owns the iframe |\n| `options` | `SandboxOptions` | Optional document and runtime configuration |\n\n**Returns:** `SandboxHandle<State>`.\n\n`State` describes values sent from the trusted host to the sandbox through `setState()`. Ordinary interfaces are supported.\n\n```ts\ninterface PreviewState {\n locale: string;\n theme: 'dark' | 'light';\n}\n\nconst sandbox = createSandbox<PreviewState>(container, {\n styles: { theme: 'body { color-scheme: light; }' },\n title: 'Preview',\n});\n\nawait sandbox.render('<main>Ready</main>');\nsandbox.setState({ locale: 'en', theme: 'dark' });\n```\n\n### `SandboxHandle<State>`\n\n```ts\ninterface SandboxHandle<State extends object = Record<string, unknown>> {\n readonly disposalSignal: AbortSignal;\n readonly disposed: boolean;\n dispose(): void;\n onMessage(handler: (message: SandboxMessage) => void): () => void;\n render(html: string): Promise<void>;\n replaceBody(html: string): void;\n setState(update: Partial<State>): void;\n updateStyle(id: string, css: string): void;\n [Symbol.dispose](): void;\n}\n```\n\n| Method/property | Description |\n| --- | --- |\n| `render(html)` | Replace the complete document and await readiness |\n| `replaceBody(html)` | Replace body descendants without navigation |\n| `setState(update)` | Push typed host state |\n| `updateStyle(id, css)` | Patch a named style |\n| `onMessage(handler)` | Subscribe to untrusted iframe messages |\n| `disposed` / `disposalSignal` | Observe lifecycle state |\n| `dispose()` / `[Symbol.dispose]()` | Release the iframe and listeners |\n\n#### `render(html)`\n\nReplaces the complete `srcdoc` document. The returned Promise resolves when that document emits its internal ready message and rejects with `SandboxTimeoutError` after `readyTimeout`.\n\nA newer render supersedes and resolves the previous pending render. Disposal also resolves a pending render. Messages from superseded generations are ignored.\n\nThe generated bridge is installed in `<head>` before injected scripts and user HTML, so it can capture script errors during document initialization. Its ready message is sent on `DOMContentLoaded`.\n\n#### `replaceBody(html)`\n\nReplaces `document.body.innerHTML` without navigating. Head scripts, document/window listeners, and named styles survive. Body descendants, descendant listeners, references, form state, and scripts in replacement HTML do not.\n\nCall only after `render()` resolves. The method warns in development and does nothing when no ready document exists.\n\n#### `setState(update)`\n\nSends one object containing one or more state values. Inside the iframe, each key dispatches its own `sandbox:state-update` `CustomEvent` and reaches matching `SandboxBridge.onState()` subscribers.\n\n```ts\nsandbox.setState({ locale: 'en', theme: 'dark' });\n```\n\nCalls made before the document is ready are accumulated instead of dropped: the newest value per key is delivered in one message when the bridge reports ready, and again after each later `render()` so a re-rendered document starts with current state.\n\n#### `updateStyle(id, css)`\n\nUpdates a style declared in `options.styles`. Before the first render it updates only the render baseline. After readiness it also patches the live `<style>` element. Unknown IDs emit a development warning.\n\n#### `onMessage(handler)`\n\nSubscribes to application messages and returns an idempotent unsubscribe function. Internal ready messages are not forwarded.\n\n```ts\nsandbox.onMessage((message) => {\n if (message.type === 'resize') container.style.height = `${message.height}px`;\n});\n```\n\n#### Lifecycle\n\n`dispose()` removes the iframe, removes the host `message` listener, clears subscribers, resolves a pending render, and aborts `disposalSignal`. It is idempotent. `[Symbol.dispose]()` provides the same behavior for `using` declarations.\n\n## Types\n\n### `SandboxOptions`\n\n```ts\ninterface SandboxOptions {\n allowedFontOrigins?: string[];\n allowedImageOrigins?: string[];\n allowedScriptOrigins?: string[];\n allowedStyleOrigins?: string[];\n lang?: string;\n nonce?: string;\n readyTimeout?: number;\n scripts?: string[];\n styles?: Record<string, string>;\n title?: string;\n}\n```\n\n| Option | Default | Description |\n| --- | --- | --- |\n| `allowedFontOrigins` | `[]` | HTTP(S) origins added to `font-src`; otherwise `'none'` |\n| `allowedImageOrigins` | `[]` | HTTP(S) origins added to `img-src`; `data:` is always included |\n| `allowedScriptOrigins` | `[]` | HTTP(S) origins added to `script-src` |\n| `allowedStyleOrigins` | `[]` | HTTP(S) origins added to `style-src` |\n| `lang` | `'en'` | Basic BCP 47 language tag for `<html lang>` |\n| `nonce` | `undefined` | Base64/base64url-style nonce for the bridge script and CSP |\n| `readyTimeout` | `5000` | Positive finite timeout in milliseconds, at most `2^31 - 1` |\n| `scripts` | `[]` | Absolute HTTP(S) scripts injected before user HTML |\n| `styles` | `{}` | Named style blocks; IDs start with a letter and contain letters, digits, `_`, or `-` |\n| `title` | `''` | Escaped document title |\n\nAllowed origins reject paths, credentials, query strings, fragments, unsupported schemes, and CSP syntax. Script URLs must be absolute HTTP(S) URLs.\n\n### `SandboxMessage`\n\n```ts\ntype SandboxMessage =\n | { detail: unknown; event: string; type: 'custom' }\n | { message: string; stack?: string; type: 'error' }\n | { height: number; type: 'resize' };\n```\n\nAll fields originate in sandboxed code and are untrusted. Validate custom `detail`, and render text through escaping APIs rather than HTML injection.\n\n### `SandboxBridge<State, Events>`\n\nThe generated document exposes `window.__sandbox__`:\n\n```ts\ninterface SandboxBridge<\n State extends object = Record<string, unknown>,\n Events extends object = Record<string, unknown>,\n> {\n emit<K extends keyof Events & string>(\n event: K,\n ...detail: undefined extends Events[K] ? [detail?: Events[K]] : [detail: Events[K]]\n ): void;\n onState<K extends keyof State & string>(\n key: K,\n handler: (value: State[K]) => void,\n ): () => void;\n}\n```\n\nUse an ambient declaration in authored sandbox-side TypeScript:\n\n```ts\ninterface State {\n theme: 'dark' | 'light';\n}\n\ninterface Events {\n saved: { id: string };\n}\n\ndeclare interface Window {\n __sandbox__: import('@vielzeug/sandbox').SandboxBridge<State, Events>;\n}\n```\n\nThese generics check authored sandbox code only. They do not make host-side messages trustworthy.\n\n## Errors\n\n### `SandboxError`\n\nBase class for package errors. Accepts `opts?: ErrorOptions` for cause chaining.\n\n### `SandboxConfigurationError`\n\nThrown synchronously by `createSandbox()` when an option cannot produce a valid document or CSP.\n\n### `SandboxTimeoutError`\n\nRejected by `render()` when no ready message arrives before `readyTimeout`. An injected script may have blocked document initialization.\n\n## Testing\n\n```ts\nimport { createSandboxTestHelpers } from '@vielzeug/sandbox/testing';\n```\n\n`createSandboxTestHelpers(container)` returns `fireReady()`, `fireCustom()`, `fireError()`, and `fireResize()` helpers. Call `render()` first so the helper can read the iframe channel and generation metadata.\n",
6
+ "usage": "---\ntitle: 'Sandbox: Usage Guide'\ndescription: Render untrusted HTML, push typed state, receive sandbox messages, update live content, and manage iframe lifecycle.\n---\n\n[[toc]]\n\n## Basic Usage\n\nCreate a sandbox, subscribe before rendering, await readiness, and dispose it when finished.\n\n```ts\nimport { createSandbox } from '@vielzeug/sandbox';\n\nconst container = document.getElementById('preview')!;\nconst sandbox = createSandbox(container);\n\nsandbox.onMessage((message) => {\n if (message.type === 'error') console.error(message.message);\n if (message.type === 'resize') container.style.height = `${message.height}px`;\n});\n\ntry {\n await sandbox.render('<main><h1>Preview</h1></main>');\n} finally {\n sandbox.dispose();\n}\n```\n\nThe iframe is created by the first `render()` call. Each render creates a fresh document and resolves after its generated bridge reports ready.\n\n## Rendering HTML\n\nUse `render()` when scripts, styles, globals, and DOM state should reset together.\n\n```ts\nawait sandbox.render(`\n <style>body { font-family: system-ui; }</style>\n <button id=\"save\">Save</button>\n <script>\n document.querySelector('#save').addEventListener('click', () => {\n window.__sandbox__.emit('save');\n });\n </script>\n`);\n```\n\nA newer render supersedes a pending one. Messages from the old document are ignored through the per-render generation marker.\n\n## Pushing Typed State\n\nPass a state interface to `createSandbox()` and send partial updates with one object.\n\n```ts\ninterface PreviewState {\n locale: string;\n theme: 'dark' | 'light';\n user: { name: string };\n}\n\nconst sandbox = createSandbox<PreviewState>(container);\nawait sandbox.render('<main id=\"app\"></main>');\n\nsandbox.setState({\n locale: 'en',\n theme: 'dark',\n user: { name: 'Ada' },\n});\n```\n\nSubscribe inside the iframe through the typed bridge.\n\n```ts\ninterface PreviewState {\n locale: string;\n theme: 'dark' | 'light';\n}\n\ndeclare interface Window {\n __sandbox__: import('@vielzeug/sandbox').SandboxBridge<PreviewState>;\n}\n\nwindow.__sandbox__.onState('theme', (theme) => {\n document.documentElement.dataset.theme = theme;\n});\n```\n\n`setState()` is safe to call at any point in the lifecycle. Updates made before `render()` resolves are accumulated and delivered in one message when the bridge reports ready, so an early call never loses state.\n\n## Receiving Sandbox Messages\n\nSubscribe with `onMessage()` before rendering. The unsubscribe function detaches only that handler.\n\n```ts\nconst unsubscribe = sandbox.onMessage((message) => {\n if (message.type === 'custom' && message.event === 'saved') {\n if (isSavedDetail(message.detail)) persist(message.detail.id);\n }\n});\n\nfunction isSavedDetail(value: unknown): value is { id: string } {\n return typeof value === 'object' && value !== null && 'id' in value && typeof value.id === 'string';\n}\n```\n\nCustom event details remain `unknown` on the host. The iframe is isolated, but its code still controls message payloads.\n\nUse `SandboxBridge<State, Events>` only to check authored sandbox-side calls.\n\n```ts\ninterface Events {\n saved: { id: string };\n}\n\ndeclare interface Window {\n __sandbox__: import('@vielzeug/sandbox').SandboxBridge<Record<string, never>, Events>;\n}\n\nwindow.__sandbox__.emit('saved', { id: '42' });\n```\n\n## Updating Live Content\n\n### Replace the body\n\nUse `replaceBody()` when streamed HTML should preserve head scripts, styles, and global listeners.\n\n```ts\nawait sandbox.render('<main>Waiting…</main>');\n\nlet html = '';\nfor await (const chunk of stream) {\n html += chunk;\n sandbox.replaceBody(html);\n}\n```\n\nReplacement uses `document.body.innerHTML`. Scripts in replacement HTML do not execute, and body descendant state and listeners are discarded.\n\n### Update a named style\n\nDeclare styles at construction and patch them without replacing the document.\n\n```ts\nconst sandbox = createSandbox(container, {\n styles: { theme: 'body { color-scheme: light; }' },\n});\n\nawait sandbox.render('<main>Preview</main>');\nsandbox.updateStyle('theme', 'body { color-scheme: dark; }');\n```\n\nCalling `updateStyle()` before the first render updates the render baseline. Unknown IDs emit a development warning.\n\n## Injecting Scripts and Configuring CSP\n\nList absolute external scripts in `scripts`. Their origins are added to `script-src` automatically.\n\n```ts\nconst sandbox = createSandbox(container, {\n allowedFontOrigins: ['https://fonts.gstatic.com'],\n allowedStyleOrigins: ['https://fonts.googleapis.com'],\n scripts: ['https://cdn.example.com/widget.js'],\n});\n```\n\nNetwork requests remain blocked by `connect-src 'none'`. The package does not expose arbitrary CSP directives.\n\nA nonce authorizes the generated bridge while preventing un-nonced inline user scripts in CSP Level 3 browsers.\n\n```ts\nconst sandbox = createSandbox(container, {\n nonce: crypto.randomUUID(),\n});\n```\n\n## Handling Errors and Timeouts\n\n`render()` rejects if the document does not become ready before `readyTimeout`.\n\n```ts\nimport { SandboxError, SandboxTimeoutError } from '@vielzeug/sandbox';\n\ntry {\n await sandbox.render(html);\n} catch (error) {\n if (error instanceof SandboxTimeoutError) {\n showError('A script blocked preview initialization.');\n } else if (error instanceof SandboxError) {\n showError(error.message);\n }\n}\n```\n\nUncaught iframe errors and unhandled rejections arrive through `onMessage()` as untrusted `error` messages.\n\n## Disposal\n\nUse `dispose()` or a `using` declaration to remove the iframe and host listeners.\n\n```ts\nusing sandbox = createSandbox(container);\nawait sandbox.render('<p>Temporary preview</p>');\n```\n\nTie other asynchronous work to `disposalSignal`.\n\n```ts\nconst response = await fetch('/preview-data', {\n signal: sandbox.disposalSignal,\n});\n```\n\n## Testing\n\nUse the testing subpath to complete `render()` and simulate messages in jsdom.\n\n```ts\nimport { createSandbox } from '@vielzeug/sandbox';\nimport { createSandboxTestHelpers } from '@vielzeug/sandbox/testing';\nimport { expect, it } from 'vitest';\n\nit('receives resize messages', async () => {\n const container = document.createElement('div');\n const sandbox = createSandbox(container);\n const helpers = createSandboxTestHelpers(container);\n const heights: number[] = [];\n\n sandbox.onMessage((message) => {\n if (message.type === 'resize') heights.push(message.height);\n });\n\n const render = sandbox.render('<p>Preview</p>');\n helpers.fireReady();\n await render;\n helpers.fireResize(240);\n\n expect(heights).toEqual([240]);\n sandbox.dispose();\n});\n```\n\n## Framework Integration\n\nOwn one sandbox per mounted component and dispose it during unmount.\n\n::: code-group\n\n```tsx [React]\nimport { createSandbox, type SandboxHandle } from '@vielzeug/sandbox';\nimport { useEffect, useRef } from 'react';\n\nexport function Preview({ html }: { html: string }) {\n const container = useRef<HTMLDivElement>(null);\n const sandbox = useRef<SandboxHandle>();\n\n useEffect(() => {\n sandbox.current = createSandbox(container.current!);\n return () => sandbox.current?.dispose();\n }, []);\n\n useEffect(() => {\n void sandbox.current?.render(html).catch(console.error);\n }, [html]);\n\n return <div ref={container} />;\n}\n```\n\n```vue [Vue]\n<script setup lang=\"ts\">\nimport { createSandbox, type SandboxHandle } from '@vielzeug/sandbox';\nimport { onMounted, onUnmounted, ref, watch } from 'vue';\n\nconst props = defineProps<{ html: string }>();\nconst container = ref<HTMLElement>();\nlet sandbox: SandboxHandle | undefined;\n\nonMounted(() => {\n sandbox = createSandbox(container.value!);\n void sandbox.render(props.html).catch(console.error);\n});\nwatch(() => props.html, (html) => void sandbox?.render(html).catch(console.error));\nonUnmounted(() => sandbox?.dispose());\n</script>\n\n<template><div ref=\"container\" /></template>\n```\n\n```svelte [Svelte]\n<script lang=\"ts\">\n import { createSandbox, type SandboxHandle } from '@vielzeug/sandbox';\n import { onMount } from 'svelte';\n\n export let html: string;\n let container: HTMLElement;\n let sandbox: SandboxHandle | undefined;\n\n onMount(() => {\n sandbox = createSandbox(container);\n return () => sandbox?.dispose();\n });\n\n $: if (sandbox) void sandbox.render(html).catch(console.error);\n</script>\n\n<div bind:this={container}></div>\n```\n\n:::\n\n## Working with Other Vielzeug Libraries\n\nUse Sandbox to isolate generated markup that loads browser bundles of DOM-output packages such as Refine or Ore. Use Rune in the host to record validated message details; do not pass untrusted payloads directly to structured logs.\n\n## Best Practices\n\n- Await `render()` before sending state or replacing body content.\n- Narrow every custom message detail at the host boundary.\n- Prefer `setState()` for data changes and `updateStyle()` for CSS changes.\n- Use `render()` when scripts or global state must reset.\n- Handle `SandboxTimeoutError` at every non-test render boundary.\n- Dispose one sandbox with each owning component.\n- Tie related asynchronous work to `disposalSignal`.\n- Keep network access blocked unless the application requires a different isolation model.\n",
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": [
10
10
  {
@@ -1,15 +1,15 @@
1
1
  {
2
- "apiSource": "export { toFilterPredicate, toSearchMatcher } from './adapters';\nexport { ScoutConfigurationError, ScoutDisposedError, ScoutError } from './errors';\nexport { findMatchRanges, highlight, highlightField } from './highlight';\nexport type { ReactiveSearch } from './reactive';\nexport { createReactiveSearch, createSearch } from './reactive';\nexport type { ScoutIndex } from './scout-index';\nexport { createIndex } from './scout-index';\nexport { segmentWords } from './segment';\nexport type {\n CreateSearchOptions,\n FieldDef,\n FieldMatch,\n HighlightPart,\n ScoutEvent,\n ScoutIndexOptions,\n SearchConstraints,\n SearchResult,\n SearchSnapshot,\n SearchState,\n SearchSubscribeOptions,\n} from './types';\n",
2
+ "apiSource": "export { toFilterPredicate, toSearchMatcher } from './adapters';\nexport {\n dedupeEntries,\n escapeRegExp,\n type RankableEntry,\n rankEntries,\n type SplitSegment,\n splitPattern,\n} from './entries';\nexport { ScoutConfigError, ScoutDisposedError, ScoutError } from './errors';\nexport { findMatchRanges, highlight, highlightField } from './highlight';\nexport type { ReactiveSearch } from './reactive';\nexport { createReactiveSearch, createSearch } from './reactive';\nexport type { ScoutIndex } from './scout-index';\nexport { createIndex } from './scout-index';\nexport { segmentWords } from './segment';\nexport type {\n CreateSearchOptions,\n FieldDef,\n FieldMatch,\n HighlightPart,\n ScoutEvent,\n ScoutIndexOptions,\n SearchConstraints,\n SearchResult,\n SearchSnapshot,\n SearchState,\n SearchSubscribeOptions,\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 ScoutConfigurationError,\n ScoutDisposedError,\n ScoutError,\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=\"check\" size=\"16\"></ore-icon> | <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> | Atomic external-store snapshots + 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 framework-neutral subscribable search state.\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 filtering in-memory collections with a `ScoutIndex`\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\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/) — bridge a complete Scout `SearchState` with `fromSubscribable()` when composing a larger reactive graph\n- [Sourcerer](/sourcerer/) — use a `ScoutIndex` matcher to filter in-memory collections alongside remote sources\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.revision` | Monotonic counter incremented after each mutation | Sync | Use as a cache-busting token for external result caches |\n| `ScoutIndex.onMutate()` | Subscribe to changed index mutations | Sync | A changed `setItems()` reconciliation emits once; no-ops emit nothing |\n| `createSearch()` | Atomic search store backed by a `ScoutIndex` | Sync | Read one snapshot and dispose the store 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 local `filter` 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\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\n---\n\n## `createIndex(items, options)`\n\nBuilds a trigram inverted index from `items`. Construction is O(corpus × field_length); subsequent `search()` calls are O(candidates).\n\n```ts\nfunction createIndex<T>(items: T[], options: ScoutIndexOptions<T>): ScoutIndex<T>\n```\n\n**Parameters**\n\n| Param | Type | Description |\n| --- | --- | --- |\n| `items` | `T[]` | Initial corpus to index. |\n| `options.fields` | `ReadonlyArray<FieldDef<T>>` | Fields to index. Required; at least one entry. |\n| `options.threshold` | `number` | Finite overlap score in `0..1` (default `0.2`). |\n| `options.limit` | `number` | Finite non-negative integer max results (default `50`). |\n| `options.minQueryLength` | `number` | Finite positive integer min chars before trigram scoring; shorter queries use O(n) containment scan (default `3`). |\n\n**Example**\n\n```ts\nimport { createIndex } from '@vielzeug/scout';\n\nconst products = [\n { sku: 'WGT-001', title: 'Widget Pro' },\n { sku: 'GAD-002', title: 'Gadget Plus' },\n];\n\nconst index = createIndex(products, {\n fields: [\n { field: 'title', weight: 2 },\n { field: 'sku' },\n ],\n threshold: 0.25,\n limit: 20,\n});\n```\n\n---\n\n## `ScoutIndex<T>`\n\nReturned by `createIndex()`.\n\n### `.search(query, options?)`\n\n```ts\nsearch(query: string, options?: SearchConstraints): SearchResult<T>[]\n```\n\nReturns results sorted by score descending. Empty query returns all items with `score = 1`. Results below `threshold` are excluded; at most `limit` results are returned.\n\n```ts\nconst results = index.search('alice');\n// [{ item, score, matches }]\n```\n\n### `.add(item)`\n\nAdds `item` to the index. No-op if the same reference is already indexed. O(field_length).\n\n### `.remove(item)`\n\nRemoves `item` by reference equality. No-op if not found. O(field_length).\n\n### `.reindex(item)`\n\nRe-reads the item's current field values and rebuilds its index entry in-place, updating only fields whose values changed. Preserves insertion order. No-op if the item is not in the index.\n\n```ts\nitem.name = 'new name';\nindex.reindex(item);\n```\n\n### `.setItems(items)`\n\n```ts\nsetItems(items: readonly T[]): void\n```\n\nReconciles the index to a refreshed corpus in one mutation. Existing references are reindexed, missing references are removed, added references are indexed, and incoming first-occurrence order becomes index order. Duplicate references collapse to one item. Calls `onMutate()` once when indexed values, membership, or order changes.\n\n```ts\nindex.setItems(latestUsers);\n```\n\n### `.size`\n\n`number` — current number of indexed items.\n\n### `.items`\n\n`readonly T[]` — all indexed items in insertion order. Returns a new array snapshot each call.\n\n```ts\nconst all = index.items;\n```\n\n### `.onMutate(listener)`\n\n```ts\nonMutate(listener: () => void): () => void\n```\n\nSubscribes `listener` to run after every changed `add()` / `remove()` / `reindex()` / `setItems()` operation. No-ops, including unchanged bulk reconciliation, do not fire it. A changed `setItems()` reconciliation fires once. `createSearch()` uses this internally to keep `results` in sync with index mutations; most callers building on `createIndex()` directly will not need it.\n\n```ts\nconst unsubscribe = index.onMutate(() => {\n console.log(`Index changed — now ${index.size} items`);\n});\n\nindex.add(newUser); // logs \"Index changed — now 6 items\"\nunsubscribe();\n```\n\n### `.revision`\n\n`number` — monotonically increasing counter, incremented after every changed `add()` / `remove()` / `reindex()` / `setItems()` operation. Use as a cache-busting token when caching search results outside the index — `toSearchMatcher()` uses it for this purpose.\n\n---\n\n## `createSearch(index, options?)`\n\nWraps a `ScoutIndex` in a zero-dependency external store. `getSnapshot()` returns the current query, loading flag, and results as one consistent snapshot; `subscribe()` notifies after the complete snapshot is committed.\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| `getSnapshot()` | `() => SearchSnapshot<T>` | Returns the stable current snapshot. Identity changes only after a state transition. |\n| `setQuery(query)` | `(query: string) => void` | Starts or immediately commits a query according to `debounce`. |\n| `subscribe(listener, options?)` | `(listener, options?) => () => void` | Notifies after an atomic snapshot commit. Supports `{ signal }` cleanup. |\n| `tap(handler, options?)` | `(handler, options?) => () => void` | Observes typed state and disposal events. Handler errors are swallowed. |\n| `disposalSignal` | `AbortSignal` | Aborted when `dispose()` is called. Use to tie other lifecycles to this search. |\n| `disposed` | `boolean` | `true` after `dispose()` has been called. |\n| `clear()` | `() => void` | Resets query, cancels debounce, and restores empty-query results synchronously. |\n| `dispose()` | `() => void` | Releases timers and subscriptions. |\n| `[Symbol.dispose]()` | `() => void` | `using`-compatible disposal. |\n\n**Example**\n\n```ts\nimport { createIndex, createSearch } from '@vielzeug/scout';\n\nconst users = [{ name: 'Ada Lovelace' }, { name: 'Grace Hopper' }];\nconst index = createIndex(users, { fields: ['name'] });\nconst search = createSearch(index, { debounce: 150 });\n\nsearch.subscribe(() => {\n console.log(search.getSnapshot().results.map((result) => result.item.name));\n});\n\nsearch.setQuery('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';\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\nsearch.subscribe(() => console.log(search.getSnapshot().results.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 local `filter` 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, { filter: toSearchMatcher(index), params: '' });\n\nsource.setParams('ada');\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## 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### `SearchSnapshot<T>`\n\n```ts\ntype SearchSnapshot<T> = Readonly<{\n isSearching: boolean;\n query: string;\n results: ReadonlyArray<SearchResult<T>>;\n}>;\n```\n\nSnapshots and their result arrays are frozen at runtime.\n\n### `ScoutEvent<T>`\n\n```ts\ntype ScoutEvent<T> =\n | { readonly snapshot: SearchSnapshot<T>; readonly type: 'state-change' }\n | { readonly type: 'dispose' };\n```\n\n### `SearchState<T>`\n\n```ts\ntype SearchState<T> = {\n readonly disposalSignal: AbortSignal;\n readonly disposed: boolean;\n clear(): void;\n dispose(): void;\n getSnapshot(): SearchSnapshot<T>;\n setQuery(query: string): void;\n subscribe(listener: () => void, options?: { signal?: AbortSignal }): () => void;\n tap(handler: (event: ScoutEvent<T>) => void, options?: { signal?: AbortSignal }): () => void;\n [Symbol.dispose](): void;\n};\n```\n\n`subscribe()` observers are notified after the full snapshot commits. An observer error is reported asynchronously after the remaining observers run. `tap()` emits `state-change` and `dispose`; tapper errors are swallowed.\n\n### `ReactiveSearch<T>`\n\n```ts\ntype ReactiveSearch<T> = SearchState<T> & {\n readonly index: ScoutIndex<T>;\n};\n```\n\nSee `createReactiveSearch()` above.\n\n---\n\n## Errors\n\n### `ScoutError`\n\nBase class for all scout errors. Use `instanceof ScoutError` to catch any scout-originated error.\n\n```ts\nclass ScoutError extends Error {}\n```\n\n**Named subclasses**\n\n| Class | Thrown when |\n| ------------------- | ---------------------------------------------------------------------- |\n| `ScoutConfigurationError` | An index, search, or reactive search receives invalid fields or numeric options |\n| `ScoutDisposedError` | A method is called on a disposed `SearchState` instance |\n",
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 state together. It returns one external store whose snapshot contains `query`, `isSearching`, and `results`, plus `.index` for incremental mutations:\n\n```ts\nimport { createReactiveSearch } from '@vielzeug/scout';\n\nconst search = createReactiveSearch(users, {\n fields: [{ field: 'name', weight: 2 }, 'email'],\n debounce: 150,\n});\n\nsearch.subscribe(() => {\n const { isSearching, results } = search.getSnapshot();\n if (isSearching) showLoadingSpinner();\n else renderResults(results.map((result) => result.item));\n});\n\ninput.addEventListener('input', (event) => {\n search.setQuery((event.currentTarget as HTMLInputElement).value);\n});\n\nsearch.index.add(newUser);\nsearch.dispose();\n```\n\nEach notification observes a complete committed snapshot. During a debounce window, `query` contains the latest input, `isSearching` is `true`, and `results` remain the last committed results.\n\n### `createSearch()` — separate index and state\n\nUse `createSearch` when sharing one index across multiple search stores:\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` for synchronous commits with no searching transition:\n\n```ts\nconst search = createReactiveSearch(users, { fields: ['name'], debounce: 0 });\n\nsearch.setQuery('alice');\nconsole.log(search.getSnapshot().results); // Already updated\n```\n\n### Resetting search\n\n```ts\nsearch.clear(); // Resets query, results, and isSearching atomically\n```\n\n### Composing with Ripple\n\nA complete `SearchState` implements Ripple's structural subscribable contract:\n\n```ts\nimport { createRipple } from '@vielzeug/ripple';\n\nconst ripple = createRipple();\nconst state = ripple.fromSubscribable(search, { signal: search.disposalSignal });\nconst topResult = ripple.computed(() => state.value.results[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\nUse `tap()` for typed runtime observation without affecting search behavior:\n\n```ts\nimport { createIndex, createSearch } from '@vielzeug/scout';\n\nconst search = createSearch(index, { debounce: 150 });\nconst stop = search.tap((event) => {\n if (event.type === 'state-change') {\n console.debug(event.snapshot.query, event.snapshot.isSearching, event.snapshot.results.length);\n } else {\n console.debug('disposed');\n }\n});\n\nsearch.setQuery('alice');\nstop();\n```\n\nTapper errors are swallowed. Use `subscribe()` instead when application state must update after each snapshot commit.\n\n::: warning Development logging\n`query` carries the full, literal search query string — if your queries may carry PII (names, emails, medical/financial terms typed by end users), don't log them in production.\n:::\n\n## Framework Integration\n\n::: code-group\n\n```tsx [React]\nimport { createReactiveSearch } from '@vielzeug/scout';\nimport { useEffect, useMemo, useSyncExternalStore } from 'react';\n\ntype User = { id: number; name: string; email: string };\n\nfunction useScoutSearch(items: User[]) {\n const search = useMemo(\n () => createReactiveSearch(items, {\n fields: [{ field: 'name', weight: 2 }, 'email'],\n debounce: 150,\n }),\n [items],\n );\n const snapshot = useSyncExternalStore(search.subscribe, search.getSnapshot, search.getSnapshot);\n\n useEffect(() => () => search.dispose(), [search]);\n\n return { ...snapshot, setQuery: search.setQuery };\n}\n```\n\n```ts [Vue 3]\nimport { createReactiveSearch } from '@vielzeug/scout';\nimport { onScopeDispose, shallowRef } 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 const snapshot = shallowRef(search.getSnapshot());\n const stop = search.subscribe(() => { snapshot.value = search.getSnapshot(); });\n\n onScopeDispose(() => { stop(); search.dispose(); });\n\n return { search, snapshot };\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 let snapshot = search.getSnapshot();\n const stop = search.subscribe(() => { snapshot = search.getSnapshot(); });\n\n onDestroy(() => { stop(); search.dispose(); });\n</script>\n\n<input value={snapshot.query} on:input={(event) => search.setQuery(event.currentTarget.value)} placeholder=\"Search…\" />\n{#each snapshot.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` into Sourcerer's local-source filter.\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});\nconst source = createLocalSource(users, { filter: toSearchMatcher(index), params: '' });\n\nsource.setParams('alice');\nconsole.log(source.state.items);\n```\n\n> Update the index and source from the same collection change. Use `index.setItems(items)` with `source.setItems(items)` for replacement collections.\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"
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 ScoutConfigError,\n ScoutDisposedError,\n ScoutError,\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=\"check\" size=\"16\"></ore-icon> | <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> | Atomic external-store snapshots + 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 framework-neutral subscribable search state.\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 filtering in-memory collections with a `ScoutIndex`\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\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/): bridge a complete Scout `SearchState` with `fromSubscribable()` when composing a larger reactive graph\n- [Sourcerer](/sourcerer/): use a `ScoutIndex` matcher to filter in-memory collections alongside remote sources\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.revision` | Monotonic counter incremented after each mutation | Sync | Use as a cache-busting token for external result caches |\n| `ScoutIndex.onMutate()` | Subscribe to changed index mutations | Sync | A changed `setItems()` reconciliation emits once; no-ops emit nothing |\n| `createSearch()` | Atomic search store backed by a `ScoutIndex` | Sync | Read one snapshot and dispose the store 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 local `filter` 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\n## Package Entry Point\n\n| Import | Purpose |\n| --- | --- |\n| `@vielzeug/scout` | All exports: index/search/highlighting/adapters, `ScoutConfigError`, `ScoutDisposedError`, `ScoutError`, and all types |\n\n---\n\n## `createIndex(items, options)`\n\nBuilds a trigram inverted index from `items`. Construction is O(corpus × field_length); subsequent `search()` calls are O(candidates).\n\n```ts\nfunction createIndex<T>(items: T[], options: ScoutIndexOptions<T>): ScoutIndex<T>\n```\n\n**Parameters**\n\n| Param | Type | Description |\n| --- | --- | --- |\n| `items` | `T[]` | Initial corpus to index. |\n| `options.fields` | `ReadonlyArray<FieldDef<T>>` | Fields to index. Required; at least one entry. |\n| `options.threshold` | `number` | Finite overlap score in `0..1` (default `0.2`). |\n| `options.limit` | `number` | Finite non-negative integer max results (default `50`). |\n| `options.minQueryLength` | `number` | Finite positive integer min chars before trigram scoring; shorter queries use O(n) containment scan (default `3`). |\n\n**Example**\n\n```ts\nimport { createIndex } from '@vielzeug/scout';\n\nconst products = [\n { sku: 'WGT-001', title: 'Widget Pro' },\n { sku: 'GAD-002', title: 'Gadget Plus' },\n];\n\nconst index = createIndex(products, {\n fields: [\n { field: 'title', weight: 2 },\n { field: 'sku' },\n ],\n threshold: 0.25,\n limit: 20,\n});\n```\n\n---\n\n## `ScoutIndex<T>`\n\nReturned by `createIndex()`.\n\n### `.search(query, options?)`\n\n```ts\nsearch(query: string, options?: SearchConstraints): SearchResult<T>[]\n```\n\nReturns results sorted by score descending. Empty query returns all items with `score = 1`. Results below `threshold` are excluded; at most `limit` results are returned.\n\n```ts\nconst results = index.search('alice');\n// [{ item, score, matches }]\n```\n\n### `.add(item)`\n\nAdds `item` to the index. No-op if the same reference is already indexed. O(field_length).\n\n### `.remove(item)`\n\nRemoves `item` by reference equality. No-op if not found. O(field_length).\n\n### `.reindex(item)`\n\nRe-reads the item's current field values and rebuilds its index entry in-place, updating only fields whose values changed. Preserves insertion order. No-op if the item is not in the index.\n\n```ts\nitem.name = 'new name';\nindex.reindex(item);\n```\n\n### `.setItems(items)`\n\n```ts\nsetItems(items: readonly T[]): void\n```\n\nReconciles the index to a refreshed corpus in one mutation. Existing references are reindexed, missing references are removed, added references are indexed, and incoming first-occurrence order becomes index order. Duplicate references collapse to one item. Calls `onMutate()` once when indexed values, membership, or order changes.\n\n```ts\nindex.setItems(latestUsers);\n```\n\n### `.size`\n\n`number`: current number of indexed items.\n\n### `.items`\n\n`readonly T[]`: all indexed items in insertion order. Returns a new array snapshot each call.\n\n```ts\nconst all = index.items;\n```\n\n### `.onMutate(listener)`\n\n```ts\nonMutate(listener: () => void): () => void\n```\n\nSubscribes `listener` to run after every changed `add()` / `remove()` / `reindex()` / `setItems()` operation. No-ops, including unchanged bulk reconciliation, do not fire it. A changed `setItems()` reconciliation fires once. `createSearch()` uses this internally to keep `results` in sync with index mutations; most callers building on `createIndex()` directly will not need it.\n\n```ts\nconst unsubscribe = index.onMutate(() => {\n console.log(`Index changed: now ${index.size} items`);\n});\n\nindex.add(newUser); // logs \"Index changed: now 6 items\"\nunsubscribe();\n```\n\n### `.revision`\n\n`number`: monotonically increasing counter, incremented after every changed `add()` / `remove()` / `reindex()` / `setItems()` operation. Use as a cache-busting token when caching search results outside the index: `toSearchMatcher()` uses it for this purpose.\n\n---\n\n## `createSearch(index, options?)`\n\nWraps a `ScoutIndex` in a zero-dependency external store. `getSnapshot()` returns the current query, loading flag, and results as one consistent snapshot; `subscribe()` notifies after the complete snapshot is committed.\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| `getSnapshot()` | `() => SearchSnapshot<T>` | Returns the stable current snapshot. Identity changes only after a state transition. |\n| `setQuery(query)` | `(query: string) => void` | Starts or immediately commits a query according to `debounce`. |\n| `subscribe(listener, options?)` | `(listener, options?) => () => void` | Notifies after an atomic snapshot commit. Supports `{ signal }` cleanup. |\n| `tap(handler, options?)` | `(handler, options?) => () => void` | Observes typed state and disposal events. Handler errors are swallowed. |\n| `disposalSignal` | `AbortSignal` | Aborted when `dispose()` is called. Use to tie other lifecycles to this search. |\n| `disposed` | `boolean` | `true` after `dispose()` has been called. |\n| `clear()` | `() => void` | Resets query, cancels debounce, and restores empty-query results synchronously. |\n| `dispose()` | `() => void` | Releases timers and subscriptions. |\n| `[Symbol.dispose]()` | `() => void` | `using`-compatible disposal. |\n\n**Example**\n\n```ts\nimport { createIndex, createSearch } from '@vielzeug/scout';\n\nconst users = [{ name: 'Ada Lovelace' }, { name: 'Grace Hopper' }];\nconst index = createIndex(users, { fields: ['name'] });\nconst search = createSearch(index, { debounce: 150 });\n\nsearch.subscribe(() => {\n console.log(search.getSnapshot().results.map((result) => result.item.name));\n});\n\nsearch.setQuery('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';\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\nsearch.subscribe(() => console.log(search.getSnapshot().results.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 local `filter` 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, { filter: toSearchMatcher(index), params: '' });\n\nsource.setParams('ada');\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## 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### `SearchSnapshot<T>`\n\n```ts\ntype SearchSnapshot<T> = Readonly<{\n isSearching: boolean;\n query: string;\n results: ReadonlyArray<SearchResult<T>>;\n}>;\n```\n\nSnapshots and their result arrays are frozen at runtime.\n\n### `ScoutEvent<T>`\n\n```ts\ntype ScoutEvent<T> = { readonly snapshot: SearchSnapshot<T>; readonly type: 'state-change' };\n```\n\n### `SearchState<T>`\n\n```ts\ntype SearchState<T> = {\n readonly disposalSignal: AbortSignal;\n readonly disposed: boolean;\n clear(): void;\n dispose(): void;\n getSnapshot(): SearchSnapshot<T>;\n setQuery(query: string): void;\n subscribe(listener: () => void, options?: { signal?: AbortSignal }): () => void;\n tap(handler: (event: ScoutEvent<T>) => void, options?: { signal?: AbortSignal }): () => void;\n [Symbol.dispose](): void;\n};\n```\n\n`subscribe()` observers are notified after the full snapshot commits. An observer error is reported asynchronously after the remaining observers run. `tap()` emits `state-change` only: disposal is observable through `disposed` and `disposalSignal`; tapper errors are swallowed.\n\n### `ReactiveSearch<T>`\n\n```ts\ntype ReactiveSearch<T> = SearchState<T> & {\n readonly index: ScoutIndex<T>;\n};\n```\n\nSee `createReactiveSearch()` above.\n\n---\n\n## Errors\n\n### `ScoutError`\n\nBase class for all scout errors. Use `instanceof ScoutError` to catch any scout-originated error.\n\n```ts\nclass ScoutError extends Error {}\n```\n\n**Named subclasses**\n\n| Class | Thrown when |\n| ------------------- | ---------------------------------------------------------------------- |\n| `ScoutConfigError` | An index, search, or reactive search receives invalid fields or numeric options |\n| `ScoutDisposedError` | A method is called on a disposed `SearchState` instance |\n\n## Entry Ranking\n\nReference lists: glossaries, rule compendiums, profile directories: rank and render through the same helpers.\n\n### `rankEntries(entries, query, options?)`\n\nOrders `RankableEntry` items (`{ id, label, meta, text }`) by how directly they match the query: exact label, label prefix, label contains, meta contains, text contains, and finally `options.fuzzyIds` hits as last resort. Non-matching entries drop; ties break alphabetically. A blank query returns every entry.\n\n### `dedupeEntries(entries)`\n\nDrops entries whose label and body text are identical to an earlier entry (case-insensitive): alias rows in merged catalogs.\n\n### `splitPattern(text, pattern)`\n\nSplits text on a global regular expression, marking matched pieces (`SplitSegment`), for rendering keyword links over prose. A `null` pattern keeps the text whole.\n\n### `escapeRegExp(value)`\n\nEscapes a literal for embedding in a regular expression: build keyword patterns from display names.\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 `ScoutConfigError`.\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 state together. It returns one external store whose snapshot contains `query`, `isSearching`, and `results`, plus `.index` for incremental mutations:\n\n```ts\nimport { createReactiveSearch } from '@vielzeug/scout';\n\nconst search = createReactiveSearch(users, {\n fields: [{ field: 'name', weight: 2 }, 'email'],\n debounce: 150,\n});\n\nsearch.subscribe(() => {\n const { isSearching, results } = search.getSnapshot();\n if (isSearching) showLoadingSpinner();\n else renderResults(results.map((result) => result.item));\n});\n\ninput.addEventListener('input', (event) => {\n search.setQuery((event.currentTarget as HTMLInputElement).value);\n});\n\nsearch.index.add(newUser);\nsearch.dispose();\n```\n\nEach notification observes a complete committed snapshot. During a debounce window, `query` contains the latest input, `isSearching` is `true`, and `results` remain the last committed results.\n\n### `createSearch()`: separate index and state\n\nUse `createSearch` when sharing one index across multiple search stores:\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` for synchronous commits with no searching transition:\n\n```ts\nconst search = createReactiveSearch(users, { fields: ['name'], debounce: 0 });\n\nsearch.setQuery('alice');\nconsole.log(search.getSnapshot().results); // Already updated\n```\n\n### Resetting search\n\n```ts\nsearch.clear(); // Resets query, results, and isSearching atomically\n```\n\n### Composing with Ripple\n\nA complete `SearchState` implements Ripple's structural subscribable contract:\n\n```ts\nimport { createRipple } from '@vielzeug/ripple';\n\nconst ripple = createRipple();\nconst state = ripple.fromSubscribable(search, { signal: search.disposalSignal });\nconst topResult = ripple.computed(() => state.value.results[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## Searchable Reference Lists\n\nA glossary or compendium mixes entry kinds and needs direct-match ranking above fuzzy hits. Rank with `rankEntries`, render keyword links with `splitPattern`, and highlight the typed query with `findMatchRanges` + `highlight`.\n\n```ts\nimport { dedupeEntries, escapeRegExp, findMatchRanges, highlight, rankEntries, splitPattern } from '@vielzeug/scout';\n\nconst keywordPattern = new RegExp(`(${keywords.map((k) => escapeRegExp(k.name)).join('|')})`, 'g');\nconst results = rankEntries(entries, query, { fuzzyIds: fuzzyIndexIds });\n\nfor (const entry of results) {\n for (const segment of splitPattern(entry.text, keywordPattern)) {\n if (segment.matched) renderKeywordLink(segment.text);\n else for (const part of highlight(segment.text, findMatchRanges(segment.text, query))) renderMark(part);\n }\n}\n```\n\nDedupe merged catalogs with `dedupeEntries` before ranking so alias rows do not shadow their originals.\n\n## Debug Logging\n\nUse `tap()` for typed runtime observation without affecting search behavior:\n\n```ts\nimport { createIndex, createSearch } from '@vielzeug/scout';\n\nconst search = createSearch(index, { debounce: 150 });\nconst stop = search.tap((event) => {\n console.debug(event.snapshot.query, event.snapshot.isSearching, event.snapshot.results.length);\n});\n\nsearch.setQuery('alice');\nstop();\n```\n\n`tap()` emits only `state-change`; observe disposal through `disposed` or `disposalSignal`. Tapper errors are swallowed. Use `subscribe()` instead when application state must update after each snapshot commit.\n\n::: warning Development logging\n`query` carries the full, literal search query string: if your queries may carry PII (names, emails, medical/financial terms typed by end users), don't log them in production.\n:::\n\n## Framework Integration\n\n::: code-group\n\n```tsx [React]\nimport { createReactiveSearch } from '@vielzeug/scout';\nimport { useEffect, useMemo, useSyncExternalStore } from 'react';\n\ntype User = { id: number; name: string; email: string };\n\nfunction useScoutSearch(items: User[]) {\n const search = useMemo(\n () => createReactiveSearch(items, {\n fields: [{ field: 'name', weight: 2 }, 'email'],\n debounce: 150,\n }),\n [items],\n );\n const snapshot = useSyncExternalStore(search.subscribe, search.getSnapshot, search.getSnapshot);\n\n useEffect(() => () => search.dispose(), [search]);\n\n return { ...snapshot, setQuery: search.setQuery };\n}\n```\n\n```ts [Vue 3]\nimport { createReactiveSearch } from '@vielzeug/scout';\nimport { onScopeDispose, shallowRef } 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 const snapshot = shallowRef(search.getSnapshot());\n const stop = search.subscribe(() => { snapshot.value = search.getSnapshot(); });\n\n onScopeDispose(() => { stop(); search.dispose(); });\n\n return { search, snapshot };\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 let snapshot = search.getSnapshot();\n const stop = search.subscribe(() => { snapshot = search.getSnapshot(); });\n\n onDestroy(() => { stop(); search.dispose(); });\n</script>\n\n<input value={snapshot.query} on:input={(event) => search.setQuery(event.currentTarget.value)} placeholder=\"Search…\" />\n{#each snapshot.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` into Sourcerer's local-source filter.\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});\nconst source = createLocalSource(users, { filter: toSearchMatcher(index), params: '' });\n\nsource.setParams('alice');\nconsole.log(source.state.items);\n```\n\n> Update the index and source from the same collection change. Use `index.setItems(items)` with `source.setItems(items)` for replacement collections.\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
  {
11
11
  "id": "basic-search",
12
- "code": "import { createIndex, highlightField } from '@vielzeug/scout'\n\nconst users = [\n { name: 'Alice Johnson', email: 'alice@example.com', role: 'admin' },\n { name: 'Bob Smith', email: 'bob@example.com', role: 'editor' },\n { name: 'Charlie Brown', email: 'charlie@example.com', role: 'viewer' },\n { name: 'Alicia Keys', email: 'alicia@example.com', role: 'editor' },\n { name: 'Dave Alison', email: 'dave@example.com', role: 'viewer' },\n]\n\nconst index = createIndex(users, {\n fields: [\n { field: 'name', weight: 2 },\n { field: 'email' },\n ],\n threshold: 0.2,\n})\n\nconst results = index.search('alice')\n\nfor (const result of results) {\n const parts = highlightField(result, 'name', result.item.name)\n const display = parts.map(p => p.highlighted ? `[${p.text}]` : p.text).join('')\n\n console.log(`${display} — ${result.item.email} (${result.score.toFixed(2)})`)\n}",
12
+ "code": "import { createIndex, highlightField } from '@vielzeug/scout'\n\nconst users = [\n { name: 'Alice Johnson', email: 'alice@example.com', role: 'admin' },\n { name: 'Bob Smith', email: 'bob@example.com', role: 'editor' },\n { name: 'Charlie Brown', email: 'charlie@example.com', role: 'viewer' },\n { name: 'Alicia Keys', email: 'alicia@example.com', role: 'editor' },\n { name: 'Dave Alison', email: 'dave@example.com', role: 'viewer' },\n]\n\nconst index = createIndex(users, {\n fields: [\n { field: 'name', weight: 2 },\n { field: 'email' },\n ],\n threshold: 0.2,\n})\n\nconst results = index.search('alice')\n\nfor (const result of results) {\n const parts = highlightField(result, 'name', result.item.name)\n const display = parts.map(p => p.highlighted ? `[${p.text}]` : p.text).join('')\n\n console.log(`${display}: ${result.item.email} (${result.score.toFixed(2)})`)\n}",
13
13
  "name": "Basic Search"
14
14
  },
15
15
  {
@@ -19,7 +19,7 @@
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 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()",
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
  {
@@ -29,16 +29,22 @@
29
29
  },
30
30
  {
31
31
  "id": "segment-words",
32
- "code": "import { createIndex, segmentWords } from '@vielzeug/scout'\n\n// CJK text has no spaces between words — segmentWords() inserts them via the\n// runtime's native Intl.Segmenter, so word-boundary features work like they do for Latin text\nconst docs = [\n { id: 1, title: '日本語を勉強しています' },\n { id: 2, title: '我喜欢学习中文' },\n { id: 3, title: 'Learning Japanese is fun' },\n]\n\nconsole.log('Segmented:', segmentWords('日本語を勉強しています'))\n\nconst index = createIndex(docs, {\n fields: [{ field: 'title', stringify: (v) => segmentWords(String(v)) }],\n})\n\nconst results = index.search('日本語')\nconsole.log('Search \"日本語\":', results.map(r => r.item.title))",
32
+ "code": "import { createIndex, segmentWords } from '@vielzeug/scout'\n\n// CJK text has no spaces between words: segmentWords() inserts them via the\n// runtime's native Intl.Segmenter, so word-boundary features work like they do for Latin text\nconst docs = [\n { id: 1, title: '日本語を勉強しています' },\n { id: 2, title: '我喜欢学习中文' },\n { id: 3, title: 'Learning Japanese is fun' },\n]\n\nconsole.log('Segmented:', segmentWords('日本語を勉強しています'))\n\nconst index = createIndex(docs, {\n fields: [{ field: 'title', stringify: (v) => segmentWords(String(v)) }],\n})\n\nconst results = index.search('日本語')\nconsole.log('Search \"日本語\":', results.map(r => r.item.title))",
33
33
  "name": "Segmenting Non-Latin Text"
34
34
  }
35
35
  ],
36
36
  "typeSignatures": {
37
37
  "toFilterPredicate": "export { toFilterPredicate, toSearchMatcher } from './adapters';",
38
38
  "toSearchMatcher": "export { toFilterPredicate, toSearchMatcher } from './adapters';",
39
- "ScoutConfigurationError": "export { ScoutConfigurationError, ScoutDisposedError, ScoutError } from './errors';",
40
- "ScoutDisposedError": "export { ScoutConfigurationError, ScoutDisposedError, ScoutError } from './errors';",
41
- "ScoutError": "export { ScoutConfigurationError, ScoutDisposedError, ScoutError } from './errors';",
39
+ "dedupeEntries": "export {\n dedupeEntries,\n escapeRegExp,\n type RankableEntry,\n rankEntries,\n type SplitSegment,\n splitPattern,\n} from './entries';",
40
+ "escapeRegExp": "export {\n dedupeEntries,\n escapeRegExp,\n type RankableEntry,\n rankEntries,\n type SplitSegment,\n splitPattern,\n} from './entries';",
41
+ "RankableEntry": "export {\n dedupeEntries,\n escapeRegExp,\n type RankableEntry,\n rankEntries,\n type SplitSegment,\n splitPattern,\n} from './entries';",
42
+ "rankEntries": "export {\n dedupeEntries,\n escapeRegExp,\n type RankableEntry,\n rankEntries,\n type SplitSegment,\n splitPattern,\n} from './entries';",
43
+ "SplitSegment": "export {\n dedupeEntries,\n escapeRegExp,\n type RankableEntry,\n rankEntries,\n type SplitSegment,\n splitPattern,\n} from './entries';",
44
+ "splitPattern": "export {\n dedupeEntries,\n escapeRegExp,\n type RankableEntry,\n rankEntries,\n type SplitSegment,\n splitPattern,\n} from './entries';",
45
+ "ScoutConfigError": "export { ScoutConfigError, ScoutDisposedError, ScoutError } from './errors';",
46
+ "ScoutDisposedError": "export { ScoutConfigError, ScoutDisposedError, ScoutError } from './errors';",
47
+ "ScoutError": "export { ScoutConfigError, ScoutDisposedError, ScoutError } from './errors';",
42
48
  "findMatchRanges": "export { findMatchRanges, highlight, highlightField } from './highlight';",
43
49
  "highlight": "export { findMatchRanges, highlight, highlightField } from './highlight';",
44
50
  "highlightField": "export { findMatchRanges, highlight, highlightField } from './highlight';",