@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,8 +1,10 @@
1
- import { existsSync } from "fs";
2
- import { cp, mkdir, readFile, rm, writeFile } from "fs/promises";
3
- import { dirname, isAbsolute, join, relative, resolve } from "path";
1
+ import { existsSync, statSync } from "fs";
2
+ import { mkdir, readFile, writeFile } from "fs/promises";
3
+ import { createRequire } from "module";
4
+ import { dirname, isAbsolute, join, resolve } from "path";
4
5
  import { pathToFileURL } from "url";
5
6
  import {
7
+ ChromeDumpError,
6
8
  dumpHtmlDom,
7
9
  ensureParentDir,
8
10
  escapeHtml,
@@ -16,6 +18,7 @@ import {
16
18
  DIST_DIR,
17
19
  MANIFEST_PATH,
18
20
  VITE_CONFIG_PATH,
21
+ WECHAT_PREVIEW_DIR,
19
22
  } from "../runtime-paths";
20
23
  import { extractFrontmatter } from "./frontmatter-handler";
21
24
  import { getWechatPreviewStyleName, getWechatPreviewTheme } from "./themes";
@@ -23,12 +26,61 @@ import { stripLeadingH1 } from "../text";
23
26
 
24
27
  interface ViteManifestEntry {
25
28
  file: string;
26
- css?: string[];
27
29
  }
28
30
 
29
31
  interface BundleAssets {
30
32
  scriptPath: string;
31
- cssPath: string | null;
33
+ rebuilt: boolean;
34
+ stale: boolean;
35
+ }
36
+
37
+ export type WechatExportErrorKind =
38
+ | "chrome_missing"
39
+ | "chrome_failed"
40
+ | "timeout"
41
+ | "render_error"
42
+ | "bundle"
43
+ | "other";
44
+
45
+ export interface WechatExportDebugInfo {
46
+ chrome_path?: string;
47
+ virtual_time_budget_ms?: number;
48
+ bundle_stale?: boolean;
49
+ bundle_rebuilt?: boolean;
50
+ shell_path?: string;
51
+ stderr_tail?: string;
52
+ debug_dir?: string;
53
+ semantic_html?: string;
54
+ }
55
+
56
+ export class WechatExportError extends Error {
57
+ readonly kind: WechatExportErrorKind;
58
+ readonly durationMs: number;
59
+ readonly debug: WechatExportDebugInfo;
60
+ readonly account: string;
61
+ readonly title?: string;
62
+ readonly markdownPath?: string;
63
+
64
+ constructor(
65
+ message: string,
66
+ options: {
67
+ kind: WechatExportErrorKind;
68
+ durationMs: number;
69
+ debug?: WechatExportDebugInfo;
70
+ account: string;
71
+ title?: string;
72
+ markdownPath?: string;
73
+ },
74
+ ) {
75
+ super(message);
76
+ this.name = "WechatExportError";
77
+ this.kind = options.kind;
78
+ this.durationMs = options.durationMs;
79
+ this.debug = options.debug ?? {};
80
+ this.account = options.account;
81
+ this.title = options.title;
82
+ this.markdownPath = options.markdownPath;
83
+ }
32
84
  }
33
85
 
34
86
  export interface ExportMarkdownToWechatHtmlInput {
@@ -42,6 +94,10 @@ export interface ExportMarkdownToWechatHtmlInput {
42
94
  editorVars?: Record<string, string>;
43
95
  exportTheme?: Record<string, string>;
44
96
  };
97
+ /** Chrome virtual-time-budget in ms (default 15000). */
98
+ timeoutMs?: number;
99
+ /** Write intermediate artifacts for debugging. */
100
+ debugDir?: string;
45
101
  }
46
102
 
47
103
  export interface ExportMarkdownToWechatHtmlResult {
@@ -50,22 +106,71 @@ export interface ExportMarkdownToWechatHtmlResult {
50
106
  account: string;
51
107
  previewStyle: string;
52
108
  previewShellPath?: string;
109
+ durationMs: number;
110
+ bundleRebuilt: boolean;
111
+ bundleStale: boolean;
112
+ debugDir?: string;
113
+ semanticHtml?: string;
53
114
  }
54
115
 
55
- function ensureBundle(): BundleAssets {
56
- if (!existsSync(MANIFEST_PATH)) {
57
- const result = Bun.spawnSync({
58
- cmd: ["bun", "x", "vite", "build", "--config", VITE_CONFIG_PATH],
59
- cwd: PACKAGE_ROOT,
60
- stdout: "pipe",
61
- stderr: "pipe",
62
- });
63
- if (result.exitCode !== 0) {
64
- const stderr = new TextDecoder().decode(result.stderr);
65
- throw new Error(`Failed to build wechat preview bundle:\n${stderr}`);
116
+ const requireFromHere = createRequire(import.meta.url);
117
+
118
+ /** Absolute path to shared article.css (@zzclub/milkdown-article-style). */
119
+ export function resolveMilkdownArticleStylePath(): string {
120
+ try {
121
+ return requireFromHere.resolve("@zzclub/milkdown-article-style/article.css");
122
+ } catch {
123
+ // Bun / Node may need package root + relative when exports are CSS-only.
124
+ const pkgRoot = dirname(requireFromHere.resolve("@zzclub/milkdown-article-style/package.json"));
125
+ return join(pkgRoot, "article.css");
126
+ }
127
+ }
128
+
129
+ /** Source files that feed the browser Vite bundle. */
130
+ export function getWechatPreviewBundleSources(): string[] {
131
+ return [
132
+ join(WECHAT_PREVIEW_DIR, "browser/editor-export.ts"),
133
+ resolveMilkdownArticleStylePath(),
134
+ join(WECHAT_PREVIEW_DIR, "wechat-renderer.ts"),
135
+ ];
136
+ }
137
+
138
+ export function isWechatPreviewBundleStale(
139
+ sources: string[] = getWechatPreviewBundleSources(),
140
+ manifestPath: string = MANIFEST_PATH,
141
+ ): boolean {
142
+ if (!existsSync(manifestPath)) return true;
143
+ let distMtime: number;
144
+ try {
145
+ distMtime = statSync(manifestPath).mtimeMs;
146
+ } catch {
147
+ return true;
148
+ }
149
+ for (const src of sources) {
150
+ if (!existsSync(src)) continue;
151
+ try {
152
+ if (statSync(src).mtimeMs > distMtime) return true;
153
+ } catch {
154
+ // ignore unreadable sources
66
155
  }
67
156
  }
157
+ return false;
158
+ }
159
+
160
+ function buildBundle(): void {
161
+ const result = Bun.spawnSync({
162
+ cmd: ["bun", "x", "vite", "build", "--config", VITE_CONFIG_PATH],
163
+ cwd: PACKAGE_ROOT,
164
+ stdout: "pipe",
165
+ stderr: "pipe",
166
+ });
167
+ if (result.exitCode !== 0) {
168
+ const stderr = new TextDecoder().decode(result.stderr);
169
+ throw new Error(`Failed to build wechat preview bundle:\n${stderr}`);
170
+ }
171
+ }
68
172
 
173
+ function readBundleScriptPath(): string {
69
174
  const manifestRaw = readUtf8(MANIFEST_PATH);
70
175
  const manifest = JSON.parse(manifestRaw) as Record<string, ViteManifestEntry>;
71
176
  const entry =
@@ -76,13 +181,28 @@ function ensureBundle(): BundleAssets {
76
181
  throw new Error("Wechat preview manifest missing editor-export entry");
77
182
  }
78
183
 
184
+ return join(DIST_DIR, entry.file);
185
+ }
186
+
187
+ function ensureBundle(): BundleAssets {
188
+ const stale = isWechatPreviewBundleStale();
189
+ let rebuilt = false;
190
+ if (!existsSync(MANIFEST_PATH) || stale) {
191
+ try {
192
+ buildBundle();
193
+ rebuilt = true;
194
+ } catch (error) {
195
+ // Fall back to existing dist when rebuild tooling is unavailable (e.g. missing juice).
196
+ if (!existsSync(MANIFEST_PATH)) {
197
+ throw error;
198
+ }
199
+ }
200
+ }
201
+
79
202
  return {
80
- scriptPath: join(DIST_DIR, entry.file),
81
- cssPath: entry.css?.[0]
82
- ? join(DIST_DIR, entry.css[0])
83
- : manifest["style.css"]?.file
84
- ? join(DIST_DIR, manifest["style.css"].file)
85
- : null,
203
+ scriptPath: readBundleScriptPath(),
204
+ rebuilt,
205
+ stale: stale && !rebuilt,
86
206
  };
87
207
  }
88
208
 
@@ -94,7 +214,7 @@ export function escapeInlineJson(value: string): string {
94
214
  }
95
215
 
96
216
  export function isExternalUrl(value: string): boolean {
97
- return /^(https?:|data:|blob:|file:|\/\/)/i.test(value);
217
+ return /^(?:[a-z][a-z0-9+.-]*:|\/\/)/i.test(value);
98
218
  }
99
219
 
100
220
  export function resolveMarkdownAsset(rawPath: string, baseDir: string): string {
@@ -125,92 +245,49 @@ export function rewriteRelativeImagePaths(markdown: string, baseDir: string): st
125
245
  });
126
246
  }
127
247
 
128
- function rewriteRelativeImagePathsForPreview(markdown: string, baseDir: string, previewDir: string): string {
129
- return markdown
130
- .replace(/!\[([^\]]*)\]\((?:<([^>]+)>|([^)]+?))(?:\s+(["'])([^"']*)\4)?\)/g, (_match, alt, angleUrl, plainUrl, _quote, title) => {
131
- const src = angleUrl ?? plainUrl ?? "";
132
- const hasAngleBrackets = angleUrl !== undefined;
133
- const raw = src.trim();
134
- if (!raw || isExternalUrl(raw)) {
135
- const wrap = !!(hasAngleBrackets || raw.includes(" "));
136
- const titleStr = title ? ` "${title}"` : "";
137
- return `![${alt}](${wrap ? "<" : ""}${src}${wrap ? ">" : ""}${titleStr})`;
138
- }
139
- const resolved = resolveMarkdownAsset(raw, baseDir);
140
- if (!resolved) {
141
- const wrap = !!(hasAngleBrackets || raw.includes(" "));
142
- const titleStr = title ? ` "${title}"` : "";
143
- return `![${alt}](${wrap ? "<" : ""}${src}${wrap ? ">" : ""}${titleStr})`;
144
- }
145
- const previewRelative = relative(previewDir, resolved).replaceAll("\\", "/");
146
- const normalized = previewRelative.startsWith(".") ? previewRelative : `./${previewRelative}`;
147
- const wrap = !!(hasAngleBrackets || normalized.includes(" "));
148
- const titleStr = title ? ` "${title}"` : "";
149
- return `![${alt}](${wrap ? "<" : ""}${normalized}${wrap ? ">" : ""}${titleStr})`;
150
- })
151
- .replace(/(<img\b[^>]*\bsrc=["'])([^"']+)(["'][^>]*>)/gi, (_match, prefix, src, suffix) => {
152
- const raw = String(src ?? "").trim();
153
- if (!raw || isExternalUrl(raw)) {
154
- return `${prefix}${src}${suffix}`;
248
+ export function buildWechatPreviewShell(articleHtml: string, pageTitle: string): string {
249
+ return `<!doctype html>
250
+ <html lang="zh-CN">
251
+ <head>
252
+ <meta charset="utf-8">
253
+ <meta name="viewport" content="width=device-width, initial-scale=1">
254
+ <title>${escapeHtml(pageTitle)}</title>
255
+ <style>
256
+ html { background: #f3f1f2; }
257
+ body { margin: 0; padding: 32px 16px 64px; background: #f3f1f2; }
258
+ #wechat-preview-content { width: 100%; max-width: 430px; margin: 0 auto; padding: 18px 16px; box-sizing: border-box; background: #fff; box-shadow: 0 12px 36px rgba(38, 30, 34, 0.10); }
259
+ @media (max-width: 462px) {
260
+ body { padding: 0; background: #fff; }
261
+ #wechat-preview-content { max-width: none; padding: 16px; box-shadow: none; }
155
262
  }
156
- const resolved = resolveMarkdownAsset(raw, baseDir);
157
- const previewRelative = relative(previewDir, resolved).replaceAll("\\", "/");
158
- const normalized = previewRelative.startsWith(".") ? previewRelative : `./${previewRelative}`;
159
- return `${prefix}${normalized}${suffix}`;
160
- });
161
- }
162
-
163
- async function ensurePreviewBundleDir(targetDir: string): Promise<void> {
164
- await mkdir(dirname(targetDir), { recursive: true });
165
- await rm(targetDir, { recursive: true, force: true });
166
- await cp(DIST_DIR, targetDir, { recursive: true, force: true });
263
+ </style>
264
+ </head>
265
+ <body>
266
+ <main id="wechat-preview-content">${articleHtml}</main>
267
+ </body>
268
+ </html>`;
167
269
  }
168
270
 
169
- async function writePreviewShell(options: {
170
- previewShellOutPath: string;
171
- pageTitle: string;
172
- sourceMarkdown: string;
173
- sourceMarkdownDir: string;
174
- theme: ReturnType<typeof getWechatPreviewTheme>;
175
- bundle: BundleAssets;
176
- }): Promise<string> {
177
- const previewDir = dirname(options.previewShellOutPath);
178
- await mkdir(previewDir, { recursive: true });
179
- const previewBundleDir = join(previewDir, "browser-dist");
180
- await ensurePreviewBundleDir(previewBundleDir);
181
-
182
- const previewMarkdown = rewriteRelativeImagePathsForPreview(
183
- options.sourceMarkdown,
184
- options.sourceMarkdownDir,
185
- previewDir,
271
+ async function writePreviewShell(
272
+ previewShellOutPath: string,
273
+ pageTitle: string,
274
+ articleHtml: string,
275
+ ): Promise<string> {
276
+ await mkdir(dirname(previewShellOutPath), { recursive: true });
277
+ await writeFile(
278
+ previewShellOutPath,
279
+ buildWechatPreviewShell(articleHtml, pageTitle),
280
+ "utf-8",
186
281
  );
187
- const shell = readUtf8(TEMPLATE_PATH);
188
- const payloadJson = escapeInlineJson(
189
- JSON.stringify({
190
- markdown: previewMarkdown,
191
- editorVars: options.theme.editorVars,
192
- exportTheme: options.theme.exportTheme,
193
- }),
194
- );
195
- const relativeScriptPath = relative(previewDir, join(previewBundleDir, "editor-export.js")).replaceAll("\\", "/");
196
- const relativeCssPath = options.bundle.cssPath
197
- ? relative(previewDir, join(previewBundleDir, relative(DIST_DIR, options.bundle.cssPath))).replaceAll("\\", "/")
198
- : null;
199
-
200
- const shellHtml = renderTemplate(shell, {
201
- "{{PAGE_TITLE}}": escapeHtml(options.pageTitle),
202
- "{{CSS_LINK}}": relativeCssPath ? `<link rel="stylesheet" href="${relativeCssPath.startsWith(".") ? relativeCssPath : `./${relativeCssPath}`}">` : "",
203
- "{{PAYLOAD_JSON}}": payloadJson,
204
- "{{SCRIPT_URL}}": relativeScriptPath.startsWith(".") ? relativeScriptPath : `./${relativeScriptPath}`,
205
- });
206
-
207
- await writeFile(options.previewShellOutPath, shellHtml, "utf-8");
208
- return options.previewShellOutPath;
282
+ return previewShellOutPath;
209
283
  }
210
284
 
211
- export function extractRenderResult(
212
- dom: string,
213
- ): { status: "success"; html: string } | { status: "error"; error: string } | { status: string } {
285
+ export type ExtractedRenderResult =
286
+ | { status: "success"; html: string; semanticHtml?: string }
287
+ | { status: "error"; error: string }
288
+ | { status: string };
289
+
290
+ export function extractRenderResult(dom: string): ExtractedRenderResult {
214
291
  const match = dom.match(
215
292
  /<script id="zzhub-wechat-export-result" type="application\/json">([\s\S]*?)<\/script>/,
216
293
  );
@@ -219,93 +296,260 @@ export function extractRenderResult(
219
296
  throw new Error("Wechat preview result script not found");
220
297
  }
221
298
 
222
- return JSON.parse(match[1]) as
223
- | { status: "success"; html: string }
224
- | { status: "error"; error: string }
225
- | { status: string };
299
+ return JSON.parse(match[1]) as ExtractedRenderResult;
300
+ }
301
+
302
+ function stderrTail(stderr: string, max = 2000): string {
303
+ const trimmed = stderr.trim();
304
+ if (trimmed.length <= max) return trimmed;
305
+ return trimmed.slice(-max);
306
+ }
307
+
308
+ async function writeDebugArtifacts(
309
+ debugDir: string,
310
+ files: Record<string, string | undefined>,
311
+ ): Promise<void> {
312
+ await mkdir(debugDir, { recursive: true });
313
+ for (const [name, content] of Object.entries(files)) {
314
+ if (content === undefined) continue;
315
+ await writeFile(join(debugDir, name), content, "utf-8");
316
+ }
226
317
  }
227
318
 
228
319
  export async function exportMarkdownToWechatHtml(
229
320
  input: ExportMarkdownToWechatHtmlInput,
230
321
  ): Promise<ExportMarkdownToWechatHtmlResult> {
231
- const chromePath = findChrome();
232
- if (chromePath === null) {
233
- throw new Error(
234
- "Chrome/Chromium not found. Required for WeChat HTML export.\n" +
235
- "Install one of:\n" +
236
- " macOS: brew install --cask chromium\n" +
237
- " Ubuntu: sudo apt install chromium-browser\n" +
238
- " Or install Google Chrome from https://www.google.com/chrome/\n" +
239
- " Or set CHROME_PATH environment variable to the binary path.",
240
- );
241
- }
322
+ const started = Date.now();
323
+ const timeoutMs = input.timeoutMs ?? 15_000;
324
+ const title = input.title ?? "Wechat Preview Export";
325
+ let chromePath: string | undefined;
326
+ let bundleRebuilt = false;
327
+ let bundleStale = false;
242
328
 
243
- const bundle = ensureBundle();
244
- const sourceMarkdown = await readFile(input.markdownPath, "utf-8");
245
- const stripped = extractFrontmatter(sourceMarkdown).content;
246
- const bodyMarkdown = stripLeadingH1(stripped).trimStart();
247
- const markdown = rewriteRelativeImagePaths(bodyMarkdown, dirname(input.markdownPath));
248
- const theme = getWechatPreviewTheme(input.account, input.themeOverrides);
249
- const shell = readUtf8(TEMPLATE_PATH);
250
- const payloadJson = escapeInlineJson(
251
- JSON.stringify({
252
- markdown,
253
- editorVars: theme.editorVars,
254
- exportTheme: theme.exportTheme,
255
- }),
256
- );
329
+ const makeError = (
330
+ kind: WechatExportErrorKind,
331
+ message: string,
332
+ debug: WechatExportDebugInfo = {},
333
+ ): WechatExportError =>
334
+ new WechatExportError(message, {
335
+ kind,
336
+ durationMs: Date.now() - started,
337
+ account: input.account,
338
+ title,
339
+ markdownPath: input.markdownPath,
340
+ debug: {
341
+ chrome_path: chromePath,
342
+ virtual_time_budget_ms: timeoutMs,
343
+ bundle_stale: bundleStale,
344
+ bundle_rebuilt: bundleRebuilt,
345
+ debug_dir: input.debugDir,
346
+ ...debug,
347
+ },
348
+ });
257
349
 
258
- let customCssTag = "";
259
- if (input.customCss) {
260
- const cssContent = await readFile(input.customCss, "utf-8");
261
- customCssTag = `<style>${cssContent}</style>`;
262
- }
350
+ try {
351
+ const foundChrome = findChrome();
352
+ if (!foundChrome) {
353
+ throw makeError(
354
+ "chrome_missing",
355
+ "Chrome/Chromium not found. Required for WeChat HTML export.\n" +
356
+ "Install one of:\n" +
357
+ " macOS: brew install --cask chromium\n" +
358
+ " Ubuntu: sudo apt install chromium-browser\n" +
359
+ " Or install Google Chrome from https://www.google.com/chrome/\n" +
360
+ " Or set CHROME_PATH environment variable to the binary path.",
361
+ );
362
+ }
363
+ chromePath = foundChrome;
263
364
 
264
- const shellHtml = renderTemplate(shell, {
265
- "{{PAGE_TITLE}}": escapeHtml(input.title ?? "Wechat Preview Export"),
266
- "{{CSS_LINK}}": [
267
- bundle.cssPath
268
- ? `<link rel="stylesheet" href="${pathToFileURL(bundle.cssPath).href}">`
269
- : "",
270
- customCssTag,
271
- ].filter(Boolean).join("\n "),
272
- "{{PAYLOAD_JSON}}": payloadJson,
273
- "{{SCRIPT_URL}}": pathToFileURL(bundle.scriptPath).href,
274
- });
365
+ let bundle: BundleAssets;
366
+ try {
367
+ bundle = ensureBundle();
368
+ bundleRebuilt = bundle.rebuilt;
369
+ bundleStale = bundle.stale;
370
+ } catch (error) {
371
+ throw makeError(
372
+ "bundle",
373
+ error instanceof Error ? error.message : String(error),
374
+ );
375
+ }
275
376
 
276
- const dom = dumpHtmlDom({
277
- chromePath,
278
- html: shellHtml,
279
- virtualTimeBudgetMs: 15000,
280
- });
281
- const result = extractRenderResult(dom);
282
- if (result.status === "error" && "error" in result) {
283
- throw new Error(`Wechat preview export failed: ${result.error}`);
284
- }
285
- if (result.status !== "success" || !("html" in result)) {
286
- throw new Error(`Wechat preview export did not finish before timeout (status=${result.status})`);
287
- }
377
+ const sourceMarkdown = await readFile(input.markdownPath, "utf-8");
378
+ const stripped = extractFrontmatter(sourceMarkdown).content;
379
+ const bodyMarkdown = stripLeadingH1(stripped).trimStart();
380
+ const markdown = rewriteRelativeImagePaths(bodyMarkdown, dirname(input.markdownPath));
381
+ const theme = getWechatPreviewTheme(input.account, input.themeOverrides);
382
+ const customCss = input.customCss
383
+ ? await readFile(input.customCss, "utf-8")
384
+ : undefined;
385
+ const shell = readUtf8(TEMPLATE_PATH);
386
+ const payloadJson = escapeInlineJson(
387
+ JSON.stringify({
388
+ markdown,
389
+ editorVars: theme.editorVars,
390
+ exportTheme: theme.exportTheme,
391
+ customCss,
392
+ }),
393
+ );
288
394
 
289
- ensureParentDir(input.outPath);
290
- await writeFile(input.outPath, result.html, "utf-8");
291
-
292
- let previewShellPath: string | undefined;
293
- if (input.previewShellOutPath) {
294
- previewShellPath = await writePreviewShell({
295
- previewShellOutPath: input.previewShellOutPath,
296
- pageTitle: input.title ?? "Wechat Preview Export",
297
- sourceMarkdown: bodyMarkdown,
298
- sourceMarkdownDir: dirname(input.markdownPath),
299
- theme,
300
- bundle,
395
+ const shellHtml = renderTemplate(shell, {
396
+ "{{PAGE_TITLE}}": escapeHtml(title),
397
+ "{{CSS_LINK}}": "",
398
+ "{{PAYLOAD_JSON}}": payloadJson,
399
+ "{{SCRIPT_URL}}": pathToFileURL(bundle.scriptPath).href,
301
400
  });
302
- }
303
401
 
304
- return {
305
- html: result.html,
306
- htmlPath: input.outPath,
307
- account: input.account,
308
- previewStyle: getWechatPreviewStyleName(input.account),
309
- previewShellPath,
310
- };
402
+ let dumpedDom: string;
403
+ try {
404
+ dumpedDom = dumpHtmlDom({
405
+ chromePath,
406
+ html: shellHtml,
407
+ virtualTimeBudgetMs: timeoutMs,
408
+ keepTempOnError: Boolean(input.debugDir),
409
+ });
410
+ } catch (error) {
411
+ if (error instanceof ChromeDumpError) {
412
+ if (input.debugDir) {
413
+ await writeDebugArtifacts(input.debugDir, {
414
+ "00-payload-markdown.md": markdown,
415
+ "01-export-shell.html": shellHtml,
416
+ "meta.json": JSON.stringify({
417
+ status: "failed",
418
+ kind: "chrome_failed",
419
+ error: error.message,
420
+ stderr: error.stderr,
421
+ duration_ms: Date.now() - started,
422
+ }, null, 2),
423
+ });
424
+ }
425
+ throw makeError("chrome_failed", error.message, {
426
+ shell_path: error.tempHtmlPath,
427
+ stderr_tail: stderrTail(error.stderr),
428
+ debug_dir: input.debugDir,
429
+ });
430
+ }
431
+ throw makeError(
432
+ "chrome_failed",
433
+ error instanceof Error ? error.message : String(error),
434
+ );
435
+ }
436
+
437
+ let result: ExtractedRenderResult;
438
+ try {
439
+ result = extractRenderResult(dumpedDom);
440
+ } catch (error) {
441
+ if (input.debugDir) {
442
+ await writeDebugArtifacts(input.debugDir, {
443
+ "00-payload-markdown.md": markdown,
444
+ "01-export-shell.html": shellHtml,
445
+ "02-dump-dom.html": dumpedDom,
446
+ "meta.json": JSON.stringify({
447
+ status: "failed",
448
+ kind: "timeout",
449
+ error: error instanceof Error ? error.message : String(error),
450
+ duration_ms: Date.now() - started,
451
+ }, null, 2),
452
+ });
453
+ }
454
+ throw makeError(
455
+ "timeout",
456
+ `Wechat preview export did not finish before timeout (${timeoutMs}ms): ${
457
+ error instanceof Error ? error.message : String(error)
458
+ }`,
459
+ );
460
+ }
461
+
462
+ if (result.status === "error" && "error" in result) {
463
+ if (input.debugDir) {
464
+ await writeDebugArtifacts(input.debugDir, {
465
+ "00-payload-markdown.md": markdown,
466
+ "01-export-shell.html": shellHtml,
467
+ "02-dump-dom.html": dumpedDom,
468
+ "meta.json": JSON.stringify({
469
+ status: "failed",
470
+ kind: "render_error",
471
+ error: result.error,
472
+ duration_ms: Date.now() - started,
473
+ }, null, 2),
474
+ });
475
+ }
476
+ throw makeError("render_error", `Wechat preview export failed: ${result.error}`);
477
+ }
478
+ if (result.status !== "success" || !("html" in result)) {
479
+ if (input.debugDir) {
480
+ await writeDebugArtifacts(input.debugDir, {
481
+ "00-payload-markdown.md": markdown,
482
+ "01-export-shell.html": shellHtml,
483
+ "02-dump-dom.html": dumpedDom,
484
+ "meta.json": JSON.stringify({
485
+ status: "failed",
486
+ kind: "timeout",
487
+ status_value: result.status,
488
+ duration_ms: Date.now() - started,
489
+ }, null, 2),
490
+ });
491
+ }
492
+ throw makeError(
493
+ "timeout",
494
+ `Wechat preview export did not finish before timeout (status=${result.status}, budget=${timeoutMs}ms)`,
495
+ );
496
+ }
497
+
498
+ const html = result.html;
499
+ const semanticHtml =
500
+ "semanticHtml" in result && typeof result.semanticHtml === "string"
501
+ ? result.semanticHtml
502
+ : undefined;
503
+
504
+ ensureParentDir(input.outPath);
505
+ await writeFile(input.outPath, html, "utf-8");
506
+
507
+ let previewShellPath: string | undefined;
508
+ if (input.previewShellOutPath) {
509
+ previewShellPath = await writePreviewShell(
510
+ input.previewShellOutPath,
511
+ title,
512
+ html,
513
+ );
514
+ }
515
+
516
+ const durationMs = Date.now() - started;
517
+ if (input.debugDir) {
518
+ await writeDebugArtifacts(input.debugDir, {
519
+ "00-payload-markdown.md": markdown,
520
+ "01-export-shell.html": shellHtml,
521
+ "02-dump-dom.html": dumpedDom,
522
+ "03-article.html": html,
523
+ "03-semantic.html": semanticHtml,
524
+ "04-preview-shell.html": previewShellPath
525
+ ? await readFile(previewShellPath, "utf-8")
526
+ : buildWechatPreviewShell(html, title),
527
+ "meta.json": JSON.stringify({
528
+ status: "success",
529
+ account: input.account,
530
+ duration_ms: durationMs,
531
+ bundle_rebuilt: bundleRebuilt,
532
+ bundle_stale: bundleStale,
533
+ timeout_ms: timeoutMs,
534
+ html_path: input.outPath,
535
+ }, null, 2),
536
+ });
537
+ }
538
+
539
+ return {
540
+ html,
541
+ htmlPath: input.outPath,
542
+ account: input.account,
543
+ previewStyle: getWechatPreviewStyleName(input.account),
544
+ previewShellPath,
545
+ durationMs,
546
+ bundleRebuilt,
547
+ bundleStale,
548
+ debugDir: input.debugDir,
549
+ semanticHtml,
550
+ };
551
+ } catch (error) {
552
+ if (error instanceof WechatExportError) throw error;
553
+ throw makeError("other", error instanceof Error ? error.message : String(error));
554
+ }
311
555
  }