@zzclub/pipeline 0.10.0 → 0.10.2

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 (123) hide show
  1. package/README.md +113 -8
  2. package/package.json +4 -4
  3. package/src/adapter-types.ts +21 -0
  4. package/src/adapters/builtin-markdown-renderer.ts +7 -0
  5. package/src/cli.ts +5 -1
  6. package/src/commands/ingest-handoff.ts +1 -21
  7. package/src/commands/prepare-finalize.ts +2 -1
  8. package/src/commands/prepare.ts +30 -19
  9. package/src/commands/publish.ts +2 -3
  10. package/src/commands/reconcile.ts +3 -5
  11. package/src/commands/render.ts +2 -1
  12. package/src/commands/republish.ts +2 -3
  13. package/src/commands/reset.ts +9 -1
  14. package/src/commands/review.ts +3 -2
  15. package/src/commands/sync-blog.ts +2 -1
  16. package/src/commands/wechat-export.test.ts +41 -0
  17. package/src/commands/wechat-export.ts +156 -20
  18. package/src/commands/wechat-preview.ts +160 -0
  19. package/src/config.ts +12 -0
  20. package/src/imgx/runtime.ts +31 -3
  21. package/src/logger.test.ts +56 -0
  22. package/src/logger.ts +178 -0
  23. package/src/plugins.ts +2 -0
  24. package/src/providers/blog.ts +1 -2
  25. package/src/providers/index.ts +35 -3
  26. package/src/publish-targets.ts +4 -5
  27. package/src/routes.ts +9 -1
  28. package/src/schema/config.ts +2 -0
  29. package/src/state.ts +24 -0
  30. package/src/task-manager.test.ts +57 -0
  31. package/src/task-manager.ts +23 -5
  32. package/src/wechat-preview/assets/browser-dist/.vite/manifest.json +1 -302
  33. package/src/wechat-preview/assets/browser-dist/editor-export.js +500 -958
  34. package/src/wechat-preview/browser/assets.d.ts +9 -0
  35. package/src/wechat-preview/browser/editor-export.css +5 -326
  36. package/src/wechat-preview/browser/editor-export.ts +30 -65
  37. package/src/wechat-preview/bundle-stale.test.ts +52 -0
  38. package/src/wechat-preview/fixtures/all-nodes.md +68 -0
  39. package/src/wechat-preview/index.test.ts +17 -0
  40. package/src/wechat-preview/index.ts +424 -180
  41. package/src/wechat-preview/server/client.ts +164 -0
  42. package/src/wechat-preview/server/http.ts +229 -0
  43. package/src/wechat-preview/server/index.ts +33 -0
  44. package/src/wechat-preview/server/local-file.test.ts +52 -0
  45. package/src/wechat-preview/server/local-file.ts +116 -0
  46. package/src/wechat-preview/server/paths.ts +80 -0
  47. package/src/wechat-preview/server/registry.test.ts +60 -0
  48. package/src/wechat-preview/server/registry.ts +118 -0
  49. package/src/wechat-preview/server/singleton.ts +118 -0
  50. package/src/wechat-preview/server/types.ts +61 -0
  51. package/src/wechat-preview/server/ui.ts +324 -0
  52. package/src/wechat-preview/themes.ts +58 -45
  53. package/src/wechat-preview/wechat-export.integration.test.ts +94 -0
  54. package/src/wechat-preview/wechat-renderer.links.test.ts +44 -0
  55. package/src/wechat-preview/wechat-renderer.test.ts +50 -0
  56. package/src/wechat-preview/wechat-renderer.ts +698 -0
  57. package/src/wechat-preview.test.ts +25 -41
  58. package/src/workflow-materials.test.ts +52 -1
  59. package/src/workflow-materials.ts +12 -0
  60. package/src/workflow.test.ts +105 -5
  61. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  62. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  63. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  64. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  65. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  66. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  67. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  68. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  69. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  70. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  71. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  72. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  73. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  74. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  75. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  76. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  77. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  78. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  79. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  80. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  81. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  82. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  83. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  84. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  85. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  86. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  87. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  88. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  89. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  90. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  91. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  92. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  93. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  94. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  95. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  96. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  97. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  98. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  99. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  100. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  101. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  102. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  103. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  104. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  105. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  106. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  107. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  108. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  109. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  110. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  111. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  112. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  113. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  114. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  115. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  116. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  117. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  118. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  119. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  120. package/src/wechat-preview/assets/browser-dist/assets/style-vhHYSP8T.css +0 -1
  121. package/src/wechat-preview/assets/fonts/SweiCurveLegCJKsc-Regular.woff +0 -0
  122. package/src/wechat-preview/wechat-formatter.test.ts +0 -287
  123. package/src/wechat-preview/wechat-formatter.ts +0 -1531
