@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,138 @@
1
+ /**
2
+ * Adapter loader — resolves and validates rendering plugins.
3
+ *
4
+ * Priority:
5
+ * 1. User-provided plugin (via config.plugins, loaded via dynamic import)
6
+ * 2. Built-in adapter (builtin-imgx / builtin-wechat-preview)
7
+ */
8
+
9
+ import type { PipelineConfig } from "./schema/config";
10
+ import type {
11
+ ImageRenderPlugin,
12
+ MarkdownRenderPlugin,
13
+ PipelinePluginDoctorCheck,
14
+ } from "./adapter-types";
15
+ import { builtinImageRenderer } from "./adapters/builtin-image-renderer";
16
+ import { builtinMarkdownRenderer } from "./adapters/builtin-markdown-renderer";
17
+
18
+ // ── Validation ───────────────────────────────────────────────────
19
+
20
+ function validateImageRenderPlugin(plugin: unknown, specifier: string): ImageRenderPlugin {
21
+ if (!plugin || typeof plugin !== "object") {
22
+ throw new Error(`image renderer plugin "${specifier}" must export an object`);
23
+ }
24
+ const p = plugin as Record<string, unknown>;
25
+ if (typeof p.name !== "string" || !p.name) {
26
+ throw new Error(`image renderer plugin "${specifier}" must have a string "name" property`);
27
+ }
28
+ if (typeof p.render !== "function") {
29
+ throw new Error(`image renderer plugin "${specifier}" must have a "render" function`);
30
+ }
31
+ return plugin as ImageRenderPlugin;
32
+ }
33
+
34
+ function validateMarkdownRenderPlugin(plugin: unknown, specifier: string): MarkdownRenderPlugin {
35
+ if (!plugin || typeof plugin !== "object") {
36
+ throw new Error(`markdown renderer plugin "${specifier}" must export an object`);
37
+ }
38
+ const p = plugin as Record<string, unknown>;
39
+ if (typeof p.name !== "string" || !p.name) {
40
+ throw new Error(`markdown renderer plugin "${specifier}" must have a string "name" property`);
41
+ }
42
+ if (typeof p.render !== "function") {
43
+ throw new Error(`markdown renderer plugin "${specifier}" must have a "render" function`);
44
+ }
45
+ return plugin as MarkdownRenderPlugin;
46
+ }
47
+
48
+ // ── Dynamic import ───────────────────────────────────────────────
49
+
50
+ async function importPlugin(specifier: string): Promise<unknown> {
51
+ try {
52
+ const mod = await import(specifier);
53
+ // Support both default exports and named `plugin` exports
54
+ return mod.default ?? mod.plugin ?? mod;
55
+ } catch (err) {
56
+ const message = err instanceof Error ? err.message : String(err);
57
+ throw new Error(`failed to load plugin "${specifier}": ${message}`);
58
+ }
59
+ }
60
+
61
+ // ── Public API ───────────────────────────────────────────────────
62
+
63
+ /**
64
+ * Resolve the image renderer plugin.
65
+ * If config.plugins.imageRenderer is set, loads it via dynamic import.
66
+ * Otherwise returns the built-in imgx adapter.
67
+ */
68
+ export async function resolveImageRenderer(
69
+ config: PipelineConfig,
70
+ ): Promise<ImageRenderPlugin> {
71
+ const specifier = config.plugins.imageRenderer;
72
+
73
+ if (specifier && specifier.trim()) {
74
+ const mod = await importPlugin(specifier.trim());
75
+ return validateImageRenderPlugin(mod, specifier.trim());
76
+ }
77
+
78
+ return builtinImageRenderer;
79
+ }
80
+
81
+ /**
82
+ * Resolve the markdown renderer plugin.
83
+ * If config.plugins.markdownRenderer is set, loads it via dynamic import.
84
+ * Otherwise returns the built-in wechat-preview adapter.
85
+ */
86
+ export async function resolveMarkdownRenderer(
87
+ config: PipelineConfig,
88
+ ): Promise<MarkdownRenderPlugin> {
89
+ const specifier = config.plugins.markdownRenderer;
90
+
91
+ if (specifier && specifier.trim()) {
92
+ const mod = await importPlugin(specifier.trim());
93
+ return validateMarkdownRenderPlugin(mod, specifier.trim());
94
+ }
95
+
96
+ return builtinMarkdownRenderer;
97
+ }
98
+
99
+ /**
100
+ * Run doctor checks on resolved plugins.
101
+ */
102
+ export async function runPluginDoctorChecks(
103
+ config: PipelineConfig,
104
+ ): Promise<PipelinePluginDoctorCheck[]> {
105
+ const checks: PipelinePluginDoctorCheck[] = [];
106
+
107
+ try {
108
+ const imageRenderer = await resolveImageRenderer(config);
109
+ if (imageRenderer.doctor) {
110
+ checks.push(...(await imageRenderer.doctor()));
111
+ } else {
112
+ checks.push({ name: imageRenderer.name, ok: true, message: "no doctor check defined" });
113
+ }
114
+ } catch (err) {
115
+ checks.push({
116
+ name: "image-renderer",
117
+ ok: false,
118
+ message: err instanceof Error ? err.message : String(err),
119
+ });
120
+ }
121
+
122
+ try {
123
+ const markdownRenderer = await resolveMarkdownRenderer(config);
124
+ if (markdownRenderer.doctor) {
125
+ checks.push(...(await markdownRenderer.doctor()));
126
+ } else {
127
+ checks.push({ name: markdownRenderer.name, ok: true, message: "no doctor check defined" });
128
+ }
129
+ } catch (err) {
130
+ checks.push({
131
+ name: "markdown-renderer",
132
+ ok: false,
133
+ message: err instanceof Error ? err.message : String(err),
134
+ });
135
+ }
136
+
137
+ return checks;
138
+ }
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Adapter interfaces for pluggable rendering.
3
+ *
4
+ * These define the contract between the zzp core pipeline and the
5
+ * rendering subsystems (image rendering and markdown rendering).
6
+ *
7
+ * Built-in adapters wrap the existing imgx and wechat-preview modules.
8
+ * User-provided adapters can replace them via config.plugins.
9
+ */
10
+
11
+ import type { RenderAsset, RoutePrimary, WorkflowState } from "./state";
12
+
13
+ // ── Doctor check ──────────────────────────────────────────────────
14
+
15
+ export interface PipelinePluginDoctorCheck {
16
+ name: string;
17
+ ok: boolean;
18
+ message?: string;
19
+ detail?: unknown;
20
+ }
21
+
22
+ // ── Image renderer adapter ────────────────────────────────────────
23
+
24
+ /**
25
+ * Input for image rendering. Maps to the parameters currently passed
26
+ * to runRenderArticleCli and runRenderCardCli via CLI argv.
27
+ */
28
+ export interface ImageRenderInput {
29
+ /** Workflow state providing context (route, metadata, etc.) */
30
+ state: WorkflowState;
31
+
32
+ /** Markdown body text to render */
33
+ bodyText: string;
34
+
35
+ /** Output directory for rendered images */
36
+ outputDir: string;
37
+
38
+ /** Page title */
39
+ title: string;
40
+
41
+ /** Route determines template selection */
42
+ route: RoutePrimary;
43
+
44
+ /**
45
+ * Optional explicit page specifications.
46
+ * For wechat-newspic: page markers, image layouts, fill ratios.
47
+ * For wechat-article: typically empty (auto-pagination).
48
+ */
49
+ pageSpecs?: Array<{
50
+ page: number;
51
+ imageMarkers?: string[];
52
+ imageLayout?: string | null;
53
+ targetFillRatio?: number | null;
54
+ note?: string | null;
55
+ }>;
56
+
57
+ /** Body images to embed in rendered pages */
58
+ bodyImages?: Array<{
59
+ marker: string;
60
+ path: string;
61
+ }>;
62
+
63
+ /** Minimum number of pages (for longform) */
64
+ minPages?: number;
65
+
66
+ /** Maximum number of pages (0 = no limit) */
67
+ maxPages?: number;
68
+
69
+ /** Account-specific visual params (footer text, colors, icon) */
70
+ accountVisualParams?: {
71
+ footer?: string;
72
+ bg?: string;
73
+ highlight?: string;
74
+ fallbackIcon?: string;
75
+ };
76
+
77
+ /** Theme name override */
78
+ theme?: string;
79
+
80
+ /** Template name override */
81
+ template?: string;
82
+ }
83
+
84
+ /**
85
+ * Output from image rendering.
86
+ */
87
+ export interface ImageRenderOutput {
88
+ /** Rendered assets (cover image + page images) */
89
+ assets: RenderAsset[];
90
+
91
+ /** Number of pages rendered */
92
+ pageCount: number;
93
+
94
+ /** Per-page summary */
95
+ pages: Array<{
96
+ page: number;
97
+ imageCount: number;
98
+ imageSources: string[];
99
+ }>;
100
+ }
101
+
102
+ export interface ImageRenderPlugin {
103
+ name: string;
104
+ version?: string;
105
+
106
+ /** Environment checks (e.g., Chrome availability) */
107
+ doctor?: () => Promise<PipelinePluginDoctorCheck[]>;
108
+
109
+ /** Render images from markdown body */
110
+ render: (input: ImageRenderInput) => Promise<ImageRenderOutput>;
111
+ }
112
+
113
+ // ── Markdown renderer adapter ─────────────────────────────────────
114
+
115
+ /**
116
+ * Input for markdown-to-HTML rendering.
117
+ * Maps to ExportMarkdownToWechatHtmlInput from wechat-preview.
118
+ */
119
+ export interface MarkdownRenderInput {
120
+ /** Path to the markdown source file */
121
+ markdownPath: string;
122
+
123
+ /** Output path for the rendered HTML */
124
+ outPath: string;
125
+
126
+ /** WeChat account name (for theme/style selection) */
127
+ account: string;
128
+
129
+ /** Article title */
130
+ title?: string;
131
+
132
+ /** Optional path for preview shell HTML output */
133
+ previewShellOutPath?: string;
134
+
135
+ /** Path to custom CSS file for style overrides */
136
+ customCss?: string | null;
137
+
138
+ /** Workflow state for context */
139
+ state?: WorkflowState;
140
+ }
141
+
142
+ /**
143
+ * Output from markdown rendering.
144
+ * Maps to ExportMarkdownToWechatHtmlResult from wechat-preview.
145
+ */
146
+ export interface MarkdownRenderOutput {
147
+ /** Rendered HTML content */
148
+ html: string;
149
+
150
+ /** Path to the written HTML file */
151
+ htmlPath: string;
152
+
153
+ /** Account used for rendering */
154
+ account: string;
155
+
156
+ /** CSS style used for preview */
157
+ previewStyle: string;
158
+
159
+ /** Path to preview shell HTML (if generated) */
160
+ previewShellPath?: string;
161
+ }
162
+
163
+ export interface MarkdownRenderPlugin {
164
+ name: string;
165
+ version?: string;
166
+
167
+ /** Environment checks */
168
+ doctor?: () => Promise<PipelinePluginDoctorCheck[]>;
169
+
170
+ /** Render markdown to WeChat-compatible HTML */
171
+ render: (input: MarkdownRenderInput) => Promise<MarkdownRenderOutput>;
172
+ }
@@ -0,0 +1,353 @@
1
+ /**
2
+ * Built-in image renderer adapter — wraps imgx (runRenderCardCli + runRenderArticleCli).
3
+ *
4
+ * Implements ImageRenderPlugin interface. This is the default adapter used
5
+ * when no user-provided plugin is configured.
6
+ */
7
+
8
+ import { mkdir, readdir, rm, rmdir, writeFile } from "fs/promises";
9
+ import { join } from "path";
10
+
11
+ import { runRenderArticleCli, runRenderCardCli } from "../imgx";
12
+ import type { RenderArticleResult } from "../imgx";
13
+ import {
14
+ removeIllustrationMarkers,
15
+ removePageMarkers,
16
+ compressBlankLines,
17
+ generateCoverTitle,
18
+ } from "../text";
19
+ import { getLongformTheme } from "../routes";
20
+ import { resolveWorkspacePaths } from "../config";
21
+ import type {
22
+ ImageRenderPlugin,
23
+ ImageRenderInput,
24
+ ImageRenderOutput,
25
+ PipelinePluginDoctorCheck,
26
+ } from "../adapter-types";
27
+ import type {
28
+ NewspicRenderSpec,
29
+ RenderAsset,
30
+ AccountVisualParams,
31
+ } from "../state";
32
+
33
+ // ── Helpers ──────────────────────────────────────────────────────
34
+
35
+ function appendPosterVisualArgs(
36
+ cmdParts: string[],
37
+ visualParams: AccountVisualParams | null,
38
+ highlightWords: string[],
39
+ ): void {
40
+ if (visualParams) {
41
+ cmdParts.push("--highlight", visualParams.highlight);
42
+ cmdParts.push("--bg", visualParams.bg);
43
+ cmdParts.push("--footer", visualParams.footer);
44
+ cmdParts.push("--fallback-icon", visualParams.fallback_icon);
45
+ }
46
+ cmdParts.push("--highlight-words", highlightWords.join(","));
47
+ }
48
+
49
+ type ResolvedPageImageSpecFile = {
50
+ default_image_layout: string;
51
+ target_fill_ratio: number;
52
+ page_specs: Array<{
53
+ page: number;
54
+ image_layout?: string;
55
+ target_fill_ratio?: number;
56
+ images: Array<{ src: string; alt: string; caption: string }>;
57
+ }>;
58
+ };
59
+
60
+ function buildExplicitPageImageSpec(
61
+ spec: NewspicRenderSpec,
62
+ received: Array<{ marker: string; path: string }>,
63
+ ): ResolvedPageImageSpecFile | null {
64
+ if (!spec.page_specs || spec.page_specs.length === 0) {
65
+ return null;
66
+ }
67
+
68
+ const receivedMap = new Map(received.map((item) => [item.marker, item.path]));
69
+ const pageSpecs = spec.page_specs.map((pageSpec) => {
70
+ const images = pageSpec.image_markers
71
+ .map((marker) => {
72
+ const path = receivedMap.get(marker);
73
+ if (!path) return null;
74
+ return { src: path, alt: marker, caption: "" };
75
+ })
76
+ .filter((item): item is { src: string; alt: string; caption: string } => item !== null);
77
+
78
+ return {
79
+ page: pageSpec.page,
80
+ image_layout: pageSpec.image_layout ?? spec.default_image_layout,
81
+ target_fill_ratio: pageSpec.target_fill_ratio ?? spec.target_fill_ratio,
82
+ images,
83
+ };
84
+ });
85
+
86
+ return {
87
+ default_image_layout: spec.default_image_layout,
88
+ target_fill_ratio: spec.target_fill_ratio,
89
+ page_specs: pageSpecs,
90
+ };
91
+ }
92
+
93
+ function validateNewspicRenderResult(
94
+ spec: NewspicRenderSpec,
95
+ result: { pageCount: number; pages: Array<{ page: number; imageCount: number }> },
96
+ ): string[] {
97
+ const errors: string[] = [];
98
+
99
+ if (result.pageCount < spec.min_pages) {
100
+ errors.push(
101
+ `newspic render produced ${result.pageCount} page(s), but at least ${spec.min_pages} page(s) were requested`,
102
+ );
103
+ }
104
+
105
+ if (spec.require_image_every_page) {
106
+ const missingPages = result.pages
107
+ .filter((page) => page.imageCount < 1)
108
+ .map((page) => page.page);
109
+ if (missingPages.length > 0) {
110
+ errors.push(`pages without required body images: ${missingPages.join(", ")}`);
111
+ }
112
+ }
113
+
114
+ for (const pageSpec of spec.page_specs) {
115
+ if (pageSpec.image_markers.length === 0) continue;
116
+ const page = result.pages.find((item) => item.page === pageSpec.page);
117
+ if (!page) {
118
+ errors.push(
119
+ `page ${pageSpec.page} was explicitly configured but render only produced ${result.pageCount} page(s)`,
120
+ );
121
+ continue;
122
+ }
123
+ if (page.imageCount < pageSpec.image_markers.length) {
124
+ errors.push(
125
+ `page ${pageSpec.page} expected ${pageSpec.image_markers.length} body image(s), but render placed ${page.imageCount}`,
126
+ );
127
+ }
128
+ }
129
+
130
+ return errors;
131
+ }
132
+
133
+ // ── Render helpers ───────────────────────────────────────────────
134
+
135
+ async function renderCover(
136
+ template: string,
137
+ title: string,
138
+ outputDir: string,
139
+ visualParams: AccountVisualParams | null,
140
+ highlightWords: string[],
141
+ ): Promise<RenderAsset> {
142
+ const coverOut = join(outputDir, "cover.png");
143
+ const route = template === "wechat-cover-split" ? "wechat-article" : "wechat-newspic";
144
+
145
+ if (template === "wechat-cover-split") {
146
+ const cmdParts = ["--template", "wechat-cover-split", "--text", title];
147
+ cmdParts.push("--highlight-words", highlightWords.join(","));
148
+ cmdParts.push("--out", coverOut);
149
+ runRenderCardCli(cmdParts);
150
+ } else {
151
+ const cmdParts = ["--template", "poster-3-4", "--text", title];
152
+ appendPosterVisualArgs(cmdParts, visualParams, highlightWords);
153
+ cmdParts.push("--out", coverOut);
154
+ runRenderCardCli(cmdParts);
155
+ }
156
+
157
+ return { kind: "cover", route: route as RenderAsset["route"], path: coverOut };
158
+ }
159
+
160
+ async function renderLongformPages(
161
+ input: ImageRenderInput,
162
+ outputDir: string,
163
+ newspicRenderSpec: NewspicRenderSpec,
164
+ bodyImages: Array<{ marker: string; path: string }>,
165
+ vp: AccountVisualParams | null,
166
+ ): Promise<{ assets: RenderAsset[]; pageCount: number; pages: ImageRenderOutput["pages"] }> {
167
+ const state = input.state;
168
+ const cleanBody = removeIllustrationMarkers(input.bodyText);
169
+
170
+ const explicitPageImageSpec = buildExplicitPageImageSpec(newspicRenderSpec, bodyImages);
171
+
172
+ let pageBody = cleanBody;
173
+ if (explicitPageImageSpec === null) {
174
+ pageBody = removePageMarkers(pageBody);
175
+ }
176
+ pageBody = compressBlankLines(pageBody);
177
+
178
+ const workspacePaths = resolveWorkspacePaths(state.workspace_root);
179
+ const tempRunDir = join(workspacePaths.tempRoot, state.run_id);
180
+ const tempDir = join(tempRunDir, "render-longform");
181
+ const tempBodyPath = join(tempDir, "body.md");
182
+ const tempPageImageSpecPath = join(tempDir, "page-images.json");
183
+ const keepTempFiles = process.env.ZZHUB_PIPELINE_KEEP_TMP === "1";
184
+ await mkdir(tempDir, { recursive: true });
185
+ await writeFile(tempBodyPath, pageBody, "utf-8");
186
+
187
+ const theme = input.theme ?? getLongformTheme(state.route.account);
188
+ const pageParts = [
189
+ "--title", input.title,
190
+ "--text-file", tempBodyPath,
191
+ "--out-dir", outputDir,
192
+ "--theme", theme,
193
+ ];
194
+
195
+ if (newspicRenderSpec.min_pages > 1) {
196
+ pageParts.push("--min-pages", String(newspicRenderSpec.min_pages));
197
+ }
198
+ if (newspicRenderSpec.max_pages > 0) {
199
+ pageParts.push("--max-pages", String(newspicRenderSpec.max_pages));
200
+ }
201
+
202
+ if (vp?.footer) {
203
+ pageParts.push("--footer", vp.footer);
204
+ }
205
+
206
+ const highlightWords = input.state.route.highlight_words ?? [];
207
+ if (highlightWords.length > 0) {
208
+ pageParts.push("--highlight-words", highlightWords.join(","));
209
+ }
210
+
211
+ if (explicitPageImageSpec) {
212
+ await writeFile(tempPageImageSpecPath, JSON.stringify(explicitPageImageSpec, null, 2), "utf-8");
213
+ pageParts.push("--page-image-spec-file", tempPageImageSpecPath);
214
+ }
215
+
216
+ if (bodyImages.length > 0 && !explicitPageImageSpec) {
217
+ for (const img of bodyImages) {
218
+ pageParts.push("--body-image", img.path);
219
+ }
220
+ pageParts.push(
221
+ "--image-layout",
222
+ newspicRenderSpec.default_image_layout,
223
+ );
224
+ }
225
+
226
+ let result: RenderArticleResult;
227
+ try {
228
+ result = runRenderArticleCli(pageParts);
229
+ } finally {
230
+ if (!keepTempFiles) {
231
+ await rm(tempDir, { recursive: true, force: true });
232
+ await rmdir(tempRunDir).catch(() => undefined);
233
+ }
234
+ }
235
+
236
+ const errors = validateNewspicRenderResult(newspicRenderSpec, result);
237
+ if (errors.length > 0) {
238
+ throw new Error(`newspic render constraints not satisfied: ${errors.join("; ")}`);
239
+ }
240
+
241
+ // Discover generated page files
242
+ const files = await readdir(outputDir);
243
+ const pageFiles = files.filter((f) => /^article-\d+\.png$/.test(f)).sort();
244
+
245
+ const assets: RenderAsset[] = [];
246
+ for (let i = 0; i < pageFiles.length; i++) {
247
+ assets.push({
248
+ kind: "page",
249
+ route: "wechat-newspic",
250
+ index: i + 1,
251
+ path: join(outputDir, pageFiles[i]),
252
+ });
253
+ }
254
+
255
+ return { assets, pageCount: result.pageCount, pages: result.pages };
256
+ }
257
+
258
+ // ── Plugin implementation ────────────────────────────────────────
259
+
260
+ export const builtinImageRenderer: ImageRenderPlugin = {
261
+ name: "builtin-imgx",
262
+ version: "1.0.0",
263
+
264
+ async doctor(): Promise<PipelinePluginDoctorCheck[]> {
265
+ const checks: PipelinePluginDoctorCheck[] = [];
266
+
267
+ // Check @napi-rs/canvas
268
+ try {
269
+ await import("@napi-rs/canvas");
270
+ checks.push({ name: "@napi-rs/canvas", ok: true });
271
+ } catch {
272
+ checks.push({
273
+ name: "@napi-rs/canvas",
274
+ ok: false,
275
+ message:
276
+ "@napi-rs/canvas not installed. Install it with:\n" +
277
+ " npm install @napi-rs/canvas\n" +
278
+ " # or: bun add @napi-rs/canvas\n" +
279
+ "This is required for image rendering (poster, longform, cover).",
280
+ });
281
+ }
282
+
283
+ return checks;
284
+ },
285
+
286
+ async render(input: ImageRenderInput): Promise<ImageRenderOutput> {
287
+ const { state, outputDir, title, route } = input;
288
+ const highlightWords = state.route.highlight_words ?? [];
289
+
290
+ // Normalize visual params: adapter input uses camelCase, state uses snake_case
291
+ const adapterVp = input.accountVisualParams;
292
+ const stateVp = state.route.account_visual_params;
293
+ const vp: AccountVisualParams | null = adapterVp
294
+ ? {
295
+ footer: adapterVp.footer ?? "",
296
+ bg: adapterVp.bg ?? "",
297
+ highlight: adapterVp.highlight ?? "",
298
+ fallback_icon: adapterVp.fallbackIcon ?? "",
299
+ }
300
+ : stateVp;
301
+
302
+ await mkdir(outputDir, { recursive: true });
303
+
304
+ if (route === "wechat-article") {
305
+ // Article: cover only (wechat-cover-split)
306
+ const cover = await renderCover("wechat-cover-split", title, outputDir, null, highlightWords);
307
+ return { assets: [cover], pageCount: 1, pages: [{ page: 1, imageCount: 0, imageSources: [] }] };
308
+ }
309
+
310
+ if (route === "blog") {
311
+ return { assets: [], pageCount: 0, pages: [] };
312
+ }
313
+
314
+ // wechat-newspic: determine short vs long
315
+ const newspicRenderSpec: NewspicRenderSpec = {
316
+ pagination_mode: input.pageSpecs && input.pageSpecs.length > 0 ? "multi" : "single",
317
+ min_pages: input.minPages ?? 1,
318
+ max_pages: input.maxPages ?? 0,
319
+ require_image_every_page: false,
320
+ default_image_layout: "staggered",
321
+ target_fill_ratio: 0.8,
322
+ page_specs: (input.pageSpecs ?? []).map((ps) => ({
323
+ page: ps.page,
324
+ image_markers: ps.imageMarkers ?? [],
325
+ image_layout: ps.imageLayout ?? null,
326
+ target_fill_ratio: ps.targetFillRatio ?? null,
327
+ note: ps.note ?? null,
328
+ })),
329
+ };
330
+
331
+ const isLong = newspicRenderSpec.pagination_mode === "multi";
332
+
333
+ if (!isLong) {
334
+ // Short: single poster cover
335
+ const coverTitle = generateCoverTitle(title);
336
+ const cover = await renderCover("poster-3-4", coverTitle, outputDir, vp, highlightWords);
337
+ return { assets: [cover], pageCount: 1, pages: [{ page: 1, imageCount: 0, imageSources: [] }] };
338
+ }
339
+
340
+ // Long: cover + article pages
341
+ const coverTitle = generateCoverTitle(title);
342
+ const cover = await renderCover("poster-3-4", coverTitle, outputDir, vp, highlightWords);
343
+
344
+ const bodyImages = input.bodyImages ?? [];
345
+ const pageResult = await renderLongformPages(input, outputDir, newspicRenderSpec, bodyImages, vp);
346
+
347
+ return {
348
+ assets: [cover, ...pageResult.assets],
349
+ pageCount: pageResult.pageCount,
350
+ pages: pageResult.pages,
351
+ };
352
+ },
353
+ };