@akairos/pipeline 0.18.5 → 0.19.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 (47) hide show
  1. package/ARTICLE-THEMES.md +2 -2
  2. package/README.md +55 -15
  3. package/THIRD-PARTY-NOTICES.md +1 -1
  4. package/package.json +4 -4
  5. package/src/adapter-loader.ts +24 -4
  6. package/src/adapter-types.ts +14 -0
  7. package/src/commands/typeset-assets-sync.test.ts +3 -0
  8. package/src/plugin-runner.ts +10 -4
  9. package/src/plugin-runtime.test.ts +19 -0
  10. package/src/plugin-runtime.ts +11 -2
  11. package/src/runtime-package.ts +9 -3
  12. package/src/wechat-preview/assets/browser-dist/.vite/manifest.json +47 -47
  13. package/src/wechat-preview/assets/browser-dist/assets/{index-BHJY0B4E.js → index-52O71prB.js} +1 -1
  14. package/src/wechat-preview/assets/browser-dist/assets/{index-CdxKyTX6.js → index-BOH11YN9.js} +1 -1
  15. package/src/wechat-preview/assets/browser-dist/assets/{index-DDQGWU_D.js → index-BOZWI3FE.js} +1 -1
  16. package/src/wechat-preview/assets/browser-dist/assets/{index-BlVpb0dF.js → index-BRUZEgPh.js} +1 -1
  17. package/src/wechat-preview/assets/browser-dist/assets/{index-BPmBcGI-.js → index-BTPh91V-.js} +1 -1
  18. package/src/wechat-preview/assets/browser-dist/assets/{index-BYV6TA8f.js → index-BXjcry0g.js} +1 -1
  19. package/src/wechat-preview/assets/browser-dist/assets/{index-ifI5svkD.js → index-BoIlqlZt.js} +1 -1
  20. package/src/wechat-preview/assets/browser-dist/assets/{index-D_EI5vFS.js → index-Btrc4kp0.js} +1 -1
  21. package/src/wechat-preview/assets/browser-dist/assets/{index-BFLGVgsp.js → index-ButcQVWD.js} +1 -1
  22. package/src/wechat-preview/assets/browser-dist/assets/{index-vRiJxvj2.js → index-C-LFn5io.js} +1 -1
  23. package/src/wechat-preview/assets/browser-dist/assets/{index-DpuVrwze.js → index-C1Bxnw4o.js} +1 -1
  24. package/src/wechat-preview/assets/browser-dist/assets/{index-BzHql5Rp.js → index-C2N6r4Fw.js} +1 -1
  25. package/src/wechat-preview/assets/browser-dist/assets/{index-BqKARoyW.js → index-Cbm1GiZg.js} +1 -1
  26. package/src/wechat-preview/assets/browser-dist/assets/{index-DENMoE5l.js → index-CwgSvQOC.js} +1 -1
  27. package/src/wechat-preview/assets/browser-dist/assets/{index-BTVIa90Y.js → index-D7f6KXoI.js} +1 -1
  28. package/src/wechat-preview/assets/browser-dist/assets/{index-IBlhKHQr.js → index-DkhSv5cA.js} +1 -1
  29. package/src/wechat-preview/assets/browser-dist/assets/{index-DA7Z4paG.js → index-DmoEW40N.js} +1 -1
  30. package/src/wechat-preview/assets/browser-dist/assets/{index-BDdU9pNh.js → index-Dyi5bUmz.js} +1 -1
  31. package/src/wechat-preview/assets/browser-dist/assets/{index-CPFEJ9fX.js → index-TOFwZskx.js} +1 -1
  32. package/src/wechat-preview/assets/browser-dist/assets/{index-BOnKEJxh.js → index-lWl3DOj9.js} +1 -1
  33. package/src/wechat-preview/assets/browser-dist/assets/{index-CyXDvcwY.js → index-sgpqKtmR.js} +1 -1
  34. package/src/wechat-preview/assets/browser-dist/assets/{style-Cif_gYUW.css → style-CVnSjpTH.css} +1 -1
  35. package/src/wechat-preview/assets/browser-dist/assets/{wechat-renderer-CcHuq7M-.js → wechat-renderer-GrfbOjo2.js} +13 -4
  36. package/src/wechat-preview/assets/browser-dist/editor-export.js +1 -1
  37. package/src/wechat-preview/assets/browser-dist/src/wechat-preview/browser/studio/studio.html +2 -2
  38. package/src/wechat-preview/assets/browser-dist/studio.js +3 -3
  39. package/src/wechat-preview/browser/editor-export.css +2 -2
  40. package/src/wechat-preview/browser/editor-export.ts +1 -1
  41. package/src/wechat-preview/browser/studio/myno-editor.ts +2 -2
  42. package/src/wechat-preview/browser/studio/studio.ts +1 -1
  43. package/src/wechat-preview/index.ts +8 -3
  44. package/src/wechat-preview/server/http.ts +56 -1
  45. package/src/wechat-preview/server/studio.test.ts +35 -1
  46. package/src/wechat-preview/wechat-export.integration.test.ts +3 -0
  47. package/src/wechat-preview.test.ts +1 -1
package/ARTICLE-THEMES.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # 离线正文模板
2
2
 
3
- > 本文描述当前源码的 `article-theme` 与 `--article-theme` 能力,npm `0.14.0` 尚不包含。请使用包含该实现的源码 revision 或后续发布版本;以 `zzp --help` 和 `zzp wechat-export --help` 核对已安装命令。
3
+ > 本文以 Pipeline `0.19.0` 为基线;`article-theme`、独立 JSON 和 myno 插件均已随 npm 版本提供。升级旧版本后,可用 `zzp --help` 和 `zzp wechat-export --help` 核对命令。
4
4
 
5
5
  Pipeline 保持 MIT 开源。正文排版、本地模板包、customCss 和渲染插件不要求登录或会员。付费模板由各作者单独交付、单独授权,不包含在此仓库或 npm 包中。
