@arraypress/search 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/search.js ADDED
@@ -0,0 +1,68 @@
1
+ /**
2
+ * @module @arraypress/search/search
3
+ *
4
+ * Ranked in-memory search over records: field weights, synonym groups (a
5
+ * phrase of up to three words read as one), plural folding, word-start and
6
+ * whole-word matching, exact filters and stable tie-breaks.
7
+ */
8
+
9
+ import { synonymMap, termsOf } from './query/terms.js';
10
+ import { singular } from './query/plural.js';
11
+ import { planOf } from './match/fields.js';
12
+ import { prepare } from './match/record.js';
13
+ import { matches, score } from './match/score.js';
14
+ import { filterOf } from './filter/filter.js';
15
+ import { tieOf } from './filter/tie.js';
16
+ import { facets } from './filter/facets.js';
17
+
18
+ /**
19
+ * The plural option as a function: English (`true`), none (`false`), or your own.
20
+ *
21
+ * @param {import('./search.js').SearchOptions<unknown>['plural']} plural The option.
22
+ * @returns {(word: string) => string} A word's singular, or ''.
23
+ */
24
+ const singularOf = (plural = true) => (typeof plural === 'function' ? plural : plural ? singular : () => '');
25
+
26
+ /**
27
+ * Builds a search over records. Each record is prepared once (its text, lower
28
+ * case); every search then reads the query, keeps the records that pass the
29
+ * filter and have every term, and ranks them.
30
+ *
31
+ * @template {object} T
32
+ * @param {readonly T[]} records The records, in their own order (the last tie-break).
33
+ * @param {import('./search.js').SearchOptions<T>} options Fields, synonyms and scoring.
34
+ * @returns {import('./search.js').Search<T>} The search.
35
+ */
36
+ export function createSearch(records, options) {
37
+ const { fields, synonyms, prefix = 2, anywhere = 1, plural, phrase, stopWords, tie } = options;
38
+ const plan = planOf(fields);
39
+ const top = (/** @type {import('./match/fields.js').FieldPlan} */ f) => Math.max(f.weight, f.exact);
40
+ const scoring = { ranked: plan.filter((f) => top(f) > anywhere), prefixed: plan.filter((f) => top(f) > prefix), prefix, anywhere };
41
+ const docs = records.map((r) => prepare(/** @type {Record<string, unknown>} */ (r), plan));
42
+ const reading = { synonyms: synonymMap(synonyms), singular: singularOf(plural), phrase, stopWords: stopWords && new Set(stopWords) };
43
+ const breakTie = tieOf(tie);
44
+
45
+ /** @type {import('./search.js').Search<T>['terms']} */
46
+ const terms = (query) => termsOf(query, reading);
47
+
48
+ /** @type {import('./search.js').Search<T>['search']} */
49
+ function search(query = '', { filter, limit = Infinity } = {}) {
50
+ const t = terms(query);
51
+ const passes = filterOf(filter);
52
+ /** @type {import('./search.js').Hit<T>[]} */
53
+ const hits = [];
54
+ records.forEach((item, index) => {
55
+ if (passes(item, index) && matches(docs[index], t)) hits.push({ item, index, score: t.length ? score(docs[index], t, scoring) : 0 });
56
+ });
57
+ if (t.length) hits.sort((a, b) => b.score - a.score || breakTie(a.item, b.item) || a.index - b.index);
58
+ return { total: hits.length, hits: hits.slice(0, limit), terms: t };
59
+ }
60
+
61
+ return {
62
+ records,
63
+ search,
64
+ terms,
65
+ facets: (keys, { query, filter } = {}) =>
66
+ facets(search(query, { filter }).hits.map((h) => /** @type {Record<string, unknown>} */ (h.item)), keys),
67
+ };
68
+ }
@@ -0,0 +1,6 @@
1
+ /** Text made safe to use literally inside a regular expression. */
2
+ export function escape(s: string): string;
3
+ /** A phrase at the start of a word ("spa" is found in "space"). */
4
+ export function atStart(phrase: string): RegExp;
5
+ /** A phrase as a whole word ("spa" is found in "spa stones", not "space"). */
6
+ export function asWord(phrase: string): RegExp;
@@ -0,0 +1,31 @@
1
+ /**
2
+ * @module @arraypress/search/util/regex
3
+ *
4
+ * The two ways a phrase is looked for in lower-case text: at the start of a
5
+ * word (what is typed may still be being typed), and as a whole word. A word
6
+ * is a run of `a-z` and `0-9`; anything else is a boundary.
7
+ */
8
+
9
+ /**
10
+ * Text made safe to use literally inside a regular expression.
11
+ *
12
+ * @param {string} s The text.
13
+ * @returns {string} It, with every special character escaped.
14
+ */
15
+ export const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
16
+
17
+ /**
18
+ * A phrase at the start of a word: "spa" is found in "space".
19
+ *
20
+ * @param {string} phrase The phrase, lower case.
21
+ * @returns {RegExp} The pattern.
22
+ */
23
+ export const atStart = (phrase) => new RegExp(`(?:^|[^a-z0-9])${escape(phrase)}`);
24
+
25
+ /**
26
+ * A phrase as a whole word: "spa" is found in "spa stones", not "space".
27
+ *
28
+ * @param {string} phrase The phrase, lower case.
29
+ * @returns {RegExp} The pattern.
30
+ */
31
+ export const asWord = (phrase) => new RegExp(`(?:^|[^a-z0-9])${escape(phrase)}(?![a-z0-9])`);
@@ -0,0 +1,4 @@
1
+ /** A value as a list: a list as it is, anything else as one item ('' when absent). */
2
+ export function listOf(v: unknown): unknown[];
3
+ /** Whether a value says something: a non-empty string or list, or any other value that isn't null or undefined. */
4
+ export function filled(v: unknown): boolean;
@@ -0,0 +1,23 @@
1
+ /**
2
+ * @module @arraypress/search/util/values
3
+ *
4
+ * Small helpers for record values, which are a string, a list of strings, or
5
+ * absent.
6
+ */
7
+
8
+ /**
9
+ * A value as a list: a list as it is, anything else as one item ('' when absent).
10
+ *
11
+ * @param {unknown} v The value.
12
+ * @returns {unknown[]} The list.
13
+ */
14
+ export const listOf = (v) => (Array.isArray(v) ? v : [v ?? '']);
15
+
16
+ /**
17
+ * Whether a value says something: a non-empty string or list, or any other
18
+ * value that isn't null or undefined.
19
+ *
20
+ * @param {unknown} v The value.
21
+ * @returns {boolean} Whether it has content.
22
+ */
23
+ export const filled = (v) => (Array.isArray(v) || typeof v === 'string' ? v.length > 0 : v != null);