@zzclub/pipeline 0.10.0 → 0.10.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/README.md +113 -8
  2. package/package.json +4 -4
  3. package/src/adapter-types.ts +21 -0
  4. package/src/adapters/builtin-markdown-renderer.ts +7 -0
  5. package/src/cli.ts +5 -1
  6. package/src/commands/ingest-handoff.ts +1 -21
  7. package/src/commands/prepare-finalize.ts +2 -1
  8. package/src/commands/prepare.ts +30 -19
  9. package/src/commands/publish.ts +2 -3
  10. package/src/commands/reconcile.ts +3 -5
  11. package/src/commands/render.ts +2 -1
  12. package/src/commands/republish.ts +2 -3
  13. package/src/commands/reset.ts +9 -1
  14. package/src/commands/review.ts +3 -2
  15. package/src/commands/sync-blog.ts +2 -1
  16. package/src/commands/wechat-export.test.ts +41 -0
  17. package/src/commands/wechat-export.ts +156 -20
  18. package/src/commands/wechat-preview.ts +160 -0
  19. package/src/config.ts +12 -0
  20. package/src/imgx/runtime.ts +31 -3
  21. package/src/logger.test.ts +56 -0
  22. package/src/logger.ts +178 -0
  23. package/src/plugins.ts +2 -0
  24. package/src/providers/blog.ts +1 -2
  25. package/src/providers/index.ts +35 -3
  26. package/src/publish-targets.ts +4 -5
  27. package/src/routes.ts +9 -1
  28. package/src/schema/config.ts +2 -0
  29. package/src/state.ts +24 -0
  30. package/src/task-manager.test.ts +57 -0
  31. package/src/task-manager.ts +23 -5
  32. package/src/wechat-preview/assets/browser-dist/.vite/manifest.json +1 -302
  33. package/src/wechat-preview/assets/browser-dist/editor-export.js +500 -958
  34. package/src/wechat-preview/browser/assets.d.ts +9 -0
  35. package/src/wechat-preview/browser/editor-export.css +5 -326
  36. package/src/wechat-preview/browser/editor-export.ts +30 -65
  37. package/src/wechat-preview/bundle-stale.test.ts +52 -0
  38. package/src/wechat-preview/fixtures/all-nodes.md +68 -0
  39. package/src/wechat-preview/index.test.ts +17 -0
  40. package/src/wechat-preview/index.ts +424 -180
  41. package/src/wechat-preview/server/client.ts +164 -0
  42. package/src/wechat-preview/server/http.ts +229 -0
  43. package/src/wechat-preview/server/index.ts +33 -0
  44. package/src/wechat-preview/server/local-file.test.ts +52 -0
  45. package/src/wechat-preview/server/local-file.ts +116 -0
  46. package/src/wechat-preview/server/paths.ts +80 -0
  47. package/src/wechat-preview/server/registry.test.ts +60 -0
  48. package/src/wechat-preview/server/registry.ts +118 -0
  49. package/src/wechat-preview/server/singleton.ts +118 -0
  50. package/src/wechat-preview/server/types.ts +61 -0
  51. package/src/wechat-preview/server/ui.ts +324 -0
  52. package/src/wechat-preview/themes.ts +58 -45
  53. package/src/wechat-preview/wechat-export.integration.test.ts +94 -0
  54. package/src/wechat-preview/wechat-renderer.links.test.ts +44 -0
  55. package/src/wechat-preview/wechat-renderer.test.ts +50 -0
  56. package/src/wechat-preview/wechat-renderer.ts +698 -0
  57. package/src/wechat-preview.test.ts +25 -41
  58. package/src/workflow-materials.test.ts +52 -1
  59. package/src/workflow-materials.ts +12 -0
  60. package/src/workflow.test.ts +105 -5
  61. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  62. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  63. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  64. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  65. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  66. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  67. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  68. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  69. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  70. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  71. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  72. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  73. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  74. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  75. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  76. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  77. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  78. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  79. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  80. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  81. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  82. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  83. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  84. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  85. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  86. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  87. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  88. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  89. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  90. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  91. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  92. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  93. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  94. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  95. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  96. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  97. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  98. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  99. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  100. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  101. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  102. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  103. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  104. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  105. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  106. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  107. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  108. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  109. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  110. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  111. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  112. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  113. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  114. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  115. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  116. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  117. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  118. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  119. package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  120. package/src/wechat-preview/assets/browser-dist/assets/style-vhHYSP8T.css +0 -1
  121. package/src/wechat-preview/assets/fonts/SweiCurveLegCJKsc-Regular.woff +0 -0
  122. package/src/wechat-preview/wechat-formatter.test.ts +0 -287
  123. package/src/wechat-preview/wechat-formatter.ts +0 -1531
