@reglow/review-mcp 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/knowledge.mjs ADDED
@@ -0,0 +1,1130 @@
1
+ /**
2
+ * 知识源(Knowledge):为 MCP 工具提供组件文档、示例、源码、函数实现与检索能力
3
+ *
4
+ * `@reglow/review` **未发布到 npm**,因此只有两级数据来源:
5
+ * 1. `workspace` —— `--root` 指定的 ReView 仓库(或从当前项目解析到的 `uni_modules/reglow-review`),
6
+ * 读取 `docs/components/*.md`、`docs/guide/*.md`、`README.md`、`CHANGELOG.md` 与
7
+ * `uni_modules/reglow-review/components/**` 源码;
8
+ * 2. `site` —— 官方文档站点兜底(https://reglow.huoli.tech/components-library/review)。
9
+ *
10
+ * `version` 参数保留(与 reui 版工具签名一致),但因为包未发布,传版本号时只会给出提示、
11
+ * 不切换知识源;版本号一律从 `CHANGELOG.md` 顶部解析。
12
+ */
13
+
14
+ import fs from "node:fs";
15
+ import path from "node:path";
16
+ import { fileURLToPath } from "node:url";
17
+ import {
18
+ CATEGORIES,
19
+ CHANGELOG_FILE,
20
+ COMPONENT_DIR,
21
+ DOCS_BASE,
22
+ DOCS_DIR,
23
+ GUIDE_DIR,
24
+ README_FILE,
25
+ SIDEBAR_FILE,
26
+ changelogUrlFor,
27
+ createCatalog,
28
+ guideUrlFor,
29
+ readTextSafe,
30
+ toPosix,
31
+ } from "./catalog.mjs";
32
+ import {
33
+ fetchSiteChangelog,
34
+ fetchSiteDocument,
35
+ fetchSiteGuide,
36
+ fetchSiteOverview,
37
+ } from "./site.mjs";
38
+
39
+ const PACKAGE_DIR = path.dirname(fileURLToPath(import.meta.url));
40
+
41
+ /** 组件库包名(未发布到 npm,仅用于识别仓库 / 安装目录) */
42
+ export const REVIEW_PACKAGE = "@reglow/review";
43
+ /** 未发布 npm 的统一提示文案 */
44
+ export const NPM_NOT_PUBLISHED_HINT =
45
+ "`@reglow/review` 尚未发布到 npm,因此无法按版本号拉取知识;" +
46
+ "请用 `--root <review 仓库路径>` 指向本地仓库(或在含 `uni_modules/reglow-review` 的项目内运行)," +
47
+ "官网文档站点仅作为兜底。";
48
+
49
+ /** 可参与检索 / 读取的文本文件扩展名 */
50
+ const TEXT_EXTENSIONS = [
51
+ ".md",
52
+ ".uvue",
53
+ ".uts",
54
+ ".vue",
55
+ ".ts",
56
+ ".js",
57
+ ".mjs",
58
+ ".json",
59
+ ".css",
60
+ ".scss",
61
+ ".txt",
62
+ ];
63
+ /** 检索时单文件最大读取体积 */
64
+ const MAX_SEARCH_FILE_BYTES = 512 * 1024;
65
+ /** 单次读取文件的最大字节数 */
66
+ const MAX_READ_BYTES = 1024 * 1024;
67
+
68
+ /** 子包自带 readme 的乱码特征字符(脚本生成的 readme 存在 UTF-8/GBK 二次编码问题) */
69
+ const GARBLED_MARKERS = /[\u953b\u9286\u93b8\u9470\u9428]/;
70
+
71
+ /**
72
+ * 指南 topic → 知识文件映射
73
+ *
74
+ * `topic` 与 reui 版工具的参数名保持一致,取值按 ReView(uni-app x)的实际情况适配。
75
+ */
76
+ export const GUIDE_TOPICS = [
77
+ {
78
+ key: "overview",
79
+ label: "总览",
80
+ files: [README_FILE, "docs/index.md", `${GUIDE_DIR}/introduction.md`],
81
+ },
82
+ {
83
+ key: "install",
84
+ label: "安装与引入",
85
+ files: [`${GUIDE_DIR}/introduction.md`, README_FILE],
86
+ headings: ["安装", "引入", "全局配置", "快速使用", "运行", "npm"],
87
+ },
88
+ {
89
+ key: "easycom",
90
+ label: "easycom 自动引入",
91
+ files: [README_FILE, `${GUIDE_DIR}/introduction.md`],
92
+ headings: ["引入", "快速使用", "easycom"],
93
+ },
94
+ {
95
+ key: "theme",
96
+ label: "主题与样式定制",
97
+ files: [`${GUIDE_DIR}/theme.md`, `${GUIDE_DIR}/introduction.md`],
98
+ headings: ["主题", "变量", "暗色", "样式", "re-theme", "re-common"],
99
+ },
100
+ {
101
+ key: "i18n",
102
+ label: "国际化",
103
+ files: [`${GUIDE_DIR}/locale.md`, README_FILE],
104
+ headings: ["国际化", "语言", "locale", "特性"],
105
+ },
106
+ {
107
+ key: "platform",
108
+ label: "平台与运行",
109
+ files: [README_FILE, "docs/index.md"],
110
+ headings: ["平台", "运行", "特性", "脚本", "结构"],
111
+ },
112
+ {
113
+ key: "changelog",
114
+ label: "版本记录",
115
+ files: [CHANGELOG_FILE, `${GUIDE_DIR}/changelog.md`],
116
+ },
117
+ {
118
+ key: "conventions",
119
+ label: "工程与组件约定",
120
+ files: [`${GUIDE_DIR}/introduction.md`, README_FILE],
121
+ headings: ["基础模块", "项目结构", "脚本命令", "约定", "结构"],
122
+ },
123
+ ];
124
+
125
+ export const GUIDE_TOPIC_KEYS = GUIDE_TOPICS.map((item) => item.key);
126
+
127
+ // ============================ 通用工具函数 ============================
128
+
129
+ function isTextFile(file) {
130
+ return TEXT_EXTENSIONS.some((ext) => file.endsWith(ext));
131
+ }
132
+
133
+ function lineCount(text) {
134
+ return text === "" ? 1 : text.split("\n").length;
135
+ }
136
+
137
+ /** readme 是否为二次编码的乱码(这种内容不能当文档喂给 AI) */
138
+ export function isGarbledText(text) {
139
+ return GARBLED_MARKERS.test(String(text ?? ""));
140
+ }
141
+
142
+ /** 是否可作为知识点根目录(完整仓库 / 只装了 uni_modules 的项目 / 组件包目录) */
143
+ export function isReviewRoot(dir) {
144
+ if (!dir) return false;
145
+ let stat;
146
+ try {
147
+ stat = fs.statSync(dir);
148
+ } catch {
149
+ return false;
150
+ }
151
+ if (!stat.isDirectory()) return false;
152
+ // 1. 完整仓库:docs/components + uni_modules
153
+ if (fs.existsSync(path.join(dir, DOCS_DIR))) return true;
154
+ // 2. 项目 / 仓库根:含 uni_modules/reglow-review 组件目录
155
+ if (
156
+ fs.existsSync(path.join(dir, COMPONENT_DIR)) &&
157
+ fs.existsSync(path.join(dir, SIDEBAR_FILE))
158
+ )
159
+ return true;
160
+ if (fs.existsSync(path.join(dir, COMPONENT_DIR))) return true;
161
+ // 3. 组件包自身(uni_modules/reglow-review):components/re-* 直接位于其下
162
+ if (
163
+ fs.existsSync(path.join(dir, "components")) &&
164
+ fs.existsSync(path.join(dir, CHANGELOG_FILE))
165
+ )
166
+ return true;
167
+ return false;
168
+ }
169
+
170
+ /**
171
+ * 一个「知识点根目录」内部可能有三种形态,需要归一化成「仓库根」语义:
172
+ * - 仓库根:含 `docs/` 与 `uni_modules/`
173
+ * - 项目根:含 `uni_modules/reglow-review/`
174
+ * - 组件包目录:`uni_modules/reglow-review` 自身(`components/re-*` 直接在其下)
175
+ *
176
+ * 返回归一化的仓库根(把组件包目录折算到 `uni_modules/` 的上一级)。
177
+ */
178
+ export function normalizeReviewRoot(dir) {
179
+ if (!dir) return null;
180
+ if (fs.existsSync(path.join(dir, DOCS_DIR))) return dir;
181
+ if (fs.existsSync(path.join(dir, COMPONENT_DIR))) return dir;
182
+ const componentsDir = path.join(dir, "components");
183
+ if (
184
+ fs.existsSync(componentsDir) &&
185
+ fs.existsSync(path.join(dir, CHANGELOG_FILE))
186
+ ) {
187
+ const parent = path.dirname(dir);
188
+ const grand = path.dirname(parent);
189
+ // <project>/uni_modules/reglow-review → <project>(catalog 会按 uni_modules/reglow-review 找)
190
+ if (path.basename(parent) === "uni_modules") return grand;
191
+ // 兜底:把该目录当作仓库根(无 docs / pages,只能读源码)
192
+ return dir;
193
+ }
194
+ return null;
195
+ }
196
+
197
+ /** 解析本地知识点根目录 */
198
+ export function resolveLocalRoot(explicitRoot) {
199
+ const candidates = [];
200
+ if (explicitRoot) candidates.push(path.resolve(explicitRoot));
201
+ if (process.env.REVIEW_ROOT)
202
+ candidates.push(path.resolve(process.env.REVIEW_ROOT));
203
+ // 仓库内运行:review/mcp → review
204
+ candidates.push(path.resolve(PACKAGE_DIR, ".."));
205
+ // 当前项目已安装的组件(uni_modules 安装方式)
206
+ candidates.push(path.join(process.cwd(), COMPONENT_DIR));
207
+ candidates.push(process.cwd());
208
+ for (const candidate of candidates) {
209
+ if (!isReviewRoot(candidate)) continue;
210
+ const root = normalizeReviewRoot(candidate);
211
+ if (root) return root;
212
+ }
213
+ return null;
214
+ }
215
+
216
+ // ============================ 符号(函数)提取 ============================
217
+
218
+ /**
219
+ * 从起始大括号处匹配到配对的结束大括号
220
+ *
221
+ * 会跳过字符串('、"、`)、行注释与块注释,避免括号出现在字面量里导致错配。
222
+ */
223
+ export function matchBlock(text, openIndex) {
224
+ if (text[openIndex] !== "{") return openIndex;
225
+ let depth = 0;
226
+ for (let i = openIndex; i < text.length; i += 1) {
227
+ const char = text[i];
228
+ if (char === "/" && text[i + 1] === "/") {
229
+ const end = text.indexOf("\n", i);
230
+ i = end === -1 ? text.length : end;
231
+ continue;
232
+ }
233
+ if (char === "/" && text[i + 1] === "*") {
234
+ const end = text.indexOf("*/", i + 2);
235
+ i = end === -1 ? text.length : end + 1;
236
+ continue;
237
+ }
238
+ if (char === '"' || char === "'" || char === "`") {
239
+ for (let j = i + 1; j < text.length; j += 1) {
240
+ if (text[j] === "\\") {
241
+ j += 1;
242
+ continue;
243
+ }
244
+ if (text[j] === char) {
245
+ i = j;
246
+ break;
247
+ }
248
+ if (text[j] === "\n" && char !== "`") {
249
+ i = j - 1;
250
+ break;
251
+ }
252
+ }
253
+ continue;
254
+ }
255
+ if (char === "{") depth += 1;
256
+ else if (char === "}") {
257
+ depth -= 1;
258
+ if (depth === 0) return i;
259
+ }
260
+ }
261
+ return text.length - 1;
262
+ }
263
+
264
+ /** 匹配 `(...)` 的结束位置 */
265
+ function matchParen(text, openIndex) {
266
+ let depth = 0;
267
+ for (let i = openIndex; i < text.length; i += 1) {
268
+ const char = text[i];
269
+ if (char === '"' || char === "'" || char === "`") {
270
+ for (let j = i + 1; j < text.length; j += 1) {
271
+ if (text[j] === "\\") {
272
+ j += 1;
273
+ continue;
274
+ }
275
+ if (text[j] === char) {
276
+ i = j;
277
+ break;
278
+ }
279
+ }
280
+ continue;
281
+ }
282
+ if (char === "(") depth += 1;
283
+ else if (char === ")") {
284
+ depth -= 1;
285
+ if (depth === 0) return i;
286
+ }
287
+ }
288
+ return text.length - 1;
289
+ }
290
+
291
+ function escapeRegExp(value) {
292
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
293
+ }
294
+
295
+ function lineOf(text, index) {
296
+ return text.slice(0, index).split("\n").length;
297
+ }
298
+
299
+ /**
300
+ * 提取指定符号的完整定义(函数 / 常量 / 类 / 接口 / 类型)
301
+ *
302
+ * 同时适用于 `.uvue` / `.uts`:uts 的 `function name(...) : Type {}` 与
303
+ * `const name = computed(...)` 两类写法都能命中。
304
+ *
305
+ * @returns {{ name: string, kind: string, text: string, startLine: number, endLine: number } | null}
306
+ */
307
+ export function extractSymbol(text, name) {
308
+ const safe = escapeRegExp(name);
309
+ const patterns = [
310
+ {
311
+ kind: "function",
312
+ re: new RegExp(
313
+ `^[\\t ]*(?:export\\s+)?(?:default\\s+)?(?:async\\s+)?function\\s+(${safe})\\s*\\(`,
314
+ "m",
315
+ ),
316
+ },
317
+ {
318
+ kind: "const",
319
+ re: new RegExp(
320
+ `^[\\t ]*(?:export\\s+)?(?:const|let|var)\\s+(${safe})\\s*=`,
321
+ "m",
322
+ ),
323
+ },
324
+ {
325
+ kind: "class",
326
+ re: new RegExp(
327
+ `^[\\t ]*(?:export\\s+)?(?:default\\s+)?class\\s+(${safe})\\b`,
328
+ "m",
329
+ ),
330
+ },
331
+ {
332
+ kind: "interface",
333
+ re: new RegExp(
334
+ `^[\\t ]*(?:export\\s+)?(?:interface|type|enum)\\s+(${safe})\\b`,
335
+ "m",
336
+ ),
337
+ },
338
+ {
339
+ kind: "property",
340
+ re: new RegExp(`^[\\t ]*(${safe})\\s*[:=]\\s*(?:function|\\()`, "m"),
341
+ },
342
+ ];
343
+
344
+ for (const { kind, re } of patterns) {
345
+ const match = re.exec(text);
346
+ if (!match) continue;
347
+ const start = match.index;
348
+ let end;
349
+ if (kind === "function") {
350
+ const paren = text.indexOf("(", start);
351
+ const parenEnd = matchParen(text, paren);
352
+ const brace = text.indexOf("{", parenEnd);
353
+ // 无函数体(类型声明 / 重载签名)时截到行尾
354
+ end =
355
+ brace === -1 ? text.indexOf("\n", parenEnd) : matchBlock(text, brace);
356
+ if (end === -1) end = text.length - 1;
357
+ } else if (kind === "interface") {
358
+ const brace = text.indexOf("{", start);
359
+ const semi = text.indexOf(";", start);
360
+ if (brace !== -1 && (semi === -1 || brace < semi))
361
+ end = matchBlock(text, brace);
362
+ else end = semi === -1 ? text.indexOf("\n", start) : semi;
363
+ if (end === -1) end = text.length - 1;
364
+ } else if (kind === "class") {
365
+ const brace = text.indexOf("{", start);
366
+ end = brace === -1 ? text.length - 1 : matchBlock(text, brace);
367
+ } else {
368
+ // const / var / property:优先函数体,其次箭头函数体,最后到分号或行尾
369
+ const eq = text.indexOf("=", start);
370
+ const brace = text.indexOf("{", eq === -1 ? start : eq);
371
+ const semi = text.indexOf(";", eq === -1 ? start : eq);
372
+ const newline = text.indexOf("\n", eq === -1 ? start : eq);
373
+ if (brace !== -1 && (semi === -1 || brace < semi)) {
374
+ end = matchBlock(text, brace);
375
+ } else if (
376
+ eq !== -1 &&
377
+ text.slice(eq, eq + 12).includes("=>") &&
378
+ brace !== -1
379
+ ) {
380
+ end = matchBlock(text, brace);
381
+ } else if (
382
+ semi !== -1 &&
383
+ semi < (newline === -1 ? text.length : newline)
384
+ ) {
385
+ end = semi;
386
+ } else {
387
+ end = newline === -1 ? text.length - 1 : newline - 1;
388
+ }
389
+ }
390
+ const slice = text.slice(start, end + 1);
391
+ return {
392
+ name,
393
+ kind,
394
+ text: slice.replace(/\s+$/, ""),
395
+ startLine: lineOf(text, start),
396
+ endLine: lineOf(text, start + slice.length),
397
+ };
398
+ }
399
+ return null;
400
+ }
401
+
402
+ /** 列出源码中可提取的符号名(用于未命中时给出候选) */
403
+ export function listSymbols(text) {
404
+ const names = new Set();
405
+ const patterns = [
406
+ /^[\t ]*(?:export\s+)?(?:default\s+)?(?:async\s+)?function\s+([A-Za-z_$][\w$]*)/gm,
407
+ /^[\t ]*(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=/gm,
408
+ /^[\t ]*(?:export\s+)?(?:default\s+)?class\s+([A-Za-z_$][\w$]*)/gm,
409
+ /^[\t ]*(?:export\s+)?(?:interface|type|enum)\s+([A-Za-z_$][\w$]*)/gm,
410
+ ];
411
+ for (const re of patterns) {
412
+ for (const match of text.matchAll(re)) names.add(match[1]);
413
+ }
414
+ return [...names];
415
+ }
416
+
417
+ // ============================ 代码示例提取 ============================
418
+
419
+ /** 从 Markdown 中提取带标题上下文的代码块(官网兜底内容用) */
420
+ export function parseCodeExamples(markdown) {
421
+ const examples = [];
422
+ const lines = String(markdown ?? "").split("\n");
423
+ let heading = "";
424
+ for (let i = 0; i < lines.length; i += 1) {
425
+ const line = lines[i];
426
+ const headingMatch = line.match(/^(#{1,6})\s+(.*)$/);
427
+ if (headingMatch) heading = headingMatch[2].trim();
428
+ const fenceMatch = line.match(/^```(\w+)?\s*$/);
429
+ if (!fenceMatch) continue;
430
+ const lang = (fenceMatch[1] ?? "text").toLowerCase();
431
+ const body = [];
432
+ i += 1;
433
+ while (i < lines.length && !/^```\s*$/.test(lines[i])) {
434
+ body.push(lines[i]);
435
+ i += 1;
436
+ }
437
+ const code = body.join("\n").replace(/\s+$/, "");
438
+ if (code.trim() === "") continue;
439
+ examples.push({ title: heading || "未命名示例", lang, code, line: i });
440
+ }
441
+ return examples;
442
+ }
443
+
444
+ /** 匹配一段配对的 `<view ...>...</view>`(返回**不含**开始标签的内部内容与结束位置) */
445
+ function extractViewBlock(text, startIndex) {
446
+ const openEnd = text.indexOf(">", startIndex);
447
+ if (openEnd === -1) return null;
448
+ const re = /<view(?=[\s/])|<\/view>/g;
449
+ re.lastIndex = openEnd + 1;
450
+ let depth = 1;
451
+ let match;
452
+ while ((match = re.exec(text))) {
453
+ if (match[0] === "</view>") {
454
+ depth -= 1;
455
+ if (depth === 0)
456
+ return {
457
+ inner: text.slice(openEnd + 1, match.index),
458
+ end: re.lastIndex,
459
+ };
460
+ } else {
461
+ depth += 1;
462
+ }
463
+ }
464
+ return null;
465
+ }
466
+
467
+ /** 去掉每行公共缩进 */
468
+ function dedent(text) {
469
+ const lines = String(text ?? "").split("\n");
470
+ while (lines.length && lines[0].trim() === "") lines.shift();
471
+ while (lines.length && lines[lines.length - 1].trim() === "") lines.pop();
472
+ const indents = lines
473
+ .filter((line) => line.trim() !== "")
474
+ .map((line) => line.match(/^[ \t]*/)[0].replace(/\t/g, " ").length);
475
+ const min = indents.length ? Math.min(...indents) : 0;
476
+ return lines
477
+ .map((line) =>
478
+ line
479
+ .replace(/\t/g, " ")
480
+ .slice(min)
481
+ .replace(/[ \t]+$/, ""),
482
+ )
483
+ .join("\n");
484
+ }
485
+
486
+ /** 剥掉演示页的 demo-block 外壳,只留可直接复制的用法片段 */
487
+ export function stripDemoWrapper(chunk) {
488
+ const raw = String(chunk ?? "").trim();
489
+ let body = raw;
490
+ const outerStart = raw.indexOf('<view class="demo-block"');
491
+ if (outerStart !== -1) {
492
+ const outer = extractViewBlock(raw, outerStart);
493
+ if (outer) body = outer.inner;
494
+ }
495
+ body = body.replace(/<text class="demo-block__title">[\s\S]*?<\/text>/g, "");
496
+ const innerStart = body.indexOf('<view class="demo-block__content');
497
+ if (innerStart !== -1) {
498
+ const inner = extractViewBlock(body, innerStart);
499
+ if (inner) body = inner.inner;
500
+ }
501
+ return dedent(body);
502
+ }
503
+
504
+ /**
505
+ * 从演示页(`pages/<category>/<name>/<name>.uvue`)提取可复制示例
506
+ *
507
+ * ReView 的组件文档页(`docs/components/*.md`)**不含代码块**,官网「代码示例」区块
508
+ * 就是把演示页的模板按 `<!-- 小节标题 -->` 切分后展示的,因此这里做同样的切分。
509
+ *
510
+ * @param {string} code 演示页源码
511
+ * @param {{ name?: string }} [options]
512
+ * @returns {{ title: string, lang: string, code: string }[]}
513
+ */
514
+ export function parseDemoExamples(code, { name = "" } = {}) {
515
+ const source = String(code ?? "").replace(/\r\n/g, "\n");
516
+ const template = source.match(/<template>([\s\S]*?)<\/template>/)?.[1] ?? "";
517
+ const examples = [];
518
+
519
+ if (template) {
520
+ const markers = [...template.matchAll(/<!--\s*([\s\S]*?)\s*-->/g)];
521
+ for (let i = 0; i < markers.length; i += 1) {
522
+ const title = markers[i][1].replace(/\s+/g, " ").trim() || "示例";
523
+ const start = markers[i].index + markers[i][0].length;
524
+ const end =
525
+ i + 1 < markers.length ? markers[i + 1].index : template.length;
526
+ const snippet = stripDemoWrapper(template.slice(start, end));
527
+ if (snippet.trim() === "") continue;
528
+ examples.push({ title, lang: "uvue", code: snippet });
529
+ }
530
+ if (examples.length === 0) {
531
+ const snippet = stripDemoWrapper(template);
532
+ if (snippet.trim() !== "")
533
+ examples.push({
534
+ title: name ? `${name} 演示页模板` : "演示页模板",
535
+ lang: "uvue",
536
+ code: snippet,
537
+ });
538
+ }
539
+ }
540
+
541
+ const script = source.match(/<script[^>]*>([\s\S]*?)<\/script>/)?.[1]?.trim();
542
+ if (script)
543
+ examples.push({
544
+ title: name ? `${name} 演示页 script` : "演示页 script",
545
+ lang: "uts",
546
+ code: script,
547
+ });
548
+
549
+ return examples;
550
+ }
551
+
552
+ // ============================ 版本解析 ============================
553
+
554
+ /**
555
+ * 从 `CHANGELOG.md` 顶部解析版本号(`## 1.0.5(2026-09-14)`)
556
+ *
557
+ * @returns {{ version: string | null, date: string | null, source: string | null }}
558
+ */
559
+ export function parseChangelogVersion(markdown) {
560
+ const text = String(markdown ?? "");
561
+ const match = text.match(
562
+ /^##\s*v?(\d+\.\d+\.\d+[^\s((]*)\s*(?:[((]([^))]*)[))])?/m,
563
+ );
564
+ if (!match) return { version: null, date: null, source: null };
565
+ return { version: match[1], date: match[2] ?? null, source: null };
566
+ }
567
+
568
+ // ============================ 知识对象 ============================
569
+
570
+ /**
571
+ * 创建知识对象
572
+ *
573
+ * @param {{ root?: string, version?: string, allowSite?: boolean, logger?: (msg: string) => void }} options
574
+ */
575
+ export async function createKnowledge(options = {}) {
576
+ const logger = options.logger ?? (() => { });
577
+ const allowSite = options.allowSite !== false;
578
+ const requestedVersion = options.version
579
+ ? String(options.version).trim()
580
+ : null;
581
+ const root = resolveLocalRoot(options.root);
582
+ const catalog = createCatalog(root ?? path.resolve(PACKAGE_DIR, ".."));
583
+
584
+ let source = "workspace";
585
+ let warning = null;
586
+ if (!root) {
587
+ if (allowSite) {
588
+ warning =
589
+ "未找到本地 ReView 知识源(既未指定 --root,也未在项目内发现 uni_modules/reglow-review)," +
590
+ "文档将回退到官方文档站点(最新版)";
591
+ source = "site";
592
+ } else {
593
+ warning = "未找到本地 ReView 知识源,且已禁用官网回退";
594
+ source = "none";
595
+ }
596
+ // 没有本地根目录时,目录只能来自官网总览页(惰性拉取,失败则为空)
597
+ } else if (!fs.existsSync(path.join(root, DOCS_DIR))) {
598
+ // 只装了 uni_modules/reglow-review:只有源码,没有仓库级文档与演示页
599
+ source = "local";
600
+ warning =
601
+ "当前知识源只包含 `uni_modules/reglow-review` 安装包(无 `docs/` 与 `pages/`):" +
602
+ "源码可用,组件文档与官方链接将回退到官网文档站点";
603
+ } else {
604
+ // 完整仓库(含 docs/ 与 pages/)
605
+ source = "workspace";
606
+ }
607
+ if (requestedVersion) {
608
+ const hint = `${NPM_NOT_PUBLISHED_HINT}(收到 version=${requestedVersion},已忽略)`;
609
+ warning = warning ? `${warning};${hint}` : hint;
610
+ }
611
+
612
+ // ---- 版本号:CHANGELOG.md 顶部为准 ----
613
+ const changelogPath = root ? path.join(root, CHANGELOG_FILE) : null;
614
+ const changelogText = changelogPath ? readTextSafe(changelogPath) : null;
615
+ const parsedVersion = parseChangelogVersion(changelogText ?? "");
616
+ let version = parsedVersion.version;
617
+ let versionDate = parsedVersion.date;
618
+ let versionSource = version ? CHANGELOG_FILE : null;
619
+ if (!version && root) {
620
+ try {
621
+ const pkg = JSON.parse(
622
+ fs.readFileSync(path.join(root, "package.json"), "utf8"),
623
+ );
624
+ if (pkg?.version) {
625
+ version = String(pkg.version);
626
+ versionSource = "package.json";
627
+ }
628
+ } catch {
629
+ /* 忽略 */
630
+ }
631
+ }
632
+ if (!version) {
633
+ version = "unknown";
634
+ versionSource = null;
635
+ }
636
+
637
+ function readFileByRel(relPath) {
638
+ if (!root) return null;
639
+ const target = path.resolve(root, relPath);
640
+ if (!target.startsWith(path.resolve(root))) return null;
641
+ return readTextSafe(target, MAX_READ_BYTES);
642
+ }
643
+
644
+ function entryOf(name) {
645
+ return catalog.catalogItem(name);
646
+ }
647
+
648
+ /** 组件 readme(脚本生成的 readme 存在乱码时不可用) */
649
+ function readmeOf(entry) {
650
+ if (!entry?.dir) return null;
651
+ const rel = `${entry.dir}/readme.md`;
652
+ const text = readFileByRel(rel);
653
+ if (!text || isGarbledText(text)) return null;
654
+ return { path: rel, text };
655
+ }
656
+
657
+ /** 汇总组件源码(文档缺失时的最后兜底:Props / Events 一目了然) */
658
+ function sourceDigest(entry) {
659
+ const mainFiles = entry.files.filter((file) => file.endsWith(".uvue"));
660
+ const chunks = mainFiles.map((file) => {
661
+ const text = readFileByRel(`${entry.dir}/${file}`);
662
+ if (!text) return null;
663
+ const lines = Math.min(lineCount(text), 400);
664
+ const head = text.split("\n").slice(0, lines).join("\n");
665
+ return `### ${file}(前 ${lines} 行)\n\n\`\`\`uvue\n${head}\n\`\`\``;
666
+ });
667
+ return chunks.filter(Boolean).join("\n\n");
668
+ }
669
+
670
+ const knowledge = {
671
+ catalog,
672
+
673
+ meta() {
674
+ return {
675
+ version,
676
+ versionDate,
677
+ versionSource,
678
+ source,
679
+ root: root ? toPosix(root) : null,
680
+ requestedVersion,
681
+ warning,
682
+ docsBase: DOCS_BASE,
683
+ changelogUrl: changelogUrlFor(),
684
+ };
685
+ },
686
+
687
+ /** 组件清单 */
688
+ listComponents({ category } = {}) {
689
+ const all = catalog.components;
690
+ if (!category) return all;
691
+ const key = String(category).trim();
692
+ const def = catalog.categoryOf(key);
693
+ return all.filter(
694
+ (entry) =>
695
+ entry.category === key ||
696
+ entry.categoryLabel === key ||
697
+ (def && entry.categoryLabel === def.label),
698
+ );
699
+ },
700
+
701
+ /** 组件条目 */
702
+ getComponent(name) {
703
+ return entryOf(name);
704
+ },
705
+
706
+ /**
707
+ * 组件文档
708
+ * @returns {Promise<{ markdown: string, kind: "doc"|"readme"|"source"|"site"|"none", path: string|null, url: string|null }>}
709
+ */
710
+ async getDocument(name) {
711
+ const entry = entryOf(name);
712
+ const url = entry?.docsUrl ?? null;
713
+ const doc = entry?.docPath ? readFileByRel(entry.docPath) : null;
714
+ if (doc) {
715
+ return {
716
+ markdown: doc,
717
+ kind: "doc",
718
+ path: entry.docPath,
719
+ url: entry.docsUrl,
720
+ };
721
+ }
722
+
723
+ const readme = readmeOf(entry);
724
+ if (readme) {
725
+ return {
726
+ markdown: readme.text,
727
+ kind: "readme",
728
+ path: readme.path,
729
+ url,
730
+ };
731
+ }
732
+
733
+ const siteDoc = allowSite
734
+ ? await fetchSiteDocument(entry?.name ?? String(name), {
735
+ category: entry?.category ?? null,
736
+ })
737
+ : null;
738
+ if (siteDoc) {
739
+ return {
740
+ markdown: siteDoc.markdown,
741
+ kind: "site",
742
+ path: null,
743
+ url: siteDoc.url,
744
+ };
745
+ }
746
+
747
+ const digest = entry ? sourceDigest(entry) : "";
748
+ if (digest) {
749
+ return {
750
+ markdown: digest,
751
+ kind: "source",
752
+ path: entry.dir,
753
+ url: entry.docsUrl,
754
+ };
755
+ }
756
+ return { markdown: "", kind: "none", path: null, url };
757
+ },
758
+
759
+ /**
760
+ * 代码示例(优先演示页,缺失时走官网兜底)
761
+ *
762
+ * @param {string} name 组件名
763
+ * @param {{ index?: number, lang?: string }} [options]
764
+ */
765
+ async getCodeExamples(name, { index, lang } = {}) {
766
+ const entry = entryOf(name);
767
+ const filter = (items) =>
768
+ !lang || lang === "all"
769
+ ? items
770
+ : items.filter((item) => item.lang === lang);
771
+ const pick = (items) =>
772
+ index === undefined ? items : items.slice(index, index + 1);
773
+
774
+ const demo = entry?.demoPath ? readFileByRel(entry.demoPath) : null;
775
+ if (demo) {
776
+ const examples = filter(parseDemoExamples(demo, { name: entry.name }));
777
+ if (examples.length > 0) {
778
+ return {
779
+ examples: pick(examples),
780
+ source: "demo",
781
+ path: entry.demoPath,
782
+ url: entry.docsUrl,
783
+ };
784
+ }
785
+ }
786
+
787
+ const doc = entry?.docPath ? readFileByRel(entry.docPath) : null;
788
+ if (doc) {
789
+ const examples = filter(parseCodeExamples(doc));
790
+ if (examples.length > 0) {
791
+ return {
792
+ examples: pick(examples),
793
+ source: "doc",
794
+ path: entry.docPath,
795
+ url: entry.docsUrl,
796
+ };
797
+ }
798
+ }
799
+
800
+ if (allowSite) {
801
+ const siteDoc = await fetchSiteDocument(entry?.name ?? String(name), {
802
+ category: entry?.category ?? null,
803
+ });
804
+ if (siteDoc) {
805
+ const examples = filter(parseCodeExamples(siteDoc.markdown));
806
+ if (examples.length > 0) {
807
+ return {
808
+ examples: pick(examples),
809
+ source: "site",
810
+ path: null,
811
+ url: siteDoc.url,
812
+ };
813
+ }
814
+ }
815
+ }
816
+
817
+ return {
818
+ examples: [],
819
+ source: "none",
820
+ path: null,
821
+ url: entry?.docsUrl ?? null,
822
+ };
823
+ },
824
+
825
+ /** 组件文件清单 */
826
+ listFiles(name) {
827
+ const entry = entryOf(name);
828
+ if (!entry) return null;
829
+ if (!entry.dir) return [];
830
+ return entry.files.map((file) => {
831
+ const rel = `${entry.dir}/${file}`;
832
+ const abs = root ? path.join(root, rel) : null;
833
+ let size = 0;
834
+ let lines = 0;
835
+ if (abs) {
836
+ try {
837
+ const stat = fs.statSync(abs);
838
+ size = stat.size;
839
+ if (isTextFile(file) && size <= MAX_READ_BYTES) {
840
+ const text = fs.readFileSync(abs, "utf8");
841
+ if (!isGarbledText(text)) lines = lineCount(text);
842
+ }
843
+ } catch {
844
+ /* 忽略 */
845
+ }
846
+ }
847
+ return { path: file, size, lines };
848
+ });
849
+ },
850
+
851
+ /** 读取组件内文件(带路径穿越防护) */
852
+ readComponentFile(name, file, { startLine, endLine } = {}) {
853
+ const entry = entryOf(name);
854
+ if (!entry) return { error: `未找到组件「${name}」` };
855
+ if (!entry.dir)
856
+ return {
857
+ error:
858
+ `组件「${entry.name}」在仓库里没有独立源码目录(该文档页对应 ` +
859
+ `${(entry.tags ?? []).map((tag) => `\`${tag}\``).join(" / ") || "多个组件"}),` +
860
+ `可用组件见 list_component_files 的同类组件。`,
861
+ };
862
+ const normalized = toPosix(String(file ?? "")).replace(/^\.\//, "");
863
+ const componentRoot = path.resolve(root ?? "", entry.dir);
864
+ const target = path.resolve(componentRoot, normalized);
865
+ // 必须留在组件目录内(`..` / 绝对路径都会被拦下)
866
+ if (!root || !target.startsWith(componentRoot + path.sep)) {
867
+ return { error: "非法文件路径(只能读取组件目录内的文件)" };
868
+ }
869
+ const rel = toPosix(path.relative(root, target));
870
+ let text;
871
+ try {
872
+ const stat = fs.statSync(target);
873
+ if (!stat.isFile()) return { error: `不是文件:${normalized}` };
874
+ if (stat.size > MAX_READ_BYTES)
875
+ return { error: `文件过大(>${MAX_READ_BYTES} 字节)` };
876
+ text = fs.readFileSync(target, "utf8");
877
+ } catch {
878
+ const available = entry.files.slice(0, 40).join("\n- ");
879
+ return {
880
+ error: `文件不存在:${normalized}\n\n该组件可用文件:\n- ${available}`,
881
+ };
882
+ }
883
+ const total = lineCount(text);
884
+ let output = text;
885
+ let from = 1;
886
+ let to = total;
887
+ if (startLine || endLine) {
888
+ from = Math.max(1, Number(startLine ?? 1));
889
+ to = Math.min(total, Number(endLine ?? total));
890
+ output = text
891
+ .split("\n")
892
+ .slice(from - 1, to)
893
+ .map((line, i) => `${from + i}: ${line}`)
894
+ .join("\n");
895
+ }
896
+ return {
897
+ path: rel,
898
+ text: output,
899
+ totalLines: total,
900
+ startLine: from,
901
+ endLine: to,
902
+ garbled: isGarbledText(text),
903
+ };
904
+ },
905
+
906
+ /** 提取函数 / 常量 / 类定义 */
907
+ findFunction(name, symbol, { file } = {}) {
908
+ const entry = entryOf(name);
909
+ if (!entry) return { error: `未找到组件「${name}」` };
910
+ if (!entry.dir)
911
+ return { error: `组件「${entry.name}」没有独立源码目录,无法提取符号` };
912
+ const candidates = file
913
+ ? [toPosix(file)]
914
+ : entry.files.filter(
915
+ (item) =>
916
+ /\.(uvue|uts|vue|ts|mjs|js)$/.test(item) &&
917
+ !item.endsWith(".d.ts"),
918
+ );
919
+ const searched = [];
920
+ for (const candidate of candidates) {
921
+ const text = readFileByRel(`${entry.dir}/${candidate}`);
922
+ if (!text) continue;
923
+ searched.push(candidate);
924
+ const found = extractSymbol(text, symbol);
925
+ if (found) {
926
+ return {
927
+ ...found,
928
+ path: `${entry.dir}/${candidate}`,
929
+ component: entry.name,
930
+ };
931
+ }
932
+ }
933
+ const symbols = new Set();
934
+ for (const candidate of searched) {
935
+ const text = readFileByRel(`${entry.dir}/${candidate}`);
936
+ if (!text) continue;
937
+ listSymbols(text).forEach((item) => symbols.add(item));
938
+ }
939
+ const all = [...symbols].sort((a, b) =>
940
+ a.localeCompare(b, "en", { sensitivity: "base" }),
941
+ );
942
+ const similar = all.filter((item) =>
943
+ item.toLowerCase().includes(String(symbol).toLowerCase()),
944
+ );
945
+ return {
946
+ error: `未在组件「${entry.name}」中找到符号「${symbol}」`,
947
+ searched: searched.map((item) => `${entry.dir}/${item}`),
948
+ availableSymbols: all.slice(0, 300),
949
+ similarSymbols: similar,
950
+ allSymbolsCount: all.length,
951
+ };
952
+ },
953
+
954
+ /** 跨组件文档 / 源码检索 */
955
+ search(query, { category, limit = 20, component } = {}) {
956
+ const terms = String(query ?? "")
957
+ .split(/\s+/)
958
+ .map((term) => term.trim().toLowerCase())
959
+ .filter(Boolean);
960
+ if (terms.length === 0) return { hits: [], scanned: 0, terms };
961
+ if (!root) return { hits: [], scanned: 0, terms };
962
+
963
+ const entries = component
964
+ ? [entryOf(component)].filter(Boolean)
965
+ : knowledge.listComponents({ category });
966
+ const targets = [];
967
+ for (const entry of entries) {
968
+ if (entry.docPath) targets.push({ entry, file: entry.docPath });
969
+ if (entry.demoPath) targets.push({ entry, file: entry.demoPath });
970
+ for (const item of entry.files) {
971
+ // `readme.md` 多为脚本生成的二次编码文本(乱码),不参与检索
972
+ if (item === "readme.md") continue;
973
+ if (!isTextFile(item)) continue;
974
+ targets.push({ entry, file: `${entry.dir}/${item}` });
975
+ }
976
+ }
977
+ // 库级文档(仅在未限定组件时参与检索,避免污染组件范围的检索结果)
978
+ const libraryFiles = component
979
+ ? []
980
+ : [
981
+ README_FILE,
982
+ CHANGELOG_FILE,
983
+ "docs/index.md",
984
+ ...(fs.existsSync(path.join(root, GUIDE_DIR))
985
+ ? fs
986
+ .readdirSync(path.join(root, GUIDE_DIR))
987
+ .filter((file) => file.endsWith(".md"))
988
+ .map((file) => `${GUIDE_DIR}/${file}`)
989
+ : []),
990
+ ];
991
+ for (const extra of libraryFiles) {
992
+ if (fs.existsSync(path.join(root, extra))) {
993
+ targets.push({
994
+ entry: { name: "review", category: "library", docsUrl: DOCS_BASE },
995
+ file: extra,
996
+ });
997
+ }
998
+ }
999
+
1000
+ const hits = [];
1001
+ let scanned = 0;
1002
+ for (const target of targets) {
1003
+ if (hits.length >= limit) break;
1004
+ const abs = path.join(root, target.file);
1005
+ let text;
1006
+ try {
1007
+ const stat = fs.statSync(abs);
1008
+ if (!stat.isFile() || stat.size > MAX_SEARCH_FILE_BYTES) continue;
1009
+ text = fs.readFileSync(abs, "utf8");
1010
+ } catch {
1011
+ continue;
1012
+ }
1013
+ scanned += 1;
1014
+ const lower = text.toLowerCase();
1015
+ if (!terms.every((term) => lower.includes(term))) continue;
1016
+ const lines = text.split("\n");
1017
+ let found = 0;
1018
+ for (
1019
+ let i = 0;
1020
+ i < lines.length && found < 3 && hits.length < limit;
1021
+ i += 1
1022
+ ) {
1023
+ const lineLower = lines[i].toLowerCase();
1024
+ if (!terms.some((term) => lineLower.includes(term))) continue;
1025
+ const from = Math.max(0, i - 1);
1026
+ const to = Math.min(lines.length, i + 2);
1027
+ hits.push({
1028
+ component: target.entry.name,
1029
+ path: toPosix(target.file),
1030
+ line: i + 1,
1031
+ text: lines.slice(from, to).join("\n"),
1032
+ docsUrl: target.entry.docsUrl ?? null,
1033
+ });
1034
+ found += 1;
1035
+ }
1036
+ }
1037
+ return { hits, scanned, terms };
1038
+ },
1039
+
1040
+ /**
1041
+ * 库级指南(安装 / easycom / 主题 / 国际化 / 平台 / 版本记录 / 约定 等章节)
1042
+ *
1043
+ * @param {string} topic GUIDE_TOPICS 的 key
1044
+ */
1045
+ getGuide(topic) {
1046
+ if (!root) return null;
1047
+ const key = String(topic ?? "overview").trim() || "overview";
1048
+ const def = GUIDE_TOPICS.find((item) => item.key === key);
1049
+ const files = def?.files ?? [README_FILE];
1050
+ const sources = files
1051
+ .map((file) => ({ file, text: readFileByRel(file) }))
1052
+ .filter((item) => Boolean(item.text));
1053
+ if (sources.length === 0) return null;
1054
+
1055
+ const sectionsOf = (file, text) =>
1056
+ text
1057
+ .split(/\n(?=#{1,3}\s)/)
1058
+ .map((chunk) => ({
1059
+ file,
1060
+ title: chunk.match(/^#{1,3}\s+(.*)$/m)?.[1]?.trim() ?? "",
1061
+ text: chunk.trim(),
1062
+ }))
1063
+ .filter((section) => section.text !== "");
1064
+
1065
+ let sections = sources.flatMap((item) =>
1066
+ sectionsOf(item.file, item.text),
1067
+ );
1068
+ const keywords = def?.headings;
1069
+ if (keywords?.length) {
1070
+ const matched = sections.filter((section) => {
1071
+ const haystack =
1072
+ `${section.title}\n${section.text.slice(0, 400)}`.toLowerCase();
1073
+ return keywords.some((keyword) =>
1074
+ haystack.includes(String(keyword).toLowerCase()),
1075
+ );
1076
+ });
1077
+ // 关键词没命中任何章节时退回该 topic 的全部章节(避免返回空)
1078
+ if (matched.length > 0) sections = matched;
1079
+ }
1080
+ if (sections.length === 0) return null;
1081
+ return {
1082
+ sections,
1083
+ docsBase: DOCS_BASE,
1084
+ url: guideUrlFor(
1085
+ {
1086
+ install: "introduction",
1087
+ easycom: "introduction",
1088
+ theme: "theme",
1089
+ i18n: "locale",
1090
+ }[key] ?? "",
1091
+ ),
1092
+ };
1093
+ },
1094
+
1095
+ /** 官网组件总览(回退用) */
1096
+ async siteOverview() {
1097
+ if (!allowSite) return null;
1098
+ return fetchSiteOverview();
1099
+ },
1100
+
1101
+ /** 官网指南页(回退用) */
1102
+ async siteGuide(slug) {
1103
+ if (!allowSite) return null;
1104
+ return fetchSiteGuide(slug);
1105
+ },
1106
+
1107
+ /** 官网版本记录页(回退用) */
1108
+ async siteChangelog() {
1109
+ if (!allowSite) return null;
1110
+ return fetchSiteChangelog();
1111
+ },
1112
+ };
1113
+
1114
+ logger(
1115
+ `[review-mcp] 知识源:source=${source} version=${version} root=${root ?? "(none)"}` +
1116
+ (warning ? ` warning=${warning}` : ""),
1117
+ );
1118
+ return knowledge;
1119
+ }
1120
+
1121
+ export {
1122
+ CATEGORIES,
1123
+ CHANGELOG_FILE,
1124
+ COMPONENT_DIR,
1125
+ DOCS_BASE,
1126
+ DOCS_DIR,
1127
+ GUIDE_DIR,
1128
+ PACKAGE_DIR,
1129
+ createCatalog,
1130
+ };