@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/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
@@ -0,0 +1,2 @@
1
+ export { createServer } from './server.js'
2
+ export { loadProject } from './project.js'