@rinova/playground-mcp 0.5.0 → 0.7.1

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/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) => {
@@ -21,19 +22,74 @@ const locate = (root, rel, needle) => {
21
22
  codeLines.find((item) => word.test(item.line));
22
23
  return hit ? `${rel}:${hit.index + 1}` : rel;
23
24
  };
25
+ const USAGE = `rinova-playground-mcp —— 把契约清单暴露给 AI 编码工具(只读)
26
+
27
+ 用法:
28
+ rinova-playground-mcp [--project <目录>] [--manifest <文件>]
29
+
30
+ 选项:
31
+ --project <目录> 项目根(默认:当前目录,并向上找 .playground/manifest.json)
32
+ --manifest <文件> 直接指定清单文件
33
+ -h, --help 显示帮助
34
+
35
+ 清单由 @rinova/playground-vite 在 pnpm dev / pnpm build 时生成,需要 playground.config.ts 里配置 components。
36
+ `;
37
+ /** 从 start 向上最多 6 层找 .playground/manifest.json(客户端启动目录常常是包内子目录) */
38
+ const findManifestUp = (start) => {
39
+ let dir = path.resolve(start);
40
+ for (let depth = 0; depth < 6; depth += 1) {
41
+ const candidate = path.join(dir, '.playground/manifest.json');
42
+ if (fs.existsSync(candidate))
43
+ return candidate;
44
+ const parent = path.dirname(dir);
45
+ if (parent === dir)
46
+ break;
47
+ dir = parent;
48
+ }
49
+ return null;
50
+ };
51
+ const readStamp = (file) => {
52
+ try {
53
+ const stat = fs.statSync(file);
54
+ return `${stat.mtimeMs}:${stat.size}`;
55
+ }
56
+ catch {
57
+ return 'missing';
58
+ }
59
+ };
24
60
  const parseArgs = (argv) => {
25
61
  const args = argv.slice(2);
26
- const projectIndex = args.indexOf('--project');
27
- const manifestIndex = args.indexOf('--manifest');
28
- const project = path.resolve(projectIndex > -1 ? (args[projectIndex + 1] ?? '.') : '.');
29
- const manifest = manifestIndex > -1
30
- ? path.resolve(args[manifestIndex + 1] ?? '')
31
- : path.join(project, '.playground/manifest.json');
32
- return { project, manifest };
62
+ if (args.includes('--help') || args.includes('-h')) {
63
+ process.stdout.write(USAGE);
64
+ process.exit(0);
65
+ }
66
+ const readFlag = (flag) => {
67
+ const index = args.indexOf(flag);
68
+ return index > -1 ? args[index + 1] : undefined;
69
+ };
70
+ const manifestArg = readFlag('--manifest');
71
+ if (manifestArg) {
72
+ const manifest = path.resolve(manifestArg);
73
+ return { project: path.dirname(path.dirname(manifest)), manifest, searchFrom: path.dirname(manifest) };
74
+ }
75
+ const projectArg = readFlag('--project');
76
+ const base = projectArg ? path.resolve(projectArg) : process.cwd();
77
+ const found = findManifestUp(base);
78
+ return {
79
+ project: found ? path.dirname(path.dirname(found)) : base,
80
+ manifest: found ?? path.join(base, '.playground/manifest.json'),
81
+ searchFrom: base,
82
+ };
33
83
  };
34
- const loadManifest = (file) => {
84
+ const loadManifest = (file, searchFrom) => {
35
85
  if (!fs.existsSync(file)) {
36
- throw new Error(`找不到契约清单:${file}\n先运行 pnpm dev 或 pnpm build 生成(需要 playground.config.ts 里配置 components)`);
86
+ throw new Error([
87
+ `找不到契约清单:${file}`,
88
+ `(从 ${searchFrom} 向上找了 6 层)`,
89
+ '',
90
+ '生成方式:在项目里配置 playground.config.ts 的 components(可选 modules),然后跑一次 pnpm dev 或 pnpm build。',
91
+ '也可以在客户端配置里用 --project <项目根> 或 --manifest <.playground/manifest.json 的绝对路径> 显式指定。',
92
+ ].join('\n'));
37
93
  }
38
94
  return JSON.parse(fs.readFileSync(file, 'utf8'));
39
95
  };
@@ -106,22 +162,40 @@ const matches = (component, query) => {
106
162
  component.scenes.some((scene) => scene.toLowerCase().includes(needle)));
107
163
  };
108
164
  const main = async () => {
109
- const { project, manifest: manifestPath } = parseArgs(process.argv);
165
+ const { project, manifest: manifestPath, searchFrom } = parseArgs(process.argv);
110
166
  let manifest;
111
167
  try {
112
- manifest = loadManifest(manifestPath);
168
+ manifest = loadManifest(manifestPath, searchFrom);
113
169
  }
114
170
  catch (error) {
115
171
  console.error(error instanceof Error ? error.message : error);
116
172
  process.exitCode = 1;
117
173
  return;
118
174
  }
119
- const server = new McpServer({ name: 'rinova-playground', version: '0.5.0' });
175
+ /**
176
+ * 清单是构建产物,dev server 会在场景 / 组件变化后重写它。
177
+ * 每次工具调用前比一下 mtime+size,变了就重读 —— 否则 MCP 会一直拿启动那一刻的页面表。
178
+ */
179
+ let manifestStamp = readStamp(manifestPath);
180
+ const refresh = () => {
181
+ const stamp = readStamp(manifestPath);
182
+ if (stamp === manifestStamp)
183
+ return;
184
+ try {
185
+ manifest = loadManifest(manifestPath, searchFrom);
186
+ manifestStamp = stamp;
187
+ }
188
+ catch {
189
+ // 清单正在被写(JSON 半个文件)或暂时不可读:沿用上一次的,下次调用再试
190
+ }
191
+ };
192
+ const server = new McpServer({ name: 'rinova-playground', version: '0.7.1' });
120
193
  server.registerTool('capability_search', {
121
194
  title: '搜索组件能力',
122
195
  description: '按组件名 / prop 名 / 场景 id 搜索,返回带文件行号的摘要',
123
196
  inputSchema: { query: z.string().min(1).describe('关键词'), limit: z.number().int().min(1).max(20).optional() },
124
197
  }, ({ query, limit }) => {
198
+ refresh();
125
199
  const hits = manifest.components.filter((component) => matches(component, query)).slice(0, limit ?? 5);
126
200
  if (!hits.length) {
127
201
  return { content: [{ type: 'text', text: `没有匹配 "${query}" 的组件。已知组件:${manifest.components.map((item) => item.name).join(', ')}` }] };
@@ -136,6 +210,7 @@ const main = async () => {
136
210
  description: '返回组件的 props / events / slots / exposed、源码锚点与相关场景',
137
211
  inputSchema: { component: z.string().min(1).describe('组件名,如 MyButton') },
138
212
  }, ({ component }) => {
213
+ refresh();
139
214
  const found = manifest.components.find((item) => item.name.toLowerCase() === component.toLowerCase() || item.rel.endsWith(component));
140
215
  if (!found) {
141
216
  return {
@@ -152,6 +227,7 @@ const main = async () => {
152
227
  description: '列出全部场景;传 component 则只列该组件相关的场景',
153
228
  inputSchema: { component: z.string().optional().describe('组件名,可选') },
154
229
  }, ({ component }) => {
230
+ refresh();
155
231
  const scenes = component
156
232
  ? manifest.scenes.filter((scene) => {
157
233
  const target = manifest.components.find((item) => item.name === component);
@@ -170,12 +246,221 @@ const main = async () => {
170
246
  description: '列出没有场景覆盖的 props,作为补场景的待办',
171
247
  inputSchema: {},
172
248
  }, () => {
249
+ refresh();
173
250
  const rows = manifest.components.flatMap((component) => component.uncoveredProps.map((prop) => `- ${component.name}.${prop} ← ${locate(project, component.rel, prop)}`));
174
251
  const total = manifest.components.reduce((sum, component) => sum + component.props.length, 0);
175
252
  const uncovered = rows.length;
176
253
  const head = `覆盖 ${total - uncovered}/${total}|清单生成时间 ${manifest.generatedAt}`;
177
254
  return { content: [{ type: 'text', text: rows.length ? `${head}\n\n${rows.join('\n')}` : `${head}\n\n全部 props 都有场景覆盖。` }] };
178
255
  });
256
+ const kebab = (input) => {
257
+ const ascii = input
258
+ .toLowerCase()
259
+ .replace(/[^a-z0-9]+/g, '-')
260
+ .replace(/^-+|-+$/g, '');
261
+ return ascii || '';
262
+ };
263
+ const formatEnv = (manifest) => {
264
+ const env = manifest.env;
265
+ if (!env) {
266
+ return '这份清单里没有 env 段(0.5 及更早的清单没有)。重新跑一次 pnpm dev 生成:清单由 @rinova/playground-vite 在扫描场景时写入。';
267
+ }
268
+ const lines = [
269
+ `# 演示环境:${env.title}`,
270
+ '',
271
+ `- 场景目录:${env.sceneRoot}(严格三层:<分组>/<页面>/<场景>;子组件页可四层)`,
272
+ `- 路由:history 路由 ${env.routeBase} + <分组>/<页面>,页内锚点 #<场景名>`,
273
+ `- 外壳能力:明暗切换、预览宽度(自适应 / 手机 390 / 平板 820 / 桌面 1280)、源码卡片内搜索、页内索引`,
274
+ `- 契约:组件 ${env.components.count} 个${env.components.configured ? '' : '(未配置 components)'}、模块 ${env.modules.count} 个${env.modules.configured ? '' : '(未配置 modules)'}、插件 ${env.plugins.length} 个`,
275
+ `- 主题:外壳 ${env.theme.ui}、演示画布 ${env.theme.stage};颜色只用 var(--rp-color-*)`,
276
+ '',
277
+ '## 分组',
278
+ '',
279
+ ];
280
+ if (!env.groups.length)
281
+ lines.push('(没有分组:playground.config.ts 里配置 groups)');
282
+ for (const group of env.groups)
283
+ lines.push(`- ${group.dir} = ${group.label}`);
284
+ lines.push('', '## globals(场景里 usePlaygroundEnv() 读取)', '');
285
+ lines.push(Object.keys(env.globals).length ? '```json\n' + JSON.stringify(env.globals, null, 2) + '\n```' : '(没有配置 globals)');
286
+ lines.push('', '## 插件', '');
287
+ if (!env.plugins.length) {
288
+ lines.push('(没有配置插件;场景里不能假设存在登录态 / 环境变量)');
289
+ }
290
+ else {
291
+ for (const plugin of env.plugins)
292
+ lines.push(`- ${plugin}`);
293
+ lines.push('', '场景里这样取:');
294
+ lines.push('```ts');
295
+ lines.push("const env = usePlaygroundEnv<{ apiBase: string }>() // globals + 插件返回值合并");
296
+ lines.push("const auth = usePlaygroundPlugin<{ user: { name: string } }>('auth') // name 是插件注册的名字");
297
+ lines.push('```');
298
+ }
299
+ lines.push('', `## 页面(${env.pages.length})`, '', '| 页面 | id | 绑定 | 场景 |', '|---|---|---|---|');
300
+ for (const page of env.pages) {
301
+ const binding = page.component ? `组件 ${page.component}` : page.module ? `模块 ${page.module}` : '内容页';
302
+ lines.push(`| ${page.title} | ${page.id} | ${binding} | ${page.scenes.length} 个 |`);
303
+ }
304
+ return lines.join('\n');
305
+ };
306
+ server.registerTool('env_get', {
307
+ title: '演示环境',
308
+ description: '返回 globals / 插件 / 分组 / 页面清单与外壳能力,写场景前先读它',
309
+ inputSchema: {},
310
+ }, () => {
311
+ refresh();
312
+ return { content: [{ type: 'text', text: formatEnv(manifest) }] };
313
+ });
314
+ server.registerTool('scene_scaffold', {
315
+ title: '生成场景骨架',
316
+ description: '按环境规则算出该建的文件路径与内容(不落盘,写入交给客户端的文件工具)',
317
+ inputSchema: {
318
+ title: z.string().min(1).describe('场景标题,如「基础用法」'),
319
+ page: z.string().optional().describe('已有页面 id(如 button/button);新建页时传页目录名'),
320
+ group: z.string().optional().describe('分组目录名,如 button;page 已存在时可省略'),
321
+ component: z.string().optional().describe('这一页演示的组件名,如 MyButton'),
322
+ lang: z.enum(['vue', 'md']).optional().describe('场景类型,默认 vue'),
323
+ section: z.enum(['overview', 'props', 'events', 'exposes', 'slots']).optional(),
324
+ api: z.array(z.string()).optional().describe('这个场景演示的 API 名'),
325
+ slug: z.string().optional().describe('场景文件名(不含扩展名);中文标题请显式传,默认取标题的英文缩写'),
326
+ },
327
+ }, ({ title, page, group, component, lang, section, api, slug }) => {
328
+ refresh();
329
+ const env = manifest.env;
330
+ if (!env) {
331
+ return {
332
+ content: [
333
+ {
334
+ type: 'text',
335
+ text: '这份清单里没有 env 段,无法推算目录。先跑一次 pnpm dev 重新生成清单。',
336
+ },
337
+ ],
338
+ isError: true,
339
+ };
340
+ }
341
+ const matchesPage = page
342
+ ? env.pages.filter((item) => item.id === page ||
343
+ (group ? item.id === `${group}/${page}` : false) ||
344
+ item.dir.split('/').pop() === page ||
345
+ item.title === page)
346
+ : [];
347
+ if (matchesPage.length > 1) {
348
+ return {
349
+ content: [
350
+ {
351
+ type: 'text',
352
+ text: `"${page}" 命中多个页面:${matchesPage.map((item) => item.id).join(', ')}。请传完整 id。`,
353
+ },
354
+ ],
355
+ isError: true,
356
+ };
357
+ }
358
+ const target = matchesPage[0];
359
+ const groupDir = target?.groupDir ?? group;
360
+ if (!groupDir) {
361
+ return {
362
+ content: [
363
+ {
364
+ type: 'text',
365
+ text: `需要知道放在哪个分组。已有分组:${env.groups.map((item) => item.dir).join(', ') || '(无)'}\n` +
366
+ `已有页面:${env.pages.map((item) => item.id).join(', ') || '(无)'}`,
367
+ },
368
+ ],
369
+ isError: true,
370
+ };
371
+ }
372
+ if (!env.groups.some((item) => item.dir === groupDir)) {
373
+ return {
374
+ content: [
375
+ {
376
+ type: 'text',
377
+ text: `分组 "${groupDir}" 不在 playground.config.ts 的 groups 里(现有:${env.groups
378
+ .map((item) => item.dir)
379
+ .join(', ')})。要么先登记分组,要么换一个。`,
380
+ },
381
+ ],
382
+ isError: true,
383
+ };
384
+ }
385
+ const pageDir = target ? (target.dir.split('/').pop() ?? target.id) : kebab(page ?? component ?? groupDir) || groupDir;
386
+ const chosenSlug = slug ?? kebab(title) ?? '';
387
+ if (!chosenSlug) {
388
+ return {
389
+ content: [
390
+ {
391
+ type: 'text',
392
+ text: `标题「${title}」不是英文,算不出文件名。请再传一个 slug(如 basic / size)。`,
393
+ },
394
+ ],
395
+ isError: true,
396
+ };
397
+ }
398
+ const componentName = component ?? target?.component;
399
+ const componentRel = componentName
400
+ ? manifest.components.find((item) => item.name === componentName)?.rel
401
+ : undefined;
402
+ if (componentName && !componentRel) {
403
+ return {
404
+ content: [
405
+ {
406
+ type: 'text',
407
+ text: `组件 "${componentName}" 不在组件契约里(可用:${manifest.components.map((item) => item.name).join(', ') || '无'})。`,
408
+ },
409
+ ],
410
+ isError: true,
411
+ };
412
+ }
413
+ const scaffold = scaffoldScene({
414
+ sceneRoot: env.sceneRoot,
415
+ groupDir,
416
+ pageDir,
417
+ slug: chosenSlug,
418
+ title,
419
+ lang: lang ?? 'vue',
420
+ component: componentName,
421
+ componentRel,
422
+ section,
423
+ api,
424
+ });
425
+ const lines = [`# 要写入的文件`, ''];
426
+ lines.push('## ' + scaffold.sceneFile, '', '```' + (lang ?? 'vue'), scaffold.sceneContent.trimEnd(), '```', '');
427
+ if (fs.existsSync(path.join(project, scaffold.pageMetaFile))) {
428
+ lines.push(`页面目录已存在(${scaffold.pageMetaFile}),只写场景文件即可。`, '');
429
+ }
430
+ else {
431
+ lines.push('## ' + scaffold.pageMetaFile + '(新页面,需要建)', '', '```ts', scaffold.pageMetaContent.trimEnd(), '```', '');
432
+ }
433
+ lines.push(`写完之后调 scene_validate(path=${scaffold.sceneFile})校验;dev server 会自己热更新。`);
434
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
435
+ });
436
+ server.registerTool('scene_validate', {
437
+ title: '校验场景',
438
+ description: '按框架的硬约束检查场景文件:目录层级、样式 scoped、禁用全局选择器、sceneMeta 字段、页绑定',
439
+ inputSchema: {
440
+ path: z.string().optional().describe('只校验这个文件(相对项目根);不传则全量校验'),
441
+ },
442
+ }, ({ path: target }) => {
443
+ refresh();
444
+ if (!manifest.env) {
445
+ return {
446
+ content: [{ type: 'text', text: '这份清单里没有 env 段,无法校验。先跑一次 pnpm dev 重新生成清单。' }],
447
+ isError: true,
448
+ };
449
+ }
450
+ const issues = validateScenes(project, manifest, target);
451
+ const errors = issues.filter((item) => item.level === 'error');
452
+ const warns = issues.filter((item) => item.level === 'warn');
453
+ const scope = target ? `文件 ${target}` : `${manifest.scenes.length} 个场景`;
454
+ if (!issues.length) {
455
+ return { content: [{ type: 'text', text: `校验通过(${scope})。` }] };
456
+ }
457
+ const lines = [`${scope}:${errors.length} 个错误 / ${warns.length} 个提醒`, ''];
458
+ for (const issue of [...errors, ...warns]) {
459
+ lines.push(`- [${issue.level === 'error' ? '错误' : '提醒'}] ${issue.file}${issue.line ? `:${issue.line}` : ''} · ${issue.rule}`);
460
+ lines.push(` ${issue.message}`);
461
+ }
462
+ return { content: [{ type: 'text', text: lines.join('\n') }], isError: errors.length > 0 };
463
+ });
179
464
  const transport = new StdioServerTransport();
180
465
  await server.connect(transport);
181
466
  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
+ };
@@ -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
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rinova/playground-mcp",
3
- "version": "0.5.0",
3
+ "version": "0.7.1",
4
4
  "description": "rinova-playground 的 MCP server:把契约清单暴露给 AI 编码工具",
5
5
  "type": "module",
6
6
  "license": "MIT",