@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,147 @@
1
+ /**
2
+ * publish — Execute publish routes.
3
+ *
4
+ * Reads state, determines which routes to publish, executes publish commands,
5
+ * records results. Supports idempotent skipping based on version matching.
6
+ *
7
+ * Usage:
8
+ * zzhub-pipeline publish \
9
+ * --state /path/to/state.json \
10
+ * [--route wechat-article] (override: only publish this route)
11
+ * [--dry-run]
12
+ *
13
+ * Output: Updated state with publish.results
14
+ */
15
+
16
+ import { parseArgs, requireArg, optionalArg, flagArg } from "../args";
17
+ import { printResult, renderPublish } from "../output";
18
+ import { loadConfig, resolveWorkspacePaths } from "../config";
19
+ import { getPublishProvider } from "../providers";
20
+ import {
21
+ readState,
22
+ validateForPhase,
23
+ writeState,
24
+ type PublishResult,
25
+ type RoutePrimary,
26
+ } from "../state";
27
+
28
+ export async function publish(args: string[]): Promise<void> {
29
+ const parsed = parseArgs(args);
30
+
31
+ if (parsed.help) {
32
+ console.log(`
33
+ Usage: zzhub-pipeline publish [options]
34
+
35
+ Options:
36
+ --state Path to state JSON (required)
37
+ --route Only publish this route (optional; default: all routes)
38
+ --dry-run Print commands without executing (optional)
39
+ `.trim());
40
+ return;
41
+ }
42
+
43
+ const statePath = requireArg(parsed, "state", "state JSON path");
44
+ const routeOverride = optionalArg(parsed, "route") as RoutePrimary | undefined;
45
+ const dryRun = flagArg(parsed, "dry-run");
46
+
47
+ const state = await readState(statePath);
48
+ const config = loadConfig();
49
+ const workspacePaths = resolveWorkspacePaths(state.workspace_root, config);
50
+ const validationErrors = validateForPhase(state, "publish");
51
+
52
+ if (!state.asset_path) {
53
+ throw new Error("asset_path not set. Cannot publish.");
54
+ }
55
+ if (validationErrors.length > 0) {
56
+ throw new Error(
57
+ `Publish validation failed: ${validationErrors.map((item) => `${item.field}: ${item.message}`).join("; ")}`,
58
+ );
59
+ }
60
+
61
+ // Collect all routes to publish
62
+ const routes: RoutePrimary[] = routeOverride
63
+ ? [routeOverride]
64
+ : [state.route.primary, ...state.route.extras];
65
+
66
+ const results: PublishResult[] = [...state.publish.results];
67
+ let hasUnfinishedPublish = false;
68
+
69
+ for (const route of routes) {
70
+ // ── Idempotency check ──
71
+ const existing = results.find((r) => r.route === route);
72
+ if (
73
+ existing &&
74
+ existing.status === "success" &&
75
+ existing.content_version === state.artifacts.content_version &&
76
+ existing.render_version === state.artifacts.render_version
77
+ ) {
78
+ console.error(`[publish] Skipping ${route}: already published at current version`);
79
+ continue;
80
+ }
81
+
82
+ try {
83
+ const provider = getPublishProvider(route);
84
+ const result = await provider({
85
+ state,
86
+ dryRun,
87
+ config,
88
+ workspacePaths,
89
+ });
90
+
91
+ // Upsert result
92
+ const idx = results.findIndex((r) => r.route === route);
93
+ if (idx >= 0) {
94
+ results[idx] = result;
95
+ } else {
96
+ results.push(result);
97
+ }
98
+
99
+ if (result.status !== "success" && result.status !== "skipped") {
100
+ hasUnfinishedPublish = true;
101
+ }
102
+ } catch (err) {
103
+ const detail = err instanceof Error ? err.message : String(err);
104
+ const failResult: PublishResult = {
105
+ route,
106
+ status: "failed",
107
+ detail,
108
+ published_at: null,
109
+ content_version: state.artifacts.content_version,
110
+ render_version: state.artifacts.render_version,
111
+ };
112
+ const idx = results.findIndex((r) => r.route === route);
113
+ if (idx >= 0) {
114
+ results[idx] = failResult;
115
+ } else {
116
+ results.push(failResult);
117
+ }
118
+ hasUnfinishedPublish = true;
119
+ }
120
+ }
121
+
122
+ // Update state
123
+ state.publish.results = results;
124
+
125
+ const allDone = routes.every((route) => {
126
+ const r = results.find((x) => x.route === route);
127
+ return r && (r.status === "success" || r.status === "skipped");
128
+ });
129
+
130
+ if (allDone) {
131
+ state.mode = "done";
132
+ state.phase.publish = { status: "done", error: null };
133
+ state.phase.current = "done";
134
+ } else if (hasUnfinishedPublish) {
135
+ state.mode = "active";
136
+ state.phase.publish = { status: "pending", error: null };
137
+ state.phase.current = "publish";
138
+ }
139
+
140
+ await writeState(statePath, state);
141
+
142
+ printResult({
143
+ publish_results: results,
144
+ mode: state.mode,
145
+ phase: state.phase.current,
146
+ }, renderPublish);
147
+ }
@@ -0,0 +1,36 @@
1
+ import { parseArgs, requireArg } from "../args";
2
+ import { printResult, renderTaskShape } from "../output";
3
+ import { readState, writeState } from "../state";
4
+ import { getTaskByStatePath } from "../task-manager";
5
+ import { reconcileStateArtifacts } from "../workflow-materials";
6
+
7
+ export async function reconcile(args: string[]): Promise<void> {
8
+ const parsed = parseArgs(args);
9
+
10
+ if (parsed.help) {
11
+ console.log(`
12
+ Usage: zzhub-pipeline reconcile [options]
13
+
14
+ Options:
15
+ --state Path to state JSON (required)
16
+ `.trim());
17
+ return;
18
+ }
19
+
20
+ const statePath = requireArg(parsed, "state", "state JSON path");
21
+ const state = await readState(statePath);
22
+ await reconcileStateArtifacts(state);
23
+ await writeState(statePath, state);
24
+
25
+ const task = await getTaskByStatePath(statePath);
26
+ printResult(
27
+ {
28
+ summary: task.summary,
29
+ validation: task.validation,
30
+ gaps: task.gaps,
31
+ blockers: task.blockers,
32
+ next_action: task.next_action,
33
+ },
34
+ renderTaskShape,
35
+ );
36
+ }
@@ -0,0 +1,244 @@
1
+ /**
2
+ * render — Execute Render phase: image-plan + adapter-based rendering.
3
+ *
4
+ * 1. Reads state + post.md
5
+ * 2. Runs image-plan logic (template selection, cover_title, output_dir)
6
+ * 3. Invokes image renderer adapter (pluggable via config.plugins)
7
+ * 4. Records render_assets and bumps render_version
8
+ *
9
+ * Usage:
10
+ * zzhub-pipeline render \
11
+ * --state /path/to/state.json \
12
+ * [--skip-render] (plan only, don't invoke renderer)
13
+ *
14
+ * Output: Updated state with images.plan, images.render_assets
15
+ */
16
+
17
+ import { readFile } from "fs/promises";
18
+ import { join } from "path";
19
+
20
+ import { parseArgs, requireArg, flagArg } from "../args";
21
+ import { printResult, renderRender } from "../output";
22
+ import { loadConfig } from "../config";
23
+ import { resolveImageRenderer } from "../adapter-loader";
24
+ import {
25
+ defaultBodyInputs,
26
+ normalizeNewspicRenderSpec,
27
+ readState,
28
+ writeState,
29
+ type NewspicRenderSpec,
30
+ } from "../state";
31
+ import {
32
+ stripFrontmatter,
33
+ generateCoverTitle,
34
+ } from "../text";
35
+ import { getLongformTheme } from "../routes";
36
+ import { collectNewspicRequiredMarkers } from "../workflow-materials";
37
+
38
+ function getNewspicRenderSpec(state: Awaited<ReturnType<typeof readState>>): NewspicRenderSpec {
39
+ const spec = normalizeNewspicRenderSpec(state.intent.newspic_render);
40
+ return {
41
+ ...spec,
42
+ min_pages: Math.max(spec.min_pages, spec.pagination_mode === "multi" ? 2 : 1),
43
+ };
44
+ }
45
+
46
+ export async function render(args: string[]): Promise<void> {
47
+ const parsed = parseArgs(args);
48
+
49
+ if (parsed.help) {
50
+ console.log(`
51
+ Usage: zzhub-pipeline render [options]
52
+
53
+ Options:
54
+ --state Path to state JSON (required)
55
+ --skip-render Plan only, don't invoke renderer (optional)
56
+ `.trim());
57
+ return;
58
+ }
59
+
60
+ const statePath = requireArg(parsed, "state", "state JSON path");
61
+ const skipRender = flagArg(parsed, "skip-render");
62
+
63
+ const state = await readState(statePath);
64
+
65
+ // Validate prerequisites
66
+ if (!state.asset_path) {
67
+ throw new Error("asset_path not set. Run prepare-finalize first.");
68
+ }
69
+
70
+ const postPath = join(state.asset_path, "post.md");
71
+ const postContent = await readFile(postPath, "utf-8");
72
+ const cleanBody = stripFrontmatter(postContent);
73
+
74
+ // ── Image Plan ──────────────────────────────────────────────────
75
+
76
+ const routePrimary = state.route.primary;
77
+
78
+ if (routePrimary === "blog") {
79
+ // Blog doesn't need images by default
80
+ state.images.plan = {
81
+ needed: false,
82
+ template: null,
83
+ cover_template: null,
84
+ cover_title: null,
85
+ output_dir: null,
86
+ preview_required: false,
87
+ status: "skipped",
88
+ };
89
+ state.images.render_assets = [];
90
+ state.phase.render = { status: "done", error: null };
91
+ state.phase.current = state.intent.requires.publish ? "publish" : "done";
92
+ await writeState(statePath, state);
93
+ printResult({
94
+ plan: state.images.plan,
95
+ render_assets: [],
96
+ phase: state.phase.current,
97
+ }, renderRender);
98
+ return;
99
+ }
100
+
101
+ let template: string;
102
+ let coverTemplate: string | null = null;
103
+ let coverTitle: string | null = null;
104
+ let outputDir: string;
105
+ const newspicRenderSpec = getNewspicRenderSpec(state);
106
+
107
+ if (routePrimary === "wechat-article") {
108
+ template = "wechat-cover-split";
109
+ outputDir = join(state.asset_path, "images", "wechat");
110
+ } else {
111
+ // wechat-newspic: determine short vs long
112
+ const isLong = newspicRenderSpec.pagination_mode === "multi";
113
+
114
+ if (isLong) {
115
+ template = "longform-3-4";
116
+ coverTemplate = "poster-3-4";
117
+ coverTitle = generateCoverTitle(state.metadata.title);
118
+ } else {
119
+ template = "poster-3-4";
120
+ coverTitle = generateCoverTitle(state.metadata.title);
121
+ }
122
+ outputDir = join(state.asset_path, "images", "newspic");
123
+ }
124
+
125
+ state.images.plan = {
126
+ needed: true,
127
+ template,
128
+ cover_template: coverTemplate,
129
+ cover_title: coverTitle,
130
+ output_dir: outputDir,
131
+ preview_required: false,
132
+ status: "planned",
133
+ };
134
+
135
+ // For longform, set up body_inputs
136
+ if (template === "longform-3-4") {
137
+ const markers = collectNewspicRequiredMarkers(cleanBody, state);
138
+ const previousInputs =
139
+ state.images.body_inputs.scope === "newspic-longform"
140
+ ? state.images.body_inputs
141
+ : null;
142
+ const markerSet = new Set(markers);
143
+ const received = previousInputs
144
+ ? previousInputs.received.filter((item) => markerSet.has(item.marker))
145
+ : [];
146
+ state.images.body_inputs = {
147
+ scope: "newspic-longform",
148
+ expected: markers.length,
149
+ received,
150
+ status:
151
+ markers.length === 0
152
+ ? "none"
153
+ : received.length >= markers.length
154
+ ? "ready"
155
+ : "pending",
156
+ layout: previousInputs?.layout ?? newspicRenderSpec.default_image_layout,
157
+ };
158
+
159
+ // If body inputs are pending, we need to pause for user
160
+ if (state.images.body_inputs.status === "pending") {
161
+ state.images.plan.status = "planned";
162
+ await writeState(statePath, state);
163
+ printResult({
164
+ plan: state.images.plan,
165
+ body_inputs: state.images.body_inputs,
166
+ newspic_render: newspicRenderSpec,
167
+ waiting_for_user: true,
168
+ message: `Waiting for ${markers.length} body image(s)`,
169
+ phase: state.phase.current,
170
+ }, renderRender);
171
+ return;
172
+ }
173
+ } else if (state.images.body_inputs.scope === "newspic-longform") {
174
+ state.images.body_inputs = defaultBodyInputs();
175
+ }
176
+
177
+ if (skipRender) {
178
+ await writeState(statePath, state);
179
+ printResult({
180
+ plan: state.images.plan,
181
+ newspic_render: routePrimary === "wechat-newspic" ? newspicRenderSpec : null,
182
+ skip_render: true,
183
+ phase: state.phase.current,
184
+ }, renderRender);
185
+ return;
186
+ }
187
+
188
+ // ── Invoke image renderer adapter ───────────────────────────────
189
+
190
+ const config = loadConfig();
191
+ const imageRenderer = await resolveImageRenderer(config);
192
+
193
+ const bodyImages = (
194
+ state.images.body_inputs.scope === "newspic-longform" &&
195
+ state.images.body_inputs.status === "ready"
196
+ )
197
+ ? state.images.body_inputs.received
198
+ : [];
199
+
200
+ const renderResult = await imageRenderer.render({
201
+ state,
202
+ bodyText: cleanBody,
203
+ outputDir,
204
+ title: state.metadata.title,
205
+ route: routePrimary,
206
+ pageSpecs: newspicRenderSpec.page_specs.map((ps) => ({
207
+ page: ps.page,
208
+ imageMarkers: ps.image_markers,
209
+ imageLayout: ps.image_layout ?? undefined,
210
+ targetFillRatio: ps.target_fill_ratio ?? undefined,
211
+ note: ps.note ?? undefined,
212
+ })),
213
+ bodyImages,
214
+ minPages: newspicRenderSpec.min_pages,
215
+ maxPages: newspicRenderSpec.max_pages,
216
+ accountVisualParams: state.route.account_visual_params
217
+ ? {
218
+ footer: state.route.account_visual_params.footer,
219
+ bg: state.route.account_visual_params.bg,
220
+ highlight: state.route.account_visual_params.highlight,
221
+ fallbackIcon: state.route.account_visual_params.fallback_icon,
222
+ }
223
+ : undefined,
224
+ theme: routePrimary === "wechat-newspic" ? getLongformTheme(state.route.account) : undefined,
225
+ template,
226
+ });
227
+
228
+ // ── Update state ────────────────────────────────────────────────
229
+
230
+ state.images.render_assets = renderResult.assets;
231
+ state.images.plan.status = "rendered";
232
+ state.phase.render = { status: "done", error: null };
233
+ state.phase.current = state.intent.requires.publish ? "publish" : "done";
234
+ state.artifacts.render_version += 1;
235
+
236
+ await writeState(statePath, state);
237
+
238
+ printResult({
239
+ plan: state.images.plan,
240
+ render_assets: renderResult.assets,
241
+ render_version: state.artifacts.render_version,
242
+ phase: state.phase.current,
243
+ }, renderRender);
244
+ }
@@ -0,0 +1,151 @@
1
+ import { beforeEach, describe, expect, test } from "bun:test";
2
+ import { mkdtemp, writeFile } from "fs/promises";
3
+ import { join } from "path";
4
+ import { tmpdir } from "os";
5
+
6
+ import { reset } from "./reset";
7
+ import { defaultState, readState, writeState } from "../state";
8
+
9
+ const TEST_CONFIG_PATH = join(tmpdir(), `zzhub-pipeline-test-config-reset-${process.pid}.json`);
10
+ process.env.ZZHUB_PIPELINE_CONFIG = TEST_CONFIG_PATH;
11
+
12
+ async function makeTempDir(prefix: string): Promise<string> {
13
+ return mkdtemp(join(tmpdir(), prefix));
14
+ }
15
+
16
+ async function captureJsonOutput<T>(fn: () => Promise<void>): Promise<T> {
17
+ const original = console.log;
18
+ const lines: string[] = [];
19
+ console.log = (...args: unknown[]) => {
20
+ lines.push(args.map((value) => String(value)).join(" "));
21
+ };
22
+ try {
23
+ await fn();
24
+ } finally {
25
+ console.log = original;
26
+ }
27
+ return JSON.parse(lines.join("\n").trim()) as T;
28
+ }
29
+
30
+ beforeEach(async () => {
31
+ await writeFile(TEST_CONFIG_PATH, "{}", "utf-8");
32
+ });
33
+
34
+ describe("reset", () => {
35
+ test("mode=content resets all three phases and clears content_review", async () => {
36
+ const workspace = await makeTempDir("zzhub-reset-content-");
37
+ const statePath = join(workspace, "state.json");
38
+
39
+ const state = defaultState();
40
+ state.workspace_root = workspace;
41
+ state.state_path = statePath;
42
+ state.run_id = "run-reset-content";
43
+ state.phase.prepare = { status: "done", error: null };
44
+ state.phase.render = { status: "done", error: null };
45
+ state.phase.publish = { status: "done", error: null };
46
+ state.phase.current = "done";
47
+ state.content_review = { status: "passed", feedback: null };
48
+ await writeState(statePath, state);
49
+
50
+ const output = await captureJsonOutput<{ reset_mode: string; start_step: string | null }>(() =>
51
+ reset(["--state", statePath, "--mode", "content"]),
52
+ );
53
+
54
+ expect(output.reset_mode).toBe("content");
55
+ expect(output.start_step).toBe("writer");
56
+
57
+ const updated = await readState(statePath);
58
+ expect(updated.phase.prepare.status).toBe("pending");
59
+ expect(updated.phase.render.status).toBe("pending");
60
+ expect(updated.phase.publish.status).toBe("pending");
61
+ expect(updated.phase.current).toBe("prepare");
62
+ expect(updated.mode).toBe("active");
63
+ expect(updated.content_review.status).toBe("unchecked");
64
+ expect(updated.redo_hint).toBe("writer");
65
+ });
66
+
67
+ test("mode=render keeps prepare=done but resets render and publish", async () => {
68
+ const workspace = await makeTempDir("zzhub-reset-render-");
69
+ const statePath = join(workspace, "state.json");
70
+
71
+ const state = defaultState();
72
+ state.workspace_root = workspace;
73
+ state.state_path = statePath;
74
+ state.run_id = "run-reset-render";
75
+ state.phase.prepare = { status: "done", error: null };
76
+ state.phase.render = { status: "done", error: null };
77
+ state.phase.publish = { status: "done", error: null };
78
+ state.phase.current = "done";
79
+ await writeState(statePath, state);
80
+
81
+ await captureJsonOutput(() => reset(["--state", statePath, "--mode", "render"]));
82
+
83
+ const updated = await readState(statePath);
84
+ expect(updated.phase.prepare.status).toBe("done");
85
+ expect(updated.phase.render.status).toBe("pending");
86
+ expect(updated.phase.publish.status).toBe("pending");
87
+ expect(updated.phase.current).toBe("render");
88
+ expect(updated.mode).toBe("active");
89
+ expect(updated.redo_hint).toBeNull();
90
+ });
91
+
92
+ test("mode=redo.style sets redo_hint to style", async () => {
93
+ const workspace = await makeTempDir("zzhub-reset-redo-");
94
+ const statePath = join(workspace, "state.json");
95
+
96
+ const state = defaultState();
97
+ state.workspace_root = workspace;
98
+ state.state_path = statePath;
99
+ state.run_id = "run-reset-redo";
100
+ state.phase.prepare = { status: "done", error: null };
101
+ state.phase.render = { status: "done", error: null };
102
+ await writeState(statePath, state);
103
+
104
+ const output = await captureJsonOutput<{ start_step: string | null }>(() =>
105
+ reset(["--state", statePath, "--mode", "redo.style"]),
106
+ );
107
+
108
+ expect(output.start_step).toBe("style");
109
+
110
+ const updated = await readState(statePath);
111
+ expect(updated.redo_hint).toBe("style");
112
+ expect(updated.phase.prepare.status).toBe("pending");
113
+ expect(updated.phase.render.status).toBe("pending");
114
+ expect(updated.phase.publish.status).toBe("pending");
115
+ expect(updated.phase.current).toBe("prepare");
116
+ });
117
+
118
+ test("mode=full marks state as failed", async () => {
119
+ const workspace = await makeTempDir("zzhub-reset-full-");
120
+ const statePath = join(workspace, "state.json");
121
+
122
+ const state = defaultState();
123
+ state.workspace_root = workspace;
124
+ state.state_path = statePath;
125
+ state.run_id = "run-reset-full";
126
+ state.phase.prepare = { status: "done", error: null };
127
+ await writeState(statePath, state);
128
+
129
+ await captureJsonOutput(() => reset(["--state", statePath, "--mode", "full"]));
130
+
131
+ const updated = await readState(statePath);
132
+ expect(updated.mode).toBe("failed");
133
+ expect(updated.phase.current).toBe("failed");
134
+ expect(updated.content_review.status).toBe("unchecked");
135
+ expect(updated.redo_hint).toBeNull();
136
+ });
137
+
138
+ test("invalid mode throws descriptive error", async () => {
139
+ const workspace = await makeTempDir("zzhub-reset-invalid-");
140
+ const statePath = join(workspace, "state.json");
141
+
142
+ const state = defaultState();
143
+ state.workspace_root = workspace;
144
+ state.state_path = statePath;
145
+ await writeState(statePath, state);
146
+
147
+ expect(
148
+ reset(["--state", statePath, "--mode", "bogus"]),
149
+ ).rejects.toThrow("Invalid reset mode: bogus");
150
+ });
151
+ });