@arraypress/search 0.0.0-stage → 0.1.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/CHANGELOG.md ADDED
@@ -0,0 +1,15 @@
1
+ # Changelog
2
+
3
+ All notable changes to `@arraypress/search` are documented here.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.1.0] — 2026-11-05
9
+
10
+ ### Added
11
+
12
+ - **`createSearch`**: ranked in-memory search. It has field weights, list fields of search phrases with an exact-match score, and synonym groups, with a phrase of up to three words read as one. It folds English plurals and drops stop words. What is typed matches at the start of a word, and synonyms match as whole words. It has exact filters (values, list contains, all of several, or a function), facets, and stable tie-breaks (fields with a value first, or a comparison, then record order).
13
+ - **`pack` / `unpack`**: a compact JSON index, built once and loaded in the browser. Records go in as columns, with repeated strings in tables.
14
+ - **`@arraypress/search/rules`**: `scoreRule` (word patterns, points per value, vetoes) and `assign`. `assign` handles caps per record and per group, hand picks, and a review of drops, adds and keeps.
15
+ - A golden corpus of 1,118 Logomarks queries, each returning the same ordered results as the site's own search before it moved here.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ArrayPress Limited
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,70 @@
1
- # Temporary Holding Version
1
+ # @arraypress/search
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Ranked in-memory search with synonyms and facets. Field weights, synonym phrases (up to three words read as one), plural folding, word-start matching for what is typed and whole-word matching for synonyms, exact filters, stable tie-breaks, and a compact prebuilt index you build once and load in the browser. Plus rule-based tagging: weighted word rules with caps, hand picks and a review.
4
+
5
+ No dependencies. Runs in the browser and in Node. About 2.2 KB minified and gzipped; the rules are 1.1 KB more.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install @arraypress/search
11
+ ```
12
+
13
+ ## Quick start
14
+
15
+ ```js
16
+ import { createSearch } from '@arraypress/search';
17
+
18
+ const marks = [
19
+ { slug: 'scales', name: 'Pixel Scales', keywords: ['law', 'justice'], description: 'Balance scales.' },
20
+ { slug: 'house', name: 'House Line', keywords: ['estate agent', 'home'], description: 'A gabled house.' },
21
+ ];
22
+
23
+ const board = createSearch(marks, {
24
+ fields: { name: 4, keywords: { weight: 2.5, exact: 4, list: true }, description: 1 },
25
+ synonyms: [['lawyer', 'solicitor', 'attorney', 'law'], ['realtor', 'estate agent', 'real estate']],
26
+ tie: ['keywords'],
27
+ });
28
+
29
+ board.search('solicitor').hits; // [{ item: { slug: 'scales', … }, index: 0, score: 4 }]
30
+ board.search('estate agents').hits; // one phrase, plural folded: the house
31
+ board.search('ho').hits; // what was typed matches at the start of a word
32
+ ```
33
+
34
+ Build the index once, at build time, and load it anywhere:
35
+
36
+ ```js
37
+ import { pack, unpack } from '@arraypress/search';
38
+
39
+ const json = JSON.stringify(pack(marks, options)); // records as columns, repeated strings in tables
40
+ const board = unpack(JSON.parse(json)); // the same search, in the browser
41
+ ```
42
+
43
+ Tag records by rules (`@arraypress/search/rules`):
44
+
45
+ ```js
46
+ import { assign, scoreRule } from '@arraypress/search/rules';
47
+
48
+ const rules = {
49
+ law: { words: [[/\b(scales|gavel)\b/, 3], [/\bbalance\b/, 2]] },
50
+ property: { words: [[/\b(house|key)\b/, 3]], not: /\bmouse\b/ },
51
+ };
52
+ const { byRecord } = assign(marks, Object.keys(rules), {
53
+ key: (m) => m.slug,
54
+ score: (m, group) => scoreRule(rules[group], `${m.name} ${m.description}`.toLowerCase(), m),
55
+ min: 3, perRecord: 4, perGroup: 60,
56
+ });
57
+ // { house: ['property'], scales: ['law'] }
58
+ ```
59
+
60
+ ## Docs
61
+
62
+ - [How search reads, matches and ranks](https://github.com/arraypress/search/blob/main/docs/matching.md)
63
+ - [API](https://github.com/arraypress/search/blob/main/docs/api.md): every export and option
64
+ - [The packed index](https://github.com/arraypress/search/blob/main/docs/packed-index.md)
65
+ - [Rules](https://github.com/arraypress/search/blob/main/docs/rules.md): scoring and assigning records to groups
66
+ - [Changelog](https://github.com/arraypress/search/blob/main/CHANGELOG.md)
67
+
68
+ ## Licence
69
+
70
+ MIT © ArrayPress Limited; author David Sherlock.
package/package.json CHANGED
@@ -1,6 +1,63 @@
1
1
  {
2
2
  "name": "@arraypress/search",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Ranked in-memory search with synonyms and facets: field weights, synonym phrases, plural folding, word-start and whole-word matching, exact filters, stable tie-breaks and a compact prebuilt JSON index. Plus rule-based tagging (weighted word rules, caps, picks and a review). No dependencies; browser and Node.",
5
+ "type": "module",
6
+ "main": "src/index.js",
7
+ "types": "src/index.d.ts",
8
+ "sideEffects": false,
9
+ "exports": {
10
+ ".": {
11
+ "types": "./src/index.d.ts",
12
+ "import": "./src/index.js",
13
+ "default": "./src/index.js"
14
+ },
15
+ "./rules": {
16
+ "types": "./src/rules/index.d.ts",
17
+ "import": "./src/rules/index.js",
18
+ "default": "./src/rules/index.js"
19
+ },
20
+ "./package.json": "./package.json"
21
+ },
22
+ "files": [
23
+ "src",
24
+ "README.md",
25
+ "CHANGELOG.md",
26
+ "LICENSE"
27
+ ],
28
+ "scripts": {
29
+ "test": "node --test tests/*.test.js",
30
+ "typecheck": "tsc --noEmit"
31
+ },
32
+ "keywords": [
33
+ "search",
34
+ "search-engine",
35
+ "synonyms",
36
+ "ranking",
37
+ "faceted-search",
38
+ "filter",
39
+ "facets",
40
+ "plural",
41
+ "tagging",
42
+ "rules"
43
+ ],
44
+ "author": "David Sherlock",
45
+ "license": "MIT",
46
+ "repository": {
47
+ "type": "git",
48
+ "url": "git+https://github.com/arraypress/search.git"
49
+ },
50
+ "homepage": "https://www.npmjs.com/package/@arraypress/search",
51
+ "bugs": "https://github.com/arraypress/search/issues",
52
+ "engines": {
53
+ "node": ">=20.0.0"
54
+ },
55
+ "publishConfig": {
56
+ "access": "public",
57
+ "registry": "https://registry.npmjs.org/"
58
+ },
59
+ "devDependencies": {
60
+ "@types/node": "^26.6.6",
61
+ "typescript": "^6.0.3"
62
+ }
63
+ }
@@ -0,0 +1,2 @@
1
+ /** Each field's values across the records with how many have each, most first, then alphabetically. */
2
+ export function facets<K extends string>(records: Iterable<Partial<Record<K, unknown>>>, fields: readonly K[]): Record<K, Record<string, number>>;
@@ -0,0 +1,28 @@
1
+ /**
2
+ * @module @arraypress/search/filter/facets
3
+ *
4
+ * Facets: the values fields take across records, with how many records have
5
+ * each, most first.
6
+ */
7
+
8
+ /**
9
+ * Counts each field's values across the records (each item of a list field).
10
+ * Empty and absent values aren't counted. Each field's values come most first,
11
+ * then alphabetically.
12
+ *
13
+ * @param {Iterable<Record<string, unknown>>} records The records.
14
+ * @param {readonly string[]} fields The fields to count.
15
+ * @returns {Record<string, Record<string, number>>} Per field, each value and its count.
16
+ */
17
+ export function facets(records, fields) {
18
+ /** @type {Map<string, number>[]} */
19
+ const counts = fields.map(() => new Map());
20
+ for (const r of records)
21
+ fields.forEach((k, i) => {
22
+ for (const v of Array.isArray(r[k]) ? r[k] : [r[k]])
23
+ if (v != null && v !== '') counts[i].set(String(v), (counts[i].get(String(v)) ?? 0) + 1);
24
+ });
25
+ return Object.fromEntries(
26
+ fields.map((k, i) => [k, Object.fromEntries([...counts[i]].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])))]),
27
+ );
28
+ }
@@ -0,0 +1,8 @@
1
+ /** A value a field must have; a list means every one of them. */
2
+ export type FilterValue = string | number | boolean | readonly (string | number | boolean)[] | undefined;
3
+
4
+ /** Field values a record must have (a list field: contain), or a test of its own. Empty or undefined values are ignored. */
5
+ export type Filter<T> = { readonly [field: string]: FilterValue } | ((record: T, index: number) => boolean);
6
+
7
+ /** A filter as a test. */
8
+ export function filterOf<T>(filter?: Filter<T>): (record: T, index: number) => boolean;
@@ -0,0 +1,33 @@
1
+ /**
2
+ * @module @arraypress/search/filter/filter
3
+ *
4
+ * Exact filters: a record passes when each named field has the value asked
5
+ * for (a list field: contains it). Several values for one field must all be
6
+ * there. A function filter is used as it is.
7
+ */
8
+
9
+ /**
10
+ * Whether a record's field has a value: it equals it, or is a list holding it.
11
+ *
12
+ * @param {unknown} have The field's value.
13
+ * @param {unknown} want The value asked for.
14
+ * @returns {boolean} Whether it has it.
15
+ */
16
+ const has = (have, want) => have === want || (Array.isArray(have) && have.includes(want));
17
+
18
+ /**
19
+ * A filter as a test.
20
+ *
21
+ * @template T
22
+ * @param {import('./filter.js').Filter<T>} [filter] Field values to require, or a test of its own.
23
+ * @returns {(record: T, index: number) => boolean} The test.
24
+ */
25
+ export function filterOf(filter) {
26
+ if (typeof filter === 'function') return filter;
27
+ const wants = Object.entries(filter ?? {}).filter(([, v]) => v !== undefined && v !== '');
28
+ return (record) =>
29
+ wants.every(([k, v]) => {
30
+ const have = /** @type {Record<string, unknown>} */ (record)[k];
31
+ return Array.isArray(v) ? v.every((w) => has(have, w)) : has(have, v);
32
+ });
33
+ }
@@ -0,0 +1,5 @@
1
+ /** How ties are broken: fields whose records with a value go first (in order), or a comparison. Record order comes last. */
2
+ export type Tie<T> = readonly string[] | ((a: T, b: T) => number);
3
+
4
+ /** The tie option as a comparison (0 when there is nothing to break ties by). */
5
+ export function tieOf<T>(tie?: Tie<T>): (a: T, b: T) => number;
@@ -0,0 +1,29 @@
1
+ /**
2
+ * @module @arraypress/search/filter/tie
3
+ *
4
+ * Tie-breakers between records with the same score. Records keep their own
5
+ * order last, so a search's order never changes from one run to the next.
6
+ */
7
+
8
+ import { filled } from '../util/values.js';
9
+
10
+ /**
11
+ * The tie option as a comparison: a list of fields puts records that have a
12
+ * value in the first of them before those that don't, then the next field; a
13
+ * function is used as it is.
14
+ *
15
+ * @template T
16
+ * @param {import('./tie.js').Tie<T>} [tie] The fields, or a comparison.
17
+ * @returns {(a: T, b: T) => number} The comparison (0 when there is nothing to break ties by).
18
+ */
19
+ export function tieOf(tie) {
20
+ if (typeof tie === 'function') return tie;
21
+ const fields = tie ?? [];
22
+ return (a, b) => {
23
+ for (const k of fields) {
24
+ const d = Number(filled(/** @type {Record<string, unknown>} */ (b)[k])) - Number(filled(/** @type {Record<string, unknown>} */ (a)[k]));
25
+ if (d) return d;
26
+ }
27
+ return 0;
28
+ };
29
+ }
package/src/index.d.ts ADDED
@@ -0,0 +1,11 @@
1
+ export type { Field, Fields, SearchOptions, SearchQuery, Hit, Result, Search } from './search.js';
2
+ export { createSearch } from './search.js';
3
+ export type { Packed } from './pack/index.js';
4
+ export { pack, unpack, PACK_VERSION } from './pack/index.js';
5
+ export { facets } from './filter/facets.js';
6
+ export type { Filter, FilterValue } from './filter/filter.js';
7
+ export { filterOf } from './filter/filter.js';
8
+ export type { Tie } from './filter/tie.js';
9
+ export type { Term, TermOptions } from './query/terms.js';
10
+ export { termsOf, synonymMap } from './query/terms.js';
11
+ export { singular } from './query/plural.js';
package/src/index.js ADDED
@@ -0,0 +1,15 @@
1
+ /**
2
+ * @module @arraypress/search
3
+ *
4
+ * Ranked in-memory search with synonyms and facets: field weights, synonym
5
+ * phrases, plural folding, word-start and whole-word matching, exact filters,
6
+ * stable ties, and a compact prebuilt index. No dependencies; runs in the
7
+ * browser and in Node. Rule-based tagging is `@arraypress/search/rules`.
8
+ */
9
+
10
+ export { createSearch } from './search.js';
11
+ export { pack, unpack, PACK_VERSION } from './pack/index.js';
12
+ export { facets } from './filter/facets.js';
13
+ export { filterOf } from './filter/filter.js';
14
+ export { termsOf, synonymMap } from './query/terms.js';
15
+ export { singular } from './query/plural.js';
@@ -0,0 +1,15 @@
1
+ import type { Fields } from '../search.js';
2
+
3
+ /** One field, ready to match against. */
4
+ export interface FieldPlan {
5
+ key: string;
6
+ /** A phrase found in it as a whole word. */
7
+ weight: number;
8
+ /** A phrase that is one of a list field's items exactly (0: no bonus). */
9
+ exact: number;
10
+ /** Its items are separate phrases, each matched on its own. */
11
+ list: boolean;
12
+ }
13
+
14
+ /** The fields option as a plan, in order. */
15
+ export function planOf(fields: Fields): FieldPlan[];
@@ -0,0 +1,20 @@
1
+ /**
2
+ * @module @arraypress/search/match/fields
3
+ *
4
+ * The fields option read into a plan: which record fields make a record's
5
+ * text, in order, and what a match in each is worth.
6
+ */
7
+
8
+ /**
9
+ * The fields option as a plan. A number is a field's weight; an object can
10
+ * also make it a list of phrases (`list`) with a score for a phrase that is
11
+ * one of them exactly (`exact`).
12
+ *
13
+ * @param {import('../search.js').Fields} fields The fields option.
14
+ * @returns {import('./fields.js').FieldPlan[]} One entry per field, in order.
15
+ */
16
+ export const planOf = (fields) =>
17
+ Object.entries(fields).map(([key, f]) => {
18
+ const { weight = 0, exact = 0, list = false } = typeof f === 'number' ? { weight: f } : f;
19
+ return { key, weight, exact, list };
20
+ });
@@ -0,0 +1,12 @@
1
+ import type { FieldPlan } from './fields.js';
2
+
3
+ /** A record prepared for matching. */
4
+ export interface Prepared {
5
+ /** Every field, in order, joined and lower case: what a term must be found in. */
6
+ text: string;
7
+ /** Each field's text, lower case: a list field as its phrases. */
8
+ texts: Record<string, string | string[]>;
9
+ }
10
+
11
+ /** A record prepared for matching: its whole text and each field's text. */
12
+ export function prepare(record: Record<string, unknown>, plan: readonly FieldPlan[]): Prepared;
@@ -0,0 +1,44 @@
1
+ /**
2
+ * @module @arraypress/search/match/record
3
+ *
4
+ * A record prepared for matching: its whole text (every field, in order, lower
5
+ * case) and each ranked field's text.
6
+ */
7
+
8
+ import { listOf } from '../util/values.js';
9
+
10
+ /**
11
+ * A value as text: a string as it is, a number written out, anything else ''.
12
+ *
13
+ * @param {unknown} v The value.
14
+ * @returns {string} The text.
15
+ */
16
+ const textOf = (v) => (typeof v === 'string' ? v : typeof v === 'number' ? String(v) : '');
17
+
18
+ /**
19
+ * A record prepared for matching. Its text joins every field with spaces: a
20
+ * string as it is, a list's items one by one, and a list field's phrases
21
+ * joined with commas (so one phrase never runs into the next).
22
+ *
23
+ * @param {Record<string, unknown>} record The record.
24
+ * @param {readonly import('./fields.js').FieldPlan[]} plan The fields.
25
+ * @returns {import('./record.js').Prepared} The prepared record.
26
+ */
27
+ export function prepare(record, plan) {
28
+ /** @type {unknown[]} */
29
+ const parts = [];
30
+ /** @type {Record<string, string | string[]>} */
31
+ const texts = {};
32
+ for (const f of plan) {
33
+ const items = listOf(record[f.key]);
34
+ if (f.list) {
35
+ const phrases = items.map((v) => textOf(v).toLowerCase()).filter(Boolean);
36
+ parts.push(phrases.join(','));
37
+ texts[f.key] = phrases;
38
+ } else {
39
+ parts.push(...items);
40
+ texts[f.key] = items.map(textOf).join(' ').toLowerCase();
41
+ }
42
+ }
43
+ return { text: parts.map(textOf).join(' ').toLowerCase(), texts };
44
+ }
@@ -0,0 +1,16 @@
1
+ import type { Prepared } from './record.js';
2
+ import type { FieldPlan } from './fields.js';
3
+ import type { Term } from '../query/terms.js';
4
+
5
+ /** What scoring needs: the fields worth more than `anywhere`, those worth more than `prefix`, and the two scores. */
6
+ export interface Scoring {
7
+ ranked: readonly FieldPlan[];
8
+ prefixed: readonly FieldPlan[];
9
+ prefix: number;
10
+ anywhere: number;
11
+ }
12
+
13
+ /** Whether a record has every term: what was typed at the start of a word, or one of its phrases as a whole word. */
14
+ export function matches(doc: Prepared, terms: readonly Term[]): boolean;
15
+ /** How well a record answers the terms: per term, the best of its phrases over the fields, added up. */
16
+ export function score(doc: Prepared, terms: readonly Term[], scoring: Scoring): number;
@@ -0,0 +1,56 @@
1
+ /**
2
+ * @module @arraypress/search/match/score
3
+ *
4
+ * Whether a prepared record answers a query's terms, and how well.
5
+ */
6
+
7
+ /**
8
+ * Whether a record has every term: what was typed at the start of a word (it
9
+ * may still be being typed), or one of its phrases as a whole word ("dance"
10
+ * isn't found in "guidance", nor a synonym "cab" in "cabin").
11
+ *
12
+ * @param {import('./record.js').Prepared} doc The record.
13
+ * @param {readonly import('../query/terms.js').Term[]} terms The terms.
14
+ * @returns {boolean} Whether it matches.
15
+ */
16
+ export const matches = (doc, terms) => terms.every((t) => t.start.test(doc.text) || t.whole.some((r) => r.test(doc.text)));
17
+
18
+ /**
19
+ * Whether a pattern is found in a field's text (in any one phrase of a list field).
20
+ *
21
+ * @param {RegExp} r The pattern.
22
+ * @param {string | string[]} text The field's text.
23
+ * @returns {boolean} Whether it is found.
24
+ */
25
+ const foundIn = (r, text) => (Array.isArray(text) ? text.some((w) => r.test(w)) : r.test(text));
26
+
27
+ /**
28
+ * How well a record answers the terms: per term, the best any of its phrases
29
+ * scores, added up. A phrase scores a field's weight when it is a whole word
30
+ * of it (a list field's `exact` when it is one of its phrases outright), and
31
+ * `anywhere` when it is a whole word anywhere in the record. When that comes
32
+ * to less than `prefix`, what was typed at the start of a word of a field
33
+ * weighted above `prefix` scores `prefix`.
34
+ *
35
+ * @param {import('./record.js').Prepared} doc The record.
36
+ * @param {readonly import('../query/terms.js').Term[]} terms The terms.
37
+ * @param {import('./score.js').Scoring} scoring The fields and scores.
38
+ * @returns {number} The score.
39
+ */
40
+ export function score(doc, terms, { ranked, prefixed, prefix, anywhere }) {
41
+ let total = 0;
42
+ for (const t of terms) {
43
+ let best = 0;
44
+ t.whole.forEach((r, k) => {
45
+ for (const f of ranked) {
46
+ const text = doc.texts[f.key];
47
+ if (f.exact > best && Array.isArray(text) && text.includes(t.phrases[k])) best = f.exact;
48
+ if (f.weight > best && foundIn(r, text)) best = f.weight;
49
+ }
50
+ if (anywhere > best && r.test(doc.text)) best = anywhere;
51
+ });
52
+ if (best < prefix && prefixed.some((f) => foundIn(t.start, doc.texts[f.key]))) best = prefix;
53
+ total += best;
54
+ }
55
+ return total;
56
+ }
@@ -0,0 +1,4 @@
1
+ /** Records as columns (an absent value as null), with a string table for each column that is smaller as one. */
2
+ export function toColumns(records: readonly Record<string, unknown>[]): { columns: Record<string, unknown[]>; tables: Record<string, string[]> };
3
+ /** Columns back as records (a null value is left out). */
4
+ export function fromColumns(n: number, columns: Readonly<Record<string, readonly unknown[]>>, tables?: Readonly<Record<string, readonly string[]>>): Record<string, unknown>[];
@@ -0,0 +1,78 @@
1
+ /**
2
+ * @module @arraypress/search/pack/columns
3
+ *
4
+ * Records as columns, one per field, for a compact JSON index. A column of
5
+ * strings (or lists of strings) whose values repeat is stored as numbers into
6
+ * a table of its strings, most used first, when that comes out smaller.
7
+ */
8
+
9
+ /**
10
+ * Whether every value of a column is a string, a list of strings or absent (null).
11
+ *
12
+ * @param {readonly unknown[]} values The column.
13
+ * @returns {boolean} Whether its strings can go in a table.
14
+ */
15
+ const stringy = (values) =>
16
+ values.every((v) => v === null || typeof v === 'string' || (Array.isArray(v) && v.every((s) => typeof s === 'string')));
17
+
18
+ /**
19
+ * A column of strings as a table of its strings (most used first) and each value as numbers into it.
20
+ *
21
+ * @param {readonly (string | string[] | null)[]} values The column.
22
+ * @returns {{ table: string[], values: (number | number[] | null)[] }} The table and the column.
23
+ */
24
+ function tabled(values) {
25
+ /** @type {Map<string, number>} */
26
+ const uses = new Map();
27
+ for (const v of values) for (const s of v === null ? [] : [v].flat()) uses.set(s, (uses.get(s) ?? 0) + 1);
28
+ const table = [...uses.keys()].sort((a, b) => /** @type {number} */ (uses.get(b)) - /** @type {number} */ (uses.get(a)));
29
+ const at = new Map(table.map((s, i) => [s, i]));
30
+ const num = (/** @type {string} */ s) => /** @type {number} */ (at.get(s));
31
+ return { table, values: values.map((v) => (v === null ? null : Array.isArray(v) ? v.map(num) : num(v))) };
32
+ }
33
+
34
+ /**
35
+ * Records as columns: one per field (in the order fields first appear), an
36
+ * absent value as null, and a table for each column that is smaller as one.
37
+ *
38
+ * @param {readonly Record<string, unknown>[]} records The records.
39
+ * @returns {{ columns: Record<string, unknown[]>, tables: Record<string, string[]> }} The columns and their tables.
40
+ */
41
+ export function toColumns(records) {
42
+ /** @type {Record<string, unknown[]>} */
43
+ const columns = {};
44
+ /** @type {Record<string, string[]>} */
45
+ const tables = {};
46
+ for (const k of new Set(records.flatMap((r) => Object.keys(r)))) {
47
+ const values = records.map((r) => r[k] ?? null);
48
+ const t = stringy(values) ? tabled(/** @type {(string | string[] | null)[]} */ (values)) : null;
49
+ if (t && JSON.stringify(t).length < JSON.stringify(values).length) {
50
+ columns[k] = t.values;
51
+ tables[k] = t.table;
52
+ } else columns[k] = values;
53
+ }
54
+ return { columns, tables };
55
+ }
56
+
57
+ /**
58
+ * Columns back as records: a null value is left out.
59
+ *
60
+ * @param {number} n How many records.
61
+ * @param {Readonly<Record<string, readonly unknown[]>>} columns The columns.
62
+ * @param {Readonly<Record<string, readonly string[]>>} [tables] Their tables.
63
+ * @returns {Record<string, unknown>[]} The records.
64
+ */
65
+ export function fromColumns(n, columns, tables = {}) {
66
+ const entries = Object.entries(columns);
67
+ return Array.from({ length: n }, (_, i) => {
68
+ /** @type {Record<string, unknown>} */
69
+ const r = {};
70
+ for (const [k, values] of entries) {
71
+ const v = values[i];
72
+ if (v === null || v === undefined) continue;
73
+ const table = tables[k];
74
+ r[k] = !table ? v : Array.isArray(v) ? v.map((j) => table[j]) : table[/** @type {number} */ (v)];
75
+ }
76
+ return r;
77
+ });
78
+ }
@@ -0,0 +1,22 @@
1
+ import type { Search, SearchOptions } from '../search.js';
2
+
3
+ /** A prebuilt index: records as columns, and how they are searched. Plain JSON. */
4
+ export interface Packed<T = Record<string, unknown>> {
5
+ /** The format's version: 1. */
6
+ v: 1;
7
+ /** How many records. */
8
+ n: number;
9
+ /** The search options, less any functions. */
10
+ options: SearchOptions<T>;
11
+ /** One column per field: each record's value, null when absent (numbers into `tables[field]` when it has one). */
12
+ columns: Record<string, unknown[]>;
13
+ /** The string tables of the columns stored as numbers. */
14
+ tables: Record<string, string[]>;
15
+ }
16
+
17
+ /** The format's version: 1. */
18
+ export const PACK_VERSION: 1;
19
+ /** Records and their search options as a JSON-ready index (function options aren't packed: pass them to `unpack`). */
20
+ export function pack<T extends object>(records: readonly T[], options: SearchOptions<T>): Packed<T>;
21
+ /** A packed index back as a ready search; throws when it is from another version of the format. */
22
+ export function unpack<T extends object>(packed: Packed<T>, options?: Partial<SearchOptions<T>>): Search<T>;
@@ -0,0 +1,49 @@
1
+ /**
2
+ * @module @arraypress/search/pack
3
+ *
4
+ * A prebuilt index: records and search options as compact JSON, built once
5
+ * (at build time, in Node) and loaded anywhere (the browser).
6
+ */
7
+
8
+ import { createSearch } from '../search.js';
9
+ import { toColumns, fromColumns } from './columns.js';
10
+
11
+ /** The format's version. */
12
+ export const PACK_VERSION = 1;
13
+
14
+ /**
15
+ * Records and their search options as a JSON-ready index. Options that are
16
+ * functions (a `plural` or `tie` of your own) aren't packed: pass them to
17
+ * `unpack` again.
18
+ *
19
+ * @template {object} T
20
+ * @param {readonly T[]} records The records (strings, numbers, lists: anything JSON holds).
21
+ * @param {import('../search.js').SearchOptions<T>} options How they are searched.
22
+ * @returns {import('./index.js').Packed<T>} The index.
23
+ */
24
+ export function pack(records, options) {
25
+ const { plural, tie, ...rest } = options;
26
+ const { columns, tables } = toColumns(/** @type {Record<string, unknown>[]} */ (/** @type {unknown} */ (records)));
27
+ return {
28
+ v: PACK_VERSION,
29
+ n: records.length,
30
+ options: { ...rest, ...(typeof plural === 'boolean' && { plural }), ...(Array.isArray(tie) && { tie }) },
31
+ columns,
32
+ tables,
33
+ };
34
+ }
35
+
36
+ /**
37
+ * A packed index back as a ready search.
38
+ *
39
+ * @template {object} T
40
+ * @param {import('./index.js').Packed<T>} packed The index (parsed JSON).
41
+ * @param {Partial<import('../search.js').SearchOptions<T>>} [options] Options to add or change (a `plural` or `tie` function, stop words).
42
+ * @returns {import('../search.js').Search<T>} The search.
43
+ * @throws {Error} When the index is from another version of the format.
44
+ */
45
+ export function unpack(packed, options = {}) {
46
+ if (packed?.v !== PACK_VERSION) throw new Error(`@arraypress/search: index version ${packed?.v}, expected ${PACK_VERSION}`);
47
+ const records = /** @type {T[]} */ (/** @type {unknown} */ (fromColumns(packed.n, packed.columns, packed.tables)));
48
+ return createSearch(records, { ...packed.options, ...options });
49
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * The singular of a plain English plural ("plumbers" → "plumber", "bakeries" → "bakery"); '' when it isn't one.
3
+ * Words of three letters or fewer, and words ending in "ss", are left alone.
4
+ */
5
+ export function singular(word: string): string;
@@ -0,0 +1,17 @@
1
+ /**
2
+ * @module @arraypress/search/query/plural
3
+ *
4
+ * Plural folding for English: a plain plural's singular, so "plumbers" also
5
+ * finds "plumber" and "bakeries" finds "bakery".
6
+ */
7
+
8
+ /**
9
+ * The singular of a plain English plural: "plumbers" → "plumber",
10
+ * "bakeries" → "bakery". Words of three letters or fewer, and words ending in
11
+ * "ss", are left alone.
12
+ *
13
+ * @param {string} word The word (or phrase: its last word is folded), lower case.
14
+ * @returns {string} Its singular, or '' when it isn't a plain plural.
15
+ */
16
+ export const singular = (word) =>
17
+ word.length > 3 && /[^s]s$/.test(word) ? (word.endsWith('ies') ? `${word.slice(0, -3)}y` : word.slice(0, -1)) : '';
@@ -0,0 +1,28 @@
1
+ /** One term of a query: a word, or a synonym phrase read as one. */
2
+ export interface Term {
3
+ /** What was typed, lower case ("estate agents"). */
4
+ typed: string;
5
+ /** The phrases that count for it: what was typed, its synonym group, its singular. */
6
+ phrases: string[];
7
+ /** What was typed, at the start of a word. */
8
+ start: RegExp;
9
+ /** Each phrase as a whole word, in the order of `phrases`. */
10
+ whole: RegExp[];
11
+ }
12
+
13
+ /** How a query is read. */
14
+ export interface TermOptions {
15
+ /** Synonym lookup (`synonymMap`). */
16
+ synonyms: ReadonlyMap<string, readonly string[]>;
17
+ /** A word's singular, or '' (plural folding off: always ''). */
18
+ singular: (word: string) => string;
19
+ /** The most words a synonym phrase can have. Default 3. */
20
+ phrase?: number;
21
+ /** Single words left out of a query ("a", "the", "logo"). */
22
+ stopWords?: ReadonlySet<string>;
23
+ }
24
+
25
+ /** Synonym groups as a lookup: each phrase → every phrase of its group (a later group wins a shared phrase). */
26
+ export function synonymMap(groups?: readonly (readonly string[])[]): Map<string, readonly string[]>;
27
+ /** Reads a query as terms: lower case, synonym phrases of up to `phrase` words read as one, stop words left out. */
28
+ export function termsOf(query: string, options: TermOptions): Term[];
@@ -0,0 +1,49 @@
1
+ /**
2
+ * @module @arraypress/search/query/terms
3
+ *
4
+ * A query read as terms. Each term is a word, or a synonym phrase of up to
5
+ * three words read as one ("estate agents"), with the phrases that count for
6
+ * it too: its synonym group and its singular.
7
+ */
8
+
9
+ import { atStart, asWord } from '../util/regex.js';
10
+
11
+ /**
12
+ * Synonym groups as a lookup: each phrase → every phrase of its group. A
13
+ * phrase in two groups belongs to the later one.
14
+ *
15
+ * @param {readonly (readonly string[])[]} [groups] The groups, lower case.
16
+ * @returns {Map<string, readonly string[]>} The lookup.
17
+ */
18
+ export function synonymMap(groups = []) {
19
+ /** @type {Map<string, readonly string[]>} */
20
+ const map = new Map();
21
+ for (const group of groups) for (const p of group) map.set(p, group);
22
+ return map;
23
+ }
24
+
25
+ /**
26
+ * Reads a query as terms. Words are split on white space and lower-cased; up
27
+ * to `phrase` words in a row are read as one term when they make a synonym
28
+ * phrase (or its plural). Single-word stop words are left out.
29
+ *
30
+ * @param {string} query What was typed.
31
+ * @param {import('./terms.js').TermOptions} options The synonyms, plural folding, phrase length and stop words.
32
+ * @returns {import('./terms.js').Term[]} The terms, in order.
33
+ */
34
+ export function termsOf(query, { synonyms, singular, phrase = 3, stopWords }) {
35
+ const groupOf = (/** @type {string} */ p) => synonyms.get(p) ?? synonyms.get(singular(p));
36
+ const words = query.trim().toLowerCase().split(/\s+/).filter(Boolean);
37
+ /** @type {import('./terms.js').Term[]} */
38
+ const out = [];
39
+ for (let i = 0; i < words.length; ) {
40
+ let n = Math.min(phrase, words.length - i);
41
+ while (n > 1 && !groupOf(words.slice(i, i + n).join(' '))) n--;
42
+ const typed = words.slice(i, i + n).join(' ');
43
+ i += n;
44
+ if (n === 1 && stopWords?.has(typed)) continue;
45
+ const phrases = [...new Set([typed, ...(groupOf(typed) ?? []), singular(typed)].filter(Boolean))];
46
+ out.push({ typed, phrases, start: atStart(typed), whole: phrases.map(asWord) });
47
+ }
48
+ return out;
49
+ }
@@ -0,0 +1,48 @@
1
+ /** Record keys as a list, or space-separated. */
2
+ export type Keys = string | readonly string[];
3
+
4
+ /** What looking at a group's records changed. */
5
+ export interface ReviewEdit {
6
+ /** Records the rules found that don't belong. */
7
+ drop?: Keys;
8
+ /** Records no rule found that do: they go in at the pick score. */
9
+ add?: Keys;
10
+ /** Rules fits to hold past the `perGroup` cap. */
11
+ keep?: Keys;
12
+ }
13
+
14
+ /** The review, per group. */
15
+ export type Review = Readonly<Record<string, ReviewEdit>>;
16
+
17
+ /** How records are assigned. */
18
+ export interface AssignOptions<T> {
19
+ /** A record's key (its slug or id). */
20
+ key: (record: T) => string;
21
+ /** A record's score for a group (0 when it doesn't fit): `scoreRule` over your own rules, or your own scoring. */
22
+ score: (record: T, group: string) => number;
23
+ /** The lowest score that counts as a fit. Default 1. */
24
+ min?: number;
25
+ /** The most groups a record keeps. Default no cap. */
26
+ perRecord?: number;
27
+ /** The most rules fits a group keeps (its review keeps and adds come on top). Default no cap. */
28
+ perGroup?: number;
29
+ /** Hand picks: a record's groups, best first. These records skip the rules; each pick scores `pick`, a little more the earlier it is listed. */
30
+ picks?: Readonly<Record<string, Keys>>;
31
+ /** An add's score: above any rule's, so caps keep it. Default 1000. */
32
+ pick?: number;
33
+ /** Per group: drops, adds and keeps. */
34
+ review?: Review;
35
+ }
36
+
37
+ /** What `assign` decided. */
38
+ export interface Assigned {
39
+ /** Each group's records and scores, best first. */
40
+ byGroup: Record<string, [key: string, score: number][]>;
41
+ /** Each record that fits anything: its groups, best first, at most `perRecord`. Keys in alphabetical order. */
42
+ byRecord: Record<string, string[]>;
43
+ /** Review and pick keys that aren't records (they were skipped). */
44
+ missing: { group: string; key: string }[];
45
+ }
46
+
47
+ /** Assigns records to groups: rule scores with caps, then the review's drops and adds, then each record's best groups. */
48
+ export function assign<T>(records: readonly T[], groups: readonly string[], options: AssignOptions<T>): Assigned;
@@ -0,0 +1,112 @@
1
+ /**
2
+ * @module @arraypress/search/rules/assign
3
+ *
4
+ * Assigning records to groups (marks to industries, products to categories)
5
+ * by rule scores, with caps, hand picks and a review.
6
+ *
7
+ * 1. Rules. Every record is scored against every group; it keeps its best
8
+ * `perRecord` groups scoring `min` or more (ties: group order). Each group
9
+ * keeps its best `perGroup` records, plus any the review says to `keep`.
10
+ * Picked records skip this pass.
11
+ * 2. Review. Per group, `drop` removes records, and `add` puts records in at
12
+ * the pick score (`pick`), above any rule's. Each record's `picks` are adds
13
+ * on those groups, scored a little higher the earlier they are listed.
14
+ * 3. Each record's groups, best first, at most `perRecord`.
15
+ */
16
+
17
+ /**
18
+ * Space-separated keys, or a list, as a list.
19
+ *
20
+ * @param {string | readonly string[] | undefined} v The keys.
21
+ * @returns {string[]} The list.
22
+ */
23
+ const keysOf = (v) => (typeof v === 'string' ? v.split(/\s+/).filter(Boolean) : [...(v ?? [])]);
24
+
25
+ /**
26
+ * The review with every pick added to its groups' `add`s (after the review's own).
27
+ *
28
+ * @param {import('./assign.js').Review} review The review.
29
+ * @param {Readonly<Record<string, readonly string[]>>} picks The picks.
30
+ * @returns {Record<string, { drop: string[], add: string[], keep: string[] }>} The review, as lists.
31
+ */
32
+ function withPicks(review, picks) {
33
+ /** @type {Record<string, { drop: string[], add: string[], keep: string[] }>} */
34
+ const out = {};
35
+ for (const [g, e] of Object.entries(review)) out[g] = { drop: keysOf(e.drop), add: keysOf(e.add), keep: keysOf(e.keep) };
36
+ for (const [key, groups] of Object.entries(picks)) for (const g of groups) (out[g] ??= { drop: [], add: [], keep: [] }).add.push(key);
37
+ return out;
38
+ }
39
+
40
+ /**
41
+ * Assigns records to groups by rule score, caps, picks and review.
42
+ *
43
+ * @template T
44
+ * @param {readonly T[]} records The records.
45
+ * @param {readonly string[]} groups The groups, in order (the tie-break).
46
+ * @param {import('./assign.js').AssignOptions<T>} options How records score, the caps, picks and review.
47
+ * @returns {import('./assign.js').Assigned} Each group's records and each record's groups, best first.
48
+ * @throws {Error} When the review names a group that isn't one.
49
+ */
50
+ export function assign(records, groups, options) {
51
+ const { key, score, min = 1, perRecord = Infinity, perGroup = Infinity, pick = 1000, review = {} } = options;
52
+ /** @type {Record<string, string[]>} */
53
+ const picks = Object.fromEntries(Object.entries(options.picks ?? {}).map(([k, v]) => [k, keysOf(v)]));
54
+ const edits = withPicks(review, picks);
55
+ // a pick scores `pick`, plus a tenth for each place it is ahead of the record's last possible group
56
+ const pickScore = (/** @type {string} */ k, /** @type {string} */ g) => {
57
+ const at = picks[k]?.indexOf(g) ?? -1;
58
+ return at < 0 ? pick : pick + ((Number.isFinite(perRecord) ? perRecord : picks[k].length) - at) / 10;
59
+ };
60
+
61
+ // 1. rules: each record its best perRecord groups, each group its best perGroup records (and its keeps)
62
+ /** @type {Record<string, [string, number][]>} */
63
+ const byGroup = Object.fromEntries(groups.map((g) => [g, []]));
64
+ for (const r of records) {
65
+ const k = key(r);
66
+ if (picks[k]) continue;
67
+ const fits = groups
68
+ .map((g, order) => ({ g, s: score(r, g), order }))
69
+ .filter((f) => f.s >= min)
70
+ .sort((a, b) => b.s - a.s || a.order - b.order)
71
+ .slice(0, perRecord);
72
+ for (const f of fits) byGroup[f.g].push([k, f.s]);
73
+ }
74
+ for (const g of groups) {
75
+ const all = byGroup[g].sort((a, b) => b[1] - a[1]);
76
+ const keep = new Set(edits[g]?.keep);
77
+ byGroup[g] = [...all.slice(0, perGroup), ...all.slice(perGroup).filter(([k]) => keep.has(k))];
78
+ }
79
+
80
+ // 2. review: drops, then adds at the pick score
81
+ const known = new Set(records.map(key));
82
+ /** @type {{ group: string, key: string }[]} */
83
+ const missing = [];
84
+ for (const [g, { drop, add, keep }] of Object.entries(edits)) {
85
+ if (!byGroup[g]) throw new Error(`review: no group "${g}"`);
86
+ for (const k of [...drop, ...add, ...keep]) if (!known.has(k)) missing.push({ group: g, key: k });
87
+ const out = new Set(drop);
88
+ const picked = new Set(add);
89
+ const list = byGroup[g].filter(([k]) => !out.has(k)).map(([k, s]) => /** @type {[string, number]} */ ([k, picked.has(k) ? pickScore(k, g) : s]));
90
+ const have = new Set(list.map(([k]) => k));
91
+ for (const k of add) if (known.has(k) && !have.has(k)) list.push([k, pickScore(k, g)]);
92
+ byGroup[g] = list.sort((a, b) => b[1] - a[1]);
93
+ }
94
+
95
+ // 3. each record's groups, best first (ties: group order)
96
+ const order = new Map(groups.map((g, i) => [g, i]));
97
+ /** @type {Map<string, [string, number][]>} */
98
+ const fits = new Map();
99
+ for (const [g, list] of Object.entries(byGroup)) for (const [k, s] of list) fits.set(k, [...(fits.get(k) ?? []), [g, s]]);
100
+ const byRecord = Object.fromEntries(
101
+ [...fits]
102
+ .sort(([a], [b]) => a.localeCompare(b))
103
+ .map(([k, list]) => [
104
+ k,
105
+ list
106
+ .sort((a, b) => b[1] - a[1] || /** @type {number} */ (order.get(a[0])) - /** @type {number} */ (order.get(b[0])))
107
+ .slice(0, perRecord)
108
+ .map(([g]) => g),
109
+ ]),
110
+ );
111
+ return { byGroup, byRecord, missing };
112
+ }
@@ -0,0 +1,4 @@
1
+ export type { Words, Rule } from './score.js';
2
+ export { found, wordScore, pointsFor, scoreRule } from './score.js';
3
+ export type { Keys, ReviewEdit, Review, AssignOptions, Assigned } from './assign.js';
4
+ export { assign } from './assign.js';
@@ -0,0 +1,9 @@
1
+ /**
2
+ * @module @arraypress/search/rules
3
+ *
4
+ * Rule-based tagging: score records against weighted word rules, then assign
5
+ * them to groups with caps, hand picks and a review.
6
+ */
7
+
8
+ export { found, wordScore, pointsFor, scoreRule } from './score.js';
9
+ export { assign } from './assign.js';
@@ -0,0 +1,21 @@
1
+ /** Word patterns and what each is worth, strongest first: the first one found counts. */
2
+ export type Words = readonly (readonly [pattern: RegExp | string, score: number])[];
3
+
4
+ /** A rule: what makes a record fit one group. */
5
+ export interface Rule {
6
+ /** Words that veto the rule outright. */
7
+ not?: RegExp | string;
8
+ /** Word patterns and what each is worth, strongest first. */
9
+ words?: Words;
10
+ /** Points per value, per field: `{ style: { iso: 1 }, mood: { calm: 2 } }`. A list field adds each of its values. */
11
+ points?: Readonly<Record<string, Readonly<Record<string, number>>>>;
12
+ }
13
+
14
+ /** Whether a pattern is found in a text (no pattern: never; a string is read as a regular expression). */
15
+ export function found(pattern: RegExp | string | undefined, text: string): boolean;
16
+ /** The score of the first word pattern found in a text, or 0. */
17
+ export function wordScore(words: Words | undefined, text: string): number;
18
+ /** Points for values: each value adds what the table gives it. */
19
+ export function pointsFor(table: Readonly<Record<string, number>> | undefined, values: unknown): number;
20
+ /** One record's score against one rule: 0 on a veto word, else the first word pattern found plus each field's points. */
21
+ export function scoreRule(rule: Rule, text: string, record?: Readonly<Record<string, unknown>>): number;
@@ -0,0 +1,49 @@
1
+ /**
2
+ * @module @arraypress/search/rules/score
3
+ *
4
+ * Scoring one record against one rule: weighted word patterns over its text,
5
+ * plus points for the values it has, and words that veto the rule outright.
6
+ */
7
+
8
+ /**
9
+ * Whether a pattern is found in a text (no pattern: never).
10
+ *
11
+ * @param {RegExp | string | undefined} pattern The pattern (a string is read as a regular expression).
12
+ * @param {string} text The text.
13
+ * @returns {boolean} Whether it is found.
14
+ */
15
+ export const found = (pattern, text) => (pattern === undefined ? false : (typeof pattern === 'string' ? new RegExp(pattern) : pattern).test(text));
16
+
17
+ /**
18
+ * The score of the first word pattern found in a text, strongest listed first.
19
+ *
20
+ * @param {import('./score.js').Words | undefined} words The patterns and what each is worth.
21
+ * @param {string} text The text.
22
+ * @returns {number} The first found pattern's score, or 0.
23
+ */
24
+ export const wordScore = (words, text) => words?.find(([pattern]) => found(pattern, text))?.[1] ?? 0;
25
+
26
+ /**
27
+ * Points for values: each value the record has adds what the table gives it.
28
+ *
29
+ * @param {Readonly<Record<string, number>> | undefined} table Points per value.
30
+ * @param {unknown} values The record's value, or list of values.
31
+ * @returns {number} The points, added up.
32
+ */
33
+ export const pointsFor = (table, values) =>
34
+ (Array.isArray(values) ? values : [values]).reduce((s, v) => s + (table?.[String(v)] ?? 0), 0);
35
+
36
+ /**
37
+ * One record's score against one rule: 0 when the text has a veto word (`not`);
38
+ * otherwise the first word pattern found (`words`), plus the points (`points`)
39
+ * of each field's values.
40
+ *
41
+ * @param {import('./score.js').Rule} rule The rule.
42
+ * @param {string} text The record's text, as the patterns read it (lower case).
43
+ * @param {Readonly<Record<string, unknown>>} [record] The record, for `points`.
44
+ * @returns {number} The score.
45
+ */
46
+ export function scoreRule(rule, text, record = {}) {
47
+ if (found(rule.not, text)) return 0;
48
+ return Object.entries(rule.points ?? {}).reduce((s, [k, table]) => s + pointsFor(table, record[k]), wordScore(rule.words, text));
49
+ }
@@ -0,0 +1,79 @@
1
+ import type { Term } from './query/terms.js';
2
+ import type { Filter } from './filter/filter.js';
3
+ import type { Tie } from './filter/tie.js';
4
+
5
+ /** A field's scoring: a number is its weight. */
6
+ export type Field =
7
+ | number
8
+ | {
9
+ /** A phrase found in it as a whole word. Default 0. */
10
+ weight?: number;
11
+ /** A list field: a phrase that is one of its items outright. Default 0. */
12
+ exact?: number;
13
+ /** Its items are separate phrases (search words: "nail salon", "cafe"), each matched on its own. */
14
+ list?: boolean;
15
+ };
16
+
17
+ /** The fields that make a record's text, in order, with their scoring. */
18
+ export type Fields = { readonly [field: string]: Field };
19
+
20
+ /** How a search reads, matches and ranks. */
21
+ export interface SearchOptions<T> {
22
+ /** The fields searched, in order: `{ name: 4, keywords: { weight: 2.5, exact: 4, list: true }, description: 1 }`. */
23
+ fields: Fields;
24
+ /** Synonym groups, lower case: a phrase from one searches for every phrase of its group. */
25
+ synonyms?: readonly (readonly string[])[];
26
+ /** What was typed, at the start of a word of a field weighted above this, scores this. Default 2. */
27
+ prefix?: number;
28
+ /** A phrase found as a whole word anywhere in the record. Default 1. */
29
+ anywhere?: number;
30
+ /** Plural folding: English (`true`, the default), none (`false`), or a word's singular ('' for none). */
31
+ plural?: boolean | ((word: string) => string);
32
+ /** The most words a synonym phrase can have. Default 3. */
33
+ phrase?: number;
34
+ /** Single words left out of a query ("a", "the", "logo"). */
35
+ stopWords?: readonly string[];
36
+ /** Tie-breaks at an equal score: fields whose records with a value go first, or a comparison. Record order comes last. */
37
+ tie?: Tie<T>;
38
+ }
39
+
40
+ /** What one search narrows to and returns. */
41
+ export interface SearchQuery<T> {
42
+ /** Field values a record must have (a list field: contain), or a test. */
43
+ filter?: Filter<T>;
44
+ /** The most hits to return (all of them by default). */
45
+ limit?: number;
46
+ }
47
+
48
+ /** One record found. */
49
+ export interface Hit<T> {
50
+ item: T;
51
+ /** Its place in the records. */
52
+ index: number;
53
+ /** How well it answers the query (0 with no words). */
54
+ score: number;
55
+ }
56
+
57
+ /** A search's answer. */
58
+ export interface Result<T> {
59
+ /** How many records matched (more than `hits` when the limit cut it). */
60
+ total: number;
61
+ /** Best first; in record order when the query has no words. */
62
+ hits: Hit<T>[];
63
+ /** The query as read. */
64
+ terms: Term[];
65
+ }
66
+
67
+ /** A built search. */
68
+ export interface Search<T> {
69
+ readonly records: readonly T[];
70
+ /** Every record that passes the filter and has every term of the query, best first. */
71
+ search(query?: string, options?: SearchQuery<T>): Result<T>;
72
+ /** How a query is read: its terms, their synonyms and singulars. */
73
+ terms(query: string): Term[];
74
+ /** Each field's values among the records a query and filter find, with counts, most first. */
75
+ facets<K extends string>(fields: readonly K[], options?: { query?: string; filter?: Filter<T> }): Record<K, Record<string, number>>;
76
+ }
77
+
78
+ /** Builds a search over records: each is prepared once, then searched by words, filtered and ranked. */
79
+ export function createSearch<T extends object>(records: readonly T[], options: SearchOptions<T>): Search<T>;
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);