@rinova/playground-vite 0.1.4 → 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.d.ts +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +506 -89
- package/dist/index.js.map +1 -1
- package/dist/manifest.d.ts +52 -3
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js +353 -19
- package/dist/manifest.js.map +1 -1
- package/dist/module-api.d.ts +71 -0
- package/dist/module-api.d.ts.map +1 -0
- package/dist/module-api.js +177 -0
- package/dist/module-api.js.map +1 -0
- package/dist/plugin-spec.d.ts +8 -0
- package/dist/plugin-spec.d.ts.map +1 -0
- package/dist/plugin-spec.js +24 -0
- package/dist/plugin-spec.js.map +1 -0
- package/package.json +2 -2
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
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
|
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
|
-
|
|
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,62 +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
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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), '场景必须放在 分组/组件页/ 目录下');
|
|
224
|
+
}
|
|
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));
|
|
329
|
+
}
|
|
141
330
|
}
|
|
142
|
-
|
|
331
|
+
scenes.sort((a, b) => a.order - b.order || a.id.localeCompare(b.id));
|
|
332
|
+
// pages 已在扫描时按「组声明序 → 组内顺序 → 子组件紧跟父页」排好,这里不再全局重排(否则父子会被打散)
|
|
143
333
|
};
|
|
144
334
|
/** 生成契约清单(.playground/manifest.json + llms.txt + coverage.md) */
|
|
145
335
|
const generateManifest = () => {
|
|
146
|
-
const patterns = config.components;
|
|
147
|
-
|
|
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)
|
|
148
341
|
return;
|
|
342
|
+
const startedAt = Date.now();
|
|
149
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
|
+
: [];
|
|
150
349
|
const manifest = buildManifest({
|
|
151
350
|
root,
|
|
152
351
|
patterns,
|
|
153
|
-
tsconfig
|
|
352
|
+
tsconfig,
|
|
154
353
|
scenes,
|
|
354
|
+
modules,
|
|
155
355
|
});
|
|
156
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);
|
|
157
361
|
const props = manifest.components.reduce((sum, item) => sum + item.props.length, 0);
|
|
158
362
|
const uncovered = manifest.components.reduce((sum, item) => sum + item.uncoveredProps.length, 0);
|
|
159
|
-
|
|
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} 个函数` : ''));
|
|
160
366
|
}
|
|
161
367
|
catch (error) {
|
|
162
368
|
console.warn('[rinova-playground] 契约清单生成失败(不影响 dev/build):', error instanceof Error ? error.message : error);
|
|
@@ -174,7 +380,7 @@ export const playground = (options = {}) => {
|
|
|
174
380
|
config = {};
|
|
175
381
|
}
|
|
176
382
|
scenesDir = path.resolve(root, options.scenes ?? config.scenes ?? DEFAULT_SCENES_DIR);
|
|
177
|
-
|
|
383
|
+
await scanScenes();
|
|
178
384
|
resolved = true;
|
|
179
385
|
};
|
|
180
386
|
const invalidateScenes = (server) => {
|
|
@@ -186,35 +392,177 @@ export const playground = (options = {}) => {
|
|
|
186
392
|
}
|
|
187
393
|
};
|
|
188
394
|
const configModuleCode = () => {
|
|
189
|
-
const payload = {
|
|
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
|
+
};
|
|
190
402
|
return `export default ${JSON.stringify(payload)}`;
|
|
191
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
|
+
};
|
|
192
470
|
const scenesModuleCode = () => {
|
|
193
|
-
const
|
|
194
|
-
|
|
471
|
+
const hasMarkdown = scenes.some((scene) => scene.lang === 'md');
|
|
472
|
+
const sceneCode = scenes
|
|
473
|
+
.map((scene) => {
|
|
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) }))`
|
|
480
|
+
: `() => import(${JSON.stringify(scene.url)})`;
|
|
481
|
+
return ` {
|
|
195
482
|
id: ${JSON.stringify(scene.id)},
|
|
483
|
+
name: ${JSON.stringify(scene.name)},
|
|
196
484
|
title: ${JSON.stringify(scene.title)},
|
|
485
|
+
section: ${JSON.stringify(scene.section)},
|
|
486
|
+
api: ${JSON.stringify(scene.api ?? null)},
|
|
197
487
|
group: ${JSON.stringify(scene.group)},
|
|
488
|
+
groupDir: ${JSON.stringify(scene.groupDir)},
|
|
489
|
+
pageId: ${JSON.stringify(scene.pageId)},
|
|
490
|
+
page: ${JSON.stringify(scene.page)},
|
|
198
491
|
path: ${JSON.stringify(scene.url)},
|
|
199
492
|
file: ${JSON.stringify(scene.file)},
|
|
200
493
|
rel: ${JSON.stringify(scene.rel)},
|
|
201
|
-
lang:
|
|
494
|
+
lang: ${JSON.stringify(scene.lang)},
|
|
202
495
|
order: ${JSON.stringify(scene.order)},
|
|
203
496
|
tags: ${JSON.stringify(scene.tags ?? null)},
|
|
204
497
|
description: ${JSON.stringify(scene.description ?? null)},
|
|
205
498
|
hidden: ${JSON.stringify(scene.hidden === true)},
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
load:
|
|
499
|
+
showSource: ${JSON.stringify(scene.showSource)},
|
|
500
|
+
doc: ${JSON.stringify(scene.doc)},
|
|
501
|
+
sources: ${sources},
|
|
502
|
+
load: ${loader},
|
|
210
503
|
raw: () => import(${JSON.stringify(`${scene.url}?raw`)}).then((mod) => mod.default)
|
|
504
|
+
}`;
|
|
505
|
+
})
|
|
506
|
+
.join(',\n');
|
|
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)}
|
|
211
523
|
}`)
|
|
212
524
|
.join(',\n');
|
|
213
|
-
return
|
|
214
|
-
${
|
|
525
|
+
return `${hasMarkdown ? "import { defineMarkdownScene } from '@rinova/playground'\n" : ''}export const scenes = [
|
|
526
|
+
${sceneCode}
|
|
527
|
+
]
|
|
528
|
+
|
|
529
|
+
export const pages = [
|
|
530
|
+
${pageCode}
|
|
215
531
|
]
|
|
216
532
|
`;
|
|
217
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
|
+
};
|
|
218
566
|
return {
|
|
219
567
|
name: 'rinova-playground',
|
|
220
568
|
enforce: 'pre',
|
|
@@ -250,49 +598,118 @@ ${entries}
|
|
|
250
598
|
fs.copyFileSync(from, path.join(target, name));
|
|
251
599
|
}
|
|
252
600
|
},
|
|
253
|
-
resolveId: (id) =>
|
|
601
|
+
resolveId: (id) => RESOLVED_ID[id] ?? null,
|
|
254
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
|
+
}
|
|
255
611
|
if (id !== RESOLVED_ID[VIRTUAL_CONFIG] && id !== RESOLVED_ID[VIRTUAL_SCENES])
|
|
256
612
|
return null;
|
|
257
613
|
await ensureReady();
|
|
258
614
|
return id === RESOLVED_ID[VIRTUAL_CONFIG] ? configModuleCode() : scenesModuleCode();
|
|
259
615
|
},
|
|
260
616
|
configureServer: async (server) => {
|
|
617
|
+
devServer = server;
|
|
261
618
|
await ensureReady();
|
|
262
619
|
server.watcher.add(scenesDir);
|
|
263
620
|
const refresh = (file) => {
|
|
264
621
|
const target = file ?? '';
|
|
265
|
-
const isScene = target.startsWith(scenesDir) &&
|
|
266
|
-
|
|
267
|
-
if (!isScene && !isConfig)
|
|
622
|
+
const isScene = target.startsWith(scenesDir) && /\.(vue|ts)$/.test(target);
|
|
623
|
+
if (!isScene)
|
|
268
624
|
return;
|
|
269
625
|
invalidateScenes(server);
|
|
270
626
|
server.ws.send({ type: 'full-reload' });
|
|
271
627
|
};
|
|
272
628
|
// 组件改动时重算契约清单(防抖,避免每次保存都跑一遍 TS 程序)
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
629
|
+
let manifestTimer = null;
|
|
630
|
+
const scheduleManifest = (delay = 800) => {
|
|
631
|
+
if (manifestTimer)
|
|
632
|
+
clearTimeout(manifestTimer);
|
|
633
|
+
manifestTimer = setTimeout(() => generateManifest(), delay);
|
|
634
|
+
};
|
|
635
|
+
// components 变了要重挂监听:配置里新增的目录原来根本没被 watch。
|
|
636
|
+
// 注意这里只增不减 —— chokidar 的 unwatch 是异步的,紧跟着 add 会让这批路径进入坏状态
|
|
637
|
+
// (实测:unwatch 后再 add,新旧目录都不再触发事件)。多留几个失效目录无害,
|
|
638
|
+
// 最多多跑一次清单重算。
|
|
639
|
+
const componentRoots = new Set();
|
|
640
|
+
const watchComponents = () => {
|
|
641
|
+
for (const pattern of Array.isArray(config.components) ? config.components : []) {
|
|
642
|
+
const dir = path.resolve(root, pattern.split('*')[0] || '.');
|
|
643
|
+
if (componentRoots.has(dir))
|
|
644
|
+
continue;
|
|
645
|
+
componentRoots.add(dir);
|
|
276
646
|
server.watcher.add(dir);
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
}
|
|
647
|
+
}
|
|
648
|
+
};
|
|
649
|
+
const onComponentChange = (file) => {
|
|
650
|
+
if (process.env.RINOVA_DEBUG_WATCH)
|
|
651
|
+
console.log('[rp-debug] event:', file);
|
|
652
|
+
if (!file.endsWith('.vue'))
|
|
653
|
+
return;
|
|
654
|
+
const hit = [...componentRoots].some((dir) => file.startsWith(dir));
|
|
655
|
+
if (process.env.RINOVA_DEBUG_WATCH)
|
|
656
|
+
console.log('[rp-debug] vue=', true, 'hit=', hit);
|
|
657
|
+
if (!hit)
|
|
658
|
+
return;
|
|
659
|
+
scheduleManifest();
|
|
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
|
+
});
|
|
692
|
+
watchComponents();
|
|
693
|
+
server.watcher.on('change', onComponentChange);
|
|
694
|
+
server.watcher.on('add', onComponentChange);
|
|
695
|
+
server.watcher.on('unlink', onComponentChange);
|
|
291
696
|
server.watcher.on('add', refresh);
|
|
292
697
|
server.watcher.on('unlink', refresh);
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
698
|
+
// 改已有场景(尤其是 sceneMeta 的 title/section/api)也要重扫,否则虚拟模块还是旧的
|
|
699
|
+
server.watcher.on('change', (file) => refresh(file));
|
|
700
|
+
server.watcher.on('change', async (file) => {
|
|
701
|
+
if (!configFile || path.resolve(file) !== path.resolve(configFile))
|
|
702
|
+
return;
|
|
703
|
+
// 配置文件变了:重读配置 → 重扫场景 → 按新的 components 重挂监听 → 重算契约清单
|
|
704
|
+
invalidateScenes(server);
|
|
705
|
+
await ensureReady();
|
|
706
|
+
watchComponents();
|
|
707
|
+
watchPlugins();
|
|
708
|
+
const pluginMod = server.moduleGraph.getModuleById(RESOLVED_ID[VIRTUAL_PLUGINS]);
|
|
709
|
+
if (pluginMod)
|
|
710
|
+
server.moduleGraph.invalidateModule(pluginMod);
|
|
711
|
+
scheduleManifest(0);
|
|
712
|
+
server.ws.send({ type: 'full-reload' });
|
|
296
713
|
});
|
|
297
714
|
},
|
|
298
715
|
};
|