@zzclub/pipeline 0.13.0 → 0.13.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.
@@ -12,11 +12,17 @@
12
12
  <script id="zzhub-wechat-export-result" type="application/json">{"status":"pending"}</script>
13
13
  </div>
14
14
  <script>
15
+ var renderFinished = false;
16
+ var resolveRender;
17
+ window.__zzhubRenderReady = new Promise(function (resolve) { resolveRender = resolve; });
15
18
  window.__ZZHUB_SET_RESULT__ = function (payload) {
19
+ if (renderFinished) return;
20
+ renderFinished = true;
16
21
  var el = document.getElementById("zzhub-wechat-export-result");
17
22
  if (el) {
18
23
  el.textContent = JSON.stringify(payload);
19
24
  }
25
+ resolveRender();
20
26
  };
21
27
  window.addEventListener("error", function (event) {
22
28
  window.__ZZHUB_SET_RESULT__({
@@ -13,24 +13,17 @@ interface BrowserPayload {
13
13
  customCss?: string;
14
14
  }
15
15
 
16
+ type BrowserResult =
17
+ | { status: "success"; html: string; semanticHtml?: string }
18
+ | { status: "error"; error: string };
19
+
16
20
  declare global {
17
21
  interface Window {
18
22
  __ZZHUB_WECHAT_PAYLOAD__?: BrowserPayload;
23
+ __ZZHUB_SET_RESULT__: (result: BrowserResult) => void;
19
24
  }
20
25
  }
21
26
 
22
- function setResult(
23
- result:
24
- | { status: "success"; html: string; semanticHtml?: string }
25
- | { status: "error"; error: string },
26
- ): void {
27
- const el = document.getElementById("zzhub-wechat-export-result");
28
- if (!el) {
29
- return;
30
- }
31
- el.textContent = JSON.stringify(result);
32
- }
33
-
34
27
  async function main(): Promise<void> {
35
28
  const payload = window.__ZZHUB_WECHAT_PAYLOAD__;
36
29
  if (!payload) {
@@ -51,7 +44,7 @@ async function main(): Promise<void> {
51
44
  .use(gfm);
52
45
  await editor.create();
53
46
  const semanticHtml = editor.action(getHTML());
54
- const html = renderWechatHtml({
47
+ const html = await renderWechatHtml({
55
48
  semanticHtml,
56
49
  baseCss: articleCss,
57
50
  customCss: payload.customCss,
@@ -59,10 +52,10 @@ async function main(): Promise<void> {
59
52
  theme: payload.exportTheme,
60
53
  });
61
54
  await editor.destroy();
62
- setResult({ status: "success", html, semanticHtml });
55
+ window.__ZZHUB_SET_RESULT__({ status: "success", html, semanticHtml });
63
56
  }
64
57
 
65
58
  void main().catch((error: unknown) => {
66
59
  const detail = error instanceof Error ? error.message : String(error);
67
- setResult({ status: "error", error: detail });
60
+ window.__ZZHUB_SET_RESULT__({ status: "error", error: detail });
68
61
  });
@@ -4,14 +4,13 @@ import { createRequire } from "module";
4
4
  import { dirname, isAbsolute, join, resolve } from "path";
5
5
  import { pathToFileURL } from "url";
6
6
  import {
7
- ChromeDumpError,
8
- dumpHtmlDom,
9
7
  ensureParentDir,
10
8
  escapeHtml,
11
9
  findChrome,
12
10
  readUtf8,
13
11
  renderTemplate,
14
12
  } from "../imgx/runtime";
13
+ import { ChromeRenderError, dumpReadyHtml } from "../imgx/chrome-render";
15
14
  import {
16
15
  PACKAGE_ROOT,
17
16
  TEMPLATE_PATH,
@@ -45,6 +44,7 @@ export type WechatExportErrorKind =
45
44
  export interface WechatExportDebugInfo {
46
45
  chrome_path?: string;
47
46
  virtual_time_budget_ms?: number;
47
+ timeout_ms?: number;
48
48
  bundle_stale?: boolean;
49
49
  bundle_rebuilt?: boolean;
50
50
  shell_path?: string;
@@ -94,7 +94,7 @@ export interface ExportMarkdownToWechatHtmlInput {
94
94
  editorVars?: Record<string, string>;
95
95
  exportTheme?: Record<string, string>;
96
96
  };
97
- /** Chrome virtual-time-budget in ms (default 15000). */
97
+ /** 浏览器真实等待上限,默认 15000 毫秒。 */
98
98
  timeoutMs?: number;
99
99
  /** Write intermediate artifacts for debugging. */
100
100
  debugDir?: string;
@@ -320,7 +320,7 @@ export async function exportMarkdownToWechatHtml(
320
320
  input: ExportMarkdownToWechatHtmlInput,
321
321
  ): Promise<ExportMarkdownToWechatHtmlResult> {
322
322
  const started = Date.now();
323
- const timeoutMs = input.timeoutMs ?? 15_000;
323
+ const timeoutMs = input.timeoutMs && Number.isFinite(input.timeoutMs) && input.timeoutMs > 0 ? input.timeoutMs : 15_000;
324
324
  const title = input.title ?? "Wechat Preview Export";
325
325
  let chromePath: string | undefined;
326
326
  let bundleRebuilt = false;
@@ -339,7 +339,7 @@ export async function exportMarkdownToWechatHtml(
339
339
  markdownPath: input.markdownPath,
340
340
  debug: {
341
341
  chrome_path: chromePath,
342
- virtual_time_budget_ms: timeoutMs,
342
+ timeout_ms: timeoutMs,
343
343
  bundle_stale: bundleStale,
344
344
  bundle_rebuilt: bundleRebuilt,
345
345
  debug_dir: input.debugDir,
@@ -401,29 +401,31 @@ export async function exportMarkdownToWechatHtml(
401
401
 
402
402
  let dumpedDom: string;
403
403
  try {
404
- dumpedDom = dumpHtmlDom({
404
+ dumpedDom = await dumpReadyHtml({
405
405
  chromePath,
406
406
  html: shellHtml,
407
- virtualTimeBudgetMs: timeoutMs,
408
- keepTempOnError: Boolean(input.debugDir),
407
+ timeoutMs,
409
408
  });
410
409
  } catch (error) {
411
- if (error instanceof ChromeDumpError) {
410
+ if (error instanceof ChromeRenderError) {
412
411
  if (input.debugDir) {
413
412
  await writeDebugArtifacts(input.debugDir, {
414
413
  "00-payload-markdown.md": markdown,
415
414
  "01-export-shell.html": shellHtml,
416
415
  "meta.json": JSON.stringify({
417
416
  status: "failed",
418
- kind: "chrome_failed",
417
+ kind: error.kind,
419
418
  error: error.message,
420
419
  stderr: error.stderr,
421
420
  duration_ms: Date.now() - started,
421
+ timeout_ms: timeoutMs,
422
422
  }, null, 2),
423
423
  });
424
424
  }
425
- throw makeError("chrome_failed", error.message, {
426
- shell_path: error.tempHtmlPath,
425
+ throw makeError(error.kind, error.kind === "timeout"
426
+ ? `Wechat preview export did not finish before timeout (${timeoutMs}ms real time)`
427
+ : error.message, {
428
+ shell_path: input.debugDir ? join(input.debugDir, "01-export-shell.html") : undefined,
427
429
  stderr_tail: stderrTail(error.stderr),
428
430
  debug_dir: input.debugDir,
429
431
  });
@@ -445,15 +447,15 @@ export async function exportMarkdownToWechatHtml(
445
447
  "02-dump-dom.html": dumpedDom,
446
448
  "meta.json": JSON.stringify({
447
449
  status: "failed",
448
- kind: "timeout",
450
+ kind: "render_error",
449
451
  error: error instanceof Error ? error.message : String(error),
450
452
  duration_ms: Date.now() - started,
451
453
  }, null, 2),
452
454
  });
453
455
  }
454
456
  throw makeError(
455
- "timeout",
456
- `Wechat preview export did not finish before timeout (${timeoutMs}ms): ${
457
+ "render_error",
458
+ `Wechat preview export returned an invalid result: ${
457
459
  error instanceof Error ? error.message : String(error)
458
460
  }`,
459
461
  );
@@ -483,15 +485,15 @@ export async function exportMarkdownToWechatHtml(
483
485
  "02-dump-dom.html": dumpedDom,
484
486
  "meta.json": JSON.stringify({
485
487
  status: "failed",
486
- kind: "timeout",
488
+ kind: "render_error",
487
489
  status_value: result.status,
488
490
  duration_ms: Date.now() - started,
489
491
  }, null, 2),
490
492
  });
491
493
  }
492
494
  throw makeError(
493
- "timeout",
494
- `Wechat preview export did not finish before timeout (status=${result.status}, budget=${timeoutMs}ms)`,
495
+ "render_error",
496
+ `Wechat preview export returned an incomplete result (status=${result.status})`,
495
497
  );
496
498
  }
497
499
 
@@ -11,6 +11,7 @@ export type PreviewEntryStatus = "success" | "failed";
11
11
  export interface PreviewEntryDebug {
12
12
  chrome_path?: string;
13
13
  virtual_time_budget_ms?: number;
14
+ timeout_ms?: number;
14
15
  bundle_stale?: boolean;
15
16
  bundle_rebuilt?: boolean;
16
17
  shell_path?: string;
@@ -1,10 +1,14 @@
1
- import { afterAll, describe, expect, test } from "bun:test";
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
2
  import { mkdtemp, readFile, rm, writeFile } from "fs/promises";
3
3
  import { tmpdir } from "os";
4
4
  import { dirname, join } from "path";
5
5
  import { fileURLToPath } from "url";
6
+ import { PNG } from "pngjs";
6
7
  import { findChrome } from "../imgx/runtime";
7
- import { exportMarkdownToWechatHtml } from "./index";
8
+ import { dumpReadyHtml } from "../imgx/chrome-render";
9
+ import { TEMPLATE_PATH } from "../runtime-paths";
10
+ import { replaceImageUrls } from "../providers/wechat";
11
+ import { exportMarkdownToWechatHtml, extractRenderResult } from "./index";
8
12
 
9
13
  const fixturePath = join(
10
14
  dirname(fileURLToPath(import.meta.url)),
@@ -15,7 +19,7 @@ const fixturePath = join(
15
19
  describe("semantic WeChat HTML export", () => {
16
20
  let tempDir: string | null = null;
17
21
 
18
- afterAll(async () => {
22
+ afterEach(async () => {
19
23
  if (tempDir) await rm(tempDir, { recursive: true, force: true });
20
24
  });
21
25
 
@@ -91,4 +95,125 @@ describe("semantic WeChat HTML export", () => {
91
95
  expect(preview).not.toContain("editor-export.js");
92
96
  expect(preview).not.toContain("browser-dist");
93
97
  }, 30_000);
98
+
99
+ test.skipIf(findChrome() === null)("preserves Crepe image scaling and captions through export and upload URL replacement", async () => {
100
+ tempDir = await mkdtemp(join(tmpdir(), "zzhub-wechat-images-"));
101
+ for (const [name, width, height] of [["large", 1200, 600], ["small", 200, 400]] as const) {
102
+ const png = new PNG({ width, height });
103
+ png.data.fill(255);
104
+ await writeFile(join(tempDir, `${name}.png`), PNG.sync.write(png));
105
+ }
106
+
107
+ // 同一图片的不同缩放必须各自生效,小图不能按正文宽度放大。
108
+ const scaledImages = [
109
+ { name: "large", ratio: "0.50", width: 50, maxWidth: 600 },
110
+ { name: "large", ratio: "1.00", width: 100, maxWidth: 1200 },
111
+ { name: "large", ratio: "1.50", width: 100, maxWidth: 1800 },
112
+ { name: "small", ratio: "0.50", width: 50, maxWidth: 100 },
113
+ { name: "small", ratio: "1.00", width: 100, maxWidth: 200 },
114
+ { name: "small", ratio: "1.50", width: 100, maxWidth: 300 },
115
+ ];
116
+ const ordinaryAlts = ["普通说明", "", "2026", "0.00", "-0.50", "0.5", "1.00x", "Infinity"];
117
+ const markdownPath = join(tempDir, "images.md");
118
+ await writeFile(markdownPath, [
119
+ ...scaledImages.map(({ name, ratio }, index) => `![${ratio}](./${name}.png "缩放图注 ${index}")`),
120
+ ...ordinaryAlts.map(alt => `![${alt}](./small.png "普通图注")`),
121
+ '行内图片 ![0.50](./small.png "行内图注") 后文',
122
+ ].join("\n\n"));
123
+
124
+ const result = await exportMarkdownToWechatHtml({
125
+ markdownPath,
126
+ outPath: join(tempDir, "images.html"),
127
+ account: "default",
128
+ });
129
+ const uploadedHtml = replaceImageUrls(result.html, {
130
+ [join(tempDir, "large.png")]: "https://mmbiz.qpic.cn/large.png",
131
+ [join(tempDir, "small.png")]: "https://mmbiz.qpic.cn/small.png",
132
+ });
133
+ const images = uploadedHtml.match(/<img\b[^>]*>/g) ?? [];
134
+ expect(images).toHaveLength(scaledImages.length + ordinaryAlts.length + 1);
135
+ scaledImages.forEach(({ name, width, maxWidth }, index) => {
136
+ expect(images[index]).toContain(`width: ${width}%;`);
137
+ expect(images[index]).toContain(`max-width: ${maxWidth}px;`);
138
+ expect(images[index]).toContain("height: auto;");
139
+ expect(images[index]).toContain(`src="https://mmbiz.qpic.cn/${name}.png"`);
140
+ expect(images[index]).toContain(`data-src="https://mmbiz.qpic.cn/${name}.png"`);
141
+ expect(uploadedHtml).toMatch(new RegExp(`<p[^>]*>缩放图注 ${index}</p>`));
142
+ });
143
+ ordinaryAlts.forEach((alt, index) => {
144
+ expect(images[scaledImages.length + index]).toContain(`alt="${alt}"`);
145
+ expect(images[scaledImages.length + index]).toContain("width: auto;");
146
+ expect(images[scaledImages.length + index]).toContain("max-width: 100%;");
147
+ });
148
+ expect(uploadedHtml).toMatch(/<p[^>]*>普通图注<\/p>/);
149
+ expect(images.at(-1)).toContain('alt="0.50"');
150
+ expect(images.at(-1)).toContain("width: auto;");
151
+ expect(uploadedHtml).not.toContain(">行内图注</p>");
152
+ expect(uploadedHtml).not.toContain(tempDir);
153
+ }, 30_000);
154
+
155
+ test.skipIf(findChrome() === null)("reports an export error when a scaled image cannot be decoded", async () => {
156
+ tempDir = await mkdtemp(join(tmpdir(), "zzhub-wechat-image-error-"));
157
+ const markdownPath = join(tempDir, "images.md");
158
+ await writeFile(join(tempDir, "broken.png"), "not an image");
159
+ await writeFile(markdownPath, '![0.50](./broken.png "失败图注")');
160
+
161
+ await expect(exportMarkdownToWechatHtml({
162
+ markdownPath,
163
+ outPath: join(tempDir, "images.html"),
164
+ account: "default",
165
+ })).rejects.toMatchObject({
166
+ kind: "render_error",
167
+ message: expect.stringContaining("无法读取缩放图片的原始尺寸"),
168
+ });
169
+ }, 30_000);
170
+
171
+ test.skipIf(findChrome() === null)("waits for remote PNG and GIF responses before completing scaled HTML export", async () => {
172
+ tempDir = await mkdtemp(join(tmpdir(), "zzhub-wechat-remote-"));
173
+ const png = new PNG({ width: 200, height: 100 });
174
+ png.data.fill(255);
175
+ const pngBytes = PNG.sync.write(png);
176
+ const gif = Buffer.from("R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7", "base64");
177
+ const requested = Promise.withResolvers<void>();
178
+ const release = Promise.withResolvers<void>();
179
+ const server = Bun.serve({ hostname: "127.0.0.1", port: 0, async fetch(request) {
180
+ requested.resolve();
181
+ await release.promise;
182
+ const isGif = request.url.endsWith(".gif");
183
+ return new Response(isGif ? gif : pngBytes, { headers: { "content-type": isGif ? "image/gif" : "image/png", "cache-control": "no-store" } });
184
+ } });
185
+ const markdownPath = join(tempDir, "images.md");
186
+ await writeFile(markdownPath, `![0.50](http://127.0.0.1:${server.port}/photo.png "远程图注")\n\n![0.50](http://127.0.0.1:${server.port}/motion.gif "动图图注")`);
187
+ let finished = false;
188
+ const exporting = exportMarkdownToWechatHtml({ markdownPath, outPath: join(tempDir, "images.html"), account: "default" })
189
+ .then(result => { finished = true; return result; });
190
+ try {
191
+ await Promise.race([requested.promise, exporting]);
192
+ // 人为延迟网络响应,确认导出不会提前读到 pending。
193
+ await Bun.sleep(800);
194
+ expect(finished).toBe(false);
195
+ release.resolve();
196
+ const { html } = await exporting;
197
+ expect(html).toContain("width: 50%;");
198
+ expect(html).toContain("max-width: 100px;");
199
+ expect(html).toContain("max-width: 0.5px;");
200
+ expect(html).toContain("远程图注</p>");
201
+ expect(html).toContain("动图图注</p>");
202
+ expect(html).toContain("/motion.gif");
203
+ } finally {
204
+ release.resolve();
205
+ await exporting.catch(() => {});
206
+ await server.stop(true);
207
+ }
208
+ }, 30_000);
209
+
210
+ test.skipIf(findChrome() === null)("settles the export result when the browser module fails to load", async () => {
211
+ const html = (await readFile(TEMPLATE_PATH, "utf8"))
212
+ .replace("{{PAGE_TITLE}}", "加载失败")
213
+ .replace("{{CSS_LINK}}", "")
214
+ .replace("{{PAYLOAD_JSON}}", "{}")
215
+ .replace("{{SCRIPT_URL}}", "file:///missing/wechat-editor.js");
216
+ const result = extractRenderResult(await dumpReadyHtml({ chromePath: findChrome()!, html, timeoutMs: 5000 }));
217
+ expect(result).toEqual({ status: "error", error: "failed to load wechat preview bundle" });
218
+ }, 10_000);
94
219
  });
@@ -34,7 +34,7 @@ export interface WechatElementRenderer {
34
34
  selector: string;
35
35
  /** Optional selector used after CSS has been inlined. Defaults to selector. */
36
36
  finalizeSelector?: string;
37
- prepare?: (element: HTMLElement, context: WechatRenderContext) => void;
37
+ prepare?: (element: HTMLElement, context: WechatRenderContext) => void | Promise<void>;
38
38
  finalize?: (element: HTMLElement, context: WechatRenderContext) => void;
39
39
  }
40
40
 
@@ -220,11 +220,12 @@ function imageIsOnlyContent(parent: HTMLElement, image: HTMLElement): boolean {
220
220
  });
221
221
  }
222
222
 
223
+ // 图片缩放与图注共用导出流程,分别读取 alt 和 title。
223
224
  const imageRenderer: WechatElementRenderer = {
224
225
  kind: "image",
225
226
  selector: "img",
226
227
  finalizeSelector: "figure, figcaption",
227
- prepare(element, context) {
228
+ async prepare(element, context) {
228
229
  setNodeKind(element, "image");
229
230
  const src = element.getAttribute("src");
230
231
  if (src) element.setAttribute("data-src", src);
@@ -234,6 +235,24 @@ const imageRenderer: WechatElementRenderer = {
234
235
  return;
235
236
  }
236
237
 
238
+ // Crepe 仅将独立图片的缩放比例序列化为两位小数。
239
+ const alt = element.getAttribute("alt") ?? "";
240
+ const ratio = /^(?:0|[1-9]\d*)\.\d{2}$/.test(alt) ? Number(alt) : 0;
241
+ if (ratio > 0 && Number.isFinite(ratio)) {
242
+ // DOMParser 文档不加载图片,用浏览器图片对象读取原始尺寸。
243
+ const sourceImage = new Image();
244
+ sourceImage.src = src ?? "";
245
+ try {
246
+ await sourceImage.decode();
247
+ if (!sourceImage.naturalWidth) throw new Error("图片原始宽度为空");
248
+ } catch (error) {
249
+ throw new Error(`无法读取缩放图片的原始尺寸:${element.getAttribute("title") || "无图注"}`, { cause: error });
250
+ }
251
+ element.style.width = `${Math.min(ratio, 1) * 100}%`;
252
+ element.style.maxWidth = `${sourceImage.naturalWidth * ratio}px`;
253
+ element.style.height = "auto";
254
+ }
255
+
237
256
  const figure = context.document.createElement("figure");
238
257
  setNodeKind(figure, "image-block");
239
258
  parent.replaceWith(figure);
@@ -500,11 +519,12 @@ export function createWechatRendererRegistry(
500
519
  return DEFAULT_RENDERERS.map((renderer) => overrides[renderer.kind] ?? renderer);
501
520
  }
502
521
 
503
- function runRendererPhase(
522
+ /** 按渲染器顺序执行,等待图片尺寸读取完成。 */
523
+ async function runRendererPhase(
504
524
  phase: "prepare" | "finalize",
505
525
  renderers: WechatElementRenderer[],
506
526
  context: WechatRenderContext,
507
- ): void {
527
+ ): Promise<void> {
508
528
  for (const renderer of renderers) {
509
529
  const handler = renderer[phase];
510
530
  if (!handler) continue;
@@ -515,7 +535,7 @@ function runRendererPhase(
515
535
  if (context.root.matches(selector)) elements.push(context.root);
516
536
  elements.push(...Array.from(context.root.querySelectorAll<HTMLElement>(selector)));
517
537
  for (const element of elements) {
518
- if (element.isConnected || element === context.root) handler(element, context);
538
+ if (element.isConnected || element === context.root) await handler(element, context);
519
539
  }
520
540
  }
521
541
  }
@@ -640,7 +660,8 @@ function sanitizeTree(root: HTMLElement): void {
640
660
  }
641
661
  }
642
662
 
643
- export function renderWechatHtml(input: RenderWechatHtmlInput): string {
663
+ /** 完成图片缩放后,将文章样式内联为公众号 HTML。 */
664
+ export async function renderWechatHtml(input: RenderWechatHtmlInput): Promise<string> {
644
665
  const sourceDocument = parseHtml("");
645
666
  const compatibilityRoot = sourceDocument.createElement("section");
646
667
  compatibilityRoot.className = "milkdown";
@@ -655,7 +676,7 @@ export function renderWechatHtml(input: RenderWechatHtmlInput): string {
655
676
 
656
677
  const references: WechatLinkReference[] = [];
657
678
  const renderers = createWechatRendererRegistry(input.renderers);
658
- runRendererPhase("prepare", renderers, {
679
+ await runRendererPhase("prepare", renderers, {
659
680
  document: sourceDocument,
660
681
  root,
661
682
  theme: input.theme,
@@ -687,7 +708,7 @@ export function renderWechatHtml(input: RenderWechatHtmlInput): string {
687
708
  const finalRoot = finalDocument.querySelector<HTMLElement>(".zzhub-wechat-article");
688
709
  if (!finalRoot) throw new Error("Wechat article root missing after CSS inlining");
689
710
 
690
- runRendererPhase("finalize", renderers, {
711
+ await runRendererPhase("finalize", renderers, {
691
712
  document: finalDocument,
692
713
  root: finalRoot,
693
714
  theme: input.theme,