vite-plugin-siyuan 0.2.0 → 0.3.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.
package/README.md CHANGED
@@ -18,7 +18,6 @@ import { defineConfig } from 'vite';
18
18
  import siyuan from 'vite-plugin-siyuan';
19
19
 
20
20
  export default defineConfig({
21
- // entry 必填:主题指向 CSS,插件指向 TS/JS
22
21
  plugins: [siyuan({ entry: 'src/main.css' })],
23
22
  });
24
23
  ```
@@ -29,16 +28,52 @@ pnpm vite build --watch
29
28
 
30
29
  `vite dev` 不适用:思源从磁盘读产物,而 dev server 只在内存里编译。插件的 `apply` 固定为 `'build'`。
31
30
 
31
+ ## 项目结构
32
+
33
+ **除了 `README.md`,其余文件都放 `public/`。**
34
+
35
+ ```
36
+ 你的项目/
37
+ ├── README.md ← 唯一放项目根的(GitHub 只展示仓库根的 README)
38
+ ├── public/
39
+ │ ├── theme.json ← 主题;插件则是 plugin.json
40
+ │ ├── icon.png
41
+ │ ├── preview.png
42
+ │ └── i18n/
43
+ ├── src/
44
+ │ └── main.css ← entry 指向这里
45
+ └── vite.config.ts
46
+ ```
47
+
48
+ 思源按固定路径从**产物根**加载清单和资源,而 `public/` 是 Vite 唯一「原样复制到产物根」的机制。放这里,dev 和 build 都自动生效,改清单也会触发重建。
49
+
50
+ `README.md` 是唯一例外:GitHub 只认仓库根的 README,思源集市要求它在产物根。插件会把它从项目根复制进产物(清单的 `readme` 字段可指定文件名,缺省按 `README*.md` 兜底);如果你放 `public/README.md`,则以 `public/` 为准。
51
+
52
+ 清单里声明的 `icon` / `preview` 若没出现在产物里,构建会**报错**——这类缺失思源不会报错,只会导致集市缺图。
53
+
54
+ ### 关于 publicDir
55
+
56
+ 默认就是 `public/`。如果改成别的目录(`publicDir: 'static'`),插件会跟着找,无需额外配置。
57
+
58
+ **不要设成 `false`**:思源要求清单出现在产物根,而没有 `publicDir` 就没有任何机制能把清单复制进去。插件会在 `config` 阶段直接报错。
59
+
60
+ ### 不支持根目录布局
61
+
62
+ 官方 [theme-sample](https://github.com/siyuan-note/theme-sample) 和 [plugin-sample](https://github.com/siyuan-note/plugin-sample) 把清单放项目根,但它们是**无构建步骤或 webpack 项目**,不是 Vite 项目。放到项目根会直接报错:
63
+
64
+ ```
65
+ 在 {publicDir} 下找不到 theme.json / plugin.json;请把清单放进该目录,或用 kind 选项显式指定类型
66
+ ```
67
+
32
68
  ## 选项
33
69
 
34
70
  | 选项 | 说明 |
35
71
  |---|---|
36
72
  | `entry` | **必填**。构建入口,相对项目根。主题指向 CSS,插件指向 TS/JS |
37
- | `kind` | `'theme'` / `'plugin'`;缺省时在 `public/`、项目根依次查找 `theme.json` / `plugin.json` 推断 |
73
+ | `kind` | `'theme'` / `'plugin'`;缺省时按 `publicDir` 下的清单推断 |
38
74
  | `outDir` | 产物目录;缺省 `./dev`(watch)/ `./dist`(生产) |
39
75
 
40
- > `entry` 必填是因为产物名是固定契约(主题 `theme.css`、插件 `index.js`)。
41
- > 入口猜错不会报错,只会静默产出思源加载不到的东西——所以宁可要求显式指定。
76
+ `entry` 必填是因为产物名是固定契约(主题 `theme.css`、插件 `index.js`)。入口猜错不会报错,只会静默产出思源加载不到的东西。
42
77
 
43
78
  ## 做什么
44
79
 
@@ -59,13 +94,21 @@ pnpm vite build --watch
59
94
 
60
95
  ## 输出
61
96
 
62
- 每轮构建结束时打印一行,供 agent 判断「可以测量了」:
97
+ 默认只打印用户需要知道的结果与需要处理的问题:链接/启用状态、校验失败、README 复制失败、打包结果与预览入口;端口探测、跳过监听这类内部过程只在 `SIYUAN_DEBUG=1` 时输出。
98
+
99
+ watch 首轮构建不打印 `build #N` 信号(此时「会话就绪 + 预览入口」已经说明可以开始了);之后每次热重载打印一行,供 agent 判断「可以测量了」:
63
100
 
64
101
  ```
65
102
  [vite-plugin-siyuan] build #12 ready · theme · watch
66
103
  ```
67
104
 
68
- 生产构建对应的是:
105
+ watch 构建失败(含校验未通过)对应的是:
106
+
107
+ ```
108
+ [vite-plugin-siyuan] build #12 failed · theme · watch
109
+ ```
110
+
111
+ 生产构建成功时:
69
112
 
