@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.d.ts +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +508 -97
- package/dist/index.js.map +1 -1
- package/dist/manifest.d.ts +106 -3
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js +530 -33
- 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 +1 -1
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,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
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
for (const
|
|
111
|
-
|
|
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
|
-
//
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 = {
|
|
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
|
|
520
|
+
const hasMarkdown = scenes.some((scene) => scene.lang === 'md');
|
|
521
|
+
const sceneCode = scenes
|
|
218
522
|
.map((scene) => {
|
|
219
|
-
const
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
|
|
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:
|
|
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: ${
|
|
552
|
+
raw: () => import(${JSON.stringify(`${scene.url}?raw`)}).then((mod) => mod.default)
|
|
247
553
|
}`;
|
|
248
554
|
})
|
|
249
555
|
.join(',\n');
|
|
250
|
-
|
|
251
|
-
|
|
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) =>
|
|
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) &&
|
|
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(() =>
|
|
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
|
});
|