@reglow/reui-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/README.md +204 -0
- package/catalog.mjs +158 -0
- package/cli-utils.mjs +29 -0
- package/index.mjs +169 -0
- package/integrations.mjs +273 -0
- package/knowledge.mjs +923 -0
- package/package.json +46 -0
- package/server.mjs +710 -0
- package/setup.mjs +400 -0
- package/site.mjs +155 -0
- package/skills/reui-components/BEST_PRACTICES.md +219 -0
- package/skills/reui-components/COMPONENTS.md +141 -0
- package/skills/reui-components/SKILL.md +172 -0
- package/skills/reui-components/WORKFLOWS.md +264 -0
- package/skills.mjs +197 -0
- package/tar.mjs +115 -0
package/setup.mjs
ADDED
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* reui-setup:为 Trae / CodeBuddy / Qoder 等 AI 工具一键配置 ReUI 能力
|
|
4
|
+
*
|
|
5
|
+
* 做两件事:
|
|
6
|
+
* 1. **写 MCP 配置** —— 在项目(或用户级)配置文件中合并 `mcpServers["reui-mcp"]` 条目;
|
|
7
|
+
* 2. **装 Skills** —— 把 `skills/reui-components/` 复制到该工具的 Skills 目录。
|
|
8
|
+
*
|
|
9
|
+
* 用法:
|
|
10
|
+
* npx -y -p @reglow/reui-mcp reui-setup # 默认 trae,codebuddy,qoder
|
|
11
|
+
* npx -y -p @reglow/reui-mcp reui-setup --tools trae,qoder # 只配指定工具
|
|
12
|
+
* npx -y -p @reglow/reui-mcp reui-setup --tools all --scope user
|
|
13
|
+
* npx -y -p @reglow/reui-mcp reui-setup --dry-run # 仅预览将写入的内容
|
|
14
|
+
*
|
|
15
|
+
* 也支持通过主命令:npx -y @reglow/reui-mcp --setup trae,codebuddy,qoder
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import fs from "node:fs";
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
import { fileURLToPath } from "node:url";
|
|
21
|
+
import { isEntryModule } from "./cli-utils.mjs";
|
|
22
|
+
import {
|
|
23
|
+
ALL_TOOLS,
|
|
24
|
+
DEFAULT_ARGS,
|
|
25
|
+
DEFAULT_SERVER_NAME,
|
|
26
|
+
DEFAULT_SETUP_TOOLS,
|
|
27
|
+
TOOL_INTEGRATIONS,
|
|
28
|
+
buildMcpEntry,
|
|
29
|
+
parseTools,
|
|
30
|
+
resolveConfigPath,
|
|
31
|
+
resolveSkillsDir,
|
|
32
|
+
} from "./integrations.mjs";
|
|
33
|
+
import { installSkills } from "./skills.mjs";
|
|
34
|
+
|
|
35
|
+
// ============================ JSONC 解析与合并 ============================
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 解析 JSONC(JSON with Comments)
|
|
39
|
+
*
|
|
40
|
+
* 支持 `//` 行注释、`/* */` 块注释与尾逗号 —— CodeBuddy / Qoder 的配置文件允许这些写法。
|
|
41
|
+
* 注释出现在字符串内部时不会被误删。
|
|
42
|
+
*/
|
|
43
|
+
export function parseJsonc(text) {
|
|
44
|
+
const source = String(text ?? "").replace(/^\uFEFF/, "");
|
|
45
|
+
let output = "";
|
|
46
|
+
let inString = false;
|
|
47
|
+
let quote = "";
|
|
48
|
+
for (let i = 0; i < source.length; i += 1) {
|
|
49
|
+
const char = source[i];
|
|
50
|
+
const next = source[i + 1];
|
|
51
|
+
if (inString) {
|
|
52
|
+
output += char;
|
|
53
|
+
if (char === "\\") {
|
|
54
|
+
output += next ?? "";
|
|
55
|
+
i += 1;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (char === quote) inString = false;
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (char === '"' || char === "'") {
|
|
62
|
+
inString = true;
|
|
63
|
+
quote = char;
|
|
64
|
+
output += char;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (char === "/" && next === "/") {
|
|
68
|
+
const end = source.indexOf("\n", i);
|
|
69
|
+
i = end === -1 ? source.length : end - 1;
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (char === "/" && next === "*") {
|
|
73
|
+
const end = source.indexOf("*/", i + 2);
|
|
74
|
+
i = end === -1 ? source.length : end + 1;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
output += char;
|
|
78
|
+
}
|
|
79
|
+
// 去尾逗号(对象 / 数组末尾)
|
|
80
|
+
output = output.replace(/,(\s*[}\]])/g, "$1");
|
|
81
|
+
return JSON.parse(output);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** 读取 MCP 配置文件(不存在 / 为空时返回空对象) */
|
|
85
|
+
export function readMcpConfig(file) {
|
|
86
|
+
if (!file || !fs.existsSync(file))
|
|
87
|
+
return { data: {}, exists: false, raw: "", hadComments: false };
|
|
88
|
+
const raw = fs.readFileSync(file, "utf8");
|
|
89
|
+
const hadComments = /(^|[^:"'])\/\/|(^|[^:"'])\/\*/.test(raw);
|
|
90
|
+
if (raw.trim() === "")
|
|
91
|
+
return { data: {}, exists: true, raw, hadComments: false };
|
|
92
|
+
try {
|
|
93
|
+
return { data: parseJsonc(raw), exists: true, raw, hadComments };
|
|
94
|
+
} catch (error) {
|
|
95
|
+
return { data: null, exists: true, raw, hadComments, error: error.message };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** 把 server 条目合并进 mcpServers,返回新对象与变更信息 */
|
|
100
|
+
export function mergeMcpConfig(
|
|
101
|
+
existing,
|
|
102
|
+
{ serverName = DEFAULT_SERVER_NAME, entry } = {},
|
|
103
|
+
) {
|
|
104
|
+
const base = existing && typeof existing === "object" ? existing : {};
|
|
105
|
+
const servers = { ...(base.mcpServers ?? {}) };
|
|
106
|
+
const previous = servers[serverName];
|
|
107
|
+
servers[serverName] = { ...entry };
|
|
108
|
+
const changed = JSON.stringify(previous) !== JSON.stringify(entry);
|
|
109
|
+
return { next: { ...base, mcpServers: servers }, previous, changed };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function serializeConfig(data) {
|
|
113
|
+
return `${JSON.stringify(data, null, 2)}\n`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ============================ 单工具配置 ============================
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* 配置单个工具(写 MCP 配置 + 装 Skills)
|
|
120
|
+
*
|
|
121
|
+
* @param {{
|
|
122
|
+
* tool: string,
|
|
123
|
+
* cwd?: string,
|
|
124
|
+
* scope?: "project" | "user",
|
|
125
|
+
* serverName?: string,
|
|
126
|
+
* args?: string[],
|
|
127
|
+
* env?: Record<string, string>,
|
|
128
|
+
* dryRun?: boolean,
|
|
129
|
+
* force?: boolean,
|
|
130
|
+
* withMcp?: boolean,
|
|
131
|
+
* withSkills?: boolean,
|
|
132
|
+
* installSkillsFn?: typeof installSkills,
|
|
133
|
+
* }} options
|
|
134
|
+
*/
|
|
135
|
+
export function setupTool(options) {
|
|
136
|
+
const {
|
|
137
|
+
tool,
|
|
138
|
+
cwd = process.cwd(),
|
|
139
|
+
scope = "project",
|
|
140
|
+
serverName = DEFAULT_SERVER_NAME,
|
|
141
|
+
args = DEFAULT_ARGS,
|
|
142
|
+
env,
|
|
143
|
+
dryRun = false,
|
|
144
|
+
force = false,
|
|
145
|
+
withMcp = true,
|
|
146
|
+
withSkills = true,
|
|
147
|
+
} = options;
|
|
148
|
+
|
|
149
|
+
const spec = TOOL_INTEGRATIONS[tool];
|
|
150
|
+
if (!spec)
|
|
151
|
+
throw new Error(`未知工具「${tool}」,可选:${ALL_TOOLS.join(" / ")}`);
|
|
152
|
+
|
|
153
|
+
const result = {
|
|
154
|
+
tool,
|
|
155
|
+
label: spec.label,
|
|
156
|
+
scope,
|
|
157
|
+
status: "ok",
|
|
158
|
+
configFile: null,
|
|
159
|
+
configAction: null,
|
|
160
|
+
configError: null,
|
|
161
|
+
hadComments: false,
|
|
162
|
+
skillsDir: null,
|
|
163
|
+
skillFiles: [],
|
|
164
|
+
skippedSkillFiles: [],
|
|
165
|
+
manualSteps: [...(spec.mcp.manualSteps ?? [])],
|
|
166
|
+
notes: [...(spec.mcp.notes ?? [])],
|
|
167
|
+
docsUrl: spec.docsUrl,
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
// ---- MCP 配置 ----
|
|
171
|
+
if (withMcp) {
|
|
172
|
+
const file = resolveConfigPath(tool, scope, { cwd });
|
|
173
|
+
result.configFile = file;
|
|
174
|
+
if (!file) {
|
|
175
|
+
result.status = "skipped";
|
|
176
|
+
result.configAction =
|
|
177
|
+
scope === "user" ? "该工具无用户级配置文件" : "该工具无项目级配置文件";
|
|
178
|
+
} else {
|
|
179
|
+
const entry = buildMcpEntry(tool, { serverName, args, env });
|
|
180
|
+
const { data, exists, hadComments, error } = readMcpConfig(file);
|
|
181
|
+
result.hadComments = Boolean(hadComments);
|
|
182
|
+
if (error && !force) {
|
|
183
|
+
result.status = "error";
|
|
184
|
+
result.configError = `配置文件不是合法 JSON/JSONC(${error});加 --force 将备份后用标准 JSON 重写`;
|
|
185
|
+
result.manualSteps = [
|
|
186
|
+
`手动在 ${file} 中加入:mcpServers.${serverName} = ${JSON.stringify(entry)}`,
|
|
187
|
+
];
|
|
188
|
+
} else {
|
|
189
|
+
const { next, previous, changed } = mergeMcpConfig(data ?? {}, {
|
|
190
|
+
serverName,
|
|
191
|
+
entry,
|
|
192
|
+
});
|
|
193
|
+
const action = !exists ? "created" : changed ? "updated" : "unchanged";
|
|
194
|
+
result.configAction = action;
|
|
195
|
+
result.serverEntry = entry;
|
|
196
|
+
if (!dryRun) {
|
|
197
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
198
|
+
if (error && force && exists) {
|
|
199
|
+
fs.copyFileSync(file, `${file}.bak`);
|
|
200
|
+
result.configAction = "rewritten";
|
|
201
|
+
}
|
|
202
|
+
fs.writeFileSync(file, serializeConfig(next));
|
|
203
|
+
}
|
|
204
|
+
result.previousEntry = previous;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ---- Skills ----
|
|
210
|
+
if (withSkills) {
|
|
211
|
+
const dir = resolveSkillsDir(tool, scope, { cwd });
|
|
212
|
+
result.skillsDir = dir;
|
|
213
|
+
if (!dir) {
|
|
214
|
+
if (result.status === "ok") result.status = "skipped";
|
|
215
|
+
result.skillFiles = [];
|
|
216
|
+
} else {
|
|
217
|
+
const relative = path.relative(cwd, dir) || dir;
|
|
218
|
+
const installed = (options.installSkillsFn ?? installSkills)({
|
|
219
|
+
cwd,
|
|
220
|
+
targets: [relative],
|
|
221
|
+
force,
|
|
222
|
+
dryRun,
|
|
223
|
+
logger: () => { },
|
|
224
|
+
});
|
|
225
|
+
result.skillFiles = installed.installed[0]?.files ?? [];
|
|
226
|
+
result.skippedSkillFiles = installed.installed[0]?.skipped ?? [];
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return result;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* 批量配置(默认 Trae / CodeBuddy / Qoder)
|
|
235
|
+
*
|
|
236
|
+
* @param {{ tools?: string[], cwd?: string, scope?: "project"|"user", serverName?: string,
|
|
237
|
+
* args?: string[], env?: Record<string,string>, dryRun?: boolean, force?: boolean,
|
|
238
|
+
* withMcp?: boolean, withSkills?: boolean }} options
|
|
239
|
+
*/
|
|
240
|
+
export function setup(options = {}) {
|
|
241
|
+
const tools = options.tools?.length
|
|
242
|
+
? options.tools
|
|
243
|
+
: [...DEFAULT_SETUP_TOOLS];
|
|
244
|
+
const results = tools.map((tool) => setupTool({ ...options, tool }));
|
|
245
|
+
return {
|
|
246
|
+
cwd: options.cwd ?? process.cwd(),
|
|
247
|
+
scope: options.scope ?? "project",
|
|
248
|
+
serverName: options.serverName ?? DEFAULT_SERVER_NAME,
|
|
249
|
+
results,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ============================ 输出 ============================
|
|
254
|
+
|
|
255
|
+
const ACTION_LABEL = {
|
|
256
|
+
created: "已新建",
|
|
257
|
+
updated: "已更新",
|
|
258
|
+
unchanged: "无变化",
|
|
259
|
+
rewritten: "已备份并重写",
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
/** 生成人类可读的配置报告 */
|
|
263
|
+
export function renderSetupReport(report, { dryRun = false } = {}) {
|
|
264
|
+
const lines = [];
|
|
265
|
+
lines.push(
|
|
266
|
+
`${dryRun ? "[dry-run] " : ""}ReUI AI 能力配置(scope=${report.scope})`,
|
|
267
|
+
);
|
|
268
|
+
lines.push("");
|
|
269
|
+
for (const item of report.results) {
|
|
270
|
+
lines.push(`## ${item.label}(${item.tool})`);
|
|
271
|
+
if (item.configFile) {
|
|
272
|
+
const action =
|
|
273
|
+
ACTION_LABEL[item.configAction] ?? item.configAction ?? "-";
|
|
274
|
+
lines.push(`- MCP 配置:\`${item.configFile}\` → ${action}`);
|
|
275
|
+
if (item.configError) lines.push(` ⚠️ ${item.configError}`);
|
|
276
|
+
if (item.hadComments && item.configAction !== "unchanged") {
|
|
277
|
+
lines.push(" ℹ️ 原文件含注释,已按标准 JSON 重写(请留意注释丢失)");
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
if (item.skillsDir) {
|
|
281
|
+
const written = item.skillFiles.length;
|
|
282
|
+
const skipped = item.skippedSkillFiles.length;
|
|
283
|
+
lines.push(
|
|
284
|
+
`- Skills 目录:\`${item.skillsDir}\` → 写入 ${written} 个文件${skipped ? `,跳过已存在 ${skipped} 个(--force 可覆盖)` : ""}`,
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
if (item.manualSteps.length > 0) {
|
|
288
|
+
lines.push("- 仍需手动完成:");
|
|
289
|
+
for (const step of item.manualSteps) lines.push(` - ${step}`);
|
|
290
|
+
}
|
|
291
|
+
lines.push("");
|
|
292
|
+
}
|
|
293
|
+
lines.push(
|
|
294
|
+
"下一步:重启(或重载)对应工具,然后让 AI「用 reui 组件写一个 XX 页面」验证工具是否被调用。",
|
|
295
|
+
);
|
|
296
|
+
lines.push(`校验 MCP 本体:npx -y ${DEFAULT_ARGS[1]} --check`);
|
|
297
|
+
return lines.join("\n");
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// ============================ CLI ============================
|
|
301
|
+
|
|
302
|
+
const SETUP_USAGE = [
|
|
303
|
+
"reui-setup —— 为 AI 工具一键配置 ReUI(MCP + Skills)",
|
|
304
|
+
"",
|
|
305
|
+
"用法:npx -y -p @reglow/reui-mcp reui-setup [选项]",
|
|
306
|
+
"",
|
|
307
|
+
"选项:",
|
|
308
|
+
` --tools <list> 目标工具,逗号分隔(默认 ${DEFAULT_SETUP_TOOLS.join(",")});all = 全部(${ALL_TOOLS.join("/")})`,
|
|
309
|
+
" --scope <scope> project(默认)| user(写入用户级配置目录)",
|
|
310
|
+
" --server-name <n> MCP 服务器名,默认 reui-mcp",
|
|
311
|
+
" --cwd <dir> 项目根目录,默认当前目录",
|
|
312
|
+
" --skills-only 只安装 Skills,不写 MCP 配置",
|
|
313
|
+
" --mcp-only 只写 MCP 配置,不安装 Skills",
|
|
314
|
+
" --dry-run 只打印将要写入的内容",
|
|
315
|
+
" --force 覆盖已存在的 Skills 文件;配置非法时备份后重写",
|
|
316
|
+
" --list-tools 列出支持的工具与路径",
|
|
317
|
+
" -h, --help 显示帮助",
|
|
318
|
+
].join("\n");
|
|
319
|
+
|
|
320
|
+
/** 列出各工具的项目级 MCP 配置文件与 Skills 目录 */
|
|
321
|
+
export function renderToolList(options = {}) {
|
|
322
|
+
const cwd = options.cwd ?? process.cwd();
|
|
323
|
+
const lines = [`支持的工具(项目级默认路径,cwd=${cwd}):`, ""];
|
|
324
|
+
for (const tool of ALL_TOOLS) {
|
|
325
|
+
const spec = TOOL_INTEGRATIONS[tool];
|
|
326
|
+
lines.push(`- ${spec.label}(${tool})`);
|
|
327
|
+
lines.push(` - MCP 配置:${resolveConfigPath(tool, "project", { cwd })}`);
|
|
328
|
+
lines.push(` - Skills:${resolveSkillsDir(tool, "project", { cwd })}`);
|
|
329
|
+
if (spec.mcp.userFile) {
|
|
330
|
+
lines.push(` - 用户级:${resolveConfigPath(tool, "user", { cwd })}`);
|
|
331
|
+
}
|
|
332
|
+
lines.push(` - 文档:${spec.docsUrl}`);
|
|
333
|
+
}
|
|
334
|
+
return lines.join("\n");
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** CLI 入口 */
|
|
338
|
+
export function runSetupCli(argv = process.argv.slice(2)) {
|
|
339
|
+
const options = {
|
|
340
|
+
dryRun: false,
|
|
341
|
+
force: false,
|
|
342
|
+
scope: "project",
|
|
343
|
+
withMcp: true,
|
|
344
|
+
withSkills: true,
|
|
345
|
+
};
|
|
346
|
+
let toolsInput;
|
|
347
|
+
|
|
348
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
349
|
+
const arg = argv[i];
|
|
350
|
+
if (arg === "--tools" || arg === "-t") toolsInput = argv[++i];
|
|
351
|
+
else if (arg.startsWith("--tools="))
|
|
352
|
+
toolsInput = arg.slice("--tools=".length);
|
|
353
|
+
else if (arg === "--scope") options.scope = argv[++i];
|
|
354
|
+
else if (arg.startsWith("--scope="))
|
|
355
|
+
options.scope = arg.slice("--scope=".length);
|
|
356
|
+
else if (arg === "--server-name") options.serverName = argv[++i];
|
|
357
|
+
else if (arg.startsWith("--server-name="))
|
|
358
|
+
options.serverName = arg.slice("--server-name=".length);
|
|
359
|
+
else if (arg === "--cwd") options.cwd = argv[++i];
|
|
360
|
+
else if (arg.startsWith("--cwd=")) options.cwd = arg.slice("--cwd=".length);
|
|
361
|
+
else if (arg === "--dry-run") options.dryRun = true;
|
|
362
|
+
else if (arg === "--force") options.force = true;
|
|
363
|
+
else if (arg === "--skills-only") options.withMcp = false;
|
|
364
|
+
else if (arg === "--mcp-only") options.withSkills = false;
|
|
365
|
+
else if (arg === "--list-tools") {
|
|
366
|
+
console.log(renderToolList(options));
|
|
367
|
+
return 0;
|
|
368
|
+
} else if (arg === "--help" || arg === "-h") {
|
|
369
|
+
console.log(SETUP_USAGE);
|
|
370
|
+
return 0;
|
|
371
|
+
} else if (!arg.startsWith("-") && !toolsInput) {
|
|
372
|
+
// 兼容 `--setup trae,codebuddy` 这类「位置参数即工具清单」的写法
|
|
373
|
+
toolsInput = arg;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
if (options.scope !== "project" && options.scope !== "user") {
|
|
378
|
+
console.error(
|
|
379
|
+
`[reui-setup] --scope 仅支持 project / user,收到:${options.scope}`,
|
|
380
|
+
);
|
|
381
|
+
return 1;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const tools = parseTools(toolsInput);
|
|
385
|
+
const report = setup({ ...options, tools });
|
|
386
|
+
console.log(renderSetupReport(report, { dryRun: options.dryRun }));
|
|
387
|
+
if (options.dryRun) {
|
|
388
|
+
console.log("(dry-run 模式:以上内容未写入磁盘)");
|
|
389
|
+
}
|
|
390
|
+
return report.results.some((item) => item.status === "error") ? 1 : 0;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
if (isEntryModule(import.meta.url)) {
|
|
394
|
+
try {
|
|
395
|
+
process.exitCode = runSetupCli();
|
|
396
|
+
} catch (error) {
|
|
397
|
+
console.error(`[reui-setup] 配置失败:${error.message}`);
|
|
398
|
+
process.exitCode = 1;
|
|
399
|
+
}
|
|
400
|
+
}
|
package/site.mjs
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 官方文档站点兜底读取
|
|
3
|
+
*
|
|
4
|
+
* 当本地 / 指定版本的包内缺少组件文档(例如历史版本未把 `components/**` 打进产物)时,
|
|
5
|
+
* 回退抓取官方文档站点 https://reglow.huoli.tech/components-library/reui 的组件页并转成 Markdown。
|
|
6
|
+
* 返回值中始终标注来源与链接,便于 AI 明确「这是最新版官网内容」。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { DOCS_BASE, docsUrlFor } from "./catalog.mjs";
|
|
10
|
+
|
|
11
|
+
const DEFAULT_TIMEOUT = 15000;
|
|
12
|
+
/** 单页最大读取字节,防御异常大页面 */
|
|
13
|
+
const MAX_BYTES = 3 * 1024 * 1024;
|
|
14
|
+
|
|
15
|
+
/** 解开常见 HTML 实体 */
|
|
16
|
+
function decodeEntities(text) {
|
|
17
|
+
return text
|
|
18
|
+
.replace(/</g, "<")
|
|
19
|
+
.replace(/>/g, ">")
|
|
20
|
+
.replace(/"/g, '"')
|
|
21
|
+
.replace(/'/g, "'")
|
|
22
|
+
.replace(/'/g, "'")
|
|
23
|
+
.replace(/ /g, " ")
|
|
24
|
+
.replace(/&/g, "&")
|
|
25
|
+
.replace(/&#(\d+);/g, (_, code) => String.fromCodePoint(Number(code)))
|
|
26
|
+
.replace(/&#x([0-9a-fA-F]+);/g, (_, code) => String.fromCodePoint(Number.parseInt(code, 16)));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** 去掉标签、解码实体、压缩空白(行内片段用) */
|
|
30
|
+
function inlineText(html) {
|
|
31
|
+
return decodeEntities(html.replace(/<[^>]*>/g, ""))
|
|
32
|
+
.replace(/[ \t]+/g, " ")
|
|
33
|
+
.trim();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* HTML → 简化 Markdown
|
|
38
|
+
*
|
|
39
|
+
* 覆盖官方文档页实际用到的元素:标题、段落、列表、表格、代码块、行内代码、链接、强调。
|
|
40
|
+
* 目标是「让 AI 拿到可读正文」,不追求像素级还原。
|
|
41
|
+
*/
|
|
42
|
+
export function htmlToMarkdown(html) {
|
|
43
|
+
let source = String(html ?? "")
|
|
44
|
+
.replace(/<!--[\s\S]*?-->/g, "")
|
|
45
|
+
.replace(/<script[\s\S]*?<\/script>/gi, "")
|
|
46
|
+
.replace(/<style[\s\S]*?<\/style>/gi, "")
|
|
47
|
+
.replace(/<svg[\s\S]*?<\/svg>/gi, "");
|
|
48
|
+
|
|
49
|
+
// 尽量只取正文区域
|
|
50
|
+
const regionMatch =
|
|
51
|
+
source.match(/<main[^>]*>([\s\S]*?)<\/main>/i) ??
|
|
52
|
+
source.match(/<article[^>]*>([\s\S]*?)<\/article>/i);
|
|
53
|
+
if (regionMatch) source = regionMatch[1];
|
|
54
|
+
|
|
55
|
+
// 代码块:先占位,避免内部标签/空白被后续处理破坏
|
|
56
|
+
const codeBlocks = [];
|
|
57
|
+
source = source.replace(/<pre[^>]*>([\s\S]*?)<\/pre>/gi, (_match, inner) => {
|
|
58
|
+
const code = decodeEntities(
|
|
59
|
+
inner
|
|
60
|
+
.replace(/<br\s*\/?>/gi, "\n")
|
|
61
|
+
.replace(/<[^>]*>/g, ""),
|
|
62
|
+
).replace(/^\n+|\n+$/g, "");
|
|
63
|
+
codeBlocks.push(code);
|
|
64
|
+
return `\n\u0000CODE${codeBlocks.length - 1}\u0000\n`;
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// 表格:先行内处理,转成 markdown 表格
|
|
68
|
+
source = source.replace(/<table[\s\S]*?<\/table>/gi, (table) => {
|
|
69
|
+
const rows = [...table.matchAll(/<tr[^>]*>([\s\S]*?)<\/tr>/gi)].map((row) =>
|
|
70
|
+
[...row[1].matchAll(/<(t[hd])[^>]*>([\s\S]*?)<\/\1>/gi)].map((cell) =>
|
|
71
|
+
inlineText(cell[2]).replace(/\|/g, "\\|"),
|
|
72
|
+
),
|
|
73
|
+
);
|
|
74
|
+
if (rows.length === 0) return "\n";
|
|
75
|
+
const header = rows[0];
|
|
76
|
+
const body = rows.slice(1);
|
|
77
|
+
const lines = [
|
|
78
|
+
`| ${header.join(" | ")} |`,
|
|
79
|
+
`| ${header.map(() => "---").join(" | ")} |`,
|
|
80
|
+
...body.map((row) => `| ${row.join(" | ")} |`),
|
|
81
|
+
];
|
|
82
|
+
return `\n${lines.join("\n")}\n`;
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
source = source
|
|
86
|
+
.replace(/<h([1-6])[^>]*>([\s\S]*?)<\/h\1>/gi, (_m, level, text) => `\n${"#".repeat(Number(level))} ${inlineText(text)}\n`)
|
|
87
|
+
.replace(/<li[^>]*>([\s\S]*?)<\/li>/gi, (_m, text) => `\n- ${inlineText(text)}`)
|
|
88
|
+
.replace(/<(p|div|section|tr|thead|tbody|ul|ol|blockquote)[^>]*>/gi, "\n")
|
|
89
|
+
.replace(/<\/(p|div|section|tr|thead|tbody|ul|ol|blockquote)>/gi, "\n")
|
|
90
|
+
.replace(/<br\s*\/?>/gi, "\n")
|
|
91
|
+
.replace(/<code[^>]*>([\s\S]*?)<\/code>/gi, (_m, text) => `\`${inlineText(text)}\``)
|
|
92
|
+
.replace(/<a[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/gi, (_m, href, text) => {
|
|
93
|
+
const label = inlineText(text);
|
|
94
|
+
return label ? `[${label}](${href})` : href;
|
|
95
|
+
})
|
|
96
|
+
.replace(/<(strong|b)[^>]*>([\s\S]*?)<\/\1>/gi, (_m, _tag, text) => `**${inlineText(text)}**`)
|
|
97
|
+
.replace(/<[^>]*>/g, "");
|
|
98
|
+
|
|
99
|
+
source = decodeEntities(source);
|
|
100
|
+
|
|
101
|
+
// 还原代码块
|
|
102
|
+
source = source.replace(/\u0000CODE(\d+)\u0000/g, (_m, index) => {
|
|
103
|
+
const code = codeBlocks[Number(index)] ?? "";
|
|
104
|
+
return `\n\`\`\`\n${code}\n\`\`\`\n`;
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
return source
|
|
108
|
+
.replace(/[ \t]+\n/g, "\n")
|
|
109
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
110
|
+
.trim();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** 带超时的文本抓取 */
|
|
114
|
+
async function fetchText(url, timeout = DEFAULT_TIMEOUT) {
|
|
115
|
+
const controller = new AbortController();
|
|
116
|
+
const timer = setTimeout(() => controller.abort(), timeout);
|
|
117
|
+
try {
|
|
118
|
+
const response = await fetch(url, {
|
|
119
|
+
signal: controller.signal,
|
|
120
|
+
headers: { "user-agent": "reui-mcp", accept: "text/html,*/*" },
|
|
121
|
+
});
|
|
122
|
+
if (!response.ok) return null;
|
|
123
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
124
|
+
return buffer.subarray(0, MAX_BYTES).toString("utf8");
|
|
125
|
+
} catch {
|
|
126
|
+
return null;
|
|
127
|
+
} finally {
|
|
128
|
+
clearTimeout(timer);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* 抓取某组件的官网文档并转 Markdown
|
|
134
|
+
*
|
|
135
|
+
* @param {string} name 组件名(如 `splitter`)
|
|
136
|
+
* @returns {Promise<{ markdown: string, url: string, source: "site" } | null>}
|
|
137
|
+
*/
|
|
138
|
+
export async function fetchSiteDocument(name) {
|
|
139
|
+
const url = docsUrlFor(name);
|
|
140
|
+
if (!url) return null;
|
|
141
|
+
const html = await fetchText(url);
|
|
142
|
+
if (!html) return null;
|
|
143
|
+
const markdown = htmlToMarkdown(html);
|
|
144
|
+
if (markdown.length < 40) return null;
|
|
145
|
+
return { markdown, url, source: "site" };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** 抓取官网组件总览(分类 + 说明) */
|
|
149
|
+
export async function fetchSiteOverview() {
|
|
150
|
+
const html = await fetchText(DOCS_BASE);
|
|
151
|
+
if (!html) return null;
|
|
152
|
+
const markdown = htmlToMarkdown(html);
|
|
153
|
+
if (!markdown) return null;
|
|
154
|
+
return { markdown, url: DOCS_BASE, source: "site" };
|
|
155
|
+
}
|