@taurgis/bonsai 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/README.md +239 -0
- package/agents/agents/official-docs-researcher.agent.md +98 -0
- package/agents/instructions/web-research.instructions.md +29 -0
- package/agents/skills/web-research/SKILL.md +126 -0
- package/bin/cli.mjs +22 -0
- package/dist/base-command.d.ts +14 -0
- package/dist/base-command.js +56 -0
- package/dist/commands/config/base.d.ts +23 -0
- package/dist/commands/config/base.js +46 -0
- package/dist/commands/config/get.d.ts +20 -0
- package/dist/commands/config/get.js +48 -0
- package/dist/commands/config/list.d.ts +17 -0
- package/dist/commands/config/list.js +48 -0
- package/dist/commands/config/set.d.ts +31 -0
- package/dist/commands/config/set.js +94 -0
- package/dist/commands/config/unset.d.ts +21 -0
- package/dist/commands/config/unset.js +65 -0
- package/dist/commands/fetch.d.ts +38 -0
- package/dist/commands/fetch.js +311 -0
- package/dist/commands/import.d.ts +38 -0
- package/dist/commands/import.js +313 -0
- package/dist/commands/index.d.ts +35 -0
- package/dist/commands/index.js +306 -0
- package/dist/commands/inspect.d.ts +13 -0
- package/dist/commands/inspect.js +82 -0
- package/dist/commands/list.d.ts +25 -0
- package/dist/commands/list.js +143 -0
- package/dist/commands/prune.d.ts +24 -0
- package/dist/commands/prune.js +141 -0
- package/dist/commands/research/config/base.d.ts +23 -0
- package/dist/commands/research/config/base.js +46 -0
- package/dist/commands/research/config/get.d.ts +20 -0
- package/dist/commands/research/config/get.js +45 -0
- package/dist/commands/research/config/list.d.ts +17 -0
- package/dist/commands/research/config/list.js +42 -0
- package/dist/commands/research/config/set.d.ts +31 -0
- package/dist/commands/research/config/set.js +94 -0
- package/dist/commands/research/config/unset.d.ts +21 -0
- package/dist/commands/research/config/unset.js +59 -0
- package/dist/commands/research/import.d.ts +38 -0
- package/dist/commands/research/import.js +313 -0
- package/dist/commands/research/inspect.d.ts +13 -0
- package/dist/commands/research/inspect.js +86 -0
- package/dist/commands/research/list.d.ts +25 -0
- package/dist/commands/research/list.js +143 -0
- package/dist/commands/research/prune.d.ts +24 -0
- package/dist/commands/research/prune.js +141 -0
- package/dist/commands/research/search.d.ts +32 -0
- package/dist/commands/research/search.js +334 -0
- package/dist/commands/research/status.d.ts +17 -0
- package/dist/commands/research/status.js +108 -0
- package/dist/commands/research.d.ts +36 -0
- package/dist/commands/research.js +304 -0
- package/dist/commands/search.d.ts +32 -0
- package/dist/commands/search.js +334 -0
- package/dist/commands/status.d.ts +17 -0
- package/dist/commands/status.js +95 -0
- package/dist/commands.d.ts +24 -0
- package/dist/commands.js +24 -0
- package/dist/lib/config/index.d.ts +18 -0
- package/dist/lib/config/index.js +33 -0
- package/dist/lib/config/io.d.ts +8 -0
- package/dist/lib/config/io.js +75 -0
- package/dist/lib/config/resolve.d.ts +15 -0
- package/dist/lib/config/resolve.js +20 -0
- package/dist/lib/config/schema.d.ts +28 -0
- package/dist/lib/config/schema.js +32 -0
- package/dist/lib/research/artifact.d.ts +21 -0
- package/dist/lib/research/artifact.js +217 -0
- package/dist/lib/research/browser.d.ts +74 -0
- package/dist/lib/research/browser.js +359 -0
- package/dist/lib/research/cache-key.d.ts +5 -0
- package/dist/lib/research/cache-key.js +10 -0
- package/dist/lib/research/capture.d.ts +29 -0
- package/dist/lib/research/capture.js +181 -0
- package/dist/lib/research/compress.d.ts +5 -0
- package/dist/lib/research/compress.js +17 -0
- package/dist/lib/research/docs/__fixtures__/sphinx-searchindex.d.ts +1 -0
- package/dist/lib/research/docs/__fixtures__/sphinx-searchindex.js +2 -0
- package/dist/lib/research/docs/capabilities.d.ts +43 -0
- package/dist/lib/research/docs/capabilities.js +9 -0
- package/dist/lib/research/docs/clean-dom.d.ts +4 -0
- package/dist/lib/research/docs/clean-dom.js +41 -0
- package/dist/lib/research/docs/code-blocks.d.ts +5 -0
- package/dist/lib/research/docs/code-blocks.js +69 -0
- package/dist/lib/research/docs/detect.d.ts +6 -0
- package/dist/lib/research/docs/detect.js +239 -0
- package/dist/lib/research/docs/machine-readable.d.ts +21 -0
- package/dist/lib/research/docs/machine-readable.js +73 -0
- package/dist/lib/research/docs/markdown-source.d.ts +17 -0
- package/dist/lib/research/docs/markdown-source.js +194 -0
- package/dist/lib/research/docs/quality-gates.d.ts +9 -0
- package/dist/lib/research/docs/quality-gates.js +65 -0
- package/dist/lib/research/docs/remote-search-runner.d.ts +21 -0
- package/dist/lib/research/docs/remote-search-runner.js +54 -0
- package/dist/lib/research/docs/remote-search.d.ts +14 -0
- package/dist/lib/research/docs/remote-search.js +101 -0
- package/dist/lib/research/docs/rsc.d.ts +10 -0
- package/dist/lib/research/docs/rsc.js +30 -0
- package/dist/lib/research/docs/search-index.d.ts +13 -0
- package/dist/lib/research/docs/search-index.js +176 -0
- package/dist/lib/research/docs/section-artifacts.d.ts +12 -0
- package/dist/lib/research/docs/section-artifacts.js +75 -0
- package/dist/lib/research/docs/sections.d.ts +12 -0
- package/dist/lib/research/docs/sections.js +67 -0
- package/dist/lib/research/docs/source-map.d.ts +19 -0
- package/dist/lib/research/docs/source-map.js +87 -0
- package/dist/lib/research/docs/validate.d.ts +15 -0
- package/dist/lib/research/docs/validate.js +63 -0
- package/dist/lib/research/extract.d.ts +11 -0
- package/dist/lib/research/extract.js +83 -0
- package/dist/lib/research/fetcher.d.ts +29 -0
- package/dist/lib/research/fetcher.js +192 -0
- package/dist/lib/research/freshness.d.ts +21 -0
- package/dist/lib/research/freshness.js +82 -0
- package/dist/lib/research/markdown.d.ts +6 -0
- package/dist/lib/research/markdown.js +66 -0
- package/dist/lib/research/regression.d.ts +23 -0
- package/dist/lib/research/regression.js +93 -0
- package/dist/lib/research/resolve-target.d.ts +18 -0
- package/dist/lib/research/resolve-target.js +20 -0
- package/dist/lib/research/revalidate.d.ts +32 -0
- package/dist/lib/research/revalidate.js +152 -0
- package/dist/lib/research/schema.d.ts +47 -0
- package/dist/lib/research/schema.js +1 -0
- package/dist/lib/research/secret-scan.d.ts +5 -0
- package/dist/lib/research/secret-scan.js +37 -0
- package/dist/lib/research/secure-write.d.ts +16 -0
- package/dist/lib/research/secure-write.js +17 -0
- package/dist/lib/research/storage.d.ts +52 -0
- package/dist/lib/research/storage.js +182 -0
- package/dist/lib/research/store-roots.d.ts +29 -0
- package/dist/lib/research/store-roots.js +28 -0
- package/dist/lib/research/token-estimate.d.ts +5 -0
- package/dist/lib/research/token-estimate.js +7 -0
- package/dist/lib/research/url.d.ts +24 -0
- package/dist/lib/research/url.js +121 -0
- package/dist/sites/index.d.ts +4 -0
- package/dist/sites/index.js +28 -0
- package/dist/sites/salesforce/browser-search.d.ts +7 -0
- package/dist/sites/salesforce/browser-search.js +82 -0
- package/dist/sites/salesforce/coveo.d.ts +23 -0
- package/dist/sites/salesforce/coveo.js +161 -0
- package/dist/sites/salesforce/fetch-page.d.ts +6 -0
- package/dist/sites/salesforce/fetch-page.js +42 -0
- package/dist/sites/salesforce/index.d.ts +2 -0
- package/dist/sites/salesforce/index.js +16 -0
- package/dist/sites/salesforce/token.d.ts +26 -0
- package/dist/sites/salesforce/token.js +154 -0
- package/dist/sites/salesforce-developer/fetch-page.d.ts +3 -0
- package/dist/sites/salesforce-developer/fetch-page.js +18 -0
- package/dist/sites/salesforce-developer/index.d.ts +2 -0
- package/dist/sites/salesforce-developer/index.js +12 -0
- package/dist/sites/salesforce-doc-fetch.d.ts +14 -0
- package/dist/sites/salesforce-doc-fetch.js +321 -0
- package/dist/sites/types.d.ts +27 -0
- package/dist/sites/types.js +1 -0
- package/oclif.manifest.json +845 -0
- package/package.json +78 -0
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { dropEmptyLinks, htmlToMarkdown } from '../markdown.js';
|
|
2
|
+
// Parses a leading YAML frontmatter block (--- ... ---). Only flat scalar keys are read; nested
|
|
3
|
+
// structures are ignored (we only need title/description). The block is stripped from the body.
|
|
4
|
+
export function parseFrontmatter(md) {
|
|
5
|
+
const normalized = md.replace(/^/, '');
|
|
6
|
+
if (!normalized.startsWith('---'))
|
|
7
|
+
return { frontmatter: {}, body: normalized };
|
|
8
|
+
const end = normalized.indexOf('\n---', 3);
|
|
9
|
+
if (end === -1)
|
|
10
|
+
return { frontmatter: {}, body: normalized };
|
|
11
|
+
const block = normalized.slice(3, end).trim();
|
|
12
|
+
const rest = normalized.slice(end + 4).replace(/^[\r\n]+/, '');
|
|
13
|
+
const frontmatter = {};
|
|
14
|
+
for (const line of block.split('\n')) {
|
|
15
|
+
const colon = line.indexOf(':');
|
|
16
|
+
if (colon === -1 || line.startsWith(' ') || line.startsWith('-'))
|
|
17
|
+
continue;
|
|
18
|
+
const key = line.slice(0, colon).trim();
|
|
19
|
+
const value = line
|
|
20
|
+
.slice(colon + 1)
|
|
21
|
+
.trim()
|
|
22
|
+
.replace(/^["']|["']$/g, '');
|
|
23
|
+
if (key)
|
|
24
|
+
frontmatter[key] = value;
|
|
25
|
+
}
|
|
26
|
+
return { frontmatter, body: rest };
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Removes dangerous embedded HTML from untrusted Markdown: <script>/<style> blocks, inline event
|
|
30
|
+
* handler attributes, and javascript: URLs. Leaves ordinary Markdown and benign inline HTML intact.
|
|
31
|
+
*/
|
|
32
|
+
export function sanitizeSourceMarkdown(md) {
|
|
33
|
+
return (md
|
|
34
|
+
.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, '')
|
|
35
|
+
.replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, '')
|
|
36
|
+
.replace(/<iframe\b[^>]*>[\s\S]*?<\/iframe>/gi, '')
|
|
37
|
+
// Interactive / icon / media widgets authored into docs source (e.g. VitePress's pronunciation
|
|
38
|
+
// <audio>+<button><svg> control) are non-content chrome that just wastes agent tokens.
|
|
39
|
+
.replace(/<svg\b[^>]*>[\s\S]*?<\/svg>/gi, '')
|
|
40
|
+
.replace(/<audio\b[^>]*>[\s\S]*?<\/audio>/gi, '')
|
|
41
|
+
.replace(/<video\b[^>]*>[\s\S]*?<\/video>/gi, '')
|
|
42
|
+
.replace(/<button\b[^>]*>[\s\S]*?<\/button>/gi, '')
|
|
43
|
+
.replace(/<use\b[^>]*\/?>/gi, '')
|
|
44
|
+
.replace(/\son[a-z]+\s*=\s*"[^"]*"/gi, '')
|
|
45
|
+
.replace(/\son[a-z]+\s*=\s*'[^']*'/gi, '')
|
|
46
|
+
.replace(/javascript:/gi, ''));
|
|
47
|
+
}
|
|
48
|
+
// MDN raw source (mdn/content) is KumaScript: macros like {{jsxref("Promise.all()")}} and
|
|
49
|
+
// {{Compat}} are placeholders that don't render to content. Reduce cross-reference macros to their
|
|
50
|
+
// display text and drop section/embed/sidebar macros, so MDN source is as clean as its rendered
|
|
51
|
+
// page without the macro noise. Gated to MDN source URLs (see extractFromSource) — other ecosystems
|
|
52
|
+
// legitimately use {{ }} (Vue/Angular interpolation), so this must never run on them.
|
|
53
|
+
const KUMASCRIPT_DROP = new Set([
|
|
54
|
+
'compat',
|
|
55
|
+
'specifications',
|
|
56
|
+
'embedinteractiveexample',
|
|
57
|
+
'interactiveexample',
|
|
58
|
+
'embedlivesample',
|
|
59
|
+
'livesample',
|
|
60
|
+
'previousnext',
|
|
61
|
+
'previousmenunext',
|
|
62
|
+
'page',
|
|
63
|
+
'jssidebar',
|
|
64
|
+
'defaultapisidebar',
|
|
65
|
+
'htmlsidebar',
|
|
66
|
+
'cssref',
|
|
67
|
+
'jsref',
|
|
68
|
+
'glossarysidebar',
|
|
69
|
+
'learnsidebar',
|
|
70
|
+
'apiref',
|
|
71
|
+
'seecompattable',
|
|
72
|
+
'quicklinkswithsubpages',
|
|
73
|
+
]);
|
|
74
|
+
const KUMASCRIPT_BADGES = {
|
|
75
|
+
optional_inline: 'optional',
|
|
76
|
+
deprecated_inline: 'deprecated',
|
|
77
|
+
experimental_inline: 'experimental',
|
|
78
|
+
'non-standard_inline': 'non-standard',
|
|
79
|
+
readonlyinline: 'read-only',
|
|
80
|
+
};
|
|
81
|
+
function replaceMacro(_match, inner) {
|
|
82
|
+
const name = (inner.match(/^\s*([\w-]+)/)?.[1] ?? '').toLowerCase();
|
|
83
|
+
if (KUMASCRIPT_DROP.has(name))
|
|
84
|
+
return '';
|
|
85
|
+
if (KUMASCRIPT_BADGES[name])
|
|
86
|
+
return `(${KUMASCRIPT_BADGES[name]})`;
|
|
87
|
+
// Cross-reference macros (jsxref/domxref/cssxref/htmlelement…): use the last quoted argument,
|
|
88
|
+
// which is the human display text (e.g. {{jsxref("Promise/then", "then()")}} -> then()).
|
|
89
|
+
const quoted = [...inner.matchAll(/"([^"]*)"|'([^']*)'/g)].map((m) => m[1] ?? m[2]);
|
|
90
|
+
if (quoted.length)
|
|
91
|
+
return quoted[quoted.length - 1];
|
|
92
|
+
return '';
|
|
93
|
+
}
|
|
94
|
+
// MDN live-sample sources flagged `hidden` (e.g. <pre class="brush: html hidden">) are plumbing for
|
|
95
|
+
// the rendered {{EmbedLiveSample}} widget (which we already drop), not reader-facing content. Remove
|
|
96
|
+
// them so they don't survive — e.g. flattened into a converted table cell — as raw HTML.
|
|
97
|
+
function stripMdnHiddenSamples(md) {
|
|
98
|
+
return md.replace(/<pre\b[^>]*\bhidden\b[^>]*>[\s\S]*?<\/pre>/gi, '');
|
|
99
|
+
}
|
|
100
|
+
function cleanKumaScript(md) {
|
|
101
|
+
// Never touch fenced code blocks.
|
|
102
|
+
return md
|
|
103
|
+
.split(/(```[\s\S]*?```)/)
|
|
104
|
+
.map((segment, i) => (i % 2 === 1 ? segment : segment.replace(/\{\{(.+?)\}\}/g, replaceMacro)))
|
|
105
|
+
.join('');
|
|
106
|
+
}
|
|
107
|
+
// MDN (and occasionally other docs) author tables as raw HTML inside their Markdown source, which
|
|
108
|
+
// otherwise leaks verbatim into the artifact and wastes agent tokens. Turndown already emits clean
|
|
109
|
+
// GFM tables, so convert any literal <table> region to Markdown. Code fences are skipped so an HTML
|
|
110
|
+
// table shown as a *code example* (```html …<table>…) is preserved, not rewritten.
|
|
111
|
+
function convertHtmlTables(md) {
|
|
112
|
+
return md
|
|
113
|
+
.split(/(```[\s\S]*?```)/)
|
|
114
|
+
.map((segment, i) => i % 2 === 1
|
|
115
|
+
? segment
|
|
116
|
+
: // ponytail: non-greedy per-table match. A <table> nested in a cell would stop the match at
|
|
117
|
+
// the inner </table>, leaving the outer wrapper's closing tags as inert residue (no attrs,
|
|
118
|
+
// so it can't trip the leak gate). Doc sources use flat tables; upgrade to a DOM walk over
|
|
119
|
+
// the whole segment via htmlToMarkdown if a nested-table source ever appears.
|
|
120
|
+
segment.replace(/<table\b[\s\S]*?<\/table>/gi, (table) => `\n\n${htmlToMarkdown(table).trim()}\n\n`))
|
|
121
|
+
.join('');
|
|
122
|
+
}
|
|
123
|
+
const HEADING = /^(#{1,6})\s/;
|
|
124
|
+
function headingLevel(line) {
|
|
125
|
+
return line?.match(HEADING)?.[1]?.length ?? 0;
|
|
126
|
+
}
|
|
127
|
+
// Drops a heading whose section body is empty after macro/widget removal — e.g. MDN's
|
|
128
|
+
// `## Specifications` / `## Browser compatibility`, generated from external compat data that only
|
|
129
|
+
// exists in the rendered page, leaving a bare heading once `{{Specifications}}`/`{{Compat}}` are
|
|
130
|
+
// stripped. A heading is empty when only blank lines separate it from the next heading at the same
|
|
131
|
+
// or higher level (or end of document); parents that still own subsections are kept. Iterates to a
|
|
132
|
+
// fixed point so a parent left empty by dropping its only (empty) child is removed too.
|
|
133
|
+
function dropEmptySections(md) {
|
|
134
|
+
let prev;
|
|
135
|
+
let current = md;
|
|
136
|
+
do {
|
|
137
|
+
prev = current;
|
|
138
|
+
current = dropEmptySectionsOnce(prev);
|
|
139
|
+
} while (current !== prev);
|
|
140
|
+
return current;
|
|
141
|
+
}
|
|
142
|
+
function dropEmptySectionsOnce(md) {
|
|
143
|
+
const lines = md.split('\n');
|
|
144
|
+
const keep = lines.map(() => true);
|
|
145
|
+
for (let i = 0; i < lines.length; i++) {
|
|
146
|
+
const level = headingLevel(lines[i]);
|
|
147
|
+
if (!level)
|
|
148
|
+
continue;
|
|
149
|
+
let next = i + 1;
|
|
150
|
+
while (next < lines.length && lines[next].trim() === '')
|
|
151
|
+
next++;
|
|
152
|
+
const nextLevel = headingLevel(lines[next]);
|
|
153
|
+
const sectionIsEmpty = next >= lines.length || (nextLevel > 0 && nextLevel <= level);
|
|
154
|
+
if (sectionIsEmpty)
|
|
155
|
+
for (let k = i; k < next; k++)
|
|
156
|
+
keep[k] = false;
|
|
157
|
+
}
|
|
158
|
+
return lines.filter((_, i) => keep[i]).join('\n');
|
|
159
|
+
}
|
|
160
|
+
function titleFrom(frontmatter, body) {
|
|
161
|
+
if (frontmatter.title)
|
|
162
|
+
return frontmatter.title;
|
|
163
|
+
const h1 = body.match(/^#\s+(.+)$/m);
|
|
164
|
+
return h1 ? h1[1].trim() : 'Untitled';
|
|
165
|
+
}
|
|
166
|
+
function confidenceFor(length) {
|
|
167
|
+
if (length < 500)
|
|
168
|
+
return 'low';
|
|
169
|
+
if (length < 2000)
|
|
170
|
+
return 'medium';
|
|
171
|
+
return 'high';
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Builds an ExtractionResult from public Markdown/MDX source. Frontmatter is parsed for the title
|
|
175
|
+
* and the body is sanitized. The detailed Markdown is the source itself, preserving code fences,
|
|
176
|
+
* tabs, and admonitions better than HTML conversion.
|
|
177
|
+
*/
|
|
178
|
+
export function extractFromSource(md, sourceUrl) {
|
|
179
|
+
const { frontmatter, body } = parseFrontmatter(md);
|
|
180
|
+
// MDN content is KumaScript; strip its macros. Gated by host so {{ }} in other ecosystems' source
|
|
181
|
+
// (Vue/Angular interpolation) is left untouched.
|
|
182
|
+
const deMacroed = /mdn\/content/.test(sourceUrl)
|
|
183
|
+
? cleanKumaScript(stripMdnHiddenSamples(body))
|
|
184
|
+
: body;
|
|
185
|
+
const tablesAsMarkdown = convertHtmlTables(sanitizeSourceMarkdown(deMacroed));
|
|
186
|
+
const cleaned = dropEmptySections(dropEmptyLinks(tablesAsMarkdown)).trim();
|
|
187
|
+
const notes = [`captured from public Markdown/MDX source: ${sourceUrl}`];
|
|
188
|
+
return {
|
|
189
|
+
title: titleFrom(frontmatter, cleaned),
|
|
190
|
+
detailedMarkdown: cleaned,
|
|
191
|
+
confidence: confidenceFor(cleaned.length),
|
|
192
|
+
qualityNotes: notes,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export interface QualitySignals {
|
|
2
|
+
codes: string[];
|
|
3
|
+
isIndexHub: boolean;
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Analyzes extracted Markdown and returns stable quality codes plus an index/hub flag. `markdown`
|
|
7
|
+
* is the detailed representation; `title` helps the error-page heuristic on short pages.
|
|
8
|
+
*/
|
|
9
|
+
export declare function analyzeMarkdownQuality(markdown: string, title?: string): QualitySignals;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { estimateTokens } from '../token-estimate.js';
|
|
2
|
+
import { looksLikeErrorPage } from './validate.js';
|
|
3
|
+
const OVERSIZED_TOKEN_THRESHOLD = 8000;
|
|
4
|
+
const IMAGE_RE = /!\[[^\]]*\]\([^)]*\)/g;
|
|
5
|
+
const LINK_RE = /\[[^\]]*\]\([^)]*\)/g;
|
|
6
|
+
const HEADING_RE = /^#{1,6}\s+/;
|
|
7
|
+
// A lowercase letter, digit, or close-paren/quote butted directly against a shell/import keyword —
|
|
8
|
+
// the signature of two commands collapsed onto one line (e.g. "my-projectcd my-project").
|
|
9
|
+
const COLLAPSED_CODE_RE = /[a-z0-9)"'](?:cd|npm|yarn|pnpm|npx|import|export|git)\s/;
|
|
10
|
+
function countMatches(text, re) {
|
|
11
|
+
return (text.match(re) || []).length;
|
|
12
|
+
}
|
|
13
|
+
function proseLineCount(markdown) {
|
|
14
|
+
return markdown.split('\n').filter((line) => {
|
|
15
|
+
const t = line.trim();
|
|
16
|
+
if (!t || HEADING_RE.test(t))
|
|
17
|
+
return false;
|
|
18
|
+
// A line that is essentially just a link/image is navigation, not prose.
|
|
19
|
+
const withoutLinks = t
|
|
20
|
+
.replace(IMAGE_RE, '')
|
|
21
|
+
.replace(LINK_RE, '')
|
|
22
|
+
.replace(/[-*>!\s]/g, '');
|
|
23
|
+
return withoutLinks.length > 0;
|
|
24
|
+
}).length;
|
|
25
|
+
}
|
|
26
|
+
// Scans fenced code blocks for a line that looks like two commands were merged (T-17 warning).
|
|
27
|
+
function hasCollapsedCode(markdown) {
|
|
28
|
+
const lines = markdown.split('\n');
|
|
29
|
+
let inFence = false;
|
|
30
|
+
for (const line of lines) {
|
|
31
|
+
if (/^\s*```/.test(line)) {
|
|
32
|
+
inFence = !inFence;
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
if (inFence && COLLAPSED_CODE_RE.test(line))
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Analyzes extracted Markdown and returns stable quality codes plus an index/hub flag. `markdown`
|
|
42
|
+
* is the detailed representation; `title` helps the error-page heuristic on short pages.
|
|
43
|
+
*/
|
|
44
|
+
export function analyzeMarkdownQuality(markdown, title = '') {
|
|
45
|
+
const codes = [];
|
|
46
|
+
const images = countMatches(markdown, IMAGE_RE);
|
|
47
|
+
const links = countMatches(markdown, LINK_RE);
|
|
48
|
+
const headings = markdown.split('\n').filter((l) => HEADING_RE.test(l.trim())).length;
|
|
49
|
+
const prose = proseLineCount(markdown);
|
|
50
|
+
if (/!\[[^\]]*\]\(\s*data:/.test(markdown))
|
|
51
|
+
codes.push('quality:base64-image');
|
|
52
|
+
if (images > 0 && images > headings + prose)
|
|
53
|
+
codes.push('quality:high-image-ratio');
|
|
54
|
+
if (hasCollapsedCode(markdown))
|
|
55
|
+
codes.push('quality:collapsed-code');
|
|
56
|
+
if (estimateTokens(markdown) > OVERSIZED_TOKEN_THRESHOLD)
|
|
57
|
+
codes.push('quality:oversized');
|
|
58
|
+
if (looksLikeErrorPage(`${title}\n${markdown}`))
|
|
59
|
+
codes.push('quality:error-page');
|
|
60
|
+
// Hub page: many outgoing links and little prose to wrap them.
|
|
61
|
+
const isIndexHub = links >= 10 && links > prose * 2;
|
|
62
|
+
if (isIndexHub)
|
|
63
|
+
codes.push('quality:index-hub');
|
|
64
|
+
return { codes, isIndexHub };
|
|
65
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { DocsSearchResult } from './search-index.js';
|
|
2
|
+
export interface RemoteSearchDeps {
|
|
3
|
+
fetchStatic: (url: string) => Promise<{
|
|
4
|
+
content: string;
|
|
5
|
+
finalUrl: string;
|
|
6
|
+
}>;
|
|
7
|
+
fetchText: (url: string) => Promise<{
|
|
8
|
+
content: string;
|
|
9
|
+
status: number;
|
|
10
|
+
}>;
|
|
11
|
+
postJson: (url: string, body: unknown, headers: Record<string, string>) => Promise<string>;
|
|
12
|
+
}
|
|
13
|
+
export interface RemoteSearchOutcome {
|
|
14
|
+
provider: string;
|
|
15
|
+
results: DocsSearchResult[];
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Runs remote docs search for a documentation site. Detects the provider from the page, queries the
|
|
19
|
+
* matching connector, and returns normalized results. Throws when no verified connector applies.
|
|
20
|
+
*/
|
|
21
|
+
export declare function runRemoteDocsSearch(docsUrl: string, query: string, deps: RemoteSearchDeps): Promise<RemoteSearchOutcome>;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { detectDocsEngine } from './detect.js';
|
|
2
|
+
import { searchMkDocsIndex, searchJekyllIndex, searchSphinxIndex } from './search-index.js';
|
|
3
|
+
import { extractAlgoliaConfig, algoliaQueryUrl, parseAlgoliaResponse } from './remote-search.js';
|
|
4
|
+
async function getText(url, deps) {
|
|
5
|
+
const res = await deps.fetchText(url);
|
|
6
|
+
if (res.status === 304 || !res.content)
|
|
7
|
+
throw new Error(`empty search index at ${url}`);
|
|
8
|
+
return res.content;
|
|
9
|
+
}
|
|
10
|
+
// Conventional static index path + parser per framework (verified by T-25 fixtures).
|
|
11
|
+
async function staticIndexSearch(framework, origin, query, deps) {
|
|
12
|
+
if (framework === 'mkdocs' || framework === 'material-mkdocs') {
|
|
13
|
+
const indexUrl = `${origin}/search/search_index.json`;
|
|
14
|
+
return {
|
|
15
|
+
provider: 'mkdocs-local',
|
|
16
|
+
results: searchMkDocsIndex(await getText(indexUrl, deps), indexUrl, query),
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
if (framework === 'sphinx') {
|
|
20
|
+
const indexUrl = `${origin}/searchindex.js`;
|
|
21
|
+
return {
|
|
22
|
+
provider: 'sphinx-searchindex',
|
|
23
|
+
results: searchSphinxIndex(await getText(indexUrl, deps), indexUrl, query),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
if (framework === 'just-the-docs') {
|
|
27
|
+
const indexUrl = `${origin}/assets/js/search-data.json`;
|
|
28
|
+
return {
|
|
29
|
+
provider: 'jekyll-json',
|
|
30
|
+
results: searchJekyllIndex(await getText(indexUrl, deps), indexUrl, query),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Runs remote docs search for a documentation site. Detects the provider from the page, queries the
|
|
37
|
+
* matching connector, and returns normalized results. Throws when no verified connector applies.
|
|
38
|
+
*/
|
|
39
|
+
export async function runRemoteDocsSearch(docsUrl, query, deps) {
|
|
40
|
+
const page = await deps.fetchStatic(docsUrl);
|
|
41
|
+
const origin = new URL(page.finalUrl).origin;
|
|
42
|
+
const algolia = extractAlgoliaConfig(page.content);
|
|
43
|
+
if (algolia) {
|
|
44
|
+
const url = algoliaQueryUrl(algolia);
|
|
45
|
+
const body = await deps.postJson(url, { params: `query=${encodeURIComponent(query)}` }, { 'X-Algolia-API-Key': algolia.apiKey, 'X-Algolia-Application-Id': algolia.appId });
|
|
46
|
+
return { provider: 'algolia-docsearch', results: parseAlgoliaResponse(body, url) };
|
|
47
|
+
}
|
|
48
|
+
const caps = detectDocsEngine(page.content, page.finalUrl);
|
|
49
|
+
const staticResult = await staticIndexSearch(caps.framework, origin, query, deps);
|
|
50
|
+
if (staticResult)
|
|
51
|
+
return staticResult;
|
|
52
|
+
throw new Error(`No verified remote search connector for this site (detected: ${caps.framework ?? caps.docsEngine ?? 'unknown'}). ` +
|
|
53
|
+
'Supported: Algolia DocSearch, MkDocs/Material, Sphinx, Just the Docs.');
|
|
54
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { DocsSearchResult } from './search-index.js';
|
|
2
|
+
export interface AlgoliaConfig {
|
|
3
|
+
appId: string;
|
|
4
|
+
apiKey: string;
|
|
5
|
+
indexName: string;
|
|
6
|
+
}
|
|
7
|
+
export declare function extractAlgoliaConfig(html: string): AlgoliaConfig | null;
|
|
8
|
+
export declare function algoliaQueryUrl(config: AlgoliaConfig): string;
|
|
9
|
+
export declare function parseAlgoliaResponse(body: string, indexUrl: string): DocsSearchResult[];
|
|
10
|
+
/**
|
|
11
|
+
* Parses a VitePress local search index (MiniSearch documents) and ranks docs by query term hits.
|
|
12
|
+
* `base` is the docs site origin/base used to resolve the relative `id` routes.
|
|
13
|
+
*/
|
|
14
|
+
export declare function searchVitePressLocalIndex(body: string, indexUrl: string, base: string, query: string): DocsSearchResult[];
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// Algolia identifiers extracted from untrusted page HTML are interpolated into the request URL, so
|
|
2
|
+
// they MUST be format-validated to prevent SSRF (an unvalidated appId like "evil.com/x" would point
|
|
3
|
+
// the request at an attacker host). App IDs are uppercase alphanumeric; public search keys are hex;
|
|
4
|
+
// the index name must contain no slash (it is path-encoded but we still reject separators).
|
|
5
|
+
const ALGOLIA_APP_ID_RE = /^[A-Z0-9]{6,24}$/;
|
|
6
|
+
const ALGOLIA_API_KEY_RE = /^[a-f0-9]{16,64}$/;
|
|
7
|
+
const ALGOLIA_INDEX_RE = /^[A-Za-z0-9._~-]{1,128}$/;
|
|
8
|
+
// Pulls a DocSearch config out of embedded site data. Returns null unless all three fields are
|
|
9
|
+
// present AND well-formed — a partial or malformed signal is not enough to query.
|
|
10
|
+
export function extractAlgoliaConfig(html) {
|
|
11
|
+
const text = html.replace(/\\"/g, '"');
|
|
12
|
+
const appId = text.match(/"appId"\s*:\s*"([^"]+)"/)?.[1];
|
|
13
|
+
const apiKey = text.match(/"apiKey"\s*:\s*"([^"]+)"/)?.[1];
|
|
14
|
+
const indexName = text.match(/"indexName"\s*:\s*"([^"]+)"/)?.[1];
|
|
15
|
+
if (!appId || !apiKey || !indexName)
|
|
16
|
+
return null;
|
|
17
|
+
if (!ALGOLIA_APP_ID_RE.test(appId) ||
|
|
18
|
+
!ALGOLIA_API_KEY_RE.test(apiKey) ||
|
|
19
|
+
!ALGOLIA_INDEX_RE.test(indexName)) {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
return { appId, apiKey, indexName };
|
|
23
|
+
}
|
|
24
|
+
export function algoliaQueryUrl(config) {
|
|
25
|
+
return `https://${config.appId}-dsn.algolia.net/1/indexes/${encodeURIComponent(config.indexName)}/query`;
|
|
26
|
+
}
|
|
27
|
+
// DocSearch records nest the page hierarchy under `hierarchy` and the page URL under `url`.
|
|
28
|
+
function algoliaTitle(hit) {
|
|
29
|
+
const h = hit?.hierarchy ?? {};
|
|
30
|
+
// Prefer the deepest (most specific) hierarchy level present.
|
|
31
|
+
for (const level of ['lvl6', 'lvl5', 'lvl4', 'lvl3', 'lvl2', 'lvl1', 'lvl0']) {
|
|
32
|
+
if (h[level])
|
|
33
|
+
return String(h[level]);
|
|
34
|
+
}
|
|
35
|
+
return hit?.url ?? 'Untitled';
|
|
36
|
+
}
|
|
37
|
+
export function parseAlgoliaResponse(body, indexUrl) {
|
|
38
|
+
const data = JSON.parse(body);
|
|
39
|
+
if (!Array.isArray(data.hits))
|
|
40
|
+
throw new Error('Algolia response missing "hits"');
|
|
41
|
+
return data.hits
|
|
42
|
+
.filter((hit) => typeof hit?.url === 'string')
|
|
43
|
+
.map((hit, i) => ({
|
|
44
|
+
title: algoliaTitle(hit),
|
|
45
|
+
url: hit.url,
|
|
46
|
+
snippet: hit?.content ?? undefined,
|
|
47
|
+
provider: 'algolia-docsearch',
|
|
48
|
+
score: data.hits.length - i, // preserve Algolia's relevance order
|
|
49
|
+
indexUrl,
|
|
50
|
+
}));
|
|
51
|
+
}
|
|
52
|
+
function vitepressUrl(base, id) {
|
|
53
|
+
try {
|
|
54
|
+
return new URL(id, base).toString();
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
// Ranks one VitePress doc by query term hits: a title match weighs 10×, body occurrences 1× each
|
|
61
|
+
// (capped at 5). Kept separate so the search loop stays a flat filter → score → format.
|
|
62
|
+
function scoreVitePressDoc(doc, terms) {
|
|
63
|
+
const haystack = `${doc.title ?? ''} ${(doc.titles ?? []).join(' ')} ${doc.text ?? ''}`.toLowerCase();
|
|
64
|
+
let score = 0;
|
|
65
|
+
for (const term of terms) {
|
|
66
|
+
if ((doc.title ?? '').toLowerCase().includes(term))
|
|
67
|
+
score += 10;
|
|
68
|
+
score += Math.min(haystack.split(term).length - 1, 5);
|
|
69
|
+
}
|
|
70
|
+
return score;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Parses a VitePress local search index (MiniSearch documents) and ranks docs by query term hits.
|
|
74
|
+
* `base` is the docs site origin/base used to resolve the relative `id` routes.
|
|
75
|
+
*/
|
|
76
|
+
export function searchVitePressLocalIndex(body, indexUrl, base, query) {
|
|
77
|
+
const docs = JSON.parse(body);
|
|
78
|
+
if (!Array.isArray(docs))
|
|
79
|
+
throw new Error('VitePress local index is not an array');
|
|
80
|
+
const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
|
|
81
|
+
const results = [];
|
|
82
|
+
for (const doc of docs) {
|
|
83
|
+
if (!doc.id)
|
|
84
|
+
continue;
|
|
85
|
+
const score = scoreVitePressDoc(doc, terms);
|
|
86
|
+
if (score <= 0)
|
|
87
|
+
continue;
|
|
88
|
+
const url = vitepressUrl(base, doc.id);
|
|
89
|
+
if (!url)
|
|
90
|
+
continue;
|
|
91
|
+
results.push({
|
|
92
|
+
title: doc.title ?? doc.id,
|
|
93
|
+
url,
|
|
94
|
+
snippet: (doc.text ?? '').slice(0, 150),
|
|
95
|
+
provider: 'vitepress-local',
|
|
96
|
+
score,
|
|
97
|
+
indexUrl,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
return results.sort((a, b) => b.score - a.score);
|
|
101
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { PageMapEntry } from './capabilities.js';
|
|
2
|
+
export interface NextRscData {
|
|
3
|
+
pageMap: PageMapEntry[];
|
|
4
|
+
sourcePathHints: string[];
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Scans Next/RSC HTML for a page map (route + title, with optional source path) and any
|
|
8
|
+
* `filePath` source hints. Returns empty arrays when no payload is present.
|
|
9
|
+
*/
|
|
10
|
+
export declare function extractNextRscData(html: string): NextRscData;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Flight payloads embed JSON as a JS string literal, so quotes/backslashes are escaped. Undo the
|
|
2
|
+
// two escapes we care about to expose the inner JSON to scanning. Still inert text.
|
|
3
|
+
function unescapeFlight(html) {
|
|
4
|
+
return html.replace(/\\"/g, '"').replace(/\\\\/g, '\\');
|
|
5
|
+
}
|
|
6
|
+
function dedupe(items) {
|
|
7
|
+
return [...new Set(items.filter(Boolean))];
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Scans Next/RSC HTML for a page map (route + title, with optional source path) and any
|
|
11
|
+
* `filePath` source hints. Returns empty arrays when no payload is present.
|
|
12
|
+
*/
|
|
13
|
+
export function extractNextRscData(html) {
|
|
14
|
+
const text = unescapeFlight(html);
|
|
15
|
+
const sourcePathHints = dedupe([...text.matchAll(/"filePath"\s*:\s*"([^"]+)"/g)].map((m) => m[1]));
|
|
16
|
+
const pageMap = [];
|
|
17
|
+
const seen = new Set();
|
|
18
|
+
for (const match of text.matchAll(/"route"\s*:\s*"([^"]+)"/g)) {
|
|
19
|
+
const route = match[1];
|
|
20
|
+
if (seen.has(route))
|
|
21
|
+
continue;
|
|
22
|
+
seen.add(route);
|
|
23
|
+
// Look just past the route for a title / filePath belonging to the same object.
|
|
24
|
+
const window = text.slice(match.index, match.index + 400);
|
|
25
|
+
const title = window.match(/"title"\s*:\s*"([^"]+)"/)?.[1];
|
|
26
|
+
const sourcePath = window.match(/"filePath"\s*:\s*"([^"]+)"/)?.[1];
|
|
27
|
+
pageMap.push({ title: title ?? route, url: route, sourcePath });
|
|
28
|
+
}
|
|
29
|
+
return { pageMap, sourcePathHints };
|
|
30
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { SearchProvider } from './capabilities.js';
|
|
2
|
+
export interface DocsSearchResult {
|
|
3
|
+
title: string;
|
|
4
|
+
url: string;
|
|
5
|
+
anchor?: string;
|
|
6
|
+
snippet?: string;
|
|
7
|
+
provider: SearchProvider;
|
|
8
|
+
score: number;
|
|
9
|
+
indexUrl: string;
|
|
10
|
+
}
|
|
11
|
+
export declare function searchMkDocsIndex(body: string, indexUrl: string, query: string): DocsSearchResult[];
|
|
12
|
+
export declare function searchJekyllIndex(body: string, indexUrl: string, query: string): DocsSearchResult[];
|
|
13
|
+
export declare function searchSphinxIndex(js: string, indexUrl: string, query: string): DocsSearchResult[];
|