6
6
 
@@ -26,7 +26,7 @@ zzp publish --state ./workflow-state.json --article-theme ./my-template
26
26
 
27
27
  WeChat Visual Studio 的「导出配置」下载独立 `wechat-style.json`,同样通过 `--article-theme` 选择。文件含内嵌 `css`、`editorVars`、`exportTheme`、`syntaxPresets` 和 `structure`;页首图片以内嵌 Data URL 保存。不含账号凭据,不需要附带目录包文件。独立 JSON 使用自身样式,不叠加账号 `theme/customCss`,显式 `wechat-export --custom-css` 仍可覆盖。可用 `article-theme --path` 校验或安装;默认配置引用后由现有 `typeset-assets-sync` 收集部署,详见 [Studio 配置指南](docs/visual-studio-and-config-sync.md)。
28
28
 
29
- 模板样式叠加在现有基础样式上,既有账号 `theme` 覆盖和 `customCss` 最后生效。新版正文模板默认不附带内置账号署名,用户自己配置的 footerText 仍保留。旧 Markdown 插件接收可选 `articleThemePath`,可以继续忽略该字段;要支持付费模板预览,应使用内置渲染器或实现该可选参数。桌面端预览同样调用当前已配置的渲染插件。
29
+ 目录模板叠加在基础样式上,账号 `theme` 和 `customCss` 仍可覆盖;独立 JSON 使用自身样式。正文模板默认不附带内置账号署名,用户配置的 `footerText` 仍保留。内置渲染器和 myno 的浏览器型插件均由 Pipeline 解析、校验模板;其他 `render()` 型插件需自行支持可选 `articleThemePath`。桌面端预览调用当前已配置的渲染插件。
30
30
 
31
31
  发布前冻结模板版本、CSS 和资源到工作区 `.zzhub-media/article-themes`;渲染结果记录 `article_theme_hash`。同一篇文章更改模板、版本或声明素材后会重新导出。旧状态不含该字段时仍可使用。
32
32
 
package/README.md CHANGED
@@ -12,13 +12,30 @@ Pipeline 可以独立使用,也可以作为 Web、App、桌面客户端背后
12
12
 
13
13
  > Cloud Worker 已在本仓库实现,可从源码构建 Docker 镜像部署。它不包含在 npm CLI 包中;npm 只分发 CLI 和渲染资源。`v0.14.0` tag 包含初版 Worker,账号同步和正文模板等接口以实际部署的 revision 为准,生产环境请固定 commit/tag。
14
14
 
