@vielzeug/codex 26.10.0 → 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 (59) hide show
  1. package/README.md +1 -1
  2. package/data/catalog.json +53 -59
  3. package/data/llms-full.txt +1322 -1069
  4. package/data/llms.txt +6 -6
  5. package/data/manifest.json +1 -1
  6. package/data/packages/arsenal.json +13 -13
  7. package/data/packages/assay.json +4 -4
  8. package/data/packages/clockwork.json +4 -4
  9. package/data/packages/codex.json +3 -3
  10. package/data/packages/coins.json +4 -4
  11. package/data/packages/conduit.json +4 -4
  12. package/data/packages/courier.json +4 -4
  13. package/data/packages/dnd.json +10 -10
  14. package/data/packages/familiar.json +4 -4
  15. package/data/packages/flux.json +4 -4
  16. package/data/packages/focus.json +4 -4
  17. package/data/packages/forge.json +4 -4
  18. package/data/packages/gesture.json +4 -4
  19. package/data/packages/herald.json +12 -12
  20. package/data/packages/illusionist.json +5 -5
  21. package/data/packages/keymap.json +9 -9
  22. package/data/packages/ledger.json +4 -4
  23. package/data/packages/lingua.json +4 -4
  24. package/data/packages/mesh.json +5 -5
  25. package/data/packages/necromancer.json +5 -5
  26. package/data/packages/orbit.json +7 -7
  27. package/data/packages/ore.json +5 -5
  28. package/data/packages/postmaster.json +4 -4
  29. package/data/packages/prism.json +50 -42
  30. package/data/packages/pulse.json +8 -8
  31. package/data/packages/refine.json +3 -3
  32. package/data/packages/ripple.json +4 -4
  33. package/data/packages/rune.json +9 -9
  34. package/data/packages/sandbox.json +4 -4
  35. package/data/packages/scout.json +7 -7
  36. package/data/packages/scroll.json +10 -10
  37. package/data/packages/sentinel.json +4 -4
  38. package/data/packages/sigil.json +6 -6
  39. package/data/packages/sourcerer.json +4 -4
  40. package/data/packages/spell.json +9 -9
  41. package/data/packages/tandem.json +5 -5
  42. package/data/packages/tavern.json +12 -12
  43. package/data/packages/tempo.json +4 -4
  44. package/data/packages/vault.json +13 -13
  45. package/data/packages/ward.json +9 -9
  46. package/data/packages/wayfinder.json +17 -17
  47. package/data/refine.json +259 -87
  48. package/data/search.json +246 -246
  49. package/dist/http.js +2 -2
  50. package/dist/http.js.map +1 -1
  51. package/dist/index.d.ts +1 -1
  52. package/dist/index.js +1 -1
  53. package/dist/refine.d.ts +1 -1
  54. package/dist/refine.js +1 -1
  55. package/dist/server.d.ts +1 -1
  56. package/dist/server.js +1 -1
  57. package/dist/tools/refine.js +1 -1
  58. package/package.json +2 -2
  59. package/skills/vielzeug/SKILL.md +57 -57
@@ -1,15 +1,15 @@
1
1
  {
2
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 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"
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,7 +29,7 @@
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
  ],