@vielzeug/scout 26.11.5 → 26.11.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -4
- package/dist/adapters.js +1 -2
- package/dist/entries.js +1 -2
- package/dist/errors.js +1 -2
- package/dist/highlight.js +1 -2
- package/dist/index.cjs +1 -1
- package/dist/index.js +3 -3
- package/dist/reactive.js +1 -2
- package/dist/scout-index.cjs +1 -1
- package/dist/scout-index.cjs.map +1 -1
- package/dist/scout-index.js +28 -29
- package/dist/scout.js +8 -16
- package/dist/scout.js.map +1 -1
- package/dist/segment.js +1 -2
- package/dist/tokenize.js +1 -2
- package/dist/trigram.js +1 -2
- package/package.json +7 -7
package/README.md
CHANGED
|
@@ -21,10 +21,7 @@ const users = [
|
|
|
21
21
|
];
|
|
22
22
|
|
|
23
23
|
const index = createIndex(users, {
|
|
24
|
-
fields: [
|
|
25
|
-
{ field: 'name', weight: 2 },
|
|
26
|
-
{ field: 'email' },
|
|
27
|
-
],
|
|
24
|
+
fields: [{ field: 'name', weight: 2 }, { field: 'email' }],
|
|
28
25
|
});
|
|
29
26
|
|
|
30
27
|
console.log(index.search('ada')[0]?.item.name); // Ada Lovelace
|
package/dist/adapters.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
|
|
1
|
+
// src/adapters.ts
|
|
2
2
|
function toSearchMatcher(e, t) {
|
|
3
3
|
let n, r = -1, i = /* @__PURE__ */ new Set();
|
|
4
4
|
return (a, o) => ((o !== n || e.revision !== r) && (n = o, r = e.revision, i = new Set(e.search(o, t).map((e) => e.item))), i.has(a));
|
|
@@ -7,7 +7,6 @@ function toFilterPredicate(e, t, n) {
|
|
|
7
7
|
let r = new Set(e.search(t, n).map((e) => e.item));
|
|
8
8
|
return (e) => r.has(e);
|
|
9
9
|
}
|
|
10
|
-
//#endregion
|
|
11
10
|
export { toFilterPredicate, toSearchMatcher };
|
|
12
11
|
|
|
13
12
|
//# sourceMappingURL=adapters.js.map
|
package/dist/entries.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
|
|
1
|
+
// src/entries.ts
|
|
2
2
|
var e = {
|
|
3
3
|
Exact: 0,
|
|
4
4
|
FuzzyOnly: 5,
|
|
@@ -38,7 +38,6 @@ function splitPattern(e, t) {
|
|
|
38
38
|
return r < e.length && n.push({ text: e.slice(r) }), n.length ? n : [{ text: e }];
|
|
39
39
|
}
|
|
40
40
|
var t = /[.*+?^${}()|[\]\\]/g, escapeRegExp = (e) => e.replace(t, "\\$&");
|
|
41
|
-
//#endregion
|
|
42
41
|
export { dedupeEntries, escapeRegExp, rankEntries, splitPattern };
|
|
43
42
|
|
|
44
43
|
//# sourceMappingURL=entries.js.map
|
package/dist/errors.js
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
|
-
|
|
1
|
+
// src/errors.ts
|
|
2
2
|
var ScoutError = class extends Error {
|
|
3
3
|
constructor(e, t) {
|
|
4
4
|
super(e, t), this.name = new.target.name, Object.setPrototypeOf(this, new.target.prototype);
|
|
5
5
|
}
|
|
6
6
|
}, ScoutDisposedError = class extends ScoutError {}, ScoutConfigError = class extends ScoutError {};
|
|
7
|
-
//#endregion
|
|
8
7
|
export { ScoutConfigError, ScoutDisposedError, ScoutError };
|
|
9
8
|
|
|
10
9
|
//# sourceMappingURL=errors.js.map
|
package/dist/highlight.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { tokenize as e } from "./tokenize.js";
|
|
2
|
-
|
|
2
|
+
// src/highlight.ts
|
|
3
3
|
function findMatchRanges(t, n) {
|
|
4
4
|
let r = t.toLowerCase(), i = e(n).split(" ").filter(Boolean), a = [];
|
|
5
5
|
for (let e of i) {
|
|
@@ -40,7 +40,6 @@ function highlight(e, t) {
|
|
|
40
40
|
function highlightField(e, t, n) {
|
|
41
41
|
return highlight(n, e.matches.find((e) => e.field === t)?.ranges ?? []);
|
|
42
42
|
}
|
|
43
|
-
//#endregion
|
|
44
43
|
export { findMatchRanges, highlight, highlightField };
|
|
45
44
|
|
|
46
45
|
//# sourceMappingURL=highlight.js.map
|
package/dist/index.cjs
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
Object.defineProperty(exports,Symbol.toStringTag,{value:`Module`});const e=require("./adapters.cjs"),t=require("./entries.cjs"),n=require("./errors.cjs"),r=require("./highlight.cjs"),i=require("./
|
|
1
|
+
Object.defineProperty(exports,Symbol.toStringTag,{value:`Module`});const e=require("./adapters.cjs"),t=require("./entries.cjs"),n=require("./errors.cjs"),r=require("./highlight.cjs"),i=require("./reactive.cjs"),a=require("./scout-index.cjs"),o=require("./segment.cjs");exports.ScoutConfigError=n.ScoutConfigError,exports.ScoutDisposedError=n.ScoutDisposedError,exports.ScoutError=n.ScoutError,exports.createIndex=a.createIndex,exports.createReactiveSearch=i.createReactiveSearch,exports.createSearch=i.createSearch,exports.dedupeEntries=t.dedupeEntries,exports.escapeRegExp=t.escapeRegExp,exports.findMatchRanges=r.findMatchRanges,exports.highlight=r.highlight,exports.highlightField=r.highlightField,exports.rankEntries=t.rankEntries,exports.segmentWords=o.segmentWords,exports.splitPattern=t.splitPattern,exports.toFilterPredicate=e.toFilterPredicate,exports.toSearchMatcher=e.toSearchMatcher;
|
package/dist/index.js
CHANGED
|
@@ -2,7 +2,7 @@ import { toFilterPredicate as e, toSearchMatcher as t } from "./adapters.js";
|
|
|
2
2
|
import { dedupeEntries as n, escapeRegExp as r, rankEntries as i, splitPattern as a } from "./entries.js";
|
|
3
3
|
import { ScoutConfigError as o, ScoutDisposedError as s, ScoutError as c } from "./errors.js";
|
|
4
4
|
import { findMatchRanges as l, highlight as u, highlightField as d } from "./highlight.js";
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
5
|
+
import { createReactiveSearch as f, createSearch as p } from "./reactive.js";
|
|
6
|
+
import { createIndex as m } from "./scout-index.js";
|
|
7
7
|
import { segmentWords as h } from "./segment.js";
|
|
8
|
-
export { o as ScoutConfigError, s as ScoutDisposedError, c as ScoutError,
|
|
8
|
+
export { o as ScoutConfigError, s as ScoutDisposedError, c as ScoutError, m as createIndex, f as createReactiveSearch, p as createSearch, n as dedupeEntries, r as escapeRegExp, l as findMatchRanges, u as highlight, d as highlightField, i as rankEntries, h as segmentWords, a as splitPattern, e as toFilterPredicate, t as toSearchMatcher };
|
package/dist/reactive.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { ScoutConfigError as e, ScoutDisposedError as t } from "./errors.js";
|
|
2
2
|
import { createIndex as n } from "./scout-index.js";
|
|
3
3
|
import { tapper as r } from "@vielzeug/arsenal";
|
|
4
|
-
|
|
4
|
+
// src/reactive.ts
|
|
5
5
|
var i = 200;
|
|
6
6
|
function notify(e) {
|
|
7
7
|
for (let t of [...e]) try {
|
|
@@ -95,7 +95,6 @@ function createReactiveSearch(e, t) {
|
|
|
95
95
|
[Symbol.dispose]: i[Symbol.dispose]
|
|
96
96
|
};
|
|
97
97
|
}
|
|
98
|
-
//#endregion
|
|
99
98
|
export { createReactiveSearch, createSearch };
|
|
100
99
|
|
|
101
100
|
//# sourceMappingURL=reactive.js.map
|
package/dist/scout-index.cjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
const e=require("./errors.cjs"),t=require("./
|
|
1
|
+
const e=require("./errors.cjs"),t=require("./highlight.cjs"),n=require("./tokenize.cjs"),r=require("./trigram.cjs");function requireFiniteInteger(t,n,r){if(!Number.isFinite(t)||!Number.isInteger(t)||t<r)throw new e.ScoutConfigError(`${n} must be a finite integer greater than or equal to ${r}.`);return t}function requireFiniteNumber(t,n,r,i=1/0){if(!Number.isFinite(t)||t<r||t>i)throw new e.ScoutConfigError(`${n} must be a finite number between ${r} and ${i}.`);return t}function resolveFields(e){return e.map(e=>typeof e==`string`?{field:e,stringify:n.defaultStringify,weight:1}:{field:e.field,stringify:e.stringify??n.defaultStringify,weight:requireFiniteNumber(e.weight??1,`weight for field "${e.field}"`,Number.MIN_VALUE)})}function createIndex(i,a){if(a.fields.length===0)throw new e.ScoutConfigError(`createIndex: at least one field is required.`);let o=resolveFields(a.fields),s=o.reduce((e,t)=>Math.max(e,t.weight),1),c=requireFiniteNumber(a.threshold??.2,`threshold`,0,1),l=requireFiniteInteger(a.limit??50,`limit`,0),u=requireFiniteInteger(a.minQueryLength??3,`minQueryLength`,1),d=new Map,f=new Map,p=new Set,m=0;function notifyMutation(){m++;for(let e of p)e()}let h=null,g=null;function getQueryTrigrams(e){return e===h&&g!==null||(h=e,g=r.generateTrigrams(e)),g}function addFieldToIndex(e,t){for(let n of t){let t=f.get(n);t||(t=new Set,f.set(n,t)),t.add(e)}}function removeFieldFromIndex(e,t){for(let n of t){let t=f.get(n);t&&(t.delete(e),t.size===0&&f.delete(n))}}function addItem(e){let t=new Map,i=new Map;for(let{field:a,stringify:s}of o){let o=e[a],c=s(o),l=n.tokenize(c),u=l.length>=1?r.generateTrigrams(l):new Set;t.set(a,u),i.set(a,c),addFieldToIndex(e,u)}d.set(e,{trigrams:t,values:i})}function containmentScan(e){let t=new Set;for(let[n,r]of d)for(let i of r.values.values())if(i.toLowerCase().includes(e)){t.add(n);break}return t}function trigramCandidates(e){let t=new Set;for(let n of e){let e=f.get(n);if(e)for(let n of e)t.add(n)}return t}function scoreCandidate(e,t,n){let i=0;for(let{field:a,weight:c}of o){let o;if(t===null)o=+!!(n.values.get(a)??``).toLowerCase().includes(e);else{let e=n.trigrams.get(a);if(!e||e.size===0)continue;o=r.overlapSimilarity(t,e)}let l=c/s*o;l>i&&(i=l)}return i}function computeMatches(e,n){let r=[];for(let{field:i}of o){let a=n.get(i);if(!a)continue;let o=t.findMatchRanges(a,e);o.length>0&&r.push({field:i,ranges:o})}return r}function reindexItem(e){let t=d.get(e);if(!t)return!1;let i=!1;for(let{field:a,stringify:s}of o){let o=s(e[a]);if(o===t.values.get(a))continue;i=!0;let c=t.trigrams.get(a);c&&removeFieldFromIndex(e,c);let l=n.tokenize(o),u=l.length>=1?r.generateTrigrams(l):new Set;t.trigrams.set(a,u),t.values.set(a,o),addFieldToIndex(e,u)}return i}function removeItem(e){let t=d.get(e);if(!t)return!1;for(let n of t.trigrams.values())removeFieldFromIndex(e,n);return d.delete(e),!0}for(let e of i)d.has(e)||addItem(e);return{add(e){d.has(e)||(addItem(e),notifyMutation())},get items(){return[...d.keys()]},onMutate(e){return p.add(e),()=>{p.delete(e)}},reindex(e){reindexItem(e)&¬ifyMutation()},remove(e){removeItem(e)&¬ifyMutation()},get revision(){return m},search(e,t){let r=requireFiniteNumber(t?.threshold??c,`threshold`,0,1),i=requireFiniteInteger(t?.limit??l,`limit`,0),a=requireFiniteInteger(t?.minQueryLength??u,`minQueryLength`,1);if(!e.trim())return[...d.keys()].slice(0,i).map(e=>({item:e,matches:[],score:1}));let o=n.tokenize(e);if(!o)return[];let s=o.length<a?null:getQueryTrigrams(o),f=s===null?containmentScan(o):trigramCandidates(s),p=[];for(let e of f){let t=d.get(e);if(!t)continue;let n=scoreCandidate(o,s,t);if(n>=r){let r=computeMatches(o,t.values);p.push({item:e,matches:r,score:n})}}return p.sort((e,t)=>t.score-e.score).slice(0,i)},setItems(e){let t=new Set(e),n=[...t],r=!1;for(let e of[...d.keys()])t.has(e)||(removeItem(e),r=!0);for(let e of n)d.has(e)?reindexItem(e)&&(r=!0):(addItem(e),r=!0);let i=[...d.keys()];if(i.length!==n.length||i.some((e,t)=>e!==n[t])){let e=new Map(n.map(e=>[e,d.get(e)]));d.clear();for(let[t,n]of e)d.set(t,n);r=!0}r&¬ifyMutation()},get size(){return d.size}}}exports.createIndex=createIndex;
|
|
2
2
|
//# sourceMappingURL=scout-index.cjs.map
|
package/dist/scout-index.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"scout-index.cjs","names":["ScoutConfigError","defaultStringify","generateTrigrams","tokenize","overlapSimilarity","findMatchRanges","require_errors","require_tokenize","require_highlight","require_trigram","value","minimum","name","maximum","defs","def","options","fields","maxWeight","max","f","defaultThreshold","defaultLimit","defaultMinQueryLength","itemData","invertedIndex","mutationListeners","revision","listener","cachedNormalized","cachedTrigrams","normalized","trigram","fieldTrigrams","bucket","item","trigrams","values","raw","field","text","stringify","result","record","query","candidates","queryTrigrams","items","bestScore","fieldScore","itemTrigrams","weighted","weight","matches","ranges","changed","newText","oldTrigrams","newTrigrams","threshold","limit","minQueryLength","results","score","b","a","incoming","next","current","index","records"],"sources":["../src/scout-index.ts"],"sourcesContent":["import { ScoutConfigError } from './errors';\nimport { findMatchRanges } from './highlight';\nimport { defaultStringify, tokenize } from './tokenize';\nimport { generateTrigrams, overlapSimilarity } from './trigram';\nimport type { FieldDef, FieldMatch, ScoutIndexOptions, SearchConstraints, SearchResult } from './types';\n\ntype FieldConfig<T> = {\n field: keyof T & string;\n stringify: (v: unknown) => string;\n weight: number;\n};\n\ntype ItemRecord = {\n /** Per-field trigrams for scoring. */\n trigrams: Map<string, Set<string>>;\n /** Per-field original text for highlighting. */\n values: Map<string, string>;\n};\n\n/**\n * A stateful, indexed search corpus. Created via `createIndex()`.\n *\n * Supports incremental `add()`, `remove()`, and `reindex()` operations: each patches\n * the trigram index in O(field_length) without a full rebuild.\n */\nexport interface ScoutIndex<T> {\n /** Adds `item` to the index. No-op if the item is already indexed (by reference). */\n add(item: T): void;\n /** All items currently in the index, in insertion order. */\n readonly items: readonly T[];\n /**\n * Subscribes `listener` to be called after every changed `add()` / `remove()` / `reindex()`\n * / `setItems()` operation. No-ops: e.g. removing an unindexed item or reconciling an\n * unchanged corpus: do not fire it. Each changed `setItems()` reconciliation fires once.\n * Returns an unsubscribe function.\n *\n * Framework-agnostic extension point: `createSearch()` uses this internally to keep\n * its snapshot in sync with index mutations. Most callers won't need this directly.\n */\n onMutate(listener: () => void): () => void;\n /**\n * Re-reads the item's current field values and rebuilds its index entry in-place,\n * only updating fields whose values have changed. Preserves insertion order.\n * No-op if the item is not in the index.\n */\n reindex(item: T): void;\n /**\n * Removes `item` from the index by reference equality.\n * No-op if the item is not in the index.\n */\n remove(item: T): void;\n /**\n * Monotonically increasing counter, incremented after every changed `add()` / `remove()` /\n * `reindex()` / `setItems()` operation. Use as a cache-busting token when caching search\n * results outside the index: `toSearchMatcher()` uses it for this purpose.\n */\n readonly revision: number;\n /**\n * Searches the index for `query` and returns results sorted by score descending.\n *\n * An empty (or whitespace-only) `query` returns all indexed items with `score = 1`.\n * A `query` with no indexable content after normalization (e.g. punctuation-only) returns\n * no results. Results below `threshold` are excluded. At most `limit` results are returned.\n */\n search(query: string, options?: SearchConstraints): SearchResult<T>[];\n /**\n * Reconciles the index to `items` by reference identity. Retained items are reindexed,\n * new items are added, missing items are removed, and one mutation notification fires\n * when indexed corpus or field values change. Duplicate references collapse to one item.\n */\n setItems(items: readonly T[]): void;\n /** Number of items currently in the index. */\n readonly size: number;\n}\n\nfunction requireFiniteInteger(value: number, name: string, minimum: number): number {\n if (!Number.isFinite(value) || !Number.isInteger(value) || value < minimum) {\n throw new ScoutConfigError(`${name} must be a finite integer greater than or equal to ${minimum}.`);\n }\n\n return value;\n}\n\nfunction requireFiniteNumber(value: number, name: string, minimum: number, maximum = Number.POSITIVE_INFINITY): number {\n if (!Number.isFinite(value) || value < minimum || value > maximum) {\n throw new ScoutConfigError(`${name} must be a finite number between ${minimum} and ${maximum}.`);\n }\n\n return value;\n}\n\nfunction resolveFields<T>(defs: ReadonlyArray<FieldDef<T>>): FieldConfig<T>[] {\n return defs.map((def) => {\n if (typeof def === 'string') {\n return { field: def, stringify: defaultStringify, weight: 1 };\n }\n\n return {\n field: def.field,\n stringify: def.stringify ?? defaultStringify,\n weight: requireFiniteNumber(def.weight ?? 1, `weight for field \"${def.field}\"`, Number.MIN_VALUE),\n };\n });\n}\n\n/**\n * Builds a trigram inverted index over `items` for fast fuzzy search.\n *\n * Construction is O(corpus × field_length). Subsequent `search()` calls are\n * O(candidates): far faster than per-query Levenshtein for large corpora.\n *\n * @example\n * ```ts\n * const index = createIndex(users, {\n * fields: [{ field: 'name', weight: 2 }, 'email'],\n * threshold: 0.3,\n * limit: 20,\n * });\n *\n * const results = index.search('alice');\n * ```\n *\n * @throws {ScoutConfigError} If options use an invalid field or numeric configuration.\n */\nexport function createIndex<T>(items: T[], options: ScoutIndexOptions<T>): ScoutIndex<T> {\n if (options.fields.length === 0) {\n throw new ScoutConfigError('createIndex: at least one field is required.');\n }\n\n const fields = resolveFields(options.fields);\n const maxWeight = fields.reduce((max, f) => Math.max(max, f.weight), 1);\n const defaultThreshold = requireFiniteNumber(options.threshold ?? 0.2, 'threshold', 0, 1);\n const defaultLimit = requireFiniteInteger(options.limit ?? 50, 'limit', 0);\n const defaultMinQueryLength = requireFiniteInteger(options.minQueryLength ?? 3, 'minQueryLength', 1);\n\n /** item → per-item record, preserves insertion order for `items` getter */\n const itemData = new Map<T, ItemRecord>();\n /** trigram → set of items that contain it */\n const invertedIndex = new Map<string, Set<T>>();\n const mutationListeners = new Set<() => void>();\n let revision = 0;\n\n function notifyMutation(): void {\n revision++;\n\n for (const listener of mutationListeners) listener();\n }\n\n /** Single-entry cache for the most recent normalized query's trigrams (F2). */\n let cachedNormalized: string | null = null;\n let cachedTrigrams: Set<string> | null = null;\n\n function getQueryTrigrams(normalized: string): Set<string> {\n if (normalized === cachedNormalized && cachedTrigrams !== null) return cachedTrigrams;\n\n cachedNormalized = normalized;\n cachedTrigrams = generateTrigrams(normalized);\n\n return cachedTrigrams;\n }\n\n function addFieldToIndex(item: T, fieldTrigrams: Set<string>): void {\n for (const trigram of fieldTrigrams) {\n let bucket = invertedIndex.get(trigram);\n\n if (!bucket) {\n bucket = new Set<T>();\n invertedIndex.set(trigram, bucket);\n }\n\n bucket.add(item);\n }\n }\n\n function removeFieldFromIndex(item: T, fieldTrigrams: Set<string>): void {\n for (const trigram of fieldTrigrams) {\n const bucket = invertedIndex.get(trigram);\n\n if (bucket) {\n bucket.delete(item);\n\n if (bucket.size === 0) invertedIndex.delete(trigram);\n }\n }\n }\n\n function addItem(item: T): void {\n const trigrams = new Map<string, Set<string>>();\n const values = new Map<string, string>();\n\n for (const { field, stringify } of fields) {\n const raw = item[field];\n const text = stringify(raw);\n const normalized = tokenize(text);\n const fieldTrigrams = normalized.length >= 1 ? generateTrigrams(normalized) : new Set<string>();\n\n trigrams.set(field, fieldTrigrams);\n values.set(field, text);\n addFieldToIndex(item, fieldTrigrams);\n }\n\n itemData.set(item, { trigrams, values });\n }\n\n /**\n * Performs a full linear scan over all items for short queries.\n * O(n × field_count): acceptable for small corpora; consider raising\n * `minQueryLength` on large datasets to avoid triggering this path.\n */\n function containmentScan(query: string): Set<T> {\n const result = new Set<T>();\n\n for (const [item, record] of itemData) {\n for (const value of record.values.values()) {\n if (value.toLowerCase().includes(query)) {\n result.add(item);\n break;\n }\n }\n }\n\n return result;\n }\n\n function trigramCandidates(queryTrigrams: Set<string>): Set<T> {\n const candidates = new Set<T>();\n\n for (const trigram of queryTrigrams) {\n const items = invertedIndex.get(trigram);\n\n if (items) {\n for (const item of items) candidates.add(item);\n }\n }\n\n return candidates;\n }\n\n function scoreCandidate(normalized: string, queryTrigrams: Set<string> | null, record: ItemRecord): number {\n let bestScore = 0;\n\n for (const { field, weight } of fields) {\n let fieldScore: number;\n\n if (queryTrigrams === null) {\n const raw = record.values.get(field) ?? '';\n\n fieldScore = raw.toLowerCase().includes(normalized) ? 1.0 : 0;\n } else {\n const itemTrigrams = record.trigrams.get(field);\n\n if (!itemTrigrams || itemTrigrams.size === 0) continue;\n\n fieldScore = overlapSimilarity(queryTrigrams, itemTrigrams);\n }\n\n const weighted = fieldScore * (weight / maxWeight);\n\n if (weighted > bestScore) bestScore = weighted;\n }\n\n return bestScore;\n }\n\n function computeMatches(query: string, values: Map<string, string>): FieldMatch<keyof T & string>[] {\n const matches: FieldMatch<keyof T & string>[] = [];\n\n for (const { field } of fields) {\n const text = values.get(field);\n\n if (!text) continue;\n\n const ranges = findMatchRanges(text, query);\n\n if (ranges.length > 0) matches.push({ field, ranges });\n }\n\n return matches;\n }\n\n function reindexItem(item: T): boolean {\n const record = itemData.get(item);\n\n if (!record) return false;\n\n let changed = false;\n\n for (const { field, stringify } of fields) {\n const newText = stringify(item[field]);\n const oldText = record.values.get(field);\n\n if (newText === oldText) continue;\n\n changed = true;\n\n const oldTrigrams = record.trigrams.get(field);\n\n if (oldTrigrams) removeFieldFromIndex(item, oldTrigrams);\n\n const normalized = tokenize(newText);\n const newTrigrams = normalized.length >= 1 ? generateTrigrams(normalized) : new Set<string>();\n\n record.trigrams.set(field, newTrigrams);\n record.values.set(field, newText);\n addFieldToIndex(item, newTrigrams);\n }\n\n return changed;\n }\n\n function removeItem(item: T): boolean {\n const record = itemData.get(item);\n\n if (!record) return false;\n\n for (const fieldTrigrams of record.trigrams.values()) {\n removeFieldFromIndex(item, fieldTrigrams);\n }\n\n itemData.delete(item);\n\n return true;\n }\n\n for (const item of items) {\n if (!itemData.has(item)) addItem(item);\n }\n\n const index: ScoutIndex<T> = {\n add(item: T): void {\n if (itemData.has(item)) return;\n\n addItem(item);\n notifyMutation();\n },\n\n get items(): readonly T[] {\n return [...itemData.keys()];\n },\n\n onMutate(listener: () => void): () => void {\n mutationListeners.add(listener);\n\n return () => {\n mutationListeners.delete(listener);\n };\n },\n\n reindex(item: T): void {\n if (reindexItem(item)) notifyMutation();\n },\n\n remove(item: T): void {\n if (removeItem(item)) notifyMutation();\n },\n\n get revision(): number {\n return revision;\n },\n\n search(query: string, options?: SearchConstraints): SearchResult<T>[] {\n const threshold = requireFiniteNumber(options?.threshold ?? defaultThreshold, 'threshold', 0, 1);\n const limit = requireFiniteInteger(options?.limit ?? defaultLimit, 'limit', 0);\n const minQueryLength = requireFiniteInteger(\n options?.minQueryLength ?? defaultMinQueryLength,\n 'minQueryLength',\n 1,\n );\n\n if (!query.trim()) {\n return [...itemData.keys()].slice(0, limit).map((item) => ({ item, matches: [], score: 1 }));\n }\n\n const normalized = tokenize(query);\n\n // Query had no indexable content (e.g. punctuation-only): no match, not \"match all\".\n if (!normalized) return [];\n\n const isShort = normalized.length < minQueryLength;\n const queryTrigrams = isShort ? null : getQueryTrigrams(normalized);\n const candidates = queryTrigrams === null ? containmentScan(normalized) : trigramCandidates(queryTrigrams);\n const results: SearchResult<T>[] = [];\n\n for (const item of candidates) {\n const record = itemData.get(item);\n\n if (!record) continue;\n\n const score = scoreCandidate(normalized, queryTrigrams, record);\n\n if (score >= threshold) {\n const matches = computeMatches(normalized, record.values);\n\n results.push({ item, matches, score });\n }\n }\n\n return results.sort((a, b) => b.score - a.score).slice(0, limit);\n },\n\n setItems(items: readonly T[]): void {\n const incoming = new Set(items);\n const next = [...incoming];\n let changed = false;\n\n for (const item of [...itemData.keys()]) {\n if (incoming.has(item)) continue;\n\n removeItem(item);\n changed = true;\n }\n\n for (const item of next) {\n if (!itemData.has(item)) {\n addItem(item);\n changed = true;\n } else if (reindexItem(item)) {\n changed = true;\n }\n }\n\n const current = [...itemData.keys()];\n const orderChanged = current.length !== next.length || current.some((item, index) => item !== next[index]);\n\n if (orderChanged) {\n const records = new Map(next.map((item) => [item, itemData.get(item)!]));\n\n itemData.clear();\n\n for (const [item, record] of records) itemData.set(item, record);\n\n changed = true;\n }\n\n if (changed) notifyMutation();\n },\n\n get size(): number {\n return itemData.size;\n },\n };\n\n return index;\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"scout-index.cjs","names":["ScoutConfigError","defaultStringify","generateTrigrams","tokenize","overlapSimilarity","findMatchRanges","require_errors","require_highlight","require_tokenize","require_trigram","value","minimum","name","maximum","defs","def","options","fields","maxWeight","max","f","defaultThreshold","defaultLimit","defaultMinQueryLength","itemData","invertedIndex","mutationListeners","revision","listener","cachedNormalized","cachedTrigrams","normalized","trigram","fieldTrigrams","bucket","item","trigrams","values","raw","field","text","stringify","result","record","query","candidates","queryTrigrams","items","bestScore","fieldScore","itemTrigrams","weighted","weight","matches","ranges","changed","newText","oldTrigrams","newTrigrams","threshold","limit","minQueryLength","results","score","b","a","incoming","next","current","index","records"],"sources":["../src/scout-index.ts"],"sourcesContent":["import { ScoutConfigError } from './errors';\nimport { findMatchRanges } from './highlight';\nimport { defaultStringify, tokenize } from './tokenize';\nimport { generateTrigrams, overlapSimilarity } from './trigram';\nimport type { FieldDef, FieldMatch, ScoutIndexOptions, SearchConstraints, SearchResult } from './types';\n\ntype FieldConfig<T> = {\n field: keyof T & string;\n stringify: (v: unknown) => string;\n weight: number;\n};\n\ntype ItemRecord = {\n /** Per-field trigrams for scoring. */\n trigrams: Map<string, Set<string>>;\n /** Per-field original text for highlighting. */\n values: Map<string, string>;\n};\n\n/**\n * A stateful, indexed search corpus. Created via `createIndex()`.\n *\n * Supports incremental `add()`, `remove()`, and `reindex()` operations: each patches\n * the trigram index in O(field_length) without a full rebuild.\n */\nexport interface ScoutIndex<T> {\n /** Adds `item` to the index. No-op if the item is already indexed (by reference). */\n add(item: T): void;\n /** All items currently in the index, in insertion order. */\n readonly items: readonly T[];\n /**\n * Subscribes `listener` to be called after every changed `add()` / `remove()` / `reindex()`\n * / `setItems()` operation. No-ops: e.g. removing an unindexed item or reconciling an\n * unchanged corpus: do not fire it. Each changed `setItems()` reconciliation fires once.\n * Returns an unsubscribe function.\n *\n * Framework-agnostic extension point: `createSearch()` uses this internally to keep\n * its snapshot in sync with index mutations. Most callers won't need this directly.\n */\n onMutate(listener: () => void): () => void;\n /**\n * Re-reads the item's current field values and rebuilds its index entry in-place,\n * only updating fields whose values have changed. Preserves insertion order.\n * No-op if the item is not in the index.\n */\n reindex(item: T): void;\n /**\n * Removes `item` from the index by reference equality.\n * No-op if the item is not in the index.\n */\n remove(item: T): void;\n /**\n * Monotonically increasing counter, incremented after every changed `add()` / `remove()` /\n * `reindex()` / `setItems()` operation. Use as a cache-busting token when caching search\n * results outside the index: `toSearchMatcher()` uses it for this purpose.\n */\n readonly revision: number;\n /**\n * Searches the index for `query` and returns results sorted by score descending.\n *\n * An empty (or whitespace-only) `query` returns all indexed items with `score = 1`.\n * A `query` with no indexable content after normalization (e.g. punctuation-only) returns\n * no results. Results below `threshold` are excluded. At most `limit` results are returned.\n */\n search(query: string, options?: SearchConstraints): SearchResult<T>[];\n /**\n * Reconciles the index to `items` by reference identity. Retained items are reindexed,\n * new items are added, missing items are removed, and one mutation notification fires\n * when indexed corpus or field values change. Duplicate references collapse to one item.\n */\n setItems(items: readonly T[]): void;\n /** Number of items currently in the index. */\n readonly size: number;\n}\n\nfunction requireFiniteInteger(value: number, name: string, minimum: number): number {\n if (!Number.isFinite(value) || !Number.isInteger(value) || value < minimum) {\n throw new ScoutConfigError(`${name} must be a finite integer greater than or equal to ${minimum}.`);\n }\n\n return value;\n}\n\nfunction requireFiniteNumber(value: number, name: string, minimum: number, maximum = Number.POSITIVE_INFINITY): number {\n if (!Number.isFinite(value) || value < minimum || value > maximum) {\n throw new ScoutConfigError(`${name} must be a finite number between ${minimum} and ${maximum}.`);\n }\n\n return value;\n}\n\nfunction resolveFields<T>(defs: ReadonlyArray<FieldDef<T>>): FieldConfig<T>[] {\n return defs.map((def) => {\n if (typeof def === 'string') {\n return { field: def, stringify: defaultStringify, weight: 1 };\n }\n\n return {\n field: def.field,\n stringify: def.stringify ?? defaultStringify,\n weight: requireFiniteNumber(def.weight ?? 1, `weight for field \"${def.field}\"`, Number.MIN_VALUE),\n };\n });\n}\n\n/**\n * Builds a trigram inverted index over `items` for fast fuzzy search.\n *\n * Construction is O(corpus × field_length). Subsequent `search()` calls are\n * O(candidates): far faster than per-query Levenshtein for large corpora.\n *\n * @example\n * ```ts\n * const index = createIndex(users, {\n * fields: [{ field: 'name', weight: 2 }, 'email'],\n * threshold: 0.3,\n * limit: 20,\n * });\n *\n * const results = index.search('alice');\n * ```\n *\n * @throws {ScoutConfigError} If options use an invalid field or numeric configuration.\n */\nexport function createIndex<T>(items: T[], options: ScoutIndexOptions<T>): ScoutIndex<T> {\n if (options.fields.length === 0) {\n throw new ScoutConfigError('createIndex: at least one field is required.');\n }\n\n const fields = resolveFields(options.fields);\n const maxWeight = fields.reduce((max, f) => Math.max(max, f.weight), 1);\n const defaultThreshold = requireFiniteNumber(options.threshold ?? 0.2, 'threshold', 0, 1);\n const defaultLimit = requireFiniteInteger(options.limit ?? 50, 'limit', 0);\n const defaultMinQueryLength = requireFiniteInteger(options.minQueryLength ?? 3, 'minQueryLength', 1);\n\n /** item → per-item record, preserves insertion order for `items` getter */\n const itemData = new Map<T, ItemRecord>();\n /** trigram → set of items that contain it */\n const invertedIndex = new Map<string, Set<T>>();\n const mutationListeners = new Set<() => void>();\n let revision = 0;\n\n function notifyMutation(): void {\n revision++;\n\n for (const listener of mutationListeners) listener();\n }\n\n /** Single-entry cache for the most recent normalized query's trigrams (F2). */\n let cachedNormalized: string | null = null;\n let cachedTrigrams: Set<string> | null = null;\n\n function getQueryTrigrams(normalized: string): Set<string> {\n if (normalized === cachedNormalized && cachedTrigrams !== null) return cachedTrigrams;\n\n cachedNormalized = normalized;\n cachedTrigrams = generateTrigrams(normalized);\n\n return cachedTrigrams;\n }\n\n function addFieldToIndex(item: T, fieldTrigrams: Set<string>): void {\n for (const trigram of fieldTrigrams) {\n let bucket = invertedIndex.get(trigram);\n\n if (!bucket) {\n bucket = new Set<T>();\n invertedIndex.set(trigram, bucket);\n }\n\n bucket.add(item);\n }\n }\n\n function removeFieldFromIndex(item: T, fieldTrigrams: Set<string>): void {\n for (const trigram of fieldTrigrams) {\n const bucket = invertedIndex.get(trigram);\n\n if (bucket) {\n bucket.delete(item);\n\n if (bucket.size === 0) invertedIndex.delete(trigram);\n }\n }\n }\n\n function addItem(item: T): void {\n const trigrams = new Map<string, Set<string>>();\n const values = new Map<string, string>();\n\n for (const { field, stringify } of fields) {\n const raw = item[field];\n const text = stringify(raw);\n const normalized = tokenize(text);\n const fieldTrigrams = normalized.length >= 1 ? generateTrigrams(normalized) : new Set<string>();\n\n trigrams.set(field, fieldTrigrams);\n values.set(field, text);\n addFieldToIndex(item, fieldTrigrams);\n }\n\n itemData.set(item, { trigrams, values });\n }\n\n /**\n * Performs a full linear scan over all items for short queries.\n * O(n × field_count): acceptable for small corpora; consider raising\n * `minQueryLength` on large datasets to avoid triggering this path.\n */\n function containmentScan(query: string): Set<T> {\n const result = new Set<T>();\n\n for (const [item, record] of itemData) {\n for (const value of record.values.values()) {\n if (value.toLowerCase().includes(query)) {\n result.add(item);\n break;\n }\n }\n }\n\n return result;\n }\n\n function trigramCandidates(queryTrigrams: Set<string>): Set<T> {\n const candidates = new Set<T>();\n\n for (const trigram of queryTrigrams) {\n const items = invertedIndex.get(trigram);\n\n if (items) {\n for (const item of items) candidates.add(item);\n }\n }\n\n return candidates;\n }\n\n function scoreCandidate(normalized: string, queryTrigrams: Set<string> | null, record: ItemRecord): number {\n let bestScore = 0;\n\n for (const { field, weight } of fields) {\n let fieldScore: number;\n\n if (queryTrigrams === null) {\n const raw = record.values.get(field) ?? '';\n\n fieldScore = raw.toLowerCase().includes(normalized) ? 1.0 : 0;\n } else {\n const itemTrigrams = record.trigrams.get(field);\n\n if (!itemTrigrams || itemTrigrams.size === 0) continue;\n\n fieldScore = overlapSimilarity(queryTrigrams, itemTrigrams);\n }\n\n const weighted = fieldScore * (weight / maxWeight);\n\n if (weighted > bestScore) bestScore = weighted;\n }\n\n return bestScore;\n }\n\n function computeMatches(query: string, values: Map<string, string>): FieldMatch<keyof T & string>[] {\n const matches: FieldMatch<keyof T & string>[] = [];\n\n for (const { field } of fields) {\n const text = values.get(field);\n\n if (!text) continue;\n\n const ranges = findMatchRanges(text, query);\n\n if (ranges.length > 0) matches.push({ field, ranges });\n }\n\n return matches;\n }\n\n function reindexItem(item: T): boolean {\n const record = itemData.get(item);\n\n if (!record) return false;\n\n let changed = false;\n\n for (const { field, stringify } of fields) {\n const newText = stringify(item[field]);\n const oldText = record.values.get(field);\n\n if (newText === oldText) continue;\n\n changed = true;\n\n const oldTrigrams = record.trigrams.get(field);\n\n if (oldTrigrams) removeFieldFromIndex(item, oldTrigrams);\n\n const normalized = tokenize(newText);\n const newTrigrams = normalized.length >= 1 ? generateTrigrams(normalized) : new Set<string>();\n\n record.trigrams.set(field, newTrigrams);\n record.values.set(field, newText);\n addFieldToIndex(item, newTrigrams);\n }\n\n return changed;\n }\n\n function removeItem(item: T): boolean {\n const record = itemData.get(item);\n\n if (!record) return false;\n\n for (const fieldTrigrams of record.trigrams.values()) {\n removeFieldFromIndex(item, fieldTrigrams);\n }\n\n itemData.delete(item);\n\n return true;\n }\n\n for (const item of items) {\n if (!itemData.has(item)) addItem(item);\n }\n\n const index: ScoutIndex<T> = {\n add(item: T): void {\n if (itemData.has(item)) return;\n\n addItem(item);\n notifyMutation();\n },\n\n get items(): readonly T[] {\n return [...itemData.keys()];\n },\n\n onMutate(listener: () => void): () => void {\n mutationListeners.add(listener);\n\n return () => {\n mutationListeners.delete(listener);\n };\n },\n\n reindex(item: T): void {\n if (reindexItem(item)) notifyMutation();\n },\n\n remove(item: T): void {\n if (removeItem(item)) notifyMutation();\n },\n\n get revision(): number {\n return revision;\n },\n\n search(query: string, options?: SearchConstraints): SearchResult<T>[] {\n const threshold = requireFiniteNumber(options?.threshold ?? defaultThreshold, 'threshold', 0, 1);\n const limit = requireFiniteInteger(options?.limit ?? defaultLimit, 'limit', 0);\n const minQueryLength = requireFiniteInteger(\n options?.minQueryLength ?? defaultMinQueryLength,\n 'minQueryLength',\n 1,\n );\n\n if (!query.trim()) {\n return [...itemData.keys()].slice(0, limit).map((item) => ({ item, matches: [], score: 1 }));\n }\n\n const normalized = tokenize(query);\n\n // Query had no indexable content (e.g. punctuation-only): no match, not \"match all\".\n if (!normalized) return [];\n\n const isShort = normalized.length < minQueryLength;\n const queryTrigrams = isShort ? null : getQueryTrigrams(normalized);\n const candidates = queryTrigrams === null ? containmentScan(normalized) : trigramCandidates(queryTrigrams);\n const results: SearchResult<T>[] = [];\n\n for (const item of candidates) {\n const record = itemData.get(item);\n\n if (!record) continue;\n\n const score = scoreCandidate(normalized, queryTrigrams, record);\n\n if (score >= threshold) {\n const matches = computeMatches(normalized, record.values);\n\n results.push({ item, matches, score });\n }\n }\n\n return results.sort((a, b) => b.score - a.score).slice(0, limit);\n },\n\n setItems(items: readonly T[]): void {\n const incoming = new Set(items);\n const next = [...incoming];\n let changed = false;\n\n for (const item of [...itemData.keys()]) {\n if (incoming.has(item)) continue;\n\n removeItem(item);\n changed = true;\n }\n\n for (const item of next) {\n if (!itemData.has(item)) {\n addItem(item);\n changed = true;\n } else if (reindexItem(item)) {\n changed = true;\n }\n }\n\n const current = [...itemData.keys()];\n const orderChanged = current.length !== next.length || current.some((item, index) => item !== next[index]);\n\n if (orderChanged) {\n const records = new Map(next.map((item) => [item, itemData.get(item)!]));\n\n itemData.clear();\n\n for (const [item, record] of records) itemData.set(item, record);\n\n changed = true;\n }\n\n if (changed) notifyMutation();\n },\n\n get size(): number {\n return itemData.size;\n },\n };\n\n return index;\n}\n"],"mappings":"A,oHA2EA,SAAS,qBAAqB,EAAe,EAAc,EAAyB,CAClF,GAAI,CAAC,OAAO,SAASU,CAAK,GAAK,CAAC,OAAO,UAAUA,CAAK,GAAKA,EAAQC,EACjE,MAAM,IAAIX,EAAAA,iBAAiB,GAAGY,EAAK,qDAAqDD,EAAQ,EAAE,EAGpG,OAAOD,CACT,CAEA,SAAS,oBAAoB,EAAe,EAAc,EAAiB,EAAU,IAAkC,CACrH,GAAI,CAAC,OAAO,SAASA,CAAK,GAAKA,EAAQC,GAAWD,EAAQG,EACxD,MAAM,IAAIb,EAAAA,iBAAiB,GAAGY,EAAK,mCAAmCD,EAAQ,OAAOE,EAAQ,EAAE,EAGjG,OAAOH,CACT,CAEA,SAAS,cAAiB,EAAoD,CAC5E,OAAOI,EAAK,IAAK,GACX,OAAOC,GAAQ,SACV,CAAE,MAAOA,EAAK,UAAWd,EAAAA,iBAAkB,OAAQ,CAAE,EAGvD,CACL,MAAOc,EAAI,MACX,UAAWA,EAAI,WAAad,EAAAA,iBAC5B,OAAQ,oBAAoBc,EAAI,QAAU,EAAG,qBAAqBA,EAAI,MAAM,GAAI,OAAO,SAAS,CAClG,CACD,CACH,CAqBA,SAAgB,YAAe,EAAY,EAA8C,CACvF,GAAIC,EAAQ,OAAO,SAAW,EAC5B,MAAM,IAAIhB,EAAAA,iBAAiB,8CAA8C,EAG3E,IAAMiB,EAAS,cAAcD,EAAQ,MAAM,EACrCE,EAAYD,EAAO,QAAQ,EAAK,IAAM,KAAK,IAAIE,EAAKC,EAAE,MAAM,EAAG,CAAC,EAChEC,EAAmB,oBAAoBL,EAAQ,WAAa,GAAK,YAAa,EAAG,CAAC,EAClFM,EAAe,qBAAqBN,EAAQ,OAAS,GAAI,QAAS,CAAC,EACnEO,EAAwB,qBAAqBP,EAAQ,gBAAkB,EAAG,iBAAkB,CAAC,EAG7FQ,EAAW,IAAI,IAEfC,EAAgB,IAAI,IACpBC,EAAoB,IAAI,IAC1BC,EAAW,EAEf,SAAS,gBAAuB,CAC9B,IAEA,IAAK,IAAMC,KAAYF,EAAmBE,EAAS,CACrD,CAGA,IAAIC,EAAkC,KAClCC,EAAqC,KAEzC,SAAS,iBAAiB,EAAiC,CAMzD,OALIC,IAAeF,GAAoBC,IAAmB,OAE1D,EAAmBC,EACnB,EAAiB7B,EAAAA,iBAAiB6B,CAAU,GAErCD,CACT,CAEA,SAAS,gBAAgB,EAAS,EAAkC,CAClE,IAAK,IAAME,KAAWC,EAAe,CACnC,IAAIC,EAAST,EAAc,IAAIO,CAAO,EAEjCE,IACH,EAAS,IAAI,IACbT,EAAc,IAAIO,EAASE,CAAM,GAGnCA,EAAO,IAAIC,CAAI,CACjB,CACF,CAEA,SAAS,qBAAqB,EAAS,EAAkC,CACvE,IAAK,IAAMH,KAAWC,EAAe,CACnC,IAAMC,EAAST,EAAc,IAAIO,CAAO,EAEpCE,IACFA,EAAO,OAAOC,CAAI,EAEdD,EAAO,OAAS,GAAGT,EAAc,OAAOO,CAAO,EAEvD,CACF,CAEA,SAAS,QAAQ,EAAe,CAC9B,IAAMI,EAAW,IAAI,IACfC,EAAS,IAAI,IAEnB,IAAK,GAAM,CAAE,QAAO,eAAepB,EAAQ,CACzC,IAAMqB,EAAMH,EAAKI,GACXC,EAAOC,EAAUH,CAAG,EACpBP,EAAa5B,EAAAA,SAASqC,CAAI,EAC1BP,EAAgBF,EAAW,QAAU,EAAI7B,EAAAA,iBAAiB6B,CAAU,EAAI,IAAI,IAElFK,EAAS,IAAIG,EAAON,CAAa,EACjCI,EAAO,IAAIE,EAAOC,CAAI,EACtB,gBAAgBL,EAAMF,CAAa,CACrC,CAEAT,EAAS,IAAIW,EAAM,CAAE,WAAU,QAAO,CAAC,CACzC,CAOA,SAAS,gBAAgB,EAAuB,CAC9C,IAAMO,EAAS,IAAI,IAEnB,IAAK,GAAM,CAACP,EAAMQ,KAAWnB,EAC3B,IAAK,IAAMd,KAASiC,EAAO,OAAO,OAAO,EACvC,GAAIjC,EAAM,YAAY,CAAC,CAAC,SAASkC,CAAK,EAAG,CACvCF,EAAO,IAAIP,CAAI,EACf,KACF,CAIJ,OAAOO,CACT,CAEA,SAAS,kBAAkB,EAAoC,CAC7D,IAAMG,EAAa,IAAI,IAEvB,IAAK,IAAMb,KAAWc,EAAe,CACnC,IAAMC,EAAQtB,EAAc,IAAIO,CAAO,EAEvC,GAAIe,EACF,IAAK,IAAMZ,KAAQY,EAAOF,EAAW,IAAIV,CAAI,CAEjD,CAEA,OAAOU,CACT,CAEA,SAAS,eAAe,EAAoB,EAAmC,EAA4B,CACzG,IAAIG,EAAY,EAEhB,IAAK,GAAM,CAAE,QAAO,YAAY/B,EAAQ,CACtC,IAAIgC,EAEJ,GAAIH,IAAkB,KAGpB,EAAA,IAFYH,EAAO,OAAO,IAAIJ,CAAK,GAAK,GAAA,CAEvB,YAAY,CAAC,CAAC,SAASR,CAAU,MAC7C,CACL,IAAMmB,EAAeP,EAAO,SAAS,IAAIJ,CAAK,EAE9C,GAAI,CAACW,GAAgBA,EAAa,OAAS,EAAG,SAE9C,EAAa9C,EAAAA,kBAAkB0C,EAAeI,CAAY,CAC5D,CAEA,IAAMC,EAAyBC,EAASlC,EAAvB+B,EAEbE,EAAWH,IAAW,EAAYG,EACxC,CAEA,OAAOH,CACT,CAEA,SAAS,eAAe,EAAe,EAA6D,CAClG,IAAMK,EAA0C,CAAC,EAEjD,IAAK,GAAM,CAAE,WAAWpC,EAAQ,CAC9B,IAAMuB,EAAOH,EAAO,IAAIE,CAAK,EAE7B,GAAI,CAACC,EAAM,SAEX,IAAMc,EAASjD,EAAAA,gBAAgBmC,EAAMI,CAAK,EAEtCU,EAAO,OAAS,GAAGD,EAAQ,KAAK,CAAE,QAAO,QAAO,CAAC,CACvD,CAEA,OAAOA,CACT,CAEA,SAAS,YAAY,EAAkB,CACrC,IAAMV,EAASnB,EAAS,IAAIW,CAAI,EAEhC,GAAI,CAACQ,EAAQ,MAAO,GAEpB,IAAIY,EAAU,GAEd,IAAK,GAAM,CAAE,QAAO,eAAetC,EAAQ,CACzC,IAAMuC,EAAUf,EAAUN,EAAKI,EAAM,EAGrC,GAAIiB,IAFYb,EAAO,OAAO,IAAIJ,CAElB,EAAS,SAEzB,EAAU,GAEV,IAAMkB,EAAcd,EAAO,SAAS,IAAIJ,CAAK,EAEzCkB,GAAa,qBAAqBtB,EAAMsB,CAAW,EAEvD,IAAM1B,EAAa5B,EAAAA,SAASqD,CAAO,EAC7BE,EAAc3B,EAAW,QAAU,EAAI7B,EAAAA,iBAAiB6B,CAAU,EAAI,IAAI,IAEhFY,EAAO,SAAS,IAAIJ,EAAOmB,CAAW,EACtCf,EAAO,OAAO,IAAIJ,EAAOiB,CAAO,EAChC,gBAAgBrB,EAAMuB,CAAW,CACnC,CAEA,OAAOH,CACT,CAEA,SAAS,WAAW,EAAkB,CACpC,IAAMZ,EAASnB,EAAS,IAAIW,CAAI,EAEhC,GAAI,CAACQ,EAAQ,MAAO,GAEpB,IAAK,IAAMV,KAAiBU,EAAO,SAAS,OAAO,EACjD,qBAAqBR,EAAMF,CAAa,EAK1C,OAFAT,EAAS,OAAOW,CAAI,EAEb,EACT,CAEA,IAAK,IAAMA,KAAQY,EACZvB,EAAS,IAAIW,CAAI,GAAG,QAAQA,CAAI,EAqHvC,MAAO,CAjHL,IAAI,EAAe,CACbX,EAAS,IAAIW,CAAI,IAErB,QAAQA,CAAI,EACZ,eAAe,EACjB,EAEA,IAAI,OAAsB,CACxB,MAAO,CAAC,GAAGX,EAAS,KAAK,CAAC,CAC5B,EAEA,SAAS,EAAkC,CAGzC,OAFAE,EAAkB,IAAIE,CAAQ,MAEjB,CACXF,EAAkB,OAAOE,CAAQ,CACnC,CACF,EAEA,QAAQ,EAAe,CACjB,YAAYO,CAAI,GAAG,eAAe,CACxC,EAEA,OAAO,EAAe,CAChB,WAAWA,CAAI,GAAG,eAAe,CACvC,EAEA,IAAI,UAAmB,CACrB,OAAOR,CACT,EAEA,OAAO,EAAe,EAAgD,CACpE,IAAMgC,EAAY,oBAAoB3C,GAAS,WAAaK,EAAkB,YAAa,EAAG,CAAC,EACzFuC,EAAQ,qBAAqB5C,GAAS,OAASM,EAAc,QAAS,CAAC,EACvEuC,EAAiB,qBACrB7C,GAAS,gBAAkBO,EAC3B,iBACA,CACF,EAEA,GAAI,CAACqB,EAAM,KAAK,EACd,MAAO,CAAC,GAAGpB,EAAS,KAAK,CAAC,CAAC,CAAC,MAAM,EAAGoC,CAAK,CAAC,CAAC,IAAK,IAAU,CAAE,OAAM,QAAS,CAAC,EAAG,MAAO,CAAE,EAAE,EAG7F,IAAM7B,EAAa5B,EAAAA,SAASyC,CAAK,EAGjC,GAAI,CAACb,EAAY,MAAO,CAAC,EAGzB,IAAMe,EADUf,EAAW,OAAS8B,EACJ,KAAO,iBAAiB9B,CAAU,EAC5Dc,EAAaC,IAAkB,KAAO,gBAAgBf,CAAU,EAAI,kBAAkBe,CAAa,EACnGgB,EAA6B,CAAC,EAEpC,IAAK,IAAM3B,KAAQU,EAAY,CAC7B,IAAMF,EAASnB,EAAS,IAAIW,CAAI,EAEhC,GAAI,CAACQ,EAAQ,SAEb,IAAMoB,EAAQ,eAAehC,EAAYe,EAAeH,CAAM,EAE9D,GAAIoB,GAASJ,EAAW,CACtB,IAAMN,EAAU,eAAetB,EAAYY,EAAO,MAAM,EAExDmB,EAAQ,KAAK,CAAE,OAAM,UAAS,OAAM,CAAC,CACvC,CACF,CAEA,OAAOA,EAAQ,MAAM,EAAG,IAAME,EAAE,MAAQC,EAAE,KAAK,CAAC,CAAC,MAAM,EAAGL,CAAK,CACjE,EAEA,SAAS,EAA2B,CAClC,IAAMM,EAAW,IAAI,IAAInB,CAAK,EACxBoB,EAAO,CAAC,GAAGD,CAAQ,EACrBX,EAAU,GAEd,IAAK,IAAMpB,IAAQ,CAAC,GAAGX,EAAS,KAAK,CAAC,EAChC0C,EAAS,IAAI/B,CAAI,IAErB,WAAWA,CAAI,EACf,EAAU,IAGZ,IAAK,IAAMA,KAAQgC,EACZ3C,EAAS,IAAIW,CAAI,EAGX,YAAYA,CAAI,IACzB,EAAU,KAHV,QAAQA,CAAI,EACZ,EAAU,IAMd,IAAMiC,EAAU,CAAC,GAAG5C,EAAS,KAAK,CAAC,EAGnC,GAFqB4C,EAAQ,SAAWD,EAAK,QAAUC,EAAQ,MAAM,EAAM,IAAUjC,IAASgC,EAAKE,EAAM,EAEvF,CAChB,IAAMC,EAAU,IAAI,IAAIH,EAAK,IAAK,GAAS,CAAChC,EAAMX,EAAS,IAAIW,CAAI,CAAE,CAAC,CAAC,EAEvEX,EAAS,MAAM,EAEf,IAAK,GAAM,CAACW,EAAMQ,KAAW2B,EAAS9C,EAAS,IAAIW,EAAMQ,CAAM,EAE/D,EAAU,EACZ,CAEIY,GAAS,eAAe,CAC9B,EAEA,IAAI,MAAe,CACjB,OAAO/B,EAAS,IAClB,CAGK,CACT,C"}
|
package/dist/scout-index.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { ScoutConfigError as e } from "./errors.js";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { findMatchRanges as t } from "./highlight.js";
|
|
3
|
+
import { defaultStringify as n, tokenize as r } from "./tokenize.js";
|
|
4
4
|
import { generateTrigrams as i, overlapSimilarity as a } from "./trigram.js";
|
|
5
|
-
|
|
5
|
+
// src/scout-index.ts
|
|
6
6
|
function requireFiniteInteger(t, n, r) {
|
|
7
7
|
if (!Number.isFinite(t) || !Number.isInteger(t) || t < r) throw new e(`${n} must be a finite integer greater than or equal to ${r}.`);
|
|
8
8
|
return t;
|
|
@@ -14,15 +14,15 @@ function requireFiniteNumber(t, n, r, i = Infinity) {
|
|
|
14
14
|
function resolveFields(e) {
|
|
15
15
|
return e.map((e) => typeof e == "string" ? {
|
|
16
16
|
field: e,
|
|
17
|
-
stringify:
|
|
17
|
+
stringify: n,
|
|
18
18
|
weight: 1
|
|
19
19
|
} : {
|
|
20
20
|
field: e.field,
|
|
21
|
-
stringify: e.stringify ??
|
|
21
|
+
stringify: e.stringify ?? n,
|
|
22
22
|
weight: requireFiniteNumber(e.weight ?? 1, `weight for field "${e.field}"`, Number.MIN_VALUE)
|
|
23
23
|
});
|
|
24
24
|
}
|
|
25
|
-
function createIndex(
|
|
25
|
+
function createIndex(n, o) {
|
|
26
26
|
if (o.fields.length === 0) throw new e("createIndex: at least one field is required.");
|
|
27
27
|
let s = resolveFields(o.fields), c = s.reduce((e, t) => Math.max(e, t.weight), 1), l = requireFiniteNumber(o.threshold ?? .2, "threshold", 0, 1), u = requireFiniteInteger(o.limit ?? 50, "limit", 0), d = requireFiniteInteger(o.minQueryLength ?? 3, "minQueryLength", 1), f = /* @__PURE__ */ new Map(), p = /* @__PURE__ */ new Map(), m = /* @__PURE__ */ new Set(), h = 0;
|
|
28
28
|
function notifyMutation() {
|
|
@@ -46,14 +46,14 @@ function createIndex(t, o) {
|
|
|
46
46
|
}
|
|
47
47
|
}
|
|
48
48
|
function addItem(e) {
|
|
49
|
-
let t = /* @__PURE__ */ new Map(),
|
|
49
|
+
let t = /* @__PURE__ */ new Map(), n = /* @__PURE__ */ new Map();
|
|
50
50
|
for (let { field: a, stringify: o } of s) {
|
|
51
|
-
let s = e[a], c = o(s), l =
|
|
52
|
-
t.set(a, u),
|
|
51
|
+
let s = e[a], c = o(s), l = r(c), u = l.length >= 1 ? i(l) : /* @__PURE__ */ new Set();
|
|
52
|
+
t.set(a, u), n.set(a, c), addFieldToIndex(e, u);
|
|
53
53
|
}
|
|
54
54
|
f.set(e, {
|
|
55
55
|
trigrams: t,
|
|
56
|
-
values:
|
|
56
|
+
values: n
|
|
57
57
|
});
|
|
58
58
|
}
|
|
59
59
|
function containmentScan(e) {
|
|
@@ -87,33 +87,33 @@ function createIndex(t, o) {
|
|
|
87
87
|
}
|
|
88
88
|
return r;
|
|
89
89
|
}
|
|
90
|
-
function computeMatches(e,
|
|
91
|
-
let
|
|
90
|
+
function computeMatches(e, n) {
|
|
91
|
+
let r = [];
|
|
92
92
|
for (let { field: i } of s) {
|
|
93
|
-
let a =
|
|
93
|
+
let a = n.get(i);
|
|
94
94
|
if (!a) continue;
|
|
95
|
-
let o =
|
|
96
|
-
o.length > 0 &&
|
|
95
|
+
let o = t(a, e);
|
|
96
|
+
o.length > 0 && r.push({
|
|
97
97
|
field: i,
|
|
98
98
|
ranges: o
|
|
99
99
|
});
|
|
100
100
|
}
|
|
101
|
-
return
|
|
101
|
+
return r;
|
|
102
102
|
}
|
|
103
103
|
function reindexItem(e) {
|
|
104
104
|
let t = f.get(e);
|
|
105
105
|
if (!t) return !1;
|
|
106
|
-
let
|
|
106
|
+
let n = !1;
|
|
107
107
|
for (let { field: a, stringify: o } of s) {
|
|
108
108
|
let s = o(e[a]);
|
|
109
109
|
if (s === t.values.get(a)) continue;
|
|
110
|
-
|
|
110
|
+
n = !0;
|
|
111
111
|
let c = t.trigrams.get(a);
|
|
112
112
|
c && removeFieldFromIndex(e, c);
|
|
113
|
-
let l =
|
|
113
|
+
let l = r(s), u = l.length >= 1 ? i(l) : /* @__PURE__ */ new Set();
|
|
114
114
|
t.trigrams.set(a, u), t.values.set(a, s), addFieldToIndex(e, u);
|
|
115
115
|
}
|
|
116
|
-
return
|
|
116
|
+
return n;
|
|
117
117
|
}
|
|
118
118
|
function removeItem(e) {
|
|
119
119
|
let t = f.get(e);
|
|
@@ -121,7 +121,7 @@ function createIndex(t, o) {
|
|
|
121
121
|
for (let n of t.trigrams.values()) removeFieldFromIndex(e, n);
|
|
122
122
|
return f.delete(e), !0;
|
|
123
123
|
}
|
|
124
|
-
for (let e of
|
|
124
|
+
for (let e of n) f.has(e) || addItem(e);
|
|
125
125
|
return {
|
|
126
126
|
add(e) {
|
|
127
127
|
f.has(e) || (addItem(e), notifyMutation());
|
|
@@ -144,25 +144,25 @@ function createIndex(t, o) {
|
|
|
144
144
|
return h;
|
|
145
145
|
},
|
|
146
146
|
search(e, t) {
|
|
147
|
-
let
|
|
147
|
+
let n = requireFiniteNumber(t?.threshold ?? l, "threshold", 0, 1), i = requireFiniteInteger(t?.limit ?? u, "limit", 0), a = requireFiniteInteger(t?.minQueryLength ?? d, "minQueryLength", 1);
|
|
148
148
|
if (!e.trim()) return [...f.keys()].slice(0, i).map((e) => ({
|
|
149
149
|
item: e,
|
|
150
150
|
matches: [],
|
|
151
151
|
score: 1
|
|
152
152
|
}));
|
|
153
|
-
let o =
|
|
153
|
+
let o = r(e);
|
|
154
154
|
if (!o) return [];
|
|
155
155
|
let s = o.length < a ? null : getQueryTrigrams(o), c = s === null ? containmentScan(o) : trigramCandidates(s), p = [];
|
|
156
156
|
for (let e of c) {
|
|
157
157
|
let t = f.get(e);
|
|
158
158
|
if (!t) continue;
|
|
159
|
-
let
|
|
160
|
-
if (
|
|
161
|
-
let
|
|
159
|
+
let r = scoreCandidate(o, s, t);
|
|
160
|
+
if (r >= n) {
|
|
161
|
+
let n = computeMatches(o, t.values);
|
|
162
162
|
p.push({
|
|
163
163
|
item: e,
|
|
164
|
-
matches:
|
|
165
|
-
score:
|
|
164
|
+
matches: n,
|
|
165
|
+
score: r
|
|
166
166
|
});
|
|
167
167
|
}
|
|
168
168
|
}
|
|
@@ -186,7 +186,6 @@ function createIndex(t, o) {
|
|
|
186
186
|
}
|
|
187
187
|
};
|
|
188
188
|
}
|
|
189
|
-
//#endregion
|
|
190
189
|
export { createIndex };
|
|
191
190
|
|
|
192
191
|
//# sourceMappingURL=scout-index.js.map
|
package/dist/scout.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { tapper as e } from "@vielzeug/arsenal";
|
|
2
|
-
|
|
2
|
+
// src/adapters.ts
|
|
3
3
|
function toSearchMatcher(e, t) {
|
|
4
4
|
let n, r = -1, i = /* @__PURE__ */ new Set();
|
|
5
5
|
return (a, o) => ((o !== n || e.revision !== r) && (n = o, r = e.revision, i = new Set(e.search(o, t).map((e) => e.item))), i.has(a));
|
|
@@ -8,8 +8,7 @@ function toFilterPredicate(e, t, n) {
|
|
|
8
8
|
let r = new Set(e.search(t, n).map((e) => e.item));
|
|
9
9
|
return (e) => r.has(e);
|
|
10
10
|
}
|
|
11
|
-
|
|
12
|
-
//#region src/entries.ts
|
|
11
|
+
// src/entries.ts
|
|
13
12
|
var t = {
|
|
14
13
|
Exact: 0,
|
|
15
14
|
FuzzyOnly: 5,
|
|
@@ -53,16 +52,14 @@ var n = /[.*+?^${}()|[\]\\]/g, escapeRegExp = (e) => e.replace(n, "\\$&"), Scout
|
|
|
53
52
|
super(e, t), this.name = new.target.name, Object.setPrototypeOf(this, new.target.prototype);
|
|
54
53
|
}
|
|
55
54
|
}, ScoutDisposedError = class extends ScoutError {}, ScoutConfigError = class extends ScoutError {};
|
|
56
|
-
|
|
57
|
-
//#region src/tokenize.ts
|
|
55
|
+
// src/tokenize.ts
|
|
58
56
|
function tokenize(e) {
|
|
59
57
|
return e.toLowerCase().replace(/[^\p{L}\p{N}\s']/gu, " ").trim().replace(/\s+/g, " ");
|
|
60
58
|
}
|
|
61
59
|
function defaultStringify(e) {
|
|
62
60
|
return e == null ? "" : typeof e == "string" ? e : typeof e == "number" || typeof e == "boolean" ? String(e) : Array.isArray(e) ? e.filter((e) => typeof e == "string" || typeof e == "number" || typeof e == "boolean").join(" ") : "";
|
|
63
61
|
}
|
|
64
|
-
|
|
65
|
-
//#region src/highlight.ts
|
|
62
|
+
// src/highlight.ts
|
|
66
63
|
function findMatchRanges(e, t) {
|
|
67
64
|
let n = e.toLowerCase(), r = tokenize(t).split(" ").filter(Boolean), i = [];
|
|
68
65
|
for (let e of r) {
|
|
@@ -103,8 +100,7 @@ function highlight(e, t) {
|
|
|
103
100
|
function highlightField(e, t, n) {
|
|
104
101
|
return highlight(n, e.matches.find((e) => e.field === t)?.ranges ?? []);
|
|
105
102
|
}
|
|
106
|
-
|
|
107
|
-
//#region src/trigram.ts
|
|
103
|
+
// src/trigram.ts
|
|
108
104
|
function generateTrigrams(e) {
|
|
109
105
|
let t = /* @__PURE__ */ new Set(), n = ` ${e} `;
|
|
110
106
|
for (let e = 0; e < n.length - 2; e++) t.add(n.slice(e, e + 3));
|
|
@@ -116,8 +112,7 @@ function overlapSimilarity(e, t) {
|
|
|
116
112
|
for (let r of e) t.has(r) && n++;
|
|
117
113
|
return n < 2 && n < e.size ? 0 : n / Math.min(e.size, t.size);
|
|
118
114
|
}
|
|
119
|
-
|
|
120
|
-
//#region src/scout-index.ts
|
|
115
|
+
// src/scout-index.ts
|
|
121
116
|
function requireFiniteInteger(e, t, n) {
|
|
122
117
|
if (!Number.isFinite(e) || !Number.isInteger(e) || e < n) throw new ScoutConfigError(`${t} must be a finite integer greater than or equal to ${n}.`);
|
|
123
118
|
return e;
|
|
@@ -301,8 +296,7 @@ function createIndex(e, t) {
|
|
|
301
296
|
}
|
|
302
297
|
};
|
|
303
298
|
}
|
|
304
|
-
|
|
305
|
-
//#region src/reactive.ts
|
|
299
|
+
// src/reactive.ts
|
|
306
300
|
var r = 200;
|
|
307
301
|
function notify(e) {
|
|
308
302
|
for (let t of [...e]) try {
|
|
@@ -396,8 +390,7 @@ function createReactiveSearch(e, t) {
|
|
|
396
390
|
[Symbol.dispose]: r[Symbol.dispose]
|
|
397
391
|
};
|
|
398
392
|
}
|
|
399
|
-
|
|
400
|
-
//#region src/segment.ts
|
|
393
|
+
// src/segment.ts
|
|
401
394
|
function segmentWords(e) {
|
|
402
395
|
let t = getSegmenter();
|
|
403
396
|
return t ? [...t.segment(e)].filter((e) => e.isWordLike).map((e) => e.segment).join(" ") : e;
|
|
@@ -406,7 +399,6 @@ var i;
|
|
|
406
399
|
function getSegmenter() {
|
|
407
400
|
return i === void 0 && (i = typeof Intl > "u" || typeof Intl.Segmenter != "function" ? null : new Intl.Segmenter(void 0, { granularity: "word" })), i;
|
|
408
401
|
}
|
|
409
|
-
//#endregion
|
|
410
402
|
export { ScoutConfigError, ScoutDisposedError, ScoutError, createIndex, createReactiveSearch, createSearch, dedupeEntries, escapeRegExp, findMatchRanges, highlight, highlightField, rankEntries, segmentWords, splitPattern, toFilterPredicate, toSearchMatcher };
|
|
411
403
|
|
|
412
404
|
//# sourceMappingURL=scout.js.map
|
package/dist/scout.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"scout.js","names":["lastQuery","lastRevision","matches","query","index","options","result","item","matchSet","Score","lower","entries","fuzzyIds","scored","entry","label","score","a","b","seen","key","pattern","segments","last","match","text","start","REGEXP_SPECIAL","value","message","opts","v","words","ranges","word","pos","idx","merged","range","parts","cursor","end","m","field","trigrams","padded","i","shared","trigram","minimum","name","maximum","defs","def","fields","maxWeight","max","f","defaultThreshold","defaultLimit","defaultMinQueryLength","itemData","invertedIndex","mutationListeners","revision","listener","cachedNormalized","cachedTrigrams","normalized","fieldTrigrams","bucket","values","raw","stringify","record","candidates","queryTrigrams","items","bestScore","fieldScore","itemTrigrams","weighted","weight","changed","newText","oldTrigrams","newTrigrams","threshold","limit","minQueryLength","results","incoming","next","current","records","DEFAULT_DEBOUNCE","listeners","error","disposed","lifetimeSignal","debounceMs","tappers","tapper","controller","snapshot","committedQuery","timer","unsubscribeIndex","method","subscribeOptions","handler","tapOptions","state","segmenter","segment","cachedSegmenter"],"sources":["../src/adapters.ts","../src/entries.ts","../src/errors.ts","../src/tokenize.ts","../src/highlight.ts","../src/trigram.ts","../src/scout-index.ts","../src/reactive.ts","../src/segment.ts"],"sourcesContent":["import type { ScoutIndex } from './scout-index';\nimport type { SearchConstraints } from './types';\n\n/**\n * Adapts a `ScoutIndex` to sourcerer's local-source `filter` callback\n * (`filter?: (item, params) => boolean`, with the query passed as `params`).\n * Caches one match set per query so local filtering does not repeat index work per item.\n */\nexport function toSearchMatcher<T>(\n index: ScoutIndex<T>,\n options?: SearchConstraints,\n): (item: T, query: string) => boolean {\n let lastQuery: string | undefined;\n let lastRevision = -1;\n let matches = new Set<T>();\n\n return (item, query) => {\n if (query !== lastQuery || index.revision !== lastRevision) {\n lastQuery = query;\n lastRevision = index.revision;\n matches = new Set(index.search(query, options).map((result) => result.item));\n }\n\n return matches.has(item);\n };\n}\n\n/**\n * Returns a predicate that returns `true` for items matching `query` in the given index.\n *\n * The predicate is computed once at call time: call `toFilterPredicate` again if the\n * query or corpus changes.\n *\n * Compatible with `Array.filter`, `vault`'s `query.filter()`, or any predicate pipeline.\n */\nexport function toFilterPredicate<T>(\n index: ScoutIndex<T>,\n query: string,\n options?: SearchConstraints,\n): (item: T) => boolean {\n const matchSet = new Set(index.search(query, options).map((result) => result.item));\n\n return (item) => matchSet.has(item);\n}\n","/**\n * Entry ranking and segmentation for searchable reference lists: the presentation half of\n * search: a heterogeneous set of entries (glossary terms, rules, profiles) ordered by how\n * directly they match a typed query, plus segmentation helpers for rendering matched and\n * keyword-linked text.\n */\n\n/** An entry a reference list ranks and renders: identified, labeled, with summary and body text. */\nexport interface RankableEntry {\n id: string;\n /** The entry's title: the primary match surface. */\n label: string;\n /** One-line context shown beside the label. */\n meta: string;\n /** The entry's body text. */\n text: string;\n}\n\nconst Score = {\n Exact: 0,\n FuzzyOnly: 5,\n LabelContains: 2,\n LabelStart: 1,\n MetaContains: 3,\n TextContains: 4,\n} as const;\ntype Score = (typeof Score)[keyof typeof Score];\n\nexport interface RankEntriesOptions {\n /**\n * Entry ids the fuzzy index already matched: offered as the last resort when no field\n * contains the query, so approximate hits still surface.\n */\n fuzzyIds?: ReadonlySet<string>;\n}\n\n/**\n * Orders entries by how directly they match `query`: exact label, then label prefix, label,\n * meta, text, and finally fuzzy-index hits (`fuzzyIds`) as last resort. Entries matching none\n * of these are dropped. Ties break alphabetically by label, then by body text.\n */\nexport function rankEntries<T extends RankableEntry>(\n entries: readonly T[],\n query: string,\n options: RankEntriesOptions = {},\n): T[] {\n const lower = query.trim().toLowerCase();\n if (!lower) return [...entries];\n const fuzzyIds = options.fuzzyIds ?? new Set<string>();\n const scored: [Score, T][] = [];\n for (const entry of entries) {\n const label = entry.label.toLowerCase();\n const score =\n label === lower\n ? Score.Exact\n : label.startsWith(lower)\n ? Score.LabelStart\n : label.includes(lower)\n ? Score.LabelContains\n : entry.meta.toLowerCase().includes(lower)\n ? Score.MetaContains\n : entry.text.toLowerCase().includes(lower)\n ? Score.TextContains\n : fuzzyIds.has(entry.id)\n ? Score.FuzzyOnly\n : null;\n if (score !== null) scored.push([score, entry]);\n }\n return scored\n .sort((a, b) => a[0] - b[0] || a[1].label.localeCompare(b[1].label) || a[1].text.localeCompare(b[1].text))\n .map(([, entry]) => entry);\n}\n\n/** Drops entries whose label and body are identical to an earlier entry (alias rows). */\nexport function dedupeEntries<T extends { label: string; text: string }>(entries: readonly T[]): T[] {\n const seen = new Set<string>();\n return entries.filter((entry) => {\n const key = `${entry.label}\\u0000${entry.text}`.toLowerCase();\n if (seen.has(key)) return false;\n seen.add(key);\n return true;\n });\n}\n\n/** One renderable piece of a text: matched a pattern, or plain. */\nexport interface SplitSegment {\n /** Piece matched the pattern and should render emphasized or linked. */\n matched?: boolean;\n text: string;\n}\n\n/** Splits `text` on `pattern` matches, marking the matched pieces; a null pattern keeps it whole. */\nexport function splitPattern(text: string, pattern: RegExp | null): SplitSegment[] {\n if (!pattern) return [{ text }];\n const segments: SplitSegment[] = [];\n let last = 0;\n for (const match of text.matchAll(pattern)) {\n // Zero-length matches (optional-quantifier patterns) carry no text to emphasize.\n if (match[0].length === 0) continue;\n const start = match.index ?? 0;\n if (start > last) segments.push({ text: text.slice(last, start) });\n segments.push({ matched: true, text: match[0] });\n last = start + match[0].length;\n }\n if (last < text.length) segments.push({ text: text.slice(last) });\n return segments.length ? segments : [{ text }];\n}\n\nconst REGEXP_SPECIAL = /[.*+?^${}()|[\\]\\\\]/g;\n\n/** Escapes a literal so it can be embedded in a regular expression. */\nexport const escapeRegExp = (value: string): string => value.replace(REGEXP_SPECIAL, '\\\\$&');\n","/** Base class for all scout errors. Use `instanceof ScoutError` to catch any scout-originated error. */\nexport class ScoutError extends Error {\n constructor(message: string, opts?: ErrorOptions) {\n super(message, opts);\n this.name = new.target.name;\n Object.setPrototypeOf(this, new.target.prototype);\n }\n}\n\n/** Thrown when a method is called on a disposed search state instance. */\nexport class ScoutDisposedError extends ScoutError {}\n\n/** Thrown when an index, search, or reactive search receives an invalid configuration. */\nexport class ScoutConfigError extends ScoutError {}\n","/**\n * Normalizes a string for indexing or querying.\n * Lowercases, replaces punctuation (except apostrophes) with spaces,\n * trims, and collapses runs of whitespace to a single space.\n * @internal\n */\nexport function tokenize(text: string): string {\n return text\n .toLowerCase()\n .replace(/[^\\p{L}\\p{N}\\s']/gu, ' ')\n .trim()\n .replace(/\\s+/g, ' ');\n}\n\n/**\n * Converts an unknown field value to an indexable string.\n * Returns an empty string for `null`, `undefined`, objects, or symbols.\n * Arrays are joined with a space after filtering to string/number/boolean elements.\n * @internal\n */\nexport function defaultStringify(value: unknown): string {\n if (value == null) return '';\n\n if (typeof value === 'string') return value;\n\n if (typeof value === 'number' || typeof value === 'boolean') return String(value);\n\n if (Array.isArray(value)) {\n return value.filter((v) => typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean').join(' ');\n }\n\n return '';\n}\n","import { tokenize } from './tokenize';\nimport type { HighlightPart, SearchResult } from './types';\n\n/**\n * Finds character ranges within `text` where `query` words appear.\n * Ranges are sorted and overlapping ranges are merged.\n *\n * Useful when you need to apply match ranges to a different string than the\n * indexed field value (e.g. a truncated preview or a differently formatted display string).\n *\n * @param text - The string to search within.\n * @param query - Raw query text. It is normalized with Scout's tokenizer before literal lookup.\n * @returns Sorted, non-overlapping `[start, end]` character ranges.\n */\nexport function findMatchRanges(text: string, query: string): [number, number][] {\n const lower = text.toLowerCase();\n const words = tokenize(query).split(' ').filter(Boolean);\n const ranges: [number, number][] = [];\n\n for (const word of words) {\n let pos = 0;\n\n while (pos < lower.length) {\n const idx = lower.indexOf(word, pos);\n\n if (idx === -1) break;\n\n ranges.push([idx, idx + word.length]);\n pos = idx + 1;\n }\n }\n\n ranges.sort((a, b) => a[0] - b[0]);\n\n const merged: [number, number][] = [];\n\n for (const range of ranges) {\n const last = merged[merged.length - 1];\n\n if (last && range[0] <= last[1]) {\n last[1] = Math.max(last[1], range[1]);\n } else {\n merged.push([range[0], range[1]]);\n }\n }\n\n return merged;\n}\n\n/**\n * Splits `text` into highlighted and unhighlighted fragments using match `ranges`.\n *\n * Ranges must be sorted and non-overlapping (as produced by `SearchResult.matches[n].ranges`).\n * Use the returned parts to render highlighted text in a UI component.\n *\n * **`part.text` is the original, unescaped field value** (e.g. a user's name, bio, or\n * product title): this function does no HTML escaping. Render each part via safe DOM APIs\n * (`textContent`, a framework's text binding) and wrap `highlighted` parts in your own\n * element (e.g. `<mark>`); never concatenate `part.text` into an HTML string for\n * `innerHTML`, that reintroduces the XSS risk this structured return shape avoids.\n *\n * @example\n * ```ts\n * highlight('Hello World', [[0, 5]]);\n * // [{ text: 'Hello', highlighted: true }, { text: ' World', highlighted: false }]\n *\n * highlight('Hello World', [[0, 5], [6, 11]]);\n * // [\n * // { text: 'Hello', highlighted: true },\n * // { text: ' ', highlighted: false },\n * // { text: 'World', highlighted: true },\n * // ]\n * ```\n *\n * @param text - The original field value to split.\n * @param ranges - Sorted, non-overlapping `[start, end]` ranges from `FieldMatch.ranges`.\n * @returns An array of `HighlightPart` objects. Returns an empty array for an empty `text`.\n */\nexport function highlight(text: string, ranges: [number, number][]): HighlightPart[] {\n if (!text) return [];\n\n if (!ranges.length) return [{ highlighted: false, text }];\n\n const parts: HighlightPart[] = [];\n let cursor = 0;\n\n for (const [start, end] of ranges) {\n if (start > cursor) {\n parts.push({ highlighted: false, text: text.slice(cursor, start) });\n }\n\n if (end > start) {\n parts.push({ highlighted: true, text: text.slice(start, end) });\n }\n\n cursor = end;\n }\n\n if (cursor < text.length) {\n parts.push({ highlighted: false, text: text.slice(cursor) });\n }\n\n return parts;\n}\n\n/**\n * Finds the match ranges for `field` in `result` and splits `text` into\n * highlighted and unhighlighted fragments in one step.\n *\n * This is the ergonomic shorthand for the common pattern:\n * ```ts\n * const match = result.matches.find(m => m.field === 'name');\n * const parts = highlight(item.name, match?.ranges ?? []);\n * ```\n *\n * @example\n * ```ts\n * for (const result of index.search('alice')) {\n * const parts = highlightField(result, 'name', result.item.name);\n * console.log(parts.map(p => p.highlighted ? `[${p.text}]` : p.text).join(''));\n * }\n * ```\n *\n * @param result - A `SearchResult` from `ScoutIndex.search()`.\n * @param field - The field name to look up in `result.matches`.\n * @param text - The original field value string to split.\n * @returns An array of `HighlightPart` objects.\n */\nexport function highlightField<T>(result: SearchResult<T>, field: keyof T & string, text: string): HighlightPart[] {\n const match = result.matches.find((m) => m.field === field);\n\n return highlight(text, match?.ranges ?? []);\n}\n","/**\n * Generates padded trigrams from a pre-normalized (lowercased, trimmed) string.\n *\n * The string is padded with a single leading and trailing space so that prefix\n * and suffix characters form complete trigrams.\n *\n * @example\n * `\"hello\"` → `{ \" he\", \"hel\", \"ell\", \"llo\", \"lo \" }`\n *\n * @internal\n */\nexport function generateTrigrams(text: string): Set<string> {\n const trigrams = new Set<string>();\n const padded = ` ${text} `;\n\n for (let i = 0; i < padded.length - 2; i++) {\n trigrams.add(padded.slice(i, i + 3));\n }\n\n return trigrams;\n}\n\n/**\n * Computes the Szymkiewicz–Simpson overlap coefficient between two trigram sets.\n * Returns `0` when either set is empty; `1` when the smaller set is fully contained in the\n * larger one (including when the sets are identical).\n *\n * Formula: `|A ∩ B| / min(|A|, |B|)`\n *\n * Deliberately *not* the more common Sørensen–Dice coefficient (`2·|A∩B| / (|A|+|B|)`): Dice's\n * denominator grows with the *combined* size of both sets, so a short query fully contained in a\n * much longer target field is unfairly penalized purely for the target's length: e.g. querying\n * `\"fin\"` against a field containing `\"finalize project budget\"` shares 2 of the query's 3\n * trigrams under Dice (`2·2/(3+~24) ≈ 0.15`, likely below a typical `0.2` threshold) even though\n * the query is a clean prefix match. The overlap coefficient instead asks \"what fraction of the\n * *smaller* set (almost always the query, for autocomplete/command-palette use) was found?\" :\n * the same example scores `2/min(3,~24) ≈ 0.67`. Overlap is always `>= Dice` for the same inputs\n * (by the AM ≥ min inequality), so this can only ever include *more* legitimate matches, never\n * fewer, than the previous Dice-based scoring.\n *\n * A single shared trigram is treated as `0` (no match), not folded into the ratio above: for\n * any query of 4+ characters (the overwhelmingly common case: `generateTrigrams` yields\n * `length + 1` trigrams once padded), one incidental boundary trigram already clears the\n * `min(|A|,|B|) `-based ratio far enough to pass the library's own default `threshold` (`0.2`,\n * `1/4`), regardless of how unrelated the two strings actually are: e.g. `\"x300\"` and `\"a200\"`\n * share nothing but the trailing `\"00 \"` boundary trigram (both end in a round hundred) yet\n * would otherwise score `0.25`. A real fuzzy/typo match almost always shares *several*\n * consecutive trigrams, not one; requiring `>= 2` (or a full match, for the rare query short\n * enough to normalize to a single trigram) filters out this class of false positive without\n * weakening genuine partial matches: see `scout-index.ts`'s `search()` tests for both cases.\n *\n * @internal\n */\nexport function overlapSimilarity(a: Set<string>, b: Set<string>): number {\n if (a.size === 0 || b.size === 0) return 0;\n\n let shared = 0;\n\n for (const trigram of a) {\n if (b.has(trigram)) shared++;\n }\n\n if (shared < 2 && shared < a.size) return 0;\n\n return shared / Math.min(a.size, b.size);\n}\n","import { ScoutConfigError } from './errors';\nimport { findMatchRanges } from './highlight';\nimport { defaultStringify, tokenize } from './tokenize';\nimport { generateTrigrams, overlapSimilarity } from './trigram';\nimport type { FieldDef, FieldMatch, ScoutIndexOptions, SearchConstraints, SearchResult } from './types';\n\ntype FieldConfig<T> = {\n field: keyof T & string;\n stringify: (v: unknown) => string;\n weight: number;\n};\n\ntype ItemRecord = {\n /** Per-field trigrams for scoring. */\n trigrams: Map<string, Set<string>>;\n /** Per-field original text for highlighting. */\n values: Map<string, string>;\n};\n\n/**\n * A stateful, indexed search corpus. Created via `createIndex()`.\n *\n * Supports incremental `add()`, `remove()`, and `reindex()` operations: each patches\n * the trigram index in O(field_length) without a full rebuild.\n */\nexport interface ScoutIndex<T> {\n /** Adds `item` to the index. No-op if the item is already indexed (by reference). */\n add(item: T): void;\n /** All items currently in the index, in insertion order. */\n readonly items: readonly T[];\n /**\n * Subscribes `listener` to be called after every changed `add()` / `remove()` / `reindex()`\n * / `setItems()` operation. No-ops: e.g. removing an unindexed item or reconciling an\n * unchanged corpus: do not fire it. Each changed `setItems()` reconciliation fires once.\n * Returns an unsubscribe function.\n *\n * Framework-agnostic extension point: `createSearch()` uses this internally to keep\n * its snapshot in sync with index mutations. Most callers won't need this directly.\n */\n onMutate(listener: () => void): () => void;\n /**\n * Re-reads the item's current field values and rebuilds its index entry in-place,\n * only updating fields whose values have changed. Preserves insertion order.\n * No-op if the item is not in the index.\n */\n reindex(item: T): void;\n /**\n * Removes `item` from the index by reference equality.\n * No-op if the item is not in the index.\n */\n remove(item: T): void;\n /**\n * Monotonically increasing counter, incremented after every changed `add()` / `remove()` /\n * `reindex()` / `setItems()` operation. Use as a cache-busting token when caching search\n * results outside the index: `toSearchMatcher()` uses it for this purpose.\n */\n readonly revision: number;\n /**\n * Searches the index for `query` and returns results sorted by score descending.\n *\n * An empty (or whitespace-only) `query` returns all indexed items with `score = 1`.\n * A `query` with no indexable content after normalization (e.g. punctuation-only) returns\n * no results. Results below `threshold` are excluded. At most `limit` results are returned.\n */\n search(query: string, options?: SearchConstraints): SearchResult<T>[];\n /**\n * Reconciles the index to `items` by reference identity. Retained items are reindexed,\n * new items are added, missing items are removed, and one mutation notification fires\n * when indexed corpus or field values change. Duplicate references collapse to one item.\n */\n setItems(items: readonly T[]): void;\n /** Number of items currently in the index. */\n readonly size: number;\n}\n\nfunction requireFiniteInteger(value: number, name: string, minimum: number): number {\n if (!Number.isFinite(value) || !Number.isInteger(value) || value < minimum) {\n throw new ScoutConfigError(`${name} must be a finite integer greater than or equal to ${minimum}.`);\n }\n\n return value;\n}\n\nfunction requireFiniteNumber(value: number, name: string, minimum: number, maximum = Number.POSITIVE_INFINITY): number {\n if (!Number.isFinite(value) || value < minimum || value > maximum) {\n throw new ScoutConfigError(`${name} must be a finite number between ${minimum} and ${maximum}.`);\n }\n\n return value;\n}\n\nfunction resolveFields<T>(defs: ReadonlyArray<FieldDef<T>>): FieldConfig<T>[] {\n return defs.map((def) => {\n if (typeof def === 'string') {\n return { field: def, stringify: defaultStringify, weight: 1 };\n }\n\n return {\n field: def.field,\n stringify: def.stringify ?? defaultStringify,\n weight: requireFiniteNumber(def.weight ?? 1, `weight for field \"${def.field}\"`, Number.MIN_VALUE),\n };\n });\n}\n\n/**\n * Builds a trigram inverted index over `items` for fast fuzzy search.\n *\n * Construction is O(corpus × field_length). Subsequent `search()` calls are\n * O(candidates): far faster than per-query Levenshtein for large corpora.\n *\n * @example\n * ```ts\n * const index = createIndex(users, {\n * fields: [{ field: 'name', weight: 2 }, 'email'],\n * threshold: 0.3,\n * limit: 20,\n * });\n *\n * const results = index.search('alice');\n * ```\n *\n * @throws {ScoutConfigError} If options use an invalid field or numeric configuration.\n */\nexport function createIndex<T>(items: T[], options: ScoutIndexOptions<T>): ScoutIndex<T> {\n if (options.fields.length === 0) {\n throw new ScoutConfigError('createIndex: at least one field is required.');\n }\n\n const fields = resolveFields(options.fields);\n const maxWeight = fields.reduce((max, f) => Math.max(max, f.weight), 1);\n const defaultThreshold = requireFiniteNumber(options.threshold ?? 0.2, 'threshold', 0, 1);\n const defaultLimit = requireFiniteInteger(options.limit ?? 50, 'limit', 0);\n const defaultMinQueryLength = requireFiniteInteger(options.minQueryLength ?? 3, 'minQueryLength', 1);\n\n /** item → per-item record, preserves insertion order for `items` getter */\n const itemData = new Map<T, ItemRecord>();\n /** trigram → set of items that contain it */\n const invertedIndex = new Map<string, Set<T>>();\n const mutationListeners = new Set<() => void>();\n let revision = 0;\n\n function notifyMutation(): void {\n revision++;\n\n for (const listener of mutationListeners) listener();\n }\n\n /** Single-entry cache for the most recent normalized query's trigrams (F2). */\n let cachedNormalized: string | null = null;\n let cachedTrigrams: Set<string> | null = null;\n\n function getQueryTrigrams(normalized: string): Set<string> {\n if (normalized === cachedNormalized && cachedTrigrams !== null) return cachedTrigrams;\n\n cachedNormalized = normalized;\n cachedTrigrams = generateTrigrams(normalized);\n\n return cachedTrigrams;\n }\n\n function addFieldToIndex(item: T, fieldTrigrams: Set<string>): void {\n for (const trigram of fieldTrigrams) {\n let bucket = invertedIndex.get(trigram);\n\n if (!bucket) {\n bucket = new Set<T>();\n invertedIndex.set(trigram, bucket);\n }\n\n bucket.add(item);\n }\n }\n\n function removeFieldFromIndex(item: T, fieldTrigrams: Set<string>): void {\n for (const trigram of fieldTrigrams) {\n const bucket = invertedIndex.get(trigram);\n\n if (bucket) {\n bucket.delete(item);\n\n if (bucket.size === 0) invertedIndex.delete(trigram);\n }\n }\n }\n\n function addItem(item: T): void {\n const trigrams = new Map<string, Set<string>>();\n const values = new Map<string, string>();\n\n for (const { field, stringify } of fields) {\n const raw = item[field];\n const text = stringify(raw);\n const normalized = tokenize(text);\n const fieldTrigrams = normalized.length >= 1 ? generateTrigrams(normalized) : new Set<string>();\n\n trigrams.set(field, fieldTrigrams);\n values.set(field, text);\n addFieldToIndex(item, fieldTrigrams);\n }\n\n itemData.set(item, { trigrams, values });\n }\n\n /**\n * Performs a full linear scan over all items for short queries.\n * O(n × field_count): acceptable for small corpora; consider raising\n * `minQueryLength` on large datasets to avoid triggering this path.\n */\n function containmentScan(query: string): Set<T> {\n const result = new Set<T>();\n\n for (const [item, record] of itemData) {\n for (const value of record.values.values()) {\n if (value.toLowerCase().includes(query)) {\n result.add(item);\n break;\n }\n }\n }\n\n return result;\n }\n\n function trigramCandidates(queryTrigrams: Set<string>): Set<T> {\n const candidates = new Set<T>();\n\n for (const trigram of queryTrigrams) {\n const items = invertedIndex.get(trigram);\n\n if (items) {\n for (const item of items) candidates.add(item);\n }\n }\n\n return candidates;\n }\n\n function scoreCandidate(normalized: string, queryTrigrams: Set<string> | null, record: ItemRecord): number {\n let bestScore = 0;\n\n for (const { field, weight } of fields) {\n let fieldScore: number;\n\n if (queryTrigrams === null) {\n const raw = record.values.get(field) ?? '';\n\n fieldScore = raw.toLowerCase().includes(normalized) ? 1.0 : 0;\n } else {\n const itemTrigrams = record.trigrams.get(field);\n\n if (!itemTrigrams || itemTrigrams.size === 0) continue;\n\n fieldScore = overlapSimilarity(queryTrigrams, itemTrigrams);\n }\n\n const weighted = fieldScore * (weight / maxWeight);\n\n if (weighted > bestScore) bestScore = weighted;\n }\n\n return bestScore;\n }\n\n function computeMatches(query: string, values: Map<string, string>): FieldMatch<keyof T & string>[] {\n const matches: FieldMatch<keyof T & string>[] = [];\n\n for (const { field } of fields) {\n const text = values.get(field);\n\n if (!text) continue;\n\n const ranges = findMatchRanges(text, query);\n\n if (ranges.length > 0) matches.push({ field, ranges });\n }\n\n return matches;\n }\n\n function reindexItem(item: T): boolean {\n const record = itemData.get(item);\n\n if (!record) return false;\n\n let changed = false;\n\n for (const { field, stringify } of fields) {\n const newText = stringify(item[field]);\n const oldText = record.values.get(field);\n\n if (newText === oldText) continue;\n\n changed = true;\n\n const oldTrigrams = record.trigrams.get(field);\n\n if (oldTrigrams) removeFieldFromIndex(item, oldTrigrams);\n\n const normalized = tokenize(newText);\n const newTrigrams = normalized.length >= 1 ? generateTrigrams(normalized) : new Set<string>();\n\n record.trigrams.set(field, newTrigrams);\n record.values.set(field, newText);\n addFieldToIndex(item, newTrigrams);\n }\n\n return changed;\n }\n\n function removeItem(item: T): boolean {\n const record = itemData.get(item);\n\n if (!record) return false;\n\n for (const fieldTrigrams of record.trigrams.values()) {\n removeFieldFromIndex(item, fieldTrigrams);\n }\n\n itemData.delete(item);\n\n return true;\n }\n\n for (const item of items) {\n if (!itemData.has(item)) addItem(item);\n }\n\n const index: ScoutIndex<T> = {\n add(item: T): void {\n if (itemData.has(item)) return;\n\n addItem(item);\n notifyMutation();\n },\n\n get items(): readonly T[] {\n return [...itemData.keys()];\n },\n\n onMutate(listener: () => void): () => void {\n mutationListeners.add(listener);\n\n return () => {\n mutationListeners.delete(listener);\n };\n },\n\n reindex(item: T): void {\n if (reindexItem(item)) notifyMutation();\n },\n\n remove(item: T): void {\n if (removeItem(item)) notifyMutation();\n },\n\n get revision(): number {\n return revision;\n },\n\n search(query: string, options?: SearchConstraints): SearchResult<T>[] {\n const threshold = requireFiniteNumber(options?.threshold ?? defaultThreshold, 'threshold', 0, 1);\n const limit = requireFiniteInteger(options?.limit ?? defaultLimit, 'limit', 0);\n const minQueryLength = requireFiniteInteger(\n options?.minQueryLength ?? defaultMinQueryLength,\n 'minQueryLength',\n 1,\n );\n\n if (!query.trim()) {\n return [...itemData.keys()].slice(0, limit).map((item) => ({ item, matches: [], score: 1 }));\n }\n\n const normalized = tokenize(query);\n\n // Query had no indexable content (e.g. punctuation-only): no match, not \"match all\".\n if (!normalized) return [];\n\n const isShort = normalized.length < minQueryLength;\n const queryTrigrams = isShort ? null : getQueryTrigrams(normalized);\n const candidates = queryTrigrams === null ? containmentScan(normalized) : trigramCandidates(queryTrigrams);\n const results: SearchResult<T>[] = [];\n\n for (const item of candidates) {\n const record = itemData.get(item);\n\n if (!record) continue;\n\n const score = scoreCandidate(normalized, queryTrigrams, record);\n\n if (score >= threshold) {\n const matches = computeMatches(normalized, record.values);\n\n results.push({ item, matches, score });\n }\n }\n\n return results.sort((a, b) => b.score - a.score).slice(0, limit);\n },\n\n setItems(items: readonly T[]): void {\n const incoming = new Set(items);\n const next = [...incoming];\n let changed = false;\n\n for (const item of [...itemData.keys()]) {\n if (incoming.has(item)) continue;\n\n removeItem(item);\n changed = true;\n }\n\n for (const item of next) {\n if (!itemData.has(item)) {\n addItem(item);\n changed = true;\n } else if (reindexItem(item)) {\n changed = true;\n }\n }\n\n const current = [...itemData.keys()];\n const orderChanged = current.length !== next.length || current.some((item, index) => item !== next[index]);\n\n if (orderChanged) {\n const records = new Map(next.map((item) => [item, itemData.get(item)!]));\n\n itemData.clear();\n\n for (const [item, record] of records) itemData.set(item, record);\n\n changed = true;\n }\n\n if (changed) notifyMutation();\n },\n\n get size(): number {\n return itemData.size;\n },\n };\n\n return index;\n}\n","import { tapper } from '@vielzeug/arsenal';\n\nimport { ScoutConfigError, ScoutDisposedError } from './errors';\nimport { createIndex, type ScoutIndex } from './scout-index';\nimport type {\n CreateSearchOptions,\n ScoutEvent,\n ScoutIndexOptions,\n SearchResult,\n SearchSnapshot,\n SearchState,\n SearchSubscribeOptions,\n} from './types';\n\nexport type ReactiveSearch<T> = SearchState<T> & { readonly index: ScoutIndex<T> };\n\nconst DEFAULT_DEBOUNCE = 200;\n\nfunction notify(listeners: Set<() => void>): void {\n for (const listener of [...listeners]) {\n try {\n listener();\n } catch (error) {\n queueMicrotask(() => {\n throw error;\n });\n }\n }\n}\n\nfunction subscribe(\n listeners: Set<() => void>,\n listener: () => void,\n options: SearchSubscribeOptions | undefined,\n disposed: boolean,\n lifetimeSignal: AbortSignal,\n): () => void {\n if (disposed || options?.signal?.aborted) return () => {};\n\n listeners.add(listener);\n const unsubscribe = (): void => {\n listeners.delete(listener);\n options?.signal?.removeEventListener('abort', unsubscribe);\n };\n options?.signal?.addEventListener('abort', unsubscribe, { once: true, signal: lifetimeSignal });\n return unsubscribe;\n}\n\nexport function createSearch<T>(index: ScoutIndex<T>, options: CreateSearchOptions = {}): SearchState<T> {\n const { debounce: debounceMs = DEFAULT_DEBOUNCE, limit, minQueryLength, threshold } = options;\n\n if (!Number.isSafeInteger(debounceMs) || debounceMs < 0) {\n throw new ScoutConfigError('debounce must be a finite non-negative integer.');\n }\n\n const search = (query: string) => index.search(query, { limit, minQueryLength, threshold });\n const createSnapshot = (\n query: string,\n isSearching: boolean,\n results: ReadonlyArray<SearchResult<T>>,\n ): SearchSnapshot<T> => Object.freeze({ isSearching, query, results: Object.freeze(results) });\n const listeners = new Set<() => void>();\n const tappers = tapper<ScoutEvent<T>>();\n const controller = new AbortController();\n let snapshot = createSnapshot('', false, search(''));\n let committedQuery = '';\n let disposed = false;\n let timer: ReturnType<typeof setTimeout> | undefined;\n\n const publish = (next: SearchSnapshot<T>): void => {\n snapshot = next;\n notify(listeners);\n tappers.emit({ snapshot: next, type: 'state-change' });\n };\n const commit = (query: string): void => {\n committedQuery = query;\n publish(createSnapshot(query, false, search(query)));\n };\n const unsubscribeIndex = index.onMutate(() => {\n publish(createSnapshot(snapshot.query, snapshot.isSearching, search(committedQuery)));\n });\n const assertActive = (method: string): void => {\n if (disposed) throw new ScoutDisposedError(`SearchState.${method}() called after dispose()`);\n };\n const dispose = (): void => {\n if (disposed) return;\n disposed = true;\n if (timer !== undefined) clearTimeout(timer);\n timer = undefined;\n unsubscribeIndex();\n listeners.clear();\n tappers.clear();\n controller.abort();\n };\n\n return {\n clear() {\n assertActive('clear');\n if (snapshot.query === '' && !snapshot.isSearching) return;\n if (timer !== undefined) clearTimeout(timer);\n timer = undefined;\n commit('');\n },\n get disposalSignal() {\n return controller.signal;\n },\n dispose,\n get disposed() {\n return disposed;\n },\n getSnapshot() {\n return snapshot;\n },\n setQuery(query) {\n assertActive('setQuery');\n if (query === snapshot.query) return;\n if (timer !== undefined) clearTimeout(timer);\n timer = undefined;\n\n if (query === committedQuery) {\n publish(createSnapshot(query, false, snapshot.results));\n return;\n }\n\n if (debounceMs === 0) {\n commit(query);\n return;\n }\n\n publish(createSnapshot(query, true, snapshot.results));\n timer = setTimeout(() => {\n timer = undefined;\n commit(query);\n }, debounceMs);\n },\n subscribe(listener, subscribeOptions) {\n return subscribe(listeners, listener, subscribeOptions, disposed, controller.signal);\n },\n tap(handler, tapOptions) {\n if (disposed) return () => {};\n\n return tappers.tap(handler, tapOptions);\n },\n [Symbol.dispose]: dispose,\n };\n}\n\nexport function createReactiveSearch<T>(\n items: T[],\n options: ScoutIndexOptions<T> & Pick<CreateSearchOptions, 'debounce'>,\n): ReactiveSearch<T> {\n const index = createIndex(items, options);\n const state = createSearch(index, options);\n\n return {\n clear: state.clear,\n get disposalSignal() {\n return state.disposalSignal;\n },\n dispose: state.dispose,\n get disposed() {\n return state.disposed;\n },\n getSnapshot: state.getSnapshot,\n index,\n setQuery: state.setQuery,\n subscribe: state.subscribe,\n tap: state.tap,\n [Symbol.dispose]: state[Symbol.dispose],\n };\n}\n","/**\n * Splits `text` into whitespace-joined word segments using the runtime's native\n * `Intl.Segmenter`: no dependency, no bundle cost beyond this file.\n *\n * `tokenize()`'s trigram-based scoring works on unsegmented scripts (Chinese, Japanese,\n * Thai, ...) without this: trigrams are generated per-character, not per-word, but\n * `findMatchRanges()` / highlighting and the multi-word query semantics documented on\n * `SearchConstraints` assume space-separated words. Corpora in those scripts benefit from\n * pre-segmenting via a custom `stringify` so word boundaries exist for those features too.\n *\n * Not applied inside `tokenize()` itself: benchmarked at ~15x slower than the plain regex\n * path for the common whitespace-delimited case, which would regress `createIndex()`'s\n * O(corpus × field_length) construction cost for every caller, not just those indexing\n * unsegmented scripts. Opt in per-field instead.\n *\n * Returns `text` unchanged where `Intl.Segmenter` isn't available in the current runtime.\n *\n * @example\n * ```ts\n * const index = createIndex(documents, {\n * fields: [{ field: 'title', stringify: (v) => segmentWords(String(v)) }],\n * });\n * ```\n */\nexport function segmentWords(text: string): string {\n const segmenter = getSegmenter();\n\n if (!segmenter) return text;\n\n return [...segmenter.segment(text)]\n .filter((segment) => segment.isWordLike)\n .map((segment) => segment.segment)\n .join(' ');\n}\n\n// Constructing an `Intl.Segmenter` costs roughly as much as a `segment()` call itself\n// (both are called from `stringify`, once per field per item during `createIndex()`): cache\n// the single stateless instance instead of rebuilding it on every `segmentWords()` call.\nlet cachedSegmenter: Intl.Segmenter | null | undefined;\n\nfunction getSegmenter(): Intl.Segmenter | null {\n if (cachedSegmenter === undefined) {\n cachedSegmenter =\n typeof Intl === 'undefined' || typeof Intl.Segmenter !== 'function'\n ? null\n : new Intl.Segmenter(undefined, { granularity: 'word' });\n }\n\n return cachedSegmenter;\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"scout.js","names":["lastQuery","lastRevision","matches","query","index","options","result","item","matchSet","Score","lower","entries","fuzzyIds","scored","entry","label","score","a","b","seen","key","pattern","segments","last","match","text","start","REGEXP_SPECIAL","value","message","opts","v","words","ranges","word","pos","idx","merged","range","parts","cursor","end","m","field","trigrams","padded","i","shared","trigram","minimum","name","maximum","defs","def","fields","maxWeight","max","f","defaultThreshold","defaultLimit","defaultMinQueryLength","itemData","invertedIndex","mutationListeners","revision","listener","cachedNormalized","cachedTrigrams","normalized","fieldTrigrams","bucket","values","raw","stringify","record","candidates","queryTrigrams","items","bestScore","fieldScore","itemTrigrams","weighted","weight","changed","newText","oldTrigrams","newTrigrams","threshold","limit","minQueryLength","results","incoming","next","current","records","DEFAULT_DEBOUNCE","listeners","error","disposed","lifetimeSignal","debounceMs","tappers","tapper","controller","snapshot","committedQuery","timer","unsubscribeIndex","method","subscribeOptions","handler","tapOptions","state","segmenter","segment","cachedSegmenter"],"sources":["../src/adapters.ts","../src/entries.ts","../src/errors.ts","../src/tokenize.ts","../src/highlight.ts","../src/trigram.ts","../src/scout-index.ts","../src/reactive.ts","../src/segment.ts"],"sourcesContent":["import type { ScoutIndex } from './scout-index';\nimport type { SearchConstraints } from './types';\n\n/**\n * Adapts a `ScoutIndex` to sourcerer's local-source `filter` callback\n * (`filter?: (item, params) => boolean`, with the query passed as `params`).\n * Caches one match set per query so local filtering does not repeat index work per item.\n */\nexport function toSearchMatcher<T>(\n index: ScoutIndex<T>,\n options?: SearchConstraints,\n): (item: T, query: string) => boolean {\n let lastQuery: string | undefined;\n let lastRevision = -1;\n let matches = new Set<T>();\n\n return (item, query) => {\n if (query !== lastQuery || index.revision !== lastRevision) {\n lastQuery = query;\n lastRevision = index.revision;\n matches = new Set(index.search(query, options).map((result) => result.item));\n }\n\n return matches.has(item);\n };\n}\n\n/**\n * Returns a predicate that returns `true` for items matching `query` in the given index.\n *\n * The predicate is computed once at call time: call `toFilterPredicate` again if the\n * query or corpus changes.\n *\n * Compatible with `Array.filter`, `vault`'s `query.filter()`, or any predicate pipeline.\n */\nexport function toFilterPredicate<T>(\n index: ScoutIndex<T>,\n query: string,\n options?: SearchConstraints,\n): (item: T) => boolean {\n const matchSet = new Set(index.search(query, options).map((result) => result.item));\n\n return (item) => matchSet.has(item);\n}\n","/**\n * Entry ranking and segmentation for searchable reference lists: the presentation half of\n * search: a heterogeneous set of entries (glossary terms, rules, profiles) ordered by how\n * directly they match a typed query, plus segmentation helpers for rendering matched and\n * keyword-linked text.\n */\n\n/** An entry a reference list ranks and renders: identified, labeled, with summary and body text. */\nexport interface RankableEntry {\n id: string;\n /** The entry's title: the primary match surface. */\n label: string;\n /** One-line context shown beside the label. */\n meta: string;\n /** The entry's body text. */\n text: string;\n}\n\nconst Score = {\n Exact: 0,\n FuzzyOnly: 5,\n LabelContains: 2,\n LabelStart: 1,\n MetaContains: 3,\n TextContains: 4,\n} as const;\ntype Score = (typeof Score)[keyof typeof Score];\n\nexport interface RankEntriesOptions {\n /**\n * Entry ids the fuzzy index already matched: offered as the last resort when no field\n * contains the query, so approximate hits still surface.\n */\n fuzzyIds?: ReadonlySet<string>;\n}\n\n/**\n * Orders entries by how directly they match `query`: exact label, then label prefix, label,\n * meta, text, and finally fuzzy-index hits (`fuzzyIds`) as last resort. Entries matching none\n * of these are dropped. Ties break alphabetically by label, then by body text.\n */\nexport function rankEntries<T extends RankableEntry>(\n entries: readonly T[],\n query: string,\n options: RankEntriesOptions = {},\n): T[] {\n const lower = query.trim().toLowerCase();\n if (!lower) return [...entries];\n const fuzzyIds = options.fuzzyIds ?? new Set<string>();\n const scored: [Score, T][] = [];\n for (const entry of entries) {\n const label = entry.label.toLowerCase();\n const score =\n label === lower\n ? Score.Exact\n : label.startsWith(lower)\n ? Score.LabelStart\n : label.includes(lower)\n ? Score.LabelContains\n : entry.meta.toLowerCase().includes(lower)\n ? Score.MetaContains\n : entry.text.toLowerCase().includes(lower)\n ? Score.TextContains\n : fuzzyIds.has(entry.id)\n ? Score.FuzzyOnly\n : null;\n if (score !== null) scored.push([score, entry]);\n }\n return scored\n .sort((a, b) => a[0] - b[0] || a[1].label.localeCompare(b[1].label) || a[1].text.localeCompare(b[1].text))\n .map(([, entry]) => entry);\n}\n\n/** Drops entries whose label and body are identical to an earlier entry (alias rows). */\nexport function dedupeEntries<T extends { label: string; text: string }>(entries: readonly T[]): T[] {\n const seen = new Set<string>();\n return entries.filter((entry) => {\n const key = `${entry.label}\\u0000${entry.text}`.toLowerCase();\n if (seen.has(key)) return false;\n seen.add(key);\n return true;\n });\n}\n\n/** One renderable piece of a text: matched a pattern, or plain. */\nexport interface SplitSegment {\n /** Piece matched the pattern and should render emphasized or linked. */\n matched?: boolean;\n text: string;\n}\n\n/** Splits `text` on `pattern` matches, marking the matched pieces; a null pattern keeps it whole. */\nexport function splitPattern(text: string, pattern: RegExp | null): SplitSegment[] {\n if (!pattern) return [{ text }];\n const segments: SplitSegment[] = [];\n let last = 0;\n for (const match of text.matchAll(pattern)) {\n // Zero-length matches (optional-quantifier patterns) carry no text to emphasize.\n if (match[0].length === 0) continue;\n const start = match.index ?? 0;\n if (start > last) segments.push({ text: text.slice(last, start) });\n segments.push({ matched: true, text: match[0] });\n last = start + match[0].length;\n }\n if (last < text.length) segments.push({ text: text.slice(last) });\n return segments.length ? segments : [{ text }];\n}\n\nconst REGEXP_SPECIAL = /[.*+?^${}()|[\\]\\\\]/g;\n\n/** Escapes a literal so it can be embedded in a regular expression. */\nexport const escapeRegExp = (value: string): string => value.replace(REGEXP_SPECIAL, '\\\\$&');\n","/** Base class for all scout errors. Use `instanceof ScoutError` to catch any scout-originated error. */\nexport class ScoutError extends Error {\n constructor(message: string, opts?: ErrorOptions) {\n super(message, opts);\n this.name = new.target.name;\n Object.setPrototypeOf(this, new.target.prototype);\n }\n}\n\n/** Thrown when a method is called on a disposed search state instance. */\nexport class ScoutDisposedError extends ScoutError {}\n\n/** Thrown when an index, search, or reactive search receives an invalid configuration. */\nexport class ScoutConfigError extends ScoutError {}\n","/**\n * Normalizes a string for indexing or querying.\n * Lowercases, replaces punctuation (except apostrophes) with spaces,\n * trims, and collapses runs of whitespace to a single space.\n * @internal\n */\nexport function tokenize(text: string): string {\n return text\n .toLowerCase()\n .replace(/[^\\p{L}\\p{N}\\s']/gu, ' ')\n .trim()\n .replace(/\\s+/g, ' ');\n}\n\n/**\n * Converts an unknown field value to an indexable string.\n * Returns an empty string for `null`, `undefined`, objects, or symbols.\n * Arrays are joined with a space after filtering to string/number/boolean elements.\n * @internal\n */\nexport function defaultStringify(value: unknown): string {\n if (value == null) return '';\n\n if (typeof value === 'string') return value;\n\n if (typeof value === 'number' || typeof value === 'boolean') return String(value);\n\n if (Array.isArray(value)) {\n return value.filter((v) => typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean').join(' ');\n }\n\n return '';\n}\n","import { tokenize } from './tokenize';\nimport type { HighlightPart, SearchResult } from './types';\n\n/**\n * Finds character ranges within `text` where `query` words appear.\n * Ranges are sorted and overlapping ranges are merged.\n *\n * Useful when you need to apply match ranges to a different string than the\n * indexed field value (e.g. a truncated preview or a differently formatted display string).\n *\n * @param text - The string to search within.\n * @param query - Raw query text. It is normalized with Scout's tokenizer before literal lookup.\n * @returns Sorted, non-overlapping `[start, end]` character ranges.\n */\nexport function findMatchRanges(text: string, query: string): [number, number][] {\n const lower = text.toLowerCase();\n const words = tokenize(query).split(' ').filter(Boolean);\n const ranges: [number, number][] = [];\n\n for (const word of words) {\n let pos = 0;\n\n while (pos < lower.length) {\n const idx = lower.indexOf(word, pos);\n\n if (idx === -1) break;\n\n ranges.push([idx, idx + word.length]);\n pos = idx + 1;\n }\n }\n\n ranges.sort((a, b) => a[0] - b[0]);\n\n const merged: [number, number][] = [];\n\n for (const range of ranges) {\n const last = merged[merged.length - 1];\n\n if (last && range[0] <= last[1]) {\n last[1] = Math.max(last[1], range[1]);\n } else {\n merged.push([range[0], range[1]]);\n }\n }\n\n return merged;\n}\n\n/**\n * Splits `text` into highlighted and unhighlighted fragments using match `ranges`.\n *\n * Ranges must be sorted and non-overlapping (as produced by `SearchResult.matches[n].ranges`).\n * Use the returned parts to render highlighted text in a UI component.\n *\n * **`part.text` is the original, unescaped field value** (e.g. a user's name, bio, or\n * product title): this function does no HTML escaping. Render each part via safe DOM APIs\n * (`textContent`, a framework's text binding) and wrap `highlighted` parts in your own\n * element (e.g. `<mark>`); never concatenate `part.text` into an HTML string for\n * `innerHTML`, that reintroduces the XSS risk this structured return shape avoids.\n *\n * @example\n * ```ts\n * highlight('Hello World', [[0, 5]]);\n * // [{ text: 'Hello', highlighted: true }, { text: ' World', highlighted: false }]\n *\n * highlight('Hello World', [[0, 5], [6, 11]]);\n * // [\n * // { text: 'Hello', highlighted: true },\n * // { text: ' ', highlighted: false },\n * // { text: 'World', highlighted: true },\n * // ]\n * ```\n *\n * @param text - The original field value to split.\n * @param ranges - Sorted, non-overlapping `[start, end]` ranges from `FieldMatch.ranges`.\n * @returns An array of `HighlightPart` objects. Returns an empty array for an empty `text`.\n */\nexport function highlight(text: string, ranges: [number, number][]): HighlightPart[] {\n if (!text) return [];\n\n if (!ranges.length) return [{ highlighted: false, text }];\n\n const parts: HighlightPart[] = [];\n let cursor = 0;\n\n for (const [start, end] of ranges) {\n if (start > cursor) {\n parts.push({ highlighted: false, text: text.slice(cursor, start) });\n }\n\n if (end > start) {\n parts.push({ highlighted: true, text: text.slice(start, end) });\n }\n\n cursor = end;\n }\n\n if (cursor < text.length) {\n parts.push({ highlighted: false, text: text.slice(cursor) });\n }\n\n return parts;\n}\n\n/**\n * Finds the match ranges for `field` in `result` and splits `text` into\n * highlighted and unhighlighted fragments in one step.\n *\n * This is the ergonomic shorthand for the common pattern:\n * ```ts\n * const match = result.matches.find(m => m.field === 'name');\n * const parts = highlight(item.name, match?.ranges ?? []);\n * ```\n *\n * @example\n * ```ts\n * for (const result of index.search('alice')) {\n * const parts = highlightField(result, 'name', result.item.name);\n * console.log(parts.map(p => p.highlighted ? `[${p.text}]` : p.text).join(''));\n * }\n * ```\n *\n * @param result - A `SearchResult` from `ScoutIndex.search()`.\n * @param field - The field name to look up in `result.matches`.\n * @param text - The original field value string to split.\n * @returns An array of `HighlightPart` objects.\n */\nexport function highlightField<T>(result: SearchResult<T>, field: keyof T & string, text: string): HighlightPart[] {\n const match = result.matches.find((m) => m.field === field);\n\n return highlight(text, match?.ranges ?? []);\n}\n","/**\n * Generates padded trigrams from a pre-normalized (lowercased, trimmed) string.\n *\n * The string is padded with a single leading and trailing space so that prefix\n * and suffix characters form complete trigrams.\n *\n * @example\n * `\"hello\"` → `{ \" he\", \"hel\", \"ell\", \"llo\", \"lo \" }`\n *\n * @internal\n */\nexport function generateTrigrams(text: string): Set<string> {\n const trigrams = new Set<string>();\n const padded = ` ${text} `;\n\n for (let i = 0; i < padded.length - 2; i++) {\n trigrams.add(padded.slice(i, i + 3));\n }\n\n return trigrams;\n}\n\n/**\n * Computes the Szymkiewicz–Simpson overlap coefficient between two trigram sets.\n * Returns `0` when either set is empty; `1` when the smaller set is fully contained in the\n * larger one (including when the sets are identical).\n *\n * Formula: `|A ∩ B| / min(|A|, |B|)`\n *\n * Deliberately *not* the more common Sørensen–Dice coefficient (`2·|A∩B| / (|A|+|B|)`): Dice's\n * denominator grows with the *combined* size of both sets, so a short query fully contained in a\n * much longer target field is unfairly penalized purely for the target's length: e.g. querying\n * `\"fin\"` against a field containing `\"finalize project budget\"` shares 2 of the query's 3\n * trigrams under Dice (`2·2/(3+~24) ≈ 0.15`, likely below a typical `0.2` threshold) even though\n * the query is a clean prefix match. The overlap coefficient instead asks \"what fraction of the\n * *smaller* set (almost always the query, for autocomplete/command-palette use) was found?\" :\n * the same example scores `2/min(3,~24) ≈ 0.67`. Overlap is always `>= Dice` for the same inputs\n * (by the AM ≥ min inequality), so this can only ever include *more* legitimate matches, never\n * fewer, than the previous Dice-based scoring.\n *\n * A single shared trigram is treated as `0` (no match), not folded into the ratio above: for\n * any query of 4+ characters (the overwhelmingly common case: `generateTrigrams` yields\n * `length + 1` trigrams once padded), one incidental boundary trigram already clears the\n * `min(|A|,|B|) `-based ratio far enough to pass the library's own default `threshold` (`0.2`,\n * `1/4`), regardless of how unrelated the two strings actually are: e.g. `\"x300\"` and `\"a200\"`\n * share nothing but the trailing `\"00 \"` boundary trigram (both end in a round hundred) yet\n * would otherwise score `0.25`. A real fuzzy/typo match almost always shares *several*\n * consecutive trigrams, not one; requiring `>= 2` (or a full match, for the rare query short\n * enough to normalize to a single trigram) filters out this class of false positive without\n * weakening genuine partial matches: see `scout-index.ts`'s `search()` tests for both cases.\n *\n * @internal\n */\nexport function overlapSimilarity(a: Set<string>, b: Set<string>): number {\n if (a.size === 0 || b.size === 0) return 0;\n\n let shared = 0;\n\n for (const trigram of a) {\n if (b.has(trigram)) shared++;\n }\n\n if (shared < 2 && shared < a.size) return 0;\n\n return shared / Math.min(a.size, b.size);\n}\n","import { ScoutConfigError } from './errors';\nimport { findMatchRanges } from './highlight';\nimport { defaultStringify, tokenize } from './tokenize';\nimport { generateTrigrams, overlapSimilarity } from './trigram';\nimport type { FieldDef, FieldMatch, ScoutIndexOptions, SearchConstraints, SearchResult } from './types';\n\ntype FieldConfig<T> = {\n field: keyof T & string;\n stringify: (v: unknown) => string;\n weight: number;\n};\n\ntype ItemRecord = {\n /** Per-field trigrams for scoring. */\n trigrams: Map<string, Set<string>>;\n /** Per-field original text for highlighting. */\n values: Map<string, string>;\n};\n\n/**\n * A stateful, indexed search corpus. Created via `createIndex()`.\n *\n * Supports incremental `add()`, `remove()`, and `reindex()` operations: each patches\n * the trigram index in O(field_length) without a full rebuild.\n */\nexport interface ScoutIndex<T> {\n /** Adds `item` to the index. No-op if the item is already indexed (by reference). */\n add(item: T): void;\n /** All items currently in the index, in insertion order. */\n readonly items: readonly T[];\n /**\n * Subscribes `listener` to be called after every changed `add()` / `remove()` / `reindex()`\n * / `setItems()` operation. No-ops: e.g. removing an unindexed item or reconciling an\n * unchanged corpus: do not fire it. Each changed `setItems()` reconciliation fires once.\n * Returns an unsubscribe function.\n *\n * Framework-agnostic extension point: `createSearch()` uses this internally to keep\n * its snapshot in sync with index mutations. Most callers won't need this directly.\n */\n onMutate(listener: () => void): () => void;\n /**\n * Re-reads the item's current field values and rebuilds its index entry in-place,\n * only updating fields whose values have changed. Preserves insertion order.\n * No-op if the item is not in the index.\n */\n reindex(item: T): void;\n /**\n * Removes `item` from the index by reference equality.\n * No-op if the item is not in the index.\n */\n remove(item: T): void;\n /**\n * Monotonically increasing counter, incremented after every changed `add()` / `remove()` /\n * `reindex()` / `setItems()` operation. Use as a cache-busting token when caching search\n * results outside the index: `toSearchMatcher()` uses it for this purpose.\n */\n readonly revision: number;\n /**\n * Searches the index for `query` and returns results sorted by score descending.\n *\n * An empty (or whitespace-only) `query` returns all indexed items with `score = 1`.\n * A `query` with no indexable content after normalization (e.g. punctuation-only) returns\n * no results. Results below `threshold` are excluded. At most `limit` results are returned.\n */\n search(query: string, options?: SearchConstraints): SearchResult<T>[];\n /**\n * Reconciles the index to `items` by reference identity. Retained items are reindexed,\n * new items are added, missing items are removed, and one mutation notification fires\n * when indexed corpus or field values change. Duplicate references collapse to one item.\n */\n setItems(items: readonly T[]): void;\n /** Number of items currently in the index. */\n readonly size: number;\n}\n\nfunction requireFiniteInteger(value: number, name: string, minimum: number): number {\n if (!Number.isFinite(value) || !Number.isInteger(value) || value < minimum) {\n throw new ScoutConfigError(`${name} must be a finite integer greater than or equal to ${minimum}.`);\n }\n\n return value;\n}\n\nfunction requireFiniteNumber(value: number, name: string, minimum: number, maximum = Number.POSITIVE_INFINITY): number {\n if (!Number.isFinite(value) || value < minimum || value > maximum) {\n throw new ScoutConfigError(`${name} must be a finite number between ${minimum} and ${maximum}.`);\n }\n\n return value;\n}\n\nfunction resolveFields<T>(defs: ReadonlyArray<FieldDef<T>>): FieldConfig<T>[] {\n return defs.map((def) => {\n if (typeof def === 'string') {\n return { field: def, stringify: defaultStringify, weight: 1 };\n }\n\n return {\n field: def.field,\n stringify: def.stringify ?? defaultStringify,\n weight: requireFiniteNumber(def.weight ?? 1, `weight for field \"${def.field}\"`, Number.MIN_VALUE),\n };\n });\n}\n\n/**\n * Builds a trigram inverted index over `items` for fast fuzzy search.\n *\n * Construction is O(corpus × field_length). Subsequent `search()` calls are\n * O(candidates): far faster than per-query Levenshtein for large corpora.\n *\n * @example\n * ```ts\n * const index = createIndex(users, {\n * fields: [{ field: 'name', weight: 2 }, 'email'],\n * threshold: 0.3,\n * limit: 20,\n * });\n *\n * const results = index.search('alice');\n * ```\n *\n * @throws {ScoutConfigError} If options use an invalid field or numeric configuration.\n */\nexport function createIndex<T>(items: T[], options: ScoutIndexOptions<T>): ScoutIndex<T> {\n if (options.fields.length === 0) {\n throw new ScoutConfigError('createIndex: at least one field is required.');\n }\n\n const fields = resolveFields(options.fields);\n const maxWeight = fields.reduce((max, f) => Math.max(max, f.weight), 1);\n const defaultThreshold = requireFiniteNumber(options.threshold ?? 0.2, 'threshold', 0, 1);\n const defaultLimit = requireFiniteInteger(options.limit ?? 50, 'limit', 0);\n const defaultMinQueryLength = requireFiniteInteger(options.minQueryLength ?? 3, 'minQueryLength', 1);\n\n /** item → per-item record, preserves insertion order for `items` getter */\n const itemData = new Map<T, ItemRecord>();\n /** trigram → set of items that contain it */\n const invertedIndex = new Map<string, Set<T>>();\n const mutationListeners = new Set<() => void>();\n let revision = 0;\n\n function notifyMutation(): void {\n revision++;\n\n for (const listener of mutationListeners) listener();\n }\n\n /** Single-entry cache for the most recent normalized query's trigrams (F2). */\n let cachedNormalized: string | null = null;\n let cachedTrigrams: Set<string> | null = null;\n\n function getQueryTrigrams(normalized: string): Set<string> {\n if (normalized === cachedNormalized && cachedTrigrams !== null) return cachedTrigrams;\n\n cachedNormalized = normalized;\n cachedTrigrams = generateTrigrams(normalized);\n\n return cachedTrigrams;\n }\n\n function addFieldToIndex(item: T, fieldTrigrams: Set<string>): void {\n for (const trigram of fieldTrigrams) {\n let bucket = invertedIndex.get(trigram);\n\n if (!bucket) {\n bucket = new Set<T>();\n invertedIndex.set(trigram, bucket);\n }\n\n bucket.add(item);\n }\n }\n\n function removeFieldFromIndex(item: T, fieldTrigrams: Set<string>): void {\n for (const trigram of fieldTrigrams) {\n const bucket = invertedIndex.get(trigram);\n\n if (bucket) {\n bucket.delete(item);\n\n if (bucket.size === 0) invertedIndex.delete(trigram);\n }\n }\n }\n\n function addItem(item: T): void {\n const trigrams = new Map<string, Set<string>>();\n const values = new Map<string, string>();\n\n for (const { field, stringify } of fields) {\n const raw = item[field];\n const text = stringify(raw);\n const normalized = tokenize(text);\n const fieldTrigrams = normalized.length >= 1 ? generateTrigrams(normalized) : new Set<string>();\n\n trigrams.set(field, fieldTrigrams);\n values.set(field, text);\n addFieldToIndex(item, fieldTrigrams);\n }\n\n itemData.set(item, { trigrams, values });\n }\n\n /**\n * Performs a full linear scan over all items for short queries.\n * O(n × field_count): acceptable for small corpora; consider raising\n * `minQueryLength` on large datasets to avoid triggering this path.\n */\n function containmentScan(query: string): Set<T> {\n const result = new Set<T>();\n\n for (const [item, record] of itemData) {\n for (const value of record.values.values()) {\n if (value.toLowerCase().includes(query)) {\n result.add(item);\n break;\n }\n }\n }\n\n return result;\n }\n\n function trigramCandidates(queryTrigrams: Set<string>): Set<T> {\n const candidates = new Set<T>();\n\n for (const trigram of queryTrigrams) {\n const items = invertedIndex.get(trigram);\n\n if (items) {\n for (const item of items) candidates.add(item);\n }\n }\n\n return candidates;\n }\n\n function scoreCandidate(normalized: string, queryTrigrams: Set<string> | null, record: ItemRecord): number {\n let bestScore = 0;\n\n for (const { field, weight } of fields) {\n let fieldScore: number;\n\n if (queryTrigrams === null) {\n const raw = record.values.get(field) ?? '';\n\n fieldScore = raw.toLowerCase().includes(normalized) ? 1.0 : 0;\n } else {\n const itemTrigrams = record.trigrams.get(field);\n\n if (!itemTrigrams || itemTrigrams.size === 0) continue;\n\n fieldScore = overlapSimilarity(queryTrigrams, itemTrigrams);\n }\n\n const weighted = fieldScore * (weight / maxWeight);\n\n if (weighted > bestScore) bestScore = weighted;\n }\n\n return bestScore;\n }\n\n function computeMatches(query: string, values: Map<string, string>): FieldMatch<keyof T & string>[] {\n const matches: FieldMatch<keyof T & string>[] = [];\n\n for (const { field } of fields) {\n const text = values.get(field);\n\n if (!text) continue;\n\n const ranges = findMatchRanges(text, query);\n\n if (ranges.length > 0) matches.push({ field, ranges });\n }\n\n return matches;\n }\n\n function reindexItem(item: T): boolean {\n const record = itemData.get(item);\n\n if (!record) return false;\n\n let changed = false;\n\n for (const { field, stringify } of fields) {\n const newText = stringify(item[field]);\n const oldText = record.values.get(field);\n\n if (newText === oldText) continue;\n\n changed = true;\n\n const oldTrigrams = record.trigrams.get(field);\n\n if (oldTrigrams) removeFieldFromIndex(item, oldTrigrams);\n\n const normalized = tokenize(newText);\n const newTrigrams = normalized.length >= 1 ? generateTrigrams(normalized) : new Set<string>();\n\n record.trigrams.set(field, newTrigrams);\n record.values.set(field, newText);\n addFieldToIndex(item, newTrigrams);\n }\n\n return changed;\n }\n\n function removeItem(item: T): boolean {\n const record = itemData.get(item);\n\n if (!record) return false;\n\n for (const fieldTrigrams of record.trigrams.values()) {\n removeFieldFromIndex(item, fieldTrigrams);\n }\n\n itemData.delete(item);\n\n return true;\n }\n\n for (const item of items) {\n if (!itemData.has(item)) addItem(item);\n }\n\n const index: ScoutIndex<T> = {\n add(item: T): void {\n if (itemData.has(item)) return;\n\n addItem(item);\n notifyMutation();\n },\n\n get items(): readonly T[] {\n return [...itemData.keys()];\n },\n\n onMutate(listener: () => void): () => void {\n mutationListeners.add(listener);\n\n return () => {\n mutationListeners.delete(listener);\n };\n },\n\n reindex(item: T): void {\n if (reindexItem(item)) notifyMutation();\n },\n\n remove(item: T): void {\n if (removeItem(item)) notifyMutation();\n },\n\n get revision(): number {\n return revision;\n },\n\n search(query: string, options?: SearchConstraints): SearchResult<T>[] {\n const threshold = requireFiniteNumber(options?.threshold ?? defaultThreshold, 'threshold', 0, 1);\n const limit = requireFiniteInteger(options?.limit ?? defaultLimit, 'limit', 0);\n const minQueryLength = requireFiniteInteger(\n options?.minQueryLength ?? defaultMinQueryLength,\n 'minQueryLength',\n 1,\n );\n\n if (!query.trim()) {\n return [...itemData.keys()].slice(0, limit).map((item) => ({ item, matches: [], score: 1 }));\n }\n\n const normalized = tokenize(query);\n\n // Query had no indexable content (e.g. punctuation-only): no match, not \"match all\".\n if (!normalized) return [];\n\n const isShort = normalized.length < minQueryLength;\n const queryTrigrams = isShort ? null : getQueryTrigrams(normalized);\n const candidates = queryTrigrams === null ? containmentScan(normalized) : trigramCandidates(queryTrigrams);\n const results: SearchResult<T>[] = [];\n\n for (const item of candidates) {\n const record = itemData.get(item);\n\n if (!record) continue;\n\n const score = scoreCandidate(normalized, queryTrigrams, record);\n\n if (score >= threshold) {\n const matches = computeMatches(normalized, record.values);\n\n results.push({ item, matches, score });\n }\n }\n\n return results.sort((a, b) => b.score - a.score).slice(0, limit);\n },\n\n setItems(items: readonly T[]): void {\n const incoming = new Set(items);\n const next = [...incoming];\n let changed = false;\n\n for (const item of [...itemData.keys()]) {\n if (incoming.has(item)) continue;\n\n removeItem(item);\n changed = true;\n }\n\n for (const item of next) {\n if (!itemData.has(item)) {\n addItem(item);\n changed = true;\n } else if (reindexItem(item)) {\n changed = true;\n }\n }\n\n const current = [...itemData.keys()];\n const orderChanged = current.length !== next.length || current.some((item, index) => item !== next[index]);\n\n if (orderChanged) {\n const records = new Map(next.map((item) => [item, itemData.get(item)!]));\n\n itemData.clear();\n\n for (const [item, record] of records) itemData.set(item, record);\n\n changed = true;\n }\n\n if (changed) notifyMutation();\n },\n\n get size(): number {\n return itemData.size;\n },\n };\n\n return index;\n}\n","import { tapper } from '@vielzeug/arsenal';\n\nimport { ScoutConfigError, ScoutDisposedError } from './errors';\nimport { createIndex, type ScoutIndex } from './scout-index';\nimport type {\n CreateSearchOptions,\n ScoutEvent,\n ScoutIndexOptions,\n SearchResult,\n SearchSnapshot,\n SearchState,\n SearchSubscribeOptions,\n} from './types';\n\nexport type ReactiveSearch<T> = SearchState<T> & { readonly index: ScoutIndex<T> };\n\nconst DEFAULT_DEBOUNCE = 200;\n\nfunction notify(listeners: Set<() => void>): void {\n for (const listener of [...listeners]) {\n try {\n listener();\n } catch (error) {\n queueMicrotask(() => {\n throw error;\n });\n }\n }\n}\n\nfunction subscribe(\n listeners: Set<() => void>,\n listener: () => void,\n options: SearchSubscribeOptions | undefined,\n disposed: boolean,\n lifetimeSignal: AbortSignal,\n): () => void {\n if (disposed || options?.signal?.aborted) return () => {};\n\n listeners.add(listener);\n const unsubscribe = (): void => {\n listeners.delete(listener);\n options?.signal?.removeEventListener('abort', unsubscribe);\n };\n options?.signal?.addEventListener('abort', unsubscribe, { once: true, signal: lifetimeSignal });\n return unsubscribe;\n}\n\nexport function createSearch<T>(index: ScoutIndex<T>, options: CreateSearchOptions = {}): SearchState<T> {\n const { debounce: debounceMs = DEFAULT_DEBOUNCE, limit, minQueryLength, threshold } = options;\n\n if (!Number.isSafeInteger(debounceMs) || debounceMs < 0) {\n throw new ScoutConfigError('debounce must be a finite non-negative integer.');\n }\n\n const search = (query: string) => index.search(query, { limit, minQueryLength, threshold });\n const createSnapshot = (\n query: string,\n isSearching: boolean,\n results: ReadonlyArray<SearchResult<T>>,\n ): SearchSnapshot<T> => Object.freeze({ isSearching, query, results: Object.freeze(results) });\n const listeners = new Set<() => void>();\n const tappers = tapper<ScoutEvent<T>>();\n const controller = new AbortController();\n let snapshot = createSnapshot('', false, search(''));\n let committedQuery = '';\n let disposed = false;\n let timer: ReturnType<typeof setTimeout> | undefined;\n\n const publish = (next: SearchSnapshot<T>): void => {\n snapshot = next;\n notify(listeners);\n tappers.emit({ snapshot: next, type: 'state-change' });\n };\n const commit = (query: string): void => {\n committedQuery = query;\n publish(createSnapshot(query, false, search(query)));\n };\n const unsubscribeIndex = index.onMutate(() => {\n publish(createSnapshot(snapshot.query, snapshot.isSearching, search(committedQuery)));\n });\n const assertActive = (method: string): void => {\n if (disposed) throw new ScoutDisposedError(`SearchState.${method}() called after dispose()`);\n };\n const dispose = (): void => {\n if (disposed) return;\n disposed = true;\n if (timer !== undefined) clearTimeout(timer);\n timer = undefined;\n unsubscribeIndex();\n listeners.clear();\n tappers.clear();\n controller.abort();\n };\n\n return {\n clear() {\n assertActive('clear');\n if (snapshot.query === '' && !snapshot.isSearching) return;\n if (timer !== undefined) clearTimeout(timer);\n timer = undefined;\n commit('');\n },\n get disposalSignal() {\n return controller.signal;\n },\n dispose,\n get disposed() {\n return disposed;\n },\n getSnapshot() {\n return snapshot;\n },\n setQuery(query) {\n assertActive('setQuery');\n if (query === snapshot.query) return;\n if (timer !== undefined) clearTimeout(timer);\n timer = undefined;\n\n if (query === committedQuery) {\n publish(createSnapshot(query, false, snapshot.results));\n return;\n }\n\n if (debounceMs === 0) {\n commit(query);\n return;\n }\n\n publish(createSnapshot(query, true, snapshot.results));\n timer = setTimeout(() => {\n timer = undefined;\n commit(query);\n }, debounceMs);\n },\n subscribe(listener, subscribeOptions) {\n return subscribe(listeners, listener, subscribeOptions, disposed, controller.signal);\n },\n tap(handler, tapOptions) {\n if (disposed) return () => {};\n\n return tappers.tap(handler, tapOptions);\n },\n [Symbol.dispose]: dispose,\n };\n}\n\nexport function createReactiveSearch<T>(\n items: T[],\n options: ScoutIndexOptions<T> & Pick<CreateSearchOptions, 'debounce'>,\n): ReactiveSearch<T> {\n const index = createIndex(items, options);\n const state = createSearch(index, options);\n\n return {\n clear: state.clear,\n get disposalSignal() {\n return state.disposalSignal;\n },\n dispose: state.dispose,\n get disposed() {\n return state.disposed;\n },\n getSnapshot: state.getSnapshot,\n index,\n setQuery: state.setQuery,\n subscribe: state.subscribe,\n tap: state.tap,\n [Symbol.dispose]: state[Symbol.dispose],\n };\n}\n","/**\n * Splits `text` into whitespace-joined word segments using the runtime's native\n * `Intl.Segmenter`: no dependency, no bundle cost beyond this file.\n *\n * `tokenize()`'s trigram-based scoring works on unsegmented scripts (Chinese, Japanese,\n * Thai, ...) without this: trigrams are generated per-character, not per-word, but\n * `findMatchRanges()` / highlighting and the multi-word query semantics documented on\n * `SearchConstraints` assume space-separated words. Corpora in those scripts benefit from\n * pre-segmenting via a custom `stringify` so word boundaries exist for those features too.\n *\n * Not applied inside `tokenize()` itself: benchmarked at ~15x slower than the plain regex\n * path for the common whitespace-delimited case, which would regress `createIndex()`'s\n * O(corpus × field_length) construction cost for every caller, not just those indexing\n * unsegmented scripts. Opt in per-field instead.\n *\n * Returns `text` unchanged where `Intl.Segmenter` isn't available in the current runtime.\n *\n * @example\n * ```ts\n * const index = createIndex(documents, {\n * fields: [{ field: 'title', stringify: (v) => segmentWords(String(v)) }],\n * });\n * ```\n */\nexport function segmentWords(text: string): string {\n const segmenter = getSegmenter();\n\n if (!segmenter) return text;\n\n return [...segmenter.segment(text)]\n .filter((segment) => segment.isWordLike)\n .map((segment) => segment.segment)\n .join(' ');\n}\n\n// Constructing an `Intl.Segmenter` costs roughly as much as a `segment()` call itself\n// (both are called from `stringify`, once per field per item during `createIndex()`): cache\n// the single stateless instance instead of rebuilding it on every `segmentWords()` call.\nlet cachedSegmenter: Intl.Segmenter | null | undefined;\n\nfunction getSegmenter(): Intl.Segmenter | null {\n if (cachedSegmenter === undefined) {\n cachedSegmenter =\n typeof Intl === 'undefined' || typeof Intl.Segmenter !== 'function'\n ? null\n : new Intl.Segmenter(undefined, { granularity: 'word' });\n }\n\n return cachedSegmenter;\n}\n"],"mappings":"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"}
|
package/dist/segment.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
|
|
1
|
+
// src/segment.ts
|
|
2
2
|
function segmentWords(e) {
|
|
3
3
|
let t = getSegmenter();
|
|
4
4
|
return t ? [...t.segment(e)].filter((e) => e.isWordLike).map((e) => e.segment).join(" ") : e;
|
|
@@ -7,7 +7,6 @@ var e;
|
|
|
7
7
|
function getSegmenter() {
|
|
8
8
|
return e === void 0 && (e = typeof Intl > "u" || typeof Intl.Segmenter != "function" ? null : new Intl.Segmenter(void 0, { granularity: "word" })), e;
|
|
9
9
|
}
|
|
10
|
-
//#endregion
|
|
11
10
|
export { segmentWords };
|
|
12
11
|
|
|
13
12
|
//# sourceMappingURL=segment.js.map
|
package/dist/tokenize.js
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
|
-
|
|
1
|
+
// src/tokenize.ts
|
|
2
2
|
function tokenize(e) {
|
|
3
3
|
return e.toLowerCase().replace(/[^\p{L}\p{N}\s']/gu, " ").trim().replace(/\s+/g, " ");
|
|
4
4
|
}
|
|
5
5
|
function defaultStringify(e) {
|
|
6
6
|
return e == null ? "" : typeof e == "string" ? e : typeof e == "number" || typeof e == "boolean" ? String(e) : Array.isArray(e) ? e.filter((e) => typeof e == "string" || typeof e == "number" || typeof e == "boolean").join(" ") : "";
|
|
7
7
|
}
|
|
8
|
-
//#endregion
|
|
9
8
|
export { defaultStringify, tokenize };
|
|
10
9
|
|
|
11
10
|
//# sourceMappingURL=tokenize.js.map
|
package/dist/trigram.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
|
|
1
|
+
// src/trigram.ts
|
|
2
2
|
function generateTrigrams(e) {
|
|
3
3
|
let t = /* @__PURE__ */ new Set(), n = ` ${e} `;
|
|
4
4
|
for (let e = 0; e < n.length - 2; e++) t.add(n.slice(e, e + 3));
|
|
@@ -10,7 +10,6 @@ function overlapSimilarity(e, t) {
|
|
|
10
10
|
for (let r of e) t.has(r) && n++;
|
|
11
11
|
return n < 2 && n < e.size ? 0 : n / Math.min(e.size, t.size);
|
|
12
12
|
}
|
|
13
|
-
//#endregion
|
|
14
13
|
export { generateTrigrams, overlapSimilarity };
|
|
15
14
|
|
|
16
15
|
//# sourceMappingURL=trigram.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vielzeug/scout",
|
|
3
|
-
"version": "26.11.
|
|
3
|
+
"version": "26.11.6",
|
|
4
4
|
"description": "Trigram fuzzy-search index with highlighting and reactive layer",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -31,21 +31,21 @@
|
|
|
31
31
|
"registry": "https://registry.npmjs.org/"
|
|
32
32
|
},
|
|
33
33
|
"scripts": {
|
|
34
|
-
"build": "vite build &&
|
|
34
|
+
"build": "vite build && bun run --silent build:bundle && bun run --silent build:types",
|
|
35
35
|
"build:bundle": "vite build --config vite.bundle.config.ts",
|
|
36
36
|
"build:types": "tsc -p tsconfig.declarations.json",
|
|
37
37
|
"fix": "biome check --write src",
|
|
38
38
|
"lint": "biome ci src",
|
|
39
|
-
"prepublishOnly": "
|
|
39
|
+
"prepublishOnly": "bun run --silent build",
|
|
40
40
|
"test": "vitest"
|
|
41
41
|
},
|
|
42
42
|
"dependencies": {
|
|
43
|
-
"@vielzeug/arsenal": "26.11.
|
|
43
|
+
"@vielzeug/arsenal": "26.11.6"
|
|
44
44
|
},
|
|
45
45
|
"devDependencies": {
|
|
46
|
-
"@types/node": "^26.6.
|
|
46
|
+
"@types/node": "^26.6.6",
|
|
47
47
|
"typescript": "^6.0.3",
|
|
48
|
-
"vite": "^8.3.
|
|
49
|
-
"vitest": "^5.0
|
|
48
|
+
"vite": "^8.3.6",
|
|
49
|
+
"vitest": "^5.1.0"
|
|
50
50
|
}
|
|
51
51
|
}
|