@rinova/playground-vite 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/dist/index.js CHANGED
@@ -3,11 +3,17 @@ import path from 'node:path';
3
3
  import { createJiti } from 'jiti';
4
4
  import { parse as parseSfc } from '@vue/compiler-sfc';
5
5
  import { buildManifest, writeManifest } from './manifest.js';
6
+ import { buildModules } from './module-api.js';
7
+ import { resolvePluginSpecifier } from './plugin-spec.js';
6
8
  const VIRTUAL_CONFIG = 'virtual:playground/config';
7
9
  const VIRTUAL_SCENES = 'virtual:playground/scenes';
10
+ const VIRTUAL_API = 'virtual:playground/api';
11
+ const VIRTUAL_PLUGINS = 'virtual:playground/plugins';
8
12
  const RESOLVED_ID = {
9
13
  [VIRTUAL_CONFIG]: `\0${VIRTUAL_CONFIG}`,
10
14
  [VIRTUAL_SCENES]: `\0${VIRTUAL_SCENES}`,
15
+ [VIRTUAL_API]: `\0${VIRTUAL_API}`,
16
+ [VIRTUAL_PLUGINS]: `\0${VIRTUAL_PLUGINS}`,
11
17
  };
12
18
  const CONFIG_FILES = [
13
19
  'playground.config.ts',
@@ -15,7 +21,10 @@ const CONFIG_FILES = [
15
21
  'playground.config.js',
16
22
  'playground.config.mjs',
17
23
  ];
24
+ const PAGE_META_FILES = ['meta.ts', 'meta.mts', 'meta.js', 'meta.mjs'];
18
25
  const DEFAULT_SCENES_DIR = 'src/scenes';
26
+ /** 页内索引的分组顺序,空组不展示 */
27
+ export const SECTION_ORDER = ['overview', 'props', 'events', 'exposes', 'slots'];
19
28
  /** `size-large` → `Size Large`;含中文时原样返回 */
20
29
  const titleize = (name) => {
21
30
  if (/[\u3400-\u9fff]/.test(name))
@@ -27,25 +36,40 @@ const titleize = (name) => {
27
36
  .replace(/\b\w/g, (char) => char.toUpperCase());
28
37
  };
29
38
  const toPosix = (value) => value.split(path.sep).join('/');
30
- const walkScenes = (dir, prefix = '') => {
31
- const out = [];
32
- for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
33
- if (entry.name.startsWith('.') || entry.name === 'node_modules')
39
+ const messageOf = (error) => (error instanceof Error ? error.message : String(error));
40
+ /** `---` 头:`key: value` / `key: [a, b]`。只用于 Markdown 场景,标题排序标签够用 */
41
+ const parseFrontmatter = (source) => {
42
+ const match = source.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
43
+ if (!match)
44
+ return {};
45
+ const data = {};
46
+ for (const raw of (match[1] ?? '').split(/\r?\n/)) {
47
+ const line = raw.trim();
48
+ if (!line || line.startsWith('#'))
34
49
  continue;
35
- const abs = path.join(dir, entry.name);
36
- const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
37
- if (entry.isDirectory()) {
38
- out.push(...walkScenes(abs, rel));
39
- }
40
- else if (entry.isFile() && entry.name.endsWith('.vue')) {
41
- out.push({ abs, rel });
42
- }
50
+ const index = line.indexOf(':');
51
+ if (index < 1)
52
+ continue;
53
+ const key = line.slice(0, index).trim();
54
+ const value = line.slice(index + 1).trim();
55
+ if (!key)
56
+ continue;
57
+ data[key] = /^\[.*\]$/.test(value)
58
+ ? value
59
+ .slice(1, -1)
60
+ .split(',')
61
+ .map((item) => item.trim().replace(/^['"]|['"]$/g, ''))
62
+ .filter(Boolean)
63
+ : value.replace(/^['"]|['"]$/g, '');
43
64
  }
44
- return out;
65
+ return data;
45
66
  };
67
+ const isSection = (value) => typeof value === 'string' && SECTION_ORDER.includes(value);
46
68
  /**
47
69
  * rinova-playground 的 Vite 插件。
48
70
  *
71
+ * 目录约定(必须三层):`scenes/<分组>/<组件页>/<场景>.vue`
72
+ *
49
73
  * ```ts
50
74
  * import { playground } from '@rinova/playground-vite'
51
75
  * export default defineConfig({ plugins: [vue(), playground()] })
@@ -57,9 +81,11 @@ export const playground = (options = {}) => {
57
81
  let resolved = false;
58
82
  let scenesDir = '';
59
83
  let scenes = [];
84
+ let pages = [];
60
85
  let configFile = '';
61
86
  let isBuild = false;
62
87
  let outDir = 'dist';
88
+ let devServer = null;
63
89
  const jiti = createJiti(import.meta.url, { interopDefault: true, moduleCache: false });
64
90
  const findConfigFile = () => {
65
91
  if (options.configFile)
@@ -71,7 +97,20 @@ export const playground = (options = {}) => {
71
97
  }
72
98
  return '';
73
99
  };
74
- /** 执行场景 `<script>` 块里的 `export const sceneMeta`,只取可序列化的导航字段 */
100
+ const listDirs = (dir) => fs
101
+ .readdirSync(dir, { withFileTypes: true })
102
+ .filter((entry) => entry.isDirectory() && !entry.name.startsWith('.') && entry.name !== 'node_modules')
103
+ .map((entry) => entry.name)
104
+ .sort();
105
+ /** 场景文件:`.vue` 手写场景,或 `.md`(Markdown 内容页) */
106
+ const listScenes = (dir) => fs
107
+ .readdirSync(dir, { withFileTypes: true })
108
+ .filter((entry) => entry.isFile() &&
109
+ (entry.name.endsWith('.vue') || entry.name.endsWith('.md')) &&
110
+ !entry.name.startsWith('.'))
111
+ .map((entry) => entry.name)
112
+ .sort();
113
+ /** 执行场景 `<script>` 块里的 `export const sceneMeta`,只取可序列化的字段 */
75
114
  const readSceneMeta = async (file) => {
76
115
  let source = '';
77
116
  try {
@@ -80,6 +119,22 @@ export const playground = (options = {}) => {
80
119
  catch {
81
120
  return {};
82
121
  }
122
+ // Markdown 场景:标题 / 排序 / 标签写在 `---` 头里
123
+ if (file.endsWith('.md')) {
124
+ const data = parseFrontmatter(source);
125
+ const tags = data.tags;
126
+ const api = data.api;
127
+ return {
128
+ title: typeof data.title === 'string' ? data.title : undefined,
129
+ order: data.order !== undefined && Number.isFinite(Number(data.order)) ? Number(data.order) : undefined,
130
+ tags: Array.isArray(tags) ? tags : typeof tags === 'string' ? tags.split(',').map((item) => item.trim()).filter(Boolean) : undefined,
131
+ description: typeof data.description === 'string' ? data.description : undefined,
132
+ hideInNav: data.hideInNav === 'true' ? true : undefined,
133
+ section: isSection(data.section) ? data.section : undefined,
134
+ api: Array.isArray(api) ? api : typeof api === 'string' ? api.split(',').map((item) => item.trim()).filter(Boolean) : undefined,
135
+ showSource: data.showSource === 'false' ? false : data.showSource === 'true' ? true : undefined,
136
+ };
137
+ }
83
138
  const { descriptor } = parseSfc(source, { filename: file });
84
139
  const block = descriptor.script?.content;
85
140
  if (!block || !block.includes('sceneMeta'))
@@ -94,6 +149,10 @@ export const playground = (options = {}) => {
94
149
  description: typeof meta.description === 'string' ? meta.description : undefined,
95
150
  hideInNav: meta.hideInNav === true ? true : undefined,
96
151
  files: Array.isArray(meta.files) ? meta.files.map(String) : undefined,
152
+ section: isSection(meta.section) ? meta.section : undefined,
153
+ api: Array.isArray(meta.api) ? meta.api.map(String).filter(Boolean) : undefined,
154
+ // 场景级源码开关:.md 走 frontmatter,.vue 走 sceneMeta,两边要保持一致
155
+ showSource: typeof meta.showSource === 'boolean' ? meta.showSource : undefined,
97
156
  };
98
157
  }
99
158
  catch (error) {
@@ -101,86 +160,258 @@ export const playground = (options = {}) => {
101
160
  return {};
102
161
  }
103
162
  };
163
+ /**
164
+ * 场景样式约束:`<style>` 必须带 scoped,且不许命中 :root / html / body。
165
+ * 不做沙箱(不引 iframe / Shadow DOM),靠这条把最容易串味的写法挡在门口。
166
+ */
167
+ const checkSceneStyle = (file) => {
168
+ let source = '';
169
+ try {
170
+ source = fs.readFileSync(file, 'utf8');
171
+ }
172
+ catch {
173
+ return;
174
+ }
175
+ const relPath = toPosix(path.relative(root, file));
176
+ const { descriptor } = parseSfc(source, { filename: file });
177
+ for (const block of descriptor.styles) {
178
+ if (!block.scoped) {
179
+ console.warn(`[rinova-playground] ${relPath} 的 <style> 没有 scoped,样式会污染其它场景`);
180
+ }
181
+ if (/(^|[},]\s*)(:root|html|body)\b/.test(block.content)) {
182
+ console.warn(`[rinova-playground] ${relPath} 的样式命中了 :root / html / body,会影响整个页面`);
183
+ }
184
+ }
185
+ };
186
+ /** 页级 meta(可选):`<页>/meta.ts` 导出 `pageMeta` */
187
+ const readPageMeta = async (dir) => {
188
+ for (const name of PAGE_META_FILES) {
189
+ const file = path.join(dir, name);
190
+ if (!fs.existsSync(file))
191
+ continue;
192
+ try {
193
+ const mod = (await jiti.import(file));
194
+ const meta = mod?.pageMeta ?? {};
195
+ return {
196
+ title: typeof meta.title === 'string' ? meta.title : undefined,
197
+ description: typeof meta.description === 'string' ? meta.description : undefined,
198
+ order: typeof meta.order === 'number' ? meta.order : undefined,
199
+ component: typeof meta.component === 'string' ? meta.component : undefined,
200
+ module: typeof meta.module === 'string' ? meta.module : undefined,
201
+ apiNotes: meta.apiNotes && typeof meta.apiNotes === 'object'
202
+ ? Object.fromEntries(Object.entries(meta.apiNotes).map(([key, value]) => [key, String(value)]))
203
+ : undefined,
204
+ showSource: typeof meta.showSource === 'boolean' ? meta.showSource : undefined,
205
+ doc: typeof meta.doc === 'boolean' ? meta.doc : undefined,
206
+ };
207
+ }
208
+ catch (error) {
209
+ console.warn(`[rinova-playground] ${toPosix(path.relative(root, file))} 的 pageMeta 解析失败:`, error instanceof Error ? error.message : error);
210
+ }
211
+ }
212
+ return {};
213
+ };
104
214
  const scanScenes = async () => {
215
+ scenes = [];
216
+ pages = [];
105
217
  if (!fs.existsSync(scenesDir))
106
- return [];
107
- const entries = [];
108
- /** 分组目录名 → 显示名,顺带收集给合成落地页用 */
109
- const groupLabels = new Map();
110
- for (const { abs, rel } of walkScenes(scenesDir)) {
111
- const id = rel.replace(/\.vue$/, '');
112
- const segments = id.split('/');
113
- const groupRaw = segments.length > 1 ? segments[0] : '';
114
- const nameRaw = segments.at(-1);
115
- const meta = await readSceneMeta(abs);
116
- const relPath = toPosix(path.relative(root, abs));
117
- const groupLabel = groupRaw
118
- ? (config.groups?.[groupRaw] ?? titleize(groupRaw))
119
- : 'Scenes';
120
- if (groupRaw)
121
- groupLabels.set(groupRaw, groupLabel);
122
- entries.push({
123
- id,
124
- title: meta.title ?? titleize(nameRaw),
125
- group: groupLabel,
126
- url: `/${relPath}`,
127
- file: abs,
128
- rel: relPath,
129
- order: typeof meta.order === 'number' ? meta.order : Number.MAX_SAFE_INTEGER,
130
- tags: meta.tags,
131
- description: meta.description,
132
- hidden: meta.hideInNav,
133
- sources: [
134
- { rel: relPath, url: `/${relPath}` },
135
- ...(meta.files ?? [])
136
- .map((file) => {
137
- const absExtra = path.resolve(root, file);
138
- if (!fs.existsSync(absExtra)) {
139
- console.warn(`[rinova-playground] ${relPath} 的 sceneMeta.files 里 ${file} 不存在,已跳过`);
140
- return null;
141
- }
142
- const relExtra = toPosix(path.relative(root, absExtra));
143
- return { rel: relExtra, url: `/${relExtra}` };
144
- })
145
- .filter((entry) => entry !== null),
146
- ],
147
- });
218
+ return;
219
+ const ignored = (file, reason) => {
220
+ console.warn(`[rinova-playground] 忽略 ${toPosix(path.relative(root, file))}:${reason}`);
221
+ };
222
+ for (const name of listScenes(scenesDir)) {
223
+ ignored(path.join(scenesDir, name), '场景必须放在 分组/组件页/ 目录下');
148
224
  }
149
- // 每个分组合成一个「概览」落地页(groupIndex: true 时)
150
- if (config.groupIndex) {
151
- for (const [dir, label] of groupLabels) {
152
- entries.push({
153
- id: `${dir}/__index`,
154
- title: '概览',
155
- group: label,
156
- url: '',
157
- file: '',
158
- rel: '',
159
- order: Number.MIN_SAFE_INTEGER,
160
- kind: 'group-index',
161
- sources: [],
162
- });
225
+ // 分组顺序 = config.groups 的声明顺序;未登记的组排在最后(再按目录名)
226
+ const groupKeys = Object.keys(config.groups ?? {});
227
+ const groupOrderOf = (dir) => {
228
+ const index = groupKeys.indexOf(dir);
229
+ return index === -1 ? Number.MAX_SAFE_INTEGER : index;
230
+ };
231
+ const groupDirs = listDirs(scenesDir).sort((a, b) => groupOrderOf(a) - groupOrderOf(b) || a.localeCompare(b));
232
+ for (const groupDir of groupDirs) {
233
+ const groupAbs = path.join(scenesDir, groupDir);
234
+ const groupLabel = config.groups?.[groupDir] ?? titleize(groupDir);
235
+ for (const name of listScenes(groupAbs)) {
236
+ ignored(path.join(groupAbs, name), '分组目录下不能直接放场景,请放进组件页目录');
237
+ }
238
+ /** 扫一个页(一级组件页或二级子组件页):读页 meta + 收自己的场景 */
239
+ const collectPage = async (pageAbs, pageId, pageDir, depth, parentId) => {
240
+ const meta = await readPageMeta(pageAbs);
241
+ const pageTitle = meta.title ?? titleize(pageDir);
242
+ const page = {
243
+ id: pageId,
244
+ group: groupLabel,
245
+ groupDir,
246
+ groupOrder: groupOrderOf(groupDir),
247
+ depth,
248
+ title: pageTitle,
249
+ description: meta.description,
250
+ order: typeof meta.order === 'number' ? meta.order : Number.MAX_SAFE_INTEGER,
251
+ component: meta.component,
252
+ module: meta.module,
253
+ apiNotes: meta.apiNotes,
254
+ showSource: meta.showSource,
255
+ doc: meta.doc,
256
+ };
257
+ if (parentId)
258
+ page.parentId = parentId;
259
+ const seen = new Set();
260
+ for (const fileName of listScenes(pageAbs)) {
261
+ const isMarkdown = fileName.endsWith('.md');
262
+ const name = fileName.replace(/\.(vue|md)$/, '');
263
+ if (seen.has(name)) {
264
+ ignored(path.join(pageAbs, fileName), `同一页内场景名重复(${name})`);
265
+ continue;
266
+ }
267
+ seen.add(name);
268
+ const abs = path.join(pageAbs, fileName);
269
+ if (!isMarkdown)
270
+ checkSceneStyle(abs);
271
+ const sceneMeta = await readSceneMeta(abs);
272
+ const relPath = toPosix(path.relative(root, abs));
273
+ scenes.push({
274
+ id: `${pageId}/${name}`,
275
+ name,
276
+ title: sceneMeta.title ?? titleize(name),
277
+ section: sceneMeta.section ?? 'overview',
278
+ api: sceneMeta.api,
279
+ group: groupLabel,
280
+ groupDir,
281
+ pageId,
282
+ page: pageTitle,
283
+ url: `/${relPath}`,
284
+ file: abs,
285
+ rel: relPath,
286
+ order: typeof sceneMeta.order === 'number' ? sceneMeta.order : Number.MAX_SAFE_INTEGER,
287
+ tags: sceneMeta.tags,
288
+ description: sceneMeta.description,
289
+ hidden: sceneMeta.hideInNav,
290
+ lang: isMarkdown ? 'md' : 'vue',
291
+ doc: isMarkdown || meta.doc === true,
292
+ // 优先级:场景 > 页 > 全局;Markdown 场景默认不展示源码
293
+ showSource: sceneMeta.showSource ?? meta.showSource ?? (isMarkdown ? false : config.sources !== false),
294
+ sources: [
295
+ { rel: relPath, url: `/${relPath}` },
296
+ ...(sceneMeta.files ?? [])
297
+ .map((file) => {
298
+ const absExtra = path.resolve(root, file);
299
+ if (!fs.existsSync(absExtra)) {
300
+ console.warn(`[rinova-playground] ${relPath} 的 sceneMeta.files 里 ${file} 不存在,已跳过`);
301
+ return null;
302
+ }
303
+ const relExtra = toPosix(path.relative(root, absExtra));
304
+ return { rel: relExtra, url: `/${relExtra}` };
305
+ })
306
+ .filter((entry) => entry !== null),
307
+ ],
308
+ });
309
+ }
310
+ return page;
311
+ };
312
+ // 一级 = 组件页;组件页下再深一层 = 子组件页
313
+ const groupPages = [];
314
+ for (const pageDir of listDirs(groupAbs)) {
315
+ const pageAbs = path.join(groupAbs, pageDir);
316
+ const page = await collectPage(pageAbs, `${groupDir}/${pageDir}`, pageDir, 1);
317
+ groupPages.push(page);
318
+ for (const subDir of listDirs(pageAbs)) {
319
+ const subAbs = path.join(pageAbs, subDir);
320
+ groupPages.push(await collectPage(subAbs, `${page.id}/${subDir}`, subDir, 2, page.id));
321
+ }
322
+ }
323
+ // 组内顺序:一级按 order / 目录名,二级紧跟自己的父页(同样按 order / 目录名)
324
+ const byOrder = (a, b) => a.order - b.order || a.id.localeCompare(b.id);
325
+ const roots = groupPages.filter((item) => item.depth === 1).sort(byOrder);
326
+ for (const root of roots) {
327
+ pages.push(root);
328
+ pages.push(...groupPages.filter((item) => item.parentId === root.id).sort(byOrder));
163
329
  }
164
330
  }
165
- return entries.sort((a, b) => a.order - b.order || a.rel.localeCompare(b.rel));
331
+ scenes.sort((a, b) => a.order - b.order || a.id.localeCompare(b.id));
332
+ // pages 已在扫描时按「组声明序 → 组内顺序 → 子组件紧跟父页」排好,这里不再全局重排(否则父子会被打散)
166
333
  };
167
334
  /** 生成契约清单(.playground/manifest.json + llms.txt + coverage.md) */
168
335
  const generateManifest = () => {
169
- const patterns = config.components;
170
- if (!Array.isArray(patterns) || !patterns.length || !scenes.length)
336
+ const patterns = Array.isArray(config.components) ? config.components : [];
337
+ const modulePatterns = Array.isArray(config.modules)
338
+ ? config.modules.filter((item) => typeof item === 'string' && item.trim() !== '')
339
+ : [];
340
+ if ((!patterns.length && !modulePatterns.length) || !scenes.length)
171
341
  return;
172
342
  const startedAt = Date.now();
173
343
  try {
344
+ const tsconfig = typeof config.tsconfig === 'string' ? config.tsconfig : 'tsconfig.json';
345
+ // 非组件模块:函数 / hooks / 常量的契约(只有配了 modules 才会建 Program)
346
+ const modules = modulePatterns.length
347
+ ? buildModules({ root, patterns: modulePatterns, tsconfig, scenes })
348
+ : [];
349
+ /** 演示环境:globals / 插件 / 分组 / 页面清单(MCP 与 llms.txt 的数据源) */
350
+ const buildEnv = () => {
351
+ const scenesOfPage = new Map();
352
+ for (const scene of scenes) {
353
+ const list = scenesOfPage.get(scene.pageId) ?? [];
354
+ list.push(scene.id);
355
+ scenesOfPage.set(scene.pageId, list);
356
+ }
357
+ const envPages = pages.map((page) => ({
358
+ id: page.id,
359
+ group: page.group,
360
+ groupDir: page.groupDir,
361
+ title: page.title,
362
+ order: page.order,
363
+ depth: page.depth,
364
+ ...(page.parentId ? { parentId: page.parentId } : {}),
365
+ ...(page.component ? { component: page.component } : {}),
366
+ ...(page.module ? { module: page.module } : {}),
367
+ ...(page.doc ? { doc: true } : {}),
368
+ ...(page.showSource !== undefined ? { showSource: page.showSource } : {}),
369
+ dir: `${toPosix(path.relative(root, scenesDir))}/${page.id}`,
370
+ scenes: scenesOfPage.get(page.id) ?? [],
371
+ }));
372
+ const groupLabels = new Map();
373
+ for (const page of pages)
374
+ groupLabels.set(page.groupDir, page.group);
375
+ const theme = (config.theme ?? {});
376
+ const plugins = Array.isArray(config.plugins)
377
+ ? config.plugins.filter((item) => typeof item === 'string')
378
+ : [];
379
+ const routeBase = typeof config.routeBase === 'string' ? config.routeBase : typeof config.base === 'string' ? config.base : '/';
380
+ return {
381
+ title: typeof config.title === 'string' ? config.title : 'rinova-playground',
382
+ sceneRoot: toPosix(path.relative(root, scenesDir)),
383
+ routeBase,
384
+ groups: [...groupLabels].map(([dir, label]) => ({ dir, label })),
385
+ globals: (config.globals ?? {}),
386
+ plugins,
387
+ theme: { ui: theme.ui ?? 'auto', stage: theme.stage ?? 'light' },
388
+ sources: config.sources !== false,
389
+ components: { configured: patterns.length > 0, count: 0 },
390
+ modules: { configured: modulePatterns.length > 0, count: 0 },
391
+ pages: envPages,
392
+ };
393
+ };
174
394
  const manifest = buildManifest({
175
395
  root,
176
396
  patterns,
177
- tsconfig: typeof config.tsconfig === 'string' ? config.tsconfig : 'tsconfig.json',
397
+ tsconfig,
178
398
  scenes,
399
+ modules,
400
+ env: buildEnv(),
179
401
  });
402
+ // 数量以实际提取结果为准(glob 命中了多少组件 / 模块)
403
+ manifest.env.components.count = manifest.components.length;
404
+ manifest.env.modules.count = manifest.modules.length;
180
405
  writeManifest(path.join(root, '.playground'), manifest);
406
+ // 契约变了要让 API 模块失效:下次请求重新注入
407
+ const apiMod = devServer?.moduleGraph.getModuleById(RESOLVED_ID[VIRTUAL_API]);
408
+ if (apiMod && devServer)
409
+ devServer.moduleGraph.invalidateModule(apiMod);
181
410
  const props = manifest.components.reduce((sum, item) => sum + item.props.length, 0);
182
411
  const uncovered = manifest.components.reduce((sum, item) => sum + item.uncoveredProps.length, 0);
183
- console.log(`[rinova-playground] 契约清单:${manifest.components.length} 个组件 / ${props} props / 未覆盖 ${uncovered} → .playground/(${Date.now() - startedAt}ms)`);
412
+ const functions = manifest.modules.reduce((sum, item) => sum + item.functions.length, 0);
413
+ console.log(`[rinova-playground] 契约清单:${manifest.components.length} 个组件 / ${props} props / 未覆盖 ${uncovered} → .playground/(${Date.now() - startedAt}ms)` +
414
+ (manifest.modules.length ? `;模块 ${manifest.modules.length} 个 / ${functions} 个函数` : ''));
184
415
  }
185
416
  catch (error) {
186
417
  console.warn('[rinova-playground] 契约清单生成失败(不影响 dev/build):', error instanceof Error ? error.message : error);
@@ -198,7 +429,7 @@ export const playground = (options = {}) => {
198
429
  config = {};
199
430
  }
200
431
  scenesDir = path.resolve(root, options.scenes ?? config.scenes ?? DEFAULT_SCENES_DIR);
201
- scenes = await scanScenes();
432
+ await scanScenes();
202
433
  resolved = true;
203
434
  };
204
435
  const invalidateScenes = (server) => {
@@ -210,48 +441,177 @@ export const playground = (options = {}) => {
210
441
  }
211
442
  };
212
443
  const configModuleCode = () => {
213
- const payload = { ...config, sceneCount: scenes.length };
444
+ const payload = {
445
+ ...config,
446
+ /** 供「在本地 IDE 打开」拼绝对路径;exposeRoot: false 时清空 */
447
+ root: config.editor?.exposeRoot === false ? '' : root,
448
+ sceneCount: scenes.length,
449
+ pageCount: pages.length,
450
+ };
214
451
  return `export default ${JSON.stringify(payload)}`;
215
452
  };
453
+ /**
454
+ * API 文档模块:只注入「被页面引用到的组件」的契约,
455
+ * 运行时零请求;页面 → 组件的绑定优先取 pageMeta.component,否则按目录名同名匹配。
456
+ */
457
+ const apiModuleCode = () => {
458
+ const manifestPath = path.join(root, '.playground/manifest.json');
459
+ const hasComponents = Array.isArray(config.components) && config.components.length > 0;
460
+ const hasModules = Array.isArray(config.modules) && config.modules.length > 0;
461
+ const empty = 'export const api = {}\nexport const pageComponents = {}\nexport const modules = {}\nexport const pageModules = {}\n';
462
+ // 首次请求时清单可能还没生成(dev 下 buildStart 与首个请求的先后不保证)
463
+ if (!fs.existsSync(manifestPath) && (hasComponents || hasModules))
464
+ generateManifest();
465
+ if (!fs.existsSync(manifestPath))
466
+ return empty;
467
+ let manifest = null;
468
+ try {
469
+ manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
470
+ }
471
+ catch {
472
+ return empty;
473
+ }
474
+ const components = manifest?.components ?? [];
475
+ const modules = manifest?.modules ?? [];
476
+ if (!components.length && !modules.length)
477
+ return empty;
478
+ const byName = new Map(components.map((item) => [item.name.toLowerCase(), item]));
479
+ const pageComponents = {};
480
+ const used = new Map();
481
+ for (const page of pages) {
482
+ const candidates = [
483
+ page.component,
484
+ page.id.split('/').slice(-1)[0],
485
+ page.title.replace(/\s+/g, ''),
486
+ ].filter(Boolean);
487
+ const hit = candidates
488
+ .map((candidate) => byName.get(candidate.toLowerCase()))
489
+ .find((item) => Boolean(item));
490
+ if (!hit)
491
+ continue;
492
+ pageComponents[page.id] = hit.name;
493
+ used.set(hit.name, hit);
494
+ }
495
+ // 非组件模块:pageMeta.module 优先,其次按页目录名同名匹配
496
+ const moduleByName = new Map(modules.map((item) => [item.name.toLowerCase(), item]));
497
+ const pageModules = {};
498
+ const usedModules = new Map();
499
+ for (const page of pages) {
500
+ const candidates = [
501
+ page.module,
502
+ page.id.split('/').slice(-1)[0],
503
+ page.title.replace(/\s+/g, ''),
504
+ ].filter(Boolean);
505
+ const hit = candidates
506
+ .map((candidate) => moduleByName.get(candidate.toLowerCase()))
507
+ .find((item) => Boolean(item));
508
+ if (!hit)
509
+ continue;
510
+ pageModules[page.id] = hit.name;
511
+ usedModules.set(hit.name, hit);
512
+ }
513
+ return `export const api = ${JSON.stringify(Object.fromEntries(used))}
514
+ export const pageComponents = ${JSON.stringify(pageComponents)}
515
+ export const modules = ${JSON.stringify(Object.fromEntries(usedModules))}
516
+ export const pageModules = ${JSON.stringify(pageModules)}
517
+ `;
518
+ };
216
519
  const scenesModuleCode = () => {
217
- const entries = scenes
520
+ const hasMarkdown = scenes.some((scene) => scene.lang === 'md');
521
+ const sceneCode = scenes
218
522
  .map((scene) => {
219
- const isIndex = scene.kind === 'group-index';
220
- const sources = isIndex
221
- ? '[]'
222
- : `[${scene.sources
223
- .map((source) => `{ rel: ${JSON.stringify(source.rel)}, raw: () => import(${JSON.stringify(`${source.url}?raw`)}).then((mod) => mod.default) }`)
224
- .join(', ')}]`;
225
- const loader = isIndex
226
- ? '() => Promise.resolve({ default: null })'
523
+ const sources = `[${scene.sources
524
+ .map((source) => `{ rel: ${JSON.stringify(source.rel)}, raw: () => import(${JSON.stringify(`${source.url}?raw`)}).then((mod) => mod.default) }`)
525
+ .join(', ')}]`;
526
+ // `.md` 场景:读原文 → 交给 defineMarkdownScene 包装成组件
527
+ const loader = scene.lang === 'md'
528
+ ? `() => import(${JSON.stringify(`${scene.url}?raw`)}).then((mod) => ({ default: defineMarkdownScene(mod.default) }))`
227
529
  : `() => import(${JSON.stringify(scene.url)})`;
228
- const rawLoader = isIndex
229
- ? "() => Promise.resolve('')"
230
- : `() => import(${JSON.stringify(`${scene.url}?raw`)}).then((mod) => mod.default)`;
231
530
  return ` {
232
531
  id: ${JSON.stringify(scene.id)},
532
+ name: ${JSON.stringify(scene.name)},
233
533
  title: ${JSON.stringify(scene.title)},
534
+ section: ${JSON.stringify(scene.section)},
535
+ api: ${JSON.stringify(scene.api ?? null)},
234
536
  group: ${JSON.stringify(scene.group)},
235
- kind: ${JSON.stringify(scene.kind ?? 'scene')},
537
+ groupDir: ${JSON.stringify(scene.groupDir)},
538
+ pageId: ${JSON.stringify(scene.pageId)},
539
+ page: ${JSON.stringify(scene.page)},
236
540
  path: ${JSON.stringify(scene.url)},
237
541
  file: ${JSON.stringify(scene.file)},
238
542
  rel: ${JSON.stringify(scene.rel)},
239
- lang: 'vue',
543
+ lang: ${JSON.stringify(scene.lang)},
240
544
  order: ${JSON.stringify(scene.order)},
241
545
  tags: ${JSON.stringify(scene.tags ?? null)},
242
546
  description: ${JSON.stringify(scene.description ?? null)},
243
547
  hidden: ${JSON.stringify(scene.hidden === true)},
548
+ showSource: ${JSON.stringify(scene.showSource)},
549
+ doc: ${JSON.stringify(scene.doc)},
244
550
  sources: ${sources},
245
551
  load: ${loader},
246
- raw: ${rawLoader}
552
+ raw: () => import(${JSON.stringify(`${scene.url}?raw`)}).then((mod) => mod.default)
247
553
  }`;
248
554
  })
249
555
  .join(',\n');
250
- return `export const scenes = [
251
- ${entries}
556
+ const pageCode = pages
557
+ .map((page) => ` {
558
+ id: ${JSON.stringify(page.id)},
559
+ group: ${JSON.stringify(page.group)},
560
+ groupDir: ${JSON.stringify(page.groupDir)},
561
+ groupOrder: ${JSON.stringify(page.groupOrder)},
562
+ depth: ${JSON.stringify(page.depth)},
563
+ parentId: ${JSON.stringify(page.parentId ?? null)},
564
+ title: ${JSON.stringify(page.title)},
565
+ description: ${JSON.stringify(page.description ?? null)},
566
+ order: ${JSON.stringify(page.order)},
567
+ component: ${JSON.stringify(page.component ?? null)},
568
+ module: ${JSON.stringify(page.module ?? null)},
569
+ apiNotes: ${JSON.stringify(page.apiNotes ?? null)},
570
+ showSource: ${JSON.stringify(page.showSource ?? null)},
571
+ doc: ${JSON.stringify(page.doc ?? null)}
572
+ }`)
573
+ .join(',\n');
574
+ return `${hasMarkdown ? "import { defineMarkdownScene } from '@rinova/playground'\n" : ''}export const scenes = [
575
+ ${sceneCode}
576
+ ]
577
+
578
+ export const pages = [
579
+ ${pageCode}
252
580
  ]
253
581
  `;
254
582
  };
583
+ /**
584
+ * 插件虚拟模块:把 config.plugins 变成懒加载入口。
585
+ * 走动态 import,dev 与 build 完全同一条路径 —— 不存在「只有 dev 可用」的分支。
586
+ */
587
+ const pluginsModuleCode = () => {
588
+ const entries = Array.isArray(config.plugins) ? config.plugins : [];
589
+ const specs = [];
590
+ for (const item of entries) {
591
+ if (typeof item !== 'string' || !item.trim()) {
592
+ console.warn('[rinova-playground] plugins 的每一项都必须是字符串路径,已跳过:', item);
593
+ continue;
594
+ }
595
+ try {
596
+ const spec = resolvePluginSpecifier(root, item);
597
+ if (spec.startsWith('/') && !fs.existsSync(path.join(root, spec))) {
598
+ const message = `插件文件不存在:${item}`;
599
+ if (isBuild)
600
+ throw new Error(message);
601
+ console.warn(`[rinova-playground] ${message}`);
602
+ }
603
+ specs.push(spec);
604
+ }
605
+ catch (error) {
606
+ console.warn(`[rinova-playground] ${messageOf(error)},已跳过`);
607
+ }
608
+ }
609
+ if (!specs.length)
610
+ return 'export const pluginModules = []\n';
611
+ return `export const pluginModules = [\n${specs
612
+ .map((spec) => ` () => import(${JSON.stringify(spec)}),`)
613
+ .join('\n')}\n]\n`;
614
+ };
255
615
  return {
256
616
  name: 'rinova-playground',
257
617
  enforce: 'pre',
@@ -287,22 +647,33 @@ ${entries}
287
647
  fs.copyFileSync(from, path.join(target, name));
288
648
  }
289
649
  },
290
- resolveId: (id) => (id === VIRTUAL_CONFIG || id === VIRTUAL_SCENES ? RESOLVED_ID[id] : null),
650
+ resolveId: (id) => RESOLVED_ID[id] ?? null,
291
651
  load: async (id) => {
652
+ if (id === RESOLVED_ID[VIRTUAL_API]) {
653
+ await ensureReady();
654
+ return apiModuleCode();
655
+ }
656
+ if (id === RESOLVED_ID[VIRTUAL_PLUGINS]) {
657
+ await ensureReady();
658
+ return pluginsModuleCode();
659
+ }
292
660
  if (id !== RESOLVED_ID[VIRTUAL_CONFIG] && id !== RESOLVED_ID[VIRTUAL_SCENES])
293
661
  return null;
294
662
  await ensureReady();
295
663
  return id === RESOLVED_ID[VIRTUAL_CONFIG] ? configModuleCode() : scenesModuleCode();
296
664
  },
297
665
  configureServer: async (server) => {
666
+ devServer = server;
298
667
  await ensureReady();
299
668
  server.watcher.add(scenesDir);
300
669
  const refresh = (file) => {
301
670
  const target = file ?? '';
302
- const isScene = target.startsWith(scenesDir) && target.endsWith('.vue');
671
+ const isScene = target.startsWith(scenesDir) && /\.(vue|ts|md)$/.test(target);
303
672
  if (!isScene)
304
673
  return;
305
674
  invalidateScenes(server);
675
+ // 场景增删改都会影响 env.pages / scenes 清单(MCP 与 llms.txt 读它),防抖后重算
676
+ scheduleManifest(400);
306
677
  server.ws.send({ type: 'full-reload' });
307
678
  };
308
679
  // 组件改动时重算契约清单(防抖,避免每次保存都跑一遍 TS 程序)
@@ -310,7 +681,10 @@ ${entries}
310
681
  const scheduleManifest = (delay = 800) => {
311
682
  if (manifestTimer)
312
683
  clearTimeout(manifestTimer);
313
- manifestTimer = setTimeout(() => generateManifest(), delay);
684
+ manifestTimer = setTimeout(() => {
685
+ // 场景新增 / 删除会 invalidate 扫描结果,清单要用新的 scenes / pages 重算
686
+ void ensureReady().then(() => generateManifest());
687
+ }, delay);
314
688
  };
315
689
  // components 变了要重挂监听:配置里新增的目录原来根本没被 watch。
316
690
  // 注意这里只增不减 —— chokidar 的 unwatch 是异步的,紧跟着 add 会让这批路径进入坏状态
@@ -338,12 +712,45 @@ ${entries}
338
712
  return;
339
713
  scheduleManifest();
340
714
  };
715
+ // 插件文件改动 → 整页重载(插件状态会重置,这是期望行为:登录态得重新拿)
716
+ const pluginFiles = new Set();
717
+ const watchPlugins = () => {
718
+ for (const item of Array.isArray(config.plugins) ? config.plugins : []) {
719
+ if (typeof item !== 'string' || !item.trim())
720
+ continue;
721
+ let spec = '';
722
+ try {
723
+ spec = resolvePluginSpecifier(root, item);
724
+ }
725
+ catch {
726
+ continue;
727
+ }
728
+ if (!spec.startsWith('/'))
729
+ continue;
730
+ const abs = path.join(root, spec);
731
+ if (pluginFiles.has(abs) || !fs.existsSync(abs))
732
+ continue;
733
+ pluginFiles.add(abs);
734
+ server.watcher.add(abs);
735
+ }
736
+ };
737
+ watchPlugins();
738
+ server.watcher.on('change', (file) => {
739
+ if (!pluginFiles.has(path.resolve(file)))
740
+ return;
741
+ const mod = server.moduleGraph.getModuleById(RESOLVED_ID[VIRTUAL_PLUGINS]);
742
+ if (mod)
743
+ server.moduleGraph.invalidateModule(mod);
744
+ server.ws.send({ type: 'full-reload' });
745
+ });
341
746
  watchComponents();
342
747
  server.watcher.on('change', onComponentChange);
343
748
  server.watcher.on('add', onComponentChange);
344
749
  server.watcher.on('unlink', onComponentChange);
345
750
  server.watcher.on('add', refresh);
346
751
  server.watcher.on('unlink', refresh);
752
+ // 改已有场景(尤其是 sceneMeta 的 title/section/api)也要重扫,否则虚拟模块还是旧的
753
+ server.watcher.on('change', (file) => refresh(file));
347
754
  server.watcher.on('change', async (file) => {
348
755
  if (!configFile || path.resolve(file) !== path.resolve(configFile))
349
756
  return;
@@ -351,6 +758,10 @@ ${entries}
351
758
  invalidateScenes(server);
352
759
  await ensureReady();
353
760
  watchComponents();
761
+ watchPlugins();
762
+ const pluginMod = server.moduleGraph.getModuleById(RESOLVED_ID[VIRTUAL_PLUGINS]);
763
+ if (pluginMod)
764
+ server.moduleGraph.invalidateModule(pluginMod);
354
765
  scheduleManifest(0);
355
766
  server.ws.send({ type: 'full-reload' });
356
767
  });