15
+ ## 生态与使用者
16
+
17
+ 本仓库是发布执行层,供本机用户、Agent 和应用后端调用。以下都是可独立使用的公开仓库;npm 包统一使用 `@akairos` scope,GitHub 仓库位于 `k7airos`。
18
+
19
+ | 项目 | 与 Pipeline 的关系 | 什么时候使用 |
20
+ | --- | --- | --- |
21
+ | [myno](https://github.com/k7airos/myno) · [`@akairos/myno`](https://www.npmjs.com/package/@akairos/myno) | 可选的正文编辑、排版与 Studio 插件,配置 `plugins.markdownRenderer` | 交互调样式、导出独立配置、复制富文本;也能脱离 Pipeline 独立运行 |
22
+ | [Typeset Studio](https://github.com/k7airos/typeset-studio) · [`@akairos/typeset-studio`](https://www.npmjs.com/package/@akairos/typeset-studio) | 图片渲染插件,配置 `plugins.imageRenderer` | 设计与渲染文章封面、贴图封面和多页内容图 |
23
+ | [Milkdown Article Plugins](https://github.com/k7airos/milkdown-article-plugins) | Pipeline 与 myno 共用的语法、序列化和微信组件渲染 | 给自己的 Milkdown 宿主接入高亮、提示卡、徽章和快捷键 |
24
+ | myno CSS 入口 | `@akairos/myno/article.css` 与 `tokens-default.css`,统一提供正文排版和主题变量 | 仅复用样式时无需加载编辑器脚本 |
25
+ | [Obsidian 微信发布插件](https://github.com/k7airos/obsidian-wechat-publish) | 通过本机 `zzp` 调用 Pipeline 的桌面客户端 | 在 Obsidian 内提交笔记到公众号草稿箱 |
26
+ | [Kairos Skills](https://github.com/k7airos/kairos-skills) | 外部 Agent 的写作规范、审核与状态机编排说明 | 让 Agent 按 `status → next_action` 写稿和推进任务 |
27
+ | [blog.zzao.club](https://github.com/k7airos/blog.zzao.club) | 博客路由的公开消费方和目录约定示例 | 将文章同步到 Nuxt Content,再执行自定义发布命令 |
28
+ | [Bun 微信中转服务](https://github.com/k7airos/bun-wx-server) | 可参考的自托管认证、COS 与基础微信代理实现 | 研究中转 API;当前只覆盖部分微信端点,接入限制见该仓库 README |
29
+
30
+ Nezus 也是现有消费方:桌面端调用本机 CLI,云端通过业务后端调用 Worker。它是独立应用,不是安装 Pipeline 的前提。Pipeline 不依赖任何私有仓库才能启动或本地排版。
31
+
15
32
  ## 能做什么
16
33
 
17
34
  | 能力 | 说明 |
18
35
  | --- | --- |
19
36
  | 公众号文章 | Markdown 转微信兼容 HTML,生成自适应头图,上传图片,创建或更新文章草稿 |
20
- | 全语法与扩展组件 | 支持 GFM Callout(含自定义标题/Emoji)、导读卡 (Intro/CTA)、行内徽章 (Badge)、按键 (Kbd)、荧光笔与防拉伸短分割线 |
21
- | 智能封面裁切 | 自动计算 2.35:1 与 1:1 双视图坐标比率,彻底规避微信 53401 拒收报错 |
37
+ | Markdown 与扩展组件 | 支持 CommonMark/GFM、Callout、导读/互动卡、徽章、按键和高亮;微信导出范围见正文模板文档 |
38
+ | 封面裁切 | 按真实图片尺寸计算 2.35:1 与右对齐 1:1 裁切参数;仍需符合微信素材要求 |
22
39
  | 公众号贴图 | 渲染单页卡片或多页 PNG 图集,创建 `newspic` 草稿 |
23
40
  | 博客同步 | 把 Markdown 和图片同步到配置的博客项目,并执行发布命令 |
24
41
  | 可恢复工作流 | 保存任务状态、素材、审核决定、渲染版本和发布结果,失败后可以继续或重置 |
@@ -26,7 +43,7 @@ Pipeline 可以独立使用,也可以作为 Web、App、桌面客户端背后
26
43
  | 排版扩展 | 封面主题、正文模板、品牌配置和可替换渲染插件 |
27
44
  | 排版工作台 | `zzp wechat-preview studio` 提供所见即所得排版调优、配色选型、语法预设与配置持久化 |
28
45
  | 本机监控 | `zzp monitor` 提供任务快照、日志和 SSE 事件流 |
29
- | 云端执行 | 私有 HTTP Worker 提供账号同步、串行队列、持久化任务和状态轮询 |
46
+ | 云端执行 | 私有 HTTP Worker 提供账号同步、串行队列、持久化任务和状态轮询,运行中可附带当前动作进度 |
30
47
 
31
48
  Pipeline 不内置 LLM、用户登录、计费或配额服务。调用方需要自己提供正文和审核决定;云端 Worker 接收已经确认的完整正文,并只推进支持的 CLI 动作。
32
49
 
@@ -94,19 +111,19 @@ zzp wechat-export \
94
111
 
95
112
  ### 视觉排版工作台 (WeChat Visual Studio)
96
113
 
97
- Pipeline 提供了开箱即用的所见即所得排版工作台:
114
+ 启用 [myno 排版插件](#myno-排版插件) 后,下面的命令加载 myno 随 npm 包提供的工作台;未配置插件时使用 Pipeline 内置工作台:
98
115
 
99
116
  ```bash
100
117
  zzp wechat-preview studio
101
118
  ```
102
119
 
103
- 终端启动后会自动唤起浏览器打开 `http://127.0.0.1:18765/studio`。你可以在界面中直观调整主题配色、二级标题与表格语法预设、提示卡片(Callout)自定义 Emoji,并一键复制到微信后台或保存为全局配置。
120
+ 终端启动后会自动唤起浏览器打开 `http://127.0.0.1:18765/studio`。可调整主题配色、字号行距、标题/引用/分割线等预设和自定义 CSS;提示卡片的标题与 Emoji 写在 Markdown 中。调整后可复制富文本、导出独立 JSON,或保存到当前账号配置。
104
121
 
105
122
  > **云端使用者注意**:本地 Studio 保存的样式会写入本地 `config.json`。若使用私有云端 Worker,需将调整后的配置同步至服务器,详见 [Visual Studio 排版调优与云端配置同步指南](docs/visual-studio-and-config-sync.md)。
106
123
 
107
124
  ### 公众号全语法与扩展组件
108
125
 
109
- Pipeline 内置基于 `@akairos/milkdown-article-plugins` 与 `@akairos/milkdown-article-style` 的现代排版扩展,所有语法在导出与发布时均会自动编译为微信内联样式:
126
+ Pipeline 与 myno 基于 `@akairos/milkdown-article-plugins` 和 myno 内置正文样式支持以下扩展,导出时转换为微信兼容的内联样式:
110
127
 
111
128
  #### 1. 语义提示卡片 (Callout Alerts)
112
129
  支持 GFM 标准的 5 种语义卡片,并支持行内自定义标题与 Emoji:
@@ -141,26 +158,27 @@ Pipeline 内置基于 `@akairos/milkdown-article-plugins` 与 `@akairos/milkdown
141
158
  ```
142
159
 
143
160
  #### 3. 行内组件 (Badge / Kbd / Highlight)
144
- - **状态徽章**:`[badge:primary|活力橙红]`、`[badge:success|运行正常]`、`[badge:warning|重点监测]`、`[badge:purple|专业模式]`
161
+ - **状态徽章**:`[badge:brand|推荐]`、`[badge:success|运行正常]`、`[badge:warning|待确认]`、`[badge:danger|异常]`、`[badge:info|说明]`
145
162
  - **键盘按键**:按 `[[kbd:Cmd]] + [[kbd:K]]` 快速调起指令,按 `[[kbd:Shift]] + [[kbd:Enter]]` 换行
146
163
  - **荧光笔高亮**:使用 `==荧光笔标记核心概念与重点论述==`
147
164
 
148
165
  #### 4. 智能居中防拉伸分割线 (Divider)
149
- 标准 Markdown `---` 会自动应用 Studio 中配置的分割线预设(如 `short-center` 居中 36% 呼吸线、`dashed` 极简虚线、`hairline` 细实线),并自动编译为抗样式覆盖的 `<section>` 节点,防止被微信移动端强行拉伸至 100% 通栏。
166
+ 标准 Markdown `---` 可应用 Studio 中配置的分割线预设(如 `short-center`、`dashed`、`hairline`),导出为微信可用的结构和内联样式。
150
167
 
151
168
  #### 5. 智能封面裁切比率自动适配
152
- Pipeline 在创建和更新草稿时,会自动读取封面图真实尺寸,默认裁切最右侧的 1:1 区域(纵向居中),2.35:1 裁切保持原有规则,自动注入 `pic_crop_235_1` 与 `pic_crop_1_1` 微信 API 参数,彻底告别微信官方接口的 `53401`(`WX_CONTENT_REJECTED`)拒收报错。
169
+ Pipeline 在创建和更新草稿时读取封面图真实尺寸,默认裁切最右侧的 1:1 区域(纵向居中),并生成 `pic_crop_235_1` 与 `pic_crop_1_1` 参数。此处解决裁切几何问题,不能保证绕过微信的其他内容或素材校验。
153
170
 
154
171
  ### 模块化生态组成
155
172
 
156
173
  | 模块包名 | 职责说明 |
157
174
  | --- | --- |
158
175
  | **`@akairos/pipeline`** | 核心可恢复状态机、CLI 工具链与多端发布器 |
159
- | **`@akairos/milkdown-article-style`** | Kairos 原创微信排版 CSS 样式规范与设计令牌 |
160
- | **`@akairos/milkdown-article-plugins`** | Remark / Milkdown 全语法自定义解析与渲染插件 |
176
+ | **`@akairos/myno/article.css`** | Kairos 原创微信排版 CSS 样式规范与设计令牌,随 myno 维护 |
177
+ | **`@akairos/milkdown-article-plugins`** | Remark / Milkdown 文章扩展语法与渲染插件 |
178
+ | **`@akairos/myno`** | 可独立运行的正文编辑器、样式工作台与可选 Markdown 渲染插件 |
161
179
  | **`@akairos/typeset-studio`** | 微信大图头图、多比例排版与卡片视觉合成引擎 |
162
180
 
163
- 配置在 `plugins.imageRenderer` / `plugins.markdownRenderer` 的外部插件在独立 Bun 子进程中加载和执行。加载与 doctor 默认上限为 10 秒,渲染为 120 秒;Markdown 渲染显式传入 `timeoutMs` 时,子进程上限额外预留 10 秒。可通过 `ZZHUB_PIPELINE_PLUGIN_TIMEOUT_MS` 覆盖调用上限。同步死循环、超时和异常退出会终止插件进程,非法返回值会给出 `PLUGIN_*` 错误,命令不会自动重试或静默切换模板。
181
+ 配置在 `plugins.imageRenderer` / `plugins.markdownRenderer` 的外部插件在独立 Bun 子进程中加载,插件的 `render`、`doctor`、`browser` 和 `studio` 方法也在子进程调用。加载、资源入口查询与 doctor 默认上限为 10 秒,`render` 为 120 秒;Markdown 渲染显式传入 `timeoutMs` 时,子进程上限额外预留 10 秒。可通过 `ZZHUB_PIPELINE_PLUGIN_TIMEOUT_MS` 覆盖子进程调用上限。同步死循环、超时和异常退出会终止插件进程,非法返回值会给出 `PLUGIN_*` 错误,命令不会自动重试或静默切换模板。浏览器型插件返回入口后,由 pipeline 现有 Chrome 运行时加载页面,并按渲染 `timeoutMs` 管理浏览器生命周期。
164
182
 
165
183
  图片渲染失败后任务进入失败状态,保留正文与审核结果。修复插件后执行 `zzp reset --state <state_path> --mode render`,再继续任务;也可以将对应的 `plugins.*Renderer` 配置设为 `null` 使用内置渲染器。外部插件通过 `onProgress` 回传进度,不应依赖修改输入对象或跨调用保存内存状态。子进程继承当前环境和文件权限,这一机制负责故障隔离,不是权限沙箱。
166
184
 
@@ -170,6 +188,27 @@ Pipeline 在创建和更新草稿时,会自动读取封面图真实尺寸,
170
188
 
171
189
  旧的字符串入口继续有效,等同于没有配置专属参数;`null` 使用内置渲染器。`zzp config --import` 中的渲染器条目整体替换原条目,只改一个参数时使用 `--key plugins.imageRenderer.options.<参数名>`。
172
190
 
191
+ ### myno 排版插件
192
+
193
+ Pipeline 0.19.1 起固定依赖 `@akairos/myno@0.3.1`,内置导出与插件共用 myno 正文 CSS。使用 myno 完整排版和工作台时,仍需在配置中显式选择插件。npm 配置示例:
194
+
195
+ ```json
196
+ {
197
+ "plugins": {
198
+ "markdownRenderer": {
199
+ "module": "@akairos/myno/adapter",
200
+ "options": {}
201
+ }
202
+ }
203
+ }
204
+ ```
205
+
206
+ 本地开发时可先在 myno 仓库执行 `bun run build`,将 `module` 指向构建后的 `dist/adapter.js` 绝对路径。运行时部署包只接受 npm 入口和空 `options`,不会复制本机插件路径。
207
+
208
+ 插件可以实现原有的 `render(input, options)`,也可以像 myno 一样只实现 `browser(options)`,返回浏览器排版入口的绝对路径 `{ scriptPath }`。`wechat-export` 和公众号文章发布通过同一个插件加载器调度;浏览器型插件复用 pipeline 现有的 Chrome、超时清理、离线模板校验和文件输出,仅页面排版实现来自插件。myno 不增加浏览器自动化依赖,调样式和导出 config 也不需要启动无头浏览器。独立样式仍与发布账号分离。
209
+
210
+ Markdown 插件可选提供 `studio(options)`,返回绝对资源目录 `root` 和该目录内的 HTML 相对路径 `entry`。`zzp wechat-preview studio` 将插件页面挂到 `/studio`,脚本和样式挂到 `/studio-assets/`;`/api/studio/config`、`/api/studio/save-config` 和历史记录继续由 pipeline 提供。插件不支持 Studio 时返回明确提示;配置为 `null` 保留内置工作台。切换插件后需重启已运行的预览服务。
211
+
173
212
  ### Typeset Studio 插件配置
174
213
 
175
214
  Pipeline 已通过 npm 依赖 `@akairos/typeset-studio`。在本地配置文件启用适配器,并指定与 Studio 编辑器共用的数据目录:
@@ -195,7 +234,7 @@ Studio 模板与 pipeline 配置分开保存:`data/templates/*.json` 保存模
195
234
 
196
235
  固定素材的 `templateAssetKey` 是模板内的替换标识,图片来源仍是 `images[].src`。支持可访问的 HTTP/HTTPS URL、本地文件及 `/uploads/...` 引用。使用 `/uploads/...` 时应连同 `uploads/` 目录原文件名一起同步;绝对路径应改为服务器可用路径,或把素材上传到 COS/CDN 后使用稳定 HTTPS URL。编辑器预览远程图片还需要图片服务允许跨域读取。
197
236
 
198
- **版本边界(2026-10-13):** 当前固定依赖为 `@akairos/typeset-studio@0.2.5`,已包含固定素材、`studioTemplates`、`templateImageOverrides` 和共享数据目录支持。模板与素材不打进 npm 包,生产环境通过运行时配置包提供。源码适配器路径仅用于本地联调,正式 Worker 使用 npm 入口。
237
+ **版本基线(2026-10-15):** Pipeline `0.19.1` 固定 Typeset Studio `0.2.6` 和 myno `0.3.1`;插件仍需显式配置。模板与素材不打进 npm 包,服务器通过运行时配置包提供。源码适配器路径仅用于本地联调,Worker 使用 npm 入口。
199
238
 
200
239
  ### 生成 Worker 运行时配置包
201
240
 
@@ -210,7 +249,7 @@ zzp typeset-assets-sync --output ./pipeline-runtime --account-map '{"default":"W
210
249
 
211
250
  命令扫描模板引用的本地文件和 Base64 图片,使用现有 COS 配置上传;图片 URL 验证成功后,备份并回写模板,再生成带逐文件 SHA-256 的 `manifest.json`、`pipeline.patch.json`、`typeset-studio/` 和 `markdown/`。上传记录按目标和图片内容保存,重复执行复用成功上传;本地原图保留。上传或校验失败保留模板和旧输出包,上传期间的模板修改会触发冲突提示。`--template` 仅限制本次素材迁移范围,输出仍包含全部模板;其他模板存在未迁移素材时拒绝生成不完整包。
212
251
 
213
- 配置包只包含插件入口、Studio 容器路径和账号主题/CSS/文章模板路径,不包含微信凭据、COS PAT 或 Worker token。账号的 `customCss`、`articleTheme` 为 null 时会清除服务器旧引用。服务器用 `nezus-infra` 的 `sync pipeline-runtime` 命令校验文件清单、合并排版白名单并备份切换;镜像与 npm 依赖继续固定版本部署。
252
+ 配置包只包含插件入口、Studio 容器路径和账号主题/CSS/文章模板路径,不包含微信凭据、COS PAT 或 Worker token。账号的 `customCss`、`articleTheme` 为 null 时会清除服务器旧引用。Markdown 插件只导出 `null` 或 `@akairos/myno/adapter` npm 入口和空 `options`,不导出本机模块路径。自托管时按文件哈希校验、备份和样式白名单合并后切换;项目自己的部署工具可消费此包,镜像与 npm 依赖仍需固定版本。
214
253
 
215
254
  WeChat Visual Studio 可通过「导出配置」下载不绑定账号的独立排版 JSON。CLI 用 `--article-theme ./wechat-style.json` 选择;在默认配置的账号 `articleTheme` 中引用后,也会由上述运行时包收集部署。文件内嵌 CSS 与页首图片,无需附带模板目录。详见 [Studio 独立配置与部署](docs/visual-studio-and-config-sync.md)。
216
255
 
@@ -272,7 +311,7 @@ Worker 已经可以部署,但应作为**内部服务**使用:
272
311
  - `PIPELINE_WORKER_TOKEN` 是服务令牌,不是用户登录 token,也不是微信中转 PAT;不要下发给客户端。
273
312
  - 当前是单进程串行队列,使用 JSON 文件保存任务、正文和账号;数据目录必须挂载持久卷,不支持多个副本共享同一目录。
274
313
  - 任务状态为 `queued`、`running`、`succeeded`、`failed` 或 `interrupted`,`step` 表示当前 Pipeline 动作。
275
- - 当前 Worker 只提供步骤和结果轮询,不提供云端 SSE、逐文件进度或原始日志。本机 `zzp monitor` 是另一套观测接口。
314
+ - Worker 轮询返回步骤、结果和可选当前动作进度,不提供云端 SSE 或原始日志。本机 `zzp monitor` 是另一套观测接口。
276
315
 
277
316
  最短 Docker 启动示例:
278
317
 
@@ -299,6 +338,7 @@ docker run -d --name pipeline-worker --restart unless-stopped \
299
338
  | 方法 | 路径 | 用途 |
300
339
  | --- | --- | --- |
301
340
  | `GET` | `/health` | 查看 Worker 是否存活、排队数和账号数 |
341
+ | `PUT` | `/v1/maintenance` | 暂停或恢复接收新任务,供维护更新使用 |
302
342
  | `PUT` | `/v1/accounts/:account` | 写入或替换公众号凭据 |
303
343
  | `DELETE` | `/v1/accounts/:account` | 删除 Worker 中的公众号凭据 |
304
344
  | `POST` | `/v1/jobs` | 提交完整正文,返回 job ID |
@@ -1,6 +1,6 @@
1
1
  # Bundled article styles
2
2
 
3
- The browser renderer includes `@akairos/milkdown-article-style` (`article.css` and `tokens-default.css`), distributed under the following license. Other dependencies retain their own licenses. Commercial article templates are separate packages and are not covered by this license.
3
+ The browser renderer includes `@akairos/myno` article styles (`article.css` and `tokens-default.css`, migrated from `@akairos/milkdown-article-style`), distributed under the following license. Other dependencies retain their own licenses. Commercial article templates are separate packages and are not covered by this license.
4
4
 
5
5
  MIT License
6
6
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akairos/pipeline",
3
- "version": "0.18.5",
3
+ "version": "0.19.1",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -28,12 +28,12 @@
28
28
  },
29
29
  "dependencies": {
30
30
  "@akairos/milkdown-article-plugins": "^0.1.6",
31
- "@akairos/milkdown-article-style": "^0.1.2",
32
- "@akairos/typeset-studio": "0.2.5",
31
+ "@akairos/typeset-studio": "0.2.6",
33
32
  "@chenglou/pretext": "0.0.8",
34
33
  "image-size": "^2.0.2",
35
34
  "pngjs": "^7.0.0",
36
- "zod": "^4.4.2"
35
+ "zod": "^4.4.2",
36
+ "@akairos/myno": "0.3.1"
37
37
  },
38
38
  "optionalDependencies": {
39
39
  "@napi-rs/canvas": "^0.1.97",
@@ -15,7 +15,10 @@ import type {
15
15
  } from "./adapter-types";
16
16
  import { builtinImageRenderer } from "./adapters/builtin-image-renderer";
17
17
  import { builtinMarkdownRenderer } from "./adapters/builtin-markdown-renderer";
18
- import { PluginDoctorSchema, PluginImageOutputSchema, PluginMarkdownOutputSchema, PluginMetadataSchema, runExternalPlugin } from "./plugin-runtime";
18
+ import { PluginBrowserSchema, PluginDoctorSchema, PluginImageOutputSchema, PluginMarkdownOutputSchema, PluginMetadataSchema, PluginStudioSchema, runExternalPlugin } from "./plugin-runtime";
19
+ import { exportMarkdownToWechatHtml } from "./wechat-preview";
20
+ import { notifyProgress } from "./monitor/recorder";
21
+ import { findChrome } from "./imgx/runtime";
19
22
 
20
23
  // ── Public API ───────────────────────────────────────────────────
21
24
 
@@ -75,9 +78,26 @@ export async function resolveMarkdownRenderer(
75
78
  return {
76
79
  name: metadata.name,
77
80
  version: metadata.version,
78
- ...(metadata.hasDoctor ? { doctor: () => runExternalPlugin(pluginPath, "doctor", PluginDoctorSchema, undefined, { options }) } : {}),
79
- render: ({ onProgress, ...input }) => runExternalPlugin(pluginPath, "render", PluginMarkdownOutputSchema, input, { options }, onProgress,
80
- input.timeoutMs && Number.isFinite(input.timeoutMs) && input.timeoutMs > 0 ? input.timeoutMs + 10_000 : 120_000),
81
+ ...(metadata.hasDoctor || metadata.hasBrowser ? { doctor: async () => {
82
+ const checks = metadata.hasDoctor ? await runExternalPlugin(pluginPath, "doctor", PluginDoctorSchema, undefined, { options }) : [];
83
+ if (metadata.hasBrowser) {
84
+ const chrome = findChrome();
85
+ checks.push({ name: "chrome", ok: Boolean(chrome), message: chrome ?? "Chrome/Chromium not found" });
86
+ }
87
+ return checks;
88
+ } } : {}),
89
+ ...(metadata.hasStudio ? { studio: () => runExternalPlugin(pluginPath, "studio", PluginStudioSchema, undefined, { options }) } : {}),
90
+ render: async ({ onProgress, ...input }) => {
91
+ if (metadata.hasBrowser) {
92
+ const browser = await runExternalPlugin(pluginPath, "browser", PluginBrowserSchema, undefined, { options });
93
+ notifyProgress(onProgress, { stage: "render.html", message: "正在导出微信 HTML" });
94
+ const result = await exportMarkdownToWechatHtml({ ...input, browserScriptPath: browser.scriptPath });
95
+ notifyProgress(onProgress, { stage: "render.html", message: "微信 HTML 导出完成", current: 1, total: 1, unit: "files" });
96
+ return result;
97
+ }
98
+ return runExternalPlugin(pluginPath, "render", PluginMarkdownOutputSchema, input, { options }, onProgress,
99
+ input.timeoutMs && Number.isFinite(input.timeoutMs) && input.timeoutMs > 0 ? input.timeoutMs + 10_000 : 120_000);
100
+ },
81
101
  };
82
102
  }
83
103
 
@@ -207,7 +207,21 @@ export interface MarkdownRenderPlugin {
207
207
  /** Environment checks */
208
208
  doctor?: (options?: Record<string, unknown>) => Promise<PipelinePluginDoctorCheck[]>;
209
209
 
210
+ /** 工作台静态资源由插件分发,配置读写仍由宿主提供。 */
211
+ studio?: (options?: Record<string, unknown>) => Promise<MarkdownStudioAssets>;
212
+
210
213
  /** Render markdown to WeChat-compatible HTML */
211
214
  /** 专属配置作为第二个参数传入,旧插件可忽略。 */
212
215
  render: (input: MarkdownRenderInput, options?: Record<string, unknown>) => Promise<MarkdownRenderOutput>;
213
216
  }
217
+
218
+ /** root 是插件资源目录,entry 是该目录内的 HTML 相对路径。 */
219
+ export interface MarkdownStudioAssets {
220
+ root: string;
221
+ entry: string;
222
+ }
223
+
224
+ /** 仅提供浏览器排版资源的插件,Chrome 生命周期由 pipeline 管理。 */
225
+ export interface BrowserMarkdownRenderPlugin extends Omit<MarkdownRenderPlugin, "render"> {
226
+ browser: (options?: Record<string, unknown>) => Promise<{ scriptPath: string }>;
227
+ }
@@ -66,6 +66,7 @@ test("deduplicates shared images, rewrites only sources, and exports styles with
66
66
  const configPath = join(root, "config.json");
67
67
  const config = await result(configPath);
68
68
  config.wx.accounts.default.articleTheme = "style.json";
69
+ config.plugins.markdownRenderer = { module: "@akairos/myno/adapter", options: {} };
69
70
  await writeFile(configPath, JSON.stringify(config));
70
71
  let uploads = 0;
71
72
  const command = createTypesetAssetsSyncCommand({ upload: uploader(async () => { uploads++; }), verifyUrl });
@@ -74,10 +75,12 @@ test("deduplicates shared images, rewrites only sources, and exports styles with
74
75
  expect(uploads).toBe(1);
75
76
  expect((await result(join(root, "templates/jinx.json"))).images[0]).toEqual({ ...template.images[0], src: "https://cdn.example/jinx.png" });
76
77
  const patch = await result(join(destination, "pipeline.patch.json"));
78
+ expect(patch.plugins.markdownRenderer).toEqual({ module: "@akairos/myno/adapter", options: {} });
77
79
  expect(patch.wx.accounts.default).toMatchObject({ customCss: null, articleTheme: "/data/pipeline-runtime/markdown/article-themes/default/manifest.json" });
78
80
  expect((await readArticleTheme(join(destination, "markdown/article-themes/default/manifest.json"))).css).toBe(style.css);
79
81
  for (const secret of ["secret-cos-token", "private-secret", "private-app-id", "private-pat"]) expect(JSON.stringify(patch)).not.toContain(secret);
80
82
  expect((await result(join(destination, "manifest.json"))).typesetTemplates).toBe(2);
83
+ expect(await readFile(join(destination, "typeset-studio/templates/.canvas-presets-v1"), "utf8")).toBe("1");
81
84
  expect((await readFile(join(root, "uploads/jinx.png"))).equals(image)).toBe(true);
82
85
  await command(["--output", destination]);
83
86
  expect(uploads).toBe(1);
@@ -1,13 +1,15 @@
1
1
  import type { MonitorProgress } from "./monitor/types";
2
2
 
3
3
  /** 子进程只执行一次插件调用,进度与结果通过 IPC 返回。 */
4
- process.on("message", async (request: { specifier: string; action: "describe" | "doctor" | "render"; input?: Record<string, unknown>; options?: Record<string, unknown> }) => {
4
+ process.on("message", async (request: { specifier: string; action: "describe" | "doctor" | "render" | "studio" | "browser"; input?: Record<string, unknown>; options?: Record<string, unknown> }) => {
5
5
  try {
6
6
  const module = await import(request.specifier);
7
7
  const plugin = module.default ?? module.plugin ?? module;
8
8
  if (!plugin || typeof plugin !== "object" || typeof plugin.name !== "string" || !plugin.name.trim() ||
9
- typeof plugin.render !== "function" || (plugin.doctor !== undefined && typeof plugin.doctor !== "function")) {
10
- throw new Error("插件必须导出 name、render,以及可选的 doctor 函数");
9
+ (typeof plugin.render !== "function" && typeof plugin.browser !== "function") ||
10
+ (plugin.browser !== undefined && typeof plugin.browser !== "function") || (plugin.doctor !== undefined && typeof plugin.doctor !== "function") ||
11
+ (plugin.studio !== undefined && typeof plugin.studio !== "function")) {
12
+ throw new Error("插件必须导出 name、render 或 browser,以及可选的 doctor 函数和 studio 函数");
11
13
  }
12
14
  // 故障插件频繁报告进度时,限制 IPC 消息数量。
13
15
  let lastProgressAt = 0;
@@ -18,9 +20,13 @@ process.on("message", async (request: { specifier: string; action: "describe" |
18
20
  process.send?.({ type: "progress", value: progress });
19
21
  };
20
22
  const value = request.action === "describe"
21
- ? { name: plugin.name, version: plugin.version, hasDoctor: typeof plugin.doctor === "function" }
23
+ ? { name: plugin.name, version: plugin.version, hasDoctor: typeof plugin.doctor === "function", hasStudio: typeof plugin.studio === "function", hasBrowser: typeof plugin.browser === "function" }
22
24
  : request.action === "doctor"
23
25
  ? await plugin.doctor(request.options)
26
+ : request.action === "studio"
27
+ ? await plugin.studio(request.options)
28
+ : request.action === "browser"
29
+ ? await plugin.browser(request.options)
24
30
  : await plugin.render({ ...request.input, onProgress }, request.options);
25
31
  process.send?.({ type: "result", value });
26
32
  } catch (error) {
@@ -115,6 +115,25 @@ test("markdown plugins receive their own options in doctor and render", async ()
115
115
  expect((await plugin.render({ markdownPath: join(directory, "post.md"), outPath: join(directory, "post.html"), account: "default" })).html).toBe(options.html);
116
116
  });
117
117
 
118
+ test("browser-only markdown plugins validate their entry and receive their own options", async () => {
119
+ const pluginPath = join(directory, "browser-plugin.mjs");
120
+ await writeFile(pluginPath, `export default {name:'browser-test',browser(options){return {scriptPath:options.scriptPath}}}`);
121
+ const plugin = await resolveMarkdownRenderer(PipelineConfigSchema.parse({ plugins: { markdownRenderer: { module: pluginPath, options: { scriptPath: "relative.js" } } } }));
122
+ await expect(plugin.render({ markdownPath: "unused.md", outPath: "out.html", account: "default" })).rejects.toMatchObject({ code: "PLUGIN_INVALID_RESULT" });
123
+ expect((await plugin.doctor!()).some(check => check.name === "chrome")).toBe(true);
124
+ });
125
+
126
+ test("Studio capability is optional, isolated and validates asset directory paths", async () => {
127
+ const pluginPath = await fixture("return null", `async studio(options){return {root:${JSON.stringify(directory)},entry:options.entry}},`);
128
+ const config = (entry: string) => PipelineConfigSchema.parse({ plugins: { markdownRenderer: { module: pluginPath, options: { entry } } } });
129
+ const plugin = await resolveMarkdownRenderer(config("studio.html"));
130
+ expect(await plugin.studio!()).toEqual({ root: directory, entry: "studio.html" });
131
+ const invalid = await resolveMarkdownRenderer(config("../outside.html"));
132
+ await expect(invalid.studio!()).rejects.toMatchObject({ code: "PLUGIN_INVALID_RESULT" });
133
+ const plain = await resolveMarkdownRenderer(PipelineConfigSchema.parse({ plugins: { markdownRenderer: await fixture("return null") } }));
134
+ expect(plain.studio).toBeUndefined();
135
+ });
136
+
118
137
  test("reports thrown errors, process exits, and invalid image results without escaping the parent", async () => {
119
138
  for (const [body, code] of [
120
139
  ['throw new Error("bad template")', "PLUGIN_EXECUTION_FAILED"],
@@ -10,7 +10,7 @@ import { resolveBunBinary } from "./spawn";
10
10
  /** 插件错误保留阶段和错误码,终端与调用方使用同一提示。 */
11
11
  export class PluginExecutionError extends Error {
12
12
  constructor(readonly code: string, specifier: string, action: string, detail: string) {
13
- const stage = action === "describe" ? "加载" : action === "doctor" ? "检查" : "渲染";
13
+ const stage = action === "describe" || action === "browser" ? "加载" : action === "doctor" ? "检查" : action === "studio" ? "工作台加载" : "渲染";
14
14
  super(`[${code}] 插件「${specifier}」${stage}失败:${detail}。请检查插件配置、运行 zzp doctor 或切换内置渲染器,修复后再重试;本次未自动重试。`);
15
15
  this.name = "PluginExecutionError";
16
16
  }
@@ -20,6 +20,15 @@ export const PluginMetadataSchema = z.object({
20
20
  name: z.string().trim().min(1),
21
21
  version: z.string().optional(),
22
22
  hasDoctor: z.boolean(),
23
+ hasStudio: z.boolean().default(false),
24
+ hasBrowser: z.boolean().default(false),
25
+ });
26
+ export const PluginBrowserSchema = z.object({
27
+ scriptPath: z.string().refine(isAbsolute, "浏览器入口必须是绝对路径"),
28
+ });
29
+ export const PluginStudioSchema = z.object({
30
+ root: z.string().refine(isAbsolute, "Studio root 必须是绝对路径"),
31
+ entry: z.string().min(1).refine(value => !isAbsolute(value) && !value.split(/[\\/]/).includes(".."), "Studio entry 必须位于资源目录内"),
23
32
  });
24
33
  export const PluginDoctorSchema = z.array(z.object({
25
34
  name: z.string().min(1), ok: z.boolean(), message: z.string().optional(), detail: z.unknown().optional(),
@@ -48,7 +57,7 @@ const ProgressSchema = z.object({
48
57
  /** 外部插件独立执行;父进程可终止同步死循环、异常退出和永不结束的 Promise。 */
49
58
  export async function runExternalPlugin<T>(
50
59
  specifier: string,
51
- action: "describe" | "doctor" | "render",
60
+ action: "describe" | "doctor" | "render" | "studio" | "browser",
52
61
  schema: z.ZodType<T>,
53
62
  input?: Record<string, unknown>,
54
63
  configuration: { options?: Record<string, unknown>; environment?: Record<string, string> } = {},
@@ -61,7 +61,7 @@ export function validateStudioDefaults(text: string, templateIds: Set<string>):
61
61
  }
62
62
 
63
63
  /** 准备账号主题文件,只导出样式白名单,不复制凭据或本机插件入口。 */
64
- export async function prepareMarkdownRuntime(config: PipelineConfig, accountMap: Record<string, string> = {}): Promise<{ files: Map<string, Uint8Array>; accounts: Record<string, unknown> }> {
64
+ export async function prepareMarkdownRuntime(config: PipelineConfig, accountMap: Record<string, string> = {}): Promise<{ files: Map<string, Uint8Array>; accounts: Record<string, unknown>; markdownRenderer: PipelineConfig["plugins"]["markdownRenderer"] }> {
65
65
  const files = new Map<string, Uint8Array>();
66
66
  const accounts: Record<string, unknown> = {};
67
67
  for (const [localAccount, value] of Object.entries(config.wx.accounts)) {
@@ -84,7 +84,12 @@ export async function prepareMarkdownRuntime(config: PipelineConfig, accountMap:
84
84
  }
85
85
  accounts[account] = next;
86
86
  }
87
- return { files, accounts };
87
+ const entry = config.plugins.markdownRenderer;
88
+ const module = typeof entry === "string" ? entry : entry?.module;
89
+ // 部署只接受可复现的 npm 入口,禁止把本机绝对路径带到服务器。
90
+ if (module && module !== "@akairos/myno/adapter") throw new Error("运行时同步仅支持 @akairos/myno/adapter 或内置 Markdown 渲染器");
91
+ if (typeof entry === "object" && entry && Object.keys(entry.options).length) throw new Error("myno 运行时同步暂不接受专属参数");
92
+ return { files, accounts, markdownRenderer: module ? { module, options: {} } : null };
88
93
  }
89
94
 
90
95
  /** 写入带文件哈希的独立配置包,调用者在全部成功后切换目录。 */
@@ -101,8 +106,9 @@ export async function writeRuntimePackage(destination: string, templates: Map<st
101
106
  files.set("typeset-studio/default-template.json", Buffer.from(defaults));
102
107
  // 防止渲染时重新播种内置模板,使部署目录与文件清单保持一致。
103
108
  files.set("typeset-studio/templates/.initialized", Buffer.from("1"));
109
+ files.set("typeset-studio/templates/.canvas-presets-v1", Buffer.from("1"));
104
110
  const patch = {
105
- plugins: { imageRenderer: { module: "@akairos/typeset-studio/adapter", options: { dataDir: `${RUNTIME_ROOT}/typeset-studio` } }, markdownRenderer: null },
111
+ plugins: { imageRenderer: { module: "@akairos/typeset-studio/adapter", options: { dataDir: `${RUNTIME_ROOT}/typeset-studio` } }, markdownRenderer: markdown.markdownRenderer },
106
112
  wx: { accounts: markdown.accounts },
107
113
  };
108
114
  files.set("pipeline.patch.json", Buffer.from(JSON.stringify(patch, null, 2) + "\n"));