@rinova/playground-mcp 0.1.5 → 0.7.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 +1 -1
- package/dist/index.js +246 -10
- package/dist/validate.d.ts +68 -0
- package/dist/validate.js +294 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -46,7 +46,7 @@ export default defineConfig({
|
|
|
46
46
|
|
|
47
47
|
## 工具
|
|
48
48
|
|
|
49
|
-
| 工具 |
|
|
49
|
+
| 工具 | 输入 | 返回 |
|
|
50
50
|
|---|---|---|
|
|
51
51
|
| `capability_search` | `query`、`limit?` | 命中的组件 + prop 名 + 场景数 |
|
|
52
52
|
| `capability_get` | `component` | props/events/slots/exposed 全量契约,每个 prop 带 `文件:行号`,附相关场景与未覆盖 props |
|
package/dist/index.js
CHANGED
|
@@ -4,6 +4,7 @@ import path from 'node:path';
|
|
|
4
4
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
5
5
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
6
6
|
import { z } from 'zod';
|
|
7
|
+
import { scaffoldScene, validateScenes } from './validate.js';
|
|
7
8
|
const toPosix = (value) => value.split(path.sep).join('/');
|
|
8
9
|
/** 在源文件里定位某个标识符首次出现的行,产出 `path:line` 锚点 */
|
|
9
10
|
const locate = (root, rel, needle) => {
|
|
@@ -11,9 +12,15 @@ const locate = (root, rel, needle) => {
|
|
|
11
12
|
if (!fs.existsSync(abs))
|
|
12
13
|
return rel;
|
|
13
14
|
const lines = fs.readFileSync(abs, 'utf8').split('\n');
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
const escaped = needle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
16
|
+
// 先找声明行(`rows?:` / `rows:` / `rows =`),避免命中 JSDoc 里提到的同名词
|
|
17
|
+
const declaration = new RegExp(`^\\s*(?:readonly\\s+)?${escaped}\\??\\s*[:=]`);
|
|
18
|
+
const word = new RegExp(`\\b${escaped}\\b`);
|
|
19
|
+
const isComment = (line) => /^\s*(?:\*|\/\/|<!--)/.test(line);
|
|
20
|
+
const codeLines = lines.map((line, index) => ({ line, index })).filter((item) => !isComment(item.line));
|
|
21
|
+
const hit = codeLines.find((item) => declaration.test(item.line)) ??
|
|
22
|
+
codeLines.find((item) => word.test(item.line));
|
|
23
|
+
return hit ? `${rel}:${hit.index + 1}` : rel;
|
|
17
24
|
};
|
|
18
25
|
const parseArgs = (argv) => {
|
|
19
26
|
const args = argv.slice(2);
|
|
@@ -47,13 +54,39 @@ const formatComponent = (root, component, manifest) => {
|
|
|
47
54
|
lines.push(`- ${prop.name}: ${prop.type}${flags ? ` (${flags})` : ''} ← ${locate(root, component.rel, prop.name)}`);
|
|
48
55
|
if (prop.description)
|
|
49
56
|
lines.push(` ${prop.description}`);
|
|
57
|
+
for (const field of prop.fields ?? []) {
|
|
58
|
+
lines.push(` · ${field.name}: ${field.type}${field.required ? ' (必填)' : ''}${field.description ? ` — ${field.description}` : ''}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (component.events.length) {
|
|
62
|
+
lines.push('', '## Events');
|
|
63
|
+
for (const event of component.events) {
|
|
64
|
+
lines.push(`- ${event.name} ${event.type}${event.description ? ` — ${event.description}` : ''}`);
|
|
65
|
+
if (event.signature)
|
|
66
|
+
lines.push(` ${event.signature}`);
|
|
67
|
+
for (const field of event.fields ?? []) {
|
|
68
|
+
lines.push(` · ${field.name}: ${field.type}${field.description ? ` — ${field.description}` : ''}`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (component.slots.length) {
|
|
73
|
+
lines.push('', '## Slots');
|
|
74
|
+
for (const slot of component.slots) {
|
|
75
|
+
lines.push(`- ${slot.name}${slot.description ? ` — ${slot.description}` : ''}`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (component.exposed.length) {
|
|
79
|
+
lines.push('', '## Exposed');
|
|
80
|
+
for (const item of component.exposed) {
|
|
81
|
+
lines.push(`- ${item.name}: ${item.type}${item.description ? ` — ${item.description}` : ''}`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (component.types?.length) {
|
|
85
|
+
lines.push('', '## Types');
|
|
86
|
+
for (const item of component.types) {
|
|
87
|
+
lines.push(`- ${item.name}(${item.kind},${item.file})`, '```ts', item.code, '```');
|
|
88
|
+
}
|
|
50
89
|
}
|
|
51
|
-
if (component.events.length)
|
|
52
|
-
lines.push('', `## Events`, ...component.events.map((name) => `- ${name}`));
|
|
53
|
-
if (component.slots.length)
|
|
54
|
-
lines.push('', `## Slots`, ...component.slots.map((name) => `- ${name}`));
|
|
55
|
-
if (component.exposed.length)
|
|
56
|
-
lines.push('', `## Exposed`, ...component.exposed.map((name) => `- ${name}`));
|
|
57
90
|
const scenes = manifest.scenes.filter((scene) => component.scenes.includes(scene.id));
|
|
58
91
|
lines.push('', '## 相关场景');
|
|
59
92
|
if (!scenes.length)
|
|
@@ -84,7 +117,7 @@ const main = async () => {
|
|
|
84
117
|
process.exitCode = 1;
|
|
85
118
|
return;
|
|
86
119
|
}
|
|
87
|
-
const server = new McpServer({ name: 'rinova-playground', version: '0.
|
|
120
|
+
const server = new McpServer({ name: 'rinova-playground', version: '0.7.0' });
|
|
88
121
|
server.registerTool('capability_search', {
|
|
89
122
|
title: '搜索组件能力',
|
|
90
123
|
description: '按组件名 / prop 名 / 场景 id 搜索,返回带文件行号的摘要',
|
|
@@ -144,6 +177,209 @@ const main = async () => {
|
|
|
144
177
|
const head = `覆盖 ${total - uncovered}/${total}|清单生成时间 ${manifest.generatedAt}`;
|
|
145
178
|
return { content: [{ type: 'text', text: rows.length ? `${head}\n\n${rows.join('\n')}` : `${head}\n\n全部 props 都有场景覆盖。` }] };
|
|
146
179
|
});
|
|
180
|
+
const kebab = (input) => {
|
|
181
|
+
const ascii = input
|
|
182
|
+
.toLowerCase()
|
|
183
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
184
|
+
.replace(/^-+|-+$/g, '');
|
|
185
|
+
return ascii || '';
|
|
186
|
+
};
|
|
187
|
+
const formatEnv = (manifest) => {
|
|
188
|
+
const env = manifest.env;
|
|
189
|
+
if (!env) {
|
|
190
|
+
return '这份清单里没有 env 段(0.5 及更早的清单没有)。重新跑一次 pnpm dev 生成:清单由 @rinova/playground-vite 在扫描场景时写入。';
|
|
191
|
+
}
|
|
192
|
+
const lines = [
|
|
193
|
+
`# 演示环境:${env.title}`,
|
|
194
|
+
'',
|
|
195
|
+
`- 场景目录:${env.sceneRoot}(严格三层:<分组>/<页面>/<场景>;子组件页可四层)`,
|
|
196
|
+
`- 路由:history 路由 ${env.routeBase} + <分组>/<页面>,页内锚点 #<场景名>`,
|
|
197
|
+
`- 外壳能力:明暗切换、预览宽度(自适应 / 手机 390 / 平板 820 / 桌面 1280)、源码卡片内搜索、页内索引`,
|
|
198
|
+
`- 契约:组件 ${env.components.count} 个${env.components.configured ? '' : '(未配置 components)'}、模块 ${env.modules.count} 个${env.modules.configured ? '' : '(未配置 modules)'}、插件 ${env.plugins.length} 个`,
|
|
199
|
+
`- 主题:外壳 ${env.theme.ui}、演示画布 ${env.theme.stage};颜色只用 var(--rp-color-*)`,
|
|
200
|
+
'',
|
|
201
|
+
'## 分组',
|
|
202
|
+
'',
|
|
203
|
+
];
|
|
204
|
+
if (!env.groups.length)
|
|
205
|
+
lines.push('(没有分组:playground.config.ts 里配置 groups)');
|
|
206
|
+
for (const group of env.groups)
|
|
207
|
+
lines.push(`- ${group.dir} = ${group.label}`);
|
|
208
|
+
lines.push('', '## globals(场景里 usePlaygroundEnv() 读取)', '');
|
|
209
|
+
lines.push(Object.keys(env.globals).length ? '```json\n' + JSON.stringify(env.globals, null, 2) + '\n```' : '(没有配置 globals)');
|
|
210
|
+
lines.push('', '## 插件', '');
|
|
211
|
+
if (!env.plugins.length) {
|
|
212
|
+
lines.push('(没有配置插件;场景里不能假设存在登录态 / 环境变量)');
|
|
213
|
+
}
|
|
214
|
+
else {
|
|
215
|
+
for (const plugin of env.plugins)
|
|
216
|
+
lines.push(`- ${plugin}`);
|
|
217
|
+
lines.push('', '场景里这样取:');
|
|
218
|
+
lines.push('```ts');
|
|
219
|
+
lines.push("const env = usePlaygroundEnv<{ apiBase: string }>() // globals + 插件返回值合并");
|
|
220
|
+
lines.push("const auth = usePlaygroundPlugin<{ user: { name: string } }>('auth') // name 是插件注册的名字");
|
|
221
|
+
lines.push('```');
|
|
222
|
+
}
|
|
223
|
+
lines.push('', `## 页面(${env.pages.length})`, '', '| 页面 | id | 绑定 | 场景 |', '|---|---|---|---|');
|
|
224
|
+
for (const page of env.pages) {
|
|
225
|
+
const binding = page.component ? `组件 ${page.component}` : page.module ? `模块 ${page.module}` : '内容页';
|
|
226
|
+
lines.push(`| ${page.title} | ${page.id} | ${binding} | ${page.scenes.length} 个 |`);
|
|
227
|
+
}
|
|
228
|
+
return lines.join('\n');
|
|
229
|
+
};
|
|
230
|
+
server.registerTool('env_get', {
|
|
231
|
+
title: '演示环境',
|
|
232
|
+
description: '返回 globals / 插件 / 分组 / 页面清单与外壳能力,写场景前先读它',
|
|
233
|
+
inputSchema: {},
|
|
234
|
+
}, () => ({ content: [{ type: 'text', text: formatEnv(manifest) }] }));
|
|
235
|
+
server.registerTool('scene_scaffold', {
|
|
236
|
+
title: '生成场景骨架',
|
|
237
|
+
description: '按环境规则算出该建的文件路径与内容(不落盘,写入交给客户端的文件工具)',
|
|
238
|
+
inputSchema: {
|
|
239
|
+
title: z.string().min(1).describe('场景标题,如「基础用法」'),
|
|
240
|
+
page: z.string().optional().describe('已有页面 id(如 button/button);新建页时传页目录名'),
|
|
241
|
+
group: z.string().optional().describe('分组目录名,如 button;page 已存在时可省略'),
|
|
242
|
+
component: z.string().optional().describe('这一页演示的组件名,如 MyButton'),
|
|
243
|
+
lang: z.enum(['vue', 'md']).optional().describe('场景类型,默认 vue'),
|
|
244
|
+
section: z.enum(['overview', 'props', 'events', 'exposes', 'slots']).optional(),
|
|
245
|
+
api: z.array(z.string()).optional().describe('这个场景演示的 API 名'),
|
|
246
|
+
slug: z.string().optional().describe('场景文件名(不含扩展名);中文标题请显式传,默认取标题的英文缩写'),
|
|
247
|
+
},
|
|
248
|
+
}, ({ title, page, group, component, lang, section, api, slug }) => {
|
|
249
|
+
const env = manifest.env;
|
|
250
|
+
if (!env) {
|
|
251
|
+
return {
|
|
252
|
+
content: [
|
|
253
|
+
{
|
|
254
|
+
type: 'text',
|
|
255
|
+
text: '这份清单里没有 env 段,无法推算目录。先跑一次 pnpm dev 重新生成清单。',
|
|
256
|
+
},
|
|
257
|
+
],
|
|
258
|
+
isError: true,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
const matchesPage = page
|
|
262
|
+
? env.pages.filter((item) => item.id === page ||
|
|
263
|
+
(group ? item.id === `${group}/${page}` : false) ||
|
|
264
|
+
item.dir.split('/').pop() === page ||
|
|
265
|
+
item.title === page)
|
|
266
|
+
: [];
|
|
267
|
+
if (matchesPage.length > 1) {
|
|
268
|
+
return {
|
|
269
|
+
content: [
|
|
270
|
+
{
|
|
271
|
+
type: 'text',
|
|
272
|
+
text: `"${page}" 命中多个页面:${matchesPage.map((item) => item.id).join(', ')}。请传完整 id。`,
|
|
273
|
+
},
|
|
274
|
+
],
|
|
275
|
+
isError: true,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
const target = matchesPage[0];
|
|
279
|
+
const groupDir = target?.groupDir ?? group;
|
|
280
|
+
if (!groupDir) {
|
|
281
|
+
return {
|
|
282
|
+
content: [
|
|
283
|
+
{
|
|
284
|
+
type: 'text',
|
|
285
|
+
text: `需要知道放在哪个分组。已有分组:${env.groups.map((item) => item.dir).join(', ') || '(无)'}\n` +
|
|
286
|
+
`已有页面:${env.pages.map((item) => item.id).join(', ') || '(无)'}`,
|
|
287
|
+
},
|
|
288
|
+
],
|
|
289
|
+
isError: true,
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
if (!env.groups.some((item) => item.dir === groupDir)) {
|
|
293
|
+
return {
|
|
294
|
+
content: [
|
|
295
|
+
{
|
|
296
|
+
type: 'text',
|
|
297
|
+
text: `分组 "${groupDir}" 不在 playground.config.ts 的 groups 里(现有:${env.groups
|
|
298
|
+
.map((item) => item.dir)
|
|
299
|
+
.join(', ')})。要么先登记分组,要么换一个。`,
|
|
300
|
+
},
|
|
301
|
+
],
|
|
302
|
+
isError: true,
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
const pageDir = target ? (target.dir.split('/').pop() ?? target.id) : kebab(page ?? component ?? groupDir) || groupDir;
|
|
306
|
+
const chosenSlug = slug ?? kebab(title) ?? '';
|
|
307
|
+
if (!chosenSlug) {
|
|
308
|
+
return {
|
|
309
|
+
content: [
|
|
310
|
+
{
|
|
311
|
+
type: 'text',
|
|
312
|
+
text: `标题「${title}」不是英文,算不出文件名。请再传一个 slug(如 basic / size)。`,
|
|
313
|
+
},
|
|
314
|
+
],
|
|
315
|
+
isError: true,
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
const componentName = component ?? target?.component;
|
|
319
|
+
const componentRel = componentName
|
|
320
|
+
? manifest.components.find((item) => item.name === componentName)?.rel
|
|
321
|
+
: undefined;
|
|
322
|
+
if (componentName && !componentRel) {
|
|
323
|
+
return {
|
|
324
|
+
content: [
|
|
325
|
+
{
|
|
326
|
+
type: 'text',
|
|
327
|
+
text: `组件 "${componentName}" 不在组件契约里(可用:${manifest.components.map((item) => item.name).join(', ') || '无'})。`,
|
|
328
|
+
},
|
|
329
|
+
],
|
|
330
|
+
isError: true,
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
const scaffold = scaffoldScene({
|
|
334
|
+
sceneRoot: env.sceneRoot,
|
|
335
|
+
groupDir,
|
|
336
|
+
pageDir,
|
|
337
|
+
slug: chosenSlug,
|
|
338
|
+
title,
|
|
339
|
+
lang: lang ?? 'vue',
|
|
340
|
+
component: componentName,
|
|
341
|
+
componentRel,
|
|
342
|
+
section,
|
|
343
|
+
api,
|
|
344
|
+
});
|
|
345
|
+
const lines = [`# 要写入的文件`, ''];
|
|
346
|
+
lines.push('## ' + scaffold.sceneFile, '', '```' + (lang ?? 'vue'), scaffold.sceneContent.trimEnd(), '```', '');
|
|
347
|
+
if (fs.existsSync(path.join(project, scaffold.pageMetaFile))) {
|
|
348
|
+
lines.push(`页面目录已存在(${scaffold.pageMetaFile}),只写场景文件即可。`, '');
|
|
349
|
+
}
|
|
350
|
+
else {
|
|
351
|
+
lines.push('## ' + scaffold.pageMetaFile + '(新页面,需要建)', '', '```ts', scaffold.pageMetaContent.trimEnd(), '```', '');
|
|
352
|
+
}
|
|
353
|
+
lines.push(`写完之后调 scene_validate(path=${scaffold.sceneFile})校验;dev server 会自己热更新。`);
|
|
354
|
+
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
355
|
+
});
|
|
356
|
+
server.registerTool('scene_validate', {
|
|
357
|
+
title: '校验场景',
|
|
358
|
+
description: '按框架的硬约束检查场景文件:目录层级、样式 scoped、禁用全局选择器、sceneMeta 字段、页绑定',
|
|
359
|
+
inputSchema: {
|
|
360
|
+
path: z.string().optional().describe('只校验这个文件(相对项目根);不传则全量校验'),
|
|
361
|
+
},
|
|
362
|
+
}, ({ path: target }) => {
|
|
363
|
+
if (!manifest.env) {
|
|
364
|
+
return {
|
|
365
|
+
content: [{ type: 'text', text: '这份清单里没有 env 段,无法校验。先跑一次 pnpm dev 重新生成清单。' }],
|
|
366
|
+
isError: true,
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
const issues = validateScenes(project, manifest, target);
|
|
370
|
+
const errors = issues.filter((item) => item.level === 'error');
|
|
371
|
+
const warns = issues.filter((item) => item.level === 'warn');
|
|
372
|
+
const scope = target ? `文件 ${target}` : `${manifest.scenes.length} 个场景`;
|
|
373
|
+
if (!issues.length) {
|
|
374
|
+
return { content: [{ type: 'text', text: `校验通过(${scope})。` }] };
|
|
375
|
+
}
|
|
376
|
+
const lines = [`${scope}:${errors.length} 个错误 / ${warns.length} 个提醒`, ''];
|
|
377
|
+
for (const issue of [...errors, ...warns]) {
|
|
378
|
+
lines.push(`- [${issue.level === 'error' ? '错误' : '提醒'}] ${issue.file}${issue.line ? `:${issue.line}` : ''} · ${issue.rule}`);
|
|
379
|
+
lines.push(` ${issue.message}`);
|
|
380
|
+
}
|
|
381
|
+
return { content: [{ type: 'text', text: lines.join('\n') }], isError: errors.length > 0 };
|
|
382
|
+
});
|
|
147
383
|
const transport = new StdioServerTransport();
|
|
148
384
|
await server.connect(transport);
|
|
149
385
|
console.error(`[rinova-playground-mcp] 已启动,清单:${manifestPath}(${manifest.components.length} 个组件)`);
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 场景校验:把插件的构建期约束抽成可复跑的一套规则,供 MCP 的 `scene_validate` 调用。
|
|
3
|
+
*
|
|
4
|
+
* 刻意不依赖任何包(只用 fs / path):脚本、MCP、verify 都能直接 import 同一份实现。
|
|
5
|
+
*/
|
|
6
|
+
export interface SceneIssue {
|
|
7
|
+
level: 'error' | 'warn';
|
|
8
|
+
/** 规则名,报告里直接显示 */
|
|
9
|
+
rule: string;
|
|
10
|
+
/** 相对项目根的路径 */
|
|
11
|
+
file: string;
|
|
12
|
+
line?: number;
|
|
13
|
+
message: string;
|
|
14
|
+
}
|
|
15
|
+
export interface ValidatePage {
|
|
16
|
+
id: string;
|
|
17
|
+
group: string;
|
|
18
|
+
groupDir: string;
|
|
19
|
+
dir: string;
|
|
20
|
+
component?: string;
|
|
21
|
+
module?: string;
|
|
22
|
+
showSource?: boolean;
|
|
23
|
+
doc?: boolean;
|
|
24
|
+
}
|
|
25
|
+
export interface ValidateManifest {
|
|
26
|
+
env?: {
|
|
27
|
+
sceneRoot?: string;
|
|
28
|
+
pages?: ValidatePage[];
|
|
29
|
+
};
|
|
30
|
+
components: Array<{
|
|
31
|
+
name: string;
|
|
32
|
+
}>;
|
|
33
|
+
modules: Array<{
|
|
34
|
+
name: string;
|
|
35
|
+
}>;
|
|
36
|
+
scenes: Array<{
|
|
37
|
+
id: string;
|
|
38
|
+
rel: string;
|
|
39
|
+
lang?: 'vue' | 'md';
|
|
40
|
+
}>;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* 校验场景与页面绑定。
|
|
44
|
+
*
|
|
45
|
+
* `only` 传一个相对路径时只校验该文件(用于「写完立刻验」),否则全量。
|
|
46
|
+
*/
|
|
47
|
+
export declare const validateScenes: (root: string, manifest: ValidateManifest, only?: string) => SceneIssue[];
|
|
48
|
+
/** 场景骨架:`scene_scaffold` 用它生成文件内容(不落盘,写入交给客户端) */
|
|
49
|
+
export interface ScaffoldInput {
|
|
50
|
+
sceneRoot: string;
|
|
51
|
+
groupDir: string;
|
|
52
|
+
pageDir: string;
|
|
53
|
+
/** 场景文件名(不含扩展名) */
|
|
54
|
+
slug: string;
|
|
55
|
+
title: string;
|
|
56
|
+
lang: 'vue' | 'md';
|
|
57
|
+
component?: string;
|
|
58
|
+
/** 组件源码相对项目根的路径(来自清单),用来算相对 import */
|
|
59
|
+
componentRel?: string;
|
|
60
|
+
section?: string;
|
|
61
|
+
api?: string[];
|
|
62
|
+
}
|
|
63
|
+
export declare const scaffoldScene: (input: ScaffoldInput) => {
|
|
64
|
+
sceneFile: string;
|
|
65
|
+
sceneContent: string;
|
|
66
|
+
pageMetaFile: string;
|
|
67
|
+
pageMetaContent: string;
|
|
68
|
+
};
|
package/dist/validate.js
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
/** sceneMeta 允许的字段(拼错的字段构建期会被静默忽略,所以这里显式列出来) */
|
|
4
|
+
const SCENE_META_FIELDS = new Set([
|
|
5
|
+
'title',
|
|
6
|
+
'order',
|
|
7
|
+
'tags',
|
|
8
|
+
'description',
|
|
9
|
+
'hideInNav',
|
|
10
|
+
'files',
|
|
11
|
+
'section',
|
|
12
|
+
'api',
|
|
13
|
+
'showSource',
|
|
14
|
+
]);
|
|
15
|
+
const toPosix = (value) => value.split(path.sep).join('/');
|
|
16
|
+
const lineOf = (source, index) => source.slice(0, index).split('\n').length;
|
|
17
|
+
/** 极简 frontmatter 读取:只认 `---` 头里的 `key: value` */
|
|
18
|
+
const readFrontmatter = (source) => {
|
|
19
|
+
const match = source.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
20
|
+
if (!match)
|
|
21
|
+
return {};
|
|
22
|
+
const data = {};
|
|
23
|
+
for (const raw of (match[1] ?? '').split(/\r?\n/)) {
|
|
24
|
+
const line = raw.trim();
|
|
25
|
+
if (!line || line.startsWith('#'))
|
|
26
|
+
continue;
|
|
27
|
+
const index = line.indexOf(':');
|
|
28
|
+
if (index < 1)
|
|
29
|
+
continue;
|
|
30
|
+
data[line.slice(0, index).trim()] = line.slice(index + 1).trim();
|
|
31
|
+
}
|
|
32
|
+
return data;
|
|
33
|
+
};
|
|
34
|
+
/** 取出 `<script>`(不含 setup)块里的 sceneMeta 对象原文 */
|
|
35
|
+
const readSceneMetaBlock = (source) => {
|
|
36
|
+
const scripts = [...source.matchAll(/<script([^>]*)>([\s\S]*?)<\/script>/g)];
|
|
37
|
+
for (const script of scripts) {
|
|
38
|
+
if (/\bsetup\b/.test(script[1] ?? ''))
|
|
39
|
+
continue;
|
|
40
|
+
const body = script[2] ?? '';
|
|
41
|
+
if (body.includes('sceneMeta'))
|
|
42
|
+
return body;
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
};
|
|
46
|
+
const checkVueScene = (rel, source) => {
|
|
47
|
+
const issues = [];
|
|
48
|
+
for (const style of source.matchAll(/<style([^>]*)>([\s\S]*?)<\/style>/g)) {
|
|
49
|
+
const attrs = style[1] ?? '';
|
|
50
|
+
const body = style[2] ?? '';
|
|
51
|
+
const start = style.index ?? 0;
|
|
52
|
+
if (!/\bscoped\b/.test(attrs)) {
|
|
53
|
+
issues.push({
|
|
54
|
+
level: 'error',
|
|
55
|
+
rule: 'style-scoped',
|
|
56
|
+
file: rel,
|
|
57
|
+
line: lineOf(source, start),
|
|
58
|
+
message: '<style> 缺少 scoped,样式会污染其它场景',
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
for (const hit of body.matchAll(/(?:^|[},])\s*(:root|html|body)\b/gm)) {
|
|
62
|
+
issues.push({
|
|
63
|
+
level: 'error',
|
|
64
|
+
rule: 'style-global',
|
|
65
|
+
file: rel,
|
|
66
|
+
line: lineOf(source, start + (hit.index ?? 0)),
|
|
67
|
+
message: `样式命中 \`${hit[2]}\`,会影响整个 playground`,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (!/<style/.test(source)) {
|
|
72
|
+
// 不需要样式是合法写法,这里不报
|
|
73
|
+
}
|
|
74
|
+
if (/\buseScenePayload\b/.test(source)) {
|
|
75
|
+
issues.push({
|
|
76
|
+
level: 'error',
|
|
77
|
+
rule: 'deprecated-api',
|
|
78
|
+
file: rel,
|
|
79
|
+
line: lineOf(source, source.search(/\buseScenePayload\b/)),
|
|
80
|
+
message: 'useScenePayload 已删除,改用 useSceneArgs / useSceneOutput',
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
const meta = readSceneMetaBlock(source);
|
|
84
|
+
if (meta) {
|
|
85
|
+
for (const field of meta.matchAll(/(?:^|[{,;\n])\s*([A-Za-z_$][\w$]*)\s*:/gm)) {
|
|
86
|
+
const name = field[1] ?? '';
|
|
87
|
+
if (SCENE_META_FIELDS.has(name))
|
|
88
|
+
continue;
|
|
89
|
+
issues.push({
|
|
90
|
+
level: 'warn',
|
|
91
|
+
rule: 'scene-meta-field',
|
|
92
|
+
file: rel,
|
|
93
|
+
line: lineOf(source, (source.indexOf(meta) + (field.index ?? 0))),
|
|
94
|
+
message: `sceneMeta 里没有 \`${name}\` 这个字段(会被静默忽略)`,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
if (/\bpayload\s*:/.test(meta)) {
|
|
98
|
+
issues.push({
|
|
99
|
+
level: 'error',
|
|
100
|
+
rule: 'deprecated-api',
|
|
101
|
+
file: rel,
|
|
102
|
+
line: lineOf(source, source.indexOf(meta)),
|
|
103
|
+
message: 'sceneMeta.payload 不生效,改用 useSceneArgs / useSceneOutput',
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
else if (source.includes('sceneMeta')) {
|
|
108
|
+
issues.push({
|
|
109
|
+
level: 'warn',
|
|
110
|
+
rule: 'scene-meta-block',
|
|
111
|
+
file: rel,
|
|
112
|
+
message: 'sceneMeta 必须写在普通 <script> 块里(<script setup> 里的构建期读不到)',
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
return issues;
|
|
116
|
+
};
|
|
117
|
+
const checkMarkdownScene = (rel, source) => {
|
|
118
|
+
const issues = [];
|
|
119
|
+
const data = readFrontmatter(source);
|
|
120
|
+
if (!data.title) {
|
|
121
|
+
issues.push({ level: 'warn', rule: 'md-title', file: rel, line: 1, message: 'Markdown 场景缺少 frontmatter `title`' });
|
|
122
|
+
}
|
|
123
|
+
if (data.showSource && data.showSource !== 'false') {
|
|
124
|
+
issues.push({
|
|
125
|
+
level: 'warn',
|
|
126
|
+
rule: 'md-source',
|
|
127
|
+
file: rel,
|
|
128
|
+
line: 1,
|
|
129
|
+
message: 'Markdown 场景默认不展示源码,这里的 showSource 通常不需要',
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
return issues;
|
|
133
|
+
};
|
|
134
|
+
/**
|
|
135
|
+
* 校验场景与页面绑定。
|
|
136
|
+
*
|
|
137
|
+
* `only` 传一个相对路径时只校验该文件(用于「写完立刻验」),否则全量。
|
|
138
|
+
*/
|
|
139
|
+
export const validateScenes = (root, manifest, only) => {
|
|
140
|
+
const issues = [];
|
|
141
|
+
const sceneRoot = toPosix(manifest.env?.sceneRoot ?? 'src/scenes').replace(/\/$/, '');
|
|
142
|
+
const pages = manifest.env?.pages ?? [];
|
|
143
|
+
const componentNames = new Set(manifest.components.map((item) => item.name));
|
|
144
|
+
const moduleNames = new Set(manifest.modules.map((item) => item.name));
|
|
145
|
+
const target = only ? toPosix(only).replace(/^\.\//, '') : '';
|
|
146
|
+
// 页绑定:组件 / 模块必须真的在契约里(只校验单个文件时跳过,避免报告与这次改动无关的页面)
|
|
147
|
+
for (const page of target ? [] : pages) {
|
|
148
|
+
const metaFile = `${page.dir}/meta.ts`;
|
|
149
|
+
if (page.component && !componentNames.has(page.component)) {
|
|
150
|
+
issues.push({
|
|
151
|
+
level: 'error',
|
|
152
|
+
rule: 'page-component',
|
|
153
|
+
file: metaFile,
|
|
154
|
+
message: `pageMeta.component = "${page.component}" 不在组件契约里(可用:${[...componentNames].join(', ') || '无'})`,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
if (page.module && !moduleNames.has(page.module)) {
|
|
158
|
+
issues.push({
|
|
159
|
+
level: 'error',
|
|
160
|
+
rule: 'page-module',
|
|
161
|
+
file: metaFile,
|
|
162
|
+
message: `pageMeta.module = "${page.module}" 不在模块契约里(可用:${[...moduleNames].join(', ') || '无'})`,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
if (!page.component && !page.module && page.showSource !== false && !page.doc) {
|
|
166
|
+
issues.push({
|
|
167
|
+
level: 'warn',
|
|
168
|
+
rule: 'page-content-source',
|
|
169
|
+
file: metaFile,
|
|
170
|
+
message: '内容页(没有 component / module)应写 showSource: false,否则源码卡片会挂在文档页上',
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
const pageDirs = new Set(pages.map((page) => toPosix(page.dir)));
|
|
175
|
+
const entries = [...manifest.scenes];
|
|
176
|
+
// 刚写出来的场景还没被扫进清单:指定 path 时也校验(读磁盘,不依赖清单)
|
|
177
|
+
if (target && !entries.some((scene) => toPosix(scene.rel ?? '') === target)) {
|
|
178
|
+
entries.push({ id: target, rel: target, lang: target.endsWith('.md') ? 'md' : 'vue' });
|
|
179
|
+
}
|
|
180
|
+
for (const scene of entries) {
|
|
181
|
+
const rel = toPosix(scene.rel ?? '');
|
|
182
|
+
if (!rel)
|
|
183
|
+
continue;
|
|
184
|
+
if (target && rel !== target)
|
|
185
|
+
continue;
|
|
186
|
+
const segments = rel.split('/');
|
|
187
|
+
const depth = segments.length - sceneRoot.split('/').length;
|
|
188
|
+
if (!rel.startsWith(`${sceneRoot}/`)) {
|
|
189
|
+
issues.push({
|
|
190
|
+
level: 'error',
|
|
191
|
+
rule: 'scene-dir',
|
|
192
|
+
file: rel,
|
|
193
|
+
message: `场景必须放在 \`${sceneRoot}/\` 下面`,
|
|
194
|
+
});
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
if (depth < 3 || depth > 4) {
|
|
198
|
+
issues.push({
|
|
199
|
+
level: 'error',
|
|
200
|
+
rule: 'scene-dir',
|
|
201
|
+
file: rel,
|
|
202
|
+
line: 1,
|
|
203
|
+
message: `目录层级是 ${depth} 层,必须是 <分组>/<页面>/<场景>(3 层)或 <分组>/<页面>/<子页>/<场景>(4 层)`,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
const pageDir = segments.slice(0, -1).join('/');
|
|
207
|
+
if (pages.length && !pageDirs.has(pageDir)) {
|
|
208
|
+
issues.push({
|
|
209
|
+
level: 'error',
|
|
210
|
+
rule: 'scene-page',
|
|
211
|
+
file: rel,
|
|
212
|
+
message: `所在目录 \`${pageDir}\` 不是任何已登记的页面(缺 meta.ts?)`,
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
let source = '';
|
|
216
|
+
try {
|
|
217
|
+
source = fs.readFileSync(path.join(root, rel), 'utf8');
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
issues.push({ level: 'error', rule: 'scene-missing', file: rel, message: '读不到文件' });
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
if ((scene.lang ?? (rel.endsWith('.md') ? 'md' : 'vue')) === 'md') {
|
|
224
|
+
issues.push(...checkMarkdownScene(rel, source));
|
|
225
|
+
}
|
|
226
|
+
else {
|
|
227
|
+
issues.push(...checkVueScene(rel, source));
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return issues.sort((a, b) => a.file.localeCompare(b.file) || (a.line ?? 0) - (b.line ?? 0) || a.level.localeCompare(b.level));
|
|
231
|
+
};
|
|
232
|
+
/** 相对 import:从场景目录指向组件文件 */
|
|
233
|
+
const relativeImport = (fromDir, targetRel) => {
|
|
234
|
+
const rel = path.posix.relative(fromDir, targetRel);
|
|
235
|
+
return rel.startsWith('.') ? rel : `./${rel}`;
|
|
236
|
+
};
|
|
237
|
+
export const scaffoldScene = (input) => {
|
|
238
|
+
const dir = `${input.sceneRoot}/${input.groupDir}/${input.pageDir}`;
|
|
239
|
+
const component = input.component || 'MyComponent';
|
|
240
|
+
const importPath = input.componentRel ? relativeImport(dir, input.componentRel) : `@/lib/${component}.vue`;
|
|
241
|
+
const importHint = input.componentRel ? '' : ` // 换成真实路径:清单里没有 ${component}`;
|
|
242
|
+
const section = input.section || 'overview';
|
|
243
|
+
const metaLines = [
|
|
244
|
+
'export const sceneMeta = {',
|
|
245
|
+
` title: '${input.title}',`,
|
|
246
|
+
` section: '${section}',`,
|
|
247
|
+
...(input.api?.length ? [` api: [${input.api.map((name) => `'${name}'`).join(', ')}],`] : []),
|
|
248
|
+
'}',
|
|
249
|
+
];
|
|
250
|
+
const sceneContent = input.lang === 'md'
|
|
251
|
+
? ['---', `title: ${input.title}`, '---', '', '正文……', ''].join('\n')
|
|
252
|
+
: [
|
|
253
|
+
'<script setup lang="ts">',
|
|
254
|
+
"import { useSceneArgs } from '@rinova/playground'",
|
|
255
|
+
`import ${component} from '${importPath}'`,
|
|
256
|
+
...(importHint ? [importHint] : []),
|
|
257
|
+
'',
|
|
258
|
+
'const args = useSceneArgs({})',
|
|
259
|
+
'</script>',
|
|
260
|
+
'',
|
|
261
|
+
'<script lang="ts">',
|
|
262
|
+
...metaLines,
|
|
263
|
+
'</script>',
|
|
264
|
+
'',
|
|
265
|
+
'<template>',
|
|
266
|
+
' <div class="demo">',
|
|
267
|
+
` <${component} v-bind="args" />`,
|
|
268
|
+
' </div>',
|
|
269
|
+
'</template>',
|
|
270
|
+
'',
|
|
271
|
+
'<style scoped lang="less">',
|
|
272
|
+
'.demo {',
|
|
273
|
+
' display: flex;',
|
|
274
|
+
' gap: 8px;',
|
|
275
|
+
'}',
|
|
276
|
+
'</style>',
|
|
277
|
+
'',
|
|
278
|
+
].join('\n');
|
|
279
|
+
const pageMetaContent = [
|
|
280
|
+
"import { definePageMeta } from '@rinova/playground'",
|
|
281
|
+
'',
|
|
282
|
+
'export const pageMeta = definePageMeta({',
|
|
283
|
+
` title: '${input.pageDir}',`,
|
|
284
|
+
...(input.component ? [` component: '${input.component}',`] : []),
|
|
285
|
+
'})',
|
|
286
|
+
'',
|
|
287
|
+
].join('\n');
|
|
288
|
+
return {
|
|
289
|
+
sceneFile: `${dir}/${input.slug}.${input.lang}`,
|
|
290
|
+
sceneContent,
|
|
291
|
+
pageMetaFile: `${dir}/meta.ts`,
|
|
292
|
+
pageMetaContent,
|
|
293
|
+
};
|
|
294
|
+
};
|