@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,164 @@
1
+ import { spawn } from "child_process";
2
+ import { resolve } from "path";
3
+ import { PACKAGE_ROOT } from "../../runtime-paths";
4
+ import {
5
+ DEFAULT_PREVIEW_HOST,
6
+ DEFAULT_PREVIEW_PORT,
7
+ previewBaseUrl,
8
+ resolvePreviewHost,
9
+ resolvePreviewPort,
10
+ } from "./paths";
11
+ import { findExistingServer, probeHealth } from "./singleton";
12
+ import type { PreviewEntryMeta, PreviewRegisterInput } from "./types";
13
+
14
+ export interface RegisterPreviewResult {
15
+ ok: boolean;
16
+ preview_url?: string;
17
+ dashboard_url?: string;
18
+ id?: string;
19
+ error?: string;
20
+ }
21
+
22
+ export interface EnsurePreviewServerOptions {
23
+ host?: string;
24
+ port?: number;
25
+ autoStart?: boolean;
26
+ /** Max wait for auto-started server readiness. */
27
+ waitMs?: number;
28
+ }
29
+
30
+ /**
31
+ * Ensure a preview server is reachable. Optionally spawn one in the background.
32
+ */
33
+ export async function ensurePreviewServer(
34
+ options: EnsurePreviewServerOptions = {},
35
+ ): Promise<{ url: string; started: boolean }> {
36
+ const host = resolvePreviewHost(options.host);
37
+ const port = resolvePreviewPort(options.port);
38
+ const existing = await findExistingServer(host, port);
39
+ if (existing.kind === "running") {
40
+ return { url: existing.lock.url, started: false };
41
+ }
42
+
43
+ if (!options.autoStart) {
44
+ throw new Error(
45
+ `WeChat preview server is not running at ${previewBaseUrl(host, port)}. ` +
46
+ `Start it with: bun run src/cli.ts wechat-preview serve`,
47
+ );
48
+ }
49
+
50
+ const args = [
51
+ resolve(PACKAGE_ROOT, "src/cli.ts"),
52
+ "wechat-preview",
53
+ "serve",
54
+ "--host",
55
+ host,
56
+ "--port",
57
+ String(port),
58
+ ];
59
+
60
+ const child = spawn(process.execPath, args, {
61
+ detached: true,
62
+ stdio: "ignore",
63
+ env: {
64
+ ...process.env,
65
+ ZZHUB_WECHAT_PREVIEW_HOST: host,
66
+ ZZHUB_WECHAT_PREVIEW_PORT: String(port),
67
+ },
68
+ });
69
+ child.unref();
70
+
71
+ const url = previewBaseUrl(host, port);
72
+ const deadline = Date.now() + (options.waitMs ?? 8000);
73
+ while (Date.now() < deadline) {
74
+ if (await probeHealth(url, 400)) {
75
+ return { url, started: true };
76
+ }
77
+ await Bun.sleep(150);
78
+ }
79
+
80
+ throw new Error(
81
+ `Timed out waiting for WeChat preview server at ${url} (spawned pid ${child.pid ?? "?"})`,
82
+ );
83
+ }
84
+
85
+ export async function registerPreviewEntry(
86
+ input: PreviewRegisterInput,
87
+ options: EnsurePreviewServerOptions = {},
88
+ ): Promise<RegisterPreviewResult> {
89
+ try {
90
+ const { url } = await ensurePreviewServer({
91
+ autoStart: options.autoStart ?? true,
92
+ host: options.host,
93
+ port: options.port,
94
+ waitMs: options.waitMs,
95
+ });
96
+
97
+ const res = await fetch(`${url.replace(/\/$/, "")}/api/entries`, {
98
+ method: "POST",
99
+ headers: { "content-type": "application/json" },
100
+ body: JSON.stringify(input),
101
+ });
102
+ if (!res.ok) {
103
+ const text = await res.text();
104
+ return { ok: false, error: `register failed (${res.status}): ${text}` };
105
+ }
106
+ const body = (await res.json()) as PreviewEntryMeta & {
107
+ preview_url?: string;
108
+ dashboard_url?: string;
109
+ };
110
+ return {
111
+ ok: true,
112
+ id: body.id,
113
+ preview_url: body.preview_url ?? `${url}/e/${body.id}`,
114
+ dashboard_url: body.dashboard_url ?? url,
115
+ };
116
+ } catch (error) {
117
+ return {
118
+ ok: false,
119
+ error: error instanceof Error ? error.message : String(error),
120
+ };
121
+ }
122
+ }
123
+
124
+ export async function getPreviewServerStatus(options: {
125
+ host?: string;
126
+ port?: number;
127
+ } = {}): Promise<{
128
+ running: boolean;
129
+ url?: string;
130
+ host?: string;
131
+ port?: number;
132
+ entries?: number;
133
+ }> {
134
+ const host = resolvePreviewHost(options.host);
135
+ const port = resolvePreviewPort(options.port);
136
+ const existing = await findExistingServer(host, port);
137
+ if (existing.kind !== "running") {
138
+ return { running: false, host, port };
139
+ }
140
+ let entries: number | undefined;
141
+ try {
142
+ const res = await fetch(`${existing.lock.url}/api/entries`);
143
+ if (res.ok) {
144
+ const list = (await res.json()) as unknown[];
145
+ entries = Array.isArray(list) ? list.length : undefined;
146
+ }
147
+ } catch {
148
+ // ignore
149
+ }
150
+ return {
151
+ running: true,
152
+ url: existing.lock.url,
153
+ host: existing.lock.host,
154
+ port: existing.lock.port,
155
+ entries,
156
+ };
157
+ }
158
+
159
+ export function defaultPreviewListen(): { host: string; port: number } {
160
+ return {
161
+ host: DEFAULT_PREVIEW_HOST,
162
+ port: DEFAULT_PREVIEW_PORT,
163
+ };
164
+ }
@@ -0,0 +1,229 @@
1
+ import {
2
+ previewBaseUrl,
3
+ resolvePreviewHost,
4
+ resolvePreviewPort,
5
+ } from "./paths";
6
+ import {
7
+ clearPreviewEntries,
8
+ createPreviewEntry,
9
+ getPreviewEntry,
10
+ listPreviewEntries,
11
+ } from "./registry";
12
+ import {
13
+ buildLock,
14
+ clearServerLock,
15
+ findExistingServer,
16
+ readServerLock,
17
+ writeServerLock,
18
+ } from "./singleton";
19
+ import {
20
+ guessContentType,
21
+ readLocalFile,
22
+ resolveLocalFilePath,
23
+ rewriteHtmlLocalAssets,
24
+ } from "./local-file";
25
+ import { renderDashboardHtml, renderFailedEntryHtml } from "./ui";
26
+ import { buildWechatPreviewShell } from "../index";
27
+ import type { PreviewRegisterInput } from "./types";
28
+
29
+ export interface StartPreviewServerOptions {
30
+ host?: string;
31
+ port?: number;
32
+ open?: boolean;
33
+ /** If true, return early when server already running instead of throwing. */
34
+ reuseExisting?: boolean;
35
+ }
36
+
37
+ export interface StartPreviewServerResult {
38
+ url: string;
39
+ host: string;
40
+ port: number;
41
+ reused: boolean;
42
+ stop?: () => void;
43
+ }
44
+
45
+ function json(data: unknown, status = 200): Response {
46
+ return new Response(JSON.stringify(data), {
47
+ status,
48
+ headers: {
49
+ "content-type": "application/json; charset=utf-8",
50
+ "cache-control": "no-store",
51
+ },
52
+ });
53
+ }
54
+
55
+ function html(body: string, status = 200): Response {
56
+ return new Response(body, {
57
+ status,
58
+ headers: {
59
+ "content-type": "text/html; charset=utf-8",
60
+ "cache-control": "no-store",
61
+ },
62
+ });
63
+ }
64
+
65
+ export async function startPreviewServer(
66
+ options: StartPreviewServerOptions = {},
67
+ ): Promise<StartPreviewServerResult> {
68
+ const host = resolvePreviewHost(options.host);
69
+ const port = resolvePreviewPort(options.port);
70
+ const existing = await findExistingServer(host, port);
71
+
72
+ if (existing.kind === "running") {
73
+ if (options.reuseExisting !== false) {
74
+ return {
75
+ url: existing.lock.url,
76
+ host: existing.lock.host,
77
+ port: existing.lock.port,
78
+ reused: true,
79
+ };
80
+ }
81
+ throw new Error(
82
+ `WeChat preview server already running at ${existing.lock.url} (pid ${existing.lock.pid})`,
83
+ );
84
+ }
85
+
86
+ if (existing.kind === "stale_lock") {
87
+ clearServerLock();
88
+ }
89
+
90
+ const baseUrl = previewBaseUrl(host, port);
91
+
92
+ const server = Bun.serve({
93
+ hostname: host,
94
+ port,
95
+ async fetch(req) {
96
+ const url = new URL(req.url);
97
+ const { pathname } = url;
98
+
99
+ if (pathname === "/api/health") {
100
+ return json({ ok: true, service: "wechat-preview", url: baseUrl, pid: process.pid });
101
+ }
102
+
103
+ if (pathname === "/api/entries" && req.method === "GET") {
104
+ return json(listPreviewEntries());
105
+ }
106
+
107
+ if (pathname === "/api/entries" && req.method === "POST") {
108
+ let body: PreviewRegisterInput;
109
+ try {
110
+ body = (await req.json()) as PreviewRegisterInput;
111
+ } catch {
112
+ return json({ error: "invalid JSON body" }, 400);
113
+ }
114
+ if (!body || typeof body !== "object") {
115
+ return json({ error: "body required" }, 400);
116
+ }
117
+ const entry = createPreviewEntry({
118
+ title: String(body.title ?? "Untitled"),
119
+ account: String(body.account ?? "default"),
120
+ status: body.status === "failed" ? "failed" : "success",
121
+ duration_ms: Number(body.duration_ms) || 0,
122
+ markdown_path: body.markdown_path,
123
+ html_path: body.html_path,
124
+ preview_style: body.preview_style,
125
+ html: body.html,
126
+ error: body.error,
127
+ error_kind: body.error_kind,
128
+ debug: body.debug,
129
+ });
130
+ return json({
131
+ ...entry,
132
+ html: undefined,
133
+ preview_url: `${baseUrl}/e/${entry.id}`,
134
+ dashboard_url: baseUrl,
135
+ }, 201);
136
+ }
137
+
138
+ if (pathname === "/api/entries" && req.method === "DELETE") {
139
+ const cleared = clearPreviewEntries();
140
+ return json({ cleared });
141
+ }
142
+
143
+ const entryApiMatch = pathname.match(/^\/api\/entries\/([^/]+)$/);
144
+ if (entryApiMatch && req.method === "GET") {
145
+ const id = decodeURIComponent(entryApiMatch[1] ?? "");
146
+ const entry = getPreviewEntry(id);
147
+ if (!entry) return json({ error: "not found" }, 404);
148
+ return json(entry);
149
+ }
150
+
151
+ const entryPageMatch = pathname.match(/^\/e\/([^/]+)$/);
152
+ if (entryPageMatch && req.method === "GET") {
153
+ const id = decodeURIComponent(entryPageMatch[1] ?? "");
154
+ const entry = getPreviewEntry(id);
155
+ if (!entry) return html("<h1>Not found</h1>", 404);
156
+ if (entry.status === "failed" || !entry.html) {
157
+ return html(renderFailedEntryHtml(entry));
158
+ }
159
+ const rewritten = rewriteHtmlLocalAssets(entry.html, baseUrl);
160
+ return html(buildWechatPreviewShell(rewritten, entry.title));
161
+ }
162
+
163
+ if (pathname === "/local-file" && req.method === "GET") {
164
+ const resolved = resolveLocalFilePath(url.searchParams.get("path"));
165
+ if (!resolved.ok) {
166
+ return json({ error: resolved.error }, resolved.status);
167
+ }
168
+ try {
169
+ const buf = readLocalFile(resolved.path);
170
+ return new Response(new Uint8Array(buf), {
171
+ headers: {
172
+ "content-type": guessContentType(resolved.path),
173
+ "cache-control": "private, max-age=60",
174
+ },
175
+ });
176
+ } catch (error) {
177
+ return json({
178
+ error: error instanceof Error ? error.message : String(error),
179
+ }, 500);
180
+ }
181
+ }
182
+
183
+ if (pathname === "/" || pathname === "/index.html") {
184
+ return html(renderDashboardHtml(baseUrl));
185
+ }
186
+
187
+ return json({ error: "not found" }, 404);
188
+ },
189
+ });
190
+
191
+ const boundPort = server.port;
192
+ if (typeof boundPort !== "number") {
193
+ server.stop(true);
194
+ throw new Error("WeChat preview server failed to bind a port");
195
+ }
196
+
197
+ const lock = buildLock(host, boundPort, process.pid);
198
+ writeServerLock(lock);
199
+
200
+ const stop = () => {
201
+ try {
202
+ server.stop(true);
203
+ } catch {
204
+ // ignore
205
+ }
206
+ const existingLock = readServerLock();
207
+ if (existingLock && existingLock.pid === process.pid) {
208
+ clearServerLock();
209
+ }
210
+ };
211
+
212
+ process.on("exit", stop);
213
+ process.on("SIGINT", () => {
214
+ stop();
215
+ process.exit(0);
216
+ });
217
+ process.on("SIGTERM", () => {
218
+ stop();
219
+ process.exit(0);
220
+ });
221
+
222
+ return {
223
+ url: lock.url,
224
+ host: lock.host,
225
+ port: lock.port,
226
+ reused: false,
227
+ stop,
228
+ };
229
+ }
@@ -0,0 +1,33 @@
1
+ export {
2
+ DEFAULT_PREVIEW_HOST,
3
+ DEFAULT_PREVIEW_PORT,
4
+ getPreviewServerDir,
5
+ previewBaseUrl,
6
+ resolvePreviewHost,
7
+ resolvePreviewPort,
8
+ } from "./paths";
9
+ export {
10
+ clearPreviewEntries,
11
+ createPreviewEntry,
12
+ getPreviewEntry,
13
+ listPreviewEntries,
14
+ } from "./registry";
15
+ export {
16
+ buildLock,
17
+ clearServerLock,
18
+ findExistingServer,
19
+ probeHealth,
20
+ readServerLock,
21
+ } from "./singleton";
22
+ export {
23
+ ensurePreviewServer,
24
+ getPreviewServerStatus,
25
+ registerPreviewEntry,
26
+ } from "./client";
27
+ export { startPreviewServer } from "./http";
28
+ export type {
29
+ PreviewEntry,
30
+ PreviewEntryMeta,
31
+ PreviewRegisterInput,
32
+ PreviewServerLock,
33
+ } from "./types";
@@ -0,0 +1,52 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { homedir } from "os";
3
+ import { join } from "path";
4
+ import { resolveLocalFilePath, rewriteHtmlLocalAssets } from "./local-file";
5
+
6
+ describe("resolveLocalFilePath", () => {
7
+ test("rejects empty path", () => {
8
+ expect(resolveLocalFilePath("")).toEqual({
9
+ ok: false,
10
+ status: 400,
11
+ error: "missing path",
12
+ });
13
+ });
14
+
15
+ test("rejects relative paths", () => {
16
+ const result = resolveLocalFilePath("relative/img.png");
17
+ expect(result.ok).toBe(false);
18
+ if (!result.ok) expect(result.status).toBe(400);
19
+ });
20
+
21
+ test("rejects path outside home (except /tmp)", () => {
22
+ const result = resolveLocalFilePath("/etc/passwd");
23
+ expect(result.ok).toBe(false);
24
+ if (!result.ok) expect(result.status).toBe(403);
25
+ });
26
+
27
+ test("accepts existing file under home", () => {
28
+ // package.json under repo may be outside home in some setups; use a file in home if possible
29
+ const candidate = join(homedir(), ".zshrc");
30
+ const result = resolveLocalFilePath(candidate);
31
+ if (result.ok) {
32
+ expect(result.path).toBe(candidate);
33
+ } else {
34
+ // .zshrc may not exist; still ensure absolute home path is not rejected for wrong reason
35
+ expect(result.status).not.toBe(403);
36
+ }
37
+ });
38
+ });
39
+
40
+ describe("rewriteHtmlLocalAssets", () => {
41
+ test("rewrites absolute img src to local-file proxy", () => {
42
+ const html = '<img src="/Users/me/pic.png" alt="x">';
43
+ const out = rewriteHtmlLocalAssets(html, "http://127.0.0.1:18765");
44
+ expect(out).toContain("/local-file?path=");
45
+ expect(out).toContain(encodeURIComponent("/Users/me/pic.png"));
46
+ });
47
+
48
+ test("leaves https sources alone", () => {
49
+ const html = '<img src="https://cdn.example.com/a.png">';
50
+ expect(rewriteHtmlLocalAssets(html, "http://127.0.0.1:18765")).toBe(html);
51
+ });
52
+ });
@@ -0,0 +1,116 @@
1
+ import { existsSync, readFileSync, statSync } from "fs";
2
+ import { homedir } from "os";
3
+ import { isAbsolute, resolve } from "path";
4
+
5
+ const HOME = homedir();
6
+
7
+ /**
8
+ * Resolve a local filesystem path for the /local-file proxy.
9
+ * Rejects non-absolute paths, missing files, and paths outside $HOME (unless overridden).
10
+ */
11
+ export function resolveLocalFilePath(
12
+ raw: string | null,
13
+ options: { allowOutsideHome?: boolean } = {},
14
+ ): { ok: true; path: string } | { ok: false; status: number; error: string } {
15
+ if (!raw || !raw.trim()) {
16
+ return { ok: false, status: 400, error: "missing path" };
17
+ }
18
+
19
+ let decoded: string;
20
+ try {
21
+ decoded = decodeURIComponent(raw.trim());
22
+ } catch {
23
+ return { ok: false, status: 400, error: "invalid path encoding" };
24
+ }
25
+
26
+ // Strip file:// prefix if present
27
+ if (decoded.startsWith("file://")) {
28
+ try {
29
+ decoded = new URL(decoded).pathname;
30
+ // macOS: file:///Users/... → /Users/...
31
+ } catch {
32
+ return { ok: false, status: 400, error: "invalid file URL" };
33
+ }
34
+ }
35
+
36
+ if (!isAbsolute(decoded)) {
37
+ return { ok: false, status: 400, error: "path must be absolute" };
38
+ }
39
+
40
+ const resolved = resolve(decoded);
41
+ if (resolved.includes("\0")) {
42
+ return { ok: false, status: 400, error: "invalid path" };
43
+ }
44
+
45
+ if (!options.allowOutsideHome && !resolved.startsWith(HOME + "/") && resolved !== HOME) {
46
+ // Also allow /tmp for debug artifacts
47
+ if (!resolved.startsWith("/tmp/") && resolved !== "/tmp") {
48
+ return { ok: false, status: 403, error: "path outside home directory" };
49
+ }
50
+ }
51
+
52
+ if (!existsSync(resolved)) {
53
+ return { ok: false, status: 404, error: "file not found" };
54
+ }
55
+
56
+ try {
57
+ const st = statSync(resolved);
58
+ if (!st.isFile()) {
59
+ return { ok: false, status: 400, error: "not a file" };
60
+ }
61
+ } catch {
62
+ return { ok: false, status: 404, error: "file not accessible" };
63
+ }
64
+
65
+ return { ok: true, path: resolved };
66
+ }
67
+
68
+ export function guessContentType(path: string): string {
69
+ const lower = path.toLowerCase();
70
+ if (lower.endsWith(".png")) return "image/png";
71
+ if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
72
+ if (lower.endsWith(".gif")) return "image/gif";
73
+ if (lower.endsWith(".webp")) return "image/webp";
74
+ if (lower.endsWith(".svg")) return "image/svg+xml";
75
+ if (lower.endsWith(".avif")) return "image/avif";
76
+ if (lower.endsWith(".html") || lower.endsWith(".htm")) return "text/html; charset=utf-8";
77
+ if (lower.endsWith(".css")) return "text/css; charset=utf-8";
78
+ if (lower.endsWith(".js")) return "text/javascript; charset=utf-8";
79
+ if (lower.endsWith(".json")) return "application/json; charset=utf-8";
80
+ return "application/octet-stream";
81
+ }
82
+
83
+ export function readLocalFile(path: string): Buffer {
84
+ return readFileSync(path);
85
+ }
86
+
87
+ /**
88
+ * Rewrite absolute / file:// image src attributes to the local-file proxy.
89
+ */
90
+ export function rewriteHtmlLocalAssets(html: string, baseOrigin: string): string {
91
+ const proxy = (src: string): string => {
92
+ const trimmed = src.trim();
93
+ if (!trimmed) return src;
94
+ if (/^(?:https?:|data:|blob:|\/\/)/i.test(trimmed)) return src;
95
+ if (trimmed.startsWith("/local-file")) return src;
96
+
97
+ let abs = trimmed;
98
+ if (trimmed.startsWith("file://")) {
99
+ try {
100
+ abs = new URL(trimmed).pathname;
101
+ } catch {
102
+ return src;
103
+ }
104
+ }
105
+ if (!isAbsolute(abs)) return src;
106
+ return `${baseOrigin}/local-file?path=${encodeURIComponent(abs)}`;
107
+ };
108
+
109
+ return html
110
+ .replace(/(<img\b[^>]*\bsrc=["'])([^"']+)(["'])/gi, (_m, pre, src, post) => {
111
+ return `${pre}${proxy(src)}${post}`;
112
+ })
113
+ .replace(/(<img\b[^>]*\bdata-src=["'])([^"']+)(["'])/gi, (_m, pre, src, post) => {
114
+ return `${pre}${proxy(src)}${post}`;
115
+ });
116
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Data directory for the singleton WeChat preview server.
3
+ * Mirrors config base-dir resolution in src/config.ts.
4
+ */
5
+
6
+ import { existsSync, mkdirSync } from "fs";
7
+ import { homedir, platform } from "os";
8
+ import { join } from "path";
9
+
10
+ const APP_DIR = "zzhub-pipeline";
11
+ const SERVER_DIR = "wechat-preview";
12
+
13
+ export const DEFAULT_PREVIEW_HOST = "127.0.0.1";
14
+ export const DEFAULT_PREVIEW_PORT = 18765;
15
+
16
+ function getBaseConfigDir(appDir: string): string {
17
+ const home = homedir();
18
+ const currentPlatform = platform();
19
+
20
+ if (currentPlatform === "win32") {
21
+ const appData = process.env.APPDATA;
22
+ if (appData) return join(appData, appDir);
23
+ return join(home, "AppData", "Roaming", appDir);
24
+ }
25
+
26
+ if (currentPlatform === "darwin") {
27
+ return join(home, "Library", "Application Support", appDir);
28
+ }
29
+
30
+ const xdgConfigHome = process.env.XDG_CONFIG_HOME;
31
+ if (xdgConfigHome && xdgConfigHome.trim().length > 0) {
32
+ return join(xdgConfigHome, appDir);
33
+ }
34
+
35
+ return join(home, ".config", appDir);
36
+ }
37
+
38
+ export function getPreviewServerDir(): string {
39
+ const override = process.env.ZZHUB_WECHAT_PREVIEW_DIR?.trim();
40
+ if (override) return override;
41
+ return join(getBaseConfigDir(APP_DIR), SERVER_DIR);
42
+ }
43
+
44
+ export function getPreviewServerLockPath(): string {
45
+ return join(getPreviewServerDir(), "server.json");
46
+ }
47
+
48
+ export function getPreviewEntriesDir(): string {
49
+ return join(getPreviewServerDir(), "entries");
50
+ }
51
+
52
+ export function ensurePreviewServerDirs(): void {
53
+ const root = getPreviewServerDir();
54
+ const entries = getPreviewEntriesDir();
55
+ if (!existsSync(root)) mkdirSync(root, { recursive: true });
56
+ if (!existsSync(entries)) mkdirSync(entries, { recursive: true });
57
+ }
58
+
59
+ export function resolvePreviewPort(port?: number): number {
60
+ if (typeof port === "number" && Number.isFinite(port) && port > 0) {
61
+ return Math.floor(port);
62
+ }
63
+ const env = process.env.ZZHUB_WECHAT_PREVIEW_PORT?.trim();
64
+ if (env) {
65
+ const n = Number(env);
66
+ if (Number.isFinite(n) && n > 0) return Math.floor(n);
67
+ }
68
+ return DEFAULT_PREVIEW_PORT;
69
+ }
70
+
71
+ export function resolvePreviewHost(host?: string): string {
72
+ if (host && host.trim()) return host.trim();
73
+ const env = process.env.ZZHUB_WECHAT_PREVIEW_HOST?.trim();
74
+ if (env) return env;
75
+ return DEFAULT_PREVIEW_HOST;
76
+ }
77
+
78
+ export function previewBaseUrl(host: string, port: number): string {
79
+ return `http://${host}:${port}`;
80
+ }