package/README.md CHANGED
@@ -38,12 +38,13 @@ bun install --global .
38
38
 
39
39
  这是一个状态机模型,输入是任务意图、正文、图片、路由和发布要求,输出是可恢复的工作流状态、渲染产物和发布结果。
40
40
 
41
- 当前只有两条主工作流路由:
41
+ 当前有三条主工作流路由:
42
42
 
43
43
  - `wechat-article` — 公众号文章草稿(HTML)
44
44
  - `wechat-newspic` — 公众号图片消息(PNG 卡片/图集)
45
+ - `blog` — 将 Markdown 同步到配置的博客项目并执行发布命令
45
46
 
46
- `blog` 只做事后同步(`sync-blog`),不参与主工作流分支,但同步结果会写回 `workflow-state.json` 的 `publish.results`。
47
+ `blog` 参与统一的 `prepare → publish` 工作流;作为唯一目标时跳过 render。`sync-blog` 继续保留为事后同步命令,两种路径都会把结果写回 `workflow-state.json` 的 `publish.results`。
47
48
 
48
49
  状态文件分两类:
49
50
 
@@ -374,7 +375,7 @@ src/
374
375
  wechat-preview/ # 微信文章 HTML 预览,Milkdown + 主题
375
376
  index.ts
376
377
  themes.ts
377
- wechat-formatter.ts
378
+ wechat-renderer.ts # 元素 renderer 注册表、CSS 内联和微信安全清理
378
379
  frontmatter-handler.ts
379
380
  browser/
380
381
  editor-export.ts
@@ -409,13 +410,15 @@ src/
409
410
  | `review` | Update content review status |
410
411
  | `abandon` | Mark one or more tasks as abandoned |
411
412
 
412
- `ops` 组,11 个命令:
413
+ `ops` 组,12 个命令:
413
414
 
414
415
  | 命令 | 说明 |
415
416
  | --- | --- |
416
417
  | `sync-blog` | Copy post.md to blog repo, publish, and record result in state JSON |
418
+ | `republish` | Publish a completed task to an additional account or platform |
417
419
  | `imgx` | Run bundled imgx renderer subcommands |
418
420
  | `wechat-export` | Render markdown to WeChat HTML with bundled preview styles |
421
+ | `wechat-preview` | Local singleton server to preview exported WeChat HTML |
419
422
  | `cos-upload` | Upload a local image to configured COS CDN |
420
423
  | `config` | Read or update pipeline config |
421
424
  | `doctor` | Inspect resolved paths and provider health |
@@ -618,9 +621,98 @@ bun run src/cli.ts hermes-metrics --workspace /abs/workspace
618
621
  ### 微信 HTML 预览导出
619
622
 
620
623
  ```bash
621
- bun run src/cli.ts wechat-export --body /abs/path/body.md --account default
624
+ bun run src/cli.ts wechat-export \
625
+ --markdown /abs/path/body.md \
626
+ --out /abs/path/article.html \
627
+ --account default \
628
+ --preview-shell-out /abs/path/article-preview.html
622
629
  ```
623
630
 