@@ -1,13 +1,15 @@
1
- import { optionalArg, parseArgs, requireArg } from "../args";
1
+ import { spawn } from "child_process";
2
+ import { resolve } from "path";
3
+ import { flagArg, optionalArg, parseArgs, requireArg } from "../args";
2
4
  import { printResult, renderWechatExport } from "../output";
3
- import { loadConfig } from "../config";
5
+ import { loadConfig, resolveConfigRelativePath } from "../config";
4
6
  import { resolveMarkdownRenderer } from "../adapter-loader";
7
+ import { WechatExportError } from "../wechat-preview";
8
+ import { registerPreviewEntry } from "../wechat-preview/server";
5
9
 
6
10
  const CSS_DEMO = `
7
- Custom CSS is injected as a <style> tag after the built-in Milkdown styles
8
- and before the browser renders. WeChat requires inline styles, so the
9
- rendering pipeline captures computed styles from the DOM — your CSS
10
- cascades naturally and the final inline-styled HTML will reflect it.
11
+ Custom CSS is cascaded after the built-in theme and converted to conservative
12
+ inline styles. Use semantic selectors or stable data-wechat-node hooks.
11
13
 
12
14
  Example custom.css:
13
15
 
@@ -26,6 +28,32 @@ Example custom.css:
26
28
  }
27
29
  `.trim();
28
30
 
