@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
@@ -0,0 +1,60 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { mkdtempSync, rmSync } from "fs";
3
+ import { tmpdir } from "os";
4
+ import { join } from "path";
5
+ import {
6
+ clearPreviewEntries,
7
+ createPreviewEntry,
8
+ getPreviewEntry,
9
+ listPreviewEntries,
10
+ } from "./registry";
11
+
12
+ const prevDir = process.env.ZZHUB_WECHAT_PREVIEW_DIR;
13
+
14
+ describe("preview registry", () => {
15
+ let dir: string;
16
+
17
+ afterEach(() => {
18
+ if (dir) rmSync(dir, { recursive: true, force: true });
19
+ if (prevDir === undefined) delete process.env.ZZHUB_WECHAT_PREVIEW_DIR;
20
+ else process.env.ZZHUB_WECHAT_PREVIEW_DIR = prevDir;
21
+ });
22
+
23
+ test("creates, lists, gets, and clears entries", () => {
24
+ dir = mkdtempSync(join(tmpdir(), "zzhub-preview-reg-"));
25
+ process.env.ZZHUB_WECHAT_PREVIEW_DIR = dir;
26
+
27
+ const created = createPreviewEntry({
28
+ title: "Hello",
29
+ account: "default",
30
+ status: "success",
31
+ duration_ms: 42,
32
+ html: "<section>hi</section>",
33
+ });
34
+ expect(created.id).toBeTruthy();
35
+ expect(created.html).toBe("<section>hi</section>");
36
+
37
+ const listed = listPreviewEntries();
38
+ expect(listed).toHaveLength(1);
39
+ expect(listed[0]?.title).toBe("Hello");
40
+
41
+ const loaded = getPreviewEntry(created.id);
42
+ expect(loaded?.html).toBe("<section>hi</section>");
43
+ expect(loaded?.status).toBe("success");
44
+
45
+ const failed = createPreviewEntry({
46
+ title: "Bad",
47
+ account: "default",
48
+ status: "failed",
49
+ duration_ms: 10,
50
+ error: "boom",
51
+ error_kind: "timeout",
52
+ });
53
+ expect(listPreviewEntries()).toHaveLength(2);
54
+ expect(getPreviewEntry(failed.id)?.error_kind).toBe("timeout");
55
+
56
+ const cleared = clearPreviewEntries();
57
+ expect(cleared).toBe(2);
58
+ expect(listPreviewEntries()).toHaveLength(0);
59
+ });
60
+ });
@@ -0,0 +1,118 @@
1
+ import {
2
+ existsSync,
3
+ mkdirSync,
4
+ readdirSync,
5
+ readFileSync,
6
+ rmSync,
7
+ writeFileSync,
8
+ } from "fs";
9
+ import { join } from "path";
10
+ import { ensurePreviewServerDirs, getPreviewEntriesDir } from "./paths";
11
+ import type { PreviewEntry, PreviewEntryMeta, PreviewRegisterInput } from "./types";
12
+
13
+ function entryDir(id: string): string {
14
+ return join(getPreviewEntriesDir(), id);
15
+ }
16
+
17
+ function metaPath(id: string): string {
18
+ return join(entryDir(id), "meta.json");
19
+ }
20
+
21
+ function htmlPath(id: string): string {
22
+ return join(entryDir(id), "article.html");
23
+ }
24
+
25
+ function newEntryId(): string {
26
+ return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
27
+ }
28
+
29
+ export function createPreviewEntry(input: PreviewRegisterInput): PreviewEntry {
30
+ ensurePreviewServerDirs();
31
+ const id = newEntryId();
32
+ const dir = entryDir(id);
33
+ const entry: PreviewEntry = {
34
+ id,
35
+ title: input.title || "Untitled",
36
+ account: input.account || "default",
37
+ status: input.status,
38
+ created_at: new Date().toISOString(),
39
+ duration_ms: input.duration_ms,
40
+ markdown_path: input.markdown_path,
41
+ html_path: input.html_path,
42
+ preview_style: input.preview_style,
43
+ error: input.error,
44
+ error_kind: input.error_kind,
45
+ debug: input.debug,
46
+ html: input.html,
47
+ };
48
+
49
+ mkdirSync(dir, { recursive: true });
50
+
51
+ const meta: PreviewEntryMeta = {
52
+ id: entry.id,
53
+ title: entry.title,
54
+ account: entry.account,
55
+ status: entry.status,
56
+ created_at: entry.created_at,
57
+ duration_ms: entry.duration_ms,
58
+ markdown_path: entry.markdown_path,
59
+ html_path: entry.html_path,
60
+ preview_style: entry.preview_style,
61
+ error: entry.error,
62
+ error_kind: entry.error_kind,
63
+ debug: entry.debug,
64
+ };
65
+ writeFileSync(metaPath(id), JSON.stringify(meta, null, 2), "utf-8");
66
+ if (entry.html) {
67
+ writeFileSync(htmlPath(id), entry.html, "utf-8");
68
+ }
69
+ return entry;
70
+ }
71
+
72
+ export function listPreviewEntries(): PreviewEntryMeta[] {
73
+ ensurePreviewServerDirs();
74
+ const root = getPreviewEntriesDir();
75
+ if (!existsSync(root)) return [];
76
+
77
+ const entries: PreviewEntryMeta[] = [];
78
+ for (const name of readdirSync(root)) {
79
+ const path = metaPath(name);
80
+ if (!existsSync(path)) continue;
81
+ try {
82
+ const meta = JSON.parse(readFileSync(path, "utf-8")) as PreviewEntryMeta;
83
+ entries.push(meta);
84
+ } catch {
85
+ // skip corrupt entries
86
+ }
87
+ }
88
+ entries.sort((a, b) => b.created_at.localeCompare(a.created_at));
89
+ return entries;
90
+ }
91
+
92
+ export function getPreviewEntry(id: string): PreviewEntry | null {
93
+ const path = metaPath(id);
94
+ if (!existsSync(path)) return null;
95
+ try {
96
+ const meta = JSON.parse(readFileSync(path, "utf-8")) as PreviewEntryMeta;
97
+ let html: string | undefined;
98
+ const hp = htmlPath(id);
99
+ if (existsSync(hp)) {
100
+ html = readFileSync(hp, "utf-8");
101
+ }
102
+ return { ...meta, html };
103
+ } catch {
104
+ return null;
105
+ }
106
+ }
107
+
108
+ export function clearPreviewEntries(): number {
109
+ ensurePreviewServerDirs();
110
+ const root = getPreviewEntriesDir();
111
+ if (!existsSync(root)) return 0;
112
+ let count = 0;
113
+ for (const name of readdirSync(root)) {
114
+ rmSync(join(root, name), { recursive: true, force: true });
115
+ count += 1;
116
+ }
117
+ return count;
118
+ }
@@ -0,0 +1,118 @@
1
+ import { existsSync, readFileSync, unlinkSync, writeFileSync } from "fs";
2
+ import {
3
+ ensurePreviewServerDirs,
4
+ getPreviewServerLockPath,
5
+ previewBaseUrl,
6
+ resolvePreviewHost,
7
+ resolvePreviewPort,
8
+ } from "./paths";
9
+ import type { PreviewServerLock } from "./types";
10
+
11
+ function isProcessAlive(pid: number): boolean {
12
+ if (!Number.isFinite(pid) || pid <= 0) return false;
13
+ try {
14
+ process.kill(pid, 0);
15
+ return true;
16
+ } catch {
17
+ return false;
18
+ }
19
+ }
20
+
21
+ export function readServerLock(): PreviewServerLock | null {
22
+ const path = getPreviewServerLockPath();
23
+ if (!existsSync(path)) return null;
24
+ try {
25
+ return JSON.parse(readFileSync(path, "utf-8")) as PreviewServerLock;
26
+ } catch {
27
+ return null;
28
+ }
29
+ }
30
+
31
+ export function writeServerLock(lock: PreviewServerLock): void {
32
+ ensurePreviewServerDirs();
33
+ writeFileSync(getPreviewServerLockPath(), JSON.stringify(lock, null, 2), "utf-8");
34
+ }
35
+
36
+ export function clearServerLock(): void {
37
+ const path = getPreviewServerLockPath();
38
+ if (existsSync(path)) {
39
+ try {
40
+ unlinkSync(path);
41
+ } catch {
42
+ // ignore
43
+ }
44
+ }
45
+ }
46
+
47
+ export async function probeHealth(url: string, timeoutMs = 800): Promise<boolean> {
48
+ const controller = new AbortController();
49
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
50
+ try {
51
+ const res = await fetch(`${url.replace(/\/$/, "")}/api/health`, {
52
+ signal: controller.signal,
53
+ });
54
+ if (!res.ok) return false;
55
+ const body = (await res.json()) as { ok?: boolean; service?: string };
56
+ return body.ok === true && body.service === "wechat-preview";
57
+ } catch {
58
+ return false;
59
+ } finally {
60
+ clearTimeout(timer);
61
+ }
62
+ }
63
+
64
+ export type ExistingServerResult =
65
+ | { kind: "running"; lock: PreviewServerLock }
66
+ | { kind: "stale_lock"; lock: PreviewServerLock | null }
67
+ | { kind: "none" };
68
+
69
+ /**
70
+ * Check if a live singleton server is already serving.
71
+ */
72
+ export async function findExistingServer(
73
+ host = resolvePreviewHost(),
74
+ port = resolvePreviewPort(),
75
+ ): Promise<ExistingServerResult> {
76
+ const lock = readServerLock();
77
+ if (lock) {
78
+ const url = lock.url || previewBaseUrl(lock.host, lock.port);
79
+ if (isProcessAlive(lock.pid) && (await probeHealth(url))) {
80
+ return { kind: "running", lock: { ...lock, url } };
81
+ }
82
+ // try health even if pid check fails (different user namespaces)
83
+ if (await probeHealth(url)) {
84
+ return { kind: "running", lock: { ...lock, url } };
85
+ }
86
+ }
87
+
88
+ // Probe default/requested address without lock
89
+ const url = previewBaseUrl(host, port);
90
+ if (await probeHealth(url)) {
91
+ return {
92
+ kind: "running",
93
+ lock: {
94
+ pid: lock?.pid ?? 0,
95
+ host,
96
+ port,
97
+ url,
98
+ started_at: lock?.started_at ?? new Date().toISOString(),
99
+ },
100
+ };
101
+ }
102
+
103
+ if (lock && !isProcessAlive(lock.pid)) {
104
+ return { kind: "stale_lock", lock };
105
+ }
106
+
107
+ return { kind: "none" };
108
+ }
109
+
110
+ export function buildLock(host: string, port: number, pid = process.pid): PreviewServerLock {
111
+ return {
112
+ pid,
113
+ host,
114
+ port,
115
+ url: previewBaseUrl(host, port),
116
+ started_at: new Date().toISOString(),
117
+ };
118
+ }
@@ -0,0 +1,61 @@
1
+ export type PreviewErrorKind =
2
+ | "chrome_missing"
3
+ | "chrome_failed"
4
+ | "timeout"
5
+ | "render_error"
6
+ | "bundle"
7
+ | "other";
8
+
9
+ export type PreviewEntryStatus = "success" | "failed";
10
+
11
+ export interface PreviewEntryDebug {
12
+ chrome_path?: string;
13
+ virtual_time_budget_ms?: number;
14
+ bundle_stale?: boolean;
15
+ bundle_rebuilt?: boolean;
16
+ shell_path?: string;
17
+ stderr_tail?: string;
18
+ debug_dir?: string;
19
+ }
20
+
21
+ export interface PreviewEntryMeta {
22
+ id: string;
23
+ title: string;
24
+ account: string;
25
+ status: PreviewEntryStatus;
26
+ created_at: string;
27
+ duration_ms: number;
28
+ markdown_path?: string;
29
+ html_path?: string;
30
+ preview_style?: string;
31
+ error?: string;
32
+ error_kind?: PreviewErrorKind;
33
+ debug?: PreviewEntryDebug;
34
+ }
35
+
36
+ export interface PreviewEntry extends PreviewEntryMeta {
37
+ /** Final inlined article HTML (success only). */
38
+ html?: string;
39
+ }
40
+
41
+ export interface PreviewServerLock {
42
+ pid: number;
43
+ host: string;
44
+ port: number;
45
+ url: string;
46
+ started_at: string;
47
+ }
48
+
49
+ export interface PreviewRegisterInput {
50
+ title: string;
51
+ account: string;
52
+ status: PreviewEntryStatus;
53
+ duration_ms: number;
54
+ markdown_path?: string;
55
+ html_path?: string;
56
+ preview_style?: string;
57
+ html?: string;
58
+ error?: string;
59
+ error_kind?: PreviewErrorKind;
60
+ debug?: PreviewEntryDebug;
61
+ }
@@ -0,0 +1,324 @@
1
+ import { escapeHtml } from "../../imgx/runtime";
2
+
3
+ export function renderDashboardHtml(baseUrl: string): string {
4
+ return `<!doctype html>
5
+ <html lang="zh-CN">
6
+ <head>
7
+ <meta charset="utf-8">
8
+ <meta name="viewport" content="width=device-width, initial-scale=1">
9
+ <title>WeChat Preview Server</title>
10
+ <style>
11
+ :root {
12
+ --bg: #f3f1f2;
13
+ --panel: #ffffff;
14
+ --ink: #292526;
15
+ --muted: #6f696b;
16
+ --border: #e2dcdf;
17
+ --ok: #2f7d4a;
18
+ --fail: #b42318;
19
+ --accent: #a94473;
20
+ }
21
+ * { box-sizing: border-box; }
22
+ body {
23
+ margin: 0;
24
+ font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
25
+ color: var(--ink);
26
+ background: var(--bg);
27
+ height: 100vh;
28
+ display: flex;
29
+ flex-direction: column;
30
+ }
31
+ header {
32
+ display: flex;
33
+ align-items: center;
34
+ gap: 12px;
35
+ padding: 12px 16px;
36
+ background: var(--panel);
37
+ border-bottom: 1px solid var(--border);
38
+ }
39
+ header h1 {
40
+ margin: 0;
41
+ font-size: 15px;
42
+ font-weight: 650;
43
+ letter-spacing: 0.02em;
44
+ }
45
+ header .meta { color: var(--muted); font-size: 12px; margin-left: auto; }
46
+ header button {
47
+ border: 1px solid var(--border);
48
+ background: #fff;
49
+ border-radius: 8px;
50
+ padding: 6px 10px;
51
+ font-size: 12px;
52
+ cursor: pointer;
53
+ }
54
+ header button:hover { border-color: var(--accent); color: var(--accent); }
55
+ .layout { display: flex; flex: 1; min-height: 0; }
56
+ aside {
57
+ width: 300px;
58
+ background: var(--panel);
59
+ border-right: 1px solid var(--border);
60
+ overflow: auto;
61
+ padding: 8px;
62
+ }
63
+ .entry {
64
+ display: block;
65
+ width: 100%;
66
+ text-align: left;
67
+ border: 1px solid transparent;
68
+ background: transparent;
69
+ border-radius: 10px;
70
+ padding: 10px 10px;
71
+ margin-bottom: 4px;
72
+ cursor: pointer;
73
+ color: inherit;
74
+ font: inherit;
75
+ }
76
+ .entry:hover { background: #faf7f8; }
77
+ .entry.active { border-color: #e8c9d7; background: #fdf6f9; }
78
+ .entry .title {
79
+ font-size: 13px;
80
+ font-weight: 600;
81
+ line-height: 1.35;
82
+ margin-bottom: 4px;
83
+ word-break: break-word;
84
+ }
85
+ .entry .row {
86
+ display: flex;
87
+ gap: 8px;
88
+ align-items: center;
89
+ font-size: 11px;
90
+ color: var(--muted);
91
+ }
92
+ .badge {
93
+ display: inline-block;
94
+ padding: 1px 6px;
95
+ border-radius: 999px;
96
+ font-size: 10px;
97
+ font-weight: 650;
98
+ letter-spacing: 0.04em;
99
+ text-transform: uppercase;
100
+ }
101
+ .badge.ok { background: #e8f6ec; color: var(--ok); }
102
+ .badge.fail { background: #fdecea; color: var(--fail); }
103
+ main {
104
+ flex: 1;
105
+ min-width: 0;
106
+ display: flex;
107
+ flex-direction: column;
108
+ }
109
+ .toolbar {
110
+ display: flex;
111
+ gap: 10px;
112
+ align-items: center;
113
+ padding: 10px 16px;
114
+ border-bottom: 1px solid var(--border);
115
+ background: rgba(255,255,255,0.7);
116
+ font-size: 12px;
117
+ color: var(--muted);
118
+ }
119
+ .toolbar a { color: var(--accent); }
120
+ .stage {
121
+ flex: 1;
122
+ min-height: 0;
123
+ overflow: auto;
124
+ padding: 20px 16px 40px;
125
+ }
126
+ iframe {
127
+ display: block;
128
+ width: min(430px, 100%);
129
+ height: calc(100vh - 140px);
130
+ min-height: 640px;
131
+ margin: 0 auto;
132
+ border: 0;
133
+ border-radius: 16px;
134
+ background: #fff;
135
+ box-shadow: 0 12px 36px rgba(38, 30, 34, 0.10);
136
+ }
137
+ .empty, .error-panel {
138
+ max-width: 560px;
139
+ margin: 48px auto;
140
+ background: var(--panel);
141
+ border-radius: 14px;
142
+ padding: 24px;
143
+ box-shadow: 0 8px 24px rgba(38, 30, 34, 0.06);
144
+ }
145
+ .error-panel h2 { margin: 0 0 8px; font-size: 16px; color: var(--fail); }
146
+ .error-panel pre {
147
+ white-space: pre-wrap;
148
+ word-break: break-word;
149
+ background: #faf7f8;
150
+ border-radius: 10px;
151
+ padding: 12px;
152
+ font-size: 12px;
153
+ line-height: 1.5;
154
+ overflow: auto;
155
+ }
156
+ .empty { color: var(--muted); text-align: center; }
157
+ </style>
158
+ </head>
159
+ <body>
160
+ <header>
161
+ <h1>WeChat Preview</h1>
162
+ <button type="button" id="btn-refresh">刷新</button>
163
+ <button type="button" id="btn-clear">清空</button>
164
+ <span class="meta" id="server-meta">${escapeHtml(baseUrl)}</span>
165
+ </header>
166
+ <div class="layout">
167
+ <aside id="list"></aside>
168
+ <main>
169
+ <div class="toolbar" id="toolbar">选择左侧导出结果进行预览</div>
170
+ <div class="stage" id="stage">
171
+ <div class="empty">还没有预览条目。运行 <code>wechat-export</code> 后会自动登记到这里。</div>
172
+ </div>
173
+ </main>
174
+ </div>
175
+ <script>
176
+ const state = { entries: [], activeId: null };
177
+
178
+ async function api(path, options) {
179
+ const res = await fetch(path, options);
180
+ if (!res.ok) {
181
+ const text = await res.text();
182
+ throw new Error(text || res.statusText);
183
+ }
184
+ const ct = res.headers.get("content-type") || "";
185
+ if (ct.includes("application/json")) return res.json();
186
+ return res.text();
187
+ }
188
+
189
+ function fmtTime(iso) {
190
+ try {
191
+ const d = new Date(iso);
192
+ return d.toLocaleString();
193
+ } catch { return iso; }
194
+ }
195
+
196
+ function renderList() {
197
+ const list = document.getElementById("list");
198
+ if (!state.entries.length) {
199
+ list.innerHTML = '<div class="empty" style="margin:16px;box-shadow:none;padding:12px;">暂无条目</div>';
200
+ return;
201
+ }
202
+ list.innerHTML = state.entries.map((e) => {
203
+ const active = e.id === state.activeId ? " active" : "";
204
+ const badge = e.status === "success"
205
+ ? '<span class="badge ok">ok</span>'
206
+ : '<span class="badge fail">fail</span>';
207
+ return \`<button type="button" class="entry\${active}" data-id="\${e.id}">
208
+ <div class="title">\${escape(e.title)}</div>
209
+ <div class="row">\${badge}<span>\${escape(e.account)}</span><span>\${e.duration_ms}ms</span></div>
210
+ <div class="row">\${escape(fmtTime(e.created_at))}</div>
211
+ </button>\`;
212
+ }).join("");
213
+ list.querySelectorAll(".entry").forEach((btn) => {
214
+ btn.addEventListener("click", () => selectEntry(btn.getAttribute("data-id")));
215
+ });
216
+ }
217
+
218
+ function escape(s) {
219
+ return String(s ?? "")
220
+ .replaceAll("&", "&amp;")
221
+ .replaceAll("<", "&lt;")
222
+ .replaceAll(">", "&gt;")
223
+ .replaceAll('"', "&quot;");
224
+ }
225
+
226
+ async function selectEntry(id) {
227
+ state.activeId = id;
228
+ renderList();
229
+ const entry = state.entries.find((e) => e.id === id);
230
+ const toolbar = document.getElementById("toolbar");
231
+ const stage = document.getElementById("stage");
232
+ if (!entry) return;
233
+
234
+ toolbar.innerHTML = \`
235
+ <strong style="color:var(--ink)">\${escape(entry.title)}</strong>
236
+ <span>· \${escape(entry.account)}</span>
237
+ <span>· \${entry.duration_ms}ms</span>
238
+ <a href="/e/\${encodeURIComponent(id)}" target="_blank" rel="noreferrer">新窗口打开</a>
239
+ \${entry.html_path ? \`<span title="\${escape(entry.html_path)}">disk</span>\` : ""}
240
+ \`;
241
+
242
+ if (entry.status === "failed") {
243
+ const detail = await api("/api/entries/" + encodeURIComponent(id));
244
+ const debug = detail.debug ? JSON.stringify(detail.debug, null, 2) : "";
245
+ stage.innerHTML = \`
246
+ <div class="error-panel">
247
+ <h2>渲染失败 · \${escape(detail.error_kind || "error")}</h2>
248
+ <pre>\${escape(detail.error || "unknown error")}</pre>
249
+ \${debug ? \`<h3 style="font-size:13px;margin:16px 0 8px;color:var(--muted)">debug</h3><pre>\${escape(debug)}</pre>\` : ""}
250
+ </div>
251
+ \`;
252
+ return;
253
+ }
254
+
255
+ stage.innerHTML = \`<iframe title="preview" src="/e/\${encodeURIComponent(id)}"></iframe>\`;
256
+ }
257
+
258
+ async function refresh() {
259
+ state.entries = await api("/api/entries");
260
+ renderList();
261
+ if (state.activeId && state.entries.some((e) => e.id === state.activeId)) {
262
+ await selectEntry(state.activeId);
263
+ } else if (state.entries[0]) {
264
+ await selectEntry(state.entries[0].id);
265
+ } else {
266
+ state.activeId = null;
267
+ document.getElementById("stage").innerHTML =
268
+ '<div class="empty">还没有预览条目。运行 <code>wechat-export</code> 后会自动登记到这里。</div>';
269
+ document.getElementById("toolbar").textContent = "选择左侧导出结果进行预览";
270
+ }
271
+ }
272
+
273
+ document.getElementById("btn-refresh").addEventListener("click", () => refresh().catch(alert));
274
+ document.getElementById("btn-clear").addEventListener("click", async () => {
275
+ if (!confirm("清空全部预览条目?")) return;
276
+ await api("/api/entries", { method: "DELETE" });
277
+ state.activeId = null;
278
+ await refresh();
279
+ });
280
+
281
+ refresh().catch((err) => {
282
+ document.getElementById("stage").innerHTML =
283
+ '<div class="error-panel"><h2>加载失败</h2><pre>' + escape(err.message) + "</pre></div>";
284
+ });
285
+
286
+ // Auto-refresh list every 3s while page is open
287
+ setInterval(() => {
288
+ refresh().catch(() => {});
289
+ }, 3000);
290
+ </script>
291
+ </body>
292
+ </html>`;
293
+ }
294
+
295
+ export function renderFailedEntryHtml(entry: {
296
+ title: string;
297
+ error?: string;
298
+ error_kind?: string;
299
+ debug?: unknown;
300
+ }): string {
301
+ const debug = entry.debug ? JSON.stringify(entry.debug, null, 2) : "";
302
+ return `<!doctype html>
303
+ <html lang="zh-CN">
304
+ <head>
305
+ <meta charset="utf-8">
306
+ <meta name="viewport" content="width=device-width, initial-scale=1">
307
+ <title>${escapeHtml(entry.title)} · failed</title>
308
+ <style>
309
+ body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; background: #f3f1f2; color: #292526; }
310
+ main { max-width: 640px; margin: 40px auto; background: #fff; border-radius: 14px; padding: 24px; box-shadow: 0 8px 24px rgba(38,30,34,.08); }
311
+ h1 { font-size: 18px; color: #b42318; margin: 0 0 8px; }
312
+ pre { white-space: pre-wrap; word-break: break-word; background: #faf7f8; padding: 12px; border-radius: 10px; font-size: 12px; line-height: 1.5; }
313
+ </style>
314
+ </head>
315
+ <body>
316
+ <main>
317
+ <h1>渲染失败 · ${escapeHtml(entry.error_kind || "error")}</h1>
318
+ <p>${escapeHtml(entry.title)}</p>
319
+ <pre>${escapeHtml(entry.error || "unknown error")}</pre>
320
+ ${debug ? `<h2 style="font-size:14px;color:#6f696b">debug</h2><pre>${escapeHtml(debug)}</pre>` : ""}
321
+ </main>
322
+ </body>
323
+ </html>`;
324
+ }