vite-plugin-siyuan 0.0.0-stage → 0.2.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.
Files changed (45) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +116 -2
  3. package/dist/artifact/assets.d.ts +17 -0
  4. package/dist/artifact/assets.js +92 -0
  5. package/dist/artifact/manifest-schema.d.ts +35 -0
  6. package/dist/artifact/manifest-schema.js +81 -0
  7. package/dist/artifact/verify.d.ts +16 -0
  8. package/dist/artifact/verify.js +99 -0
  9. package/dist/artifact/zip.d.ts +6 -0
  10. package/dist/artifact/zip.js +26 -0
  11. package/dist/config.d.ts +20 -0
  12. package/dist/config.js +92 -0
  13. package/dist/index.d.ts +15 -0
  14. package/dist/index.js +87 -0
  15. package/dist/output/log.d.ts +11 -0
  16. package/dist/output/log.js +32 -0
  17. package/dist/output/preview-links.d.ts +17 -0
  18. package/dist/output/preview-links.js +39 -0
  19. package/dist/session.d.ts +45 -0
  20. package/dist/session.js +195 -0
  21. package/dist/siyuan/activate.d.ts +9 -0
  22. package/dist/siyuan/activate.js +61 -0
  23. package/dist/siyuan/kinds.d.ts +25 -0
  24. package/dist/siyuan/kinds.js +59 -0
  25. package/dist/siyuan/link.d.ts +19 -0
  26. package/dist/siyuan/link.js +203 -0
  27. package/dist/siyuan/manifest.d.ts +9 -0
  28. package/dist/siyuan/manifest.js +24 -0
  29. package/dist/siyuan/port.d.ts +28 -0
  30. package/dist/siyuan/port.js +171 -0
  31. package/dist/siyuan/workspace.d.ts +9 -0
  32. package/dist/siyuan/workspace.js +58 -0
  33. package/dist/support/env.d.ts +8 -0
  34. package/dist/support/env.js +12 -0
  35. package/dist/support/errors.d.ts +12 -0
  36. package/dist/support/errors.js +19 -0
  37. package/dist/support/fs.d.ts +20 -0
  38. package/dist/support/fs.js +55 -0
  39. package/dist/support/guards.d.ts +3 -0
  40. package/dist/support/guards.js +7 -0
  41. package/dist/support/log.d.ts +5 -0
  42. package/dist/support/log.js +2 -0
  43. package/dist/support/timing.d.ts +13 -0
  44. package/dist/support/timing.js +13 -0
  45. package/package.json +57 -3
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 svchord
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,117 @@
1
- # Temporary Holding Version
1
+ # vite-plugin-siyuan
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ 思源主题/插件开发的 Vite 插件:注入构建配置、链接到工作区、热重载、校验产物、打包。
4
+
5
+ ## 安装
6
+
7
+ 要求 Node.js >= 22.18、Vite >= 6。
8
+
9
+ ```bash
10
+ pnpm add -D vite-plugin-siyuan
11
+ ```
12
+
13
+ ## 使用
14
+
15
+ ```ts
16
+ // vite.config.ts
17
+ import { defineConfig } from 'vite';
18
+ import siyuan from 'vite-plugin-siyuan';
19
+
20
+ export default defineConfig({
21
+ // entry 必填:主题指向 CSS,插件指向 TS/JS
22
+ plugins: [siyuan({ entry: 'src/main.css' })],
23
+ });
24
+ ```
25
+
26
+ ```bash
27
+ pnpm vite build --watch
28
+ ```
29
+
30
+ `vite dev` 不适用:思源从磁盘读产物,而 dev server 只在内存里编译。插件的 `apply` 固定为 `'build'`。
31
+
32
+ ## 选项
33
+
34
+ | 选项 | 说明 |
35
+ |---|---|
36
+ | `entry` | **必填**。构建入口,相对项目根。主题指向 CSS,插件指向 TS/JS |
37
+ | `kind` | `'theme'` / `'plugin'`;缺省时在 `public/`、项目根依次查找 `theme.json` / `plugin.json` 推断 |
38
+ | `outDir` | 产物目录;缺省 `./dev`(watch)/ `./dist`(生产) |
39
+
40
+ > `entry` 必填是因为产物名是固定契约(主题 `theme.css`、插件 `index.js`)。
41
+ > 入口猜错不会报错,只会静默产出思源加载不到的东西——所以宁可要求显式指定。
42
+
43
+ ## 做什么
44
+
45
+ 两种模式行为差别很大。
46
+
47
+ ### watch(`vite build --watch`)
48
+
49
+ 1. **注入构建配置**:主题产出 `theme.css`;插件走 `lib` CJS 单文件产出 `index.js`。`base: './'` 保证宿主下资源不 404。用 `mergeConfig` 合并,**用户已写的值优先**。
50
+ 2. **链接产物**:把 `dev/` 链接到 `{workspace}/data/themes|plugins/{name}`。插件目录必须是真 symlink(思源 Go 运行时按 `ModeSymlink` 判定),主题可用 junction 兜底。
51
+ 3. **激活**:主题调 `setAppearance` 选中它;插件调 `setPetalEnabled` 启用。
52
+ 4. **热重载**:产物变化后合并 250ms 广播 `reloadUI`,思源整页刷新。
53
+
54
+ ### 生产(`vite build`)
55
+
56
+ 只做 **校验 + 打包**:检查清单字段、版本一致性、必需产物、主题的 `@layer` 与双重包装颜色;通过后在**项目根目录**写出 `package.zip`。
57
+
58
+ 生产构建**不会**链接目录、不会切换主题/插件;校验不通过会直接抛错中止。
59
+
60
+ ## 输出
61
+
62
+ 每轮构建结束时打印一行,供 agent 判断「可以测量了」:
63
+
64
+ ```
65
+ [vite-plugin-siyuan] build #12 ready · theme · watch
66
+ ```
67
+
68
+ 生产构建对应的是:
69
+
70
+ ```
71
+ [vite-plugin-siyuan] build #1 ready · theme · package.zip
72
+ ```
73
+
74
+ watch 模式首次就绪后还会打印桌面端 / 移动端两个稳定预览入口。
75
+
76
+ ## 环境变量
77
+
78
+ | 变量 | 说明 |
79
+ |---|---|
80
+ | `SIYUAN_PORT` | kernel 端口;缺省用 kernel 进程 PID 匹配 `port.json`,再探测记录端口与 6806–6809 |
81
+ | `SIYUAN_THEME_DIR` | 主题安装父目录(`{workspace}/data/themes`),实际装到 `{该目录}/{name}` |
82
+ | `SIYUAN_PLUGIN_DIR` | 插件安装父目录(`{workspace}/data/plugins`),同上 |
83
+ | `SIYUAN_TOKEN` | 开了 API token 时使用 |
84
+ | `SIYUAN_NO_ELEVATION=1` | 不弹 UAC 提权(CI 环境自动不弹) |
85
+ | `SIYUAN_DEBUG=1` | 输出 debug 级日志 |
86
+
87
+ > 旧名 `SYD_NO_ELEVATION` / `SYD_DEBUG` 仍然兼容。
88
+
89
+ ## 配套 CLI
90
+
91
+ 测量真实渲染用 [siyuan-devtools](https://github.com/svchord/siyuan-devtools)。
92
+
93
+ ## 参与开发
94
+
95
+ ### 提 PR
96
+
97
+ 1. 先开 issue 对齐需求,避免白做。
98
+ 2. PR 标题必须符合 [Conventional Commits](https://www.conventionalcommits.org/)——CI 会检查:
99
+ - `feat: ...` → 次版本(0.2.0)
100
+ - `fix: ...` → 补丁版本(0.1.1)
101
+ - `feat!: ...` → 1.0 之前只升次版本
102
+ - `docs:` / `chore:` / `ci:` / `test:` / `refactor:` → 不发版
103
+ 3. 合并方式固定为 **Squash and merge**,PR 标题会成为最终 commit。
104
+
105
+ ### 发布流程
106
+
107
+ 用 [release-please](https://github.com/googleapis/release-please) 自动管理版本,仓库里不手写版本号:
108
+
109
+ ```
110
+ 普通 PR 合入 main
111
+ └─ release-please 开/更新 Release PR「chore(main): release x.y.z」
112
+ └─ 合并该 PR
113
+ ├─ 自动打 tag、建 GitHub Release、生成 CHANGELOG.md
114
+ └─ 自动发布到 npm(OIDC trusted publishing)
115
+ ```
116
+
117
+ `package.json` 的 `version` 由 Release PR 更新,不要手动改。
@@ -0,0 +1,17 @@
1
+ import type { Logger } from '../support/log.ts';
2
+ /** 把 `public/**` 逐个交给构建器登记监听。 */
3
+ export declare function watchPublicFiles(publicDir: string, registerWatchFile: (file: string) => void, log: Logger): void;
4
+ /**
5
+ * 把清单声明的元数据文件搬进产物目录;已由构建器搬过的(public/ 下)跳过。
6
+ *
7
+ * watch 下复制失败只报错继续(一次文件锁不该停掉开发服务器);
8
+ * 生产构建必须失败——否则 package.zip 会缺清单声明的 readme/icon/preview。
9
+ */
10
+ export declare function copyMetadata({ root, publicDir, outDir, manifestName, watch, log, }: {
11
+ root: string;
12
+ publicDir: string;
13
+ outDir: string;
14
+ manifestName: string;
15
+ watch: boolean;
16
+ log: Logger;
17
+ }): void;
@@ -0,0 +1,92 @@
1
+ /**
2
+ * 思源产物契约:让 `public/` 的改动也触发重建,并搬运清单声明的元数据文件。
3
+ *
4
+ * 主流构建器的 `publicDir` 都只在**启动时**复制进产物,之后不再监听它,
5
+ * 而思源的清单恰恰在 `public/` 里——改个 `theme.json` 的 frontends 或版本号,
6
+ * 不重启就永远不生效。所以要把 `public/**` 显式加进监听集合。
7
+ */
8
+ import { copyFileSync, existsSync, mkdirSync, readdirSync } from 'node:fs';
9
+ import { dirname, join, resolve } from 'node:path';
10
+ import { readJsonFile, walkFiles } from '../support/fs.js';
11
+ import { isRecord } from '../support/guards.js';
12
+ const README_PATTERN = /^README.*\.md$/;
13
+ /** 把 `public/**` 逐个交给构建器登记监听。 */
14
+ export function watchPublicFiles(publicDir, registerWatchFile, log) {
15
+ if (!publicDir || !existsSync(publicDir)) {
16
+ log(`publicDir 不存在,跳过监听:${publicDir}`, 'warn');
17
+ return;
18
+ }
19
+ for (const file of walkFiles(publicDir))
20
+ registerWatchFile(file.path);
21
+ }
22
+ /** 从清单的 readme / icon / preview 收集要搬的文件名。 */
23
+ function declaredFiles(manifestPath, log) {
24
+ const parsed = readJsonFile(manifestPath);
25
+ if (!isRecord(parsed)) {
26
+ log(`读不到或无法解析清单 ${manifestPath};元数据按 README*.md 兜底`, 'warn');
27
+ return null;
28
+ }
29
+ const files = new Set();
30
+ const readme = parsed['readme'];
31
+ if (typeof readme === 'string')
32
+ files.add(readme);
33
+ else if (isRecord(readme)) {
34
+ for (const value of Object.values(readme))
35
+ if (typeof value === 'string')
36
+ files.add(value);
37
+ }
38
+ for (const key of ['icon', 'preview']) {
39
+ const value = parsed[key];
40
+ if (typeof value === 'string')
41
+ files.add(value);
42
+ }
43
+ return files.size ? [...files] : null;
44
+ }
45
+ /**
46
+ * 把清单声明的元数据文件搬进产物目录;已由构建器搬过的(public/ 下)跳过。
47
+ *
48
+ * watch 下复制失败只报错继续(一次文件锁不该停掉开发服务器);
49
+ * 生产构建必须失败——否则 package.zip 会缺清单声明的 readme/icon/preview。
50
+ */
51
+ export function copyMetadata({ root, publicDir, outDir, manifestName, watch, log, }) {
52
+ const absoluteOutDir = resolve(root, outDir);
53
+ mkdirSync(absoluteOutDir, { recursive: true });
54
+ const candidates = [join(root, 'public', manifestName), join(root, manifestName)];
55
+ const manifestPath = candidates.find((file) => existsSync(file)) ?? candidates[0];
56
+ const declared = declaredFiles(manifestPath, log);
57
+ const names = [];
58
+ if (declared) {
59
+ names.push(...declared);
60
+ }
61
+ else {
62
+ log(`清单 ${manifestPath} 没声明 readme/icon/preview,按 README*.md 兜底搬运`, 'warn');
63
+ names.push(...readdirSync(root, { withFileTypes: true })
64
+ .filter((entry) => entry.isFile() && README_PATTERN.test(entry.name))
65
+ .map((entry) => entry.name));
66
+ }
67
+ const failures = [];
68
+ for (const name of names) {
69
+ const source = resolve(root, name);
70
+ if (!existsSync(source)) {
71
+ if (publicDir && existsSync(join(publicDir, name)))
72
+ continue;
73
+ failures.push(`${name}:项目根与 public/ 下都不存在`);
74
+ continue;
75
+ }
76
+ try {
77
+ const destination = join(absoluteOutDir, name);
78
+ mkdirSync(dirname(destination), { recursive: true });
79
+ copyFileSync(source, destination);
80
+ }
81
+ catch (cause) {
82
+ failures.push(`${name}:${cause instanceof Error ? cause.message : String(cause)}`);
83
+ }
84
+ }
85
+ if (!failures.length)
86
+ return;
87
+ const detail = failures.join(';');
88
+ if (watch)
89
+ log(`资产复制失败 ${detail}`, 'error');
90
+ else
91
+ throw new Error(`资产复制失败:${detail}`);
92
+ }
@@ -0,0 +1,35 @@
1
+ import { z } from 'zod';
2
+ /** 主题清单:modes 决定思源的主题列表。 */
3
+ export declare const themeManifestShape: z.ZodObject<{
4
+ name: z.ZodString;
5
+ author: z.ZodString;
6
+ url: z.ZodString;
7
+ version: z.ZodString;
8
+ displayName: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodRecord<z.ZodString, z.ZodString>]>>;
9
+ description: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodRecord<z.ZodString, z.ZodString>]>>;
10
+ readme: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodRecord<z.ZodString, z.ZodString>]>>;
11
+ icon: z.ZodOptional<z.ZodString>;
12
+ preview: z.ZodOptional<z.ZodString>;
13
+ modes: z.ZodArray<z.ZodEnum<{
14
+ dark: "dark";
15
+ light: "light";
16
+ }>>;
17
+ }, z.core.$strip>;
18
+ /** 插件清单:minAppVersion 决定思源能否安装。 */
19
+ export declare const pluginManifestShape: z.ZodObject<{
20
+ name: z.ZodString;
21
+ author: z.ZodString;
22
+ url: z.ZodString;
23
+ version: z.ZodString;
24
+ displayName: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodRecord<z.ZodString, z.ZodString>]>>;
25
+ description: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodRecord<z.ZodString, z.ZodString>]>>;
26
+ readme: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodRecord<z.ZodString, z.ZodString>]>>;
27
+ icon: z.ZodOptional<z.ZodString>;
28
+ preview: z.ZodOptional<z.ZodString>;
29
+ minAppVersion: z.ZodString;
30
+ }, z.core.$strip>;
31
+ /** 一条 zod 问题转成一句中文(不含字段名前缀,调用方自己拼)。 */
32
+ export declare function manifestIssueMessage(issue: z.core.$ZodIssue): {
33
+ message: string;
34
+ hint?: string;
35
+ };
@@ -0,0 +1,81 @@
1
+ /**
2
+ * 思源清单的 schema:唯一的形状来源。
3
+ *
4
+ * 为什么需要:思源的失败模式是**静默的**。`name: 123` 不会被思源报错,
5
+ * 只是主题不出现在列表里。旧实现只检查字段是否存在,类型错了照样通过。
6
+ *
7
+ * 清单允许额外的集市字段(icon / preview / readme 的 i18n 对象等),
8
+ * 所以这里**不** `.strict()`——未知字段是正常的。真正要拦的是已知字段的类型。
9
+ */
10
+ import { parse } from 'semver';
11
+ import { z } from 'zod';
12
+ /**
13
+ * 只认 `x.y.z`(与思源集市一致):排除前导零、`v` 前缀、prerelease、build。
14
+ *
15
+ * 不用正则:正则的 `\d+` 会接受 `01.2.3`,而 semver 规范里这是非法的;
16
+ * 也会接受超出安全整数范围的 `999999999999999999999.0.0`。
17
+ * `semver.parse()` 按规范收窄,再按三点确认:可解析、`parsed.version === value`
18
+ * (排除 `v1.2.3` 与带 build 的 `1.2.3+build`)、无 prerelease(排除 `1.2.3-beta`)。
19
+ */
20
+ function isSemver(value) {
21
+ if (typeof value !== 'string')
22
+ return false;
23
+ const parsed = parse(value);
24
+ if (parsed === null)
25
+ return false;
26
+ if (parsed.version !== value)
27
+ return false;
28
+ return parsed.prerelease.length === 0;
29
+ }
30
+ const semverShape = z.string({ error: '必须是字符串' }).refine(isSemver, {
31
+ error: '必须是 主.次.修订 形式的 semver,如 1.2.3',
32
+ });
33
+ /** i18n 对象:思源按 default 回退,缺了会显示空白。 */
34
+ const i18nFieldShape = z
35
+ .union([z.string(), z.record(z.string(), z.string(), { error: 'i18n 对象的值必须是字符串' })], {
36
+ error: '必须是字符串或 { default: string, ... } 的 i18n 对象',
37
+ })
38
+ .refine((value) => typeof value === 'string' || 'default' in value, {
39
+ error: '是 i18n 对象但没有 default 项(思源按 default 回退,缺了会显示空白)',
40
+ });
41
+ /** 主题与插件共有的清单字段。 */
42
+ const baseManifestShape = z.object({
43
+ name: z.string({ error: '必须是字符串' }).min(1, { error: '不能为空' }),
44
+ author: z.string({ error: '必须是字符串' }).min(1, { error: '不能为空' }),
45
+ url: z.string({ error: '必须是字符串' }).min(1, { error: '不能为空' }),
46
+ version: semverShape,
47
+ displayName: i18nFieldShape.optional(),
48
+ description: i18nFieldShape.optional(),
49
+ readme: i18nFieldShape.optional(),
50
+ icon: z.string({ error: '必须是字符串' }).optional(),
51
+ preview: z.string({ error: '必须是字符串' }).optional(),
52
+ });
53
+ /** 主题清单:modes 决定思源的主题列表。 */
54
+ export const themeManifestShape = baseManifestShape.extend({
55
+ modes: z
56
+ .array(z.enum(['light', 'dark'], { error: '只接受 light / dark' }), { error: '必须是数组' })
57
+ .min(1, { error: '必须是非空数组,如 ["light", "dark"]' }),
58
+ });
59
+ /** 插件清单:minAppVersion 决定思源能否安装。 */
60
+ export const pluginManifestShape = baseManifestShape.extend({
61
+ minAppVersion: semverShape,
62
+ });
63
+ /** 把 zod 的路径转成人能读的位置。 */
64
+ function pathLabel(path) {
65
+ let label = '';
66
+ for (const segment of path) {
67
+ if (typeof segment === 'number')
68
+ label += `[${segment}]`;
69
+ else
70
+ label += label ? `.${String(segment)}` : String(segment);
71
+ }
72
+ return label;
73
+ }
74
+ /** 一条 zod 问题转成一句中文(不含字段名前缀,调用方自己拼)。 */
75
+ export function manifestIssueMessage(issue) {
76
+ const label = pathLabel(issue.path) || '清单';
77
+ if (issue.code === 'invalid_type' && issue.expected === 'string') {
78
+ return { message: `${label} 必须是字符串` };
79
+ }
80
+ return { message: `${label} ${issue.message}` };
81
+ }
@@ -0,0 +1,16 @@
1
+ import type { Kind } from '../siyuan/kinds.ts';
2
+ export interface VerifyIssue {
3
+ message: string;
4
+ hint?: string;
5
+ }
6
+ export interface VerifyResult {
7
+ ok: boolean;
8
+ issues: VerifyIssue[];
9
+ }
10
+ /** 校验一个目标的清单与产物;返回问题列表,空表示通过。 */
11
+ export declare function verifyTarget(kind: Kind, { root, outDir }: {
12
+ root: string;
13
+ outDir: string;
14
+ }): VerifyResult;
15
+ /** 把问题列表排成可读文本。 */
16
+ export declare function formatIssues(issues: VerifyIssue[]): string;
@@ -0,0 +1,99 @@
1
+ /**
2
+ * 产物与清单校验。
3
+ *
4
+ * 思源的失败模式是**静默的**:theme.json 缺 modes → 主题不出现在列表里;
5
+ * 版本号不一致 → 集市判为旧版;产物缺 index.js → 插件加载不到。
6
+ */
7
+ import { existsSync } from 'node:fs';
8
+ import { isAbsolute, join } from 'node:path';
9
+ import { readJsonFile, readTextFile, walkFiles } from '../support/fs.js';
10
+ import { isRecord } from '../support/guards.js';
11
+ import { readManifest } from '../siyuan/manifest.js';
12
+ import { kindSpec } from '../siyuan/kinds.js';
13
+ import { manifestIssueMessage, pluginManifestShape, themeManifestShape } from './manifest-schema.js';
14
+ /** 去掉 CSS 注释:watch 产物不压缩,注释里常写着「别写 rgb(var(--b3-*))」。 */
15
+ function stripCssComments(css) {
16
+ return css.replace(/\/\*[\s\S]*?\*\//g, '');
17
+ }
18
+ /** 统计 `rgb(var(--b3-...))` 这类双重包装(整条声明会被浏览器静默丢弃)。 */
19
+ function findDoubleWrappedColors(css) {
20
+ const problems = [];
21
+ const pattern = /\brgba?\(\s*var\(\s*(--b3-[\w-]+)\s*\)\)?/g;
22
+ for (const match of stripCssComments(css).matchAll(pattern)) {
23
+ if (match[1] !== undefined)
24
+ problems.push(`${match[1]} 被 rgb() 双重包装:${match[0]}`);
25
+ }
26
+ return problems;
27
+ }
28
+ /** 校验一个目标的清单与产物;返回问题列表,空表示通过。 */
29
+ export function verifyTarget(kind, { root, outDir }) {
30
+ const spec = kindSpec(kind);
31
+ const issues = [];
32
+ // 只校验本次构建的产物目录。不做「产物目录不存在就改扫项目根」的兜底:
33
+ // 插件永远在真实构建之后校验,那种兜底只会把 node_modules 当成产物扫进来。
34
+ const absOutDir = isAbsolute(outDir) ? outDir : join(root, outDir);
35
+ const walked = walkFiles(absOutDir);
36
+ const files = walked.map((file) => file.name);
37
+ const byName = new Map(walked.map((file) => [file.name, file.path]));
38
+ const readOutput = (file) => {
39
+ const path = byName.get(file);
40
+ return path ? readTextFile(path) : null;
41
+ };
42
+ const manifest = readManifest(root, spec.manifestName, [absOutDir])?.data ?? null;
43
+ const packageJsonRaw = readJsonFile(join(root, 'package.json'));
44
+ const packageJson = isRecord(packageJsonRaw) ? packageJsonRaw : null;
45
+ // ---- 通用:清单存在与形状 ----
46
+ if (!manifest) {
47
+ issues.push({
48
+ message: `读不到清单:${spec.manifestName}(找过 public/、项目根与产物目录)`,
49
+ hint: '思源按清单识别主题/插件,缺了它不会出现在列表里。',
50
+ });
51
+ }
52
+ else {
53
+ // 形状交给 schema:字段缺失、类型错误、semver 格式、i18n 缺 default
54
+ // 都在这里一次报完。思源对这些错误都是静默的(`name: 123` 只是不出现)。
55
+ const shape = kind === 'theme' ? themeManifestShape : pluginManifestShape;
56
+ const parsed = shape.safeParse(manifest);
57
+ if (!parsed.success) {
58
+ for (const issue of parsed.error.issues)
59
+ issues.push(manifestIssueMessage(issue));
60
+ }
61
+ // 跨文件一致性:集市按清单版本判新旧,不一致会导致更新不推送。
62
+ if (packageJson && manifest['version'] && packageJson['version'] !== manifest['version']) {
63
+ issues.push({
64
+ message: `版本不一致:package.json=${String(packageJson['version'])},清单=${String(manifest['version'])}`,
65
+ hint: '集市按清单版本判新旧,不一致会导致更新不推送。',
66
+ });
67
+ }
68
+ }
69
+ // ---- 通用:产物目录存在与必需文件 ----
70
+ if (!existsSync(absOutDir)) {
71
+ issues.push({ message: `产物目录不存在:${absOutDir}`, hint: '先跑一次构建。' });
72
+ }
73
+ else {
74
+ for (const expected of spec.expectedFiles) {
75
+ if (!files.includes(expected)) {
76
+ issues.push({ message: `产物缺少 ${expected}`, hint: '思源按固定文件名加载,缺了不会报错、只是加载不到。' });
77
+ }
78
+ }
79
+ }
80
+ // ---- 类型特有的产物检查(清单形状已由上面的 schema 覆盖) ----
81
+ if (existsSync(absOutDir) && kind === 'theme') {
82
+ const scripts = files.filter((file) => file.endsWith('.js'));
83
+ if (scripts.length) {
84
+ issues.push({ message: `主题产物里出现 JavaScript:${scripts.join('、')}`, hint: '主题只交付 CSS。检查 rollupOptions.input 与 lib 配置。' });
85
+ }
86
+ const css = readOutput('theme.css') ?? '';
87
+ if (stripCssComments(css).includes('@layer')) {
88
+ issues.push({ message: 'theme.css 含 @layer,会被未分层的 base.css 压制', hint: '移除 @layer 包装,让主题规则保持 unlayered。' });
89
+ }
90
+ for (const problem of findDoubleWrappedColors(css)) {
91
+ issues.push({ message: problem, hint: '--b3-* 已是完整色值,直接 var() 使用;rgb(var(--rc-*)) 才是正确写法。' });
92
+ }
93
+ }
94
+ return { ok: issues.length === 0, issues };
95
+ }
96
+ /** 把问题列表排成可读文本。 */
97
+ export function formatIssues(issues) {
98
+ return issues.map((issue) => (issue.hint ? ` ${issue.message}\n ${issue.hint}` : ` ${issue.message}`)).join('\n');
99
+ }
@@ -0,0 +1,6 @@
1
+ /** 思源集市的发布产物名。 */
2
+ export declare const PACKAGE_NAME = "package.zip";
3
+ export declare function writeZip(dir: string, outFile: string): {
4
+ files: number;
5
+ bytes: number;
6
+ };
@@ -0,0 +1,26 @@
1
+ /**
2
+ * 打包:把一个目录写成 `package.zip`。
3
+ *
4
+ * 用 fflate 的 `zipSync`:纯 JS、同步 API,与「构建结束时同步写盘」的语义一致,
5
+ * 且与标准 zip 工具双向互通。
6
+ */
7
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
8
+ import { dirname } from 'node:path';
9
+ import { zipSync } from 'fflate';
10
+ import { walkFiles } from '../support/fs.js';
11
+ /** 思源集市的发布产物名。 */
12
+ export const PACKAGE_NAME = 'package.zip';
13
+ /** 经典 zip 的条目数上限(16 位字段)。 */
14
+ const ZIP_MAX_ENTRIES = 0xffff;
15
+ export function writeZip(dir, outFile) {
16
+ const files = walkFiles(dir);
17
+ if (files.length > ZIP_MAX_ENTRIES)
18
+ throw new Error(`文件数 ${files.length} 超过 zip 格式上限 ${ZIP_MAX_ENTRIES}`);
19
+ const entries = {};
20
+ for (const file of files)
21
+ entries[file.name] = new Uint8Array(readFileSync(file.path));
22
+ const archive = zipSync(entries, { level: 6 });
23
+ mkdirSync(dirname(outFile), { recursive: true });
24
+ writeFileSync(outFile, archive);
25
+ return { files: files.length, bytes: archive.length };
26
+ }
@@ -0,0 +1,20 @@
1
+ import type { UserConfig } from 'vite';
2
+ import type { Kind } from './siyuan/kinds.ts';
3
+ /** 生成构建片段所需的事实。 */
4
+ export interface BuildSpecInput {
5
+ kind: Kind;
6
+ watch: boolean;
7
+ /** 入口文件绝对路径。必填:产物名是固定契约,入口不能靠猜。 */
8
+ entry: string;
9
+ /** 插件选项里显式给的产物目录。 */
10
+ outDir: string | null;
11
+ }
12
+ /** 生成插件契约配置(不含用户值)。 */
13
+ export declare function buildSpecFor(input: BuildSpecInput): UserConfig;
14
+ /**
15
+ * 把契约配置与用户配置合并,返回给 `config()` 钩子。
16
+ *
17
+ * 用户若已写了旧键名 `rollupOptions` 而当前 Vite 用新键名,先把 spec 里的那一块
18
+ * 换到旧键名下,再合并——避免出现两个键同时存在、优先级不确定的情况。
19
+ */
20
+ export declare function applyBuildConfig(userConfig: UserConfig, input: BuildSpecInput): UserConfig;
package/dist/config.js ADDED
@@ -0,0 +1,92 @@
1
+ /**
2
+ * 构建配置注入:把思源的加载机制翻译成 Vite 配置形状。
3
+ *
4
+ * `build.lib`、`rollupOptions`、`base` 是**思源加载机制决定的契约**,不是项目口味,
5
+ * 必须由插件设;但用户显式写的值优先。
6
+ *
7
+ * 用 Vite 公共 API `mergeConfig(spec, userConfig)`:**用户放后面**,深度合并后用户胜出。
8
+ * 直接返回 spec 会覆盖用户值(实测 `user-dist` 被改成 `dev`);原地 mutate + 手写
9
+ * 一堆 `??=` 也不是 Vite 推荐写法。函数值 `assetFileNames` 实测能正确合并。
10
+ */
11
+ import { mergeConfig, version as viteVersion } from 'vite';
12
+ import { kindSpec } from './siyuan/kinds.js';
13
+ const ASSET_DIR = 'assets';
14
+ /** 资源名:CSS 固定名,其余按源目录归到 assets/ 下(避免不同目录的同名文件被去重)。 */
15
+ function assetNamer(cssName) {
16
+ return (info) => {
17
+ const name = info.name ?? 'asset';
18
+ return name.endsWith('.css') ? cssName : `${ASSET_DIR}/${name}`;
19
+ };
20
+ }
21
+ /** Vite 8 把 build.rollupOptions 改名为 build.rolldownOptions(旧名仍可用)。 */
22
+ function defaultOptionsKey() {
23
+ return Number.parseInt(viteVersion, 10) >= 8 ? 'rolldownOptions' : 'rollupOptions';
24
+ }
25
+ /** 用户已经写了哪个键就跟着用哪个,避免两个键同时出现;否则按版本选。 */
26
+ function optionsKey(userConfig) {
27
+ const build = userConfig.build;
28
+ if (build && 'rollupOptions' in build)
29
+ return 'rollupOptions';
30
+ if (build && 'rolldownOptions' in build)
31
+ return 'rolldownOptions';
32
+ return defaultOptionsKey();
33
+ }
34
+ /** 生成插件契约配置(不含用户值)。 */
35
+ export function buildSpecFor(input) {
36
+ const spec = kindSpec(input.kind);
37
+ const outDir = input.outDir ?? (input.watch ? './dev' : './dist');
38
+ const options = { input: input.entry };
39
+ if (input.kind === 'plugin') {
40
+ options.external = ['siyuan', 'process'];
41
+ options.output = {
42
+ entryFileNames: '[name].js',
43
+ assetFileNames: assetNamer(spec.cssName),
44
+ };
45
+ }
46
+ else {
47
+ // theme 不能走 lib:lib 隐含 cssCodeSplit:false,与 CSS 入口冲突、Vite 直接报错。
48
+ options.output = { assetFileNames: assetNamer(spec.cssName) };
49
+ }
50
+ const build = {
51
+ outDir,
52
+ emptyOutDir: false,
53
+ // watch 不压缩(便于直接看落盘产物),生产用默认压缩器。
54
+ minify: !input.watch,
55
+ };
56
+ if (input.kind === 'plugin') {
57
+ // 插件入口是 `export default class ...`,必须走 lib:lib cjs 产出
58
+ // `module.exports = 类`;普通 input 只得到 `exports.default`,思源报
59
+ // "plugin xxx has no export"。
60
+ build.lib = { entry: input.entry, fileName: 'index', cssFileName: 'index', formats: ['cjs'] };
61
+ }
62
+ build[defaultOptionsKey()] = options;
63
+ return {
64
+ // 思源从 /appearance/themes/<name>/ 提供主题;Vite 默认 base:'/' 会产出
65
+ // 根绝对 URL,字体/图片在宿主下 404。
66
+ base: './',
67
+ build,
68
+ // 插件 JS 在思源前端 eval 执行,而前端没有 process 对象;React 等依赖会读
69
+ // process.env.NODE_ENV。
70
+ define: { 'process.env.NODE_ENV': JSON.stringify(input.watch ? 'development' : 'production') },
71
+ };
72
+ }
73
+ /**
74
+ * 把契约配置与用户配置合并,返回给 `config()` 钩子。
75
+ *
76
+ * 用户若已写了旧键名 `rollupOptions` 而当前 Vite 用新键名,先把 spec 里的那一块
77
+ * 换到旧键名下,再合并——避免出现两个键同时存在、优先级不确定的情况。
78
+ */
79
+ export function applyBuildConfig(userConfig, input) {
80
+ const spec = buildSpecFor(input);
81
+ const key = optionsKey(userConfig);
82
+ const specBuild = spec.build;
83
+ if (specBuild && key in specBuild === false) {
84
+ const other = key === 'rollupOptions' ? 'rolldownOptions' : 'rollupOptions';
85
+ const value = specBuild[other];
86
+ if (value !== undefined) {
87
+ specBuild[key] = value;
88
+ delete specBuild[other];
89
+ }
90
+ }
91
+ return mergeConfig(spec, userConfig);
92
+ }
@@ -0,0 +1,15 @@
1
+ import type { Plugin } from 'vite';
2
+ import type { Kind } from './siyuan/kinds.ts';
3
+ export interface SiYuanOptions {
4
+ /**
5
+ * 构建入口,相对项目根。必填:主题要产出固定名 `theme.css`、插件要产出
6
+ * CJS 单文件 `index.js`,产物名是思源加载机制决定的契约,入口不能靠猜——
7
+ * 猜错不会报错,只会静默产出思源加载不到的东西。
8
+ */
9
+ entry: string;
10
+ /** 被测类型(theme / plugin);缺省从清单推断。 */
11
+ kind?: Kind | null;
12
+ /** 产物目录;缺省 `./dev`(watch)/ `./dist`(生产)。 */
13
+ outDir?: string | null;
14
+ }
15
+ export default function siyuan(options: SiYuanOptions): Plugin;