70
113
  ```
71
114
  [vite-plugin-siyuan] build #1 ready · theme · package.zip
@@ -82,9 +125,9 @@ watch 模式首次就绪后还会打印桌面端 / 移动端两个稳定预览
82
125
  | `SIYUAN_PLUGIN_DIR` | 插件安装父目录(`{workspace}/data/plugins`),同上 |
83
126
  | `SIYUAN_TOKEN` | 开了 API token 时使用 |
84
127
  | `SIYUAN_NO_ELEVATION=1` | 不弹 UAC 提权(CI 环境自动不弹) |
85
- | `SIYUAN_DEBUG=1` | 输出 debug 级日志 |
128
+ | `SIYUAN_DEBUG=1` | 输出内部调试日志(端口探测、跳过监听、README 兜底等) |
86
129
 
87
- > 旧名 `SYD_NO_ELEVATION` / `SYD_DEBUG` 仍然兼容。
130
+ 旧名 `SYD_NO_ELEVATION` / `SYD_DEBUG` 仍然兼容。
88
131
 
89
132
  ## 配套 CLI
90
133
 
@@ -95,10 +138,9 @@ watch 模式首次就绪后还会打印桌面端 / 移动端两个稳定预览
95
138
  ### 提 PR
96
139
 
97
140
  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 之前只升次版本
141
+ 2. PR 标题必须符合 [Conventional Commits](https://www.conventionalcommits.org/),CI 会检查:
142
+ - `feat:` → 次版本,`fix:` → 补丁版本
143
+ - `feat!:` → 1.0 之前只升次版本
102
144
  - `docs:` / `chore:` / `ci:` / `test:` / `refactor:` → 不发版
103
145
  3. 合并方式固定为 **Squash and merge**,PR 标题会成为最终 commit。
104
146
 
@@ -111,7 +153,8 @@ watch 模式首次就绪后还会打印桌面端 / 移动端两个稳定预览
111
153
  └─ release-please 开/更新 Release PR「chore(main): release x.y.z」
112
154
  └─ 合并该 PR
113
155
  ├─ 自动打 tag、建 GitHub Release、生成 CHANGELOG.md
114
- └─ 自动发布到 npm(OIDC trusted publishing)
156
+ └─ 自动 stage 到 npm(OIDC trusted publishing)
157
+ └─ 你在 npmjs.com 用安全密钥批准后上线
115
158
  ```
116
159
 
117
160
  `package.json` 的 `version` 由 Release PR 更新,不要手动改。
@@ -1,13 +1,18 @@
1
1
  import type { Logger } from '../support/log.ts';
2
- /** 把 `public/**` 逐个交给构建器登记监听。 */
2
+ /** 把 publicDir 下的文件逐个交给构建器登记监听。 */
3
3
  export declare function watchPublicFiles(publicDir: string, registerWatchFile: (file: string) => void, log: Logger): void;
4
4
  /**
5
- * 把清单声明的元数据文件搬进产物目录;已由构建器搬过的(public/ 下)跳过。
5
+ * 把 README 从项目根搬进产物目录(GitHub 只认根目录,思源要求产物根)。
6
+ *
7
+ * 清单声明的 README 优先;没声明时按 `README*.md` 兜底。
8
+ *
9
+ * **publicDir 优先**:若同名文件已经在 publicDir 下,Vite 已经把它复制进产物,
10
+ * 这里直接跳过——否则项目根那份会静默覆盖用户放在 publicDir 的版本。
6
11
  *
7
12
  * watch 下复制失败只报错继续(一次文件锁不该停掉开发服务器);
8
- * 生产构建必须失败——否则 package.zip 会缺清单声明的 readme/icon/preview。
13
+ * 生产构建必须失败——否则 package.zip 会缺 README。
9
14
  */
