@rinova/playground-vite 0.1.5 → 0.5.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,209 @@ 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
+ : [];
174
349
  const manifest = buildManifest({
175
350
  root,
176
351
  patterns,
177
- tsconfig: typeof config.tsconfig === 'string' ? config.tsconfig : 'tsconfig.json',
352
+ tsconfig,
178
353
  scenes,
354
+ modules,
179
355
  });
180
356
  writeManifest(path.join(root, '.playground'), manifest);
357
+ // 契约变了要让 API 模块失效:下次请求重新注入
358
+ const apiMod = devServer?.moduleGraph.getModuleById(RESOLVED_ID[VIRTUAL_API]);
359
+ if (apiMod && devServer)
360
+ devServer.moduleGraph.invalidateModule(apiMod);
181
361
  const props = manifest.components.reduce((sum, item) => sum + item.props.length, 0);
182
362
  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)`);
363
+ const functions = manifest.modules.reduce((sum, item) => sum + item.functions.length, 0);
364
+ console.log(`[rinova-playground] 契约清单:${manifest.components.length} 个组件 / ${props} props / 未覆盖 ${uncovered} → .playground/(${Date.now() - startedAt}ms)` +
365
+ (manifest.modules.length ? `;模块 ${manifest.modules.length} 个 / ${functions} 个函数` : ''));
184
366
  }
185
367
  catch (error) {
186
368
  console.warn('[rinova-playground] 契约清单生成失败(不影响 dev/build):', error instanceof Error ? error.message : error);
@@ -198,7 +380,7 @@ export const playground = (options = {}) => {
198
380
  config = {};
199
381
  }
200
382
  scenesDir = path.resolve(root, options.scenes ?? config.scenes ?? DEFAULT_SCENES_DIR);
201
- scenes = await scanScenes();
383
+ await scanScenes();
202
384
  resolved = true;
203
385
  };
204
386
  const invalidateScenes = (server) => {
@@ -210,48 +392,177 @@ export const playground = (options = {}) => {
210
392
  }
211
393
  };
212
394
  const configModuleCode = () => {
213
- const payload = { ...config, sceneCount: scenes.length };
395
+ const payload = {
396
+ ...config,
397
+ /** 供「在本地 IDE 打开」拼绝对路径;exposeRoot: false 时清空 */
398
+ root: config.editor?.exposeRoot === false ? '' : root,
399
+ sceneCount: scenes.length,
400
+ pageCount: pages.length,
401
+ };
214
402
  return `export default ${JSON.stringify(payload)}`;
215
403
  };
404
+ /**
405
+ * API 文档模块:只注入「被页面引用到的组件」的契约,
406
+ * 运行时零请求;页面 → 组件的绑定优先取 pageMeta.component,否则按目录名同名匹配。
407
+ */
408
+ const apiModuleCode = () => {
409
+ const manifestPath = path.join(root, '.playground/manifest.json');
410
+ const hasComponents = Array.isArray(config.components) && config.components.length > 0;
411
+ const hasModules = Array.isArray(config.modules) && config.modules.length > 0;
412
+ const empty = 'export const api = {}\nexport const pageComponents = {}\nexport const modules = {}\nexport const pageModules = {}\n';
413
+ // 首次请求时清单可能还没生成(dev 下 buildStart 与首个请求的先后不保证)
414
+ if (!fs.existsSync(manifestPath) && (hasComponents || hasModules))
415
+ generateManifest();
416
+ if (!fs.existsSync(manifestPath))
417
+ return empty;
418
+ let manifest = null;
419
+ try {
420
+ manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
421
+ }
422
+ catch {
423
+ return empty;
424
+ }
425
+ const components = manifest?.components ?? [];
426
+ const modules = manifest?.modules ?? [];
427
+ if (!components.length && !modules.length)
428
+ return empty;
429
+ const byName = new Map(components.map((item) => [item.name.toLowerCase(), item]));
430
+ const pageComponents = {};
431
+ const used = new Map();
432
+ for (const page of pages) {
433
+ const candidates = [
434
+ page.component,
435
+ page.id.split('/').slice(-1)[0],
436
+ page.title.replace(/\s+/g, ''),
437
+ ].filter(Boolean);
438
+ const hit = candidates
439
+ .map((candidate) => byName.get(candidate.toLowerCase()))
440
+ .find((item) => Boolean(item));
441
+ if (!hit)
442
+ continue;
443
+ pageComponents[page.id] = hit.name;
444
+ used.set(hit.name, hit);
445
+ }
446
+ // 非组件模块:pageMeta.module 优先,其次按页目录名同名匹配
447
+ const moduleByName = new Map(modules.map((item) => [item.name.toLowerCase(), item]));
448
+ const pageModules = {};
449
+ const usedModules = new Map();
450
+ for (const page of pages) {
451
+ const candidates = [
452
+ page.module,
453
+ page.id.split('/').slice(-1)[0],
454
+ page.title.replace(/\s+/g, ''),
455
+ ].filter(Boolean);
456
+ const hit = candidates
457
+ .map((candidate) => moduleByName.get(candidate.toLowerCase()))
458
+ .find((item) => Boolean(item));
459
+ if (!hit)
460
+ continue;
461
+ pageModules[page.id] = hit.name;
462
+ usedModules.set(hit.name, hit);
463
+ }
464
+ return `export const api = ${JSON.stringify(Object.fromEntries(used))}
465
+ export const pageComponents = ${JSON.stringify(pageComponents)}
466
+ export const modules = ${JSON.stringify(Object.fromEntries(usedModules))}
467
+ export const pageModules = ${JSON.stringify(pageModules)}
468
+ `;
469
+ };
216
470
  const scenesModuleCode = () => {
217
- const entries = scenes
471
+ const hasMarkdown = scenes.some((scene) => scene.lang === 'md');
472
+ const sceneCode = scenes
218
473
  .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 })'
474
+ const sources = `[${scene.sources
475
+ .map((source) => `{ rel: ${JSON.stringify(source.rel)}, raw: () => import(${JSON.stringify(`${source.url}?raw`)}).then((mod) => mod.default) }`)
476
+ .join(', ')}]`;
477
+ // `.md` 场景:读原文 → 交给 defineMarkdownScene 包装成组件
478
+ const loader = scene.lang === 'md'
479
+ ? `() => import(${JSON.stringify(`${scene.url}?raw`)}).then((mod) => ({ default: defineMarkdownScene(mod.default) }))`
227
480
  : `() => import(${JSON.stringify(scene.url)})`;
228
- const rawLoader = isIndex
229
- ? "() => Promise.resolve('')"
230
- : `() => import(${JSON.stringify(`${scene.url}?raw`)}).then((mod) => mod.default)`;
231
481
  return ` {
232
482
  id: ${JSON.stringify(scene.id)},
483
+ name: ${JSON.stringify(scene.name)},
233
484
  title: ${JSON.stringify(scene.title)},
485
+ section: ${JSON.stringify(scene.section)},
486
+ api: ${JSON.stringify(scene.api ?? null)},
234
487
  group: ${JSON.stringify(scene.group)},
235
- kind: ${JSON.stringify(scene.kind ?? 'scene')},
488
+ groupDir: ${JSON.stringify(scene.groupDir)},
489
+ pageId: ${JSON.stringify(scene.pageId)},
490
+ page: ${JSON.stringify(scene.page)},
236
491
  path: ${JSON.stringify(scene.url)},
237
492
  file: ${JSON.stringify(scene.file)},
238
493
  rel: ${JSON.stringify(scene.rel)},
239
- lang: 'vue',
494
+ lang: ${JSON.stringify(scene.lang)},
240
495
  order: ${JSON.stringify(scene.order)},
241
496
  tags: ${JSON.stringify(scene.tags ?? null)},
242
497
  description: ${JSON.stringify(scene.description ?? null)},
243
498
  hidden: ${JSON.stringify(scene.hidden === true)},
499
+ showSource: ${JSON.stringify(scene.showSource)},
500
+ doc: ${JSON.stringify(scene.doc)},
244
501
  sources: ${sources},
245
502
  load: ${loader},
246
- raw: ${rawLoader}
503
+ raw: () => import(${JSON.stringify(`${scene.url}?raw`)}).then((mod) => mod.default)
247
504
  }`;
248
505
  })
249
506
  .join(',\n');
250
- return `export const scenes = [
251
- ${entries}
507
+ const pageCode = pages
508
+ .map((page) => ` {
509
+ id: ${JSON.stringify(page.id)},
510
+ group: ${JSON.stringify(page.group)},
511
+ groupDir: ${JSON.stringify(page.groupDir)},
512
+ groupOrder: ${JSON.stringify(page.groupOrder)},
513
+ depth: ${JSON.stringify(page.depth)},
514
+ parentId: ${JSON.stringify(page.parentId ?? null)},
515
+ title: ${JSON.stringify(page.title)},
516
+ description: ${JSON.stringify(page.description ?? null)},
517
+ order: ${JSON.stringify(page.order)},
518
+ component: ${JSON.stringify(page.component ?? null)},
519
+ module: ${JSON.stringify(page.module ?? null)},
520
+ apiNotes: ${JSON.stringify(page.apiNotes ?? null)},
521
+ showSource: ${JSON.stringify(page.showSource ?? null)},
522
+ doc: ${JSON.stringify(page.doc ?? null)}
523
+ }`)
524
+ .join(',\n');
525
+ return `${hasMarkdown ? "import { defineMarkdownScene } from '@rinova/playground'\n" : ''}export const scenes = [
526
+ ${sceneCode}
527
+ ]
528
+
529
+ export const pages = [
530
+ ${pageCode}
252
531
  ]
253
532
  `;
254
533
  };
