skinsuite 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/README.md +44 -6
  2. package/dist/constants.d.ts +44 -7
  3. package/dist/constants.d.ts.map +1 -1
  4. package/dist/constants.js +44 -7
  5. package/dist/constants.js.map +1 -1
  6. package/dist/index.d.ts +32 -6
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +25 -4
  9. package/dist/index.js.map +1 -1
  10. package/dist/manifest.d.ts +83 -0
  11. package/dist/manifest.d.ts.map +1 -0
  12. package/dist/manifest.js +90 -0
  13. package/dist/manifest.js.map +1 -0
  14. package/dist/registry.d.ts +73 -0
  15. package/dist/registry.d.ts.map +1 -0
  16. package/dist/registry.js +299 -0
  17. package/dist/registry.js.map +1 -0
  18. package/dist/remote-config.d.ts +25 -0
  19. package/dist/remote-config.d.ts.map +1 -0
  20. package/dist/remote-config.js +43 -0
  21. package/dist/remote-config.js.map +1 -0
  22. package/dist/scheme.d.ts +36 -0
  23. package/dist/scheme.d.ts.map +1 -0
  24. package/dist/scheme.js +47 -0
  25. package/dist/scheme.js.map +1 -0
  26. package/dist/types.d.ts +17 -1
  27. package/dist/types.d.ts.map +1 -1
  28. package/dist/views/device-registry.d.ts +45 -0
  29. package/dist/views/device-registry.d.ts.map +1 -0
  30. package/dist/views/device-registry.js +55 -0
  31. package/dist/views/device-registry.js.map +1 -0
  32. package/dist/views/device.d.ts +63 -0
  33. package/dist/views/device.d.ts.map +1 -0
  34. package/dist/views/device.js +59 -0
  35. package/dist/views/device.js.map +1 -0
  36. package/dist/views/params.d.ts +24 -21
  37. package/dist/views/params.d.ts.map +1 -1
  38. package/dist/views/params.js +11 -11
  39. package/dist/views/params.js.map +1 -1
  40. package/docs/onboarding.md +479 -0
  41. package/package.json +23 -2
  42. package/src/constants.ts +141 -100
  43. package/src/index.ts +128 -65
  44. package/src/manifest.ts +131 -0
  45. package/src/registry.ts +400 -0
  46. package/src/scheme.ts +94 -0
  47. package/src/types.ts +19 -1
  48. package/src/views/device-registry.ts +96 -0
  49. package/src/views/device.ts +107 -0
  50. package/src/views/params.ts +26 -23
  51. package/src/vite/dir.ts +85 -0
  52. package/src/vite/index.ts +41 -0
  53. package/src/vite/plugin.ts +409 -0
  54. package/src/vite/theme.d.ts +49 -0
  55. package/src/vue/ThemeDebugPanel.vue +36 -0
  56. package/src/vue/ThemeDebugPanelBody.vue +505 -0
  57. package/src/vue/ThemeRoot.vue +59 -0
  58. package/src/vue/ThemeView.vue +92 -0
  59. package/src/vue/create-theme-state.ts +116 -0
  60. package/src/vue/define-theme-device.ts +157 -0
  61. package/src/vue/device-containers.ts +73 -0
  62. package/src/vue/device-domain.ts +149 -0
  63. package/src/vue/index.ts +98 -0
  64. package/src/vue/panel.ts +16 -0
  65. package/src/vue/plugin.ts +35 -0
  66. package/src/vue/remote-fetch.ts +74 -0
  67. package/src/vue/theme-intent.ts +157 -0
  68. package/src/vue/theme-persistence.ts +136 -0
  69. package/src/vue/theme-runtime.ts +163 -0
  70. package/src/vue/theme-scope.ts +148 -0
  71. package/src/vue/theme-store.ts +58 -0
  72. package/src/vue/theme-views.ts +39 -0
  73. package/src/vue/token-runtime.ts +81 -0
  74. package/src/vue/types.ts +67 -0
  75. package/src/vue/useParentThemeSync.ts +99 -0
  76. package/src/vue/useThemeDebugPanel.ts +192 -0
  77. package/src/vue/useThemeSources.ts +74 -0
  78. package/src/vue/view-page.ts +99 -0
  79. package/src/vue/view-registry.ts +215 -0
  80. package/src/vue/viewport.ts +54 -0
  81. package/src/mode.ts +0 -38
  82. package/src/tone.ts +0 -56