10
- export declare function copyMetadata({ root, publicDir, outDir, manifestName, watch, log, }: {
15
+ export declare function copyReadmes({ root, publicDir, outDir, manifestName, watch, log, }: {
11
16
  root: string;
12
17
  publicDir: string;
13
18
  outDir: string;
@@ -1,76 +1,74 @@
1
1
  /**
2
- * 思源产物契约:让 `public/` 的改动也触发重建,并搬运清单声明的元数据文件。
2
+ * 思源产物契约:让 `public/` 的改动也触发重建,并搬运唯一必须放项目根的文件。
3
+ *
4
+ * ## 文件放哪
5
+ *
6
+ * **只有 README 必须放项目根**:GitHub 只展示仓库根的 README,而思源集市要求
7
+ * 它出现在产物根。这一份由本模块搬进产物。
8
+ *
9
+ * 其他一切(`theme.json` / `plugin.json` / `icon` / `preview` / `i18n/` / 图片字体
10
+ * 等静态资源)都放 `public/`,由 Vite 的 `publicDir` 原样复制到产物根,本模块不介入。
11
+ *
12
+ * ## 为什么要监听 public/
3
13
  *
4
14
  * 主流构建器的 `publicDir` 都只在**启动时**复制进产物,之后不再监听它,
5
- * 而思源的清单恰恰在 `public/` 里——改个 `theme.json` 的 frontends 或版本号,
15
+ * 而思源的清单恰恰在 `public/` 里——改个 `theme.json` 的 modes 或版本号,
6
16
  * 不重启就永远不生效。所以要把 `public/**` 显式加进监听集合。
7
17
  */
8
18
  import { copyFileSync, existsSync, mkdirSync, readdirSync } from 'node:fs';
9
19
  import { dirname, join, resolve } from 'node:path';
10
20
  import { readJsonFile, walkFiles } from '../support/fs.js';
11
21
  import { isRecord } from '../support/guards.js';
22
+ import { declaredAssets } from '../siyuan/manifest.js';
12
23
  const README_PATTERN = /^README.*\.md$/;
13
- /** 把 `public/**` 逐个交给构建器登记监听。 */
24
+ /** 把 publicDir 下的文件逐个交给构建器登记监听。 */
14
25
  export function watchPublicFiles(publicDir, registerWatchFile, log) {
15
26
  if (!publicDir || !existsSync(publicDir)) {
16
- log(`publicDir 不存在,跳过监听:${publicDir}`, 'warn');
27
+ // 清单缺失会在校验阶段报错;这里只是内部流程信息,不该默认打扰用户。
28
+ log(`publicDir 不存在,跳过监听:${publicDir}`, 'debug');
17
29
  return;
18
30
  }
19
31
  for (const file of walkFiles(publicDir))
20
32
  registerWatchFile(file.path);
21
33
  }
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
34
  /**
46
- * 把清单声明的元数据文件搬进产物目录;已由构建器搬过的(public/ 下)跳过。
35
+ * 把 README 从项目根搬进产物目录(GitHub 只认根目录,思源要求产物根)。
36
+ *
37
+ * 清单声明的 README 优先;没声明时按 `README*.md` 兜底。
38
+ *
39
+ * **publicDir 优先**:若同名文件已经在 publicDir 下,Vite 已经把它复制进产物,
40
+ * 这里直接跳过——否则项目根那份会静默覆盖用户放在 publicDir 的版本。
47
41
  *
48
42
  * watch 下复制失败只报错继续(一次文件锁不该停掉开发服务器);
49
- * 生产构建必须失败——否则 package.zip 会缺清单声明的 readme/icon/preview。
43
+ * 生产构建必须失败——否则 package.zip 会缺 README。
50
44
  */
51
- export function copyMetadata({ root, publicDir, outDir, manifestName, watch, log, }) {
45
+ export function copyReadmes({ root, publicDir, outDir, manifestName, watch, log, }) {
52
46
  const absoluteOutDir = resolve(root, outDir);
53
47
  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);
48
+ const manifestPath = publicDir ? join(publicDir, manifestName) : join(root, 'public', manifestName);
49
+ const parsed = readJsonFile(manifestPath);
50
+ const declared = isRecord(parsed) ? declaredAssets(parsed, ['readme']) : null;
51
+ // 清单问题由构建收尾的校验统一报;这里只是 README 搬运的内部兜底过程。
52
+ if (!isRecord(parsed))
53
+ log(`读不到或无法解析清单 ${manifestPath};README 按 README*.md 兜底`, 'debug');
57
54
  const names = [];
58
- if (declared) {
55
+ if (declared?.length) {
59
56
  names.push(...declared);
60
57
  }
61
58
  else {
62
- log(`清单 ${manifestPath} 没声明 readme/icon/preview,按 README*.md 兜底搬运`, 'warn');
59
+ log(`清单 ${manifestPath} 没声明 readme,按 README*.md 兜底搬运`, 'debug');
63
60
  names.push(...readdirSync(root, { withFileTypes: true })
64
61
  .filter((entry) => entry.isFile() && README_PATTERN.test(entry.name))
65
62
  .map((entry) => entry.name));
66
63
  }
67
64
  const failures = [];
68
65
  for (const name of names) {
66
+ // publicDir 优先:Vite 已复制过,不再覆盖。
67
+ if (publicDir && existsSync(join(publicDir, name)))
68
+ continue;
69
69
  const source = resolve(root, name);
70
70
  if (!existsSync(source)) {
71
- if (publicDir && existsSync(join(publicDir, name)))
72
- continue;
73
- failures.push(`${name}:项目根与 public/ 下都不存在`);
71
+ failures.push(`${name}:项目根与 public 目录下都不存在`);
74
72
  continue;
75
73
  }
76
74
  try {
@@ -86,7 +84,7 @@ export function copyMetadata({ root, publicDir, outDir, manifestName, watch, log
86
84
  return;
87
85
  const detail = failures.join(';');
88
86
  if (watch)
89
- log(`资产复制失败 ${detail}`, 'error');
87
+ log(`README 复制失败 ${detail}`, 'error');
90
88
  else
91
- throw new Error(`资产复制失败:${detail}`);
89
+ throw new Error(`README 复制失败:${detail}`);
92
90
  }
@@ -8,8 +8,9 @@ export interface VerifyResult {
8
8
  issues: VerifyIssue[];
9
9
  }
10
10
  /** 校验一个目标的清单与产物;返回问题列表,空表示通过。 */
11
- export declare function verifyTarget(kind: Kind, { root, outDir }: {
11
+ export declare function verifyTarget(kind: Kind, { root, publicDir, outDir }: {
12
12
  root: string;
13
+ publicDir: string;
13
14
  outDir: string;
14
15
  }): VerifyResult;
15
16
  /** 把问题列表排成可读文本。 */
@@ -8,7 +8,7 @@ import { existsSync } from 'node:fs';
8
8
  import { isAbsolute, join } from 'node:path';
9
9
  import { readJsonFile, readTextFile, walkFiles } from '../support/fs.js';
10
10
  import { isRecord } from '../support/guards.js';
11
- import { readManifest } from '../siyuan/manifest.js';
11
+ import { declaredAssets, readManifest } from '../siyuan/manifest.js';
12
12
  import { kindSpec } from '../siyuan/kinds.js';
13
13
  import { manifestIssueMessage, pluginManifestShape, themeManifestShape } from './manifest-schema.js';
14
14
  /** 去掉 CSS 注释:watch 产物不压缩,注释里常写着「别写 rgb(var(--b3-*))」。 */
@@ -26,7 +26,7 @@ function findDoubleWrappedColors(css) {
26
26
  return problems;
27
27
  }
28
28
  /** 校验一个目标的清单与产物;返回问题列表,空表示通过。 */
29
- export function verifyTarget(kind, { root, outDir }) {
29
+ export function verifyTarget(kind, { root, publicDir, outDir }) {
30
30
  const spec = kindSpec(kind);
31
31
  const issues = [];
32
32
  // 只校验本次构建的产物目录。不做「产物目录不存在就改扫项目根」的兜底:
@@ -39,13 +39,13 @@ export function verifyTarget(kind, { root, outDir }) {
39
39
  const path = byName.get(file);
40
40
  return path ? readTextFile(path) : null;
41
41
  };
42
- const manifest = readManifest(root, spec.manifestName, [absOutDir])?.data ?? null;
42
+ const manifest = readManifest(publicDir, spec.manifestName, [absOutDir])?.data ?? null;
43
43
  const packageJsonRaw = readJsonFile(join(root, 'package.json'));
44
44
  const packageJson = isRecord(packageJsonRaw) ? packageJsonRaw : null;
45
45
  // ---- 通用:清单存在与形状 ----
46
46
  if (!manifest) {
47
47
  issues.push({
48
- message: `读不到清单:${spec.manifestName}(找过 public/、项目根与产物目录)`,
48
+ message: `读不到清单:${spec.manifestName}(应在 public 目录下,或已在产物目录里)`,
49
49
  hint: '思源按清单识别主题/插件,缺了它不会出现在列表里。',
50
50
  });
51
51
  }
@@ -77,6 +77,22 @@ export function verifyTarget(kind, { root, outDir }) {
77
77
  }
78
78
  }
79
79
  }
80
+ // ---- 清单声明的资源必须真的在产物里 ----
81
+ // icon / preview / readme 由清单引用,思源集市按这些名字去找。它们只能通过
82
+ // public/ 进产物(README 额外支持项目根),放错位置不会报错、只是集市缺图。
83
+ if (manifest && existsSync(absOutDir)) {
84
+ for (const name of declaredAssets(manifest)) {
85
+ if (files.includes(name))
86
+ continue;
87
+ const inPublic = publicDir !== '' && existsSync(join(publicDir, name));
88
+ issues.push({
89
+ message: `清单声明的 ${name} 不在产物里`,
90
+ hint: inPublic
91
+ ? `它在 public 目录下,但没被复制进 ${absOutDir};确认 publicDir 配置。`
92
+ : `把 ${name} 放进 public 目录,它会随构建复制到产物根。`,
93
+ });
94
+ }
95
+ }
80
96
  // ---- 类型特有的产物检查(清单形状已由上面的 schema 覆盖) ----
81
97
  if (existsSync(absOutDir) && kind === 'theme') {
82
98
  const scripts = files.filter((file) => file.endsWith('.js'));
package/dist/index.js CHANGED
@@ -20,6 +20,7 @@ import { existsSync } from 'node:fs';
20
20
  import { resolve } from 'node:path';
21
21
  import { applyBuildConfig } from './config.js';
22
22
  import { detectKind } from './siyuan/kinds.js';
23
+ import { resolvePublicDir } from './siyuan/manifest.js';
23
24
  import { createTaggedLogger } from './output/log.js';
24
25
  import { createBuildSession } from './session.js';
25
26
  import { describeError, UserError } from './support/errors.js';
@@ -37,14 +38,12 @@ export default function siyuan(options) {
37
38
  if (!existsSync(entry)) {
38
39
  throw new UserError(`entry 指向的文件不存在:${entry}(选项值:${options.entry})`);
39
40
  }
40
- try {
41
- kind ??= detectKind(root);
42
- }
43
- catch (cause) {
44
- // 没有清单就不知道产物该叫什么;交给项目自己配,不在这里报错。
45
- log(`跳过构建配置注入:${describeError(cause)}`, 'warn');
46
- return undefined;
47
- }
41
+ // entry 必填意味着用户确实在建思源产物,所以识别不到类型是真错误,
42
+ // 不能像以前那样 warn 后跳过——那会让构建继续跑,最后崩在 Vite 的
43
+ // "Cannot resolve entry module index.html" 上,完全指错方向。
44
+ // publicDir 在这里还没解析(configResolved 才给),先按用户配置算一遍。
45
+ const publicDir = resolvePublicDir(root, userConfig.publicDir);
46
+ kind ??= detectKind(publicDir);
48
47
  const watch = Boolean(userConfig.build?.watch);
49
48
  return applyBuildConfig(userConfig, { kind, watch, entry, outDir: options.outDir ?? null });
50
49
  },
@@ -54,12 +53,14 @@ export default function siyuan(options) {
54
53
  const publicDir = resolvedConfig.publicDir;
55
54
  const watch = resolvedConfig.command === 'build' && Boolean(resolvedConfig.build.watch);
56
55
  if (!kind) {
57
- log('识别不到被测类型,跳过链接与校验', 'warn');
58
- return;
56
+ // 正常情况下 config 阶段已确定类型或抛错;走到这里说明状态异常,
57
+ // 不能只 warn 后跳过校验——静默产出思源加载不到的东西更难排查。
58
+ throw new UserError('识别不到被测类型:请确认 publicDir 下的 theme.json / plugin.json,或用 kind 选项显式指定');
59
59
  }
60
60
  session = createBuildSession({ root, outDir: resolve(root, outDir), publicDir, watch }, { kind, log, previewLogger: log });
61
+ // 生产构建的最终结果由「已打包 + build #N ready」体现,模式说明只对排查有用。
61
62
  if (!watch)
62
- log('生产构建:不启动热重载会话,只校验产物并打包');
63
+ log('生产构建:不启动热重载会话,只校验产物并打包', 'debug');
63
64
  },
64
65
  buildStart() {
65
66
  session?.onStart((file) => this.addWatchFile(file));
@@ -1,9 +1,11 @@
1
1
  /**
2
2
  * 日志:consola 带 tag 前缀,保留级别图标与配色。
3
3
  *
4
+ * 默认级别只输出用户需要知道的结果与需要处理的问题;内部过程信息走 debug,
5
+ * 由 `SIYUAN_DEBUG=1`(旧名 `SYD_DEBUG`)打开。用精确比较而不是 truthiness:
6
+ * `=0` 在 shell 里很常见,truthiness 会把它读成「开」。
7
+ *
4
8
  * 用 `compact: false` 让多行消息保持原样——预览入口那块靠它维持缩进。
5
- * `SIYUAN_DEBUG=1`(旧名 `SYD_DEBUG`)时降到 debug 级别;用精确比较而不是
6
- * truthiness:`=0` 在 shell 里很常见,truthiness 会把它读成「开」。
7
9
  */
8
10
  import { createConsola } from 'consola';
9
11
  import { envFlag } from '../support/env.js';
@@ -14,6 +16,9 @@ export function createTaggedLogger(tag) {
14
16
  }).withTag(tag);
15
17
  const log = (message, level = 'info') => {
16
18
  switch (level) {
19
+ case 'debug':
20
+ logger.debug(message);
21
+ break;
17
22
  case 'success':
18
23
  logger.success(message);
19
24
  break;
package/dist/session.js CHANGED
@@ -5,8 +5,8 @@
5
5
  */
6
6
  import { relative, resolve } from 'node:path';
7
7
  import { activate, reloadUI } from './siyuan/activate.js';
8
- import { copyMetadata, watchPublicFiles } from './artifact/assets.js';
9
- import { checkLinkedFiles, ensureLink } from './siyuan/link.js';
8
+ import { copyReadmes, watchPublicFiles } from './artifact/assets.js';
9
+ import { ensureLink } from './siyuan/link.js';
10
10
  import { kindSpec, readName } from './siyuan/kinds.js';
11
11
  import { findKernelPort } from './siyuan/port.js';
12
12
  import { logPreviewLinks } from './output/preview-links.js';
@@ -32,13 +32,16 @@ export function createBuildSession(facts, options) {
32
32
  let starting = null;
33
33
  let reported = false;
34
34
  let buildCount = 0;
35
+ let failed = false;
36
+ let wrote = false;
37
+ let signaled = false;
35
38
  let pendingTimer = null;
36
39
  let pendingFiles = [];
37
40
  let chain = Promise.resolve();
38
41
  /** 建会话:找端口 → 解析安装目录 → 链接 → 激活。失败抛可读错误。 */
39
42
  async function start() {
40
43
  const current = facts;
41
- const name = readName(current.root, kind);
44
+ const name = readName(current.publicDir, kind);
42
45
  const port = await findKernelPort();
43
46
  const installPath = await resolveInstallDir({
44
47
  kind,
@@ -50,25 +53,18 @@ export function createBuildSession(facts, options) {
50
53
  const link = ensureLink(current.outDir, installPath, { requireSymlink: spec.requireSymlink });
51
54
  if (!link.ok)
52
55
  throw new UserError(link.reason ?? '建立链接失败');
53
- if (link.changed)
54
- log(`已链接:${installPath}`, 'success');
55
- const missing = checkLinkedFiles(installPath, spec.expectedFiles);
56
- if (missing.length)
57
- log(`链接目录缺少 ${missing.join('、')},构建完成后会补齐`, 'warn');
56
+ // 首次建链是用户关心的安装结果;链接本来就在时路径只对排查有用,放 debug。
57
+ log(`已链接:${installPath}`, link.changed ? 'success' : 'debug');
58
58
  const activation = await activate(kind, port, name);
59
59
  if (!activation.ok)
60
60
  throw new UserError(activation.message);
61
61
  // 首次启用插件必须补一次刷新:思源配置刚变,不刷新页面不会加载它。
62
- const firstReload = activation.changed ? { ok: await reloadUI(port), message: '' } : null;
63
- if (firstReload) {
64
- firstReload.message = firstReload.ok ? '已广播刷新,思源正在加载' : '刷新广播失败,请手动刷新思源窗口';
65
- log(firstReload.message, firstReload.ok ? 'success' : 'warn');
66
- }
62
+ // 成功是预期结果,不单独占一行;失败才需要提醒用户手动刷新。
63
+ const firstReload = activation.changed
64
+ ? { ok: await reloadUI(port), message: '刷新广播失败,请手动刷新思源窗口' }
65
+ : null;
67
66
  return {
68
67
  port,
69
- name,
70
- linkedPath: link.ok ? installPath : null,
71
- missingFiles: missing,
72
68
  activation: { ok: activation.ok, message: activation.message },
73
69
  firstReload,
74
70
  };
@@ -101,26 +97,39 @@ export function createBuildSession(facts, options) {
101
97
  if (!ready)
102
98
  return;
103
99
  const ok = await reloadUI(ready.port);
104
- log(ok ? `页面已刷新:${matched[0]}` : `刷新未生效:${matched[0]}`, ok ? 'success' : 'warn');
100
+ // 每次保存都成功刷新是正常工作过程,不刷屏;失败必须让用户看到。
101
+ log(ok ? `页面已刷新:${matched[0]}` : `刷新未生效:${matched[0]}`, ok ? 'debug' : 'warn');
105
102
  });
106
103
  }
107
104
  return {
108
105
  /** 构建开始:把 public/** 逐个交给构建器登记监听。 */
109
106
  onStart(registerWatchFile) {
107
+ // 每轮开始重置状态:上一轮失败不能污染下一轮的成功信号。
108
+ buildCount += 1;
109
+ failed = false;
110
+ wrote = false;
111
+ signaled = false;
110
112
  watchPublicFiles(facts.publicDir, registerWatchFile, log);
111
113
  },
112
114
  /** 产物写完。 */
113
115
  async onWritten(files) {
114
116
  const current = facts;
117
+ wrote = true;
115
118
  // 元数据搬运先做:校验时文件必须已经就位。
116
- copyMetadata({
117
- root: current.root,
118
- publicDir: current.publicDir,
119
- outDir: relative(current.root, current.outDir) || '.',
120
- manifestName: spec.manifestName,
121
- watch: current.watch,
122
- log,
123
- });
119
+ try {
120
+ copyReadmes({
121
+ root: current.root,
122
+ publicDir: current.publicDir,
123
+ outDir: relative(current.root, current.outDir) || '.',
124
+ manifestName: spec.manifestName,
125
+ watch: current.watch,
126
+ log,
127
+ });
128
+ }
129
+ catch (cause) {
130
+ failed = true;
131
+ throw cause;
132
+ }
124
133
  if (!current.watch)
125
134
  return;
126
135
  // 会话还没起来:启动它,本轮不通知重载。
@@ -130,6 +139,11 @@ export function createBuildSession(facts, options) {
130
139
  await ensure();
131
140
  }
132
141
  catch (cause) {
142
+ failed = true;
143
+ // UserError 已经是可读文案(链接权限、安装目录不存在、多个工作区…),
144
+ // 再套一层「思源在运行」会把权限问题误指成思源没启动。
145
+ if (cause instanceof UserError)
146
+ throw cause;
133
147
  throw new UserError(`watch 模式需要思源在运行:${describeError(cause)}`);
134
148
  }
135
149
  return;
@@ -145,12 +159,22 @@ export function createBuildSession(facts, options) {
145
159
  /** 一轮结束:校验 + 打包。 */
146
160
  onFinished() {
147
161
  const current = facts;
148
- buildCount += 1;
149
- const result = verifyTarget(kind, { root: current.root, outDir: current.outDir });
162
+ // 构建在 writeBundle 之前就失败,或 writeBundle 已经失败:跳过校验/打包,
163
+ // 否则「产物目录不存在」会盖住真正的构建错误。
164
+ if (!wrote || failed) {
165
+ failed = true;
166
+ return;
167
+ }
168
+ const result = verifyTarget(kind, {
169
+ root: current.root,
170
+ publicDir: current.publicDir,
171
+ outDir: current.outDir,
172
+ });
150
173
  if (!result.ok) {
151
174
  const text = formatIssues(result.issues);
152
175
  if (current.watch) {
153
176
  // 开发中中断会让 watch 退出,得手动重开,代价太大。
177
+ failed = true;
154
178
  log(`产物校验未通过,开发模式仅提示:\n${text}`, 'warn');
155
179
  }
156
180
  else {
@@ -166,20 +190,30 @@ export function createBuildSession(facts, options) {
166
190
  /** 本轮最后:打印会话汇报与预览入口(只一次),再打构建完成日志。 */
167
191
  onEnd() {
168
192
  const current = facts;
169
- if (current.watch && ready && !reported) {
193
+ // Vite 在构建失败时可能多次触发 closeBundle;每轮只发一次完成信号。
194
+ if (signaled)
195
+ return;
196
+ signaled = true;
197
+ if (current.watch && ready && !reported && !failed) {
170
198
  reported = true;
171
- log(`已链接到思源 ${ready.linkedPath}`, 'success');
172
- if (ready.missingFiles.length)
173
- log(`链接目录缺少 ${ready.missingFiles.join('、')}`, 'warn');
174
- if (ready.activation)
175
- log(ready.activation.message, ready.activation.ok ? 'success' : 'error');
176
- if (ready.firstReload)
177
- log(ready.firstReload.message, ready.firstReload.ok ? 'success' : 'warn');
199
+ // 只汇报「现在处于什么状态」:链接/启用的成功路径已在动作发生时打过,
200
+ // 这里不再重复;刷新失败才是需要用户处理的问题。
201
+ log(ready.activation.message, 'success');
202
+ if (ready.firstReload && !ready.firstReload.ok)
203
+ log(ready.firstReload.message, 'warn');
178
204
  log('会话就绪,改源码会自动刷新页面', 'success');
179
205
  logPreviewLinks(previewLogger, ready.port);
180
206
  }
181
207
  // agent 的构建完成信号:纯文本、序号递增,便于从 stdout 里认。
208
+ // watch 首轮不打印:此时「会话就绪 + 预览入口」已经说明可以开始了,
209
+ // 只有后续热重载才需要这个信号。失败仍然要打印。
182
210
  const files = current.watch ? 'watch' : PACKAGE_NAME;
211
+ if (!wrote || failed) {
212
+ console.log(`[vite-plugin-siyuan] build #${buildCount} failed · ${kind} · ${files}`);
213
+ return;
214
+ }
215
+ if (current.watch && buildCount === 1)
216
+ return;
183
217
  console.log(`[vite-plugin-siyuan] build #${buildCount} ready · ${kind} · ${files}`);
184
218
  },
185
219
  /** 释放定时器(幂等)。 */
@@ -17,9 +17,9 @@ export interface KindSpec {
17
17
  }
18
18
  export declare function kindSpec(kind: Kind): KindSpec;
19
19
  /**
20
- * 从项目目录推断类型:看 public/ 或项目根存在哪份清单。
20
+ * 从项目目录推断类型:看 publicDir 下存在哪份清单。
21
21
  * 同时存在多种时必须让人指定——装错类型的产物名会静默加载不到。
22
22
  */
23
- export declare function detectKind(root: string): Kind;
23
+ export declare function detectKind(publicDir: string): Kind;
24
24
  /** 读被测对象的名字;读不到抛可读错误,不猜默认值。 */
25
- export declare function readName(root: string, kind: Kind): string;
25
+ export declare function readName(publicDir: string, kind: Kind): string;
@@ -33,21 +33,28 @@ export function kindSpec(kind) {
33
33
  }
34
34
  const KIND_NAMES = ['theme', 'plugin'];
35
35
  /**
36
- * 从项目目录推断类型:看 public/ 或项目根存在哪份清单。
36
+ * 从项目目录推断类型:看 publicDir 下存在哪份清单。
37
37
  * 同时存在多种时必须让人指定——装错类型的产物名会静默加载不到。
38
38
  */
39
- export function detectKind(root) {
40
- const matches = KIND_NAMES.filter((kind) => manifestCandidates(root, SPECS[kind].manifestName).some((file) => existsSync(file)));
39
+ export function detectKind(publicDir) {
40
+ // publicDir 关掉时清单无处安放:思源要求清单在产物根,而没有 publicDir 就没有
41
+ // 「原样复制到产物根」的机制。这里直接说清楚,不要让用户去猜「产物缺少」。
42
+ if (!publicDir) {
43
+ throw new UserError('publicDir 已关闭,清单(theme.json / plugin.json)无处安放。\n' +
44
+ ' 思源要求清单出现在产物根,而 publicDir 是 Vite 唯一能原样复制到产物根的机制。\n' +
45
+ ' 请移除 publicDir: false,或改成指向存放清单的目录。');
46
+ }
47
+ const matches = KIND_NAMES.filter((kind) => manifestCandidates(publicDir, SPECS[kind].manifestName).some((file) => existsSync(file)));
41
48
  const [only] = matches;
42
49
  if (matches.length === 1 && only !== undefined)
43
50
  return only;
44
51
  throw new UserError(matches.length === 0
45
- ? `在 ${root} 的 public/ 或项目根下找不到 theme.json / plugin.json;请用 kind 选项显式指定类型`
52
+ ? `在 ${publicDir} 下找不到 theme.json / plugin.json;请把清单放进该目录,或用 kind 选项显式指定类型`
46
53
  : `同时匹配到多种类型:${matches.join('、')};请用 kind 选项显式指定`);
47
54
  }
48
55
  /** 读被测对象的名字;读不到抛可读错误,不猜默认值。 */
49
- export function readName(root, kind) {
50
- for (const file of manifestCandidates(root, SPECS[kind].manifestName)) {
56
+ export function readName(publicDir, kind) {
57
+ for (const file of manifestCandidates(publicDir, SPECS[kind].manifestName)) {
51
58
  const data = readJsonFile(file);
52
59
  if (isRecord(data)) {
53
60
  const name = data['name'];
@@ -14,6 +14,4 @@ export interface LinkResult {
14
14
  export declare function ensureLink(srcPath: string, targetPath: string, { requireSymlink }?: {
15
15
  requireSymlink?: boolean;
16
16
  }): LinkResult;
17
- /** 链接建好后,确认思源该读的文件真的在(防止链到空目录还以为成功)。 */
18
- export declare function checkLinkedFiles(targetPath: string, expectedFiles: string[]): string[];
19
17
  export {};
@@ -110,7 +110,10 @@ function elevateSymlink(srcPath, targetPath) {
110
110
  }
111
111
  }
112
112
  function describeFailure(cause, requireSymlink, elevationFailed) {
113
- const lines = [`无法建立目录链接:${cause instanceof Error ? cause.message : String(cause)}`];
113
+ // cause 为 null 只可能出现在「循环没走到 catch」的理论分支;仍给出可读文案,
114
+ // 绝不能把 `null` 打给用户。
115
+ const detail = cause instanceof Error ? cause.message : cause === null ? '系统拒绝创建链接(权限不足)' : String(cause);
116
+ const lines = [`无法建立目录链接:${detail}`];
114
117
  if (elevationFailed) {
115
118
  lines.push(' UAC 提权被拒绝或超时:请重新运行并在弹窗点「是」,或预先开启 Windows 开发者模式。');
116
119
  lines.push(' 插件目录必须是真 symlink,不能用 junction 兜底。');
@@ -197,7 +200,3 @@ export function ensureLink(srcPath, targetPath, { requireSymlink = false } = {})
197
200
  }
198
201
  return { ok: false, changed: false, linkType: null, reason: describeFailure(null, requireSymlink, elevationFailed) };
199
202
  }
200
- /** 链接建好后,确认思源该读的文件真的在(防止链到空目录还以为成功)。 */
201
- export function checkLinkedFiles(targetPath, expectedFiles) {
202
- return expectedFiles.filter((file) => !existsSync(resolve(targetPath, file)));
203
- }
@@ -3,7 +3,21 @@ export interface ManifestFile {
3
3
  path: string;
4
4
  data: Record<string, unknown>;
5
5
  }
6
- /** 清单候选路径,按优先级排列。 */
7
- export declare function manifestCandidates(root: string, manifest: string, extraDirs?: readonly string[]): string[];
6
+ /**
7
+ * 解析实际生效的 publicDir 绝对路径;`publicDir: false` 时返回空串。
8
+ *
9
+ * Vite 的 `publicDir` 可以指向任意目录(默认 `public`),也可以关掉。
10
+ * 所有找清单/资源的地方都必须走这里,不能拼死 `'public'`。
11
+ */
12
+ export declare function resolvePublicDir(root: string, configured: string | false | undefined): string;
13
+ /** 清单候选路径,按优先级排列。publicDir 关闭时只剩显式传入的目录。 */
14
+ export declare function manifestCandidates(publicDir: string, manifest: string, extraDirs?: readonly string[]): string[];
8
15
  /** 读一份清单与其路径;都读不到返回 null。 */
9
- export declare function readManifest(root: string, manifest: string, extraDirs?: readonly string[]): ManifestFile | null;
16
+ export declare function readManifest(publicDir: string, manifest: string, extraDirs?: readonly string[]): ManifestFile | null;
17
+ /**
18
+ * 从清单里收集引用到的文件名。
19
+ *
20
+ * `readme` 可能是字符串,也可能是 `{ default, zh_CN, ... }` 的 i18n 对象;
21
+ * `icon` / `preview` 只能是字符串。`keys` 用于只取其中一部分(比如只搬 README)。
22
+ */
23
+ export declare function declaredAssets(manifest: Record<string, unknown>, keys?: readonly string[]): string[];
@@ -1,24 +1,69 @@
1
1
  /**
2
- * 清单文件的读取。
2
+ * 清单文件的读取与 publicDir 解析。
3
3
  *
4
- * 清单可能出现在两处:`public/`(源码)或项目根(官方 sample 的布局)。
5
- * 构建产物里的清单由调用方通过 `extraDirs` 显式传入,不作为默认候选——
6
- * 否则上一次构建留下的 `dist/theme.json` 会盖住源码里的新清单。
4
+ * 清单**只从 publicDir 读**:它是思源要求的产物根文件,而 `publicDir` 是 Vite
5
+ * 唯一「原样复制到产物根」的机制。项目根那份官方 sample 布局不做支持——
6
+ * 探测到它却复制不进产物,只会得到「产物缺少 theme.json」这种半截行为。
7
+ *
8
+ * 构建产物里的清单由调用方通过 `extraDirs` 显式传入(校验时用),
9
+ * 不作为默认候选——否则上一次构建留下的旧清单会盖住源码里的新清单。
7
10
  */
8
- import { join } from 'node:path';
11
+ import { join, resolve } from 'node:path';
9
12
  import { readJsonFile } from '../support/fs.js';
10
13
  import { isRecord } from '../support/guards.js';
11
- /** 清单候选路径,按优先级排列。 */
12
- export function manifestCandidates(root, manifest, extraDirs = []) {
13
- const dirs = [join(root, 'public'), root, ...extraDirs];
14
+ /**
15
+ * 解析实际生效的 publicDir 绝对路径;`publicDir: false` 时返回空串。
16
+ *
17
+ * Vite 的 `publicDir` 可以指向任意目录(默认 `public`),也可以关掉。
18
+ * 所有找清单/资源的地方都必须走这里,不能拼死 `'public'`。
19
+ */
20
+ export function resolvePublicDir(root, configured) {
21
+ if (configured === false)
22
+ return '';
23
+ if (typeof configured === 'string' && configured)
24
+ return resolve(root, configured);
25
+ return join(root, 'public');
26
+ }
27
+ /** 清单候选路径,按优先级排列。publicDir 关闭时只剩显式传入的目录。 */
28
+ export function manifestCandidates(publicDir, manifest, extraDirs = []) {
29
+ const dirs = [...(publicDir ? [publicDir] : []), ...extraDirs];
14
30
  return dirs.map((dir) => join(dir, manifest));
15
31
  }
16
32
  /** 读一份清单与其路径;都读不到返回 null。 */
17
- export function readManifest(root, manifest, extraDirs = []) {
18
- for (const path of manifestCandidates(root, manifest, extraDirs)) {
33
+ export function readManifest(publicDir, manifest, extraDirs = []) {
34
+ for (const path of manifestCandidates(publicDir, manifest, extraDirs)) {
19
35
  const data = readJsonFile(path);
20
36
  if (isRecord(data))
21
37
  return { path, data };
22
38
  }
23
39
  return null;
24
40
  }
41
+ /** 清单里引用外部文件名的字段名。 */
42
+ const ASSET_KEYS = ['readme', 'icon', 'preview'];
43
+ /**
44
+ * 从清单里收集引用到的文件名。
45
+ *
46
+ * `readme` 可能是字符串,也可能是 `{ default, zh_CN, ... }` 的 i18n 对象;
47
+ * `icon` / `preview` 只能是字符串。`keys` 用于只取其中一部分(比如只搬 README)。
48
+ */
49
+ export function declaredAssets(manifest, keys = ASSET_KEYS) {
50
+ const names = new Set();
51
+ if (keys.includes('readme')) {
52
+ const readme = manifest['readme'];
53
+ if (typeof readme === 'string')
54
+ names.add(readme);
55
+ else if (isRecord(readme)) {
56
+ for (const value of Object.values(readme))
57
+ if (typeof value === 'string')
58
+ names.add(value);
59
+ }
60
+ }
61
+ for (const key of keys) {
62
+ if (key === 'readme')
63
+ continue;
64
+ const value = manifest[key];
65
+ if (typeof value === 'string')
66
+ names.add(value);
67
+ }
68
+ return [...names];
69
+ }
@@ -1,5 +1,10 @@
1
1
  /** 日志级别与回调契约(不含输出实现,实现在 output/log.ts)。 */
2
- /** 日志级别。 */
3
- export type LogLevel = 'info' | 'success' | 'warn' | 'error';
2
+ /**
3
+ * 日志级别。
4
+ *
5
+ * 默认只应使用 info / success / warn / error 输出用户关心的结果与问题;
6
+ * 探测端口、跳过监听、兜底读取这类过程信息走 debug,仅 `SIYUAN_DEBUG=1` 时可见。
7
+ */
8
+ export type LogLevel = 'debug' | 'info' | 'success' | 'warn' | 'error';
4
9
  /** 日志回调。level 可选,缺省按 info 处理。 */
5
10
  export type Logger = (message: string, level?: LogLevel) => void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vite-plugin-siyuan",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "Vite plugin for SiYuan theme/plugin development: config injection, link to workspace, hot reload, verify and package",
5
5
  "keywords": [
6
6
  "siyuan",