534
+ /**
535
+ * 插件虚拟模块:把 config.plugins 变成懒加载入口。
536
+ * 走动态 import,dev 与 build 完全同一条路径 —— 不存在「只有 dev 可用」的分支。
537
+ */
538
+ const pluginsModuleCode = () => {
539
+ const entries = Array.isArray(config.plugins) ? config.plugins : [];
540
+ const specs = [];
541
+ for (const item of entries) {
542
+ if (typeof item !== 'string' || !item.trim()) {
543
+ console.warn('[rinova-playground] plugins 的每一项都必须是字符串路径,已跳过:', item);
544
+ continue;
545
+ }
546
+ try {
547
+ const spec = resolvePluginSpecifier(root, item);
548
+ if (spec.startsWith('/') && !fs.existsSync(path.join(root, spec))) {
549
+ const message = `插件文件不存在:${item}`;
550
+ if (isBuild)
551
+ throw new Error(message);
552
+ console.warn(`[rinova-playground] ${message}`);
553
+ }
554
+ specs.push(spec);
555
+ }
556
+ catch (error) {
557
+ console.warn(`[rinova-playground] ${messageOf(error)},已跳过`);
558
+ }
559
+ }
560
+ if (!specs.length)
561
+ return 'export const pluginModules = []\n';
562
+ return `export const pluginModules = [\n${specs
563
+ .map((spec) => ` () => import(${JSON.stringify(spec)}),`)
564
+ .join('\n')}\n]\n`;
565
+ };
255
566
  return {
256
567
  name: 'rinova-playground',
257
568
  enforce: 'pre',
@@ -287,19 +598,28 @@ ${entries}
287
598
  fs.copyFileSync(from, path.join(target, name));
288
599
  }
