@zzclub/pipeline 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (194) hide show
  1. package/README.md +366 -170
  2. package/package.json +4 -7
  3. package/src/adapter-loader.ts +138 -0
  4. package/src/adapter-types.ts +172 -0
  5. package/src/adapters/builtin-image-renderer.ts +353 -0
  6. package/src/adapters/builtin-markdown-renderer.ts +63 -0
  7. package/src/adapters/index.ts +2 -0
  8. package/src/args.test.ts +118 -0
  9. package/src/args.ts +90 -0
  10. package/src/cli.ts +51 -0
  11. package/src/commands/abandon.test.ts +104 -0
  12. package/src/commands/abandon.ts +202 -0
  13. package/src/commands/attach-body-images.ts +88 -0
  14. package/src/commands/attach-body.ts +86 -0
  15. package/src/commands/attach-newspic-spec.ts +41 -0
  16. package/src/commands/checkpoint.test.ts +91 -0
  17. package/src/commands/checkpoint.ts +66 -0
  18. package/src/commands/config.ts +106 -0
  19. package/src/commands/cos-upload.ts +62 -0
  20. package/src/commands/doctor.ts +66 -0
  21. package/src/commands/find-run.ts +55 -0
  22. package/src/commands/hermes-metrics.ts +226 -0
  23. package/src/commands/imgx.ts +60 -0
  24. package/src/commands/ingest-handoff.ts +509 -0
  25. package/src/commands/init.ts +126 -0
  26. package/src/commands/prepare-finalize.ts +348 -0
  27. package/src/commands/prepare.ts +310 -0
  28. package/src/commands/publish.ts +147 -0
  29. package/src/commands/reconcile.ts +36 -0
  30. package/src/commands/render.ts +244 -0
  31. package/src/commands/reset.test.ts +151 -0
  32. package/src/commands/reset.ts +159 -0
  33. package/src/commands/review.ts +77 -0
  34. package/src/commands/status.ts +68 -0
  35. package/src/commands/sync-blog.ts +39 -0
  36. package/src/commands/tasks.ts +50 -0
  37. package/src/commands/wechat-export.ts +68 -0
  38. package/src/config.ts +0 -0
  39. package/src/imgx/ascii-portrait.ts +77 -0
  40. package/src/imgx/assets/fonts/AlimamaShuHeiTi-Bold.ttf +0 -0
  41. package/src/imgx/assets/fonts/LXGWNeoZhiSongPlus.ttf +0 -0
  42. package/src/imgx/assets/fonts/LXGWWenKai-Regular.ttf +0 -0
  43. package/src/imgx/assets/icons/logo.png +0 -0
  44. package/src/imgx/assets/icons/logo.svg +8 -0
  45. package/{dist/assets → src}/imgx/assets/templates/longform-3-4.html +1 -1
  46. package/{dist/assets → src}/imgx/assets/templates/poster-3-4.html +69 -30
  47. package/{dist/assets → src}/imgx/assets/templates/wechat-cover-split.html +58 -27
  48. package/src/imgx/cli.ts +57 -0
  49. package/src/imgx/geometry.ts +68 -0
  50. package/src/imgx/index.ts +5 -0
  51. package/src/imgx/longform-theme.ts +243 -0
  52. package/src/imgx/poster-recipe.ts +81 -0
  53. package/src/imgx/pretext-adapter.ts +14 -0
  54. package/src/imgx/pretext-runtime.ts +61 -0
  55. package/src/imgx/references/ascii-portrait-3-4.md +59 -0
  56. package/src/imgx/references/longform-3-4.md +173 -0
  57. package/src/imgx/references/poster-3-4.md +87 -0
  58. package/src/imgx/references/tips-3-4.md +69 -0
  59. package/src/imgx/references/wechat-cover-split.md +89 -0
  60. package/src/imgx/references/x-like-posts.md +139 -0
  61. package/src/imgx/render-article.test.ts +53 -0
  62. package/src/imgx/render-article.ts +1364 -0
  63. package/src/imgx/render-ascii-portrait.ts +53 -0
  64. package/src/imgx/render-card.ts +232 -0
  65. package/src/imgx/render-x-like-posts.ts +235 -0
  66. package/src/imgx/runtime.ts +403 -0
  67. package/src/imgx/typographic-scale.test.ts +56 -0
  68. package/src/imgx/typographic-scale.ts +44 -0
  69. package/src/output.ts +632 -0
  70. package/src/plugins.ts +101 -0
  71. package/src/profiles.ts +149 -0
  72. package/src/providers/blog.test.ts +58 -0
  73. package/src/providers/blog.ts +154 -0
  74. package/src/providers/cos.ts +183 -0
  75. package/src/providers/index.test.ts +26 -0
  76. package/src/providers/index.ts +185 -0
  77. package/src/providers/wechat.test.ts +215 -0
  78. package/src/providers/wechat.ts +548 -0
  79. package/src/routes.ts +287 -0
  80. package/src/runtime-paths.ts +179 -0
  81. package/src/schema/config.ts +133 -0
  82. package/src/schema/state.ts +376 -0
  83. package/src/spawn.ts +103 -0
  84. package/src/state.ts +265 -0
  85. package/src/task-manager.ts +657 -0
  86. package/src/task-views.ts +222 -0
  87. package/src/text.test.ts +689 -0
  88. package/src/text.ts +683 -0
  89. package/src/wechat-preview/assets/fonts/SweiCurveLegCJKsc-Regular.woff +0 -0
  90. package/src/wechat-preview/browser/editor-export.css +241 -0
  91. package/src/wechat-preview/browser/editor-export.ts +103 -0
  92. package/src/wechat-preview/frontmatter-handler.ts +73 -0
  93. package/src/wechat-preview/index.test.ts +154 -0
  94. package/src/wechat-preview/index.ts +294 -0
  95. package/src/wechat-preview/themes.ts +141 -0
  96. package/src/wechat-preview/wechat-formatter.test.ts +288 -0
  97. package/src/wechat-preview/wechat-formatter.ts +1506 -0
  98. package/src/wechat-preview.test.ts +73 -0
  99. package/src/workflow-materials.test.ts +225 -0
  100. package/src/workflow-materials.ts +217 -0
  101. package/src/workflow.test.ts +2087 -0
  102. package/dist/assets/imgx/assets/icons/avatar_jinx_cartoon.jpg +0 -0
  103. package/dist/assets/imgx/assets/icons/fishbone-logo-square.png +0 -0
  104. package/dist/assets/imgx/assets/icons/fishbone-logo.jpg +0 -0
  105. package/dist/assets/imgx/assets/icons/fishbone-logo.png +0 -0
  106. package/dist/assets/imgx/assets/icons/openclaw-logo.svg +0 -22
  107. package/dist/assets/imgx/assets/icons/zzclub-logo-black.jpg +0 -0
  108. package/dist/assets/imgx/assets/icons/zzclub-logo-gray.svg +0 -25
  109. package/dist/cli.js +0 -432
  110. package/dist/node_modules/@chenglou/pretext/dist/analysis.d.ts +0 -33
  111. package/dist/node_modules/@chenglou/pretext/dist/analysis.js +0 -1063
  112. package/dist/node_modules/@chenglou/pretext/dist/bidi.d.ts +0 -1
  113. package/dist/node_modules/@chenglou/pretext/dist/bidi.js +0 -175
  114. package/dist/node_modules/@chenglou/pretext/dist/generated/bidi-data.d.ts +0 -4
  115. package/dist/node_modules/@chenglou/pretext/dist/generated/bidi-data.js +0 -979
  116. package/dist/node_modules/@chenglou/pretext/dist/layout.d.ts +0 -70
  117. package/dist/node_modules/@chenglou/pretext/dist/layout.js +0 -496
  118. package/dist/node_modules/@chenglou/pretext/dist/line-break.d.ts +0 -36
  119. package/dist/node_modules/@chenglou/pretext/dist/line-break.js +0 -820
  120. package/dist/node_modules/@chenglou/pretext/dist/measurement.d.ts +0 -28
  121. package/dist/node_modules/@chenglou/pretext/dist/measurement.js +0 -219
  122. package/dist/node_modules/@chenglou/pretext/dist/rich-inline.d.ts +0 -51
  123. package/dist/node_modules/@chenglou/pretext/dist/rich-inline.js +0 -401
  124. /package/{dist/assets → src}/imgx/assets/browser/obstacle-flow.d.ts +0 -0
  125. /package/{dist/assets → src}/imgx/assets/browser/obstacle-flow.js +0 -0
  126. /package/{dist/assets → src}/imgx/assets/icons/clover.svg +0 -0
  127. /package/{dist/assets → src}/imgx/assets/templates/ascii-portrait-3-4.html +0 -0
  128. /package/{dist/assets → src}/imgx/assets/templates/ascii-portrait-tile.html +0 -0
  129. /package/{dist/assets → src}/imgx/assets/templates/tips-3-4.html +0 -0
  130. /package/{dist/assets → src}/imgx/assets/templates/x-like-posts.html +0 -0
  131. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/.vite/manifest.json +0 -0
  132. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  133. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  134. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  135. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  136. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  137. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  138. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  139. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  140. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  141. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  142. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  143. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  144. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  145. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  146. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  147. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  148. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  149. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  150. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  151. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  152. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  153. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  154. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  155. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  156. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  157. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  158. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  159. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  160. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  161. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  162. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  163. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  164. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  165. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  166. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  167. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  168. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  169. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  170. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  171. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  172. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  173. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  174. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  175. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  176. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  177. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  178. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  179. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  180. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  181. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  182. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  183. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  184. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  185. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  186. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  187. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  188. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  189. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  190. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  191. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/SweiCurveLegCJKsc-Regular-B3Ut5bwH.woff +0 -0
  192. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/style-BYNXnUvc.css +0 -0
  193. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/editor-export.js +0 -0
  194. /package/{dist/assets → src}/wechat-preview/assets/templates/export-shell.html +0 -0