@@ -0,0 +1,409 @@
1
+ /**
2
+ * 主题域扫描插件 —— **主题库自带的 Vite 适配器**(随 `skinsuite/vite` 一起发布)
3
+ *
4
+ * 把 `VITE_THEME_DIR` 指向的**主题根目录**扫成一个虚拟模块 `virtual:theme`,它同时产出三样东西:
5
+ *
6
+ * ① **资产**:主题包定义、配色元信息、配色附加样式(惰性)、版式组件加载表 `themeViews`
7
+ * ② **清单**:根目录里那份 `theme.config.ts`(调试开关,见 `defineThemeManifest`)
8
+ * ③ **声明**:`views/*.ts`(设备声明 + 页面契约)以副作用 import 拉齐注册
9
+ *
10
+ * 本模块被 import 的那一刻(模块求值期副作用)自动完成三件装配:
11
+ * `installThemeViews(themeViews)` + `installThemeRegistry(...)` + `installThemeManifest(...)`,
12
+ * 宿主**无需 import `virtual:theme` 再转发**,也无需手动 import 任何 `views/*.ts`。
13
+ *
14
+ * === 目录口径:`VITE_THEME_DIR` 是「主题根目录」,不是「主题包目录」===
15
+ * 根目录内固定三个名字(约定俗成,换根目录位置时整包跟着走):
16
+ * `theme.config.ts` 清单
17
+ * `themes/` 主题包目录(本插件扫这里,出 packages/colors/styles/themeViews)
18
+ * `views/` 声明目录(devices.ts 设备声明 + 页面契约,本插件副作用 import 拉齐注册)
19
+ * 所以本插件在 `dir` 下**再进一层 `themes/`** 扫包、再进一层 `views/` 拉声明,
20
+ * 清单则从 `dir` 根层读。
21
+ *
22
+ * === 为什么它放在库里(而非让每个宿主各写一份)===
23
+ * 它是主题机制的**构建期半边**:内核负责契约(`ThemeManifest`),
24
+ * 这里负责扫描。没有它,「主题目录是配置项」就只实现了一半 —— 每个宿主都得
25
+ * 自己抄一遍一百多行的扫描逻辑,正是"库不够强大"的典型症状。
26
+ *
27
+ * `vite.config.ts` 由 Vite 用 esbuild 预打包后执行,裸包说明符会被当外部依赖交给
28
+ * Node `import()`。因此本入口**刻意用 subpath export 指向源码 `.ts`**(而非 `dist/`),
29
+ * 与 `skinsuite/vue` 同一手法:Vite 自己能转译 TS,源码直供就绕开了
30
+ * "包必须先构建 dist 才能被 config 引用"的脆弱前提(CI 里 `npm ci` 不会构建工作区包)。
31
+ *
32
+ * === 它解决的问题 ===
33
+ * `import.meta.glob` 的模式必须是**字面量**(Vite 在编译期静态展开),所以
34
+ * 「主题目录放在哪」一旦写进 glob 就再也改不了 —— 想换目录只能改源码。
35
+ * 本插件把扫描搬到构建期(Node 侧 `fs`),用**虚拟模块**把结果喂给运行时,
36
+ * 于是目录路径变成 `VITE_THEME_DIR` 这一个环境变量。
37
+ *
38
+ * === 产出形态(资产部分与 `import.meta.glob` 的结果保持一致)===
39
+ * ```ts
40
+ * export const packages: { './default/index.ts': Module }
41
+ * export const colors: { './default/mobile/login/colors/white/index.ts': Module }
42
+ * export const styles: { './default/mobile/login/colors/white/theme.scss': () => Promise<string> }
43
+ * export const themeViews: { 'login': { './default/mobile/login/index.vue': () => Module } }
44
+ * // 副作用:installThemeViews(themeViews) / installThemeRegistry(...) / installThemeManifest(...)
45
+ * ```
46
+ * 资产键**相对主题包目录**(`themes/`,`./` 开头),值保持「模块命名空间」而非默认导出 ——
47
+ * 这样宿主注册表代码一行都不用改(它按「包/设备/页面/配色」四段解析键)。`styles` 是
48
+ * **惰性**的(`?inline` 动态 import),背景图与字体不会进首屏包。
49
+ *
50
+ * === 为什么不用 codegen 落盘 ===
51
+ * 生成物入库有两个麻烦:`git status` 常脏、以及"忘了重新生成"导致的静默过期。
52
+ * 虚拟模块没有落盘产物,改一个目录下次请求就是新的。
53
+ *
54
+ * === 容错的边界 ===
55
+ * 目录不存在、包写坏、配色缺 `tone` —— 一律**跳过而不是报错**:一个写歪的目录不该
56
+ * 让整站起不来。目录口径的问题由宿主的关卡负责大喊。
57
+ * **唯独清单不跳过**:它一旦写错,`defineThemeManifest` 会在启动时直接抛,
58
+ * 因为「面板不出现」「路由推不出页面」这类症状根本无从归因。
59
+ *
60
+ * @author yijiu2025
61
+ * @since 2026-10-17
62
+ */
63
+ import { existsSync, readdirSync, statSync } from 'node:fs';
64
+ import { join, resolve, sep } from 'node:path';
65
+ import type { Plugin } from 'vite';
66
+ import {
67
+ DEFAULT_THEME_PACKAGE,
68
+ THEME_COLOR_DEF_FILE,
69
+ THEME_COLOR_STYLE_FILE,
70
+ THEME_COLORS_SEGMENT,
71
+ THEME_DIR_LEVELS,
72
+ THEME_PACKAGE_DEF_FILE
73
+ } from '../constants.ts';
74
+ import { DEFAULT_THEME_MANIFEST, THEME_PACKAGES_SUBDIR, THEME_VIEWS_SUBDIR } from './dir.ts';
75
+
76
+ /** 版式组件的叶子文件名(`themes/<包>/<设备>/<页面>/index.vue`) */
77
+ const VIEW_COMPONENT_FILE = 'index.vue';
78
+
79
+ export interface ThemeAssetsPluginOptions {
80
+ /** 主题**根目录**:相对 Vite root 或绝对路径(通常来自 `resolveThemeDir()`,已是绝对路径) */
81
+ dir: string;
82
+ /** 虚拟模块 id,缺省 `virtual:theme` */
83
+ moduleId?: string;
84
+ /** 清单文件名,缺省 `theme.config.ts`;设为空串则**不**注入清单 */
85
+ manifestFile?: string;
86
+ }
87
+
88
+ interface ScanEntry {
89
+ key: string;
90
+ file: string;
91
+ }
92
+
93
+ /** 版式组件条目(`themes/<包>/<设备>/<页面>/index.vue`) */
94
+ interface ViewEntry {
95
+ /** 包名(内置包判断用) */
96
+ pkg: string;
97
+ /** 页面名(分组用) */
98
+ page: string;
99
+ key: string;
100
+ file: string;
101
+ }
102
+
103
+ interface ScanResult {
104
+ packages: ScanEntry[];
105
+ colors: ScanEntry[];
106
+ styles: ScanEntry[];
107
+ views: ViewEntry[];
108
+ /** `views/*.ts` 声明文件(设备声明 + 页面契约),副作用 import 拉齐注册 */
109
+ decls: ScanEntry[];
110
+ }
111
+
112
+ const DEFAULT_MODULE_ID = 'virtual:theme';
113
+
114
+ /** 目录不存在 / 没有清单时的产出:空注册表 + 空清单(import 本模块即自动装配) */
115
+ const EMPTY_MODULE = [
116
+ "import { installThemeRegistry, buildThemeRegistry, resolveThemeManifest, installThemeManifest } from 'skinsuite';",
117
+ "import { installThemeViews } from 'skinsuite/vue';",
118
+ 'export const packages = {};',
119
+ 'export const colors = {};',
120
+ 'export const styles = {};',
121
+ 'export const themeViews = {};',
122
+ 'installThemeViews(themeViews);',
123
+ 'installThemeRegistry(buildThemeRegistry({ packages, colors, styles }));',
124
+ 'export const themeManifest = resolveThemeManifest(null);',
125
+ 'installThemeManifest(themeManifest);'
126
+ ].join('\n');
127
+
128
+ /** 是否为目录;不存在或不可读一律 false(扫描全程"读不到就跳过") */
129
+ function isDir(path: string): boolean {
130
+ try {
131
+ return statSync(path).isDirectory();
132
+ } catch {
133
+ return false;
134
+ }
135
+ }
136
+
137
+ function subdirs(path: string): string[] {
138
+ try {
139
+ return readdirSync(path, { withFileTypes: true })
140
+ .filter(entry => entry.isDirectory())
141
+ .map(entry => entry.name)
142
+ .sort();
143
+ } catch {
144
+ return [];
145
+ }
146
+ }
147
+
148
+ /** 目录下的 `.ts` 文件(不含 `.d.ts`);不存在或不可读返回空(读不到就跳过) */
149
+ function tsFiles(path: string): string[] {
150
+ try {
151
+ return readdirSync(path, { withFileTypes: true })
152
+ .filter(entry => entry.isFile() && entry.name.endsWith('.ts') && !entry.name.endsWith('.d.ts'))
153
+ .map(entry => entry.name)
154
+ .sort();
155
+ } catch {
156
+ return [];
157
+ }
158
+ }
159
+
160
+ /**
161
+ * 扫描主题**包目录**(根目录下的 `themes/` 子目录)
162
+ *
163
+ * `dir` 是主题根目录(`VITE_THEME_DIR`),真正的包住在 `dir/themes/` 下 ——
164
+ * 这个子目录名固定,宿主 `views/*.ts` 的 `import.meta.glob` 字面量也认它,两边靠名字对齐。
165
+ *
166
+ * === 目录结构由内核契约驱动,而非手写嵌套 for(2026-10-17)===
167
+ * 「包 / 设备 / 页面 / colors / 配色」这五段里,`colors` 是**固定字面段**、其余四段
168
+ * 是**取值段**(取值由文件系统与宿主设备清单决定,本插件不认识具体值)。这段结构的
169
+ * 唯一来源是 `constants.ts` 的 `THEME_DIR_LEVELS`(取值目录段)+ `THEME_COLORS_SEGMENT`
170
+ * (固定字面段)+ `THEME_COLOR_DEF_FILE` / `THEME_COLOR_STYLE_FILE`(叶子文件)。
171
+ * 本函数用 `walk` 按 `THEME_DIR_LEVELS` 做**通用递归下降**——目录加一层、或 `colors`
172
+ * 改名,只动那一份契约,这里与注册表的 `colorFromKey()` 一起跟着变,不再有
173
+ * "扫得到但解析不出"或反之的静默脱节。
174
+ *
175
+ * 只认内核约定的三种文件,其余一律忽略(清单文件住在根层,不在 `themes/` 里,天然不进这里)。
176
+ */
177
+ function scan(dir: string): ScanResult {
178
+ const packagesDir = join(dir, THEME_PACKAGES_SUBDIR);
179
+ const result: ScanResult = { packages: [], colors: [], styles: [], views: [], decls: [] };
180
+
181
+ for (const pkg of subdirs(packagesDir)) {
182
+ const pkgDir = join(packagesDir, pkg);
183
+ if (existsSync(join(pkgDir, THEME_PACKAGE_DEF_FILE))) {
184
+ result.packages.push({
185
+ key: `./${pkg}/${THEME_PACKAGE_DEF_FILE}`,
186
+ file: join(pkgDir, THEME_PACKAGE_DEF_FILE)
187
+ });
188
+ }
189
+ // 按契约递归下降:取值目录段(THEME_DIR_LEVELS)逐层深入,最后在 `colors/`
190
+ // 固定字面段之下收叶子(index.ts / theme.scss)。
191
+ walk(pkgDir, [pkg], result);
192
+ }
193
+
194
+ // 声明目录 `views/*.ts`(设备声明 devices.ts + 页面契约 login/register/forgot-password):
195
+ // 插件以副作用 import 拉齐,宿主 bootstrap/store 不再手动 import。
196
+ for (const name of tsFiles(join(dir, THEME_VIEWS_SUBDIR))) {
197
+ result.decls.push({
198
+ key: `./${THEME_VIEWS_SUBDIR}/${name}`,
199
+ file: join(dir, THEME_VIEWS_SUBDIR, name)
200
+ });
201
+ }
202
+ return result;
203
+ }
204
+
205
+ /**
206
+ * 按 `THEME_DIR_LEVELS` 递归下降主题包目录
207
+ *
208
+ * `THEME_DIR_LEVELS = ['pkg','device','page','colorId']`,其中 `pkg` 由 `scan()` 的
209
+ * 外层循环进入,本函数递归「设备 → 页面 → 配色」三段;`colorId` 是最后一个取值段,
210
+ * 它的**父目录**是固定字面段 `colors/`(不是一层取值目录),所以到 `colorId` 这层
211
+ * 不再递归子目录,而是直接在 `colors/<配色>/` 下收叶子文件。
212
+ *
213
+ * @param base 当前目录的绝对路径
214
+ * @param segments 已累积的**取值段**值(与 `THEME_DIR_LEVELS` 前缀一一对应,含包名)
215
+ * @param result 累积产物
216
+ */
217
+ function walk(base: string, segments: string[], result: ScanResult): void {
218
+ // 已累积到「包/设备/页面」三段后,下一层是固定字面段 `colors/` + 最后一个取值段 `colorId`。
219
+ // 判定依据 = `THEME_DIR_LEVELS` 里还剩几段未进入:只剩 `colorId` 一段时收叶子。
220
+ if (segments.length === THEME_DIR_LEVELS.length - 1) {
221
+ // 已到「包/设备/页面」层:版式组件(本页 index.vue)+ colors/ 配色
222
+ const viewFile = join(base, VIEW_COMPONENT_FILE);
223
+ if (existsSync(viewFile)) {
224
+ result.views.push({
225
+ pkg: segments[0],
226
+ page: segments[2],
227
+ key: './' + [...segments, VIEW_COMPONENT_FILE].join('/'),
228
+ file: viewFile
229
+ });
230
+ }
231
+ const colorsDir = join(base, THEME_COLORS_SEGMENT);
232
+ for (const color of subdirs(colorsDir)) {
233
+ const colorDir = join(colorsDir, color);
234
+ // 键以 `./` 开头(相对主题包目录),段间单斜杠:`./<包>/<设备>/<页面>/colors/<配色>`
235
+ const keyBase = './' + [...segments, THEME_COLORS_SEGMENT, color].join('/');
236
+ if (existsSync(join(colorDir, THEME_COLOR_DEF_FILE))) {
237
+ result.colors.push({
238
+ key: `${keyBase}/${THEME_COLOR_DEF_FILE}`,
239
+ file: join(colorDir, THEME_COLOR_DEF_FILE)
240
+ });
241
+ }
242
+ if (existsSync(join(colorDir, THEME_COLOR_STYLE_FILE))) {
243
+ result.styles.push({
244
+ key: `${keyBase}/${THEME_COLOR_STYLE_FILE}`,
245
+ file: join(colorDir, THEME_COLOR_STYLE_FILE)
246
+ });
247
+ }
248
+ }
249
+ return;
250
+ }
251
+
252
+ // 取值段逐层下降:设备 → 页面。
253
+ for (const name of subdirs(base)) {
254
+ walk(join(base, name), [...segments, name], result);
255
+ }
256
+ }
257
+
258
+ /** 绝对路径 → 模块说明符(root 内用 `/xxx`,root 外用正斜杠绝对路径) */
259
+ function toSpecifier(abs: string, root: string): string {
260
+ const absolute = resolve(abs);
261
+ const base = resolve(root);
262
+ if (absolute.startsWith(base + sep)) {
263
+ return '/' + absolute.slice(base.length + 1).split(sep).join('/');
264
+ }
265
+ return absolute.split(sep).join('/');
266
+ }
267
+
268
+ /** 生成虚拟模块源码 */
269
+ function render(result: ScanResult, root: string, manifestSpec: string | null): string {
270
+ const lines: string[] = [
271
+ // 装配依赖(纯内核机制):本模块被 import 时自动装注册表 + 解析清单单例
272
+ "import { installThemeRegistry, buildThemeRegistry, resolveThemeManifest, installThemeManifest } from 'skinsuite';",
273
+ // 版式加载表单例(Vue 绑定层):本模块体把 themeViews 装进内核单例,页面契约惰性解析
274
+ "import { installThemeViews } from 'skinsuite/vue';",
275
+ manifestSpec
276
+ ? `import { default as __manifest } from ${JSON.stringify(manifestSpec)};`
277
+ : 'const __manifest = null;',
278
+ // 声明文件副作用 import(views/*.ts):拉齐 defineThemeDevice / defineThemePage 注册。
279
+ // 🔴 必须在 `installThemeViews(themeViews)` 之前**作为 import 语句**存在(ESM import 全部
280
+ // 提升到模块体之前执行),而页面契约现在是惰性解析 loader,不读 themeViews ——
281
+ // 所以这里安全,不再是「页面契约 import virtual:theme」的循环。
282
+ ...result.decls.map(entry => `import ${JSON.stringify(toSpecifier(entry.file, root))};`),
283
+ ''
284
+ ];
285
+
286
+ const emit = (entries: ScanEntry[], exportName: 'packages' | 'colors'): void => {
287
+ entries.forEach((entry, index) => {
288
+ lines.push(
289
+ `import * as __${exportName}${index} from ${JSON.stringify(toSpecifier(entry.file, root))};`
290
+ );
291
+ });
292
+ lines.push(`export const ${exportName} = {`);
293
+ entries.forEach((entry, index) => {
294
+ lines.push(` ${JSON.stringify(entry.key)}: __${exportName}${index},`);
295
+ });
296
+ lines.push('};');
297
+ };
298
+
299
+ emit(result.packages, 'packages');
300
+ emit(result.colors, 'colors');
301
+
302
+ lines.push('export const styles = {');
303
+ result.styles.forEach((entry, index) => {
304
+ const spec = JSON.stringify(toSpecifier(entry.file, root) + '?inline');
305
+ lines.push(
306
+ ` ${JSON.stringify(entry.key)}: () => import(${spec}).then(__m${index} => __m${index}.default),`
307
+ );
308
+ });
309
+ lines.push('};');
310
+
311
+ // ---- themeViews:版式组件(按页面分组;内置包 eager 零请求、非内置包惰性)----
312
+ // 🔴 内置包用**同步返回**(`() => __viewN`,eager 静态 import 的模块命名空间),
313
+ // 这样 `ViewRegistry.builtin()` 才能首帧同步拿到内置包版式(零请求、不闪屏)。
314
+ const builtinViews = result.views.filter(v => v.pkg === DEFAULT_THEME_PACKAGE);
315
+ builtinViews.forEach((entry, index) => {
316
+ lines.push(`import * as __view${index} from ${JSON.stringify(toSpecifier(entry.file, root))};`);
317
+ });
318
+ const byPage = new Map<string, ViewEntry[]>();
319
+ for (const entry of result.views) {
320
+ const list = byPage.get(entry.page);
321
+ if (list) list.push(entry);
322
+ else byPage.set(entry.page, [entry]);
323
+ }
324
+ lines.push('export const themeViews = {');
325
+ for (const [page, entries] of byPage) {
326
+ lines.push(` ${JSON.stringify(page)}: {`);
327
+ entries.forEach(entry => {
328
+ if (entry.pkg === DEFAULT_THEME_PACKAGE) {
329
+ const idx = builtinViews.indexOf(entry);
330
+ lines.push(` ${JSON.stringify(entry.key)}: () => __view${idx},`);
331
+ } else {
332
+ const spec = JSON.stringify(toSpecifier(entry.file, root));
333
+ lines.push(` ${JSON.stringify(entry.key)}: () => import(${spec}),`);
334
+ }
335
+ });
336
+ lines.push(' },');
337
+ }
338
+ lines.push('};');
339
+
340
+ // ---- 装配副作用 + 清单单例(import 本模块即自动装版式加载表 + 注册表 + 清单)----
341
+ lines.push('installThemeViews(themeViews);');
342
+ lines.push('installThemeRegistry(buildThemeRegistry({ packages, colors, styles }));');
343
+ lines.push('export const themeManifest = resolveThemeManifest(__manifest);');
344
+ lines.push('installThemeManifest(themeManifest);');
345
+
346
+ return lines.join('\n');
347
+ }
348
+
349
+ /**
350
+ * 主题域插件
351
+ *
352
+ * @example
353
+ * ```ts
354
+ * // vite.config.ts
355
+ * import { themeAssets, resolveThemeDir } from 'skinsuite/vite';
356
+ *
357
+ * export default defineConfig(({ mode }) => ({
358
+ * plugins: [themeAssets({ dir: resolveThemeDir(mode) })]
359
+ * }));
360
+ * ```
361
+ */
362
+ export function themeAssets(options: ThemeAssetsPluginOptions): Plugin {
363
+ const moduleId = options.moduleId ?? DEFAULT_MODULE_ID;
364
+ const virtualId = '\0' + moduleId;
365
+ const manifestFile = options.manifestFile ?? DEFAULT_THEME_MANIFEST;
366
+ let root = process.cwd();
367
+
368
+ return {
369
+ name: 'skinsuite:theme-assets',
370
+ // 虚拟模块要抢在 Vite 的内置解析之前
371
+ enforce: 'pre',
372
+
373
+ configResolved(config) {
374
+ root = config.root;
375
+ },
376
+
377
+ resolveId(id) {
378
+ return id === moduleId ? virtualId : null;
379
+ },
380
+
381
+ load(id) {
382
+ if (id !== virtualId) return null;
383
+ const dir = resolve(root, options.dir);
384
+ // 根目录不存在 = 还没建主题,给**空注册表 + 空清单**而不是让应用起不来
385
+ if (!isDir(dir)) return EMPTY_MODULE;
386
+
387
+ const manifestPath = manifestFile ? join(dir, manifestFile) : '';
388
+ const manifestSpec = manifestPath && existsSync(manifestPath)
389
+ ? toSpecifier(manifestPath, root)
390
+ : null;
391
+
392
+ return render(scan(dir), root, manifestSpec);
393
+ },
394
+
395
+ configureServer(server) {
396
+ const dir = resolve(root, options.dir);
397
+ const onChange = (file: string): void => {
398
+ if (!file.startsWith(dir)) return;
399
+ const mod = server.moduleGraph.getModuleById(virtualId);
400
+ if (mod) server.moduleGraph.invalidateModule(mod);
401
+ server.ws.send({ type: 'full-reload' });
402
+ };
403
+ // 只监听**增删**:新增/删除主题目录或清单会改变虚拟模块的导出面;
404
+ // 改已有文件的内容由 HMR 自己处理(那是普通模块,不必重建清单)。
405
+ server.watcher.on('add', onChange);
406
+ server.watcher.on('unlink', onChange);
407
+ }
408
+ };
409
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * 虚拟模块 `virtual:theme` 的类型声明(随 `skinsuite/vite` 一起发布,宿主不必自写)
3
+ *
4
+ * 该模块由 `skinsuite/vite` 的 `themeAssets()` 在构建期生成(宿主在 `vite.config.ts`
5
+ * 挂上它即可,不自己写扫描逻辑),扫的是 `.env` 里 `VITE_THEME_DIR` 指向的**主题根目录**
6
+ * (缺省 `src/theme`)—— 所以主题根目录是配置项,不再写死在 `import.meta.glob` 里。
7
+ *
8
+ * 它一次产出两样东西(同一份扫描结果,不会出现"资产在 A、配置在 B"):
9
+ * · 资产:`packages` / `colors` / `styles`
10
+ * · 清单:`manifest`(根目录里的 `theme.config.ts`,契约见 `defineThemeManifest`)
11
+ *
12
+ * 资产键**相对主题包目录 `themes/`**(`./<包>/...`),值保持"模块命名空间"形态,
13
+ * 与原先 `import.meta.glob` 的结果一致。
14
+ *
15
+ * ⚠️ 本文件属于 `skinsuite/vite` 构建器层(内核 tsc 会 exclude 它),由宿主的
16
+ * vue-tsc / Vite 转译检查 —— 因为 `virtual:theme` 是构建期产物,不在纯内核里。
17
+ */
18
+ declare module 'virtual:theme' {
19
+ import type { Component } from 'vue';
20
+ import type { MauthThemeColor, MauthThemePackage, ResolvedThemeManifest } from 'skinsuite';
21
+
22
+ /** 包定义:`./<包>/index.ts` */
23
+ export const packages: Record<string, { default: MauthThemePackage }>;
24
+ /** 配色元信息 + tokens:`./<包>/<设备>/<页面>/colors/<配色>/index.ts` */
25
+ export const colors: Record<string, { default: MauthThemeColor }>;
26
+ /** 附加样式(惰性,取编译后的 CSS 字符串):`./<包>/<设备>/<页面>/colors/<配色>/theme.scss` */
27
+ export const styles: Record<string, () => Promise<string>>;
28
+ /**
29
+ * 版式组件(按页面分组):`themeViews.<页面>` = 该页各「包 × 设备」的版式组件加载器
30
+ *
31
+ * 键相对主题包目录:`./<包>/<设备>/<页面>/index.vue`。内置包(default)eager(零请求,
32
+ * 加载器**同步返回**模块命名空间)、非内置包惰性(返回 Promise)。与原先
33
+ * `import.meta.glob` 的惰性加载器形态一致。
34
+ */
35
+ export const themeViews: Record<
36
+ string,
37
+ Record<string, () => { default: Component } | Promise<{ default: Component }>>
38
+ >;
39
+
40
+ /**
41
+ * 主题域清单(**已 resolve**,非 null)
42
+ *
43
+ * 目录里没有 `theme.config.ts` 时回落到机制默认值(`resolveThemeManifest` 兜底),
44
+ * 所以这里恒非 null —— 机制照常工作(有资产、面板退回默认档),
45
+ * 而不是让应用起不来。本模块被 import 时同时完成「版式加载表安装 + 注册表装配 +
46
+ * 清单解析 + 声明目录(views/*.ts)副作用拉齐」。
47
+ */
48
+ export const themeManifest: ResolvedThemeManifest;
49
+ }
@@ -0,0 +1,36 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * 主题 / 版式调试面板的**门卫**(对外,宿主 App.vue 里一行 `<ThemeDebugPanel />`)
4
+ *
5
+ * === 职责只有一个:决定「要不要加载面板主体」 ===
6
+ * 本组件是随主包的**轻量门卫**,只算 `visible`(清单 `debug` 开关 + `?debug=theme`),
7
+ * 再用 `defineAsyncComponent(() => import('./ThemeDebugPanelBody.vue'))` 按需加载真正的
8
+ * 面板重 chunk。于是:
9
+ * · `off` 档 → `visible` 恒 false → `import()` 永不触发 → **chunk 不进产物**
10
+ * · `url` 档 + 无参数 → `visible` false → 同上,不下载也不执行
11
+ * · `url` 档 + 有参数 / `always` → `visible` true → 才触发异步加载 + 渲染
12
+ *
13
+ * === 为什么不做成「宿主自己 v-if」===
14
+ * 可见性判断(`isThemeDebugVisible(debug, query)`)是**库的机制**,不该让宿主写;宿主
15
+ * 保持零接线(一行 `<ThemeDebugPanel />`),「什么时候加载」这个决策收在门卫里。
16
+ *
17
+ * @author yijiu2025
18
+ * @since 2026-09-23(2026-10-18 拆「门卫 + 主体」:off 档连 chunk 都不下载)
19
+ */
20
+ import { computed, defineAsyncComponent } from 'vue';
21
+ import { isThemeDebugVisible, themeManifest } from '../manifest';
22
+ import { currentQuery } from './device-domain';
23
+
24
+ /** 面板此刻是否该显示(清单 debug 开关 + URL 触发参数) */
25
+ const visible = computed(() => isThemeDebugVisible(themeManifest().debug, currentQuery()));
26
+
27
+ /**
28
+ * 面板主体(重 chunk,独立分割):`v-if="visible"` 保证 false 时 Vue 不挂载本组件,
29
+ * 于是 `import()` 不被触发 → chunk 不下载。真正的面板 UI / 数据 / 动作都在 Body 里。
30
+ */
31
+ const PanelBody = defineAsyncComponent(() => import('./ThemeDebugPanelBody.vue'));
32
+ </script>
33
+
34
+ <template>
35
+ <PanelBody v-if="visible" />
36
+ </template>