289
600
  },
290
- resolveId: (id) => (id === VIRTUAL_CONFIG || id === VIRTUAL_SCENES ? RESOLVED_ID[id] : null),
601
+ resolveId: (id) => RESOLVED_ID[id] ?? null,
291
602
  load: async (id) => {
603
+ if (id === RESOLVED_ID[VIRTUAL_API]) {
604
+ await ensureReady();
605
+ return apiModuleCode();
606
+ }
607
+ if (id === RESOLVED_ID[VIRTUAL_PLUGINS]) {
608
+ await ensureReady();
609
+ return pluginsModuleCode();
610
+ }
292
611
  if (id !== RESOLVED_ID[VIRTUAL_CONFIG] && id !== RESOLVED_ID[VIRTUAL_SCENES])
293
612
  return null;
294
613
  await ensureReady();
295
614
  return id === RESOLVED_ID[VIRTUAL_CONFIG] ? configModuleCode() : scenesModuleCode();
296
615
  },
297
616
  configureServer: async (server) => {
617
+ devServer = server;
298
618
  await ensureReady();
299
619
  server.watcher.add(scenesDir);
300
620
  const refresh = (file) => {
301
621
  const target = file ?? '';
302
- const isScene = target.startsWith(scenesDir) && target.endsWith('.vue');
622
+ const isScene = target.startsWith(scenesDir) && /\.(vue|ts)$/.test(target);
303
623
  if (!isScene)
304
624
  return;
305
625
  invalidateScenes(server);
@@ -338,12 +658,45 @@ ${entries}
338
658
  return;
339
659
  scheduleManifest();
340
660
  };
661
+ // 插件文件改动 → 整页重载(插件状态会重置,这是期望行为:登录态得重新拿)
662
+ const pluginFiles = new Set();
663
+ const watchPlugins = () => {
664
+ for (const item of Array.isArray(config.plugins) ? config.plugins : []) {
665
+ if (typeof item !== 'string' || !item.trim())
666
+ continue;
667
+ let spec = '';
668
+ try {
669
+ spec = resolvePluginSpecifier(root, item);
670
+ }
671
+ catch {
672
+ continue;
673
+ }
674
+ if (!spec.startsWith('/'))
675
+ continue;
676
+ const abs = path.join(root, spec);
677
+ if (pluginFiles.has(abs) || !fs.existsSync(abs))
678
+ continue;
679
+ pluginFiles.add(abs);
680
+ server.watcher.add(abs);
681
+ }
682
+ };
683
+ watchPlugins();
684
+ server.watcher.on('change', (file) => {
685
+ if (!pluginFiles.has(path.resolve(file)))
686
+ return;
687
+ const mod = server.moduleGraph.getModuleById(RESOLVED_ID[VIRTUAL_PLUGINS]);
688
+ if (mod)
689
+ server.moduleGraph.invalidateModule(mod);
690
+ server.ws.send({ type: 'full-reload' });
691
+ });
341
692
  watchComponents();
342
693
  server.watcher.on('change', onComponentChange);
343
694
  server.watcher.on('add', onComponentChange);
344
695
  server.watcher.on('unlink', onComponentChange);
345
696
  server.watcher.on('add', refresh);
346
697
  server.watcher.on('unlink', refresh);
698
+ // 改已有场景(尤其是 sceneMeta 的 title/section/api)也要重扫,否则虚拟模块还是旧的
699
+ server.watcher.on('change', (file) => refresh(file));
347
700
  server.watcher.on('change', async (file) => {
348
701
  if (!configFile || path.resolve(file) !== path.resolve(configFile))
349
702
  return;
@@ -351,6 +704,10 @@ ${entries}
351
704
  invalidateScenes(server);
352
705
  await ensureReady();
353
706
  watchComponents();
707
+ watchPlugins();
708
+ const pluginMod = server.moduleGraph.getModuleById(RESOLVED_ID[VIRTUAL_PLUGINS]);
709
+ if (pluginMod)
710
+ server.moduleGraph.invalidateModule(pluginMod);
354
711
  scheduleManifest(0);
355
712
  server.ws.send({ type: 'full-reload' });
356
713
  });