@@ -0,0 +1,73 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { extractFrontmatter, parseYAML } from "./wechat-preview/frontmatter-handler";
3
+ import { getWechatPreviewStyleName, getWechatPreviewTheme } from "./wechat-preview/themes";
4
+ import { minifyHtmlPreservingCodeBlocks, resolveImageDimensionStyles } from "./wechat-preview/wechat-formatter";
5
+
6
+ describe("wechat preview theme mapping", () => {
7
+ test("resolves account-specific theme metadata", () => {
8
+ const ancientone = getWechatPreviewTheme("ancientone");
9
+ expect(ancientone.name).toBe("rose-ledger");
10
+ expect(ancientone.exportTheme.footerText).toBe("公众号 · 古一软件");
11
+ expect(getWechatPreviewStyleName("default")).toBe("sage-journal");
12
+ });
13
+
14
+ test("falls back to default theme for unknown account", () => {
15
+ const theme = getWechatPreviewTheme("missing-account");
16
+ expect(theme.account).toBe("default");
17
+ expect(theme.exportTheme.footerText).toBe("公众号 · 早早集市");
18
+ });
19
+ });
20
+
21
+ describe("wechat preview frontmatter helpers", () => {
22
+ test("extracts frontmatter and body", () => {
23
+ const parsed = extractFrontmatter("---\nslug: hello-world\ntags: [\"a\", \"b\"]\n---\n# Title\nbody");
24
+ expect(parsed.frontmatter).toContain("slug: hello-world");
25
+ expect(parsed.content).toBe("# Title\nbody");
26
+ });
27
+
28
+ test("parses basic yaml values", () => {
29
+ const parsed = parseYAML("slug: hello-world\ntags: [\"a\", \"b\"]");
30
+ expect(parsed.slug).toBe("hello-world");
31
+ expect(parsed.tags).toEqual(["a", "b"]);
32
+ });
33
+ });
34
+
35
+ describe("wechat preview html minify", () => {
36
+ test("keeps indentation inside protected code blocks", () => {
37
+ const input = `<section>\n <section data-code-block="true"><pre>if (ok) {\n return 1;\n}</pre></section>\n <section>after</section>\n</section>`;
38
+ const output = minifyHtmlPreservingCodeBlocks(input);
39
+
40
+ expect(output).toContain("<pre>if (ok) {\n return 1;\n}</pre>");
41
+ expect(output).toContain("</section><section>after</section>");
42
+ });
43
+
44
+ test("does not exit preservation mode on nested section closes inside code blocks", () => {
45
+ const input = `<section data-code-block="true"><section><section> const a = 1;</section><section> return a;</section></section></section><section>after</section>`;
46
+ const output = minifyHtmlPreservingCodeBlocks(input);
47
+
48
+ expect(output).toContain("<section> const a = 1;</section><section> return a;</section>");
49
+ expect(output).toContain("</section><section>after</section>");
50
+ });
51
+ });
52
+
53
+ describe("wechat preview image dimensions", () => {
54
+ test("preserves Crepe image-block height when width is implicit", () => {
55
+ const styles = resolveImageDimensionStyles({
56
+ styleHeight: "180.5px",
57
+ renderedWidth: 320,
58
+ renderedHeight: 120,
59
+ });
60
+
61
+ expect(styles).toEqual(["width: 320px", "max-width: 100%", "height: 180.5px"]);
62
+ });
63
+
64
+ test("uses Crepe image-block data-height when inline height is absent", () => {
65
+ const styles = resolveImageDimensionStyles({
66
+ dataHeight: "210.25",
67
+ renderedWidth: 360,
68
+ renderedHeight: 140,
69
+ });
70
+
71
+ expect(styles).toEqual(["width: 360px", "max-width: 100%", "height: 210.25px"]);
72
+ });
73
+ });
@@ -0,0 +1,225 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { mkdtemp, mkdir, writeFile } from "fs/promises";
3
+ import { join } from "path";
4
+ import { tmpdir } from "os";
5
+ import {
6
+ getPreferredBodyPath,
7
+ collectNewspicRequiredMarkers,
8
+ shouldUseNewspicLongform,
9
+ discoverRenderAssets,
10
+ } from "./workflow-materials";
11
+ import { defaultState } from "./state";
12
+
13
+ async function makeTempDir(prefix: string): Promise<string> {
14
+ return mkdtemp(join(tmpdir(), prefix));
15
+ }
16
+
17
+ describe("getPreferredBodyPath", () => {
18
+ test("returns source_body_path when it exists on disk", async () => {
19
+ const workspace = await makeTempDir("zzhub-materials-body-");
20
+ const bodyPath = join(workspace, "source.md");
21
+ await writeFile(bodyPath, "content", "utf-8");
22
+
23
+ const state = defaultState();
24
+ state.source_body_path = bodyPath;
25
+ state.asset_path = join(workspace, "posts");
26
+
27
+ const result = await getPreferredBodyPath(state);
28
+ expect(result).toBe(bodyPath);
29
+ });
30
+
31
+ test("falls back to asset_path/post.md when source_body_path missing", async () => {
32
+ const workspace = await makeTempDir("zzhub-materials-fallback-");
33
+ const assetPath = join(workspace, "posts", "2026-04-10-test");
34
+ await mkdir(assetPath, { recursive: true });
35
+ await writeFile(join(assetPath, "post.md"), "post content", "utf-8");
36
+
37
+ const state = defaultState();
38
+ state.source_body_path = "/nonexistent/path.md";
39
+ state.asset_path = assetPath;
40
+
41
+ const result = await getPreferredBodyPath(state);
42
+ expect(result).toBe(join(assetPath, "post.md"));
43
+ });
44
+
45
+ test("returns null when neither exists", async () => {
46
+ const state = defaultState();
47
+ state.source_body_path = "/nonexistent/source.md";
48
+ state.asset_path = "/nonexistent/asset";
49
+
50
+ const result = await getPreferredBodyPath(state);
51
+ expect(result).toBeNull();
52
+ });
53
+ });
54
+
55
+ describe("collectNewspicRequiredMarkers", () => {
56
+ test("collects markers from body text", () => {
57
+ const state = defaultState();
58
+ state.route.primary = "wechat-newspic";
59
+ state.intent.newspic_render = null;
60
+
61
+ const markers = collectNewspicRequiredMarkers("Some text\n\n插图1\n\nMore text\n\n插图2", state);
62
+ expect(markers).toContain("插图1");
63
+ expect(markers).toContain("插图2");
64
+ });
65
+
66
+ test("merges body markers with page_spec markers", () => {
67
+ const state = defaultState();
68
+ state.route.primary = "wechat-newspic";
69
+ state.intent.newspic_render = {
70
+ pagination_mode: "multi",
71
+ min_pages: 2,
72
+ max_pages: 0,
73
+ require_image_every_page: true,
74
+ default_image_layout: "staggered",
75
+ target_fill_ratio: 0.8,
76
+ page_specs: [
77
+ { page: 1, image_markers: ["插图3"], image_layout: null, target_fill_ratio: null, note: null },
78
+ ],
79
+ };
80
+
81
+ const markers = collectNewspicRequiredMarkers("插图1\n\n插图2", state);
82
+ expect(markers).toContain("插图1");
83
+ expect(markers).toContain("插图2");
84
+ expect(markers).toContain("插图3");
85
+ });
86
+
87
+ test("deduplicates markers", () => {
88
+ const state = defaultState();
89
+ state.route.primary = "wechat-newspic";
90
+ state.intent.newspic_render = {
91
+ pagination_mode: "multi",
92
+ min_pages: 1,
93
+ max_pages: 0,
94
+ require_image_every_page: false,
95
+ default_image_layout: "staggered",
96
+ target_fill_ratio: 0.8,
97
+ page_specs: [
98
+ { page: 1, image_markers: ["插图1"], image_layout: null, target_fill_ratio: null, note: null },
99
+ ],
100
+ };
101
+
102
+ const markers = collectNewspicRequiredMarkers("插图1", state);
103
+ const uniqueCount = new Set(markers).size;
104
+ expect(uniqueCount).toBe(markers.length);
105
+ });
106
+
107
+ test("returns empty array when no markers found", () => {
108
+ const state = defaultState();
109
+ state.route.primary = "wechat-newspic";
110
+ state.intent.newspic_render = null;
111
+
112
+ const markers = collectNewspicRequiredMarkers("Plain text without markers", state);
113
+ expect(markers).toEqual([]);
114
+ });
115
+ });
116
+
117
+ describe("shouldUseNewspicLongform", () => {
118
+ test("returns true for multi mode", () => {
119
+ const state = defaultState();
120
+ state.route.primary = "wechat-newspic";
121
+ state.intent.newspic_render = {
122
+ pagination_mode: "multi",
123
+ min_pages: 2,
124
+ max_pages: 0,
125
+ require_image_every_page: false,
126
+ default_image_layout: "staggered",
127
+ target_fill_ratio: 0.8,
128
+ page_specs: [],
129
+ };
130
+
131
+ expect(shouldUseNewspicLongform(state)).toBe(true);
132
+ });
133
+
134
+ test("returns false for single mode even with long body", () => {
135
+ const state = defaultState();
136
+ state.route.primary = "wechat-newspic";
137
+ state.intent.newspic_render = {
138
+ pagination_mode: "single",
139
+ min_pages: 1,
140
+ max_pages: 0,
141
+ require_image_every_page: false,
142
+ default_image_layout: "staggered",
143
+ target_fill_ratio: 0.8,
144
+ page_specs: [],
145
+ };
146
+
147
+ expect(shouldUseNewspicLongform(state)).toBe(false);
148
+ });
149
+
150
+ test("returns false for default/auto mode with short body", () => {
151
+ const state = defaultState();
152
+ state.route.primary = "wechat-newspic";
153
+ state.intent.newspic_render = null;
154
+
155
+ expect(shouldUseNewspicLongform(state)).toBe(false);
156
+ });
157
+
158
+ test("returns false for auto mode with long body", () => {
159
+ const state = defaultState();
160
+ state.route.primary = "wechat-newspic";
161
+ state.intent.newspic_render = {
162
+ pagination_mode: "auto",
163
+ min_pages: 1,
164
+ max_pages: 0,
165
+ require_image_every_page: false,
166
+ default_image_layout: "staggered",
167
+ target_fill_ratio: 0.8,
168
+ page_specs: [],
169
+ };
170
+
171
+ expect(shouldUseNewspicLongform(state)).toBe(false);
172
+ });
173
+ });
174
+
175
+ describe("discoverRenderAssets", () => {
176
+ test("finds cover.png for wechat-article route", async () => {
177
+ const workspace = await makeTempDir("zzhub-discover-article-");
178
+ const assetPath = join(workspace, "posts", "2026-04-10-test");
179
+ const imageDir = join(assetPath, "images", "wechat");
180
+ await mkdir(imageDir, { recursive: true });
181
+ await writeFile(join(imageDir, "cover.png"), "fake-png", "utf-8");
182
+
183
+ const state = defaultState();
184
+ state.route.primary = "wechat-article";
185
+ state.asset_path = assetPath;
186
+
187
+ const assets = await discoverRenderAssets(state);
188
+ expect(assets).toHaveLength(1);
189
+ expect(assets[0].kind).toBe("cover");
190
+ expect(assets[0].route).toBe("wechat-article");
191
+ });
192
+
193
+ test("finds cover and page files for wechat-newspic route", async () => {
194
+ const workspace = await makeTempDir("zzhub-discover-newspic-");
195
+ const assetPath = join(workspace, "posts", "2026-04-10-newspic");
196
+ const imageDir = join(assetPath, "images", "newspic");
197
+ await mkdir(imageDir, { recursive: true });
198
+ await writeFile(join(imageDir, "cover.png"), "cover", "utf-8");
199
+ await writeFile(join(imageDir, "article-3.png"), "page3", "utf-8");
200
+ await writeFile(join(imageDir, "article-1.png"), "page1", "utf-8");
201
+ await writeFile(join(imageDir, "article-2.png"), "page2", "utf-8");
202
+
203
+ const state = defaultState();
204
+ state.route.primary = "wechat-newspic";
205
+ state.asset_path = assetPath;
206
+
207
+ const assets = await discoverRenderAssets(state);
208
+ expect(assets.length).toBeGreaterThanOrEqual(3);
209
+
210
+ const pages = assets.filter((a) => a.kind === "page");
211
+ expect(pages).toHaveLength(3);
212
+ expect(pages[0].index).toBe(1);
213
+ expect(pages[1].index).toBe(2);
214
+ expect(pages[2].index).toBe(3);
215
+ });
216
+
217
+ test("returns empty array when image directory does not exist", async () => {
218
+ const state = defaultState();
219
+ state.route.primary = "wechat-article";
220
+ state.asset_path = "/nonexistent/path";
221
+
222
+ const assets = await discoverRenderAssets(state);
223
+ expect(assets).toEqual([]);
224
+ });
225
+ });
@@ -0,0 +1,217 @@
1
+ import { access, readFile, readdir } from "fs/promises";
2
+ import { join } from "path";
3
+
4
+ import {
5
+ normalizeNewspicRenderSpec,
6
+ type BodyInputReceived,
7
+ defaultBodyInputs,
8
+ type RenderAsset,
9
+ type WorkflowState,
10
+ } from "./state";
11
+ import {
12
+ findIllustrationMarkers,
13
+ stripFrontmatter,
14
+ } from "./text";
15
+
16
+ async function pathExists(path: string): Promise<boolean> {
17
+ try {
18
+ await access(path);
19
+ return true;
20
+ } catch {
21
+ return false;
22
+ }
23
+ }
24
+
25
+ function dedupeReceived(received: BodyInputReceived[]): BodyInputReceived[] {
26
+ const map = new Map<string, string>();
27
+ for (const item of received) {
28
+ if (!item.marker?.trim() || !item.path?.trim()) {
29
+ continue;
30
+ }
31
+ map.set(item.marker.trim(), item.path.trim());
32
+ }
33
+ return [...map.entries()].map(([marker, path]) => ({ marker, path }));
34
+ }
35
+
36
+ export async function getPreferredBodyPath(state: WorkflowState): Promise<string | null> {
37
+ if (state.source_body_path && await pathExists(state.source_body_path)) {
38
+ return state.source_body_path;
39
+ }
40
+ if (state.asset_path) {
41
+ const canonicalBodyPath = join(state.asset_path, "post.md");
42
+ if (await pathExists(canonicalBodyPath)) {
43
+ return canonicalBodyPath;
44
+ }
45
+ }
46
+ return null;
47
+ }
48
+
49
+ export async function readBodyContent(state: WorkflowState): Promise<{ path: string | null; content: string | null }> {
50
+ const bodyPath = await getPreferredBodyPath(state);
51
+ if (!bodyPath) {
52
+ return { path: null, content: null };
53
+ }
54
+ return {
55
+ path: bodyPath,
56
+ content: await readFile(bodyPath, "utf-8"),
57
+ };
58
+ }
59
+
60
+ export function collectNewspicRequiredMarkers(
61
+ cleanBody: string,
62
+ state: WorkflowState,
63
+ ): string[] {
64
+ const spec = normalizeNewspicRenderSpec(state.intent.newspic_render);
65
+ const markers = new Set(findIllustrationMarkers(cleanBody).map((item) => item.trim()));
66
+ for (const pageSpec of spec.page_specs) {
67
+ for (const marker of pageSpec.image_markers) {
68
+ if (marker.trim()) {
69
+ markers.add(marker.trim());
70
+ }
71
+ }
72
+ }
73
+ return [...markers];
74
+ }
75
+
76
+ export function shouldUseNewspicLongform(state: WorkflowState): boolean {
77
+ const spec = normalizeNewspicRenderSpec(state.intent.newspic_render);
78
+ return spec.pagination_mode === "multi";
79
+ }
80
+
81
+ export async function reconcileBodyInputs(state: WorkflowState): Promise<void> {
82
+ const body = await readBodyContent(state);
83
+ if (!body.content) {
84
+ if (state.images.body_inputs.scope !== "none" && state.images.body_inputs.received.length === 0) {
85
+ state.images.body_inputs = defaultBodyInputs();
86
+ }
87
+ return;
88
+ }
89
+
90
+ const cleanBody = stripFrontmatter(body.content);
91
+ const previousReceived = dedupeReceived(state.images.body_inputs.received);
92
+ const usesArticleImages =
93
+ state.intent.content_form === "article" ||
94
+ state.route.primary === "wechat-article" ||
95
+ state.route.extras.includes("wechat-article");
96
+ const usesNewspicImages =
97
+ state.intent.content_form === "newspic" ||
98
+ state.route.primary === "wechat-newspic" ||
99
+ state.route.extras.includes("wechat-newspic");
100
+
101
+ if (usesArticleImages && !usesNewspicImages) {
102
+ const markers = findIllustrationMarkers(cleanBody);
103
+ const markerSet = new Set(markers);
104
+ const received = previousReceived.filter((item) => markerSet.has(item.marker));
105
+ if (markers.length === 0) {
106
+ state.images.body_inputs = defaultBodyInputs();
107
+ return;
108
+ }
109
+ state.images.body_inputs = {
110
+ scope: "article",
111
+ expected: markers.length,
112
+ received,
113
+ status: received.length >= markers.length ? "ready" : "pending",
114
+ layout: state.images.body_inputs.layout || "staggered",
115
+ };
116
+ return;
117
+ }
118
+
119
+ if (usesNewspicImages && shouldUseNewspicLongform(state)) {
120
+ const markers = collectNewspicRequiredMarkers(cleanBody, state);
121
+ const markerSet = new Set(markers);
122
+ const received = previousReceived.filter((item) => markerSet.has(item.marker));
123
+ state.images.body_inputs = {
124
+ scope: markers.length > 0 ? "newspic-longform" : "none",
125
+ expected: markers.length,
126
+ received,
127
+ status:
128
+ markers.length === 0
129
+ ? "none"
130
+ : received.length >= markers.length
131
+ ? "ready"
132
+ : "pending",
133
+ layout:
134
+ state.images.body_inputs.layout ||
135
+ normalizeNewspicRenderSpec(state.intent.newspic_render).default_image_layout,
136
+ };
137
+ return;
138
+ }
139
+
140
+ if (state.images.body_inputs.scope !== "none") {
141
+ state.images.body_inputs = defaultBodyInputs();
142
+ }
143
+ }
144
+
145
+ export async function discoverRenderAssets(state: WorkflowState): Promise<RenderAsset[]> {
146
+ if (!state.asset_path) {
147
+ return [];
148
+ }
149
+
150
+ const assets: RenderAsset[] = [];
151
+ const needsWechatArticle =
152
+ state.route.primary === "wechat-article" || state.route.extras.includes("wechat-article");
153
+ const needsNewspic =
154
+ state.route.primary === "wechat-newspic" || state.route.extras.includes("wechat-newspic");
155
+
156
+ if (needsWechatArticle) {
157
+ const coverPath = join(state.asset_path, "images", "wechat", "cover.png");
158
+ if (await pathExists(coverPath)) {
159
+ assets.push({
160
+ kind: "cover",
161
+ route: "wechat-article",
162
+ path: coverPath,
163
+ });
164
+ }
165
+ }
166
+
167
+ if (needsNewspic) {
168
+ const newspicDir = join(state.asset_path, "images", "newspic");
169
+ const coverPath = join(newspicDir, "cover.png");
170
+ if (await pathExists(coverPath)) {
171
+ assets.push({
172
+ kind: "cover",
173
+ route: "wechat-newspic",
174
+ path: coverPath,
175
+ });
176
+ }
177
+
178
+ if (await pathExists(newspicDir)) {
179
+ const files = await readdir(newspicDir);
180
+ const pageFiles = files
181
+ .filter((name) => /^article-\d+\.png$/.test(name))
182
+ .sort();
183
+ for (let index = 0; index < pageFiles.length; index += 1) {
184
+ assets.push({
185
+ kind: "page",
186
+ route: "wechat-newspic",
187
+ path: join(newspicDir, pageFiles[index]),
188
+ index: index + 1,
189
+ });
190
+ }
191
+ }
192
+ }
193
+
194
+ return assets;
195
+ }
196
+
197
+ export async function reconcileStateArtifacts(state: WorkflowState): Promise<void> {
198
+ // NOTE: This function mutates `state` in memory only — it does NOT write back to disk.
199
+ // It is called on every `status` read to produce a reconciled view.
200
+ // Any caller reading the raw state file directly (bypassing this function) will see
201
+ // stale values for images.body_inputs and images.render_assets.
202
+
203
+ await reconcileBodyInputs(state);
204
+
205
+ if (!state.asset_path) {
206
+ return;
207
+ }
208
+
209
+ const renderAssets = await discoverRenderAssets(state);
210
+ if (renderAssets.length > 0) {
211
+ state.images.render_assets = renderAssets;
212
+ state.images.plan.status = "rendered";
213
+ } else if (state.images.plan.needed && state.images.plan.status === "rendered") {
214
+ state.images.plan.status = "planned";
215
+ state.images.render_assets = [];
216
+ }
217
+ }