631
+ `--preview-shell-out` 生成最终发布 HTML 的精确预览:预览页直接嵌入 `--out`
632
+ 中的内联样式片段,不再加载 Milkdown 编辑器或复制浏览器 bundle。
633
+
634
+ 默认会将导出结果登记到本地 **单例预览服务**(`http://127.0.0.1:18765`),可在浏览器
635
+ 侧栏切换多篇产物,无需发到微信草稿箱。服务未启动时会自动拉起;失败导出也会登记并展示
636
+ `error_kind` / timeout / Chrome stderr 等调试信息。
637
+
638
+ ```bash
639
+ # 显式启动预览服务(全局只允许一个实例;重复启动会复用)
640
+ bun run src/cli.ts wechat-preview serve --open
641
+
642
+ # 导出并打开该条目
643
+ bun run src/cli.ts wechat-export \
644
+ --markdown /abs/path/body.md \
645
+ --out /tmp/article.html \
646
+ --account default \
647
+ --open
648
+
649
+ # 调试中间产物
650
+ bun run src/cli.ts wechat-export \
651
+ --markdown /abs/path/body.md \
652
+ --out /tmp/article.html \
653
+ --debug-dir /tmp/wx-debug \
654
+ --timeout-ms 20000
655
+
656
+ # 关闭预览登记
657
+ bun run src/cli.ts wechat-export ... --no-preview
658
+ ```
659
+
660
+ | 子命令 | 说明 |
661
+ | --- | --- |
662
+ | `wechat-preview serve` | 启动/复用单例服务 |
663
+ | `wechat-preview status` | 是否在运行 |
664
+ | `wechat-preview open` | 打开 dashboard |
665
+ | `wechat-preview list` | 列出条目 |
666
+ | `wechat-preview clear` | 清空条目 |
667
+
668
+ 环境变量:`ZZHUB_WECHAT_PREVIEW_PORT`(默认 18765)、`ZZHUB_WECHAT_PREVIEW_HOST`、
669
+ `ZZHUB_WECHAT_PREVIEW_DIR`(锁与条目落盘目录)。发布草稿时默认也会登记一条
670
+ `[publish] ...` 预览;设 `ZZHUB_WECHAT_PREVIEW_ON_PUBLISH=0` 可关闭。
671
+
672
+ 源码(CSS/browser 入口)新于 dist 时会自动尝试 rebuild;失败则回退已有 bundle 并标记
673
+ `bundle_stale`。手动重建:`bun run build:wechat-preview`。
674
+
675
+ #### 自定义 CSS
676
+
677
+ 可以通过账号配置或 CLI 覆盖内置样式:
678
+
679
+ ```json
680
+ {
681
+ "wx": {
682
+ "accounts": {
683
+ "default": {
684
+ "customCss": "./themes/default-wechat.css"
685
+ }
686
+ }
687
+ }
688
+ }
689
+ ```
690
+
691
+ ```bash
692
+ bun run src/cli.ts wechat-export \
693
+ --markdown /abs/path/body.md \
694
+ --out /abs/path/article.html \
695
+ --custom-css ./local-wechat.css
696
+ ```
697
+
698
+ 样式优先级固定为:内置排版 CSS → 账号主题令牌 → 账号 `customCss`。传入
699
+ `--custom-css` 时,它会替代账号 `customCss`。CLI 相对路径按当前工作目录解析,
700
+ 配置中的相对路径按 `config.json` 所在目录解析。
701
+
702
+ 推荐使用语义标签或稳定节点钩子:
703
+
704
+ ```css
705
+ .milkdown .editor p { line-height: 1.9; }
706
+ .milkdown .editor h2 { color: #a94473; }
707
+ .milkdown .editor [data-wechat-node="inline-code"] { background: #f7eef2; }
708
+ .milkdown .editor [data-wechat-node="image-caption"] { font-size: 13px; }
709
+ ```
710
+
711
+ `.milkdown .editor` 只是 CSS 内联阶段的兼容包装;最终 HTML 会移除 `class` 和
712
+ `data-wechat-node`。外部 CSS 支持变量、层叠和常规选择器,随后由 Juice 解析为
713
+ 内联样式。最终片段只保留微信稳定标签、图片/链接/表格必要属性和保守 CSS;
714
+ 脚本、事件属性、外部 `<style>`、未解析变量、`flex`/`grid`、定位和伪元素都会被移除。
715
+
624
716
  ### COS 图片上传
625
717
 
