@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,348 @@
1
+ /**
2
+ * prepare-finalize — Complete the Prepare phase:
3
+ * 1. channel-route 2nd pass: generate highlight_words from final title
4
+ * 2. asset-save: create asset directory, write post.md with frontmatter, write canonical state
5
+ *
6
+ * Called after style (if needed) and format are done.
7
+ * This is the boundary between temp run state and canonical asset state.
8
+ *
9
+ * Usage:
10
+ * zzhub-pipeline prepare-finalize \
11
+ * --state /path/to/state.json \
12
+ * --body /path/to/body_formatted.md \
13
+ * [--workspace /abs/workspace]
14
+ *
15
+ * Output: asset_path, canonical state_path
16
+ */
17
+
18
+ import { access, copyFile, readFile, writeFile, mkdir } from "fs/promises";
19
+ import { basename, dirname, isAbsolute, join, resolve } from "path";
20
+ import { parseArgs, requireArg, optionalArg } from "../args";
21
+ import { printResult, renderPrepareFinalize } from "../output";
22
+ import {
23
+ loadConfig,
24
+ renderPostsRelativePath,
25
+ resolveWorkspacePaths,
26
+ resolveWorkspaceRoot,
27
+ } from "../config";
28
+ import {
29
+ readState,
30
+ writeState,
31
+ getCanonicalStatePath,
32
+ } from "../state";
33
+ import {
34
+ extractHighlightWords,
35
+ buildFrontmatter,
36
+ formatArticle,
37
+ stripLeadingTitleHeading,
38
+ removePageMarkers,
39
+ } from "../text";
40
+
41
+ async function pathExists(path: string): Promise<boolean> {
42
+ try {
43
+ await access(path);
44
+ return true;
45
+ } catch {
46
+ return false;
47
+ }
48
+ }
49
+
50
+ function isExternalAssetRef(value: string): boolean {
51
+ return /^(https?:|data:|blob:|\/\/)/i.test(value);
52
+ }
53
+
54
+ function normalizeSafeRelativeAssetPath(rawPath: string): string | null {
55
+ const normalized = rawPath
56
+ .trim()
57
+ .replace(/^file:\/\//i, "")
58
+ .replaceAll("\\", "/")
59
+ .replace(/^[.]\//, "")
60
+ .replace(/[?#].*$/, "");
61
+
62
+ if (
63
+ !normalized ||
64
+ normalized.startsWith("/") ||
65
+ normalized.startsWith("../") ||
66
+ normalized.includes("/../")
67
+ ) {
68
+ return null;
69
+ }
70
+
71
+ return normalized;
72
+ }
73
+
74
+ function formatRelativeAssetRef(relativePath: string): string {
75
+ return relativePath.startsWith(".") ? relativePath : `./${relativePath}`;
76
+ }
77
+
78
+ function uniqueRelativeAssetPath(
79
+ targetRelativePath: string,
80
+ sourcePath: string,
81
+ usedTargetPaths: Map<string, string>,
82
+ ): string {
83
+ const normalizedTarget = targetRelativePath.replaceAll("\\", "/");
84
+ const existingSource = usedTargetPaths.get(normalizedTarget);
85
+ if (!existingSource || existingSource === sourcePath) {
86
+ usedTargetPaths.set(normalizedTarget, sourcePath);
87
+ return normalizedTarget;
88
+ }
89
+
90
+ const fileName = basename(normalizedTarget);
91
+ const extensionIndex = fileName.lastIndexOf(".");
92
+ const name = extensionIndex > 0 ? fileName.slice(0, extensionIndex) : fileName;
93
+ const ext = extensionIndex > 0 ? fileName.slice(extensionIndex) : "";
94
+ const directory = dirname(normalizedTarget);
95
+
96
+ let suffix = 2;
97
+ while (true) {
98
+ const candidateLeaf = `${name}-${suffix}${ext}`;
99
+ const candidate = directory === "."
100
+ ? candidateLeaf
101
+ : join(directory, candidateLeaf).replaceAll("\\", "/");
102
+ const candidateSource = usedTargetPaths.get(candidate);
103
+ if (!candidateSource || candidateSource === sourcePath) {
104
+ usedTargetPaths.set(candidate, sourcePath);
105
+ return candidate;
106
+ }
107
+ suffix += 1;
108
+ }
109
+ }
110
+
111
+ async function materializeInlineAssets(options: {
112
+ body: string;
113
+ bodySourceDir: string;
114
+ assetPath: string;
115
+ }): Promise<string> {
116
+ const usedTargetPaths = new Map<string, string>();
117
+ const copyJobs = new Map<string, { sourcePath: string; targetRelativePath: string }>();
118
+
119
+ const registerAsset = (rawSource: string): string | null => {
120
+ const trimmed = rawSource.trim();
121
+ if (!trimmed || isExternalAssetRef(trimmed)) {
122
+ return null;
123
+ }
124
+
125
+ const sourcePath = trimmed.startsWith("file://")
126
+ ? new URL(trimmed).pathname
127
+ : isAbsolute(trimmed)
128
+ ? trimmed
129
+ : resolve(options.bodySourceDir, trimmed);
130
+
131
+ const safeRelativePath = normalizeSafeRelativeAssetPath(trimmed);
132
+ const targetRelativePath = uniqueRelativeAssetPath(
133
+ safeRelativePath ?? join("_inline-assets", basename(sourcePath)).replaceAll("\\", "/"),
134
+ sourcePath,
135
+ usedTargetPaths,
136
+ );
137
+
138
+ copyJobs.set(sourcePath, { sourcePath, targetRelativePath });
139
+ return targetRelativePath;
140
+ };
141
+
142
+ let rewritten = options.body.replace(
143
+ /!\[([^\]]*)\]\((<)?([^)]+?)(>)?\)/g,
144
+ (match, alt, open, src) => {
145
+ const targetRelativePath = registerAsset(String(src ?? ""));
146
+ if (!targetRelativePath) {
147
+ return match;
148
+ }
149
+ return `![${alt}](${open ? "<" : ""}${formatRelativeAssetRef(targetRelativePath)}${open ? ">" : ""})`;
150
+ },
151
+ );
152
+
153
+ rewritten = rewritten.replace(
154
+ /(<img\b[^>]*\bsrc=["'])([^"']+)(["'][^>]*>)/gi,
155
+ (match, prefix, src, suffix) => {
156
+ const targetRelativePath = registerAsset(String(src ?? ""));
157
+ if (!targetRelativePath) {
158
+ return match;
159
+ }
160
+ return `${prefix}${formatRelativeAssetRef(targetRelativePath)}${suffix}`;
161
+ },
162
+ );
163
+
164
+ for (const job of copyJobs.values()) {
165
+ const destinationPath = join(options.assetPath, job.targetRelativePath);
166
+ await mkdir(dirname(destinationPath), { recursive: true });
167
+ await copyFile(job.sourcePath, destinationPath);
168
+ }
169
+
170
+ return rewritten;
171
+ }
172
+
173
+ function appendSuffixToRelativePath(relativePath: string, suffix: number): string {
174
+ const normalized = relativePath.replace(/\\/g, "/");
175
+ const parent = dirname(normalized);
176
+ const leaf = normalized.split("/").filter(Boolean).pop() || "untitled";
177
+ const suffixedLeaf = `${leaf}-v${suffix}`;
178
+ return parent === "." ? suffixedLeaf : join(parent, suffixedLeaf);
179
+ }
180
+
181
+ export async function prepareFinalize(args: string[]): Promise<void> {
182
+ const parsed = parseArgs(args);
183
+
184
+ if (parsed.help) {
185
+ console.log(`
186
+ Usage: zzhub-pipeline prepare-finalize [options]
187
+
188
+ Options:
189
+ --state Path to state JSON (required)
190
+ --body Path to body text file (optional; defaults to state.formatted_body_path)
191
+ --workspace Workspace root (optional; defaults to state.workspace_root)
192
+ `.trim());
193
+ return;
194
+ }
195
+
196
+ const statePath = requireArg(parsed, "state", "state JSON path");
197
+ const explicitBodyPath = optionalArg(parsed, "body");
198
+ const workspaceOverride = optionalArg(parsed, "workspace");
199
+
200
+ const state = await readState(statePath);
201
+ if (state.content_review.status !== "passed") {
202
+ throw new Error(
203
+ `content_review must be passed before prepare-finalize (current: ${state.content_review.status})`,
204
+ );
205
+ }
206
+ const bodyPath = explicitBodyPath ?? state.formatted_body_path;
207
+ if (!bodyPath) {
208
+ throw new Error(
209
+ "Missing required argument: --body (body text file path). Run prepare first or pass --body explicitly.",
210
+ );
211
+ }
212
+ let body = await readFile(bodyPath, "utf-8");
213
+
214
+ // If style was applied but format wasn't re-applied, apply format now
215
+ // (format is idempotent, safe to re-apply)
216
+ body = formatArticle(body);
217
+ body = stripLeadingTitleHeading(body, state.metadata.title);
218
+
219
+ const config = loadConfig();
220
+ const workspace = resolveWorkspaceRoot(workspaceOverride ?? state.workspace_root, config);
221
+ const workspacePaths = resolveWorkspacePaths(workspace, config);
222
+
223
+ // ── Step 1: Channel route 2nd pass — highlight_words ──
224
+ // If highlight_words were already set explicitly via `prepare --highlight-words`,
225
+ // respect that and skip the auto-extract algorithm.
226
+ const highlightWords =
227
+ state.route.highlight_words && state.route.highlight_words.length > 0
228
+ ? state.route.highlight_words
229
+ : extractHighlightWords(state.metadata.title);
230
+ state.route.highlight_words = highlightWords;
231
+
232
+ // ── Step 2: Asset save ──
233
+
234
+ // Build asset path from configured posts pattern.
235
+ let assetPath = state.asset_path;
236
+ if (!assetPath) {
237
+ const patternUsesSlug = config.paths.postsPathPattern.includes("{slug}");
238
+ const patternUsesTitle = config.paths.postsPathPattern.includes("{title}");
239
+ const baseSlug = state.metadata.slug;
240
+ const baseTitle = state.metadata.title;
241
+ let nextSlug = baseSlug;
242
+ let nextTitle = baseTitle;
243
+ let relativePath = renderPostsRelativePath(config, {
244
+ date: state.metadata.date,
245
+ slug: nextSlug,
246
+ title: nextTitle,
247
+ });
248
+ let candidatePath = join(workspacePaths.postsRoot, relativePath);
249
+ let suffix = 2;
250
+
251
+ while (await pathExists(candidatePath)) {
252
+ if (patternUsesSlug) {
253
+ nextSlug = `${baseSlug}-v${suffix}`;
254
+ relativePath = renderPostsRelativePath(config, {
255
+ date: state.metadata.date,
256
+ slug: nextSlug,
257
+ title: baseTitle,
258
+ });
259
+ } else if (patternUsesTitle) {
260
+ nextTitle = `${baseTitle}-v${suffix}`;
261
+ relativePath = renderPostsRelativePath(config, {
262
+ date: state.metadata.date,
263
+ slug: baseSlug,
264
+ title: nextTitle,
265
+ });
266
+ } else {
267
+ relativePath = appendSuffixToRelativePath(relativePath, suffix);
268
+ }
269
+ candidatePath = join(workspacePaths.postsRoot, relativePath);
270
+ suffix += 1;
271
+ }
272
+
273
+ if (patternUsesSlug && nextSlug !== state.metadata.slug) {
274
+ state.metadata.slug = nextSlug;
275
+ }
276
+ assetPath = candidatePath;
277
+ }
278
+
279
+ // Create directories
280
+ await mkdir(assetPath, { recursive: true });
281
+
282
+ const bodySourceDir = dirname(state.source_body_path ?? bodyPath);
283
+ body = await materializeInlineAssets({
284
+ body,
285
+ bodySourceDir,
286
+ assetPath,
287
+ });
288
+
289
+ // Create image directories based on route
290
+ if (
291
+ state.route.primary === "wechat-article" ||
292
+ state.route.extras.includes("wechat-article")
293
+ ) {
294
+ await mkdir(join(assetPath, "images", "wechat"), { recursive: true });
295
+ }
296
+ if (
297
+ state.route.primary === "wechat-newspic" ||
298
+ state.route.extras.includes("wechat-newspic")
299
+ ) {
300
+ await mkdir(join(assetPath, "images", "newspic"), { recursive: true });
301
+ }
302
+
303
+ // Build frontmatter
304
+ const frontmatter = buildFrontmatter({
305
+ title: state.metadata.title,
306
+ date: state.metadata.date,
307
+ platform: state.route.primary,
308
+ description: state.metadata.description,
309
+ tags: state.metadata.tags,
310
+ });
311
+
312
+ // Write post.md
313
+ const postContent = `${frontmatter}\n\n${removePageMarkers(body)}`;
314
+ const postPath = join(assetPath, "post.md");
315
+ await writeFile(postPath, postContent, "utf-8");
316
+
317
+ // Update state
318
+ state.asset_path = assetPath;
319
+ state.state_path = getCanonicalStatePath(assetPath);
320
+
321
+ // Phase transitions
322
+ state.phase.prepare = { status: "done", error: null };
323
+ state.phase.current = state.intent.requires.render ? "render" :
324
+ state.intent.requires.publish ? "publish" : "done";
325
+
326
+ // Clear redo_hint — prepare sub-sequence is complete
327
+ state.redo_hint = null;
328
+
329
+ // Bump content version
330
+ state.artifacts.content_version += 1;
331
+
332
+ // Write canonical state
333
+ await writeState(state.state_path, state);
334
+
335
+ // Also update the temp run state to point to canonical
336
+ await writeState(statePath, state);
337
+
338
+ // Output summary
339
+ const output = {
340
+ asset_path: assetPath,
341
+ state_path: state.state_path,
342
+ post_path: postPath,
343
+ highlight_words: highlightWords,
344
+ phase: state.phase.current,
345
+ content_version: state.artifacts.content_version,
346
+ };
347
+ printResult(output, renderPrepareFinalize);
348
+ }
@@ -0,0 +1,310 @@
1
+ /**
2
+ * prepare — Execute deterministic Prepare phase steps:
3
+ * 1. channel-route (1st pass): route.primary, extras, account, content_profile, visual_params
4
+ * 2. authoring decision: rewrite_allowed, style_mode
5
+ * 3. format: apply text formatting rules to body
6
+ * 4. asset-meta: title, slug, date, description, tags
7
+ *
8
+ * Style step is NOT included — it requires LLM and stays as a skill.
9
+ * This command reads the run state, applies deterministic transforms,
10
+ * and writes back the updated state.
11
+ *
12
+ * Usage:
13
+ * zzhub-pipeline prepare \
14
+ * --state /path/to/state.json \
15
+ * [--body /path/to/body_raw.md] \
16
+ * [--title "Custom title"] \
17
+ * [--intent-text "发公众号文章"] \
18
+ * [--account default] \
19
+ * [--route wechat-article] \
20
+ * [--extras blog] \
21
+ * [--style-request]
22
+ *
23
+ * Output: Updated state JSON. body_formatted is written to a managed temp file
24
+ * unless --body-out is explicitly provided.
25
+ */
26
+
27
+ import { copyFile, mkdir, readFile, writeFile } from "fs/promises";
28
+ import { extname, join, resolve } from "path";
29
+ import { parseArgs, requireArg, optionalArg, flagArg } from "../args";
30
+ import { printResult, renderPrepare } from "../output";
31
+ import { resolveWorkspacePaths } from "../config";
32
+ import {
33
+ defaultBodyInputs,
34
+ readState,
35
+ writeState,
36
+ } from "../state";
37
+ import { resolveFullRoute } from "../routes";
38
+ import { resolveAuthoring, hasStyleRequest } from "../profiles";
39
+ import {
40
+ formatArticle,
41
+ generateSlug,
42
+ todayDate,
43
+ extractDescription,
44
+ findIllustrationMarkers,
45
+ stripFrontmatter,
46
+ } from "../text";
47
+ import type { RoutePrimary } from "../state";
48
+
49
+ async function stageManagedBodyFile(
50
+ workspaceRoot: string,
51
+ runId: string,
52
+ sourcePath: string,
53
+ ): Promise<string> {
54
+ const tempRoot = resolveWorkspacePaths(workspaceRoot).tempRoot;
55
+ const extension = extname(sourcePath) || ".md";
56
+ const managedDir = join(tempRoot, runId);
57
+ const managedPath = join(managedDir, `source-body${extension}`);
58
+ await mkdir(managedDir, { recursive: true });
59
+ if (resolve(sourcePath) !== resolve(managedPath)) {
60
+ await copyFile(sourcePath, managedPath);
61
+ }
62
+ return managedPath;
63
+ }
64
+
65
+ function getManagedFormattedBodyPath(workspaceRoot: string, runId: string): string {
66
+ return join(resolveWorkspacePaths(workspaceRoot).tempRoot, runId, "formatted-body.md");
67
+ }
68
+
69
+ function shouldPreserveExistingRoute(params: {
70
+ intentText: string;
71
+ routeOverride?: RoutePrimary;
72
+ extrasRaw?: string;
73
+ accountOverride?: string;
74
+ assetPath: string;
75
+ }): boolean {
76
+ return (
77
+ Boolean(params.assetPath) &&
78
+ !params.intentText.trim() &&
79
+ !params.routeOverride &&
80
+ !params.extrasRaw &&
81
+ !params.accountOverride
82
+ );
83
+ }
84
+
85
+ export async function prepare(args: string[]): Promise<void> {
86
+ const parsed = parseArgs(args);
87
+
88
+ if (parsed.help) {
89
+ console.log(`
90
+ Usage: zzhub-pipeline prepare [options]
91
+
92
+ Options:
93
+ --state Path to state JSON (required)
94
+ --body Path to body text file (body_raw or body_styled) (optional if body already attached)
95
+ --title Override title (optional; defaults to first heading or first line)
96
+ --intent-text Original user intent text for route resolution (optional)
97
+ --account Override account (optional)
98
+ --route Override primary route (optional)
99
+ --extras Comma-separated extra routes (optional)
100
+ --style-request Flag: user requested style processing
101
+ --body-out Path to write formatted body (optional; defaults to managed temp path)
102
+ --suggested-title Suggested title from writer (optional)
103
+ --suggested-slug Suggested slug from writer (optional; English + hyphens, ≤50 chars; overrides auto-generated slug)
104
+ --highlight-words Comma-separated highlight words for cover (optional; overrides auto-extract in prepare-finalize)
105
+ `.trim());
106
+ return;
107
+ }
108
+
109
+ const statePath = requireArg(parsed, "state", "state JSON path");
110
+ const bodyPath = optionalArg(parsed, "body");
111
+ const titleOverride = optionalArg(parsed, "title");
112
+ const accountOverride = optionalArg(parsed, "account");
113
+ const routeOverride = optionalArg(parsed, "route") as RoutePrimary | undefined;
114
+ const extrasRaw = optionalArg(parsed, "extras");
115
+ const isStyleRequest = flagArg(parsed, "style-request");
116
+ const bodyOutPath = optionalArg(parsed, "body-out");
117
+ const suggestedTitle = optionalArg(parsed, "suggested-title");
118
+ const suggestedSlug = optionalArg(parsed, "suggested-slug");
119
+ const highlightWordsRaw = optionalArg(parsed, "highlight-words");
120
+ const highlightWordsOverride = highlightWordsRaw
121
+ ? highlightWordsRaw.split(",").map((s) => s.trim()).filter(Boolean)
122
+ : undefined;
123
+
124
+ const extras = extrasRaw
125
+ ? (extrasRaw.split(",").map((s) => s.trim()) as RoutePrimary[])
126
+ : undefined;
127
+
128
+ // Read state and body
129
+ const state = await readState(statePath);
130
+ const intentText = optionalArg(parsed, "intent-text") ?? state.intent.intent_text ?? "";
131
+ const resolvedBodyPath = bodyPath
132
+ ? await stageManagedBodyFile(state.workspace_root, state.run_id, bodyPath)
133
+ : state.source_body_path;
134
+ if (!resolvedBodyPath) {
135
+ throw new Error("No body is attached yet. Use attach-body first or pass --body.");
136
+ }
137
+ const bodyRaw = await readFile(resolvedBodyPath, "utf-8");
138
+ const cleanBody = stripFrontmatter(bodyRaw);
139
+ state.source_body_path = resolvedBodyPath;
140
+ state.intent.intent_text = intentText || state.intent.intent_text;
141
+
142
+ // ── Step 1: Channel route (1st pass) ──
143
+ const route = shouldPreserveExistingRoute({
144
+ intentText,
145
+ routeOverride,
146
+ extrasRaw,
147
+ accountOverride,
148
+ assetPath: state.asset_path,
149
+ })
150
+ ? state.route
151
+ : resolveFullRoute(intentText, {
152
+ primary: routeOverride,
153
+ extras,
154
+ account: accountOverride,
155
+ contentForm: state.intent.content_form,
156
+ targets: state.intent.targets,
157
+ });
158
+
159
+ state.route = route;
160
+
161
+ // If caller provided explicit highlight words, store them now so prepare-finalize
162
+ // can skip the auto-extract algorithm.
163
+ if (highlightWordsOverride && highlightWordsOverride.length > 0) {
164
+ state.route.highlight_words = highlightWordsOverride;
165
+ }
166
+
167
+ // Re-derive requires.render and requires.publish based on resolved route.
168
+ // requires.research is intentional (set at init from user request), not touched here.
169
+ // Same for render/publish: if the orchestrator set them at init, keep that value.
170
+ // wechat-article always needs render regardless.
171
+ state.intent.requires.render =
172
+ state.intent.requires.render ||
173
+ route.primary === "wechat-article";
174
+ state.intent.requires.publish =
175
+ state.intent.requires.publish ||
176
+ state.intent.task_kind === "publish";
177
+
178
+ // ── Step 2: Author select ──
179
+ const styleRequest =
180
+ isStyleRequest || hasStyleRequest(intentText);
181
+
182
+ const authoring = resolveAuthoring({
183
+ contentOrigin: state.intent.content_origin,
184
+ styleHint: state.intent.style_hint,
185
+ hasStyleRequest: styleRequest,
186
+ });
187
+
188
+ state.authoring = authoring;
189
+
190
+ // Update requires.style based on authoring result
191
+ state.intent.requires.style =
192
+ authoring.rewrite_allowed && authoring.style_mode !== "none";
193
+
194
+ // ── Step 3: Format ──
195
+ // In the current workflow, Writer/Style already own the LLM rewrite.
196
+ // Prepare records the authoring snapshot, then always formats the final body.
197
+ const bodyFormatted = formatArticle(cleanBody);
198
+
199
+ // ── Step 4: Asset meta ──
200
+ // Determine title
201
+ let title = titleOverride ?? suggestedTitle ?? state.metadata.title.trim();
202
+ if (!title) {
203
+ // Extract from body: first heading or first line
204
+ const lines = cleanBody.split("\n");
205
+ for (const line of lines) {
206
+ const trimmed = line.trim();
207
+ if (!trimmed) continue;
208
+ const headingMatch = trimmed.match(/^#{1,6}\s+(.+)/);
209
+ if (headingMatch) {
210
+ title = headingMatch[1].trim();
211
+ break;
212
+ }
213
+ // Use first non-empty line
214
+ title = trimmed;
215
+ break;
216
+ }
217
+ }
218
+
219
+ // Prefer editor-supplied slug; sanitize it defensively, then fall back to auto-generate
220
+ const slug = suggestedSlug
221
+ ? suggestedSlug
222
+ .toLowerCase()
223
+ .replace(/[^a-z0-9-]+/g, "-")
224
+ .replace(/^-+|-+$/g, "")
225
+ .slice(0, 80) || generateSlug(title)
226
+ : generateSlug(title);
227
+ const date = todayDate();
228
+
229
+ // Description: required for wechat-article, optional otherwise
230
+ let description: string | null = null;
231
+ if (route.primary === "wechat-article") {
232
+ description = extractDescription(bodyFormatted);
233
+ }
234
+
235
+ // Tags: required for blog (can be empty list)
236
+ const tags: string[] = [];
237
+
238
+ state.metadata = {
239
+ title,
240
+ slug,
241
+ date,
242
+ description,
243
+ tags,
244
+ };
245
+
246
+ // ── Scan for illustration markers (for article body_inputs) ──
247
+ const needsArticleInputs =
248
+ route.primary === "wechat-article" ||
249
+ route.extras.includes("wechat-article");
250
+ const keepsNewspicInputs =
251
+ route.primary === "wechat-newspic" ||
252
+ route.extras.includes("wechat-newspic");
253
+
254
+ if (needsArticleInputs) {
255
+ const markers = findIllustrationMarkers(cleanBody);
256
+ if (markers.length > 0) {
257
+ const previousInputs =
258
+ state.images.body_inputs.scope === "article"
259
+ ? state.images.body_inputs
260
+ : null;
261
+ const markerSet = new Set(markers);
262
+ const received = previousInputs
263
+ ? previousInputs.received.filter((item) => markerSet.has(item.marker))
264
+ : [];
265
+ state.images.body_inputs = {
266
+ scope: "article",
267
+ expected: markers.length,
268
+ received,
269
+ status:
270
+ received.length >= markers.length
271
+ ? "ready"
272
+ : "pending",
273
+ layout: previousInputs?.layout ?? "staggered",
274
+ };
275
+ } else {
276
+ state.images.body_inputs = defaultBodyInputs();
277
+ }
278
+ } else if (!keepsNewspicInputs || state.images.body_inputs.scope === "article") {
279
+ state.images.body_inputs = defaultBodyInputs();
280
+ }
281
+
282
+ // ── Write state ──
283
+ state.formatted_body_path =
284
+ bodyOutPath ?? getManagedFormattedBodyPath(state.workspace_root, state.run_id);
285
+ await writeState(statePath, state);
286
+
287
+ // ── Output formatted body ──
288
+ const formattedBodyPath = state.formatted_body_path;
289
+ await mkdir(join(resolveWorkspacePaths(state.workspace_root).tempRoot, state.run_id), { recursive: true });
290
+ await writeFile(formattedBodyPath, bodyFormatted, "utf-8");
291
+
292
+ // Output summary for orchestrator
293
+ const illustrationMarkers = findIllustrationMarkers(cleanBody);
294
+ const output = {
295
+ state_path: statePath,
296
+ route: {
297
+ primary: state.route.primary,
298
+ account: state.route.account,
299
+ },
300
+ metadata: {
301
+ title: state.metadata.title,
302
+ slug: state.metadata.slug,
303
+ date: state.metadata.date,
304
+ },
305
+ requires_style: state.intent.requires.style,
306
+ body_formatted_path: formattedBodyPath,
307
+ ...(illustrationMarkers.length > 0 ? { illustration_markers: illustrationMarkers } : {}),
308
+ };
309
+ printResult(output, renderPrepare);
310
+ }