@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/catalog.mjs ADDED
@@ -0,0 +1,537 @@
1
+ /**
2
+ * ReView 组件目录(内置于 MCP 服务器)
3
+ *
4
+ * ReView 组件库未发布到 npm,知识源是「本地仓库」,因此组件目录**从仓库实时解析**,
5
+ * 而不是像 reui 那样内置静态清单。三个权威来源:
6
+ * - 分类归属:`docs/.vitepress/sidebar.ts`(VitePress 侧边栏,9 个分类的权威定义)
7
+ * - 组件文档:`docs/components/*.md`(文档文件名即组件名,与官网 URL 末段一致)
8
+ * - 组件源码:`uni_modules/reglow-review/components/re-*`(easycom 标签为目录名)
9
+ * - 演示页:`pages/<category>/<name>/<name>.uvue`(官网「代码示例」的来源)
10
+ *
11
+ * 用途:
12
+ * - 未指定 component 时返回「按分类的组件总览」;
13
+ * - 为每个组件生成官方文档深链(human 可读的权威来源);
14
+ * - 仓库内缺少组件文档时,作为回退抓取官网的路径映射。
15
+ */
16
+
17
+ import fs from "node:fs";
18
+ import path from "node:path";
19
+
20
+ /** 官方文档站点基地址 */
21
+ export const DOCS_BASE = "https://reglow.huoli.tech/components-library/review";
22
+ /** 官网版本记录页 */
23
+ export const CHANGELOG_URL = `${DOCS_BASE}/changelog`;
24
+ /** 官网指南页前缀(快速开始 / 主题定制 / 国际化) */
25
+ export const GUIDE_BASE = `${DOCS_BASE}/getting-started`;
26
+
27
+ /** 仓库内的关键路径(相对仓库根,posix 分隔符) */
28
+ export const SIDEBAR_FILE = "docs/.vitepress/sidebar.ts";
29
+ export const DOCS_DIR = "docs/components";
30
+ export const GUIDE_DIR = "docs/guide";
31
+ export const COMPONENT_DIR = "uni_modules/reglow-review/components";
32
+ export const DEMO_DIR = "pages";
33
+ export const CHANGELOG_FILE = "CHANGELOG.md";
34
+ export const README_FILE = "README.md";
35
+ /** 组件目录 / easycom 标签前缀 */
36
+ export const COMPONENT_PREFIX = "re-";
37
+
38
+ /**
39
+ * 分类清单:key 为官网路由段(= `content/components-library/2.review/3.components/{n}.<key>` 去掉数字前缀),
40
+ * `sidebarText` 为 `docs/.vitepress/sidebar.ts` 中的分类标题(解析归属时用它匹配)。
41
+ *
42
+ * `module` 在侧边栏里写作「全局配置 Config」(只列了 config-provider),官网分类页口径为「基础模块」
43
+ * (还含 re-common / re-theme),这里取官网口径作为 label。
44
+ */
45
+ export const CATEGORIES = [
46
+ { key: "base", label: "基础", english: "Basic", sidebarText: "基础 Basic" },
47
+ {
48
+ key: "navigation",
49
+ label: "导航",
50
+ english: "Navigation",
51
+ sidebarText: "导航 Navigation",
52
+ },
53
+ { key: "input", label: "输入", english: "Input", sidebarText: "输入 Input" },
54
+ {
55
+ key: "display",
56
+ label: "数据展示",
57
+ english: "Data Display",
58
+ sidebarText: "数据展示 Data Display",
59
+ },
60
+ {
61
+ key: "feedback",
62
+ label: "反馈",
63
+ english: "Feedback",
64
+ sidebarText: "反馈 Feedback",
65
+ },
66
+ {
67
+ key: "list-layout",
68
+ label: "列表与布局",
69
+ english: "List & Layout",
70
+ sidebarText: "列表与布局 List & Layout",
71
+ },
72
+ {
73
+ key: "marketing",
74
+ label: "营销",
75
+ english: "Marketing",
76
+ sidebarText: "营销 Marketing",
77
+ },
78
+ {
79
+ key: "business",
80
+ label: "业务",
81
+ english: "Business",
82
+ sidebarText: "业务 Business",
83
+ },
84
+ {
85
+ key: "module",
86
+ label: "基础模块",
87
+ english: "Module",
88
+ sidebarText: "全局配置 Config",
89
+ },
90
+ ];
91
+
92
+ /** 官网 `pages/<key>/` 使用的分类目录名(与 CATEGORIES.key 一致) */
93
+ export const CATEGORY_KEYS = CATEGORIES.map((item) => item.key);
94
+ /** 未收录于侧边栏时的兜底分类 */
95
+ export const OTHER_CATEGORY = "other";
96
+
97
+ /**
98
+ * 一个文档页对应多个 easycom 标签的特殊情况
99
+ * (`docs/components/layout.md` 描述的是 `re-row` + `re-col`,仓库里没有 `re-layout` 目录)
100
+ */
101
+ export const TAG_ALIASES = {
102
+ layout: ["re-row", "re-col"],
103
+ };
104
+
105
+ // ============================ 通用文件工具 ============================
106
+
107
+ /** 统一使用 posix 分隔符(便于跨平台比对与展示) */
108
+ export function toPosix(value) {
109
+ return String(value ?? "")
110
+ .split(path.sep)
111
+ .join("/");
112
+ }
113
+
114
+ /** 读取 JSON(失败返回 null) */
115
+ export function readJsonSafe(file) {
116
+ try {
117
+ return JSON.parse(fs.readFileSync(file, "utf8"));
118
+ } catch {
119
+ return null;
120
+ }
121
+ }
122
+
123
+ /**
124
+ * 递归读取目录下所有文件(相对路径,posix 分隔符)
125
+ *
126
+ * 默认跳过隐藏目录、`__tests__` 与 `*.test.js`(ReView 的组件单测也被排除)。
127
+ */
128
+ export function walkFiles(dir, { skipTests = true, base = dir } = {}) {
129
+ const result = [];
130
+ let entries;
131
+ try {
132
+ entries = fs.readdirSync(dir, { withFileTypes: true });
133
+ } catch {
134
+ return result;
135
+ }
136
+ for (const entry of entries) {
137
+ if (entry.name.startsWith(".")) continue;
138
+ if (
139
+ skipTests &&
140
+ (entry.name === "__tests__" || entry.name.endsWith(".test.js"))
141
+ )
142
+ continue;
143
+ const full = path.join(dir, entry.name);
144
+ if (entry.isDirectory())
145
+ result.push(...walkFiles(full, { skipTests, base }));
146
+ else if (entry.isFile()) result.push(toPosix(path.relative(base, full)));
147
+ }
148
+ return result.sort();
149
+ }
150
+
151
+ /** 目录是否存在 */
152
+ function isDir(target) {
153
+ try {
154
+ return fs.statSync(target).isDirectory();
155
+ } catch {
156
+ return false;
157
+ }
158
+ }
159
+
160
+ /** 文件是否存在 */
161
+ function isFile(target) {
162
+ try {
163
+ return fs.statSync(target).isFile();
164
+ } catch {
165
+ return false;
166
+ }
167
+ }
168
+
169
+ /** 安全读取文本文件(不存在 / 过大时返回 null) */
170
+ function readTextSafe(file, maxBytes = 2 * 1024 * 1024) {
171
+ try {
172
+ const stat = fs.statSync(file);
173
+ if (!stat.isFile() || stat.size > maxBytes) return null;
174
+ return fs.readFileSync(file, "utf8");
175
+ } catch {
176
+ return null;
177
+ }
178
+ }
179
+
180
+ // ============================ 文档链接 ============================
181
+
182
+ /**
183
+ * 组件官网文档链接
184
+ *
185
+ * 与 reui 版不同的是:ReView 的 URL 需要分类段,因此 `category` 必传
186
+ * (分类未知时返回 null,由 `site.mjs` 从官网总览页反查)。
187
+ */
188
+ export function docsUrlFor(name, category) {
189
+ const key = String(name ?? "").trim();
190
+ const group = String(category ?? "").trim();
191
+ if (!key || !group) return null;
192
+ return `${DOCS_BASE}/components/${group}/${key}`;
193
+ }
194
+
195
+ /** 官网分类总览链接 */
196
+ export function categoryUrlFor(category) {
197
+ const group = String(category ?? "").trim();
198
+ return group ? `${DOCS_BASE}/components/${group}` : `${DOCS_BASE}/components`;
199
+ }
200
+
201
+ /** 官网版本记录页链接 */
202
+ export function changelogUrlFor() {
203
+ return CHANGELOG_URL;
204
+ }
205
+
206
+ /** 官网指南页链接(topic → 路径段) */
207
+ export function guideUrlFor(slug) {
208
+ const key = String(slug ?? "").trim();
209
+ return key ? `${GUIDE_BASE}/${key}` : GUIDE_BASE;
210
+ }
211
+
212
+ /** 组件中文名(无中文名时回退组件名) */
213
+ export function componentLabel(entry) {
214
+ if (entry == null) return "";
215
+ if (typeof entry === "string") return entry;
216
+ const name = String(entry.name ?? "");
217
+ const cn = String(entry.cn ?? "");
218
+ return cn ? `${name} ${cn}` : name;
219
+ }
220
+
221
+ // ============================ sidebar.ts 解析 ============================
222
+
223
+ /** 分类标题 → 分类 key */
224
+ export function categoryKeyForText(text) {
225
+ const raw = String(text ?? "").trim();
226
+ const exact = CATEGORIES.find((item) => item.sidebarText === raw);
227
+ if (exact) return exact.key;
228
+ // 退一步:只用英文部分匹配(如 "Basic" / "List & Layout")
229
+ const english = raw.replace(/[\u4e00-\u9fff]/g, "").trim();
230
+ const byEnglish = CATEGORIES.find(
231
+ (item) => item.english.toLowerCase() === english.toLowerCase(),
232
+ );
233
+ if (byEnglish) return byEnglish.key;
234
+ const slug = english
235
+ .toLowerCase()
236
+ .replace(/[^a-z0-9]+/g, "-")
237
+ .replace(/^-|-$/g, "");
238
+ return slug || OTHER_CATEGORY;
239
+ }
240
+
241
+ /** 侧边栏条目标题 → 中文名("Button 按钮" → "按钮") */
242
+ export function chineseNameFromText(text) {
243
+ const raw = String(text ?? "").trim();
244
+ const match = raw.match(/^([A-Za-z][A-Za-z0-9\s&.-]*?)\s+([^\x00-\x7F].*)$/);
245
+ return match ? match[2].trim() : "";
246
+ }
247
+
248
+ /**
249
+ * 解析 `docs/.vitepress/sidebar.ts`(按行状态机,不用正则硬啃整个文件)
250
+ *
251
+ * 结构约定(文件内实际写法):
252
+ * - 分类:`text: "基础 Basic",` 紧随其后的一行是 `collapsed: ...`
253
+ * - 条目:单行 `{ text: "Button 按钮", link: "/components/button" },`
254
+ * 或多行 `text: "DateTimePicker ...",` + `link: "/components/date-time-picker",`
255
+ *
256
+ * @returns {{ key: string, label: string, english: string, text: string, items: { name: string, cn: string }[] }[]}
257
+ */
258
+ export function parseSidebarCategories(source) {
259
+ const lines = String(source ?? "").split(/\r?\n/);
260
+ const categories = [];
261
+ let current = null;
262
+
263
+ const nextMeaningfulLine = (from) => {
264
+ for (let i = from; i < lines.length; i += 1) {
265
+ if (lines[i].trim()) return lines[i].trim();
266
+ }
267
+ return "";
268
+ };
269
+ const pushItem = (text, link) => {
270
+ if (!current) return;
271
+ const name = String(link ?? "").match(/\/components\/([^/?#]+)\/?$/)?.[1];
272
+ if (!name) return;
273
+ if (current.items.some((item) => item.name === name)) return;
274
+ current.items.push({ name, cn: chineseNameFromText(text) });
275
+ };
276
+
277
+ for (let i = 0; i < lines.length; i += 1) {
278
+ const single = lines[i].match(
279
+ /\{\s*text:\s*"([^"]+)"\s*,\s*link:\s*"([^"]+)"\s*\}/,
280
+ );
281
+ if (single) {
282
+ pushItem(single[1], single[2]);
283
+ continue;
284
+ }
285
+ const bare = lines[i].match(/^\s*text:\s*"([^"]+)"\s*,?\s*$/);
286
+ if (!bare) continue;
287
+ const next = nextMeaningfulLine(i + 1);
288
+ const link = next.match(/^link:\s*"([^"]+)"/)?.[1];
289
+ if (link) {
290
+ pushItem(bare[1], link);
291
+ } else if (/^collapsed:/.test(next)) {
292
+ const def = CATEGORIES.find(
293
+ (item) => item.key === categoryKeyForText(bare[1]),
294
+ );
295
+ current = {
296
+ key: categoryKeyForText(bare[1]),
297
+ label: def?.label ?? bare[1],
298
+ english: def?.english ?? "",
299
+ text: bare[1].trim(),
300
+ items: [],
301
+ };
302
+ categories.push(current);
303
+ }
304
+ }
305
+ return categories;
306
+ }
307
+
308
+ // ============================ 文档正文小工具 ============================
309
+
310
+ /** 取 Markdown 的一级标题 */
311
+ export function firstHeading(markdown) {
312
+ return (
313
+ String(markdown ?? "")
314
+ .match(/^#\s+(.+)$/m)?.[1]
315
+ ?.trim() ?? ""
316
+ );
317
+ }
318
+
319
+ /** 取 Markdown 首个正文段落(跳过标题 / 表格 / 引用 / 代码块) */
320
+ export function firstParagraph(markdown) {
321
+ const lines = String(markdown ?? "").split(/\r?\n/);
322
+ const buffer = [];
323
+ let started = false;
324
+ for (const line of lines) {
325
+ const text = line.trim();
326
+ if (!started) {
327
+ if (text.startsWith("#")) started = true;
328
+ continue;
329
+ }
330
+ if (text === "") {
331
+ if (buffer.length) break;
332
+ continue;
333
+ }
334
+ if (/^(#|\||>|```|<\/|<|[-*+]\s)/.test(text)) break;
335
+ buffer.push(text);
336
+ }
337
+ return buffer.join(" ").trim();
338
+ }
339
+
340
+ /** 文档标题中的中文名("# 按钮 Button" → "按钮") */
341
+ export function chineseNameFromHeading(heading) {
342
+ const raw = String(heading ?? "").trim();
343
+ const match = raw.match(
344
+ /^([^\x00-\x7F][^\x00-\x7F\s]*)\s+[A-Za-z0-9\s&.-]+$/,
345
+ );
346
+ return match ? match[1] : "";
347
+ }
348
+
349
+ // ============================ 目录构建 ============================
350
+
351
+ /** 扫描 `pages/<category>/<name>/<name>.uvue`,得到 组件名 → 演示页相对路径 */
352
+ function scanDemoPages(root) {
353
+ const result = new Map();
354
+ const pagesDir = path.join(root, DEMO_DIR);
355
+ if (!isDir(pagesDir)) return result;
356
+ for (const group of fs.readdirSync(pagesDir, { withFileTypes: true })) {
357
+ if (!group.isDirectory() || group.name.startsWith(".")) continue;
358
+ const groupDir = path.join(pagesDir, group.name);
359
+ for (const item of fs.readdirSync(groupDir, { withFileTypes: true })) {
360
+ if (!item.isDirectory() || item.name.startsWith(".")) continue;
361
+ const demoDir = path.join(groupDir, item.name);
362
+ const preferred = path.join(demoDir, `${item.name}.uvue`);
363
+ const target = isFile(preferred)
364
+ ? preferred
365
+ : fs.readdirSync(demoDir).find((file) => file.endsWith(".uvue")) &&
366
+ path.join(
367
+ demoDir,
368
+ fs.readdirSync(demoDir).find((file) => file.endsWith(".uvue")),
369
+ );
370
+ if (!target || !isFile(target)) continue;
371
+ if (result.has(item.name)) continue;
372
+ result.set(item.name, {
373
+ path: toPosix(path.relative(root, target)),
374
+ category: group.name,
375
+ });
376
+ }
377
+ }
378
+ return result;
379
+ }
380
+
381
+ /** 扫描组件源码目录,得到 组件名 → { dir, files, tag } */
382
+ function scanComponentDirs(root) {
383
+ const result = new Map();
384
+ const componentsDir = path.join(root, COMPONENT_DIR);
385
+ if (!isDir(componentsDir)) return result;
386
+ for (const entry of fs.readdirSync(componentsDir, { withFileTypes: true })) {
387
+ if (!entry.isDirectory()) continue;
388
+ if (!entry.name.startsWith(COMPONENT_PREFIX)) continue;
389
+ const dir = path.join(componentsDir, entry.name);
390
+ const name = entry.name.slice(COMPONENT_PREFIX.length);
391
+ const files = walkFiles(dir);
392
+ if (files.length === 0) continue;
393
+ result.set(name, {
394
+ dir: toPosix(path.relative(root, dir)),
395
+ tag: entry.name,
396
+ files,
397
+ });
398
+ }
399
+ return result;
400
+ }
401
+
402
+ /**
403
+ * 构建组件目录
404
+ *
405
+ * @param {string} root ReView 仓库根目录(也接受只含 `uni_modules/reglow-review` 的安装目录)
406
+ * @returns {{
407
+ * root: string, categories: object[], components: object[],
408
+ * byName: Map<string, object>, componentCount: number, documentedCount: number,
409
+ * catalogItem: (name: string) => object | undefined,
410
+ * docsUrlFor: (name: string) => string | null,
411
+ * componentLabel: (name: string) => string,
412
+ * categoryOf: (key: string) => object | undefined,
413
+ * }}
414
+ */
415
+ export function createCatalog(root) {
416
+ const rootPath = path.resolve(String(root ?? "."));
417
+ const sidebarPath = path.join(rootPath, SIDEBAR_FILE);
418
+ const sidebarSource = readTextSafe(sidebarPath);
419
+ const parsed = sidebarSource ? parseSidebarCategories(sidebarSource) : [];
420
+
421
+ const docsDir = path.join(rootPath, DOCS_DIR);
422
+ const docNames = isDir(docsDir)
423
+ ? fs
424
+ .readdirSync(docsDir)
425
+ .filter((file) => file.endsWith(".md"))
426
+ .map((file) => file.replace(/\.md$/, ""))
427
+ .sort()
428
+ : [];
429
+ const demoPages = scanDemoPages(rootPath);
430
+ const dirs = scanComponentDirs(rootPath);
431
+
432
+ // 组件名全集:侧边栏 ∪ 文档页 ∪ 源码目录 ∪ 演示页
433
+ const names = new Set([
434
+ ...parsed.flatMap((group) => group.items.map((item) => item.name)),
435
+ ...docNames,
436
+ ...dirs.keys(),
437
+ ...demoPages.keys(),
438
+ ]);
439
+
440
+ // 归属查询:侧边栏(权威)→ pages/<category>/ 目录 → null
441
+ const fromSidebar = new Map();
442
+ for (const group of parsed) {
443
+ for (const item of group.items) fromSidebar.set(item.name, { group, item });
444
+ }
445
+
446
+ const components = [];
447
+ for (const name of [...names].sort()) {
448
+ const docPath = docNames.includes(name) ? `${DOCS_DIR}/${name}.md` : null;
449
+ const docText = docPath ? readTextSafe(path.join(rootPath, docPath)) : null;
450
+ const sidebarHit = fromSidebar.get(name);
451
+ const demo = demoPages.get(name);
452
+ const source = dirs.get(name);
453
+ const category = sidebarHit?.group.key ?? demo?.category ?? OTHER_CATEGORY;
454
+ const def = CATEGORIES.find((item) => item.key === category);
455
+ const heading = firstHeading(docText);
456
+ const cn = sidebarHit?.item.cn || chineseNameFromHeading(heading) || "";
457
+ const tags = source ? [source.tag] : (TAG_ALIASES[name] ?? []);
458
+
459
+ components.push({
460
+ name,
461
+ tag: tags[0] ?? null,
462
+ tags,
463
+ cn,
464
+ description: docText ? firstParagraph(docText) : "",
465
+ category,
466
+ categoryLabel: def?.label ?? "其他(未收录于侧边栏)",
467
+ categoryEnglish: def?.english ?? "",
468
+ layer: source ? "uni_modules" : "docs",
469
+ dir: source?.dir ?? null,
470
+ files: source?.files ?? [],
471
+ docPath,
472
+ demoPath: demo?.path ?? null,
473
+ docsUrl: docsUrlFor(name, category === OTHER_CATEGORY ? null : category),
474
+ documented: Boolean(docPath),
475
+ /** 侧边栏未收录(内部子组件 / 新增但未登记) */
476
+ unlisted: !sidebarHit,
477
+ });
478
+ }
479
+
480
+ const byName = new Map(components.map((item) => [item.name, item]));
481
+
482
+ /** 分类总览:侧边栏分类 + 各自组件(保留侧边栏顺序) */
483
+ const categories = (
484
+ parsed.length > 0
485
+ ? parsed
486
+ : CATEGORIES.map((item) => ({
487
+ key: item.key,
488
+ label: item.label,
489
+ english: item.english,
490
+ text: item.sidebarText,
491
+ items: [],
492
+ }))
493
+ ).map((group) => ({
494
+ key: group.key,
495
+ label: group.label,
496
+ english: group.english,
497
+ text: group.text,
498
+ url: categoryUrlFor(group.key),
499
+ components: group.items
500
+ .map((item) => byName.get(item.name))
501
+ .filter(Boolean),
502
+ }));
503
+
504
+ function catalogItem(name) {
505
+ const raw = String(name ?? "").trim();
506
+ if (!raw) return null;
507
+ // 依次兼容:easycom 标签写法(`re-button` / `RE-BUTTON`)、驼峰名(`DateTimePicker`)、下划线
508
+ const normalized = raw
509
+ .replace(/([a-z0-9])([A-Z])/g, "$1-$2")
510
+ .replace(new RegExp(`^${COMPONENT_PREFIX}`, "i"), "")
511
+ .replace(/[\s_]+/g, "-")
512
+ .replace(/-+/g, "-")
513
+ .toLowerCase();
514
+ return byName.get(normalized) ?? null;
515
+ }
516
+
517
+ return {
518
+ root: toPosix(rootPath),
519
+ categories,
520
+ components,
521
+ byName,
522
+ componentCount: components.length,
523
+ documentedCount: components.filter((item) => item.documented).length,
524
+ catalogItem,
525
+ docsUrlFor(name) {
526
+ return catalogItem(name)?.docsUrl ?? null;
527
+ },
528
+ componentLabel(name) {
529
+ return componentLabel(catalogItem(name) ?? name);
530
+ },
531
+ categoryOf(key) {
532
+ return CATEGORIES.find((item) => item.key === String(key ?? "").trim());
533
+ },
534
+ };
535
+ }
536
+
537
+ export { isDir, isFile, readTextSafe };
package/cli-utils.mjs ADDED
@@ -0,0 +1,29 @@
1
+ /**
2
+ * CLI 通用工具(review-mcp / review-setup / review-skills 共用)
3
+ */
4
+
5
+ import fs from "node:fs";
6
+ import path from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+
9
+ /**
10
+ * 判断当前模块是否被「直接执行」(而非被 import)
11
+ *
12
+ * 兼容性说明:npm 安装的 bin 在软链场景(`npm link`、`npm i <本地目录>`)下,
13
+ * `process.argv[1]` 是软链路径,而 `import.meta.url` 指向真实路径,
14
+ * 直接字符串比较会判定失败并导致 CLI 静默不执行;因此两侧都做 realpath 归一化。
15
+ *
16
+ * @param {string} importMetaUrl 调用方传入的 `import.meta.url`
17
+ */
18
+ export function isEntryModule(importMetaUrl) {
19
+ const entry = process.argv[1];
20
+ if (!entry) return false;
21
+ const self = fileURLToPath(importMetaUrl);
22
+ const resolved = path.resolve(entry);
23
+ if (resolved === self) return true;
24
+ try {
25
+ return fs.realpathSync(resolved) === fs.realpathSync(self);
26
+ } catch {
27
+ return false;
28
+ }
29
+ }