626
718
  ```bash
@@ -796,10 +888,23 @@ bun run src/cli.ts init ... --note-id NOTE_ID
796
888
 
797
889
  ## wechat-preview 子系统
798
890
 
799
- - 基于 Milkdown markdown 到微信 HTML 转换
800
- - 支持多主题
891
+ - 基于 Milkdown core + CommonMark/GFM 生成语义 HTML,不读取 Crepe/CodeMirror 可视 DOM
892
+ - 固定管线:Markdown → Milkdown HTML → 元素 renderer prepare → Juice 内联 → renderer finalize → 微信白名单清理
893
+ - 元素 renderer 覆盖段落/标题、强调、行内代码、代码块、引用、图片标题、链接脚注、列表、表格、分隔线和页脚
894
+ - 内置默认采用“静默编辑”:深色标题、低装饰引用和中性行内代码,品牌色只用于链接等识别点
895
+ - `default` 与 `ancientone` 共用排版骨架,只通过主题令牌切换品牌色、字体栈和页脚
801
896
  - 构建命令:`bun run build:wechat-preview`
802
- - `wechat-export` 会直接使用这套预览样式
897
+ - `wechat-export` 和微信草稿发布使用同一份最终内联 HTML
898
+
899
+ `@zzclub/milkdown-article-style`(`article.css`)会通过 Vite 以 raw CSS 打入浏览器
900
+ bundle。修改该 CSS 或浏览器 renderer 后必须执行:
901
+
902
+ ```bash
903
+ bun run build:wechat-preview
904
+ bun install --global .
905
+ ```
906
+
907
+ 全局 `zzp` 使用编译产物;只修改源码而不重新全局安装不会更新全局命令。
803
908
 
804
909
  ## 插件系统
805
910
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zzclub/pipeline",
3
- "version": "0.10.0",
3
+ "version": "0.10.2",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "zzp": "./src/cli.ts",
@@ -25,6 +25,7 @@
25
25
  },
26
26
  "dependencies": {
27
27
  "@chenglou/pretext": "^0.0.5",
28
+ "@zzclub/milkdown-article-style": "0.1.1",
28
29
  "image-size": "^2.0.2",
29
30
  "pngjs": "^7.0.0",
30
31
  "zod": "^4.4.2"
@@ -34,12 +35,11 @@
34
35
  "cos-nodejs-sdk-v5": "^2.15.4"
35
36
  },
36
37
  "devDependencies": {
37
- "@milkdown/crepe": "^7.18.0",
38
- "@milkdown/plugin-listener": "^7.18.0",
39
- "@milkdown/utils": "^7.18.0",
38
+ "@milkdown/kit": "^7.18.0",
40
39
  "@types/bun": "latest",
41
40
  "@types/pngjs": "^6.0.5",
42
41
  "changelogen": "^0.6.2",
42
+ "juice": "11.1.1",
43
43
  "typescript": "^5.7.0",
44
44
  "vite": "^7.1.10"
45
45
  }
@@ -143,6 +143,12 @@ export interface MarkdownRenderInput {
143
143
  editorVars?: Record<string, string>;
144
144
  exportTheme?: Record<string, string>;
145
145
  };
146
+
147
+ /** Chrome virtual-time-budget in ms */
148
+ timeoutMs?: number;
149
+
150
+ /** Write intermediate artifacts for debugging */
151
+ debugDir?: string;
146
152
  }
147
153
 
148
154
  /**
@@ -164,6 +170,21 @@ export interface MarkdownRenderOutput {
164
170
 
165
171
  /** Path to preview shell HTML (if generated) */
166
172
  previewShellPath?: string;
173
+
174
+ /** Wall-clock render duration */
175
+ durationMs?: number;
176
+
177
+ /** Whether the browser bundle was rebuilt during this render */
178
+ bundleRebuilt?: boolean;
179
+
180
+ /** Whether the browser bundle was stale before render */
181
+ bundleStale?: boolean;
182
+
183
+ /** Debug directory if requested */
184
+ debugDir?: string;
185
+
186
+ /** Semantic HTML from Milkdown before WeChat finalize (when available) */
187
+ semanticHtml?: string;
167
188
  }
168
189
 
169
190
  export interface MarkdownRenderPlugin {
@@ -51,6 +51,8 @@ export const builtinMarkdownRenderer: MarkdownRenderPlugin = {
51
51
  previewShellOutPath: input.previewShellOutPath,
52
52
  customCss: input.customCss,
53
53
  themeOverrides: input.themeOverrides,
54
+ timeoutMs: input.timeoutMs,
55
+ debugDir: input.debugDir ?? undefined,
54
56
  });
55
57
 
56
58
  return {
@@ -59,6 +61,11 @@ export const builtinMarkdownRenderer: MarkdownRenderPlugin = {
59
61
  account: result.account,
60
62
  previewStyle: result.previewStyle,
61
63
  previewShellPath: result.previewShellPath,
64
+ durationMs: result.durationMs,
65
+ bundleRebuilt: result.bundleRebuilt,
66
+ bundleStale: result.bundleStale,
67
+ debugDir: result.debugDir,
68
+ semanticHtml: result.semanticHtml,
62
69
  };
63
70
  },
64
71
  };
package/src/cli.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  import { readFileSync } from "fs";
4
4
  import { dirname, join } from "path";
5
5
  import { fileURLToPath } from "url";
6
+ import { getDailyLogPath, runWithCommandLog } from "./logger";
6
7
  import { formatUsage, getCommandRegistry } from "./plugins";
7
8
 
8
9
  const COMMANDS = getCommandRegistry();
@@ -36,10 +37,13 @@ async function main() {
36
37
  }
37
38
 
38
39
  try {
39
- await command.handler(args.slice(1));
40
+ await runWithCommandLog(cmd, args.slice(1), async () => {
41
+ await command.handler(args.slice(1));
42
+ });
40
43
  } catch (err) {
41
44
  const msg = err instanceof Error ? err.message : String(err);
42
45
  console.error(`[zzhub-pipeline ${cmd}] Error: ${msg}`);
46
+ console.error(`[zzhub-pipeline ${cmd}] Full log: ${getDailyLogPath()}`);
43
47
  process.exit(1);
44
48
  }
45
49
  }
@@ -6,16 +6,12 @@ import { loadConfig, resolveWorkspacePaths, resolveWorkspaceRoot } from "../conf
6
6
  import { printResult } from "../output";
7
7
  import { resolveFullRoute } from "../routes";
8
8
  import {
9
- defaultArtifacts,
10
9
  defaultContentReview,
11
- defaultImages,
12
- defaultMetadata,
13
- defaultPhase,
14
- defaultPublish,
15
10
  defaultState,
16
11
  generateRunId,
17
12
  getRunStatePath,
18
13
  readResolvedState,
14
+ resetDerivedState,
19
15
  writeState,
20
16
  type ContentForm,
21
17
  type HandoffAuthoringPolicy,
@@ -302,22 +298,6 @@ function routesEqual(a: WorkflowState["route"], b: WorkflowState["route"]): bool
302
298
  );
303
299
  }
304
300
 
305
- function resetDerivedState(state: WorkflowState): void {
306
- state.mode = "active";
307
- state.phase = defaultPhase();
308
- state.asset_path = "";
309
- state.formatted_body_path = null;
310
- state.metadata = {
311
- ...defaultMetadata(),
312
- title: state.metadata.title,
313
- };
314
- state.artifacts = defaultArtifacts();
315
- state.images = defaultImages();
316
- state.publish = defaultPublish();
317
- state.content_review = defaultContentReview();
318
- state.redo_hint = null;
319
- }
320
-
321
301
  async function resolveExistingStatePath(
322
302
  workspaceRoot: string,
323
303
  handoff: ResolvedWorkflowHandoff,
@@ -31,6 +31,7 @@ import {
31
31
  writeState,
32
32
  getCanonicalStatePath,
33
33
  } from "../state";
34
+ import { loadTaskState } from "../task-manager";
34
35
  import {
35
36
  extractHighlightWords,
36
37
  buildFrontmatter,
@@ -195,7 +196,7 @@ Options:
195
196
  const initialResolved = await readResolvedState(requestedStatePath);
196
197
  const releaseOperationLock = await acquireStateOperationLock(initialResolved.path);
197
198
  try {
198
- const resolved = await readResolvedState(initialResolved.path);
199
+ const resolved = await loadTaskState(initialResolved.path);
199
200
  const statePath = resolved.path;
200
201
  const state = resolved.state;
201
202
  if (state.content_review.status !== "passed") {
@@ -31,10 +31,10 @@ import { printResult, renderPrepare } from "../output";
31
31
  import { resolveWorkspacePaths } from "../config";
32
32
  import {
33
33
  defaultBodyInputs,
34
- readResolvedState,
35
34
  reenterPrepare,
36
35
  writeState,
37
36
  } from "../state";
37
+ import { loadTaskState } from "../task-manager";
38
38
  import { resolveFullRoute } from "../routes";
39
39
  import { resolveAuthoring, hasStyleRequest } from "../profiles";
40
40
  import { parseAccountName, parseRoutePrimary } from "../publish-targets";
@@ -69,15 +69,16 @@ function getManagedFormattedBodyPath(workspaceRoot: string, runId: string): stri
69
69
  }
70
70
 
71
71
  function shouldPreserveExistingRoute(params: {
72
- intentText: string;
72
+ hasExplicitPublishTargets: boolean;
73
+ hasIntentOverride: boolean;
73
74
  routeOverride?: RoutePrimary;
74
75
  extrasRaw?: string;
75
76
  accountOverride?: string;
76
77
  assetPath: string;
77
78
  }): boolean {
78
79
  return (
79
- Boolean(params.assetPath) &&
80
- !params.intentText.trim() &&
80
+ (params.hasExplicitPublishTargets || Boolean(params.assetPath)) &&
81
+ !params.hasIntentOverride &&
81
82
  !params.routeOverride &&
82
83
  !params.extrasRaw &&
83
84
  !params.accountOverride
@@ -130,10 +131,11 @@ Options:
130
131
  : undefined;
131
132
 
132
133
  // Read state and body
133
- const resolved = await readResolvedState(requestedStatePath);
134
+ const resolved = await loadTaskState(requestedStatePath);
134
135
  const statePath = resolved.path;
135
136
  const state = resolved.state;
136
- const intentText = optionalArg(parsed, "intent-text") ?? state.intent.intent_text ?? "";
137
+ const intentTextOverride = optionalArg(parsed, "intent-text");
138
+ const intentText = intentTextOverride ?? state.intent.intent_text ?? "";
137
139
  const resolvedBodyPath = bodyPath
138
140
  ? await stageManagedBodyFile(state.workspace_root, state.run_id, bodyPath)
139
141
  : state.source_body_path;
@@ -142,9 +144,18 @@ Options:
142
144
  }
143
145
  const bodyRaw = await readFile(resolvedBodyPath, "utf-8");
144
146
  const cleanBody = stripFrontmatter(bodyRaw);
147
+ const incomingBody = Boolean(bodyPath);
148
+ // A newly supplied body satisfies a pending writer/style revision. Clearing
149
+ // these hints prevents status from looping back to revise-content after the
150
+ // body has already been handed in via prepare --body. (format/asset-meta/
151
+ // channel-route hints are cleared below after prepare runs their steps.)
152
+ const effectiveRedoHint = incomingBody &&
153
+ (state.redo_hint === "writer" || state.redo_hint === "style")
154
+ ? null
155
+ : state.redo_hint;
145
156
  reenterPrepare(state, {
146
- redoHint: state.redo_hint,
147
- resetReview: Boolean(bodyPath),
157
+ redoHint: effectiveRedoHint,
158
+ resetReview: incomingBody,
148
159
  });
149
160
  if (bodyPath && state.handoff.review_policy === "trust_user") {
150
161
  state.content_review = { status: "passed", feedback: null };
@@ -154,7 +165,8 @@ Options:
154
165
 
155
166
  // ── Step 1: Channel route (1st pass) ──
156
167
  const route = shouldPreserveExistingRoute({
157
- intentText,
168
+ hasExplicitPublishTargets: state.publish_targets.length > 0,
169
+ hasIntentOverride: intentTextOverride !== undefined,
158
170
  routeOverride,
159
171
  extrasRaw,
160
172
  accountOverride,
@@ -177,16 +189,15 @@ Options:
177
189
  state.route.highlight_words = highlightWordsOverride;
178
190
  }
179
191
 
180
- // Re-derive requires.render and requires.publish based on resolved route.
181
- // requires.research is intentional (set at init from user request), not touched here.
182
- // Same for render/publish: if the orchestrator set them at init, keep that value.
183
- // wechat-article always needs render regardless.
184
- state.intent.requires.render =
185
- state.intent.requires.render ||
186
- route.primary === "wechat-article";
187
- state.intent.requires.publish =
188
- state.intent.requires.publish ||
189
- state.intent.task_kind === "publish";
192
+ // Re-derive requires.render and requires.publish deterministically from the
193
+ // resolved route/task kind rather than OR-accumulating: a flag set true for a
194
+ // previous route must not linger after the route changes (e.g. switching a
195
+ // task to blog-only would otherwise keep requires.render=true forever).
196
+ // requires.research is intentional (set at init from the user request) and is
197
+ // intentionally not recomputed here.
198
+ const allRoutes = [route.primary, ...route.extras];
199
+ state.intent.requires.render = allRoutes.some((r) => r !== "blog");
200
+ state.intent.requires.publish = state.intent.task_kind === "publish";
190
201
 
191
202
  // ── Step 2: Author select ──
192
203
  const styleRequest =
@@ -33,7 +33,7 @@ import {
33
33
  executePublishTargets,
34
34
  upsertPublishResult,
35
35
  } from "../providers/publish-core";
36
- import { reconcileStateArtifacts } from "../workflow-materials";
36
+ import { loadTaskState } from "../task-manager";
37
37
 
38
38
  export async function publish(args: string[]): Promise<void> {
39
39
  const parsed = parseArgs(args);
@@ -60,10 +60,9 @@ Options:
60
60
  const initialResolved = await readResolvedState(requestedStatePath);
61
61
  const releaseOperationLock = await acquireStateOperationLock(initialResolved.path);
62
62
  try {
63
- const resolved = await readResolvedState(initialResolved.path);
63
+ const resolved = await loadTaskState(initialResolved.path);
64
64
  const statePath = resolved.path;
65
65
  const state = resolved.state;
66
- await reconcileStateArtifacts(state);
67
66
  const config = loadConfig();
68
67
  const workspacePaths = resolveWorkspacePaths(state.workspace_root, config);
69
68
 
@@ -1,8 +1,7 @@
1
1
  import { parseArgs, requireArg } from "../args";
2
2
  import { printResult, renderTaskShape } from "../output";
3
- import { readResolvedState, writeState } from "../state";
4
- import { getTaskByStatePath } from "../task-manager";
5
- import { reconcileStateArtifacts } from "../workflow-materials";
3
+ import { writeState } from "../state";
4
+ import { getTaskByStatePath, loadTaskState } from "../task-manager";
6
5
 
7
6
  export async function reconcile(args: string[]): Promise<void> {
8
7
  const parsed = parseArgs(args);
@@ -18,10 +17,9 @@ Options:
18
17
  }
19
18
 
20
19
  const requestedStatePath = requireArg(parsed, "state", "state JSON path");
21
- const resolved = await readResolvedState(requestedStatePath);
20
+ const resolved = await loadTaskState(requestedStatePath);
22
21
  const statePath = resolved.path;
23
22
  const state = resolved.state;
24
- await reconcileStateArtifacts(state);
25
23
  await writeState(statePath, state);
26
24
 
27
25
  const task = await getTaskByStatePath(statePath);
@@ -31,6 +31,7 @@ import {
31
31
  type NewspicRenderSpec,
32
32
  type WorkflowState,
33
33
  } from "../state";
34
+ import { loadTaskState } from "../task-manager";
34
35
  import {
35
36
  stripFrontmatter,
36
37
  generateCoverTitle,
@@ -66,7 +67,7 @@ Options:
66
67
  const initialResolved = await readResolvedState(requestedStatePath);
67
68
  const releaseOperationLock = await acquireStateOperationLock(initialResolved.path);
68
69
  try {
69
- const resolved = await readResolvedState(initialResolved.path);
70
+ const resolved = await loadTaskState(initialResolved.path);
70
71
  const statePath = resolved.path;
71
72
  const state = resolved.state;
72
73
 
@@ -35,7 +35,7 @@ import {
35
35
  executePublishTargets,
36
36
  upsertPublishResult,
37
37
  } from "../providers/publish-core";
38
- import { reconcileStateArtifacts } from "../workflow-materials";
38
+ import { loadTaskState } from "../task-manager";
39
39
 
40
40
  export async function republish(args: string[]): Promise<void> {
41
41
  const parsed = parseArgs(args);
@@ -75,10 +75,9 @@ Examples:
75
75
  const initialResolved = await readResolvedState(requestedStatePath);
76
76
  const releaseOperationLock = await acquireStateOperationLock(initialResolved.path);
77
77
  try {
78
- const resolved = await readResolvedState(initialResolved.path);
78
+ const resolved = await loadTaskState(initialResolved.path);
79
79
  const statePath = resolved.path;
80
80
  const state = resolved.state;
81
- await reconcileStateArtifacts(state);
82
81
  const config = loadConfig();
83
82
  const workspacePaths = resolveWorkspacePaths(state.workspace_root, config);
84
83
 
@@ -22,6 +22,7 @@
22
22
  import { parseArgs, requireArg } from "../args";
23
23
  import { printResult, renderReset } from "../output";
24
24
  import {
25
+ acquireStateOperationLock,
25
26
  defaultContentReview,
26
27
  readResolvedState,
27
28
  reenterPrepare,
@@ -29,6 +30,7 @@ import {
29
30
  reenterRender,
30
31
  writeState,
31
32
  } from "../state";
33
+ import { loadTaskState } from "../task-manager";
32
34
 
33
35
  type ResetMode =
34
36
  | "content"
@@ -84,7 +86,10 @@ Modes:
84
86
  );
85
87
  }
86
88
 
87
- const resolved = await readResolvedState(requestedStatePath);
89
+ const initialResolved = await readResolvedState(requestedStatePath);
90
+ const releaseOperationLock = await acquireStateOperationLock(initialResolved.path);
91
+ try {
92
+ const resolved = await loadTaskState(initialResolved.path);
88
93
  const statePath = resolved.path;
89
94
  const state = resolved.state;
90
95
 
@@ -149,4 +154,7 @@ Modes:
149
154
  };
150
155
 
151
156
  printResult(output, renderReset);
157
+ } finally {
158
+ await releaseOperationLock();
159
+ }
152
160
  }
@@ -17,7 +17,8 @@
17
17
 
18
18
  import { parseArgs, requireArg, optionalArg } from "../args";
19
19
  import { printResult, renderReview } from "../output";
20
- import { readResolvedState, writeState, type ContentReviewStatus } from "../state";
20
+ import { writeState, type ContentReviewStatus } from "../state";
21
+ import { loadTaskState } from "../task-manager";
21
22
 
22
23
  const VALID_STATUSES: ContentReviewStatus[] = [
23
24
  "passed",
@@ -59,7 +60,7 @@ Statuses:
59
60
  );
60
61
  }
61
62
 
62
- const resolved = await readResolvedState(requestedStatePath);
63
+ const resolved = await loadTaskState(requestedStatePath);
63
64
  const statePath = resolved.path;
64
65
  const state = resolved.state;
65
66
 
@@ -6,6 +6,7 @@ import {
6
6
  readResolvedState,
7
7
  updateState,
8
8
  } from "../state";
9
+ import { loadTaskState } from "../task-manager";
9
10
  import { publishBlogRoute } from "../providers/blog";
10
11
 
11
12
  export async function syncBlog(args: string[]): Promise<void> {
@@ -31,7 +32,7 @@ Options:
31
32
  const initialResolved = await readResolvedState(requestedStatePath);
32
33
  const releaseOperationLock = await acquireStateOperationLock(initialResolved.path);
33
34
  try {
34
- const resolved = await readResolvedState(initialResolved.path);
35
+ const resolved = await loadTaskState(initialResolved.path);
35
36
  const statePath = resolved.path;
36
37
  const state = resolved.state;
37
38
 
@@ -0,0 +1,41 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { resolveConfigRelativePath } from "../config";
3
+ import { resolveWechatExportCustomCss } from "./wechat-export";
4
+
5
+ describe("wechat-export custom CSS path precedence", () => {
6
+ const configPath = "/workspace/config/config.json";
7
+
8
+ test("resolves configured CSS relative to the config file", () => {
9
+ expect(resolveConfigRelativePath("themes/account.css", configPath)).toBe(
10
+ "/workspace/config/themes/account.css",
11
+ );
12
+ });
13
+
14
+ test("keeps an absolute configured CSS path", () => {
15
+ expect(resolveConfigRelativePath("/opt/zzhub/account.css", configPath)).toBe(
16
+ "/opt/zzhub/account.css",
17
+ );
18
+ });
19
+
20
+ test("uses account CSS when the CLI override is absent", () => {
21
+ expect(resolveWechatExportCustomCss(
22
+ undefined,
23
+ "themes/account.css",
24
+ "/workspace/post",
25
+ configPath,
26
+ )).toBe("/workspace/config/themes/account.css");
27
+ });
28
+
29
+ test("CLI CSS replaces account CSS and resolves from the current directory", () => {
30
+ expect(resolveWechatExportCustomCss(
31
+ "./local.css",
32
+ "themes/account.css",
33
+ "/workspace/post",
34
+ configPath,
35
+ )).toBe("/workspace/post/local.css");
36
+ });
37
+
38
+ test("returns null when neither source configures CSS", () => {
39
+ expect(resolveWechatExportCustomCss(undefined, null, "/workspace/post", configPath)).toBeNull();
40
+ });
41
+ });