31
+ export function resolveWechatExportCustomCss(
32
+ cliCustomCss: string | undefined,
33
+ accountCustomCss: string | null | undefined,
34
+ cwd: string = process.cwd(),
35
+ configPath?: string,
36
+ ): string | null {
37
+ return cliCustomCss
38
+ ? resolve(cwd, cliCustomCss)
39
+ : resolveConfigRelativePath(accountCustomCss, configPath);
40
+ }
41
+
42
+ async function openUrl(url: string): Promise<void> {
43
+ const opener =
44
+ process.platform === "darwin"
45
+ ? "open"
46
+ : process.platform === "win32"
47
+ ? "cmd"
48
+ : "xdg-open";
49
+ const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
50
+ try {
51
+ spawn(opener, args, { detached: true, stdio: "ignore" }).unref();
52
+ } catch {
53
+ // non-fatal
54
+ }
55
+ }
56
+
29
57
  export async function wechatExport(args: string[]): Promise<void> {
30
58
  const parsed = parseArgs(args);
31
59
 
@@ -38,8 +66,14 @@ Options:
38
66
  --out Path to exported html file (required)
39
67
  --account Account/theme key (optional; default: default)
40
68
  --title Page title for the render shell (optional)
41
- --preview-shell-out Path to full Milkdown preview html (optional)
42
- --custom-css Path to custom CSS file for style overrides (optional)
69
+ --preview-shell-out Path to exact final-HTML preview (optional)
70
+ --custom-css CSS override path; replaces account customCss (optional)
71
+ --timeout-ms Chrome virtual-time-budget in ms (default: 15000)
72
+ --debug-dir Write intermediate artifacts for debugging (optional)
73
+ --preview Register result with local preview server (default: true)
74
+ --no-preview Do not register with preview server
75
+ --preview-auto-start Auto-start preview server if needed (default: true)
76
+ --open Open preview URL in browser after export
43
77
 
44
78
  ${CSS_DEMO}
45
79
  `.trim());
@@ -51,20 +85,122 @@ ${CSS_DEMO}
51
85
  const account = optionalArg(parsed, "account") ?? "default";
52
86
  const title = optionalArg(parsed, "title");
53
87
  const previewShellOutPath = optionalArg(parsed, "preview-shell-out");
54
- const customCss = optionalArg(parsed, "custom-css");
88
+ const cliCustomCss = optionalArg(parsed, "custom-css");
89
+ const timeoutRaw = optionalArg(parsed, "timeout-ms");
90
+ const timeoutMs = timeoutRaw ? Number(timeoutRaw) : undefined;
91
+ const debugDir = optionalArg(parsed, "debug-dir");
92
+ const noPreview = flagArg(parsed, "no-preview");
93
+ // Default true unless --no-preview or --preview=false
94
+ const previewExplicit = parsed.preview;
95
+ const preview =
96
+ !noPreview &&
97
+ previewExplicit !== false &&
98
+ previewExplicit !== "false";
99
+ const previewAutoStartExplicit = parsed["preview-auto-start"];
100
+ const previewAutoStart =
101
+ previewAutoStartExplicit !== false &&
102
+ previewAutoStartExplicit !== "false";
103
+ const open = flagArg(parsed, "open");
55
104
 
56
105
  const config = loadConfig();
57
106
  const wxAccount = config.wx.accounts[account] ?? config.wx.accounts[config.wx.defaultAccount];
107
+ const customCss = resolveWechatExportCustomCss(
108
+ cliCustomCss,
109
+ wxAccount?.customCss,
110
+ );
58
111
  const markdownRenderer = await resolveMarkdownRenderer(config);
59
- const result = await markdownRenderer.render({
60
- markdownPath,
61
- outPath,
62
- account,
63
- title,
64
- previewShellOutPath,
65
- customCss,
66
- themeOverrides: wxAccount?.theme,
67
- });
68
-
69
- printResult(result, renderWechatExport);
112
+
113
+ const displayTitle = title ?? "Wechat Preview Export";
114
+
115
+ try {
116
+ const result = await markdownRenderer.render({
117
+ markdownPath,
118
+ outPath,
119
+ account,
120
+ title,
121
+ previewShellOutPath,
122
+ customCss,
123
+ themeOverrides: wxAccount?.theme,
124
+ timeoutMs: timeoutMs !== undefined && Number.isFinite(timeoutMs) ? timeoutMs : undefined,
125
+ debugDir: debugDir ? resolve(process.cwd(), debugDir) : undefined,
126
+ });
127
+
128
+ let preview_url: string | undefined;
129
+ let preview_dashboard_url: string | undefined;
130
+ let preview_register_error: string | undefined;
131
+
132
+ if (preview) {
133
+ const reg = await registerPreviewEntry(
134
+ {
135
+ title: displayTitle,
136
+ account: result.account,
137
+ status: "success",
138
+ duration_ms: result.durationMs ?? 0,
139
+ markdown_path: resolve(process.cwd(), markdownPath),
140
+ html_path: result.htmlPath,
141
+ preview_style: result.previewStyle,
142
+ html: result.html,
143
+ debug: {
144
+ bundle_rebuilt: result.bundleRebuilt,
145
+ bundle_stale: result.bundleStale,
146
+ debug_dir: result.debugDir,
147
+ },
148
+ },
149
+ { autoStart: previewAutoStart },
150
+ );
151
+ if (reg.ok) {
152
+ preview_url = reg.preview_url;
153
+ preview_dashboard_url = reg.dashboard_url;
154
+ if (open && preview_url) await openUrl(preview_url);
155
+ } else {
156
+ preview_register_error = reg.error;
157
+ }
158
+ }
159
+
160
+ // Keep semanticHtml out of default CLI output (large); it still lands in --debug-dir.
161
+ const { semanticHtml: _semanticHtml, ...resultRest } = result;
162
+ printResult(
163
+ {
164
+ ...resultRest,
165
+ preview_url,
166
+ preview_dashboard_url,
167
+ preview_register_error,
168
+ },
169
+ renderWechatExport,
170
+ );
171
+ } catch (error) {
172
+ if (preview && error instanceof WechatExportError) {
173
+ const reg = await registerPreviewEntry(
174
+ {
175
+ title: error.title ?? displayTitle,
176
+ account: error.account,
177
+ status: "failed",
178
+ duration_ms: error.durationMs,
179
+ markdown_path: error.markdownPath,
180
+ error: error.message,
181
+ error_kind: error.kind,
182
+ debug: error.debug,
183
+ },
184
+ { autoStart: previewAutoStart },
185
+ );
186
+ if (reg.ok) {
187
+ printResult(
188
+ {
189
+ ok: false,
190
+ error: error.message,
191
+ error_kind: error.kind,
192
+ duration_ms: error.durationMs,
193
+ debug: error.debug,
194
+ preview_url: reg.preview_url,
195
+ preview_dashboard_url: reg.dashboard_url,
196
+ },
197
+ renderWechatExport,
198
+ );
199
+ if (open && reg.preview_url) await openUrl(reg.preview_url);
200
+ process.exitCode = 1;
201
+ return;
202
+ }
203
+ }
204
+ throw error;
205
+ }
70
206
  }
@@ -0,0 +1,160 @@
1
+ import { spawn } from "child_process";
2
+ import { flagArg, optionalArg, parseArgs } from "../args";
3
+ import { printResult } from "../output";
4
+ import {
5
+ clearPreviewEntries,
6
+ getPreviewServerStatus,
7
+ listPreviewEntries,
8
+ resolvePreviewHost,
9
+ resolvePreviewPort,
10
+ startPreviewServer,
11
+ } from "../wechat-preview/server";
12
+
13
+ async function openUrl(url: string): Promise<void> {
14
+ const opener =
15
+ process.platform === "darwin"
16
+ ? "open"
17
+ : process.platform === "win32"
18
+ ? "cmd"
19
+ : "xdg-open";
20
+ const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
21
+ try {
22
+ spawn(opener, args, { detached: true, stdio: "ignore" }).unref();
23
+ } catch {
24
+ // non-fatal
25
+ }
26
+ }
27
+
28
+ function printHelp(): void {
29
+ console.log(`
30
+ Usage: zzhub-pipeline wechat-preview <subcommand> [options]
31
+
32
+ Subcommands:
33
+ serve Start the singleton local preview server (or print existing URL)
34
+ status Show whether the preview server is running
35
+ open Open the dashboard in a browser
36
+ list List registered preview entries
37
+ clear Clear all preview entries
38
+
39
+ serve options:
40
+ --host Bind host (default: 127.0.0.1, env ZZHUB_WECHAT_PREVIEW_HOST)
41
+ --port Bind port (default: 18765, env ZZHUB_WECHAT_PREVIEW_PORT)
42
+ --open Open the dashboard after start
43
+ --force Fail if a server is already running (default: reuse existing)
44
+
45
+ Environment:
46
+ ZZHUB_WECHAT_PREVIEW_DIR Data/lock directory override
47
+ `.trim());
48
+ }
49
+
50
+ export async function wechatPreview(args: string[]): Promise<void> {
51
+ const sub = args[0];
52
+ const rest = args.slice(1);
53
+ const parsed = parseArgs(rest);
54
+
55
+ if (!sub || sub === "help" || sub === "--help" || parsed.help) {
56
+ printHelp();
57
+ return;
58
+ }
59
+
60
+ if (sub === "serve") {
61
+ const host = optionalArg(parsed, "host");
62
+ const portRaw = optionalArg(parsed, "port");
63
+ const port = portRaw ? Number(portRaw) : undefined;
64
+ const open = flagArg(parsed, "open");
65
+ const force = flagArg(parsed, "force");
66
+
67
+ const result = await startPreviewServer({
68
+ host,
69
+ port: port !== undefined && Number.isFinite(port) ? port : undefined,
70
+ open,
71
+ reuseExisting: !force,
72
+ });
73
+
74
+ if (open) {
75
+ await openUrl(result.url);
76
+ }
77
+
78
+ printResult({
79
+ action: "serve",
80
+ url: result.url,
81
+ host: result.host,
82
+ port: result.port,
83
+ reused: result.reused,
84
+ message: result.reused
85
+ ? `Already running at ${result.url}`
86
+ : `Listening on ${result.url}`,
87
+ });
88
+
89
+ // Keep process alive when we own the server
90
+ if (!result.reused) {
91
+ await new Promise<void>(() => {
92
+ // blocked until SIGINT/SIGTERM
93
+ });
94
+ }
95
+ return;
96
+ }
97
+
98
+ if (sub === "status") {
99
+ const host = optionalArg(parsed, "host");
100
+ const portRaw = optionalArg(parsed, "port");
101
+ const port = portRaw ? Number(portRaw) : undefined;
102
+ const status = await getPreviewServerStatus({
103
+ host: host ?? resolvePreviewHost(),
104
+ port: port !== undefined && Number.isFinite(port) ? port : resolvePreviewPort(),
105
+ });
106
+ printResult({ action: "status", ...status });
107
+ return;
108
+ }
109
+
110
+ if (sub === "open") {
111
+ const status = await getPreviewServerStatus();
112
+ if (!status.running || !status.url) {
113
+ throw new Error(
114
+ "WeChat preview server is not running. Start with: wechat-preview serve",
115
+ );
116
+ }
117
+ await openUrl(status.url);
118
+ printResult({ action: "open", url: status.url });
119
+ return;
120
+ }
121
+
122
+ if (sub === "list") {
123
+ const status = await getPreviewServerStatus();
124
+ if (status.running) {
125
+ // Prefer live server list
126
+ try {
127
+ const res = await fetch(`${status.url}/api/entries`);
128
+ if (res.ok) {
129
+ printResult({ action: "list", entries: await res.json() });
130
+ return;
131
+ }
132
+ } catch {
133
+ // fall through to disk
134
+ }
135
+ }
136
+ printResult({ action: "list", entries: listPreviewEntries(), source: "disk" });
137
+ return;
138
+ }
139
+
140
+ if (sub === "clear") {
141
+ const status = await getPreviewServerStatus();
142
+ if (status.running && status.url) {
143
+ try {
144
+ const res = await fetch(`${status.url}/api/entries`, { method: "DELETE" });
145
+ if (res.ok) {
146
+ const body = (await res.json()) as { cleared?: number };
147
+ printResult({ action: "clear", cleared: body.cleared ?? 0, via: "server" });
148
+ return;
149
+ }
150
+ } catch {
151
+ // fall through
152
+ }
153
+ }
154
+ const cleared = clearPreviewEntries();
155
+ printResult({ action: "clear", cleared, via: "disk" });
156
+ return;
157
+ }
158
+
159
+ throw new Error(`Unknown wechat-preview subcommand: ${sub}`);
160
+ }
package/src/config.ts CHANGED
@@ -85,6 +85,18 @@ export function getPipelineConfigPath(): string {
85
85
  return join(getBaseConfigDir(PIPELINE_CONFIG_DIR), PIPELINE_CONFIG_FILE);
86
86
  }
87
87
 
88
+ /** Resolve a file path stored in config relative to the config file directory. */
89
+ export function resolveConfigRelativePath(
90
+ value: string | null | undefined,
91
+ configPath: string = getPipelineConfigPath(),
92
+ ): string | null {
93
+ const trimmed = value?.trim();
94
+ if (!trimmed) return null;
95
+ return isAbsolute(trimmed)
96
+ ? resolve(trimmed)
97
+ : resolve(dirname(configPath), trimmed);
98
+ }
99
+
88
100
  export function getLegacyZCliConfigPath(): string {
89
101
  return join(getBaseConfigDir(LEGACY_ZCLI_CONFIG_DIR), PIPELINE_CONFIG_FILE);
90
102
  }
@@ -304,10 +304,30 @@ export function screenshotHtml(options: {
304
304
  rmSync(tempShotDir, { recursive: true, force: true });
305
305
  }
306
306
 
307
+ export class ChromeDumpError extends Error {
308
+ readonly stderr: string;
309
+ readonly tempHtmlPath?: string;
310
+ readonly exitCode: number | null;
311
+
312
+ constructor(message: string, options: {
313
+ stderr: string;
314
+ tempHtmlPath?: string;
315
+ exitCode: number | null;
316
+ }) {
317
+ super(message);
318
+ this.name = "ChromeDumpError";
319
+ this.stderr = options.stderr;
320
+ this.tempHtmlPath = options.tempHtmlPath;
321
+ this.exitCode = options.exitCode;
322
+ }
323
+ }
324
+
307
325
  export function dumpHtmlDom(options: {
308
326
  chromePath: string;
309
327
  html: string;
310
328
  virtualTimeBudgetMs?: number;
329
+ /** When true, leave the temp shell HTML on disk if Chrome fails (caller must clean up). */
330
+ keepTempOnError?: boolean;
311
331
  }): string {
312
332
  const tempHtml = writeTempHtml(injectStaticRenderStyle(options.html));
313
333
  const fileUrl = pathToFileURL(tempHtml).href;
@@ -329,12 +349,20 @@ export function dumpHtmlDom(options: {
329
349
  stdout: "pipe",
330
350
  stderr: "pipe",
331
351
  });
332
- cleanupTempFile(tempHtml);
333
352
  if (result.exitCode !== 0) {
334
353
  const stderr = new TextDecoder().decode(result.stderr);
335
- throw new Error(`Chrome dump-dom failed:\n${stderr}`);
354
+ if (!options.keepTempOnError) {
355
+ cleanupTempFile(tempHtml);
356
+ }
357
+ throw new ChromeDumpError(`Chrome dump-dom failed (exit ${result.exitCode}):\n${stderr}`, {
358
+ stderr,
359
+ tempHtmlPath: options.keepTempOnError ? tempHtml : undefined,
360
+ exitCode: result.exitCode,
361
+ });
336
362
  }
337
- return new TextDecoder().decode(result.stdout);
363
+ const dom = new TextDecoder().decode(result.stdout);
364
+ cleanupTempFile(tempHtml);
365
+ return dom;
338
366
  }
339
367
 
340
368
  export function cropTop(options: {
@@ -0,0 +1,56 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { getDailyLogPath, getLogDir, runWithCommandLog } from "./logger";
6
+
7
+ describe("pipeline daily logger", () => {
8
+ const previousLogDir = process.env.ZZHUB_PIPELINE_LOG_DIR;
9
+ let tempDir: string | null = null;
10
+
11
+ afterEach(() => {
12
+ if (previousLogDir === undefined) {
13
+ delete process.env.ZZHUB_PIPELINE_LOG_DIR;
14
+ } else {
15
+ process.env.ZZHUB_PIPELINE_LOG_DIR = previousLogDir;
16
+ }
17
+ if (tempDir) {
18
+ rmSync(tempDir, { recursive: true, force: true });
19
+ tempDir = null;
20
+ }
21
+ });
22
+
23
+ test("writes command start/ok markers and console output to the daily log", async () => {
24
+ tempDir = mkdtempSync(join(tmpdir(), "zzhub-pipeline-log-"));
25
+ process.env.ZZHUB_PIPELINE_LOG_DIR = tempDir;
26
+
27
+ await runWithCommandLog("status", ["--state", "/tmp/x.json"], async () => {
28
+ console.log("hello-from-status");
29
+ });
30
+
31
+ const logPath = getDailyLogPath();
32
+ expect(getLogDir()).toBe(tempDir);
33
+ expect(existsSync(logPath)).toBe(true);
34
+ const body = readFileSync(logPath, "utf-8");
35
+ expect(body).toContain("=== START command=status ===");
36
+ expect(body).toContain("=== OK command=status");
37
+ expect(body).toContain("[console.log] hello-from-status");
38
+ expect(body).toContain(`"--state"`);
39
+ });
40
+
41
+ test("records failure stack and rethrows", async () => {
42
+ tempDir = mkdtempSync(join(tmpdir(), "zzhub-pipeline-log-"));
43
+ process.env.ZZHUB_PIPELINE_LOG_DIR = tempDir;
44
+
45
+ await expect(
46
+ runWithCommandLog("publish", [], async () => {
47
+ throw new Error("boom-upload");
48
+ }),
49
+ ).rejects.toThrow("boom-upload");
50
+
51
+ const body = readFileSync(getDailyLogPath(), "utf-8");
52
+ expect(body).toContain("=== FAIL command=publish");
53
+ expect(body).toContain("Error: boom-upload");
54
+ expect(body).toContain("boom-upload");
55
+ });
56
+ });
package/src/logger.ts ADDED
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Daily rolling file logger for zzhub-pipeline CLI commands.
3
+ *
4
+ * Layout (platform-aware, same base as wechat-preview config dir):
5
+ * macOS: ~/Library/Application Support/zzhub-pipeline/logs/pipeline-YYYY-MM-DD.log
6
+ * Linux: ~/.config/zzhub-pipeline/logs/pipeline-YYYY-MM-DD.log
7
+ * Windows: %APPDATA%/zzhub-pipeline/logs/pipeline-YYYY-MM-DD.log
8
+ *
9
+ * Override with ZZHUB_PIPELINE_LOG_DIR.
10
+ */
11
+
12
+ import { appendFileSync, existsSync, mkdirSync, readdirSync, unlinkSync } from "node:fs";
13
+ import { homedir, platform } from "node:os";
14
+ import { join } from "node:path";
15
+
16
+ const APP_DIR = "zzhub-pipeline";
17
+ const LOG_DIR_NAME = "logs";
18
+ const LOG_PREFIX = "pipeline-";
19
+ const LOG_SUFFIX = ".log";
20
+ /** Keep about two weeks of daily files. */
21
+ const LOG_RETENTION_DAYS = 14;
22
+
23
+ function getBaseConfigDir(): string {
24
+ const home = homedir();
25
+ const currentPlatform = platform();
26
+
27
+ if (currentPlatform === "win32") {
28
+ const appData = process.env.APPDATA;
29
+ if (appData) return join(appData, APP_DIR);
30
+ return join(home, "AppData", "Roaming", APP_DIR);
31
+ }
32
+
33
+ if (currentPlatform === "darwin") {
34
+ return join(home, "Library", "Application Support", APP_DIR);
35
+ }
36
+
37
+ const xdgConfigHome = process.env.XDG_CONFIG_HOME;
38
+ if (xdgConfigHome && xdgConfigHome.trim().length > 0) {
39
+ return join(xdgConfigHome, APP_DIR);
40
+ }
41
+
42
+ return join(home, ".config", APP_DIR);
43
+ }
44
+
45
+ export function getLogDir(): string {
46
+ const override = process.env.ZZHUB_PIPELINE_LOG_DIR?.trim();
47
+ if (override) return override;
48
+ return join(getBaseConfigDir(), LOG_DIR_NAME);
49
+ }
50
+
51
+ function formatDateKey(date: Date = new Date()): string {
52
+ const y = date.getFullYear();
53
+ const m = String(date.getMonth() + 1).padStart(2, "0");
54
+ const d = String(date.getDate()).padStart(2, "0");
55
+ return `${y}-${m}-${d}`;
56
+ }
57
+
58
+ export function getDailyLogPath(date: Date = new Date()): string {
59
+ return join(getLogDir(), `${LOG_PREFIX}${formatDateKey(date)}${LOG_SUFFIX}`);
60
+ }
61
+
62
+ function ensureLogDir(): string {
63
+ const dir = getLogDir();
64
+ if (!existsSync(dir)) {
65
+ mkdirSync(dir, { recursive: true });
66
+ }
67
+ return dir;
68
+ }
69
+
70
+ function pruneOldLogs(dir: string): void {
71
+ try {
72
+ const cutoff = Date.now() - LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000;
73
+ for (const name of readdirSync(dir)) {
74
+ if (!name.startsWith(LOG_PREFIX) || !name.endsWith(LOG_SUFFIX)) continue;
75
+ const key = name.slice(LOG_PREFIX.length, -LOG_SUFFIX.length);
76
+ // YYYY-MM-DD
77
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(key)) continue;
78
+ const stamp = Date.parse(`${key}T00:00:00`);
79
+ if (!Number.isFinite(stamp) || stamp >= cutoff) continue;
80
+ unlinkSync(join(dir, name));
81
+ }
82
+ } catch {
83
+ // pruning is best-effort
84
+ }
85
+ }
86
+
87
+ export function appendLogLine(message: string, date: Date = new Date()): void {
88
+ try {
89
+ const dir = ensureLogDir();
90
+ pruneOldLogs(dir);
91
+ const path = getDailyLogPath(date);
92
+ const line = message.endsWith("\n") ? message : `${message}\n`;
93
+ appendFileSync(path, line, "utf-8");
94
+ } catch {
95
+ // never break CLI for logging failures
96
+ }
97
+ }
98
+
99
+ function formatError(err: unknown): string {
100
+ if (err instanceof Error) {
101
+ return `${err.name}: ${err.message}${err.stack ? `\n${err.stack}` : ""}`;
102
+ }
103
+ return String(err);
104
+ }
105
+
106
+ type ConsoleMethod = "log" | "info" | "warn" | "error" | "debug";
107
+
108
+ function installConsoleTee(write: (chunk: string) => void): () => void {
109
+ const methods: ConsoleMethod[] = ["log", "info", "warn", "error", "debug"];
110
+ const originals = new Map<ConsoleMethod, (...args: unknown[]) => void>();
111
+
112
+ for (const method of methods) {
113
+ const original = console[method].bind(console);
114
+ originals.set(method, original);
115
+ console[method] = (...args: unknown[]) => {
116
+ original(...args);
117
+ try {
118
+ const text = args
119
+ .map((arg) => {
120
+ if (typeof arg === "string") return arg;
121
+ try {
122
+ return JSON.stringify(arg);
123
+ } catch {
124
+ return String(arg);
125
+ }
126
+ })
127
+ .join(" ");
128
+ write(`[console.${method}] ${text}`);
129
+ } catch {
130
+ // ignore
131
+ }
132
+ };
133
+ }
134
+
135
+ return () => {
136
+ for (const method of methods) {
137
+ const original = originals.get(method);
138
+ if (original) console[method] = original as typeof console.log;
139
+ }
140
+ };
141
+ }
142
+
143
+ /**
144
+ * Run a CLI command with daily-file logging.
145
+ * Captures console output, start/end markers, duration, and full error stacks.
146
+ */
147
+ export async function runWithCommandLog(
148
+ command: string,
149
+ args: string[],
150
+ handler: () => Promise<void>,
151
+ ): Promise<void> {
152
+ const startedAt = new Date();
153
+ const startedMs = Date.now();
154
+ const logPath = getDailyLogPath(startedAt);
155
+ const write = (message: string) => {
156
+ appendLogLine(`[${new Date().toISOString()}] ${message}`, startedAt);
157
+ };
158
+
159
+ write(`=== START command=${command} ===`);
160
+ write(`log_file=${logPath}`);
161
+ write(`cwd=${process.cwd()}`);
162
+ write(`argv=${JSON.stringify(args)}`);
163
+ if (process.env.ZZHUB_PIPELINE_ROOT) {
164
+ write(`ZZHUB_PIPELINE_ROOT=${process.env.ZZHUB_PIPELINE_ROOT}`);
165
+ }
166
+
167
+ const restoreConsole = installConsoleTee(write);
168
+ try {
169
+ await handler();
170
+ write(`=== OK command=${command} duration_ms=${Date.now() - startedMs} ===`);
171
+ } catch (err) {
172
+ write(`=== FAIL command=${command} duration_ms=${Date.now() - startedMs} ===`);
173
+ write(formatError(err));
174
+ throw err;
175
+ } finally {
176
+ restoreConsole();
177
+ }
178
+ }
package/src/plugins.ts CHANGED
@@ -25,6 +25,7 @@ import { syncBlog } from "./commands/sync-blog";
25
25
  import { tasks } from "./commands/tasks";
26
26
  import { topic } from "./commands/topic";
27
27
  import { wechatExport } from "./commands/wechat-export";
28
+ import { wechatPreview } from "./commands/wechat-preview";
28
29
  import { wxDrafts } from "./commands/wx-drafts";
29
30
  import { wxDraftDelete } from "./commands/wx-draft-delete";
30
31
 
@@ -71,6 +72,7 @@ export function getCommandPlugins(): CommandPlugin[] {
71
72
  { name: "republish", summary: "Publish completed task to additional accounts/platforms", plugin: "ops", handler: republish },
72
73
  { name: "imgx", summary: "Run bundled imgx renderer subcommands", plugin: "ops", handler: imgxCommand },
73
74
  { name: "wechat-export", summary: "Render markdown to WeChat HTML with bundled preview styles", plugin: "ops", handler: wechatExport },
75
+ { name: "wechat-preview", summary: "Local singleton server to preview exported WeChat HTML", plugin: "ops", handler: wechatPreview },
74
76
  { name: "cos-upload", summary: "Upload a local image to configured COS CDN", plugin: "ops", handler: cosUpload },
75
77
  { name: "config", summary: "Read or update pipeline config", plugin: "ops", handler: configCommand },
76
78
  { name: "doctor", summary: "Inspect resolved paths and provider health", plugin: "ops", handler: doctor },