@vielzeug/codex 2.2.8 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,9 +1,9 @@
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 ScoutIndexOptions,\n SearchConstraints,\n SearchResult,\n SearchState,\n} from './types';\n",
2
+ "apiSource": "export { toFilterPredicate, toSearchMatcher } from './adapters';\nexport { ScoutConfigurationError, ScoutDisposedError, ScoutError } from './errors';\nexport { findMatchRanges, highlight, highlightField } from './highlight';\nexport type { ReactiveSearch } from './reactive';\nexport { createReactiveSearch, createSearch } from './reactive';\nexport type { ScoutIndex } from './scout-index';\nexport { createIndex } from './scout-index';\nexport { segmentWords } from './segment';\nexport type {\n CreateSearchOptions,\n FieldDef,\n FieldMatch,\n HighlightPart,\n ScoutEvent,\n ScoutIndexOptions,\n SearchConstraints,\n SearchResult,\n SearchState,\n} from './types';\n",
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 debugSearch,\n findMatchRanges,\n highlight,\n highlightField,\n segmentWords,\n toFilterPredicate,\n toSearchMatcher,\n ]\nrelated: [arsenal, sourcerer, vault, ripple]\nenvironments: [browser, node, ssr, deno]\n---\n\n<!-- markdownlint-disable MD025 MD033 MD060 -->\n\n<PackageHero package=\"scout\" />\n\n## Why Scout?\n\nArsenal's `fuzzy` / `fuzzyFilter` helpers perform pairwise Levenshtein distance — O(n·m) per item per query. For ≤200 items they are fine. For 500–100k items with real-time keystrokes, you need an index.\n\nScout builds a **trigram inverted index** at construction time. Query time scores only items sharing a trigram with the query; broad queries can still approach O(n), while selective queries avoid scoring the whole corpus.\n\n```ts\n// Before\nconst matches = users.filter((user) => user.name.toLowerCase().includes(query.toLowerCase()));\n\n// After\nimport { createIndex } from '@vielzeug/scout';\n\nconst index = createIndex(users, { fields: ['name', 'email'] });\nconst matches = index.search(query);\n```\n\n| Feature | Arsenal `fuzzy*` | Scout `createIndex` | Fuse.js |\n| ------------------------ | ---------------------------------------------- | ----------------------------------------------------------------------------------------- | ---------------------------------------------- |\n| Bundle size | ~3 KB | <PackageInfo package=\"scout\" type=\"size\" /> | ~23 KB |\n| Zero dependencies | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> `@vielzeug/ripple` runtime dependency | <ore-icon name=\"check\" size=\"16\"></ore-icon> |\n| Algorithm | Levenshtein | Trigram + overlap coefficient | Bitap |\n| Query time | O(n·m) | O(candidates) | O(n·m) |\n| Stateful index | <ore-icon name=\"x\" size=\"16\"></ore-icon> | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"check\" size=\"16\"></ore-icon> |\n| Match highlighting | <ore-icon name=\"x\" size=\"16\"></ore-icon> | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"check\" size=\"16\"></ore-icon> |\n| Reactive layer | <ore-icon name=\"x\" size=\"16\"></ore-icon> | ripple signals + debounce | <ore-icon name=\"x\" size=\"16\"></ore-icon> |\n| Incremental updates | <ore-icon name=\"x\" size=\"16\"></ore-icon> | <ore-icon name=\"check\" size=\"16\"></ore-icon> | Partial |\n\n<div class=\"decision-callout\">\n\n**Use Scout when** you need search over 500+ items, real-time UI search boxes (combobox, command palette), or reactive query state with ripple signals.\n\n**Consider `arsenal.fuzzyFilter` when** you have fewer than 200 items and don't need a persistent index.\n\n</div>\n\n## Installation\n\n::: code-group\n\n```sh [pnpm]\npnpm add @vielzeug/scout\n```\n\n```sh [npm]\nnpm install @vielzeug/scout\n```\n\n```sh [yarn]\nyarn add @vielzeug/scout\n```\n\n:::\n\n## Quick Start\n\n```ts\nimport { createIndex } from '@vielzeug/scout';\n\nconst users = [\n { email: 'ada@example.com', name: 'Ada Lovelace' },\n { email: 'grace@example.com', name: 'Grace Hopper' },\n];\n\nconst index = createIndex(users, {\n fields: [\n { field: 'name', weight: 2 },\n { field: 'email' },\n ],\n});\n\nconst results = index.search('ada');\nconsole.log(results[0]?.item.name); // Ada Lovelace\n```\n\n## Features\n\n<div class=\"features-grid\">\n\n- `createIndex()` — Trigram inverted index; construction O(corpus × field_length), query O(candidates)\n- Per-field weights — Promote `name` matches over secondary fields; finite positive weights and custom `stringify` functions supported\n- `createReactiveSearch()` — Index + reactive `SearchState` in one call; `.index` for incremental mutations\n- `createSearch()` — Reactive search state backed by an existing `ScoutIndex`; share one index across many states\n- `highlight()` / `highlightField()` — Split field text into `HighlightPart[]` fragments for styled rendering\n- `findMatchRanges()` — Compute match ranges for custom display strings (truncated previews, formatted values)\n- `toSearchMatcher()` — Matcher adapter for sourcerer's `LocalSource`\n- `toFilterPredicate()` — Snapshot `(item: T) => boolean` predicate for `Array.filter` or vault queries\n- `setItems()` — Reconcile a refreshed corpus by reference, preserve incoming order, and notify once\n- Incremental updates — `add()` / `remove()` / `reindex()` patch individual items in O(field_length)\n- `onMutate()` — Subscribe to index mutations; powers `createSearch()`'s reactivity and bulk reconciliation\n- `segmentWords()` — Split unsegmented-script text (CJK, Thai, ...) into words via native `Intl.Segmenter`\n- Debug logging via `debugSearch()` (`@vielzeug/scout/devtools`) logs query/results transitions, tree-shaken from production bundles\n\n</div>\n\n## Documentation\n\n<div class=\"doc-links\">\n\n- [Usage Guide](./usage.md)\n- [API Reference](./api.md)\n- [Examples](./examples.md)\n- [Migration Guide](./migration.md)\n\n</div>\n\n## See Also\n\n<div class=\"see-also\">\n\n- [Arsenal](/arsenal/) — Use `fuzzyFilter` for ad-hoc filtering of small lists (< 200 items) without building an index\n- [Ripple](/ripple/) — `createReactiveSearch()` and `createSearch()` use Ripple signals for reactive query state and debounce\n- [Sourcerer](/sourcerer/) — use a `ScoutIndex` inside `createLocalSource`'s explicit `match` callback\n- [Vault](/vault/) — `toFilterPredicate()` wraps a one-time Scout query as a vault-compatible `filter()` predicate\n\n</div>\n\n<!-- markdownlint-enable MD025 MD033 MD060 -->\n",
5
- "api": "---\ntitle: Scout — API Reference\ndescription: Complete API reference for @vielzeug/scout — createIndex, createReactiveSearch, createSearch, highlight, highlightField, toSearchMatcher, toFilterPredicate.\n---\n\n[[toc]]\n\n## API Overview\n\n| Symbol | Purpose | Execution mode | Common gotcha |\n| ------------------------- | ----------------------------------------------------- | -------------- | ------------------------------------------------------------- |\n| `createIndex()` | Build trigram index from an item array | Sync | Index is built at call time — pass all initial items |\n| `ScoutIndex.search()` | Query the index, returns scored + highlighted results | Sync | Empty query returns all items with `score = 1` |\n| `ScoutIndex.add()` | Add one item to the index | Sync | No-op if same reference already indexed |\n| `ScoutIndex.remove()` | Remove one item by reference | Sync | No-op for unknown references |\n| `ScoutIndex.reindex()` | Re-index a mutated item in-place; preserves order | Sync | Call after mutating item properties; no-op if not in index |\n| `ScoutIndex.setItems()` | Reconcile a refreshed corpus in one mutation | Sync | Uses reference identity; duplicate references collapse |\n| `ScoutIndex.items` | All indexed items in insertion order | Sync | Returns a new array snapshot each call |\n| `ScoutIndex.revision` | Monotonic counter incremented after each mutation | Sync | Use as a cache-busting token for external result caches |\n| `ScoutIndex.onMutate()` | Subscribe to changed index mutations | Sync | A changed `setItems()` reconciliation emits once; no-ops emit nothing |\n| `createSearch()` | Reactive search state backed by a `ScoutIndex` | Sync | Requires `@vielzeug/ripple` — dispose when done |\n| `createReactiveSearch()` | One-call index + reactive search state | Sync | Exposes `.index` for incremental mutations |\n| `findMatchRanges()` | Compute match ranges for a text + query pair | Sync | Returns sorted, non-overlapping `[start, end]` ranges |\n| `highlight()` | Split text into highlighted/unhighlighted fragments | Sync | Ranges must be sorted and non-overlapping |\n| `highlightField()` | Highlight a named field from a `SearchResult` | Sync | Shorthand for the `matches.find(…).ranges → highlight()` pattern |\n| `toSearchMatcher()` | Adapt `ScoutIndex` to Sourcerer's `match` callback | Sync | Recomputes cached query matches after index mutation |\n| `toFilterPredicate()` | Snapshot predicate from a one-time query | Sync | Re-call when query or corpus changes |\n| `segmentWords()` | Split unsegmented-script text (CJK, Thai, ...) into words | Sync | Uses native `Intl.Segmenter` — not applied inside `tokenize()` itself (see Pitfalls) |\n| `debugSearch()` | Log a `SearchState`'s query/results transitions | Sync | Import from `@vielzeug/scout/devtools`, not the main entry point |\n\n## Package Entry Point\n\n| Import | Purpose |\n| --- | --- |\n| `@vielzeug/scout` | All exports — index/search/highlighting/adapters, `ScoutConfigurationError`, `ScoutDisposedError`, `ScoutError`, and all types |\n| `@vielzeug/scout/devtools` | `debugSearch` — reactive search state logger (dev only) |\n\n---\n\n## `createIndex(items, options)`\n\nBuilds a trigram inverted index from `items`. Construction is O(corpus × field_length); subsequent `search()` calls are O(candidates).\n\n```ts\nfunction createIndex<T>(items: T[], options: ScoutIndexOptions<T>): ScoutIndex<T>\n```\n\n**Parameters**\n\n| Param | Type | Description |\n| --- | --- | --- |\n| `items` | `T[]` | Initial corpus to index. |\n| `options.fields` | `ReadonlyArray<FieldDef<T>>` | Fields to index. Required; at least one entry. |\n| `options.threshold` | `number` | Finite overlap score in `0..1` (default `0.2`). |\n| `options.limit` | `number` | Finite non-negative integer max results (default `50`). |\n| `options.minQueryLength` | `number` | Finite positive integer min chars before trigram scoring; shorter queries use O(n) containment scan (default `3`). |\n\n**Example**\n\n```ts\nimport { createIndex } from '@vielzeug/scout';\n\nconst products = [\n { sku: 'WGT-001', title: 'Widget Pro' },\n { sku: 'GAD-002', title: 'Gadget Plus' },\n];\n\nconst index = createIndex(products, {\n fields: [\n { field: 'title', weight: 2 },\n { field: 'sku' },\n ],\n threshold: 0.25,\n limit: 20,\n});\n```\n\n---\n\n## `ScoutIndex<T>`\n\nReturned by `createIndex()`.\n\n### `.search(query, options?)`\n\n```ts\nsearch(query: string, options?: SearchConstraints): SearchResult<T>[]\n```\n\nReturns results sorted by score descending. Empty query returns all items with `score = 1`. Results below `threshold` are excluded; at most `limit` results are returned.\n\n```ts\nconst results = index.search('alice');\n// [{ item, score, matches }]\n```\n\n### `.add(item)`\n\nAdds `item` to the index. No-op if the same reference is already indexed. O(field_length).\n\n### `.remove(item)`\n\nRemoves `item` by reference equality. No-op if not found. O(field_length).\n\n### `.reindex(item)`\n\nRe-reads the item's current field values and rebuilds its index entry in-place, updating only fields whose values changed. Preserves insertion order. No-op if the item is not in the index.\n\n```ts\nitem.name = 'new name';\nindex.reindex(item);\n```\n\n### `.setItems(items)`\n\n```ts\nsetItems(items: readonly T[]): void\n```\n\nReconciles the index to a refreshed corpus in one mutation. Existing references are reindexed, missing references are removed, added references are indexed, and incoming first-occurrence order becomes index order. Duplicate references collapse to one item. Calls `onMutate()` once when indexed values, membership, or order changes.\n\n```ts\nindex.setItems(latestUsers);\n```\n\n### `.size`\n\n`number` — current number of indexed items.\n\n### `.items`\n\n`readonly T[]` — all indexed items in insertion order. Returns a new array snapshot each call.\n\n```ts\nconst all = index.items;\n```\n\n### `.onMutate(listener)`\n\n```ts\nonMutate(listener: () => void): () => void\n```\n\nSubscribes `listener` to run after every changed `add()` / `remove()` / `reindex()` / `setItems()` operation. No-ops, including unchanged bulk reconciliation, do not fire it. A changed `setItems()` reconciliation fires once. `createSearch()` uses this internally to keep `results` in sync with index mutations; most callers building on `createIndex()` directly will not need it.\n\n```ts\nconst unsubscribe = index.onMutate(() => {\n console.log(`Index changed — now ${index.size} items`);\n});\n\nindex.add(newUser); // logs \"Index changed — now 6 items\"\nunsubscribe();\n```\n\n### `.revision`\n\n`number` — monotonically increasing counter, incremented after every changed `add()` / `remove()` / `reindex()` / `setItems()` operation. Use as a cache-busting token when caching search results outside the index — `toSearchMatcher()` uses it for this purpose.\n\n---\n\n## `createSearch(index, options?)`\n\nWraps a `ScoutIndex` in a reactive search state powered by `@vielzeug/ripple` signals.\n\n```ts\nfunction createSearch<T>(index: ScoutIndex<T>, options?: CreateSearchOptions): SearchState<T>\n```\n\n**Parameters**\n\n| Param | Type | Description |\n| --- | --- | --- |\n| `options.debounce` | `number` | Finite non-negative integer milliseconds before query commit (default `200`). Pass `0` for immediate updates. |\n| `options.limit` | `number` | Finite non-negative integer override of index-level limit. |\n| `options.threshold` | `number` | Finite `0..1` override of index-level threshold. |\n| `options.minQueryLength` | `number` | Finite positive integer override of index-level minimum query length. |\n\n**Returns `SearchState<T>`**\n\n| Member | Type | Description |\n| --- | --- | --- |\n| `query` | `Signal<string>` | Writable search query. Set `.value` to trigger search. |\n| `results` | `Readable<SearchResult<T>[]>` | Reactive results, updated after debounce. |\n| `isSearching` | `Readable<boolean>` | `true` during the debounce window. |\n| `disposalSignal` | `AbortSignal` | Aborted when `dispose()` is called. Use to tie other lifecycles to this search. |\n| `disposed` | `boolean` | `true` after `dispose()` has been called. |\n| `clear()` | `() => void` | Resets query, cancels debounce, clears results synchronously. |\n| `dispose()` | `() => void` | Releases all reactive subscriptions. |\n| `[Symbol.dispose]()` | `() => void` | `using`-compatible disposal. |\n\n**Example**\n\n```ts\nimport { createIndex, createSearch } from '@vielzeug/scout';\nimport { effect } from '@vielzeug/ripple';\n\nconst users = [{ name: 'Ada Lovelace' }, { name: 'Grace Hopper' }];\nconst index = createIndex(users, { fields: ['name'] });\nconst search = createSearch(index, { debounce: 150 });\n\neffect(() => {\n console.log(search.results.value.map((result) => result.item.name));\n});\n\nsearch.query.value = 'ada';\n```\n\n---\n\n## `createReactiveSearch(items, options)`\n\nCreates a `ScoutIndex` and a reactive `SearchState` in one call — the shorthand for `createIndex` + `createSearch`. Returns a `ReactiveSearch<T>` which extends `SearchState<T>` with a `.index` property for incremental mutations.\n\n```ts\nfunction createReactiveSearch<T>(\n items: T[],\n options: ScoutIndexOptions<T> & { debounce?: number },\n): ReactiveSearch<T>\n```\n\n**Parameters**\n\n| Param | Type | Description |\n| --- | --- | --- |\n| `items` | `T[]` | Initial corpus to index. |\n| `options.fields` | `ReadonlyArray<FieldDef<T>>` | Fields to index. Required. |\n| `options.debounce` | `number` | Finite non-negative integer debounce milliseconds (default `200`). |\n| `options.threshold` | `number` | Finite overlap score in `0..1` (default `0.2`). |\n| `options.limit` | `number` | Finite non-negative integer max results (default `50`). |\n| `options.minQueryLength` | `number` | Finite positive integer min chars before trigram scoring (default `3`). |\n\n**Returns `ReactiveSearch<T>`** — all `SearchState<T>` members plus:\n\n| Member | Type | Description |\n| --- | --- | --- |\n| `index` | `ScoutIndex<T>` | The underlying index for `add`, `remove`, `reindex`. |\n\n**Example**\n\n```ts\nimport { createReactiveSearch } from '@vielzeug/scout';\nimport { effect } from '@vielzeug/ripple';\n\nconst users = [{ email: 'ada@example.com', name: 'Ada Lovelace' }];\nconst search = createReactiveSearch(users, {\n fields: [{ field: 'name', weight: 2 }, 'email'],\n debounce: 150,\n});\n\neffect(() => console.log(search.results.value.map((result) => result.item.name)));\n\nsearch.index.add({ email: 'grace@example.com', name: 'Grace Hopper' });\nsearch.dispose();\n```\n\n---\n\n## `findMatchRanges(text, query)`\n\nNormalizes raw `query` with Scout's tokenizer, then computes sorted, non-overlapping literal ranges for each normalized token within `text`. Useful when you need to apply highlighting to a different string than the indexed field value (e.g. a truncated preview or a differently formatted display string).\n\n```ts\nfunction findMatchRanges(text: string, query: string): [number, number][]\n```\n\n**Example**\n\n```ts\nimport { findMatchRanges, highlight } from '@vielzeug/scout';\n\nconst ranges = findMatchRanges('Alice Johnson', 'alice!');\n// [[0, 5]]\n\nconst parts = highlight('Alice Johnson', ranges);\n// [{ text: 'Alice', highlighted: true }, { text: ' Johnson', highlighted: false }]\n```\n\nReturns an empty array if either `text` or `query` is empty.\n\n---\n\n## `highlight(text, ranges)`\n\nSplits `text` into `HighlightPart[]` fragments based on `ranges` from `FieldMatch.ranges`.\n\n```ts\nfunction highlight(text: string, ranges: [number, number][]): HighlightPart[]\n```\n\n**Example**\n\n```ts\nimport { highlight } from '@vielzeug/scout';\n\nhighlight('Hello World', [[0, 5]]);\n// [{ text: 'Hello', highlighted: true }, { text: ' World', highlighted: false }]\n```\n\nReturns an empty array when `text` is empty. Returns a single unhighlighted part when `ranges` is empty.\n\n---\n\n## `highlightField(result, field, text)`\n\nConvenience shorthand that finds the match ranges for `field` in `result.matches` and calls `highlight()` in one step. Eliminates the manual `result.matches.find(m => m.field === …).ranges` lookup.\n\n```ts\nfunction highlightField<T>(result: SearchResult<T>, field: keyof T & string, text: string): HighlightPart[]\n```\n\n**Example**\n\n```ts\nimport { createIndex, highlightField } from '@vielzeug/scout';\n\nconst users = [{ name: 'Alice Johnson' }];\nconst index = createIndex(users, { fields: ['name'] });\n\nfor (const result of index.search('alice')) {\n const parts = highlightField(result, 'name', result.item.name);\n console.log(parts.map((part) => part.highlighted ? `[${part.text}]` : part.text).join(''));\n}\n```\n\nWhen the field has no match (e.g. the query matched via a different field), returns a single unhighlighted part.\n\n---\n\n## `toSearchMatcher(index, options?)`\n\nReturns an `(item, query) => boolean` matcher compatible with `sourcerer`'s `match` option.\n\n```ts\nfunction toSearchMatcher<T>(index: ScoutIndex<T>, options?: SearchConstraints): (item: T, query: string) => boolean\n```\n\nOne matching-item set is cached per query and index revision, so filtering does not repeat index work per item and stays current after index mutation.\n\n```ts\nimport { createIndex, toSearchMatcher } from '@vielzeug/scout';\nimport { createLocalSource } from '@vielzeug/sourcerer';\n\nconst users = [{ email: 'ada@example.com', name: 'Ada Lovelace' }];\nconst index = createIndex(users, { fields: ['name', 'email'] });\nconst source = createLocalSource(users, { match: toSearchMatcher(index) });\n```\n\n---\n\n## `toFilterPredicate(index, query, options?)`\n\nReturns a `(item: T) => boolean` predicate computed from a one-time query. Use with `Array.filter` or vault's `query.filter()`.\n\n```ts\nfunction toFilterPredicate<T>(\n index: ScoutIndex<T>,\n query: string,\n options?: SearchConstraints,\n): (item: T) => boolean\n```\n\nThe predicate is a snapshot — re-call `toFilterPredicate` if the query or corpus changes.\n\n```ts\nimport { createIndex, toFilterPredicate } from '@vielzeug/scout';\n\nconst products = [{ title: 'Widget Pro' }, { title: 'Gadget Plus' }];\nconst index = createIndex(products, { fields: ['title'] });\nconst results = products.filter(toFilterPredicate(index, 'widget'));\n\nconst top5 = products.filter(toFilterPredicate(index, 'widget', { limit: 5 }));\n```\n\n---\n\n## `segmentWords(text)`\n\nSplits `text` into whitespace-joined word segments using the runtime's native `Intl.Segmenter` — no dependency beyond the platform API. Falls back to returning `text` unchanged where `Intl.Segmenter` isn't available.\n\n```ts\nfunction segmentWords(text: string): string\n```\n\n`tokenize()`'s trigram-based scoring already works on unsegmented scripts (Chinese, Japanese, Thai, ...) without this — trigrams are generated per-character, not per-word. `segmentWords()` is for `findMatchRanges()` / highlighting and the multi-word query semantics on `SearchConstraints`, which assume space-separated words. **Not applied inside `tokenize()` itself** — benchmarked at ~15x slower than the plain regex path for the common whitespace-delimited case, which would regress `createIndex()`'s construction cost for every caller, not just those indexing unsegmented scripts.\n\n**Example**\n\n```ts\nimport { createIndex, segmentWords } from '@vielzeug/scout';\n\nconst documents = [{ title: '日本語を勉強しています' }];\nconst index = createIndex(documents, {\n fields: [{ field: 'title', stringify: (value) => segmentWords(String(value)) }],\n});\n```\n\n---\n\n## `debugSearch(search)` <Badge type=\"tip\" text=\"@vielzeug/scout/devtools\" />\n\n```ts\ndebugSearch<T>(search: SearchState<T>): () => void\n```\n\nLogs `query` → `isSearching` → `results` transitions of a `SearchState` to `console.debug`. Returns a function that unsubscribes all listeners installed by this call. Import from the dedicated sub-path so it's tree-shaken from production bundles.\n\n::: warning Development only\nLogs the full, literal search query string — if your queries may carry PII (names, emails, medical/financial terms typed by end users), don't enable this in production.\n:::\n\n**Example**\n\n```ts\nimport { createIndex, createSearch } from '@vielzeug/scout';\nimport { debugSearch } from '@vielzeug/scout/devtools';\n\nconst index = createIndex([{ name: 'Ada Lovelace' }], { fields: ['name'] });\nconst search = createSearch(index);\nconst stopDebugging = debugSearch(search);\n\nsearch.query.value = 'alice';\n// [scout:search] query -> \"alice\"\n// [scout:search] isSearching -> true\n// [scout:search] isSearching -> false\n// [scout:search] results -> 1 item(s)\n\nstopDebugging();\n```\n\n---\n\n## Types\n\n### `SearchConstraints`\n\nShared search-tuning knobs used by `ScoutIndexOptions`, `CreateSearchOptions`, and all search functions.\n\n```ts\ntype SearchConstraints = {\n limit?: number; // finite non-negative integer; default 50\n minQueryLength?: number; // finite positive integer; default 3\n threshold?: number; // finite 0..1 value; default 0.2\n};\n```\n\n### `FieldDef<T>`\n\n```ts\ntype FieldDef<T> =\n | (keyof T & string)\n | {\n field: keyof T & string;\n weight?: number; // default 1\n stringify?: (value: unknown) => string;\n };\n```\n\n### `ScoutIndexOptions<T>`\n\n```ts\ntype ScoutIndexOptions<T> = SearchConstraints & {\n fields: ReadonlyArray<FieldDef<T>>;\n};\n```\n\n### `CreateSearchOptions`\n\n```ts\ntype CreateSearchOptions = SearchConstraints & {\n debounce?: number; // finite non-negative integer; default 200\n};\n```\n\n### `SearchResult<T>`\n\n```ts\ntype SearchResult<T> = {\n item: T;\n matches: FieldMatch<keyof T & string>[]; // literal normalized-token ranges; may be empty for fuzzy-only results\n score: number; // [0, 1]; 1 when query is empty\n};\n```\n\n### `FieldMatch<F>`\n\nGeneric over the union of field names — `match.field` is typed to the actual fields of `T`.\n\n```ts\ntype FieldMatch<F extends string = string> = {\n field: F;\n ranges: [number, number][]; // literal normalized-token [start, end] ranges in original field value\n};\n```\n\n### `HighlightPart`\n\n```ts\ntype HighlightPart = {\n highlighted: boolean;\n text: string;\n};\n```\n\n### `SearchState<T>`\n\n```ts\ntype SearchState<T> = {\n readonly query: Signal<string>;\n readonly results: Readable<SearchResult<T>[]>;\n readonly isSearching: Readable<boolean>;\n readonly disposalSignal: AbortSignal;\n readonly disposed: boolean;\n clear(): void;\n dispose(): void;\n [Symbol.dispose](): void;\n};\n```\n\nSee `createSearch()` above for member descriptions.\n\n### `ReactiveSearch<T>`\n\n```ts\ntype ReactiveSearch<T> = SearchState<T> & {\n readonly index: ScoutIndex<T>;\n};\n```\n\nSee `createReactiveSearch()` above.\n\n---\n\n## Errors\n\n### `ScoutError`\n\nBase class for all scout errors. Use `instanceof ScoutError` to catch any scout-originated error.\n\n```ts\nclass ScoutError extends Error {}\n```\n\n**Named subclasses**\n\n| Class | Thrown when |\n| ------------------- | ---------------------------------------------------------------------- |\n| `ScoutConfigurationError` | An index, search, or reactive search receives invalid fields or numeric options |\n| `ScoutDisposedError` | A method is called on a disposed `SearchState` instance |\n",
6
- "usage": "---\ntitle: Scout — Usage Guide\ndescription: How-to guide for @vielzeug/scout — building indexes, reactive search, highlighting, and integrating with sourcerer and vault.\n---\n\n[[toc]]\n\n## Basic Usage\n\n### Building an index\n\nPass your item array and field configuration to `createIndex`. All items are indexed immediately at construction time.\n\n```ts\nimport { createIndex } from '@vielzeug/scout';\n\nconst users = [\n { email: 'ada@example.com', name: 'Ada Lovelace' },\n { email: 'grace@example.com', name: 'Grace Hopper' },\n];\n\nconst index = createIndex(users, {\n fields: ['name', 'email'],\n});\n```\n\n### Searching\n\nCall `index.search(query)` with any string. Results are sorted by score descending.\n\n```ts\nconst results = index.search('alice');\n\nfor (const { item, score, matches } of results) {\n console.log(item.name, score);\n}\n```\n\nAn empty `query` returns all items with `score = 1`:\n\n```ts\nindex.search(''); // All items, score = 1 each\n```\n\n### Per-field weights\n\nGive fields different weights to control score ranking. A match on a high-weight field ranks the item higher than a match on a low-weight field.\n\n```ts\nconst index = createIndex(users, {\n fields: [\n { field: 'name', weight: 3 }, // name matches rank 3× higher\n { field: 'department', weight: 1 },\n { field: 'bio', weight: 0.5 },\n ],\n});\n```\n\n### Non-string fields\n\nUse `stringify` to convert numeric or boolean fields to searchable text.\n\n```ts\nconst index = createIndex(products, {\n fields: [\n 'title',\n { field: 'price', stringify: (v) => `$${v}` },\n { field: 'inStock', stringify: (v) => (v ? 'available in stock' : 'out of stock') },\n ],\n});\n```\n\n### Non-Latin scripts (CJK, Thai, ...)\n\n`tokenize()` indexes any script correctly — trigrams are generated per-character, so Chinese, Japanese, Cyrillic, and accented Latin text are all searchable out of the box. What it doesn't do is insert word boundaries for scripts that don't use spaces (Chinese, Japanese, Thai, ...), which affects `findMatchRanges()` / highlighting and multi-word query semantics. Pre-segment those fields with `segmentWords()`:\n\n```ts\nimport { createIndex, segmentWords } from '@vielzeug/scout';\n\nconst docs = [{ title: '日本語を勉強しています' }, { title: '我喜欢学习中文' }];\n\nconst index = createIndex(docs, {\n fields: [{ field: 'title', stringify: (v) => segmentWords(String(v)) }],\n});\n\nindex.search('日本語'); // matches the first document\n```\n\n`segmentWords()` uses the runtime's native `Intl.Segmenter` — no dependency. It's opt-in per field rather than built into `tokenize()` because it benchmarks ~15x slower than the default regex path for ordinary whitespace-delimited text.\n\n### Limiting results\n\nPass `limit`, `threshold`, and `minQueryLength` in options to control result count and quality. `limit` must be a finite non-negative integer, `threshold` a finite value in `0..1`, and `minQueryLength` a finite positive integer; invalid values throw `ScoutConfigurationError`.\n\n```ts\n// At most 10 results, minimum overlap score 0.3\nconst results = index.search('widget', { limit: 10, threshold: 0.3 });\n```\n\nPer-call options override the index-level defaults set in `createIndex`.\n\nScores come from the overlap (Szymkiewicz–Simpson) coefficient — the fraction of the *shorter*\ntrigram set (almost always the query) found in the longer one. This is deliberate for the\nautocomplete/command-palette use case `createIndex` targets: a short query that's a clean prefix\nof a much longer field value (e.g. `'fin'` against `'Finalize Q3 budget report'`) scores on how\nmuch of the query matched, not diluted by how much longer the target field happens to be.\n\n### Controlling short-query behaviour\n\nQueries shorter than `minQueryLength` (default `3`) fall back to an O(n) substring containment scan. Short-query matches return `score = 1.0`.\n\n```ts\n// Use trigram scoring even for 1-char queries (good for small corpora)\nconst index = createIndex(items, { fields: ['name'], minQueryLength: 1 });\n\n// Force containment scan for all queries up to 8 chars (good for autocomplete on large sets)\nconst results = index.search('alice', { minQueryLength: 8 });\n```\n\n## Reactive Search\n\n### `createReactiveSearch()` — recommended\n\nFor most use cases, `createReactiveSearch` builds the index and reactive state together in one call. It returns a `ReactiveSearch<T>` — a `SearchState<T>` with an extra `.index` property for incremental mutations:\n\n```ts\nimport { createReactiveSearch } from '@vielzeug/scout';\nimport { effect } from '@vielzeug/ripple';\n\nconst search = createReactiveSearch(users, {\n fields: [{ field: 'name', weight: 2 }, 'email'],\n debounce: 150,\n});\n\neffect(() => {\n if (search.isSearching.value) showLoadingSpinner();\n else renderResults(search.results.value.map(r => r.item));\n});\n\ninput.addEventListener('input', e => {\n search.query.value = e.currentTarget.value;\n});\n\n// Add items at runtime via the exposed index\nsearch.index.add(newUser);\n\n// Dispose when this owner is no longer needed\nsearch.dispose();\n```\n\n### `createSearch()` — separate index and state\n\nUse `createSearch` when you need to create the index independently — for example when sharing it across multiple reactive states:\n\n```ts\nimport { createIndex, createSearch } from '@vielzeug/scout';\n\nconst index = createIndex(users, { fields: ['name', 'email'] });\nconst search = createSearch(index, { debounce: 150 });\n```\n\n### `using` declaration\n\n```ts\n{\n using search = createReactiveSearch(users, { fields: ['name'] });\n // search.dispose() called automatically at scope exit\n}\n```\n\n### Zero debounce for synchronous updates\n\nPass `debounce: 0` if you want results updated synchronously (no `isSearching` flash). Other debounce values must be finite non-negative integers; invalid values throw `ScoutConfigurationError`.\n\n```ts\nconst search = createReactiveSearch(users, { fields: ['name'], debounce: 0 });\n\nsearch.query.value = 'alice';\nconsole.log(search.results.value); // Already updated\n```\n\n### Resetting search\n\n```ts\nsearch.clear(); // Resets query + results + isSearching synchronously\n```\n\n### Composing with ripple signals\n\n`search.results` is a `Readable` signal — compose it into other computed values:\n\n```ts\nimport { computed } from '@vielzeug/ripple';\n\nconst topResult = computed(() => search.results.value[0]?.item ?? null);\n```\n\n## Incremental Updates\n\nUse `add()`, `remove()`, and `reindex()` for individual reference-based mutations. Use `setItems()` when a refreshed collection replaces the current corpus; Scout reconciles membership, current field values, and source order in one notification.\n\n```ts\nconst index = createIndex(products, { fields: ['title'] });\n\n// Add a newly created item\nconst newProduct = { id: 99, title: 'New Widget' };\nindex.add(newProduct);\n\n// Remove a deleted item (by reference)\nindex.remove(products[0]);\n\n// Re-index a mutated item after in-place mutation\nproducts[1].title = 'Updated Title';\nindex.reindex(products[1]);\n```\n\n> `remove()`, `reindex()`, and `setItems()` use **reference equality** (`===`). Pass retained object references from the current corpus; `setItems()` collapses duplicate references.\n\n### Replacing a refreshed corpus\n\n```ts\nconst latestProducts = await loadProducts();\n\nindex.setItems(latestProducts);\n```\n\n`setItems()` removes references absent from `latestProducts`, adds new references, reindexes retained references, and adopts the incoming order. It calls `onMutate()` once only when index membership, field values, or order changes.\n\n### Inspecting the corpus\n\nUse `.items` to read all currently indexed items in insertion order, or `.size` for a count:\n\n```ts\nconsole.log(index.size); // 42\nconsole.log(index.items); // [{ id: 1, title: ... }, ...]\n```\n\n### Reacting to mutations directly\n\n`createSearch()` already keeps `results` in sync with `add()`/`remove()`/`reindex()`/`setItems()` internally. `toSearchMatcher()` also invalidates its query cache after index mutation. If you're building your own reactivity on top of a plain `ScoutIndex` (no `ripple` involved), subscribe with `onMutate()`:\n\n```ts\nconst unsubscribe = index.onMutate(() => {\n rerenderResultsList();\n});\n\nindex.add(newProduct); // triggers rerenderResultsList()\n\nunsubscribe(); // when done\n```\n\n`onMutate()` only fires for mutations that actually change the index — a duplicate `add()` or a `remove()` of an unindexed item is a no-op and doesn't notify listeners.\n\n## Match Highlighting\n\nEvery `SearchResult` carries `matches` — per-field literal normalized-token ranges. A fuzzy trigram candidate can have `matches: []` when no literal query token appears in its field text.\n\n### `highlightField()` — recommended\n\n`highlightField(result, field, text)` is the shorthand that does the field lookup and fragment split in one step:\n\n```ts\nimport { highlightField } from '@vielzeug/scout';\n\nfor (const result of index.search('alice')) {\n const parts = highlightField(result, 'name', result.item.name);\n // [{ text: 'Alice', highlighted: true }, { text: ' Johnson', highlighted: false }]\n renderHighlightedText(parts);\n}\n```\n\n::: warning `part.text` is unescaped\n`highlight()` / `highlightField()` return the **original, unescaped** field text split into\nfragments — never concatenate `part.text` into an HTML string for `innerHTML`. Render each\npart as text (`textContent`, a framework's text binding) and wrap `highlighted` parts in your\nown element:\n\n```ts\nfunction renderHighlightedText(parts: HighlightPart[]): DocumentFragment {\n const fragment = document.createDocumentFragment();\n\n for (const part of parts) {\n if (part.highlighted) {\n const mark = document.createElement('mark');\n\n mark.textContent = part.text; // textContent — never innerHTML\n fragment.appendChild(mark);\n } else {\n fragment.appendChild(document.createTextNode(part.text));\n }\n }\n\n return fragment;\n}\n```\n\n:::\n\n### `findMatchRanges()` + `highlight()` — manual\n\nUse `findMatchRanges()` when you need to apply match ranges to a different string than the indexed field value — for example a truncated preview or a differently formatted display string:\n\n```ts\nimport { findMatchRanges, highlight } from '@vielzeug/scout';\n\nconst [result] = index.search('alice');\nconst preview = result.item.bio.slice(0, 100);\nconst ranges = findMatchRanges(preview, 'alice');\nconst parts = highlight(preview, ranges);\n```\n\nOr use `highlight()` directly when you already have the ranges from `result.matches`:\n\n```ts\nconst [result] = index.search('alice');\nconst nameMatch = result.matches.find(m => m.field === 'name');\nconst parts = highlight(result.item.name, nameMatch?.ranges ?? []);\n```\n\n## Debug Logging\n\nImport `debugSearch` from the dedicated `/devtools` sub-path to log a `SearchState`'s `query` → `isSearching` → `results` transitions to `console.debug`. The sub-path is tree-shaken from production bundles when not imported.\n\n::: warning Development only\n`debugSearch()` logs the full, literal search query string — if your queries may carry PII (names, emails, medical/financial terms typed by end users), don't enable this in production.\n:::\n\n```ts\nimport { debugSearch } from '@vielzeug/scout/devtools';\n\nconst search = createSearch(index, { debounce: 150 });\nconst stopDebugging = debugSearch(search);\n\nsearch.query.value = 'alice';\n// [scout:search] query -> \"alice\"\n// [scout:search] isSearching -> true\n// [scout:search] isSearching -> false\n// [scout:search] results -> 1 item(s)\n\nstopDebugging();\n```\n\n## Framework Integration\n\n::: code-group\n\n```tsx [React]\nimport { createReactiveSearch } from '@vielzeug/scout';\nimport { useEffect, useRef, useSyncExternalStore } from 'react';\n\ntype User = { id: number; name: string; email: string };\n\nfunction useScoutSearch(items: User[]) {\n const ref = useRef(\n createReactiveSearch(items, {\n fields: [{ field: 'name', weight: 2 }, 'email'],\n debounce: 150,\n }),\n );\n\n const search = ref.current;\n\n const results = useSyncExternalStore(\n (cb) => search.results.subscribe(cb),\n () => search.results.value,\n );\n\n useEffect(() => () => search.dispose(), [search]);\n\n return { query: search.query, results };\n}\n```\n\n```ts [Vue 3]\nimport { createReactiveSearch } from '@vielzeug/scout';\nimport { onScopeDispose, ref, watch } from 'vue';\n\ntype User = { id: number; name: string; email: string };\n\nfunction useScoutSearch(items: User[]) {\n const search = createReactiveSearch(items, {\n fields: [{ field: 'name', weight: 2 }, 'email'],\n debounce: 150,\n });\n\n const query = ref('');\n const results = ref(search.results.value);\n\n const unsub = search.results.subscribe(() => {\n results.value = search.results.value;\n });\n\n watch(query, (q) => { search.query.value = q; });\n\n onScopeDispose(() => { unsub(); search.dispose(); });\n\n return { query, results };\n}\n```\n\n```svelte [Svelte]\n<script lang=\"ts\">\n import { createReactiveSearch } from '@vielzeug/scout';\n import { onDestroy } from 'svelte';\n\n type User = { id: number; name: string; email: string };\n\n export let items: User[];\n\n const search = createReactiveSearch(items, {\n fields: [{ field: 'name', weight: 2 }, 'email'],\n debounce: 150,\n });\n\n let query = '';\n let results = search.results.value;\n\n const unsub = search.results.subscribe(() => {\n results = search.results.value;\n });\n\n $: search.query.value = query;\n\n onDestroy(() => { unsub(); search.dispose(); });\n</script>\n\n<input bind:value={query} placeholder=\"Search…\" />\n{#each results as { item }}\n <p>{item.name}</p>\n{/each}\n```\n\n:::\n\n## Working with Other Vielzeug Libraries\n\n### With Sourcerer\n\n`toSearchMatcher()` adapts a `ScoutIndex` to `createLocalSource`'s explicit `match` callback. Scout decides which items match; Sourcerer keeps source query and pagination.\n\n```ts\nimport { createIndex, toSearchMatcher } from '@vielzeug/scout';\nimport { createLocalSource } from '@vielzeug/sourcerer';\n\nconst index = createIndex(users, {\n fields: [{ field: 'name', weight: 2 }, 'email'],\n});\n\nconst source = createLocalSource(users, {\n match: toSearchMatcher(index),\n});\n\nsource.setQuery({ search: 'alice' });\n```\n\n> Keep the index in sync using `index.add()` / `index.remove()` / `index.reindex()`.\n\n### With Vault\n\n`toFilterPredicate()` returns an `(item: T) => boolean` snapshot predicate — pass it to vault's `query.filter()` or plain `Array.filter`.\n\n```ts\nimport { createIndex, toFilterPredicate } from '@vielzeug/scout';\n\nconst index = createIndex(products, { fields: ['title', 'sku'] });\n\nconst matching = products.filter(toFilterPredicate(index, 'widget'));\n\nconst rows = await db.query('products')\n .filter(toFilterPredicate(index, searchTerm))\n .toArray();\n```\n\nCall `toFilterPredicate` again whenever the query or corpus changes — the predicate is a snapshot, not reactive.\n\n## Best Practices\n\n- **Build the index once** — `createIndex()` runs in O(corpus × field_length). Create it at module level or in an effect, not inside render loops.\n- **Keep the index in sync** — call `index.add()` / `remove()` / `reindex()` when items mutate. Stale index entries return wrong scores.\n- **Tune threshold before limit** — set a meaningful `threshold` (e.g. `0.25–0.4`) to suppress noise, then use `limit` to cap the list length.\n- **Set `minQueryLength` for your corpus size** — the default `3` works well for most cases. Lower it for small corpora where single-char queries are expected; raise it for large corpora to avoid expensive O(n) scans.\n- **Dispose reactive state** — always call `search.dispose()` or use `using` when the component unmounts.\n- **Weight by importance** — name/title fields should have weight `2–3`; secondary fields (description, tags) stay at `1`.\n- **Segment CJK/Thai fields explicitly** — `segmentWords()` is opt-in per field, not automatic, to keep `createIndex()` fast for the common whitespace-delimited case.\n",
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 ScoutEvent,\n findMatchRanges,\n highlight,\n highlightField,\n segmentWords,\n toFilterPredicate,\n toSearchMatcher,\n ]\nrelated: [arsenal, sourcerer, vault, ripple]\nenvironments: [browser, node, ssr, deno]\n---\n\n<!-- markdownlint-disable MD025 MD033 MD060 -->\n\n<PackageHero package=\"scout\" />\n\n## Why Scout?\n\nArsenal's `fuzzy` / `fuzzyFilter` helpers perform pairwise Levenshtein distance — O(n·m) per item per query. For ≤200 items they are fine. For 500–100k items with real-time keystrokes, you need an index.\n\nScout builds a **trigram inverted index** at construction time. Query time scores only items sharing a trigram with the query; broad queries can still approach O(n), while selective queries avoid scoring the whole corpus.\n\n```ts\n// Before\nconst matches = users.filter((user) => user.name.toLowerCase().includes(query.toLowerCase()));\n\n// After\nimport { createIndex } from '@vielzeug/scout';\n\nconst index = createIndex(users, { fields: ['name', 'email'] });\nconst matches = index.search(query);\n```\n\n| Feature | Arsenal `fuzzy*` | Scout `createIndex` | Fuse.js |\n| ------------------------ | ---------------------------------------------- | ----------------------------------------------------------------------------------------- | ---------------------------------------------- |\n| Bundle size | ~3 KB | <PackageInfo package=\"scout\" type=\"size\" /> | ~23 KB |\n| Zero dependencies | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> `@vielzeug/ripple` runtime dependency | <ore-icon name=\"check\" size=\"16\"></ore-icon> |\n| Algorithm | Levenshtein | Trigram + overlap coefficient | Bitap |\n| Query time | O(n·m) | O(candidates) | O(n·m) |\n| Stateful index | <ore-icon name=\"x\" size=\"16\"></ore-icon> | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"check\" size=\"16\"></ore-icon> |\n| Match highlighting | <ore-icon name=\"x\" size=\"16\"></ore-icon> | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"check\" size=\"16\"></ore-icon> |\n| Reactive layer | <ore-icon name=\"x\" size=\"16\"></ore-icon> | ripple signals + debounce | <ore-icon name=\"x\" size=\"16\"></ore-icon> |\n| Incremental updates | <ore-icon name=\"x\" size=\"16\"></ore-icon> | <ore-icon name=\"check\" size=\"16\"></ore-icon> | Partial |\n\n<div class=\"decision-callout\">\n\n**Use Scout when** you need search over 500+ items, real-time UI search boxes (combobox, command palette), or reactive query state with ripple signals.\n\n**Consider `arsenal.fuzzyFilter` when** you have fewer than 200 items and don't need a persistent index.\n\n</div>\n\n## Installation\n\n::: code-group\n\n```sh [pnpm]\npnpm add @vielzeug/scout\n```\n\n```sh [npm]\nnpm install @vielzeug/scout\n```\n\n```sh [yarn]\nyarn add @vielzeug/scout\n```\n\n:::\n\n## Quick Start\n\n```ts\nimport { createIndex } from '@vielzeug/scout';\n\nconst users = [\n { email: 'ada@example.com', name: 'Ada Lovelace' },\n { email: 'grace@example.com', name: 'Grace Hopper' },\n];\n\nconst index = createIndex(users, {\n fields: [\n { field: 'name', weight: 2 },\n { field: 'email' },\n ],\n});\n\nconst results = index.search('ada');\nconsole.log(results[0]?.item.name); // Ada Lovelace\n```\n\n## Features\n\n<div class=\"features-grid\">\n\n- `createIndex()` — Trigram inverted index; construction O(corpus × field_length), query O(candidates)\n- Per-field weights — Promote `name` matches over secondary fields; finite positive weights and custom `stringify` functions supported\n- `createReactiveSearch()` — Index + reactive `SearchState` in one call; `.index` for incremental mutations\n- `createSearch()` — Reactive search state backed by an existing `ScoutIndex`; share one index across many states\n- `highlight()` / `highlightField()` — Split field text into `HighlightPart[]` fragments for styled rendering\n- `findMatchRanges()` — Compute match ranges for custom display strings (truncated previews, formatted values)\n- `toSearchMatcher()` — Matcher adapter for sourcerer's `LocalSource`\n- `toFilterPredicate()` — Snapshot `(item: T) => boolean` predicate for `Array.filter` or vault queries\n- `setItems()` — Reconcile a refreshed corpus by reference, preserve incoming order, and notify once\n- Incremental updates — `add()` / `remove()` / `reindex()` patch individual items in O(field_length)\n- `onMutate()` — Subscribe to index mutations; powers `createSearch()`'s reactivity and bulk reconciliation\n- `segmentWords()` — Split unsegmented-script text (CJK, Thai, ...) into words via native `Intl.Segmenter`\n- Event subscription via `search.tap()` — observe `query`/`isSearching`/`results`/`dispose` transitions; returns an unsubscribe function\n\n</div>\n\n## Documentation\n\n<div class=\"doc-links\">\n\n- [Usage Guide](./usage.md)\n- [API Reference](./api.md)\n- [Examples](./examples.md)\n- [Migration Guide](./migration.md)\n\n</div>\n\n## See Also\n\n<div class=\"see-also\">\n\n- [Arsenal](/arsenal/) — Use `fuzzyFilter` for ad-hoc filtering of small lists (< 200 items) without building an index\n- [Ripple](/ripple/) — `createReactiveSearch()` and `createSearch()` use Ripple signals for reactive query state and debounce\n- [Sourcerer](/sourcerer/) — use a `ScoutIndex` inside `createLocalSource`'s explicit `match` callback\n- [Vault](/vault/) — `toFilterPredicate()` wraps a one-time Scout query as a vault-compatible `filter()` predicate\n\n</div>\n\n<!-- markdownlint-enable MD025 MD033 MD060 -->\n",
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()` | Reactive search state backed by a `ScoutIndex` | Sync | Requires `@vielzeug/ripple` — dispose when done |\n| `createReactiveSearch()` | One-call index + reactive search state | Sync | Exposes `.index` for incremental mutations |\n| `findMatchRanges()` | Compute match ranges for a text + query pair | Sync | Returns sorted, non-overlapping `[start, end]` ranges |\n| `highlight()` | Split text into highlighted/unhighlighted fragments | Sync | Ranges must be sorted and non-overlapping |\n| `highlightField()` | Highlight a named field from a `SearchResult` | Sync | Shorthand for the `matches.find(…).ranges → highlight()` pattern |\n| `toSearchMatcher()` | Adapt `ScoutIndex` to Sourcerer's `match` callback | Sync | Recomputes cached query matches after index mutation |\n| `toFilterPredicate()` | Snapshot predicate from a one-time query | Sync | Re-call when query or corpus changes |\n| `segmentWords()` | Split unsegmented-script text (CJK, Thai, ...) into words | Sync | Uses native `Intl.Segmenter` — not applied inside `tokenize()` itself (see Pitfalls) |\n| `SearchState.tap()` | Subscribe to `query`/`isSearching`/`results`/`dispose` events | Sync | Returns an unsubscribe function; pass `{ signal }` to tie to an external lifecycle |\n\n## Package Entry Point\n\n| Import | Purpose |\n| --- | --- |\n| `@vielzeug/scout` | All exports — index/search/highlighting/adapters, `ScoutConfigurationError`, `ScoutDisposedError`, `ScoutError`, `ScoutEvent`, and all types |\n\n---\n\n## `createIndex(items, options)`\n\nBuilds a trigram inverted index from `items`. Construction is O(corpus × field_length); subsequent `search()` calls are O(candidates).\n\n```ts\nfunction createIndex<T>(items: T[], options: ScoutIndexOptions<T>): ScoutIndex<T>\n```\n\n**Parameters**\n\n| Param | Type | Description |\n| --- | --- | --- |\n| `items` | `T[]` | Initial corpus to index. |\n| `options.fields` | `ReadonlyArray<FieldDef<T>>` | Fields to index. Required; at least one entry. |\n| `options.threshold` | `number` | Finite overlap score in `0..1` (default `0.2`). |\n| `options.limit` | `number` | Finite non-negative integer max results (default `50`). |\n| `options.minQueryLength` | `number` | Finite positive integer min chars before trigram scoring; shorter queries use O(n) containment scan (default `3`). |\n\n**Example**\n\n```ts\nimport { createIndex } from '@vielzeug/scout';\n\nconst products = [\n { sku: 'WGT-001', title: 'Widget Pro' },\n { sku: 'GAD-002', title: 'Gadget Plus' },\n];\n\nconst index = createIndex(products, {\n fields: [\n { field: 'title', weight: 2 },\n { field: 'sku' },\n ],\n threshold: 0.25,\n limit: 20,\n});\n```\n\n---\n\n## `ScoutIndex<T>`\n\nReturned by `createIndex()`.\n\n### `.search(query, options?)`\n\n```ts\nsearch(query: string, options?: SearchConstraints): SearchResult<T>[]\n```\n\nReturns results sorted by score descending. Empty query returns all items with `score = 1`. Results below `threshold` are excluded; at most `limit` results are returned.\n\n```ts\nconst results = index.search('alice');\n// [{ item, score, matches }]\n```\n\n### `.add(item)`\n\nAdds `item` to the index. No-op if the same reference is already indexed. O(field_length).\n\n### `.remove(item)`\n\nRemoves `item` by reference equality. No-op if not found. O(field_length).\n\n### `.reindex(item)`\n\nRe-reads the item's current field values and rebuilds its index entry in-place, updating only fields whose values changed. Preserves insertion order. No-op if the item is not in the index.\n\n```ts\nitem.name = 'new name';\nindex.reindex(item);\n```\n\n### `.setItems(items)`\n\n```ts\nsetItems(items: readonly T[]): void\n```\n\nReconciles the index to a refreshed corpus in one mutation. Existing references are reindexed, missing references are removed, added references are indexed, and incoming first-occurrence order becomes index order. Duplicate references collapse to one item. Calls `onMutate()` once when indexed values, membership, or order changes.\n\n```ts\nindex.setItems(latestUsers);\n```\n\n### `.size`\n\n`number` — current number of indexed items.\n\n### `.items`\n\n`readonly T[]` — all indexed items in insertion order. Returns a new array snapshot each call.\n\n```ts\nconst all = index.items;\n```\n\n### `.onMutate(listener)`\n\n```ts\nonMutate(listener: () => void): () => void\n```\n\nSubscribes `listener` to run after every changed `add()` / `remove()` / `reindex()` / `setItems()` operation. No-ops, including unchanged bulk reconciliation, do not fire it. A changed `setItems()` reconciliation fires once. `createSearch()` uses this internally to keep `results` in sync with index mutations; most callers building on `createIndex()` directly will not need it.\n\n```ts\nconst unsubscribe = index.onMutate(() => {\n console.log(`Index changed — now ${index.size} items`);\n});\n\nindex.add(newUser); // logs \"Index changed — now 6 items\"\nunsubscribe();\n```\n\n### `.revision`\n\n`number` — monotonically increasing counter, incremented after every changed `add()` / `remove()` / `reindex()` / `setItems()` operation. Use as a cache-busting token when caching search results outside the index — `toSearchMatcher()` uses it for this purpose.\n\n---\n\n## `createSearch(index, options?)`\n\nWraps a `ScoutIndex` in a reactive search state powered by `@vielzeug/ripple` signals.\n\n```ts\nfunction createSearch<T>(index: ScoutIndex<T>, options?: CreateSearchOptions): SearchState<T>\n```\n\n**Parameters**\n\n| Param | Type | Description |\n| --- | --- | --- |\n| `options.debounce` | `number` | Finite non-negative integer milliseconds before query commit (default `200`). Pass `0` for immediate updates. |\n| `options.limit` | `number` | Finite non-negative integer override of index-level limit. |\n| `options.threshold` | `number` | Finite `0..1` override of index-level threshold. |\n| `options.minQueryLength` | `number` | Finite positive integer override of index-level minimum query length. |\n\n**Returns `SearchState<T>`**\n\n| Member | Type | Description |\n| --- | --- | --- |\n| `query` | `Signal<string>` | Writable search query. Set `.value` to trigger search. |\n| `results` | `Readable<SearchResult<T>[]>` | Reactive results, updated after debounce. |\n| `isSearching` | `Readable<boolean>` | `true` during the debounce window. |\n| `disposalSignal` | `AbortSignal` | Aborted when `dispose()` is called. Use to tie other lifecycles to this search. |\n| `disposed` | `boolean` | `true` after `dispose()` has been called. |\n| `clear()` | `() => void` | Resets query, cancels debounce, clears results synchronously. |\n| `dispose()` | `() => void` | Releases all reactive subscriptions. |\n| `tap()` | `(handler, options?) => () => void` | Subscribe to `ScoutEvent` transitions; returns an unsubscribe function. |\n| `[Symbol.dispose]()` | `() => void` | `using`-compatible disposal. |\n\n**Example**\n\n```ts\nimport { createIndex, createSearch } from '@vielzeug/scout';\nimport { effect } from '@vielzeug/ripple';\n\nconst users = [{ name: 'Ada Lovelace' }, { name: 'Grace Hopper' }];\nconst index = createIndex(users, { fields: ['name'] });\nconst search = createSearch(index, { debounce: 150 });\n\neffect(() => {\n console.log(search.results.value.map((result) => result.item.name));\n});\n\nsearch.query.value = 'ada';\n```\n\n---\n\n## `createReactiveSearch(items, options)`\n\nCreates a `ScoutIndex` and a reactive `SearchState` in one call — the shorthand for `createIndex` + `createSearch`. Returns a `ReactiveSearch<T>` which extends `SearchState<T>` with a `.index` property for incremental mutations.\n\n```ts\nfunction createReactiveSearch<T>(\n items: T[],\n options: ScoutIndexOptions<T> & { debounce?: number },\n): ReactiveSearch<T>\n```\n\n**Parameters**\n\n| Param | Type | Description |\n| --- | --- | --- |\n| `items` | `T[]` | Initial corpus to index. |\n| `options.fields` | `ReadonlyArray<FieldDef<T>>` | Fields to index. Required. |\n| `options.debounce` | `number` | Finite non-negative integer debounce milliseconds (default `200`). |\n| `options.threshold` | `number` | Finite overlap score in `0..1` (default `0.2`). |\n| `options.limit` | `number` | Finite non-negative integer max results (default `50`). |\n| `options.minQueryLength` | `number` | Finite positive integer min chars before trigram scoring (default `3`). |\n\n**Returns `ReactiveSearch<T>`** — all `SearchState<T>` members plus:\n\n| Member | Type | Description |\n| --- | --- | --- |\n| `index` | `ScoutIndex<T>` | The underlying index for `add`, `remove`, `reindex`. |\n\n**Example**\n\n```ts\nimport { createReactiveSearch } from '@vielzeug/scout';\nimport { effect } from '@vielzeug/ripple';\n\nconst users = [{ email: 'ada@example.com', name: 'Ada Lovelace' }];\nconst search = createReactiveSearch(users, {\n fields: [{ field: 'name', weight: 2 }, 'email'],\n debounce: 150,\n});\n\neffect(() => console.log(search.results.value.map((result) => result.item.name)));\n\nsearch.index.add({ email: 'grace@example.com', name: 'Grace Hopper' });\nsearch.dispose();\n```\n\n---\n\n## `findMatchRanges(text, query)`\n\nNormalizes raw `query` with Scout's tokenizer, then computes sorted, non-overlapping literal ranges for each normalized token within `text`. Useful when you need to apply highlighting to a different string than the indexed field value (e.g. a truncated preview or a differently formatted display string).\n\n```ts\nfunction findMatchRanges(text: string, query: string): [number, number][]\n```\n\n**Example**\n\n```ts\nimport { findMatchRanges, highlight } from '@vielzeug/scout';\n\nconst ranges = findMatchRanges('Alice Johnson', 'alice!');\n// [[0, 5]]\n\nconst parts = highlight('Alice Johnson', ranges);\n// [{ text: 'Alice', highlighted: true }, { text: ' Johnson', highlighted: false }]\n```\n\nReturns an empty array if either `text` or `query` is empty.\n\n---\n\n## `highlight(text, ranges)`\n\nSplits `text` into `HighlightPart[]` fragments based on `ranges` from `FieldMatch.ranges`.\n\n```ts\nfunction highlight(text: string, ranges: [number, number][]): HighlightPart[]\n```\n\n**Example**\n\n```ts\nimport { highlight } from '@vielzeug/scout';\n\nhighlight('Hello World', [[0, 5]]);\n// [{ text: 'Hello', highlighted: true }, { text: ' World', highlighted: false }]\n```\n\nReturns an empty array when `text` is empty. Returns a single unhighlighted part when `ranges` is empty.\n\n---\n\n## `highlightField(result, field, text)`\n\nConvenience shorthand that finds the match ranges for `field` in `result.matches` and calls `highlight()` in one step. Eliminates the manual `result.matches.find(m => m.field === …).ranges` lookup.\n\n```ts\nfunction highlightField<T>(result: SearchResult<T>, field: keyof T & string, text: string): HighlightPart[]\n```\n\n**Example**\n\n```ts\nimport { createIndex, highlightField } from '@vielzeug/scout';\n\nconst users = [{ name: 'Alice Johnson' }];\nconst index = createIndex(users, { fields: ['name'] });\n\nfor (const result of index.search('alice')) {\n const parts = highlightField(result, 'name', result.item.name);\n console.log(parts.map((part) => part.highlighted ? `[${part.text}]` : part.text).join(''));\n}\n```\n\nWhen the field has no match (e.g. the query matched via a different field), returns a single unhighlighted part.\n\n---\n\n## `toSearchMatcher(index, options?)`\n\nReturns an `(item, query) => boolean` matcher compatible with `sourcerer`'s `match` option.\n\n```ts\nfunction toSearchMatcher<T>(index: ScoutIndex<T>, options?: SearchConstraints): (item: T, query: string) => boolean\n```\n\nOne matching-item set is cached per query and index revision, so filtering does not repeat index work per item and stays current after index mutation.\n\n```ts\nimport { createIndex, toSearchMatcher } from '@vielzeug/scout';\nimport { createLocalSource } from '@vielzeug/sourcerer';\n\nconst users = [{ email: 'ada@example.com', name: 'Ada Lovelace' }];\nconst index = createIndex(users, { fields: ['name', 'email'] });\nconst source = createLocalSource(users, { match: toSearchMatcher(index) });\n```\n\n---\n\n## `toFilterPredicate(index, query, options?)`\n\nReturns a `(item: T) => boolean` predicate computed from a one-time query. Use with `Array.filter` or vault's `query.filter()`.\n\n```ts\nfunction toFilterPredicate<T>(\n index: ScoutIndex<T>,\n query: string,\n options?: SearchConstraints,\n): (item: T) => boolean\n```\n\nThe predicate is a snapshot — re-call `toFilterPredicate` if the query or corpus changes.\n\n```ts\nimport { createIndex, toFilterPredicate } from '@vielzeug/scout';\n\nconst products = [{ title: 'Widget Pro' }, { title: 'Gadget Plus' }];\nconst index = createIndex(products, { fields: ['title'] });\nconst results = products.filter(toFilterPredicate(index, 'widget'));\n\nconst top5 = products.filter(toFilterPredicate(index, 'widget', { limit: 5 }));\n```\n\n---\n\n## `segmentWords(text)`\n\nSplits `text` into whitespace-joined word segments using the runtime's native `Intl.Segmenter` — no dependency beyond the platform API. Falls back to returning `text` unchanged where `Intl.Segmenter` isn't available.\n\n```ts\nfunction segmentWords(text: string): string\n```\n\n`tokenize()`'s trigram-based scoring already works on unsegmented scripts (Chinese, Japanese, Thai, ...) without this — trigrams are generated per-character, not per-word. `segmentWords()` is for `findMatchRanges()` / highlighting and the multi-word query semantics on `SearchConstraints`, which assume space-separated words. **Not applied inside `tokenize()` itself** — benchmarked at ~15x slower than the plain regex path for the common whitespace-delimited case, which would regress `createIndex()`'s construction cost for every caller, not just those indexing unsegmented scripts.\n\n**Example**\n\n```ts\nimport { createIndex, segmentWords } from '@vielzeug/scout';\n\nconst documents = [{ title: '日本語を勉強しています' }];\nconst index = createIndex(documents, {\n fields: [{ field: 'title', stringify: (value) => segmentWords(String(value)) }],\n});\n```\n\n---\n\n## `search.tap(handler, options?)`\n\nSubscribes `handler` to `ScoutEvent` transitions emitted by a `SearchState` — `query` changes, `isSearching` transitions, `results` changes, and `dispose`. Returns an unsubscribe function; calling it removes the handler. Pass `{ signal }` to tie the subscription to an external `AbortSignal` — when the signal aborts (or `dispose()` is called, which aborts `disposalSignal`) the handler is removed automatically.\n\n```ts\ntap(\n handler: (event: ScoutEvent<T>) => void,\n options?: { signal?: AbortSignal },\n): () => void\n```\n\n**Example**\n\n```ts\nimport { createIndex, createSearch } from '@vielzeug/scout';\n\nconst index = createIndex([{ name: 'Ada Lovelace' }], { fields: ['name'] });\nconst search = createSearch(index);\n\nconst unsubscribe = search.tap((event) => {\n if (event.type === 'query-change') console.debug('query:', event.query);\n if (event.type === 'results-change') console.debug('results:', event.results.length);\n});\n\nsearch.query.value = 'alice';\n// query: alice\n// results: 1\n\nunsubscribe();\n```\n\n::: warning Development logging\nIf your queries may carry PII (names, emails, medical/financial terms typed by end users), don't log `query-change` events in production.\n:::\n\n---\n\n## Types\n\n### `SearchConstraints`\n\nShared search-tuning knobs used by `ScoutIndexOptions`, `CreateSearchOptions`, and all search functions.\n\n```ts\ntype SearchConstraints = {\n limit?: number; // finite non-negative integer; default 50\n minQueryLength?: number; // finite positive integer; default 3\n threshold?: number; // finite 0..1 value; default 0.2\n};\n```\n\n### `FieldDef<T>`\n\n```ts\ntype FieldDef<T> =\n | (keyof T & string)\n | {\n field: keyof T & string;\n weight?: number; // default 1\n stringify?: (value: unknown) => string;\n };\n```\n\n### `ScoutIndexOptions<T>`\n\n```ts\ntype ScoutIndexOptions<T> = SearchConstraints & {\n fields: ReadonlyArray<FieldDef<T>>;\n};\n```\n\n### `CreateSearchOptions`\n\n```ts\ntype CreateSearchOptions = SearchConstraints & {\n debounce?: number; // finite non-negative integer; default 200\n};\n```\n\n### `SearchResult<T>`\n\n```ts\ntype SearchResult<T> = {\n item: T;\n matches: FieldMatch<keyof T & string>[]; // literal normalized-token ranges; may be empty for fuzzy-only results\n score: number; // [0, 1]; 1 when query is empty\n};\n```\n\n### `FieldMatch<F>`\n\nGeneric over the union of field names — `match.field` is typed to the actual fields of `T`.\n\n```ts\ntype FieldMatch<F extends string = string> = {\n field: F;\n ranges: [number, number][]; // literal normalized-token [start, end] ranges in original field value\n};\n```\n\n### `HighlightPart`\n\n```ts\ntype HighlightPart = {\n highlighted: boolean;\n text: string;\n};\n```\n\n### `SearchState<T>`\n\n```ts\ntype SearchState<T> = {\n readonly query: Signal<string>;\n readonly results: Readable<SearchResult<T>[]>;\n readonly isSearching: Readable<boolean>;\n readonly disposalSignal: AbortSignal;\n readonly disposed: boolean;\n clear(): void;\n dispose(): void;\n tap(handler: (event: ScoutEvent<T>) => void, options?: { signal?: AbortSignal }): () => void;\n [Symbol.dispose](): void;\n};\n```\n\nSee `createSearch()` above for member descriptions.\n\n### `ScoutEvent<T>`\n\nDiscriminated union of events emitted by `SearchState.tap()`. Each variant carries a `type` discriminant; narrow with a `switch` or `if` on `event.type`.\n\n```ts\ntype ScoutEvent<T> =\n | { type: 'query-change'; query: string }\n | { type: 'searching-change'; isSearching: boolean }\n | { type: 'results-change'; results: readonly SearchResult<T>[] }\n | { type: 'dispose' };\n```\n\n| `type` | Payload | Emitted when |\n| --- | --- | --- |\n| `query-change` | `query: string` | The writable `query` signal's value changes. |\n| `searching-change` | `isSearching: boolean` | The debounce window opens (`true`) or closes (`false`). |\n| `results-change` | `results: readonly SearchResult<T>[]` | Committed results change after debounce. |\n| `dispose` | — | `dispose()` is called on the `SearchState`. |\n\n### `ReactiveSearch<T>`\n\n```ts\ntype ReactiveSearch<T> = SearchState<T> & {\n readonly index: ScoutIndex<T>;\n};\n```\n\nSee `createReactiveSearch()` above.\n\n---\n\n## Errors\n\n### `ScoutError`\n\nBase class for all scout errors. Use `instanceof ScoutError` to catch any scout-originated error.\n\n```ts\nclass ScoutError extends Error {}\n```\n\n**Named subclasses**\n\n| Class | Thrown when |\n| ------------------- | ---------------------------------------------------------------------- |\n| `ScoutConfigurationError` | An index, search, or reactive search receives invalid fields or numeric options |\n| `ScoutDisposedError` | A method is called on a disposed `SearchState` instance |\n",
6
+ "usage": "---\ntitle: Scout — Usage Guide\ndescription: How-to guide for @vielzeug/scout — building indexes, reactive search, highlighting, and integrating with sourcerer and vault.\n---\n\n[[toc]]\n\n## Basic Usage\n\n### Building an index\n\nPass your item array and field configuration to `createIndex`. All items are indexed immediately at construction time.\n\n```ts\nimport { createIndex } from '@vielzeug/scout';\n\nconst users = [\n { email: 'ada@example.com', name: 'Ada Lovelace' },\n { email: 'grace@example.com', name: 'Grace Hopper' },\n];\n\nconst index = createIndex(users, {\n fields: ['name', 'email'],\n});\n```\n\n### Searching\n\nCall `index.search(query)` with any string. Results are sorted by score descending.\n\n```ts\nconst results = index.search('alice');\n\nfor (const { item, score, matches } of results) {\n console.log(item.name, score);\n}\n```\n\nAn empty `query` returns all items with `score = 1`:\n\n```ts\nindex.search(''); // All items, score = 1 each\n```\n\n### Per-field weights\n\nGive fields different weights to control score ranking. A match on a high-weight field ranks the item higher than a match on a low-weight field.\n\n```ts\nconst index = createIndex(users, {\n fields: [\n { field: 'name', weight: 3 }, // name matches rank 3× higher\n { field: 'department', weight: 1 },\n { field: 'bio', weight: 0.5 },\n ],\n});\n```\n\n### Non-string fields\n\nUse `stringify` to convert numeric or boolean fields to searchable text.\n\n```ts\nconst index = createIndex(products, {\n fields: [\n 'title',\n { field: 'price', stringify: (v) => `$${v}` },\n { field: 'inStock', stringify: (v) => (v ? 'available in stock' : 'out of stock') },\n ],\n});\n```\n\n### Non-Latin scripts (CJK, Thai, ...)\n\n`tokenize()` indexes any script correctly — trigrams are generated per-character, so Chinese, Japanese, Cyrillic, and accented Latin text are all searchable out of the box. What it doesn't do is insert word boundaries for scripts that don't use spaces (Chinese, Japanese, Thai, ...), which affects `findMatchRanges()` / highlighting and multi-word query semantics. Pre-segment those fields with `segmentWords()`:\n\n```ts\nimport { createIndex, segmentWords } from '@vielzeug/scout';\n\nconst docs = [{ title: '日本語を勉強しています' }, { title: '我喜欢学习中文' }];\n\nconst index = createIndex(docs, {\n fields: [{ field: 'title', stringify: (v) => segmentWords(String(v)) }],\n});\n\nindex.search('日本語'); // matches the first document\n```\n\n`segmentWords()` uses the runtime's native `Intl.Segmenter` — no dependency. It's opt-in per field rather than built into `tokenize()` because it benchmarks ~15x slower than the default regex path for ordinary whitespace-delimited text.\n\n### Limiting results\n\nPass `limit`, `threshold`, and `minQueryLength` in options to control result count and quality. `limit` must be a finite non-negative integer, `threshold` a finite value in `0..1`, and `minQueryLength` a finite positive integer; invalid values throw `ScoutConfigurationError`.\n\n```ts\n// At most 10 results, minimum overlap score 0.3\nconst results = index.search('widget', { limit: 10, threshold: 0.3 });\n```\n\nPer-call options override the index-level defaults set in `createIndex`.\n\nScores come from the overlap (Szymkiewicz–Simpson) coefficient — the fraction of the *shorter*\ntrigram set (almost always the query) found in the longer one. This is deliberate for the\nautocomplete/command-palette use case `createIndex` targets: a short query that's a clean prefix\nof a much longer field value (e.g. `'fin'` against `'Finalize Q3 budget report'`) scores on how\nmuch of the query matched, not diluted by how much longer the target field happens to be.\n\n### Controlling short-query behaviour\n\nQueries shorter than `minQueryLength` (default `3`) fall back to an O(n) substring containment scan. Short-query matches return `score = 1.0`.\n\n```ts\n// Use trigram scoring even for 1-char queries (good for small corpora)\nconst index = createIndex(items, { fields: ['name'], minQueryLength: 1 });\n\n// Force containment scan for all queries up to 8 chars (good for autocomplete on large sets)\nconst results = index.search('alice', { minQueryLength: 8 });\n```\n\n## Reactive Search\n\n### `createReactiveSearch()` — recommended\n\nFor most use cases, `createReactiveSearch` builds the index and reactive state together in one call. It returns a `ReactiveSearch<T>` — a `SearchState<T>` with an extra `.index` property for incremental mutations:\n\n```ts\nimport { createReactiveSearch } from '@vielzeug/scout';\nimport { effect } from '@vielzeug/ripple';\n\nconst search = createReactiveSearch(users, {\n fields: [{ field: 'name', weight: 2 }, 'email'],\n debounce: 150,\n});\n\neffect(() => {\n if (search.isSearching.value) showLoadingSpinner();\n else renderResults(search.results.value.map(r => r.item));\n});\n\ninput.addEventListener('input', e => {\n search.query.value = e.currentTarget.value;\n});\n\n// Add items at runtime via the exposed index\nsearch.index.add(newUser);\n\n// Dispose when this owner is no longer needed\nsearch.dispose();\n```\n\n### `createSearch()` — separate index and state\n\nUse `createSearch` when you need to create the index independently — for example when sharing it across multiple reactive states:\n\n```ts\nimport { createIndex, createSearch } from '@vielzeug/scout';\n\nconst index = createIndex(users, { fields: ['name', 'email'] });\nconst search = createSearch(index, { debounce: 150 });\n```\n\n### `using` declaration\n\n```ts\n{\n using search = createReactiveSearch(users, { fields: ['name'] });\n // search.dispose() called automatically at scope exit\n}\n```\n\n### Zero debounce for synchronous updates\n\nPass `debounce: 0` if you want results updated synchronously (no `isSearching` flash). Other debounce values must be finite non-negative integers; invalid values throw `ScoutConfigurationError`.\n\n```ts\nconst search = createReactiveSearch(users, { fields: ['name'], debounce: 0 });\n\nsearch.query.value = 'alice';\nconsole.log(search.results.value); // Already updated\n```\n\n### Resetting search\n\n```ts\nsearch.clear(); // Resets query + results + isSearching synchronously\n```\n\n### Composing with ripple signals\n\n`search.results` is a `Readable` signal — compose it into other computed values:\n\n```ts\nimport { computed } from '@vielzeug/ripple';\n\nconst topResult = computed(() => search.results.value[0]?.item ?? null);\n```\n\n## Incremental Updates\n\nUse `add()`, `remove()`, and `reindex()` for individual reference-based mutations. Use `setItems()` when a refreshed collection replaces the current corpus; Scout reconciles membership, current field values, and source order in one notification.\n\n```ts\nconst index = createIndex(products, { fields: ['title'] });\n\n// Add a newly created item\nconst newProduct = { id: 99, title: 'New Widget' };\nindex.add(newProduct);\n\n// Remove a deleted item (by reference)\nindex.remove(products[0]);\n\n// Re-index a mutated item after in-place mutation\nproducts[1].title = 'Updated Title';\nindex.reindex(products[1]);\n```\n\n> `remove()`, `reindex()`, and `setItems()` use **reference equality** (`===`). Pass retained object references from the current corpus; `setItems()` collapses duplicate references.\n\n### Replacing a refreshed corpus\n\n```ts\nconst latestProducts = await loadProducts();\n\nindex.setItems(latestProducts);\n```\n\n`setItems()` removes references absent from `latestProducts`, adds new references, reindexes retained references, and adopts the incoming order. It calls `onMutate()` once only when index membership, field values, or order changes.\n\n### Inspecting the corpus\n\nUse `.items` to read all currently indexed items in insertion order, or `.size` for a count:\n\n```ts\nconsole.log(index.size); // 42\nconsole.log(index.items); // [{ id: 1, title: ... }, ...]\n```\n\n### Reacting to mutations directly\n\n`createSearch()` already keeps `results` in sync with `add()`/`remove()`/`reindex()`/`setItems()` internally. `toSearchMatcher()` also invalidates its query cache after index mutation. If you're building your own reactivity on top of a plain `ScoutIndex` (no `ripple` involved), subscribe with `onMutate()`:\n\n```ts\nconst unsubscribe = index.onMutate(() => {\n rerenderResultsList();\n});\n\nindex.add(newProduct); // triggers rerenderResultsList()\n\nunsubscribe(); // when done\n```\n\n`onMutate()` only fires for mutations that actually change the index — a duplicate `add()` or a `remove()` of an unindexed item is a no-op and doesn't notify listeners.\n\n## Match Highlighting\n\nEvery `SearchResult` carries `matches` — per-field literal normalized-token ranges. A fuzzy trigram candidate can have `matches: []` when no literal query token appears in its field text.\n\n### `highlightField()` — recommended\n\n`highlightField(result, field, text)` is the shorthand that does the field lookup and fragment split in one step:\n\n```ts\nimport { highlightField } from '@vielzeug/scout';\n\nfor (const result of index.search('alice')) {\n const parts = highlightField(result, 'name', result.item.name);\n // [{ text: 'Alice', highlighted: true }, { text: ' Johnson', highlighted: false }]\n renderHighlightedText(parts);\n}\n```\n\n::: warning `part.text` is unescaped\n`highlight()` / `highlightField()` return the **original, unescaped** field text split into\nfragments — never concatenate `part.text` into an HTML string for `innerHTML`. Render each\npart as text (`textContent`, a framework's text binding) and wrap `highlighted` parts in your\nown element:\n\n```ts\nfunction renderHighlightedText(parts: HighlightPart[]): DocumentFragment {\n const fragment = document.createDocumentFragment();\n\n for (const part of parts) {\n if (part.highlighted) {\n const mark = document.createElement('mark');\n\n mark.textContent = part.text; // textContent — never innerHTML\n fragment.appendChild(mark);\n } else {\n fragment.appendChild(document.createTextNode(part.text));\n }\n }\n\n return fragment;\n}\n```\n\n:::\n\n### `findMatchRanges()` + `highlight()` — manual\n\nUse `findMatchRanges()` when you need to apply match ranges to a different string than the indexed field value — for example a truncated preview or a differently formatted display string:\n\n```ts\nimport { findMatchRanges, highlight } from '@vielzeug/scout';\n\nconst [result] = index.search('alice');\nconst preview = result.item.bio.slice(0, 100);\nconst ranges = findMatchRanges(preview, 'alice');\nconst parts = highlight(preview, ranges);\n```\n\nOr use `highlight()` directly when you already have the ranges from `result.matches`:\n\n```ts\nconst [result] = index.search('alice');\nconst nameMatch = result.matches.find(m => m.field === 'name');\nconst parts = highlight(result.item.name, nameMatch?.ranges ?? []);\n```\n\n## Debug Logging\n\n`search.tap()` subscribes a handler to `ScoutEvent` transitions emitted by a `SearchState` — `query` changes, `isSearching` transitions, `results` changes, and `dispose`. It returns an unsubscribe function. Pass `{ signal }` to tie the subscription to an external `AbortSignal` (or to `search.disposalSignal`, which aborts when `dispose()` is called).\n\n```ts\nimport { createIndex, createSearch } from '@vielzeug/scout';\n\nconst search = createSearch(index, { debounce: 150 });\nconst unsubscribe = search.tap((event) => {\n if (event.type === 'query-change') console.debug('query:', event.query);\n if (event.type === 'searching-change') console.debug('isSearching:', event.isSearching);\n if (event.type === 'results-change') console.debug('results:', event.results.length);\n});\n\nsearch.query.value = 'alice';\n// query: alice\n// isSearching: true\n// isSearching: false\n// results: 1\n\nunsubscribe();\n```\n\n::: warning Development logging\n`query-change` events carry the full, literal search query string — if your queries may carry PII (names, emails, medical/financial terms typed by end users), don't log them in production.\n:::\n\n## Framework Integration\n\n::: code-group\n\n```tsx [React]\nimport { createReactiveSearch } from '@vielzeug/scout';\nimport { useEffect, useRef, useSyncExternalStore } from 'react';\n\ntype User = { id: number; name: string; email: string };\n\nfunction useScoutSearch(items: User[]) {\n const ref = useRef(\n createReactiveSearch(items, {\n fields: [{ field: 'name', weight: 2 }, 'email'],\n debounce: 150,\n }),\n );\n\n const search = ref.current;\n\n const results = useSyncExternalStore(\n (cb) => search.results.subscribe(cb),\n () => search.results.value,\n );\n\n useEffect(() => () => search.dispose(), [search]);\n\n return { query: search.query, results };\n}\n```\n\n```ts [Vue 3]\nimport { createReactiveSearch } from '@vielzeug/scout';\nimport { onScopeDispose, ref, watch } from 'vue';\n\ntype User = { id: number; name: string; email: string };\n\nfunction useScoutSearch(items: User[]) {\n const search = createReactiveSearch(items, {\n fields: [{ field: 'name', weight: 2 }, 'email'],\n debounce: 150,\n });\n\n const query = ref('');\n const results = ref(search.results.value);\n\n const unsub = search.results.subscribe(() => {\n results.value = search.results.value;\n });\n\n watch(query, (q) => { search.query.value = q; });\n\n onScopeDispose(() => { unsub(); search.dispose(); });\n\n return { query, results };\n}\n```\n\n```svelte [Svelte]\n<script lang=\"ts\">\n import { createReactiveSearch } from '@vielzeug/scout';\n import { onDestroy } from 'svelte';\n\n type User = { id: number; name: string; email: string };\n\n export let items: User[];\n\n const search = createReactiveSearch(items, {\n fields: [{ field: 'name', weight: 2 }, 'email'],\n debounce: 150,\n });\n\n let query = '';\n let results = search.results.value;\n\n const unsub = search.results.subscribe(() => {\n results = search.results.value;\n });\n\n $: search.query.value = query;\n\n onDestroy(() => { unsub(); search.dispose(); });\n</script>\n\n<input bind:value={query} placeholder=\"Search…\" />\n{#each results as { item }}\n <p>{item.name}</p>\n{/each}\n```\n\n:::\n\n## Working with Other Vielzeug Libraries\n\n### With Sourcerer\n\n`toSearchMatcher()` adapts a `ScoutIndex` to `createLocalSource`'s explicit `match` callback. Scout decides which items match; Sourcerer keeps source query and pagination.\n\n```ts\nimport { createIndex, toSearchMatcher } from '@vielzeug/scout';\nimport { createLocalSource } from '@vielzeug/sourcerer';\n\nconst index = createIndex(users, {\n fields: [{ field: 'name', weight: 2 }, 'email'],\n});\n\nconst source = createLocalSource(users, {\n match: toSearchMatcher(index),\n});\n\nsource.setQuery({ search: 'alice' });\n```\n\n> Keep the index in sync using `index.add()` / `index.remove()` / `index.reindex()`.\n\n### With Vault\n\n`toFilterPredicate()` returns an `(item: T) => boolean` snapshot predicate — pass it to vault's `query.filter()` or plain `Array.filter`.\n\n```ts\nimport { createIndex, toFilterPredicate } from '@vielzeug/scout';\n\nconst index = createIndex(products, { fields: ['title', 'sku'] });\n\nconst matching = products.filter(toFilterPredicate(index, 'widget'));\n\nconst rows = await db.query('products')\n .filter(toFilterPredicate(index, searchTerm))\n .toArray();\n```\n\nCall `toFilterPredicate` again whenever the query or corpus changes — the predicate is a snapshot, not reactive.\n\n## Best Practices\n\n- **Build the index once** — `createIndex()` runs in O(corpus × field_length). Create it at module level or in an effect, not inside render loops.\n- **Keep the index in sync** — call `index.add()` / `remove()` / `reindex()` when items mutate. Stale index entries return wrong scores.\n- **Tune threshold before limit** — set a meaningful `threshold` (e.g. `0.25–0.4`) to suppress noise, then use `limit` to cap the list length.\n- **Set `minQueryLength` for your corpus size** — the default `3` works well for most cases. Lower it for small corpora where single-char queries are expected; raise it for large corpora to avoid expensive O(n) scans.\n- **Dispose reactive state** — always call `search.dispose()` or use `using` when the component unmounts.\n- **Weight by importance** — name/title fields should have weight `2–3`; secondary fields (description, tags) stay at `1`.\n- **Segment CJK/Thai fields explicitly** — `segmentWords()` is opt-in per field, not automatic, to keep `createIndex()` fast for the common whitespace-delimited case.\n",
7
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": [
@@ -48,13 +48,14 @@
48
48
  "ScoutIndex": "export type { ScoutIndex } from './scout-index';",
