1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
|
/*
* Copyright (c) 2016-2023 Martin Donath <martin.donath@squidfunk.com>
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to
* deal in the Software without restriction, including without limitation the
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
* sell copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
* IN THE SOFTWARE.
*/
import {
SearchDocument,
SearchIndex,
SearchOptions,
setupSearchDocumentMap
} from "../config"
import {
Position,
PositionTable,
highlight,
highlightAll,
tokenize
} from "../internal"
import {
SearchQueryTerms,
getSearchQueryTerms,
parseSearchQuery,
segment,
transformSearchQuery
} from "../query"
/* ----------------------------------------------------------------------------
* Types
* ------------------------------------------------------------------------- */
/**
* Search item
*/
export interface SearchItem
extends SearchDocument
{
score: number /* Score (relevance) */
terms: SearchQueryTerms /* Search query terms */
}
/**
* Search result
*/
export interface SearchResult {
items: SearchItem[][] /* Search items */
suggest?: string[] /* Search suggestions */
}
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Create field extractor factory
*
* @param table - Position table map
*
* @returns Extractor factory
*/
function extractor(table: Map<string, PositionTable>) {
return (name: keyof SearchDocument) => {
return (doc: SearchDocument) => {
if (typeof doc[name] === "undefined")
return undefined
/* Compute identifier and initialize table */
const id = [doc.location, name].join(":")
table.set(id, lunr.tokenizer.table = [])
/* Return field value */
return doc[name]
}
}
}
/**
* Compute the difference of two lists of strings
*
* @param a - 1st list of strings
* @param b - 2nd list of strings
*
* @returns Difference
*/
function difference(a: string[], b: string[]): string[] {
const [x, y] = [new Set(a), new Set(b)]
return [
...new Set([...x].filter(value => !y.has(value)))
]
}
/* ----------------------------------------------------------------------------
* Class
* ------------------------------------------------------------------------- */
/**
* Search index
*/
export class Search {
/**
* Search document map
*/
protected map: Map<string, SearchDocument>
/**
* Search options
*/
protected options: SearchOptions
/**
* The underlying Lunr.js search index
*/
protected index: lunr.Index
/**
* Internal position table map
*/
protected table: Map<string, PositionTable>
/**
* Create the search integration
*
* @param data - Search index
*/
public constructor({ config, docs, options }: SearchIndex) {
const field = extractor(this.table = new Map())
/* Set up document map and options */
this.map = setupSearchDocumentMap(docs)
this.options = options
/* Set up document index */
this.index = lunr(function () {
this.metadataWhitelist = ["position"]
this.b(0)
/* Set up (multi-)language support */
if (config.lang.length === 1 && config.lang[0] !== "en") {
// @ts-expect-error - namespace indexing not supported
this.use(lunr[config.lang[0]])
} else if (config.lang.length > 1) {
this.use(lunr.multiLanguage(...config.lang))
}
/* Set up custom tokenizer (must be after language setup) */
this.tokenizer = tokenize as typeof lunr.tokenizer
lunr.tokenizer.separator = new RegExp(config.separator)
/* Set up custom segmenter, if loaded */
lunr.segmenter = "TinySegmenter" in lunr
? new lunr.TinySegmenter()
: undefined
/* Compute functions to be removed from the pipeline */
const fns = difference([
"trimmer", "stopWordFilter", "stemmer"
], config.pipeline)
/* Remove functions from the pipeline for registered languages */
for (const lang of config.lang.map(language => (
// @ts-expect-error - namespace indexing not supported
language === "en" ? lunr : lunr[language]
)))
for (const fn of fns) {
this.pipeline.remove(lang[fn])
this.searchPipeline.remove(lang[fn])
}
/* Set up index reference */
this.ref("location")
/* Set up index fields */
this.field("title", { boost: 1e3, extractor: field("title") })
this.field("text", { boost: 1e0, extractor: field("text") })
this.field("tags", { boost: 1e6, extractor: field("tags") })
/* Add documents to index */
for (const doc of docs)
this.add(doc, { boost: doc.boost })
})
}
/**
* Search for matching documents
*
* @param query - Search query
*
* @returns Search result
*/
public search(query: string): SearchResult {
// Experimental Chinese segmentation
query = query.replace(/\p{sc=Han}+/gu, value => {
return [...segment(value, this.index.invertedIndex)]
.join("* ")
})
// @todo: move segmenter (above) into transformSearchQuery
query = transformSearchQuery(query)
if (!query)
return { items: [] }
/* Parse query to extract clauses for analysis */
const clauses = parseSearchQuery(query)
.filter(clause => (
clause.presence !== lunr.Query.presence.PROHIBITED
))
/* Perform search and post-process results */
const groups = this.index.search(query)
/* Apply post-query boosts based on title and search query terms */
.reduce<SearchItem[]>((item, { ref, score, matchData }) => {
let doc = this.map.get(ref)
if (typeof doc !== "undefined") {
/* Shallow copy document */
doc = { ...doc }
if (doc.tags)
doc.tags = [...doc.tags]
/* Compute and analyze search query terms */
const terms = getSearchQueryTerms(
clauses,
Object.keys(matchData.metadata)
)
/* Highlight matches in fields */
for (const field of this.index.fields) {
if (typeof doc[field] === "undefined")
continue
/* Collect positions from matches */
const positions: Position[] = []
for (const match of Object.values(matchData.metadata))
if (typeof match[field] !== "undefined")
positions.push(...match[field].position)
/* Skip highlighting, if no positions were collected */
if (!positions.length)
continue
/* Load table and determine highlighting method */
const table = this.table.get([doc.location, field].join(":"))!
const fn = Array.isArray(doc[field])
? highlightAll
: highlight
// @ts-expect-error - stop moaning, TypeScript!
doc[field] = fn(doc[field], table, positions, field !== "text")
}
/* Highlight title and text and apply post-query boosts */
const boost = +!doc.parent +
Object.values(terms)
.filter(t => t).length /
Object.keys(terms).length
/* Append item */
item.push({
...doc,
score: score * (1 + boost ** 2),
terms
})
}
return item
}, [])
/* Sort search results again after applying boosts */
.sort((a, b) => b.score - a.score)
/* Group search results by article */
.reduce((items, result) => {
const doc = this.map.get(result.location)
if (typeof doc !== "undefined") {
const ref = doc.parent
? doc.parent.location
: doc.location
items.set(ref, [...items.get(ref) || [], result])
}
return items
}, new Map<string, SearchItem[]>())
/* Ensure that every item set has an article */
for (const [ref, items] of groups)
if (!items.find(item => item.location === ref)) {
const doc = this.map.get(ref)!
items.push({ ...doc, score: 0, terms: {} })
}
/* Generate search suggestions, if desired */
let suggest: string[] | undefined
if (this.options.suggest) {
const titles = this.index.query(builder => {
for (const clause of clauses)
builder.term(clause.term, {
fields: ["title"],
presence: lunr.Query.presence.REQUIRED,
wildcard: lunr.Query.wildcard.TRAILING
})
})
/* Retrieve suggestions for best match */
suggest = titles.length
? Object.keys(titles[0].matchData.metadata)
: []
}
/* Return search result */
return {
items: [...groups.values()],
...typeof suggest !== "undefined" && { suggest }
}
}
}
|