contextqmd 0.1.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 +87 -0
- package/dist/index.js +199 -0
- package/dist/lib/config.js +22 -0
- package/dist/lib/doc-indexer.js +270 -0
- package/dist/lib/local-cache.js +183 -0
- package/dist/lib/registry-client.js +100 -0
- package/dist/lib/service.js +776 -0
- package/dist/lib/types.js +1 -0
- package/package.json +30 -0
|
@@ -0,0 +1,776 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { dirname, join, posix } from "node:path";
|
|
5
|
+
import { normalizeDocPath } from "./local-cache.js";
|
|
6
|
+
const DOC_INDEX_SCHEMA_VERSION = 2;
|
|
7
|
+
const DEFAULT_EXCERPT_MAX_LINES = 60;
|
|
8
|
+
const DEFAULT_EXPAND_BEFORE = 30;
|
|
9
|
+
const DEFAULT_EXPAND_AFTER = 60;
|
|
10
|
+
function textResult(text) {
|
|
11
|
+
return { text };
|
|
12
|
+
}
|
|
13
|
+
function structuredTextResult(text, structuredContent) {
|
|
14
|
+
return { text, structuredContent };
|
|
15
|
+
}
|
|
16
|
+
function errorResult(text, code, details = {}) {
|
|
17
|
+
return {
|
|
18
|
+
text,
|
|
19
|
+
isError: true,
|
|
20
|
+
structuredContent: {
|
|
21
|
+
error: {
|
|
22
|
+
code,
|
|
23
|
+
...details,
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
function parseLibrary(library) {
|
|
29
|
+
const [namespace, name] = library.split("/");
|
|
30
|
+
return namespace && name ? { namespace, name } : null;
|
|
31
|
+
}
|
|
32
|
+
function librarySlug(namespace, name) {
|
|
33
|
+
return `${namespace}/${name}`;
|
|
34
|
+
}
|
|
35
|
+
function normalizeSha256(value) {
|
|
36
|
+
if (!value)
|
|
37
|
+
return null;
|
|
38
|
+
const normalized = value.trim().toLowerCase();
|
|
39
|
+
return normalized.startsWith("sha256:") ? normalized.slice("sha256:".length) : normalized;
|
|
40
|
+
}
|
|
41
|
+
function sha256Hex(input) {
|
|
42
|
+
return createHash("sha256").update(input).digest("hex");
|
|
43
|
+
}
|
|
44
|
+
function reportProgress(deps, message) {
|
|
45
|
+
deps.reportProgress?.(message);
|
|
46
|
+
}
|
|
47
|
+
function shouldReportPageProgress(completed, total) {
|
|
48
|
+
return total <= 10 || completed === 1 || completed === total || completed % 10 === 0;
|
|
49
|
+
}
|
|
50
|
+
function installedVersions(cache, namespace, name) {
|
|
51
|
+
return cache
|
|
52
|
+
.listInstalled()
|
|
53
|
+
.filter(lib => lib.namespace === namespace && lib.name === name)
|
|
54
|
+
.map(lib => lib.version)
|
|
55
|
+
.sort();
|
|
56
|
+
}
|
|
57
|
+
function isSupportedBundle(bundle) {
|
|
58
|
+
const format = bundle.format.trim().toLowerCase();
|
|
59
|
+
return (format === "tar.gz" ||
|
|
60
|
+
format === "tgz" ||
|
|
61
|
+
format === "tar+gzip" ||
|
|
62
|
+
format === "application/gzip" ||
|
|
63
|
+
format === "application/x-gzip" ||
|
|
64
|
+
bundle.url.endsWith(".tar.gz") ||
|
|
65
|
+
bundle.url.endsWith(".tgz"));
|
|
66
|
+
}
|
|
67
|
+
function selectBundle(manifest) {
|
|
68
|
+
for (const profile of ["full", "slim"]) {
|
|
69
|
+
const bundle = manifest.profiles[profile]?.bundle;
|
|
70
|
+
if (bundle && isSupportedBundle(bundle)) {
|
|
71
|
+
return { profile, bundle };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
function bundleArchiveFilename(namespace, name, version, profile, bundle) {
|
|
77
|
+
const suffix = bundle.url.endsWith(".tgz") ? ".tgz" : ".tar.gz";
|
|
78
|
+
return `${namespace}-${name}-${version}-${profile}${suffix}`;
|
|
79
|
+
}
|
|
80
|
+
function listBundleEntries(archivePath) {
|
|
81
|
+
const result = spawnSync("tar", ["-tvzf", archivePath], { encoding: "utf8" });
|
|
82
|
+
if (result.error) {
|
|
83
|
+
throw new Error(`Failed to inspect bundle archive: ${result.error.message}`);
|
|
84
|
+
}
|
|
85
|
+
if (result.status !== 0) {
|
|
86
|
+
throw new Error(`Failed to inspect bundle archive: ${result.stderr.trim() || "tar exited non-zero"}`);
|
|
87
|
+
}
|
|
88
|
+
return result.stdout
|
|
89
|
+
.split("\n")
|
|
90
|
+
.map(line => line.trim())
|
|
91
|
+
.filter(Boolean)
|
|
92
|
+
.map(line => {
|
|
93
|
+
const kind = line[0] ?? "";
|
|
94
|
+
const parts = line.split(/\s+/);
|
|
95
|
+
const path = parts.slice(8).join(" ").replace(/^\.\//, "");
|
|
96
|
+
return { kind, path };
|
|
97
|
+
})
|
|
98
|
+
.filter(entry => entry.path.length > 0);
|
|
99
|
+
}
|
|
100
|
+
function ensureSafeBundleEntries(entries) {
|
|
101
|
+
for (const entry of entries) {
|
|
102
|
+
const normalized = posix.normalize(entry.path);
|
|
103
|
+
if (normalized === ".." ||
|
|
104
|
+
normalized.startsWith("../") ||
|
|
105
|
+
normalized.includes("/../") ||
|
|
106
|
+
normalized.startsWith("/")) {
|
|
107
|
+
throw new Error(`Unsafe bundle entry: ${entry.path}`);
|
|
108
|
+
}
|
|
109
|
+
if (entry.kind !== "-" && entry.kind !== "d") {
|
|
110
|
+
throw new Error(`Unsupported bundle entry type for ${entry.path}`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
function extractBundleArchive(archivePath, destinationDir) {
|
|
115
|
+
const entries = listBundleEntries(archivePath);
|
|
116
|
+
ensureSafeBundleEntries(entries);
|
|
117
|
+
const result = spawnSync("tar", ["-xzf", archivePath, "-C", destinationDir], { encoding: "utf8" });
|
|
118
|
+
if (result.error) {
|
|
119
|
+
throw new Error(`Failed to extract bundle archive: ${result.error.message}`);
|
|
120
|
+
}
|
|
121
|
+
if (result.status !== 0) {
|
|
122
|
+
throw new Error(`Failed to extract bundle archive: ${result.stderr.trim() || "tar exited non-zero"}`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
function resolveExtractedDocsDir(extractionRoot) {
|
|
126
|
+
if (existsSync(join(extractionRoot, "manifest.json"))) {
|
|
127
|
+
return extractionRoot;
|
|
128
|
+
}
|
|
129
|
+
const dirs = readdirSync(extractionRoot, { withFileTypes: true })
|
|
130
|
+
.filter(entry => entry.isDirectory())
|
|
131
|
+
.map(entry => join(extractionRoot, entry.name));
|
|
132
|
+
if (dirs.length === 1 && existsSync(join(dirs[0], "manifest.json"))) {
|
|
133
|
+
return dirs[0];
|
|
134
|
+
}
|
|
135
|
+
throw new Error("Bundle archive did not extract to the expected docs layout");
|
|
136
|
+
}
|
|
137
|
+
function bundlePageEntry(pageUid) {
|
|
138
|
+
const normalized = pageUid.trim();
|
|
139
|
+
if (normalized.length === 0 ||
|
|
140
|
+
normalized.startsWith("/") ||
|
|
141
|
+
normalized.includes("\\") ||
|
|
142
|
+
normalized.split("/").includes("..")) {
|
|
143
|
+
throw new Error(`Unsafe bundle page_uid: ${pageUid}`);
|
|
144
|
+
}
|
|
145
|
+
return `${normalized}.md`;
|
|
146
|
+
}
|
|
147
|
+
function expectedBundlePageEntry(page) {
|
|
148
|
+
const bundlePath = page.bundle_path?.trim();
|
|
149
|
+
if (bundlePath) {
|
|
150
|
+
const normalized = posix.normalize(bundlePath);
|
|
151
|
+
if (normalized === ".." ||
|
|
152
|
+
normalized.startsWith("../") ||
|
|
153
|
+
normalized.includes("/../") ||
|
|
154
|
+
normalized.startsWith("/") ||
|
|
155
|
+
!normalized.endsWith(".md")) {
|
|
156
|
+
throw new Error(`Unsafe bundle page path: ${bundlePath}`);
|
|
157
|
+
}
|
|
158
|
+
return normalized;
|
|
159
|
+
}
|
|
160
|
+
return bundlePageEntry(page.page_uid);
|
|
161
|
+
}
|
|
162
|
+
function materializeLocalPageLayout(stagedDocsDir, pageIndex) {
|
|
163
|
+
const pagesDir = join(stagedDocsDir, "pages");
|
|
164
|
+
for (const page of pageIndex) {
|
|
165
|
+
const sourcePath = join(pagesDir, expectedBundlePageEntry(page));
|
|
166
|
+
const targetPath = join(pagesDir, bundlePageEntry(page.page_uid));
|
|
167
|
+
if (sourcePath === targetPath)
|
|
168
|
+
continue;
|
|
169
|
+
mkdirSync(dirname(targetPath), { recursive: true });
|
|
170
|
+
renameSync(sourcePath, targetPath);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
function assertSafeExtractedTree(path) {
|
|
174
|
+
const stat = lstatSync(path);
|
|
175
|
+
if (stat.isSymbolicLink()) {
|
|
176
|
+
throw new Error(`Bundle archive contains an unsupported symlink: ${path}`);
|
|
177
|
+
}
|
|
178
|
+
if (!(stat.isDirectory() || stat.isFile())) {
|
|
179
|
+
throw new Error(`Bundle archive contains an unsupported entry type: ${path}`);
|
|
180
|
+
}
|
|
181
|
+
if (!stat.isDirectory()) {
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
for (const entry of readdirSync(path, { withFileTypes: true })) {
|
|
185
|
+
assertSafeExtractedTree(join(path, entry.name));
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
function listMarkdownFiles(path, relativeDir = "") {
|
|
189
|
+
const files = [];
|
|
190
|
+
for (const entry of readdirSync(path, { withFileTypes: true })) {
|
|
191
|
+
const nextRelative = relativeDir ? join(relativeDir, entry.name) : entry.name;
|
|
192
|
+
const nextPath = join(path, entry.name);
|
|
193
|
+
if (entry.isDirectory()) {
|
|
194
|
+
files.push(...listMarkdownFiles(nextPath, nextRelative));
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
if (entry.isFile() && entry.name.endsWith(".md")) {
|
|
198
|
+
files.push(nextRelative.replace(/\\/g, "/"));
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return files.sort();
|
|
202
|
+
}
|
|
203
|
+
function validateExtractedBundle(stagedDocsDir) {
|
|
204
|
+
const manifestPath = join(stagedDocsDir, "manifest.json");
|
|
205
|
+
const pageIndexPath = join(stagedDocsDir, "page-index.json");
|
|
206
|
+
const pagesDir = join(stagedDocsDir, "pages");
|
|
207
|
+
assertSafeExtractedTree(stagedDocsDir);
|
|
208
|
+
if (!existsSync(manifestPath)) {
|
|
209
|
+
throw new Error("Bundle archive is missing manifest.json");
|
|
210
|
+
}
|
|
211
|
+
if (!existsSync(pageIndexPath)) {
|
|
212
|
+
throw new Error("Bundle archive is missing page-index.json");
|
|
213
|
+
}
|
|
214
|
+
if (!existsSync(pagesDir)) {
|
|
215
|
+
throw new Error("Bundle archive is missing pages/");
|
|
216
|
+
}
|
|
217
|
+
const pageIndex = JSON.parse(readFileSync(pageIndexPath, "utf8"));
|
|
218
|
+
if (!Array.isArray(pageIndex)) {
|
|
219
|
+
throw new Error("Bundle page-index.json is not an array");
|
|
220
|
+
}
|
|
221
|
+
const expectedFiles = pageIndex.map(page => expectedBundlePageEntry(page)).sort();
|
|
222
|
+
const actualFiles = listMarkdownFiles(pagesDir);
|
|
223
|
+
const missingFiles = expectedFiles.filter(file => !actualFiles.includes(file));
|
|
224
|
+
const extraFiles = actualFiles.filter(file => !expectedFiles.includes(file));
|
|
225
|
+
if (missingFiles.length > 0 || extraFiles.length > 0) {
|
|
226
|
+
throw new Error(`Bundle archive page set does not match page-index.json (missing: ${missingFiles.join(", ") || "none"}; extra: ${extraFiles.join(", ") || "none"})`);
|
|
227
|
+
}
|
|
228
|
+
materializeLocalPageLayout(stagedDocsDir, pageIndex);
|
|
229
|
+
return { pageCount: actualFiles.length };
|
|
230
|
+
}
|
|
231
|
+
async function ensureCurrentIndexSchema({ cache, indexer }, library, version) {
|
|
232
|
+
const installed = cache.listInstalled().filter(lib => {
|
|
233
|
+
const matchesLibrary = !library || `${lib.namespace}/${lib.name}` === library;
|
|
234
|
+
const matchesVersion = !version || lib.version === version;
|
|
235
|
+
return matchesLibrary && matchesVersion;
|
|
236
|
+
});
|
|
237
|
+
for (const lib of installed) {
|
|
238
|
+
if ((lib.index_schema_version ?? 0) >= DOC_INDEX_SCHEMA_VERSION)
|
|
239
|
+
continue;
|
|
240
|
+
await indexer.removeLibraryVersion(lib.namespace, lib.name, lib.version);
|
|
241
|
+
await indexer.indexLibraryVersion(lib.namespace, lib.name, lib.version);
|
|
242
|
+
cache.addInstalled({
|
|
243
|
+
...lib,
|
|
244
|
+
page_count: cache.countPages(lib.namespace, lib.name, lib.version),
|
|
245
|
+
index_schema_version: DOC_INDEX_SCHEMA_VERSION,
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
function resolveCachedPage(cache, input) {
|
|
250
|
+
const parsed = parseLibrary(input.library);
|
|
251
|
+
if (!parsed)
|
|
252
|
+
return null;
|
|
253
|
+
const { namespace, name } = parsed;
|
|
254
|
+
const hasDocPath = typeof input.doc_path === "string";
|
|
255
|
+
const hasPageUid = typeof input.page_uid === "string";
|
|
256
|
+
if ((hasDocPath ? 1 : 0) + (hasPageUid ? 1 : 0) !== 1) {
|
|
257
|
+
return null;
|
|
258
|
+
}
|
|
259
|
+
if (hasDocPath) {
|
|
260
|
+
const page = cache.findPageByPath(namespace, name, input.version, input.doc_path);
|
|
261
|
+
if (!page)
|
|
262
|
+
return null;
|
|
263
|
+
const content = cache.readPage(namespace, name, input.version, page.page_uid);
|
|
264
|
+
return {
|
|
265
|
+
library: input.library,
|
|
266
|
+
version: input.version,
|
|
267
|
+
pageUid: page.page_uid,
|
|
268
|
+
docPath: normalizeDocPath(page.path),
|
|
269
|
+
title: page.title,
|
|
270
|
+
url: page.url,
|
|
271
|
+
content,
|
|
272
|
+
hydrationState: content === null ? "missing_content" : "ready",
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
const pageUid = input.page_uid;
|
|
276
|
+
const page = cache.findPageByUid(namespace, name, input.version, pageUid);
|
|
277
|
+
const content = cache.readPage(namespace, name, input.version, pageUid);
|
|
278
|
+
if (!page && content === null)
|
|
279
|
+
return null;
|
|
280
|
+
return {
|
|
281
|
+
library: input.library,
|
|
282
|
+
version: input.version,
|
|
283
|
+
pageUid,
|
|
284
|
+
docPath: normalizeDocPath(page?.path ?? `${pageUid}.md`),
|
|
285
|
+
title: page?.title ?? extractTitle(content ?? "", pageUid),
|
|
286
|
+
url: page?.url,
|
|
287
|
+
content,
|
|
288
|
+
hydrationState: content === null ? "missing_content" : "ready",
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
function buildExcerpt(page, { fromLine = 1, maxLines = DEFAULT_EXCERPT_MAX_LINES, lineNumbers = false, }) {
|
|
292
|
+
const lines = (page.content ?? "").split("\n");
|
|
293
|
+
const totalLines = lines.length;
|
|
294
|
+
const clampedStart = totalLines === 0 ? 1 : Math.min(Math.max(Math.trunc(fromLine), 1), totalLines);
|
|
295
|
+
const safeMaxLines = Math.max(Math.trunc(maxLines), 1);
|
|
296
|
+
const endExclusive = totalLines === 0 ? 0 : Math.min(clampedStart - 1 + safeMaxLines, totalLines);
|
|
297
|
+
const excerptLines = totalLines === 0 ? [] : lines.slice(clampedStart - 1, endExclusive);
|
|
298
|
+
const renderedLines = lineNumbers
|
|
299
|
+
? excerptLines.map((line, index) => `${clampedStart + index} | ${line}`)
|
|
300
|
+
: excerptLines;
|
|
301
|
+
const lineEnd = excerptLines.length === 0 ? clampedStart - 1 : clampedStart + excerptLines.length - 1;
|
|
302
|
+
return structuredTextResult(renderedLines.join("\n"), {
|
|
303
|
+
library: page.library,
|
|
304
|
+
version: page.version,
|
|
305
|
+
doc_path: page.docPath,
|
|
306
|
+
page_uid: page.pageUid,
|
|
307
|
+
title: page.title,
|
|
308
|
+
line_start: clampedStart,
|
|
309
|
+
line_end: lineEnd,
|
|
310
|
+
truncated: endExclusive < totalLines,
|
|
311
|
+
...(page.url ? { url: page.url } : {}),
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
function resolveExcerptWindow(input) {
|
|
315
|
+
const hasAroundLine = typeof input.around_line === "number";
|
|
316
|
+
const hasFromLine = typeof input.from_line === "number";
|
|
317
|
+
const hasWindowBounds = typeof input.before === "number" || typeof input.after === "number";
|
|
318
|
+
if (hasAroundLine && hasFromLine) {
|
|
319
|
+
return errorResult("Use either around_line or from_line/max_lines, not both.", "INVALID_RANGE");
|
|
320
|
+
}
|
|
321
|
+
if (!hasAroundLine && hasWindowBounds) {
|
|
322
|
+
return errorResult("before/after can only be used with around_line.", "INVALID_RANGE");
|
|
323
|
+
}
|
|
324
|
+
if (hasAroundLine) {
|
|
325
|
+
const aroundLine = Math.max(Math.trunc(input.around_line ?? 1), 1);
|
|
326
|
+
const before = Math.max(Math.trunc(input.before ?? DEFAULT_EXPAND_BEFORE), 0);
|
|
327
|
+
const after = Math.max(Math.trunc(input.after ?? DEFAULT_EXPAND_AFTER), 0);
|
|
328
|
+
return {
|
|
329
|
+
fromLine: Math.max(1, aroundLine - before),
|
|
330
|
+
maxLines: before + after + 1,
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
return {
|
|
334
|
+
fromLine: input.from_line,
|
|
335
|
+
maxLines: input.max_lines,
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
async function installFromPageApi(deps, namespace, name, version, manifest) {
|
|
339
|
+
const { cache, registryClient } = deps;
|
|
340
|
+
cache.saveManifest(namespace, name, version, manifest);
|
|
341
|
+
reportProgress(deps, `Fetching page index for ${librarySlug(namespace, name)}@${version}...`);
|
|
342
|
+
const allPages = await registryClient.getAllPageIndex(namespace, name, version);
|
|
343
|
+
cache.savePageIndex(namespace, name, version, allPages);
|
|
344
|
+
let downloadedCount = 0;
|
|
345
|
+
for (const page of allPages) {
|
|
346
|
+
const nextCount = downloadedCount + 1;
|
|
347
|
+
if (shouldReportPageProgress(nextCount, allPages.length)) {
|
|
348
|
+
reportProgress(deps, `Downloading page content ${nextCount}/${allPages.length}...`);
|
|
349
|
+
}
|
|
350
|
+
const pageContent = await registryClient.getPageContent(namespace, name, version, page.page_uid);
|
|
351
|
+
cache.savePage(namespace, name, version, page.page_uid, pageContent.data.content_md);
|
|
352
|
+
downloadedCount++;
|
|
353
|
+
}
|
|
354
|
+
return {
|
|
355
|
+
installMethod: "page_fallback",
|
|
356
|
+
profile: "full",
|
|
357
|
+
pageCount: downloadedCount,
|
|
358
|
+
totalPages: allPages.length,
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
async function installFromBundle(deps, namespace, name, version, manifest, selectedBundle) {
|
|
362
|
+
const { cache, registryClient } = deps;
|
|
363
|
+
const archivePath = cache.createTempArchivePath(namespace, name, version, bundleArchiveFilename(namespace, name, version, selectedBundle.profile, selectedBundle.bundle));
|
|
364
|
+
const archiveDir = dirname(archivePath);
|
|
365
|
+
const extractionRoot = join(archiveDir, "extracted");
|
|
366
|
+
try {
|
|
367
|
+
const expectedSha = normalizeSha256(selectedBundle.bundle.sha256);
|
|
368
|
+
reportProgress(deps, `Downloading ${selectedBundle.profile} bundle for ${librarySlug(namespace, name)}@${version}...`);
|
|
369
|
+
let bundleBytes = await registryClient.downloadBundle(selectedBundle.bundle.url);
|
|
370
|
+
let actualSha = sha256Hex(bundleBytes);
|
|
371
|
+
if (expectedSha && actualSha !== expectedSha) {
|
|
372
|
+
bundleBytes = await registryClient.downloadBundle(selectedBundle.bundle.url);
|
|
373
|
+
actualSha = sha256Hex(bundleBytes);
|
|
374
|
+
if (actualSha !== expectedSha) {
|
|
375
|
+
throw new Error(`Bundle checksum mismatch for ${namespace}/${name}@${version}`);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
writeFileSync(archivePath, bundleBytes);
|
|
379
|
+
mkdirSync(extractionRoot, { recursive: true });
|
|
380
|
+
reportProgress(deps, `Extracting bundle for ${librarySlug(namespace, name)}@${version}...`);
|
|
381
|
+
extractBundleArchive(archivePath, extractionRoot);
|
|
382
|
+
const stagedDocsDir = resolveExtractedDocsDir(extractionRoot);
|
|
383
|
+
reportProgress(deps, `Validating bundle contents for ${librarySlug(namespace, name)}@${version}...`);
|
|
384
|
+
const { pageCount } = validateExtractedBundle(stagedDocsDir);
|
|
385
|
+
writeFileSync(join(stagedDocsDir, "manifest.json"), JSON.stringify(manifest, null, 2));
|
|
386
|
+
cache.commitStagedVersion(namespace, name, version, stagedDocsDir);
|
|
387
|
+
return {
|
|
388
|
+
installMethod: "bundle",
|
|
389
|
+
profile: selectedBundle.profile,
|
|
390
|
+
pageCount,
|
|
391
|
+
totalPages: pageCount,
|
|
392
|
+
bundleFormat: selectedBundle.bundle.format,
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
finally {
|
|
396
|
+
cache.cleanupTempPath(archiveDir);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
async function installResolvedVersion(deps, namespace, name, version, manifest) {
|
|
400
|
+
const { cache, indexer } = deps;
|
|
401
|
+
const resolvedManifest = manifest ?? (await deps.registryClient.getManifest(namespace, name, version)).data;
|
|
402
|
+
const manifestChecksum = resolvedManifest.provenance?.manifest_checksum ?? null;
|
|
403
|
+
const existingInstall = cache.findInstalled(namespace, name, version);
|
|
404
|
+
const backupDir = existingInstall ? cache.backupVersion(namespace, name, version) : null;
|
|
405
|
+
try {
|
|
406
|
+
let install;
|
|
407
|
+
let bundleFallbackReason;
|
|
408
|
+
const selectedBundle = selectBundle(resolvedManifest);
|
|
409
|
+
if (selectedBundle) {
|
|
410
|
+
try {
|
|
411
|
+
install = await installFromBundle(deps, namespace, name, version, resolvedManifest, selectedBundle);
|
|
412
|
+
}
|
|
413
|
+
catch (error) {
|
|
414
|
+
bundleFallbackReason = error.message;
|
|
415
|
+
reportProgress(deps, `Bundle install failed for ${librarySlug(namespace, name)}@${version}; falling back to page API...`);
|
|
416
|
+
install = await installFromPageApi(deps, namespace, name, version, resolvedManifest);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
else {
|
|
420
|
+
install = await installFromPageApi(deps, namespace, name, version, resolvedManifest);
|
|
421
|
+
}
|
|
422
|
+
reportProgress(deps, `Indexing ${install.pageCount} pages for local search...`);
|
|
423
|
+
const indexedCount = await indexer.indexLibraryVersion(namespace, name, version);
|
|
424
|
+
if (backupDir) {
|
|
425
|
+
cache.discardBackup(backupDir);
|
|
426
|
+
}
|
|
427
|
+
return {
|
|
428
|
+
...install,
|
|
429
|
+
indexedCount,
|
|
430
|
+
manifestChecksum,
|
|
431
|
+
...(bundleFallbackReason ? { bundleFallbackReason } : {}),
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
catch (error) {
|
|
435
|
+
await indexer.removeLibraryVersion(namespace, name, version);
|
|
436
|
+
if (backupDir) {
|
|
437
|
+
cache.restoreVersionFromBackup(namespace, name, version, backupDir);
|
|
438
|
+
}
|
|
439
|
+
if (existingInstall) {
|
|
440
|
+
cache.addInstalled({
|
|
441
|
+
...existingInstall,
|
|
442
|
+
page_count: cache.countPages(namespace, name, version),
|
|
443
|
+
index_schema_version: 0,
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
else {
|
|
447
|
+
cache.removeVersion(namespace, name, version);
|
|
448
|
+
}
|
|
449
|
+
throw error;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
export async function searchLibraries(deps, input) {
|
|
453
|
+
const limit = Math.min(Math.max(Math.trunc(input.limit ?? 5), 1), 20);
|
|
454
|
+
const response = await deps.registryClient.searchLibraries(input.query);
|
|
455
|
+
const matches = response.data.slice(0, limit);
|
|
456
|
+
if (matches.length === 0) {
|
|
457
|
+
return structuredTextResult(`No libraries found for "${input.query}".`, {
|
|
458
|
+
query: input.query,
|
|
459
|
+
results: [],
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
const versionResponses = await Promise.allSettled(matches.map(match => deps.registryClient.getVersions(match.namespace, match.name)));
|
|
463
|
+
const results = matches.map((match, index) => {
|
|
464
|
+
const versionResponse = versionResponses[index];
|
|
465
|
+
const versions = versionResponse?.status === "fulfilled"
|
|
466
|
+
? versionResponse.value.data.map(version => version.version)
|
|
467
|
+
: (match.default_version ? [match.default_version] : []);
|
|
468
|
+
const localVersions = installedVersions(deps.cache, match.namespace, match.name);
|
|
469
|
+
return {
|
|
470
|
+
library: librarySlug(match.namespace, match.name),
|
|
471
|
+
display_name: match.display_name,
|
|
472
|
+
namespace: match.namespace,
|
|
473
|
+
name: match.name,
|
|
474
|
+
aliases: match.aliases,
|
|
475
|
+
homepage_url: match.homepage_url,
|
|
476
|
+
default_version: match.default_version,
|
|
477
|
+
source_type: match.source_type,
|
|
478
|
+
license_status: match.license_status,
|
|
479
|
+
version_count: match.version_count ?? versions.length,
|
|
480
|
+
versions,
|
|
481
|
+
installed_versions: localVersions,
|
|
482
|
+
installed: localVersions.length > 0,
|
|
483
|
+
};
|
|
484
|
+
});
|
|
485
|
+
const lines = results.map(result => {
|
|
486
|
+
const installed = result.installed_versions.length > 0 ? `installed: ${result.installed_versions.join(", ")}` : "installed: none";
|
|
487
|
+
const source = result.source_type ? ` | source: ${result.source_type}` : "";
|
|
488
|
+
return `- ${result.library} (${result.display_name}) | default: ${result.default_version} | versions: ${result.versions.join(", ") || "unknown"}${source} | ${installed}`;
|
|
489
|
+
});
|
|
490
|
+
return structuredTextResult(lines.join("\n"), {
|
|
491
|
+
query: input.query,
|
|
492
|
+
results,
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
export async function installDocs(deps, input) {
|
|
496
|
+
reportProgress(deps, `Resolving ${input.library}${input.version ? `@${input.version}` : ""}...`);
|
|
497
|
+
const resolved = await deps.registryClient.resolve({
|
|
498
|
+
query: input.library,
|
|
499
|
+
version_hint: input.version,
|
|
500
|
+
});
|
|
501
|
+
const { namespace, name } = resolved.data.library;
|
|
502
|
+
const canonicalLibrary = librarySlug(namespace, name);
|
|
503
|
+
const targetVersion = resolved.data.version.version;
|
|
504
|
+
reportProgress(deps, `Fetching manifest for ${canonicalLibrary}@${targetVersion}...`);
|
|
505
|
+
const manifest = (await deps.registryClient.getManifest(namespace, name, targetVersion)).data;
|
|
506
|
+
const targetChecksum = manifest.provenance?.manifest_checksum ?? null;
|
|
507
|
+
const existing = deps.cache.findInstalled(namespace, name, targetVersion);
|
|
508
|
+
if (existing && existing.manifest_checksum === targetChecksum) {
|
|
509
|
+
return structuredTextResult(`${canonicalLibrary}@${targetVersion} is already installed and current (${existing.page_count} pages, ${existing.profile} mode).`, {
|
|
510
|
+
library: canonicalLibrary,
|
|
511
|
+
version: targetVersion,
|
|
512
|
+
changed: false,
|
|
513
|
+
installed: true,
|
|
514
|
+
manifest_checksum: targetChecksum,
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
const outcome = await installResolvedVersion(deps, namespace, name, targetVersion, manifest);
|
|
518
|
+
deps.cache.addInstalled({
|
|
519
|
+
namespace,
|
|
520
|
+
name,
|
|
521
|
+
version: targetVersion,
|
|
522
|
+
profile: outcome.profile,
|
|
523
|
+
installed_at: new Date().toISOString(),
|
|
524
|
+
manifest_checksum: outcome.manifestChecksum,
|
|
525
|
+
page_count: outcome.pageCount,
|
|
526
|
+
index_schema_version: DOC_INDEX_SCHEMA_VERSION,
|
|
527
|
+
});
|
|
528
|
+
const installLine = outcome.installMethod === "bundle"
|
|
529
|
+
? ` Installed from bundle (${outcome.profile}, ${outcome.bundleFormat ?? "tar.gz"})`
|
|
530
|
+
: ` Installed from page API fallback (${outcome.pageCount}/${outcome.totalPages ?? outcome.pageCount} pages)`;
|
|
531
|
+
const fallbackLine = outcome.bundleFallbackReason ? `\n Bundle fallback: ${outcome.bundleFallbackReason}` : "";
|
|
532
|
+
const actionLine = existing ? "Reinstalled" : "Installed";
|
|
533
|
+
return structuredTextResult(`${actionLine} ${canonicalLibrary}@${targetVersion}\n${installLine}\n Indexed: ${outcome.indexedCount} pages for search${fallbackLine}`, {
|
|
534
|
+
library: canonicalLibrary,
|
|
535
|
+
version: targetVersion,
|
|
536
|
+
changed: true,
|
|
537
|
+
reinstall: Boolean(existing),
|
|
538
|
+
install_method: outcome.installMethod,
|
|
539
|
+
profile: outcome.profile,
|
|
540
|
+
page_count: outcome.pageCount,
|
|
541
|
+
indexed_count: outcome.indexedCount,
|
|
542
|
+
manifest_checksum: outcome.manifestChecksum,
|
|
543
|
+
...(outcome.bundleFormat ? { bundle_format: outcome.bundleFormat } : {}),
|
|
544
|
+
...(outcome.bundleFallbackReason ? { bundle_fallback_reason: outcome.bundleFallbackReason } : {}),
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
export function listInstalledDocs(deps) {
|
|
548
|
+
const installed = deps.cache.listInstalled();
|
|
549
|
+
if (installed.length === 0) {
|
|
550
|
+
return structuredTextResult("No documentation packages installed. Use install_docs to add some.", { results: [] });
|
|
551
|
+
}
|
|
552
|
+
const results = installed.map(lib => ({
|
|
553
|
+
library: librarySlug(lib.namespace, lib.name),
|
|
554
|
+
version: lib.version,
|
|
555
|
+
profile: lib.profile,
|
|
556
|
+
page_count: lib.page_count,
|
|
557
|
+
installed_at: lib.installed_at,
|
|
558
|
+
manifest_checksum: lib.manifest_checksum,
|
|
559
|
+
}));
|
|
560
|
+
const lines = results.map(result => `- ${result.library}@${result.version} (${result.profile}, ${result.page_count} pages)`);
|
|
561
|
+
return structuredTextResult(`Installed documentation packages:\n\n${lines.join("\n")}`, { results });
|
|
562
|
+
}
|
|
563
|
+
export async function searchDocs(deps, input) {
|
|
564
|
+
const { cache, indexer } = deps;
|
|
565
|
+
const installed = cache.listInstalled();
|
|
566
|
+
if (input.library) {
|
|
567
|
+
const parsed = parseLibrary(input.library);
|
|
568
|
+
if (!parsed) {
|
|
569
|
+
return errorResult("library must be in namespace/name format (for example 'vercel/nextjs').", "INVALID_LIBRARY");
|
|
570
|
+
}
|
|
571
|
+
const matchingVersions = installedVersions(cache, parsed.namespace, parsed.name);
|
|
572
|
+
const requestedInstalled = input.version ? matchingVersions.includes(input.version) : matchingVersions.length > 0;
|
|
573
|
+
if (!requestedInstalled) {
|
|
574
|
+
return errorResult(input.version ? `${input.library}@${input.version} is not installed.` : `${input.library} is not installed.`, "NOT_INSTALLED", {
|
|
575
|
+
library: input.library,
|
|
576
|
+
...(input.version ? { version: input.version } : {}),
|
|
577
|
+
installed_versions: matchingVersions,
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
else if (installed.length === 0) {
|
|
582
|
+
return structuredTextResult("No documentation packages installed. Use install_docs first.", { results: [] });
|
|
583
|
+
}
|
|
584
|
+
await ensureCurrentIndexSchema(deps, input.library, input.version);
|
|
585
|
+
const results = await indexer.search(input.query, {
|
|
586
|
+
library: input.library,
|
|
587
|
+
version: input.version,
|
|
588
|
+
maxResults: input.max_results ?? 5,
|
|
589
|
+
mode: input.mode ?? "auto",
|
|
590
|
+
});
|
|
591
|
+
if (results.length === 0) {
|
|
592
|
+
return structuredTextResult(`No results found for "${input.query}"${input.library ? ` in ${input.library}` : ""}.`, { query: input.query, results: [] });
|
|
593
|
+
}
|
|
594
|
+
const usedMode = results[0]?.searchMode ?? "fts";
|
|
595
|
+
const lines = results.map((result, index) => {
|
|
596
|
+
const location = `${result.docPath}${result.lineStart ? ` | lines ${result.lineStart}-${result.lineEnd}` : ""}`;
|
|
597
|
+
const snippet = result.snippet || result.contentMd.slice(0, 500);
|
|
598
|
+
return `## [${index + 1}] ${result.title} (${result.library}@${result.version})\n${location}\n\n${snippet}`;
|
|
599
|
+
});
|
|
600
|
+
const structured = {
|
|
601
|
+
results: results.map(result => ({
|
|
602
|
+
library: result.library,
|
|
603
|
+
version: result.version,
|
|
604
|
+
doc_path: result.docPath,
|
|
605
|
+
page_uid: result.pageUid,
|
|
606
|
+
title: result.title,
|
|
607
|
+
content_md: result.contentMd,
|
|
608
|
+
score: result.score,
|
|
609
|
+
snippet: result.snippet,
|
|
610
|
+
line_start: result.lineStart,
|
|
611
|
+
line_end: result.lineEnd,
|
|
612
|
+
search_mode: result.searchMode,
|
|
613
|
+
...(result.url ? { url: result.url } : {}),
|
|
614
|
+
})),
|
|
615
|
+
};
|
|
616
|
+
return structuredTextResult(`Search: ${usedMode} | ${results.length} page-level local results\n\n${lines.join("\n\n---\n\n")}`, structured);
|
|
617
|
+
}
|
|
618
|
+
export async function getDoc(deps, input) {
|
|
619
|
+
const parsed = parseLibrary(input.library);
|
|
620
|
+
if (!parsed) {
|
|
621
|
+
return errorResult("Error: library must be in namespace/name format", "INVALID_LIBRARY");
|
|
622
|
+
}
|
|
623
|
+
const installed = deps.cache.findInstalled(parsed.namespace, parsed.name, input.version);
|
|
624
|
+
if (!installed) {
|
|
625
|
+
return errorResult(`${input.library}@${input.version} is not installed.`, "NOT_INSTALLED");
|
|
626
|
+
}
|
|
627
|
+
const lookupCount = (input.doc_path ? 1 : 0) + (input.page_uid ? 1 : 0);
|
|
628
|
+
if (lookupCount !== 1) {
|
|
629
|
+
return errorResult("Exactly one of doc_path or page_uid must be provided.", "INVALID_LOOKUP");
|
|
630
|
+
}
|
|
631
|
+
const page = resolveCachedPage(deps.cache, input);
|
|
632
|
+
if (!page) {
|
|
633
|
+
return errorResult("Document not found in local cache.", "NOT_FOUND", {
|
|
634
|
+
library: input.library,
|
|
635
|
+
version: input.version,
|
|
636
|
+
...(input.doc_path ? { doc_path: normalizeDocPath(input.doc_path) } : {}),
|
|
637
|
+
...(input.page_uid ? { page_uid: input.page_uid } : {}),
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
if (page.hydrationState === "missing_content") {
|
|
641
|
+
return errorResult("Page metadata exists locally, but page content is not hydrated. Reinstall with install_docs or refresh with update_docs.", "PAGE_NOT_HYDRATED", {
|
|
642
|
+
library: input.library,
|
|
643
|
+
version: input.version,
|
|
644
|
+
doc_path: page.docPath,
|
|
645
|
+
page_uid: page.pageUid,
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
const excerptWindow = resolveExcerptWindow(input);
|
|
649
|
+
if ("text" in excerptWindow) {
|
|
650
|
+
return excerptWindow;
|
|
651
|
+
}
|
|
652
|
+
if ((page.content ?? "").length === 0) {
|
|
653
|
+
return errorResult("Page content is empty.", "EMPTY_CONTENT", {
|
|
654
|
+
library: input.library,
|
|
655
|
+
version: input.version,
|
|
656
|
+
doc_path: page.docPath,
|
|
657
|
+
page_uid: page.pageUid,
|
|
658
|
+
});
|
|
659
|
+
}
|
|
660
|
+
return buildExcerpt(page, {
|
|
661
|
+
fromLine: excerptWindow.fromLine,
|
|
662
|
+
maxLines: excerptWindow.maxLines,
|
|
663
|
+
lineNumbers: input.line_numbers ?? false,
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
export async function updateDocs(deps, input) {
|
|
667
|
+
const { cache, indexer, registryClient } = deps;
|
|
668
|
+
const installed = cache.listInstalled();
|
|
669
|
+
if (input.library && !parseLibrary(input.library)) {
|
|
670
|
+
return errorResult("library must be in namespace/name format (for example 'vercel/nextjs').", "INVALID_LIBRARY");
|
|
671
|
+
}
|
|
672
|
+
const targets = input.library
|
|
673
|
+
? installed.filter(lib => librarySlug(lib.namespace, lib.name) === input.library)
|
|
674
|
+
: installed;
|
|
675
|
+
if (targets.length === 0) {
|
|
676
|
+
return structuredTextResult(input.library ? `${input.library} is not installed. Use install_docs first.` : "No documentation packages installed.", { results: [] });
|
|
677
|
+
}
|
|
678
|
+
const messages = [];
|
|
679
|
+
const results = [];
|
|
680
|
+
for (const lib of targets) {
|
|
681
|
+
try {
|
|
682
|
+
reportProgress(deps, `Checking ${librarySlug(lib.namespace, lib.name)} for updates...`);
|
|
683
|
+
const resolved = await registryClient.resolve({ query: librarySlug(lib.namespace, lib.name) });
|
|
684
|
+
const targetVersion = resolved.data.version.version;
|
|
685
|
+
const manifest = await registryClient.getManifest(lib.namespace, lib.name, targetVersion);
|
|
686
|
+
const targetChecksum = manifest.data.provenance?.manifest_checksum ?? null;
|
|
687
|
+
const versionChanged = targetVersion !== lib.version;
|
|
688
|
+
const checksumChanged = targetChecksum !== lib.manifest_checksum;
|
|
689
|
+
if (!versionChanged && !checksumChanged) {
|
|
690
|
+
messages.push(`${librarySlug(lib.namespace, lib.name)}@${lib.version}: already up to date`);
|
|
691
|
+
results.push({
|
|
692
|
+
library: librarySlug(lib.namespace, lib.name),
|
|
693
|
+
version: lib.version,
|
|
694
|
+
status: "unchanged",
|
|
695
|
+
manifest_checksum: targetChecksum,
|
|
696
|
+
});
|
|
697
|
+
continue;
|
|
698
|
+
}
|
|
699
|
+
const installOutcome = await installResolvedVersion(deps, lib.namespace, lib.name, targetVersion, manifest.data);
|
|
700
|
+
if (versionChanged) {
|
|
701
|
+
await indexer.removeLibraryVersion(lib.namespace, lib.name, lib.version);
|
|
702
|
+
cache.removeVersion(lib.namespace, lib.name, lib.version);
|
|
703
|
+
cache.removeInstalled(lib.namespace, lib.name, lib.version);
|
|
704
|
+
}
|
|
705
|
+
cache.addInstalled({
|
|
706
|
+
...lib,
|
|
707
|
+
version: targetVersion,
|
|
708
|
+
installed_at: new Date().toISOString(),
|
|
709
|
+
manifest_checksum: installOutcome.manifestChecksum,
|
|
710
|
+
page_count: installOutcome.pageCount,
|
|
711
|
+
profile: installOutcome.profile,
|
|
712
|
+
index_schema_version: DOC_INDEX_SCHEMA_VERSION,
|
|
713
|
+
});
|
|
714
|
+
let message = `${librarySlug(lib.namespace, lib.name)}: ${lib.version} → ${targetVersion}`;
|
|
715
|
+
if (!versionChanged) {
|
|
716
|
+
message = `${librarySlug(lib.namespace, lib.name)}@${lib.version}: refreshed in place`;
|
|
717
|
+
}
|
|
718
|
+
message += ` (${installOutcome.pageCount} pages, ${installOutcome.indexedCount} indexed via ${installOutcome.installMethod})`;
|
|
719
|
+
if (installOutcome.bundleFallbackReason) {
|
|
720
|
+
message += ` [bundle fallback: ${installOutcome.bundleFallbackReason}]`;
|
|
721
|
+
}
|
|
722
|
+
messages.push(message);
|
|
723
|
+
results.push({
|
|
724
|
+
library: librarySlug(lib.namespace, lib.name),
|
|
725
|
+
previous_version: lib.version,
|
|
726
|
+
version: targetVersion,
|
|
727
|
+
status: versionChanged ? "updated" : "refreshed",
|
|
728
|
+
install_method: installOutcome.installMethod,
|
|
729
|
+
profile: installOutcome.profile,
|
|
730
|
+
page_count: installOutcome.pageCount,
|
|
731
|
+
indexed_count: installOutcome.indexedCount,
|
|
732
|
+
manifest_checksum: installOutcome.manifestChecksum,
|
|
733
|
+
...(installOutcome.bundleFormat ? { bundle_format: installOutcome.bundleFormat } : {}),
|
|
734
|
+
...(installOutcome.bundleFallbackReason ? { bundle_fallback_reason: installOutcome.bundleFallbackReason } : {}),
|
|
735
|
+
});
|
|
736
|
+
}
|
|
737
|
+
catch (error) {
|
|
738
|
+
messages.push(`${librarySlug(lib.namespace, lib.name)}: update failed — ${error.message}`);
|
|
739
|
+
results.push({
|
|
740
|
+
library: librarySlug(lib.namespace, lib.name),
|
|
741
|
+
version: lib.version,
|
|
742
|
+
status: "failed",
|
|
743
|
+
error: error.message,
|
|
744
|
+
});
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
return structuredTextResult(messages.join("\n"), { results });
|
|
748
|
+
}
|
|
749
|
+
export async function removeDocs(deps, input) {
|
|
750
|
+
const parsed = parseLibrary(input.library);
|
|
751
|
+
if (!parsed) {
|
|
752
|
+
return errorResult("Error: library must be in namespace/name format", "INVALID_LIBRARY");
|
|
753
|
+
}
|
|
754
|
+
const targets = deps.cache.listInstalled().filter(lib => {
|
|
755
|
+
if (lib.namespace !== parsed.namespace || lib.name !== parsed.name) {
|
|
756
|
+
return false;
|
|
757
|
+
}
|
|
758
|
+
return !input.version || lib.version === input.version;
|
|
759
|
+
});
|
|
760
|
+
if (targets.length === 0) {
|
|
761
|
+
return errorResult(input.version ? `${input.library}@${input.version} is not installed.` : `${input.library} is not installed.`, "NOT_INSTALLED");
|
|
762
|
+
}
|
|
763
|
+
for (const target of targets) {
|
|
764
|
+
await deps.indexer.removeLibraryVersion(target.namespace, target.name, target.version);
|
|
765
|
+
deps.cache.removeVersion(target.namespace, target.name, target.version);
|
|
766
|
+
deps.cache.removeInstalled(target.namespace, target.name, target.version);
|
|
767
|
+
}
|
|
768
|
+
return structuredTextResult(`Removed ${targets.length} documentation package${targets.length === 1 ? "" : "s"} for ${input.library}.`, {
|
|
769
|
+
library: input.library,
|
|
770
|
+
removed_versions: targets.map(target => target.version),
|
|
771
|
+
});
|
|
772
|
+
}
|
|
773
|
+
function extractTitle(content, fallback) {
|
|
774
|
+
const match = content.match(/^#\s+(.+)$/m);
|
|
775
|
+
return match ? match[1].trim() : fallback;
|
|
776
|
+
}
|