49
49
  "createIndex": "export { createIndex } from './scout-index';",
50
50
  "segmentWords": "export { segmentWords } from './segment';",
51
- "CreateSearchOptions": "export type {\n CreateSearchOptions,\n FieldDef,\n FieldMatch,\n HighlightPart,\n ScoutIndexOptions,\n SearchConstraints,\n SearchResult,\n SearchState,\n} from './types';",
52
- "FieldDef": "export type {\n CreateSearchOptions,\n FieldDef,\n FieldMatch,\n HighlightPart,\n ScoutIndexOptions,\n SearchConstraints,\n SearchResult,\n SearchState,\n} from './types';",
53
- "FieldMatch": "export type {\n CreateSearchOptions,\n FieldDef,\n FieldMatch,\n HighlightPart,\n ScoutIndexOptions,\n SearchConstraints,\n SearchResult,\n SearchState,\n} from './types';",
54
- "HighlightPart": "export type {\n CreateSearchOptions,\n FieldDef,\n FieldMatch,\n HighlightPart,\n ScoutIndexOptions,\n SearchConstraints,\n SearchResult,\n SearchState,\n} from './types';",
55
- "ScoutIndexOptions": "export type {\n CreateSearchOptions,\n FieldDef,\n FieldMatch,\n HighlightPart,\n ScoutIndexOptions,\n SearchConstraints,\n SearchResult,\n SearchState,\n} from './types';",
56
- "SearchConstraints": "export type {\n CreateSearchOptions,\n FieldDef,\n FieldMatch,\n HighlightPart,\n ScoutIndexOptions,\n SearchConstraints,\n SearchResult,\n SearchState,\n} from './types';",
57
- "SearchResult": "export type {\n CreateSearchOptions,\n FieldDef,\n FieldMatch,\n HighlightPart,\n ScoutIndexOptions,\n SearchConstraints,\n SearchResult,\n SearchState,\n} from './types';",
58
- "SearchState": "export type {\n CreateSearchOptions,\n FieldDef,\n FieldMatch,\n HighlightPart,\n ScoutIndexOptions,\n SearchConstraints,\n SearchResult,\n SearchState,\n} from './types';"
51
+ "CreateSearchOptions": "export type {\n CreateSearchOptions,\n FieldDef,\n FieldMatch,\n HighlightPart,\n ScoutEvent,\n ScoutIndexOptions,\n SearchConstraints,\n SearchResult,\n SearchState,\n} from './types';",
52
+ "FieldDef": "export type {\n CreateSearchOptions,\n FieldDef,\n FieldMatch,\n HighlightPart,\n ScoutEvent,\n ScoutIndexOptions,\n SearchConstraints,\n SearchResult,\n SearchState,\n} from './types';",
53
+ "FieldMatch": "export type {\n CreateSearchOptions,\n FieldDef,\n FieldMatch,\n HighlightPart,\n ScoutEvent,\n ScoutIndexOptions,\n SearchConstraints,\n SearchResult,\n SearchState,\n} from './types';",
54
+ "HighlightPart": "export type {\n CreateSearchOptions,\n FieldDef,\n FieldMatch,\n HighlightPart,\n ScoutEvent,\n ScoutIndexOptions,\n SearchConstraints,\n SearchResult,\n SearchState,\n} from './types';",
55
+ "ScoutEvent": "export type {\n CreateSearchOptions,\n FieldDef,\n FieldMatch,\n HighlightPart,\n ScoutEvent,\n ScoutIndexOptions,\n SearchConstraints,\n SearchResult,\n SearchState,\n} from './types';",
56
+ "ScoutIndexOptions": "export type {\n CreateSearchOptions,\n FieldDef,\n FieldMatch,\n HighlightPart,\n ScoutEvent,\n ScoutIndexOptions,\n SearchConstraints,\n SearchResult,\n SearchState,\n} from './types';",
57
+ "SearchConstraints": "export type {\n CreateSearchOptions,\n FieldDef,\n FieldMatch,\n HighlightPart,\n ScoutEvent,\n ScoutIndexOptions,\n SearchConstraints,\n SearchResult,\n SearchState,\n} from './types';",
58
+ "SearchResult": "export type {\n CreateSearchOptions,\n FieldDef,\n FieldMatch,\n HighlightPart,\n ScoutEvent,\n ScoutIndexOptions,\n SearchConstraints,\n SearchResult,\n SearchState,\n} from './types';",
59
+ "SearchState": "export type {\n CreateSearchOptions,\n FieldDef,\n FieldMatch,\n HighlightPart,\n ScoutEvent,\n ScoutIndexOptions,\n SearchConstraints,\n SearchResult,\n SearchState,\n} from './types';"
59
60
  }
60
61
  }