@quasar/mcp 1.0.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/LICENSE +21 -0
- package/README.md +67 -0
- package/package.json +61 -0
- package/src/api.js +272 -0
- package/src/bin.js +63 -0
- package/src/docs.js +606 -0
- package/src/index.js +2 -0
- package/src/project.js +189 -0
- package/src/server.js +526 -0
- package/src/slugify.js +21 -0
- package/src/updates.js +41 -0
- package/src/version.js +6 -0
package/src/docs.js
ADDED
|
@@ -0,0 +1,606 @@
|
|
|
1
|
+
import { readFileSync, statSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { slugify } from './slugify.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The slice format this server reads (meta.json `format`, written by
|
|
8
|
+
* the docs generator, docs/build/mcp/output/meta.js). A slice of
|
|
9
|
+
* another format is left out and named in the instructions: the
|
|
10
|
+
* server's major version tracks the format, so `@quasar/mcp@<format>`
|
|
11
|
+
* is the server for it. One reader, no legacy readers.
|
|
12
|
+
*/
|
|
13
|
+
export const DOCS_FORMAT = 1
|
|
14
|
+
|
|
15
|
+
const SITE_URL_RE = /^https?:\/\/(?:v2\.)?quasar\.dev\//
|
|
16
|
+
const HEADING_RE = /^(#{1,6})\s+(.+?)\s*#*\s*$/
|
|
17
|
+
const FRONTMATTER_RE = /^---\n[\s\S]*?\n---\n/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @typedef {object} Page
|
|
21
|
+
* @property {string} route Menu key, e.g. `vue-components/button`.
|
|
22
|
+
* @property {string} title
|
|
23
|
+
* @property {string | null} desc
|
|
24
|
+
* @property {string[]} keys The names the page documents (components, plugins, directives, composables, functions), as the docs frontmatter lists them; empty in a slice predating the field.
|
|
25
|
+
* @property {string} packageName The installed package whose slice holds the page.
|
|
26
|
+
* @property {string} file Absolute path of the markdown file.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @typedef {object} Docs
|
|
31
|
+
* @property {Map<string, Page>} pages Keyed by route.
|
|
32
|
+
* @property {Array<{ name: string, version: string, pageCount: number }>} sources
|
|
33
|
+
* @property {Array<{ name: string, version: string, format: number }>} unreadable Installed slices of another format than DOCS_FORMAT, left out.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Index every bundled slice. Bodies are read on demand and cached.
|
|
38
|
+
*
|
|
39
|
+
* @param {import('./project.js').InstalledPackage[]} packages
|
|
40
|
+
* @returns {Docs}
|
|
41
|
+
*/
|
|
42
|
+
export function loadDocs(packages) {
|
|
43
|
+
const pages = new Map()
|
|
44
|
+
const sources = []
|
|
45
|
+
const unreadable = []
|
|
46
|
+
for (const pkg of packages) {
|
|
47
|
+
if (pkg.docsDir === null) {
|
|
48
|
+
continue
|
|
49
|
+
}
|
|
50
|
+
const meta = JSON.parse(
|
|
51
|
+
readFileSync(join(pkg.docsDir, 'meta.json'), 'utf8')
|
|
52
|
+
)
|
|
53
|
+
// the first slices predate the field
|
|
54
|
+
const format = meta.format ?? 1
|
|
55
|
+
if (format !== DOCS_FORMAT) {
|
|
56
|
+
unreadable.push({ name: pkg.name, version: meta.version, format })
|
|
57
|
+
continue
|
|
58
|
+
}
|
|
59
|
+
let pageCount = 0
|
|
60
|
+
for (const { route, title, desc, keys } of meta.pages) {
|
|
61
|
+
// A page both slices carry (the agent setup page) is served once.
|
|
62
|
+
if (pages.has(route)) {
|
|
63
|
+
continue
|
|
64
|
+
}
|
|
65
|
+
pages.set(route, {
|
|
66
|
+
route,
|
|
67
|
+
title,
|
|
68
|
+
desc: desc ?? null,
|
|
69
|
+
keys: keys ?? [],
|
|
70
|
+
packageName: pkg.name,
|
|
71
|
+
file: join(pkg.docsDir, `${route}.md`)
|
|
72
|
+
})
|
|
73
|
+
pageCount++
|
|
74
|
+
}
|
|
75
|
+
sources.push({ name: pkg.name, version: meta.version, pageCount })
|
|
76
|
+
}
|
|
77
|
+
return { pages, sources, unreadable }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Accepts what an agent is likely to paste: a route, a `/route`, a
|
|
82
|
+
* `route.md`, a `../route.md` link from another page, or a quasar.dev
|
|
83
|
+
* URL, with or without a fragment.
|
|
84
|
+
*
|
|
85
|
+
* @param {string} input
|
|
86
|
+
* @returns {string}
|
|
87
|
+
*/
|
|
88
|
+
export function normalizeRoute(input) {
|
|
89
|
+
let route = input.trim().replace(SITE_URL_RE, '')
|
|
90
|
+
const hashIndex = route.indexOf('#')
|
|
91
|
+
if (hashIndex !== -1) {
|
|
92
|
+
route = route.slice(0, hashIndex)
|
|
93
|
+
}
|
|
94
|
+
return route
|
|
95
|
+
.replace(/^(\.\.?\/)+/, '')
|
|
96
|
+
.replace(/^\/+/, '')
|
|
97
|
+
.replace(/\/+$/, '')
|
|
98
|
+
.replace(/\.md$/, '')
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The heading a pasted link points at: the fragment of a `route.md#slug`
|
|
103
|
+
* link or of a quasar.dev URL, null without one.
|
|
104
|
+
*
|
|
105
|
+
* @param {string} input
|
|
106
|
+
* @returns {string | null}
|
|
107
|
+
*/
|
|
108
|
+
export function routeFragment(input) {
|
|
109
|
+
const hashIndex = input.indexOf('#')
|
|
110
|
+
const fragment = hashIndex === -1 ? '' : input.slice(hashIndex + 1).trim()
|
|
111
|
+
return fragment === '' ? null : fragment
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* What reading the whole page costs, so the caller can pick a section
|
|
116
|
+
* instead: markdown and code run to about four bytes a token.
|
|
117
|
+
*
|
|
118
|
+
* @param {Page} page
|
|
119
|
+
* @returns {string} E.g. `~400 tokens`, `~12k tokens`.
|
|
120
|
+
*/
|
|
121
|
+
export function pageSize(page) {
|
|
122
|
+
const tokens = statSync(page.file).size / 4
|
|
123
|
+
return tokens < 950
|
|
124
|
+
? `~${Math.max(1, Math.round(tokens / 100)) * 100} tokens`
|
|
125
|
+
: `~${Math.round(tokens / 1000)}k tokens`
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const bodyCache = new Map()
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* @param {Page} page
|
|
132
|
+
* @returns {string} The markdown, frontmatter included.
|
|
133
|
+
*/
|
|
134
|
+
export function readPage(page) {
|
|
135
|
+
let body = bodyCache.get(page.file)
|
|
136
|
+
if (body === void 0) {
|
|
137
|
+
body = readFileSync(page.file, 'utf8')
|
|
138
|
+
bodyCache.set(page.file, body)
|
|
139
|
+
}
|
|
140
|
+
return body
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* @param {string} markdown
|
|
145
|
+
* @returns {Array<{ level: number, text: string }>}
|
|
146
|
+
*/
|
|
147
|
+
export function listHeadings(markdown) {
|
|
148
|
+
const headings = []
|
|
149
|
+
let inFence = false
|
|
150
|
+
for (const line of markdown.split('\n')) {
|
|
151
|
+
if (line.startsWith('```')) {
|
|
152
|
+
inFence = !inFence
|
|
153
|
+
continue
|
|
154
|
+
}
|
|
155
|
+
if (inFence) {
|
|
156
|
+
continue
|
|
157
|
+
}
|
|
158
|
+
const match = HEADING_RE.exec(line)
|
|
159
|
+
if (match !== null) {
|
|
160
|
+
headings.push({ level: match[1].length, text: match[2] })
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return headings
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The part of a page under one heading: from that heading up to the
|
|
168
|
+
* next heading of the same or a higher level. Matched case-insensitively
|
|
169
|
+
* on the heading text; a `#fragment`-style slug matches too.
|
|
170
|
+
*
|
|
171
|
+
* @param {string} markdown
|
|
172
|
+
* @param {string} heading
|
|
173
|
+
* @returns {string | null}
|
|
174
|
+
*/
|
|
175
|
+
export function extractSection(markdown, heading) {
|
|
176
|
+
const wanted = slugify(heading)
|
|
177
|
+
const lines = markdown.split('\n')
|
|
178
|
+
let start = -1
|
|
179
|
+
let level = 0
|
|
180
|
+
let inFence = false
|
|
181
|
+
for (let index = 0; index < lines.length; index++) {
|
|
182
|
+
const line = lines[index]
|
|
183
|
+
if (line.startsWith('```')) {
|
|
184
|
+
inFence = !inFence
|
|
185
|
+
continue
|
|
186
|
+
}
|
|
187
|
+
if (inFence) {
|
|
188
|
+
continue
|
|
189
|
+
}
|
|
190
|
+
const match = HEADING_RE.exec(line)
|
|
191
|
+
if (match === null) {
|
|
192
|
+
continue
|
|
193
|
+
}
|
|
194
|
+
if (start === -1) {
|
|
195
|
+
if (slugify(match[2]) === wanted) {
|
|
196
|
+
start = index
|
|
197
|
+
level = match[1].length
|
|
198
|
+
}
|
|
199
|
+
} else if (match[1].length <= level) {
|
|
200
|
+
return lines.slice(start, index).join('\n').trim()
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return start === -1 ? null : lines.slice(start).join('\n').trim()
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* The terms of a text: lower-case runs of letters, digits and the
|
|
208
|
+
* characters identifiers carry (`q-btn`, `$q.notify`, `@click`,
|
|
209
|
+
* `vue.config`), trimmed of the dots and dashes punctuation leaves at
|
|
210
|
+
* either end (`notify.`), one character dropped.
|
|
211
|
+
*
|
|
212
|
+
* @param {string} text
|
|
213
|
+
* @returns {string[]}
|
|
214
|
+
*/
|
|
215
|
+
function terms(text) {
|
|
216
|
+
return text
|
|
217
|
+
.toLowerCase()
|
|
218
|
+
.split(/[^a-z0-9$@.-]+/)
|
|
219
|
+
.map(term => term.replaceAll(/^[.-]+|[.-]+$/g, ''))
|
|
220
|
+
.filter(term => term.length > 1)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* The form terms compare in: dashes and dots dropped, so the tag,
|
|
225
|
+
* the component and the key are one word (`q-btn`, `QBtn`, `qbtn`),
|
|
226
|
+
* as are `v-touch-pan` and `TouchPan`.
|
|
227
|
+
*
|
|
228
|
+
* @param {string} term
|
|
229
|
+
* @returns {string}
|
|
230
|
+
*/
|
|
231
|
+
function flat(term) {
|
|
232
|
+
return term.replaceAll(/[-.]/g, '')
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* The comparable words of a text: its terms, flat, plus the parts of
|
|
237
|
+
* the compound ones, so `btn` finds `q-btn` and `components` finds
|
|
238
|
+
* `vue-components`.
|
|
239
|
+
*
|
|
240
|
+
* @param {string} text
|
|
241
|
+
* @returns {string[]}
|
|
242
|
+
*/
|
|
243
|
+
function words(text) {
|
|
244
|
+
const list = []
|
|
245
|
+
for (const term of terms(text)) {
|
|
246
|
+
list.push(flat(term))
|
|
247
|
+
if (/[-.]/.test(term)) {
|
|
248
|
+
for (const part of term.split(/[-.]+/)) {
|
|
249
|
+
if (part.length > 1) {
|
|
250
|
+
list.push(part)
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return list
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* A plural and its singular stem alike (`buttons`/`button`,
|
|
260
|
+
* `classes`/`class`, `properties`/`property`). Consistent, not correct:
|
|
261
|
+
* both sides of a comparison go through it.
|
|
262
|
+
*
|
|
263
|
+
* @param {string} word
|
|
264
|
+
* @returns {string}
|
|
265
|
+
*/
|
|
266
|
+
function stem(word) {
|
|
267
|
+
if (word.length < 4 || !word.endsWith('s') || word.endsWith('ss')) {
|
|
268
|
+
return word
|
|
269
|
+
}
|
|
270
|
+
if (word.endsWith('ies')) {
|
|
271
|
+
return `${word.slice(0, -3)}y`
|
|
272
|
+
}
|
|
273
|
+
if (/(?:ss|sh|ch|x)es$/.test(word)) {
|
|
274
|
+
return word.slice(0, -2)
|
|
275
|
+
}
|
|
276
|
+
return word.slice(0, -1)
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* How well a term matches one word: the word itself, its plural or
|
|
281
|
+
* singular, or a word starting with it (`valid` for `validation`, three
|
|
282
|
+
* characters at least so `to` does not match `toolbar`).
|
|
283
|
+
*
|
|
284
|
+
* @param {string} term
|
|
285
|
+
* @param {string} word
|
|
286
|
+
* @returns {number} 0 for no match, else 0.5 to 1.
|
|
287
|
+
*/
|
|
288
|
+
function wordStrength(term, word) {
|
|
289
|
+
if (word === term) {
|
|
290
|
+
return 1
|
|
291
|
+
}
|
|
292
|
+
if (stem(word) === stem(term)) {
|
|
293
|
+
return 0.8
|
|
294
|
+
}
|
|
295
|
+
return term.length >= 3 && word.startsWith(term) ? 0.5 : 0
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* The best match of a term among some words.
|
|
300
|
+
*
|
|
301
|
+
* @param {string} term
|
|
302
|
+
* @param {string[]} list
|
|
303
|
+
* @returns {number}
|
|
304
|
+
*/
|
|
305
|
+
function strength(term, list) {
|
|
306
|
+
let best = 0
|
|
307
|
+
for (const word of list) {
|
|
308
|
+
const current = wordStrength(term, word)
|
|
309
|
+
if (current > best) {
|
|
310
|
+
best = current
|
|
311
|
+
if (best === 1) {
|
|
312
|
+
break
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
return best
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const API_HEADING_RE = /^(\S+) API$/
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* @typedef {object} PageIndex
|
|
323
|
+
* @property {string[]} title
|
|
324
|
+
* @property {string[]} titleStems
|
|
325
|
+
* @property {string[]} route
|
|
326
|
+
* @property {string[]} desc
|
|
327
|
+
* @property {string[]} headings
|
|
328
|
+
* @property {string[]} names The names the page documents, flat: its meta `keys`, and the `<Name> API` headings for a slice predating the field.
|
|
329
|
+
* @property {Map<string, number>} body Occurrences per distinct body word.
|
|
330
|
+
* @property {Map<string, number>} bodyStems Occurrences per distinct body word stem.
|
|
331
|
+
* @property {string[]} bodyWords The distinct body words, sorted.
|
|
332
|
+
*/
|
|
333
|
+
|
|
334
|
+
const indexCache = new Map()
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* @param {Page} page
|
|
338
|
+
* @returns {PageIndex}
|
|
339
|
+
*/
|
|
340
|
+
function indexPage(page) {
|
|
341
|
+
let index = indexCache.get(page.file)
|
|
342
|
+
if (index !== void 0) {
|
|
343
|
+
return index
|
|
344
|
+
}
|
|
345
|
+
const markdown = readPage(page)
|
|
346
|
+
const headings = listHeadings(markdown).map(heading => heading.text)
|
|
347
|
+
const names = new Set(page.keys)
|
|
348
|
+
for (const heading of headings) {
|
|
349
|
+
const match = API_HEADING_RE.exec(heading)
|
|
350
|
+
if (match !== null) {
|
|
351
|
+
names.add(match[1])
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
const body = new Map()
|
|
355
|
+
for (const word of words(markdown.replace(FRONTMATTER_RE, ''))) {
|
|
356
|
+
body.set(word, (body.get(word) ?? 0) + 1)
|
|
357
|
+
}
|
|
358
|
+
const bodyStems = new Map()
|
|
359
|
+
for (const [word, count] of body) {
|
|
360
|
+
const key = stem(word)
|
|
361
|
+
bodyStems.set(key, (bodyStems.get(key) ?? 0) + count)
|
|
362
|
+
}
|
|
363
|
+
const title = words(page.title)
|
|
364
|
+
index = {
|
|
365
|
+
title,
|
|
366
|
+
titleStems: title.map(stem),
|
|
367
|
+
route: words(page.route),
|
|
368
|
+
desc: words(page.desc ?? ''),
|
|
369
|
+
headings: words(headings.join(' ')),
|
|
370
|
+
names: [...names].map(name => flat(name.toLowerCase())),
|
|
371
|
+
body,
|
|
372
|
+
bodyStems,
|
|
373
|
+
bodyWords: [...body.keys()].sort()
|
|
374
|
+
}
|
|
375
|
+
indexCache.set(page.file, index)
|
|
376
|
+
return index
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* How much a body is about a term: the occurrences of the words it
|
|
381
|
+
* matches, each by how well (as wordStrength() rates them), on a log
|
|
382
|
+
* scale so a long page's hundredth mention adds nothing (1 occurrence:
|
|
383
|
+
* 2, 7: 6, 63: 12, the cap).
|
|
384
|
+
*
|
|
385
|
+
* @param {string} term
|
|
386
|
+
* @param {PageIndex} index
|
|
387
|
+
* @returns {{ occurrences: number, score: number }}
|
|
388
|
+
*/
|
|
389
|
+
function bodyMatch(term, { body, bodyStems, bodyWords }) {
|
|
390
|
+
const exact = body.get(term) ?? 0
|
|
391
|
+
const termStem = stem(term)
|
|
392
|
+
const stemmed = (bodyStems.get(termStem) ?? 0) - exact
|
|
393
|
+
let prefixed = 0
|
|
394
|
+
if (term.length >= 3) {
|
|
395
|
+
// the words starting with the term sit together in the sorted list
|
|
396
|
+
let low = 0
|
|
397
|
+
let high = bodyWords.length
|
|
398
|
+
while (low < high) {
|
|
399
|
+
const middle = (low + high) >>> 1
|
|
400
|
+
if (bodyWords[middle] < term) {
|
|
401
|
+
low = middle + 1
|
|
402
|
+
} else {
|
|
403
|
+
high = middle
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
for (; low < bodyWords.length && bodyWords[low].startsWith(term); low++) {
|
|
407
|
+
const word = bodyWords[low]
|
|
408
|
+
if (word !== term && stem(word) !== termStem) {
|
|
409
|
+
prefixed += body.get(word)
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
const occurrences = exact + 0.8 * stemmed + 0.5 * prefixed
|
|
414
|
+
return {
|
|
415
|
+
occurrences,
|
|
416
|
+
score: Math.min(2 * Math.log2(1 + occurrences), 12)
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* The headings under which the terms occur, the section most about
|
|
422
|
+
* the query first, so the caller can read that section instead of
|
|
423
|
+
* the page. A term weighs the inverse of how many sections mention
|
|
424
|
+
* it: on a page about tables "table" is everywhere and says nothing
|
|
425
|
+
* about a section, "sorting" is in a few and marks them. A term in
|
|
426
|
+
* the heading itself counts extra, the more so the shorter the
|
|
427
|
+
* heading ("Sorting" over "Custom sorting" over "Server side
|
|
428
|
+
* pagination, filter and sorting"), and occurrences break ties. The
|
|
429
|
+
* heading in effect is the nearest one above a line, whatever its
|
|
430
|
+
* level; text inside fences counts, fence markers and frontmatter do
|
|
431
|
+
* not. Terms match words as in searchDocs().
|
|
432
|
+
*
|
|
433
|
+
* @param {string} markdown
|
|
434
|
+
* @param {string[]} queryTerms Lower-case.
|
|
435
|
+
* @param {number} [limit]
|
|
436
|
+
* @returns {string[]}
|
|
437
|
+
*/
|
|
438
|
+
export function matchedSections(markdown, queryTerms, limit = 3) {
|
|
439
|
+
/** @type {Map<string, { words: string[], counts: Map<string, number> }>} */
|
|
440
|
+
const sections = new Map()
|
|
441
|
+
let current = null
|
|
442
|
+
let inFence = false
|
|
443
|
+
for (const line of markdown.replace(FRONTMATTER_RE, '').split('\n')) {
|
|
444
|
+
if (line.startsWith('```')) {
|
|
445
|
+
inFence = !inFence
|
|
446
|
+
continue
|
|
447
|
+
}
|
|
448
|
+
const match = inFence ? null : HEADING_RE.exec(line)
|
|
449
|
+
if (match !== null) {
|
|
450
|
+
current = { words: words(match[2]), counts: new Map() }
|
|
451
|
+
sections.set(match[2], current)
|
|
452
|
+
}
|
|
453
|
+
if (current === null) {
|
|
454
|
+
continue
|
|
455
|
+
}
|
|
456
|
+
const lineWords = words(line)
|
|
457
|
+
for (const term of queryTerms) {
|
|
458
|
+
let occurrences = 0
|
|
459
|
+
for (const word of lineWords) {
|
|
460
|
+
occurrences += wordStrength(term, word)
|
|
461
|
+
}
|
|
462
|
+
if (occurrences !== 0) {
|
|
463
|
+
current.counts.set(term, (current.counts.get(term) ?? 0) + occurrences)
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
const sectionsWith = term =>
|
|
468
|
+
[...sections.values()].filter(({ counts }) => counts.has(term)).length
|
|
469
|
+
const weight = new Map(queryTerms.map(term => [term, 1 / sectionsWith(term)]))
|
|
470
|
+
const score = ({ words: headingWords, counts }) => {
|
|
471
|
+
let total = 0
|
|
472
|
+
for (const [term, occurrences] of counts) {
|
|
473
|
+
const inHeading = strength(term, headingWords)
|
|
474
|
+
total +=
|
|
475
|
+
weight.get(term) *
|
|
476
|
+
(1 + (inHeading === 0 ? 0 : (2 * inHeading) / headingWords.length)) +
|
|
477
|
+
Math.min(occurrences, 9) / 1000
|
|
478
|
+
}
|
|
479
|
+
return total
|
|
480
|
+
}
|
|
481
|
+
return [...sections]
|
|
482
|
+
.filter(([, stats]) => stats.counts.size !== 0)
|
|
483
|
+
.sort((a, b) => score(b[1]) - score(a[1]))
|
|
484
|
+
.slice(0, limit)
|
|
485
|
+
.map(([text]) => text)
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* @typedef {object} SearchHit
|
|
490
|
+
* @property {Page} page
|
|
491
|
+
* @property {number} score
|
|
492
|
+
* @property {string[]} sections The headings the terms occur under, see matchedSections().
|
|
493
|
+
*/
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* Term matching over the page index and bodies. A term matches a word
|
|
497
|
+
* (see wordStrength()), never part of one: "tab" is Tabs, not Table.
|
|
498
|
+
* What the page is about weighs most: a term naming what it documents
|
|
499
|
+
* (its `keys`: `QBtn`, `q-btn`, `useMeta`, `v-ripple`) or covering
|
|
500
|
+
* the title whole ("Virtual Scroll" for "virtual scroll"), a third of
|
|
501
|
+
* that for a title it is only part of, less again for the description
|
|
502
|
+
* or the route, which restate the title; one of those counts, the
|
|
503
|
+
* best. Then the headings and how often the body mentions it, on a log
|
|
504
|
+
* scale so a long page cannot outrank the page about the subject;
|
|
505
|
+
* those weigh the inverse of how many pages the term matches, so "to",
|
|
506
|
+
* "use" and "component" decide nothing and "notify" everything. The
|
|
507
|
+
* subject does not: a tag every example uses is no less the name of
|
|
508
|
+
* its page.
|
|
509
|
+
* Every term must match somewhere in the page. Ties go to the page
|
|
510
|
+
* mentioning the terms most, then the first route.
|
|
511
|
+
*
|
|
512
|
+
* @param {Docs} docs
|
|
513
|
+
* @param {string} query
|
|
514
|
+
* @param {{ limit?: number, packageName?: string }} [opts]
|
|
515
|
+
* @returns {SearchHit[]}
|
|
516
|
+
*/
|
|
517
|
+
export function searchDocs(docs, query, { limit = 5, packageName } = {}) {
|
|
518
|
+
const queryTerms = [...new Set(terms(query).map(flat))]
|
|
519
|
+
if (queryTerms.length === 0) {
|
|
520
|
+
return []
|
|
521
|
+
}
|
|
522
|
+
const queryStems = new Set(queryTerms.map(stem))
|
|
523
|
+
const pages = [...docs.pages.values()].filter(
|
|
524
|
+
page => packageName === void 0 || page.packageName === packageName
|
|
525
|
+
)
|
|
526
|
+
// pass one: how each term matches each page, and in how many pages
|
|
527
|
+
const pagesWith = queryTerms.map(() => 0)
|
|
528
|
+
const matches = []
|
|
529
|
+
for (const page of pages) {
|
|
530
|
+
const index = indexPage(page)
|
|
531
|
+
const titleWeight = index.titleStems.every(word => queryStems.has(word))
|
|
532
|
+
? 60
|
|
533
|
+
: 20
|
|
534
|
+
const subjects = []
|
|
535
|
+
const mentions = []
|
|
536
|
+
let occurrences = 0
|
|
537
|
+
for (const [at, term] of queryTerms.entries()) {
|
|
538
|
+
const body = bodyMatch(term, index)
|
|
539
|
+
const subject = Math.max(
|
|
540
|
+
titleWeight * strength(term, index.title),
|
|
541
|
+
60 * strength(term, index.names),
|
|
542
|
+
10 * strength(term, index.desc),
|
|
543
|
+
5 * strength(term, index.route)
|
|
544
|
+
)
|
|
545
|
+
const mention = 15 * strength(term, index.headings) + body.score
|
|
546
|
+
if (subject + mention !== 0) {
|
|
547
|
+
pagesWith[at]++
|
|
548
|
+
}
|
|
549
|
+
subjects.push(subject)
|
|
550
|
+
mentions.push(mention)
|
|
551
|
+
occurrences += body.occurrences
|
|
552
|
+
}
|
|
553
|
+
if (subjects.every((subject, at) => subject + mentions[at] !== 0)) {
|
|
554
|
+
matches.push({ page, subjects, mentions, occurrences })
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
// pass two: a term in every page decides nothing, a rare one a lot
|
|
558
|
+
const weight = pagesWith.map(count => Math.log((pages.length + 1) / count))
|
|
559
|
+
const hits = matches.map(({ page, subjects, mentions, occurrences }) => ({
|
|
560
|
+
page,
|
|
561
|
+
score: queryTerms.reduce(
|
|
562
|
+
(total, _, at) => total + subjects[at] + mentions[at] * weight[at],
|
|
563
|
+
0
|
|
564
|
+
),
|
|
565
|
+
occurrences
|
|
566
|
+
}))
|
|
567
|
+
hits.sort(
|
|
568
|
+
(a, b) =>
|
|
569
|
+
b.score - a.score ||
|
|
570
|
+
b.occurrences - a.occurrences ||
|
|
571
|
+
a.page.route.localeCompare(b.page.route)
|
|
572
|
+
)
|
|
573
|
+
return hits.slice(0, limit).map(({ page, score }) => ({
|
|
574
|
+
page,
|
|
575
|
+
score,
|
|
576
|
+
sections: matchedSections(readPage(page), queryTerms)
|
|
577
|
+
}))
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* Routes resembling a miss. Inside a section some installed package
|
|
582
|
+
* serves, a loose match on the last segment (a typo, a plural); in a
|
|
583
|
+
* section nothing serves, only a page of that exact name, so a route
|
|
584
|
+
* of a package that is not installed does not get a look-alike from
|
|
585
|
+
* another section.
|
|
586
|
+
*
|
|
587
|
+
* @param {Docs} docs
|
|
588
|
+
* @param {string} route
|
|
589
|
+
* @returns {string[]}
|
|
590
|
+
*/
|
|
591
|
+
export function similarRoutes(docs, route) {
|
|
592
|
+
const parts = route.split('/')
|
|
593
|
+
const needle = parts.at(-1) ?? route
|
|
594
|
+
const section = parts.length > 1 ? `${parts.slice(0, -1).join('/')}/` : null
|
|
595
|
+
const known = [...docs.pages.keys()]
|
|
596
|
+
const loose =
|
|
597
|
+
section === null || known.some(candidate => candidate.startsWith(section))
|
|
598
|
+
return known
|
|
599
|
+
.filter(candidate => {
|
|
600
|
+
const leaf = candidate.split('/').at(-1)
|
|
601
|
+
return loose
|
|
602
|
+
? candidate.includes(needle) || needle.includes(leaf)
|
|
603
|
+
: leaf === needle
|
|
604
|
+
})
|
|
605
|
+
.slice(0, 8)
|
|
606
|
+
}
|
package/src/index.js
ADDED