@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/CHANGELOG.md +21 -0
- package/LICENSE +21 -0
- package/README.md +69 -2
- package/package.json +61 -4
- package/src/filter/address.d.ts +6 -0
- package/src/filter/address.js +48 -0
- package/src/filter/facets.d.ts +2 -0
- package/src/filter/facets.js +28 -0
- package/src/filter/filter.d.ts +8 -0
- package/src/filter/filter.js +33 -0
- package/src/filter/selection.d.ts +28 -0
- package/src/filter/selection.js +135 -0
- package/src/filter/tie.d.ts +5 -0
- package/src/filter/tie.js +29 -0
- package/src/index.d.ts +14 -0
- package/src/index.js +17 -0
- package/src/match/fields.d.ts +15 -0
- package/src/match/fields.js +20 -0
- package/src/match/record.d.ts +12 -0
- package/src/match/record.js +44 -0
- package/src/match/score.d.ts +16 -0
- package/src/match/score.js +56 -0
- package/src/pack/columns.d.ts +4 -0
- package/src/pack/columns.js +78 -0
- package/src/pack/index.d.ts +22 -0
- package/src/pack/index.js +49 -0
- package/src/query/plural.d.ts +5 -0
- package/src/query/plural.js +17 -0
- package/src/query/terms.d.ts +28 -0
- package/src/query/terms.js +49 -0
- package/src/rules/assign.d.ts +48 -0
- package/src/rules/assign.js +112 -0
- package/src/rules/index.d.ts +4 -0
- package/src/rules/index.js +9 -0
- package/src/rules/score.d.ts +21 -0
- package/src/rules/score.js +49 -0
- package/src/search.d.ts +79 -0
- package/src/search.js +68 -0
- package/src/util/regex.d.ts +6 -0
- package/src/util/regex.js +31 -0
- package/src/util/values.d.ts +4 -0
- package/src/util/values.js +23 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
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.2.0] — 2026-11-15
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- **A filter panel's logic:** `facetTable` (each record's values per facet, worked out once), `narrow` (any picked value within a facet, every facet with a pick), `facetCounts` (each value's live count against the other facets' picks, so it is what ticking it would show), `toggle`, `pickCount`, `picks`, `sameSelection`, and `readSelection` / `writeSelection` for the selection in a page's address. From the Logomarks filter panel. `facets()` is unchanged: it counts values without a selection.
|
|
13
|
+
|
|
14
|
+
## [0.1.0] — 2026-11-05
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
|
|
18
|
+
- **`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).
|
|
19
|
+
- **`pack` / `unpack`**: a compact JSON index, built once and loaded in the browser. Records go in as columns, with repeated strings in tables.
|
|
20
|
+
- **`@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.
|
|
21
|
+
- 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
|
-
#
|
|
1
|
+
# @arraypress/search
|
|
2
2
|
|
|
3
|
-
|
|
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, a filter panel's facet logic (live counts, the selection in the address), 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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.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,6 @@
|
|
|
1
|
+
import type { Selection } from './selection.js';
|
|
2
|
+
|
|
3
|
+
/** Writes the selection into a query (one comma-joined parameter per picked facet), keeping other parameters. */
|
|
4
|
+
export function writeSelection<F extends string>(params: URLSearchParams, ids: readonly F[], sel: Selection<F>, keys?: Partial<Record<F, string>>): URLSearchParams;
|
|
5
|
+
/** The selection a query asks for; unknown values and repeats dropped. */
|
|
6
|
+
export function readSelection<F extends string>(params: URLSearchParams, ids: readonly F[], known?: Partial<Record<F, ReadonlySet<string>>>, keys?: Partial<Record<F, string>>): Selection<F>;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module @arraypress/search/filter/address
|
|
3
|
+
*
|
|
4
|
+
* A selection in a page's address: one query parameter per facet with a pick,
|
|
5
|
+
* its values joined by commas (`?style=iso,track&mood=calm`), and back.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Writes the selection into a query: one parameter per facet with a pick (its key from `keys`, else its id), the
|
|
10
|
+
* values joined by commas, in facet order. Other parameters are kept; a facet without a pick is removed.
|
|
11
|
+
*
|
|
12
|
+
* @param {URLSearchParams} params The query, changed in place.
|
|
13
|
+
* @param {readonly string[]} ids The facets, in order.
|
|
14
|
+
* @param {import('./selection.js').Selection} sel The selection.
|
|
15
|
+
* @param {Record<string, string>} [keys] A parameter name per facet, where it isn't the facet's id.
|
|
16
|
+
* @returns {URLSearchParams} The same query.
|
|
17
|
+
*/
|
|
18
|
+
export function writeSelection(params, ids, sel, keys = {}) {
|
|
19
|
+
for (const f of ids) {
|
|
20
|
+
const key = keys[f] ?? f;
|
|
21
|
+
const vals = sel[f];
|
|
22
|
+
if (vals?.length) params.set(key, vals.join(','));
|
|
23
|
+
else params.delete(key);
|
|
24
|
+
}
|
|
25
|
+
return params;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The selection a query asks for. Values a facet doesn't take (when `known` is given) are dropped, and repeats.
|
|
30
|
+
*
|
|
31
|
+
* @param {URLSearchParams} params The query.
|
|
32
|
+
* @param {readonly string[]} ids The facets.
|
|
33
|
+
* @param {Record<string, ReadonlySet<string>>} [known] The values each facet takes (a table's `known`).
|
|
34
|
+
* @param {Record<string, string>} [keys] A parameter name per facet, where it isn't the facet's id.
|
|
35
|
+
* @returns {import('./selection.js').Selection} The selection.
|
|
36
|
+
*/
|
|
37
|
+
export function readSelection(params, ids, known, keys = {}) {
|
|
38
|
+
/** @type {Record<string, string[]>} */
|
|
39
|
+
const sel = {};
|
|
40
|
+
for (const f of ids) {
|
|
41
|
+
const raw = params.get(keys[f] ?? f);
|
|
42
|
+
if (!raw) continue;
|
|
43
|
+
const ok = known?.[f];
|
|
44
|
+
const vals = [...new Set(raw.split(',').map((v) => v.trim()))].filter((v) => v && (!ok || ok.has(v)));
|
|
45
|
+
if (vals.length) sel[f] = vals;
|
|
46
|
+
}
|
|
47
|
+
return sel;
|
|
48
|
+
}
|
|
@@ -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,28 @@
|
|
|
1
|
+
/** The values picked in each facet; an absent or empty list means any. */
|
|
2
|
+
export type Selection<F extends string = string> = Partial<Record<F, readonly string[]>>;
|
|
3
|
+
|
|
4
|
+
/** Each record's values per facet: `values[k][i]` are record i's values in facet `ids[k]`. */
|
|
5
|
+
export interface FacetTable<F extends string = string> {
|
|
6
|
+
ids: readonly F[];
|
|
7
|
+
values: (readonly string[])[][];
|
|
8
|
+
/** Every value each facet takes, for checking a selection read from an address. */
|
|
9
|
+
known: Record<F, ReadonlySet<string>>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Live counts: per facet, each value's count among the records that pass every other facet. */
|
|
13
|
+
export type FacetCounts<F extends string = string> = Record<F, Map<string, number>>;
|
|
14
|
+
|
|
15
|
+
/** Each record's values in each facet, as a table. */
|
|
16
|
+
export function facetTable<T, F extends string>(records: readonly T[], valuesOf: Record<F, (record: T) => readonly string[]>): FacetTable<F>;
|
|
17
|
+
/** The records (indices, in the order given) that pass every facet with a pick. */
|
|
18
|
+
export function narrow<F extends string>(table: FacetTable<F>, candidates: Iterable<number>, sel: Selection<F>): number[];
|
|
19
|
+
/** Each facet's value counts among the candidates, each counted against the other facets' picks. */
|
|
20
|
+
export function facetCounts<F extends string>(table: FacetTable<F>, candidates: Iterable<number>, sel: Selection<F>): FacetCounts<F>;
|
|
21
|
+
/** The selection with one value ticked, unticked or (with `on`) forced. */
|
|
22
|
+
export function toggle<F extends string>(sel: Selection<F>, facet: F, value: string, on?: boolean): Selection<F>;
|
|
23
|
+
/** How many values are picked across every facet. */
|
|
24
|
+
export function pickCount(sel: Selection): number;
|
|
25
|
+
/** Every pick as [facet, value], in facet order then pick order. */
|
|
26
|
+
export function picks<F extends string>(ids: readonly F[], sel: Selection<F>): (readonly [F, string])[];
|
|
27
|
+
/** Whether two selections pick the same values, in any order. */
|
|
28
|
+
export function sameSelection(a: Selection, b: Selection): boolean;
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module @arraypress/search/filter/selection
|
|
3
|
+
*
|
|
4
|
+
* A filter panel's logic over facets: each record's values per facet worked
|
|
5
|
+
* out once (a table), the records that pass a selection (any picked value
|
|
6
|
+
* within a facet, every facet with a pick), each value's live count (counted
|
|
7
|
+
* against the other facets' picks, so it is what ticking it would show), and
|
|
8
|
+
* small helpers for changing and comparing selections. Generic over facet ids.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Each record's values in each facet, as a table.
|
|
13
|
+
*
|
|
14
|
+
* @template T, F
|
|
15
|
+
* @param {readonly T[]} records The records.
|
|
16
|
+
* @param {Record<F & string, (record: T) => readonly string[]>} valuesOf Per facet, a record's values in it (none for none).
|
|
17
|
+
* @returns {import('./selection.js').FacetTable<F & string>} The table.
|
|
18
|
+
*/
|
|
19
|
+
export function facetTable(records, valuesOf) {
|
|
20
|
+
const ids = /** @type {(F & string)[]} */ (Object.keys(valuesOf));
|
|
21
|
+
const values = ids.map((f) => records.map((r) => valuesOf[f](r)));
|
|
22
|
+
const known = /** @type {Record<F & string, ReadonlySet<string>>} */ (Object.fromEntries(ids.map((f, k) => [f, new Set(values[k].flat())])));
|
|
23
|
+
return { ids, values, known };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The facets with a pick, as [table column, picked values].
|
|
28
|
+
*
|
|
29
|
+
* @param {import('./selection.js').FacetTable} table The table.
|
|
30
|
+
* @param {import('./selection.js').Selection} sel The selection.
|
|
31
|
+
* @returns {(readonly [number, Set<string>])[]} The picks.
|
|
32
|
+
*/
|
|
33
|
+
const picked = (table, sel) => table.ids.flatMap((f, k) => (sel[f]?.length ? [/** @type {const} */ ([k, new Set(sel[f])])] : []));
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Whether a record has any of the picked values.
|
|
37
|
+
*
|
|
38
|
+
* @param {readonly string[]} have The record's values.
|
|
39
|
+
* @param {ReadonlySet<string>} want The picked values.
|
|
40
|
+
* @returns {boolean} True when one matches.
|
|
41
|
+
*/
|
|
42
|
+
const hasAny = (have, want) => have.some((v) => want.has(v));
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The records (indices, in the order given) that pass every facet with a pick: any of its picked values.
|
|
46
|
+
*
|
|
47
|
+
* @param {import('./selection.js').FacetTable} table The table.
|
|
48
|
+
* @param {Iterable<number>} candidates The records to consider (a search's hits, or all).
|
|
49
|
+
* @param {import('./selection.js').Selection} sel The selection.
|
|
50
|
+
* @returns {number[]} The indices that pass.
|
|
51
|
+
*/
|
|
52
|
+
export function narrow(table, candidates, sel) {
|
|
53
|
+
const picks = picked(table, sel);
|
|
54
|
+
/** @type {number[]} */
|
|
55
|
+
const out = [];
|
|
56
|
+
for (const i of candidates) if (picks.every(([k, want]) => hasAny(table.values[k][i], want))) out.push(i);
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Each facet's value counts among the candidates: a value's count is how many candidates have it and pass every
|
|
62
|
+
* OTHER facet, so it is how many records ticking it would add (or, in a facet with no pick, show).
|
|
63
|
+
*
|
|
64
|
+
* @param {import('./selection.js').FacetTable} table The table.
|
|
65
|
+
* @param {Iterable<number>} candidates The records to count (a search's hits, or all).
|
|
66
|
+
* @param {import('./selection.js').Selection} sel The selection.
|
|
67
|
+
* @returns {Record<string, Map<string, number>>} Per facet, each value's count.
|
|
68
|
+
*/
|
|
69
|
+
export function facetCounts(table, candidates, sel) {
|
|
70
|
+
const picks = picked(table, sel);
|
|
71
|
+
const counts = table.ids.map(() => /** @type {Map<string, number>} */ (new Map()));
|
|
72
|
+
/** Counts record i's values in facet column k. */
|
|
73
|
+
const add = (/** @type {number} */ k, /** @type {number} */ i) => {
|
|
74
|
+
for (const v of table.values[k][i]) counts[k].set(v, (counts[k].get(v) ?? 0) + 1);
|
|
75
|
+
};
|
|
76
|
+
for (const i of candidates) {
|
|
77
|
+
// the facets it fails: none, it counts everywhere; one, it counts only there (ticking more of that facet adds it)
|
|
78
|
+
let failed = -1;
|
|
79
|
+
let fails = 0;
|
|
80
|
+
for (const [k, want] of picks)
|
|
81
|
+
if (!hasAny(table.values[k][i], want)) {
|
|
82
|
+
failed = k;
|
|
83
|
+
if (++fails > 1) break;
|
|
84
|
+
}
|
|
85
|
+
if (fails === 0) table.ids.forEach((_, k) => add(k, i));
|
|
86
|
+
else if (fails === 1) add(failed, i);
|
|
87
|
+
}
|
|
88
|
+
return Object.fromEntries(table.ids.map((f, k) => [f, counts[k]]));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The selection with one value ticked or unticked.
|
|
93
|
+
*
|
|
94
|
+
* @param {import('./selection.js').Selection} sel The selection.
|
|
95
|
+
* @param {string} facet The facet.
|
|
96
|
+
* @param {string} value The value.
|
|
97
|
+
* @param {boolean} [on] Force it on or off; toggles when absent.
|
|
98
|
+
* @returns {import('./selection.js').Selection} A new selection.
|
|
99
|
+
*/
|
|
100
|
+
export function toggle(sel, facet, value, on) {
|
|
101
|
+
const have = sel[facet] ?? [];
|
|
102
|
+
const now = on ?? !have.includes(value);
|
|
103
|
+
const next = now ? (have.includes(value) ? have : [...have, value]) : have.filter((v) => v !== value);
|
|
104
|
+
return { ...sel, [facet]: next };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* How many values are picked across every facet.
|
|
109
|
+
*
|
|
110
|
+
* @param {import('./selection.js').Selection} sel The selection.
|
|
111
|
+
* @returns {number} The count.
|
|
112
|
+
*/
|
|
113
|
+
export const pickCount = (sel) => Object.values(sel).reduce((n, v) => n + (v?.length ?? 0), 0);
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Every pick as [facet, value], in facet order then pick order (a row of chips).
|
|
117
|
+
*
|
|
118
|
+
* @param {readonly string[]} ids The facets, in order.
|
|
119
|
+
* @param {import('./selection.js').Selection} sel The selection.
|
|
120
|
+
* @returns {(readonly [string, string])[]} The picks.
|
|
121
|
+
*/
|
|
122
|
+
export const picks = (ids, sel) => ids.flatMap((f) => (sel[f] ?? []).map((v) => /** @type {const} */ ([f, v])));
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Whether two selections pick the same values, in any order (an empty list is no pick).
|
|
126
|
+
*
|
|
127
|
+
* @param {import('./selection.js').Selection} a One selection.
|
|
128
|
+
* @param {import('./selection.js').Selection} b The other.
|
|
129
|
+
* @returns {boolean} True when they match.
|
|
130
|
+
*/
|
|
131
|
+
export function sameSelection(a, b) {
|
|
132
|
+
const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
|
|
133
|
+
for (const k of keys) if ([...(a[k] ?? [])].sort().join(',') !== [...(b[k] ?? [])].sort().join(',')) return false;
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
@@ -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,14 @@
|
|
|
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 { facetTable, narrow, facetCounts, toggle, pickCount, picks, sameSelection } from './filter/selection.js';
|
|
9
|
+
export type { Selection, FacetTable, FacetCounts } from './filter/selection.js';
|
|
10
|
+
export { readSelection, writeSelection } from './filter/address.js';
|
|
11
|
+
export type { Tie } from './filter/tie.js';
|
|
12
|
+
export type { Term, TermOptions } from './query/terms.js';
|
|
13
|
+
export { termsOf, synonymMap } from './query/terms.js';
|
|
14
|
+
export { singular } from './query/plural.js';
|
package/src/index.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
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 { facetTable, narrow, facetCounts, toggle, pickCount, picks, sameSelection } from './filter/selection.js';
|
|
15
|
+
export { readSelection, writeSelection } from './filter/address.js';
|
|
16
|
+
export { termsOf, synonymMap } from './query/terms.js';
|
|
17
|
+
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
|
+
}
|