@zzclub/pipeline 0.13.2 → 0.16.0

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 (60) hide show
  1. package/ARTICLE-THEMES.md +86 -0
  2. package/LICENSE +21 -0
  3. package/README.md +182 -788
  4. package/THIRD-PARTY-NOTICES.md +25 -0
  5. package/package.json +6 -2
  6. package/src/adapter-types.ts +3 -0
  7. package/src/adapters/builtin-markdown-renderer.ts +1 -0
  8. package/src/article-theme.test.ts +87 -0
  9. package/src/article-theme.ts +115 -0
  10. package/src/commands/abandon.test.ts +1 -1
  11. package/src/commands/article-theme.ts +16 -0
  12. package/src/commands/checkpoint.test.ts +2 -2
  13. package/src/commands/ingest-handoff.ts +6 -0
  14. package/src/commands/init.ts +10 -0
  15. package/src/commands/prepare.ts +5 -1
  16. package/src/commands/publish.ts +4 -0
  17. package/src/commands/reset.ts +2 -0
  18. package/src/commands/wechat-export.ts +4 -0
  19. package/src/commands/wechat-preview.ts +35 -0
  20. package/src/plugins.ts +2 -0
  21. package/src/providers/index.ts +17 -2
  22. package/src/providers/publish-core.ts +32 -1
  23. package/src/providers/wechat.test.ts +12 -0
  24. package/src/providers/wechat.ts +60 -39
  25. package/src/render-branding.ts +1 -1
  26. package/src/schema/config.ts +12 -0
  27. package/src/schema/state.ts +5 -0
  28. package/src/wechat-preview/assets/browser-dist/.vite/manifest.json +21 -1
  29. package/src/wechat-preview/assets/browser-dist/assets/style-C9-YtTIm.css +1 -0
  30. package/src/wechat-preview/assets/browser-dist/assets/wechat-renderer-D1jHoaM2.js +624 -0
  31. package/src/wechat-preview/assets/browser-dist/editor-export.js +1 -512
  32. package/src/wechat-preview/assets/browser-dist/src/wechat-preview/browser/studio/studio.html +293 -0
  33. package/src/wechat-preview/assets/browser-dist/studio.js +332 -0
  34. package/src/wechat-preview/browser/editor-export.ts +17 -2
  35. package/src/wechat-preview/browser/highlight.ts +2 -0
  36. package/src/wechat-preview/browser/studio/color-palettes.test.ts +31 -0
  37. package/src/wechat-preview/browser/studio/color-palettes.ts +115 -0
  38. package/src/wechat-preview/browser/studio/studio.css +638 -0
  39. package/src/wechat-preview/browser/studio/studio.html +292 -0
  40. package/src/wechat-preview/browser/studio/studio.ts +721 -0
  41. package/src/wechat-preview/browser/studio/syntax-presets.test.ts +74 -0
  42. package/src/wechat-preview/browser/studio/syntax-presets.ts +590 -0
  43. package/src/wechat-preview/fixtures/all-nodes.md +38 -0
  44. package/src/wechat-preview/index.ts +36 -5
  45. package/src/wechat-preview/plugins/builtin-badge.ts +152 -0
  46. package/src/wechat-preview/plugins/builtin-callout.ts +261 -0
  47. package/src/wechat-preview/plugins/builtin-highlight.ts +46 -0
  48. package/src/wechat-preview/plugins/builtin-kbd.ts +133 -0
  49. package/src/wechat-preview/plugins/index.test.ts +313 -0
  50. package/src/wechat-preview/plugins/index.ts +6 -0
  51. package/src/wechat-preview/plugins/registry.ts +62 -0
  52. package/src/wechat-preview/plugins/types.ts +31 -0
  53. package/src/wechat-preview/server/http.ts +160 -3
  54. package/src/wechat-preview/server/paths.ts +2 -2
  55. package/src/wechat-preview/server/singleton.ts +14 -12
  56. package/src/wechat-preview/server/studio.test.ts +92 -0
  57. package/src/wechat-preview/server/ui.ts +1 -0
  58. package/src/wechat-preview/wechat-export.integration.test.ts +9 -1
  59. package/src/wechat-preview/wechat-renderer.ts +72 -7
  60. package/src/workflow.test.ts +1 -0
@@ -0,0 +1,313 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ getAllPlugins,
4
+ getPluginDocs,
5
+ getCombinedMilkdownPlugins,
6
+ getCombinedDefaultCss,
7
+ getCombinedWechatRenderers,
8
+ registerPlugin,
9
+ } from "./registry";
10
+ import { remarkCallout } from "./builtin-callout";
11
+ import { remarkKbd } from "./builtin-kbd";
12
+ import { remarkBadge } from "./builtin-badge";
13
+
14
+
15
+ describe("wechat-preview plugins system", () => {
16
+ test("loads all built-in plugins and documentation metadata", () => {
17
+ const plugins = getAllPlugins();
18
+ const ids = plugins.map((p) => p.doc.id);
19
+ expect(ids).toContain("highlight");
20
+ expect(ids).toContain("callout");
21
+ expect(ids).toContain("kbd");
22
+ expect(ids).toContain("badge");
23
+
24
+ const docs = getPluginDocs();
25
+ expect(docs.length).toBeGreaterThanOrEqual(4);
26
+ for (const doc of docs) {
27
+ expect(doc.id).toBeDefined();
28
+ expect(doc.name).toBeDefined();
29
+ expect(doc.category).toBeDefined();
30
+ expect(doc.sampleMarkdown).toBeDefined();
31
+ expect(doc.syntaxGuide).toBeDefined();
32
+ }
33
+ });
34
+
35
+ test("getCombinedMilkdownPlugins returns executable plugin list", () => {
36
+ const milkdownPlugins = getCombinedMilkdownPlugins();
37
+ expect(Array.isArray(milkdownPlugins)).toBe(true);
38
+ expect(milkdownPlugins.length).toBeGreaterThan(0);
39
+ });
40
+
41
+ test("getCombinedDefaultCss contains rules for all builtins", () => {
42
+ const css = getCombinedDefaultCss();
43
+ expect(css).toContain(".milkdown .editor mark");
44
+ expect(css).toContain(".wechat-callout");
45
+ expect(css).toContain(".wechat-kbd");
46
+ expect(css).toContain(".wechat-badge");
47
+ });
48
+
49
+ test("remarkCallout parses GFM alerts into callout nodes", () => {
50
+ const tree = {
51
+ type: "root",
52
+ children: [
53
+ {
54
+ type: "blockquote",
55
+ children: [
56
+ {
57
+ type: "paragraph",
58
+ children: [
59
+ {
60
+ type: "text",
61
+ value: "[!NOTE]\n这是一个测试提示",
62
+ },
63
+ ],
64
+ },
65
+ ],
66
+ },
67
+ ],
68
+ };
69
+
70
+ const transform = remarkCallout();
71
+ transform(tree);
72
+
73
+ const callout = tree.children[0] as any;
74
+ expect(callout.type).toBe("callout");
75
+ expect(callout.calloutType).toBe("note");
76
+ expect(callout.customTitle).toBe("");
77
+ expect(callout.children[0].children[0].value).toBe("这是一个测试提示");
78
+ });
79
+
80
+ test("remarkCallout parses GFM alerts with inline custom title and emoji", () => {
81
+ const tree = {
82
+ type: "root",
83
+ children: [
84
+ {
85
+ type: "blockquote",
86
+ children: [
87
+ {
88
+ type: "paragraph",
89
+ children: [
90
+ {
91
+ type: "text",
92
+ value: "[!TIP] 实用技巧\n这是一个技巧说明",
93
+ },
94
+ ],
95
+ },
96
+ ],
97
+ },
98
+ {
99
+ type: "blockquote",
100
+ children: [
101
+ {
102
+ type: "paragraph",
103
+ children: [
104
+ {
105
+ type: "text",
106
+ value: "[!WARNING] ⚠️ 重点防护\n这是一个警告说明",
107
+ },
108
+ ],
109
+ },
110
+ ],
111
+ },
112
+ ],
113
+ };
114
+
115
+ const transform = remarkCallout();
116
+ transform(tree);
117
+
118
+ const tipCallout = tree.children[0] as any;
119
+ expect(tipCallout.type).toBe("callout");
120
+ expect(tipCallout.calloutType).toBe("tip");
121
+ expect(tipCallout.customTitle).toBe("实用技巧");
122
+ expect(tipCallout.children[0].children[0].value).toBe("这是一个技巧说明");
123
+
124
+ const warnCallout = tree.children[1] as any;
125
+ expect(warnCallout.type).toBe("callout");
126
+ expect(warnCallout.calloutType).toBe("warning");
127
+ expect(warnCallout.customTitle).toBe("⚠️ 重点防护");
128
+ expect(warnCallout.children[0].children[0].value).toBe("这是一个警告说明");
129
+ });
130
+
131
+ test("remarkKbd parses [[kbd:Key]] into kbd nodes", () => {
132
+ const tree = {
133
+ type: "root",
134
+ children: [
135
+ {
136
+ type: "paragraph",
137
+ children: [
138
+ {
139
+ type: "text",
140
+ value: "按下 [[kbd:Cmd]] + [[kbd:C]] 复制",
141
+ },
142
+ ],
143
+ },
144
+ ],
145
+ };
146
+
147
+ const transform = remarkKbd();
148
+ transform(tree);
149
+
150
+ const paragraph = tree.children[0] as any;
151
+ expect(paragraph.children.length).toBe(5);
152
+ expect(paragraph.children[0].value).toBe("按下 ");
153
+ expect(paragraph.children[1].type).toBe("kbd");
154
+ expect(paragraph.children[1].children[0].value).toBe("Cmd");
155
+ expect(paragraph.children[2].value).toBe(" + ");
156
+ expect(paragraph.children[3].type).toBe("kbd");
157
+ expect(paragraph.children[3].children[0].value).toBe("C");
158
+ expect(paragraph.children[4].value).toBe(" 复制");
159
+ });
160
+
161
+ test("remarkBadge parses [badge:type|text] into badge nodes", () => {
162
+ const tree = {
163
+ type: "root",
164
+ children: [
165
+ {
166
+ type: "paragraph",
167
+ children: [
168
+ {
169
+ type: "text",
170
+ value: "状态:[badge:success|已上线]",
171
+ },
172
+ ],
173
+ },
174
+ ],
175
+ };
176
+
177
+ const transform = remarkBadge();
178
+ transform(tree);
179
+
180
+ const paragraph = tree.children[0] as any;
181
+ expect(paragraph.children.length).toBe(2);
182
+ expect(paragraph.children[0].value).toBe("状态:");
183
+ expect(paragraph.children[1].type).toBe("badge");
184
+ expect(paragraph.children[1].badgeType).toBe("success");
185
+ expect(paragraph.children[1].children[0].value).toBe("已上线");
186
+ });
187
+
188
+ test("calloutWechatRenderer prepares icon and title", () => {
189
+ const renderers = getCombinedWechatRenderers();
190
+ const callout = renderers.find((r) => r.kind === "callout");
191
+ expect(callout).toBeDefined();
192
+
193
+ // Mock document and element
194
+ const mockCreated: any[] = [];
195
+ const mockDocument: any = {
196
+ createElement: (tag: string) => {
197
+ const el = {
198
+ tagName: tag,
199
+ className: "",
200
+ attributes: {} as Record<string, string>,
201
+ children: [] as any[],
202
+ setAttribute: (name: string, val: string) => { el.attributes[name] = val; },
203
+ appendChild: (c: any) => { el.children.push(c); },
204
+ prepend: (c: any) => { el.children.unshift(c); },
205
+ };
206
+ mockCreated.push(el);
207
+ return el;
208
+ },
209
+ };
210
+
211
+ const element: any = {
212
+ attributes: { "data-callout-type": "tip" },
213
+ children: [],
214
+ getAttribute: (name: string) => element.attributes[name],
215
+ prepend: (c: any) => { element.children.unshift(c); },
216
+ };
217
+
218
+ const context: any = {
219
+ document: mockDocument,
220
+ };
221
+
222
+ callout?.prepare?.(element, context);
223
+ expect(element.children.length).toBe(1);
224
+ const header = element.children[0];
225
+ expect(header.attributes["data-wechat-node"]).toBe("callout-header");
226
+ expect(header.children[0].textContent).toBe("✨");
227
+ expect(header.children[1].textContent).toBe("建议");
228
+ });
229
+
230
+ test("calloutWechatRenderer handles custom title text and emoji", () => {
231
+ const renderers = getCombinedWechatRenderers();
232
+ const callout = renderers.find((r) => r.kind === "callout");
233
+
234
+ const mockDocument: any = {
235
+ createElement: (tag: string) => {
236
+ const el = {
237
+ tagName: tag,
238
+ className: "",
239
+ attributes: {} as Record<string, string>,
240
+ children: [] as any[],
241
+ setAttribute: (name: string, val: string) => { el.attributes[name] = val; },
242
+ appendChild: (c: any) => { el.children.push(c); },
243
+ prepend: (c: any) => { el.children.unshift(c); },
244
+ };
245
+ return el;
246
+ },
247
+ };
248
+
249
+ // Case 1: Custom title text without emoji (retains default emoji)
250
+ const el1: any = {
251
+ attributes: { "data-callout-type": "note", "data-callout-title": "核心注意项" },
252
+ children: [],
253
+ getAttribute: (name: string) => el1.attributes[name],
254
+ prepend: (c: any) => { el1.children.unshift(c); },
255
+ };
256
+ callout?.prepare?.(el1, { document: mockDocument } as any);
257
+ const header1 = el1.children[0];
258
+ expect(header1.children[0].textContent).toBe("💡");
259
+ expect(header1.children[1].textContent).toBe("核心注意项");
260
+
261
+ // Case 2: Custom title with leading emoji
262
+ const el2: any = {
263
+ attributes: { "data-callout-type": "tip", "data-callout-title": "🚀 快速上手" },
264
+ children: [],
265
+ getAttribute: (name: string) => el2.attributes[name],
266
+ prepend: (c: any) => { el2.children.unshift(c); },
267
+ };
268
+ callout?.prepare?.(el2, { document: mockDocument } as any);
269
+ const header2 = el2.children[0];
270
+ expect(header2.children[0].textContent).toBe("🚀");
271
+ expect(header2.children[1].textContent).toBe("快速上手");
272
+
273
+ // Case 3: Structure level preset override
274
+ const el3: any = {
275
+ attributes: { "data-callout-type": "warning" },
276
+ children: [],
277
+ getAttribute: (name: string) => el3.attributes[name],
278
+ prepend: (c: any) => { el3.children.unshift(c); },
279
+ };
280
+ callout?.prepare?.(el3, {
281
+ document: mockDocument,
282
+ structure: {
283
+ calloutPresets: {
284
+ warning: { title: "避坑指南", icon: "💣" },
285
+ },
286
+ },
287
+ } as any);
288
+ const header3 = el3.children[0];
289
+ expect(header3.children[0].textContent).toBe("💣");
290
+ expect(header3.children[1].textContent).toBe("避坑指南");
291
+ });
292
+
293
+ test("allows registering custom plugins", () => {
294
+ registerPlugin({
295
+ doc: {
296
+ id: "test-plugin",
297
+ name: "测试插件",
298
+ category: "component",
299
+ description: "自定义插件测试",
300
+ sampleMarkdown: ":::test\n自定义组件\n:::",
301
+ syntaxGuide: ":::test",
302
+ },
303
+ milkdownPlugins: [],
304
+ defaultCss: ".test-plugin { color: red; }",
305
+ });
306
+
307
+ const docs = getPluginDocs();
308
+ const found = docs.find((d) => d.id === "test-plugin");
309
+ expect(found).toBeDefined();
310
+ expect(found?.name).toBe("测试插件");
311
+ expect(getCombinedDefaultCss()).toContain(".test-plugin { color: red; }");
312
+ });
313
+ });
@@ -0,0 +1,6 @@
1
+ export * from "./types";
2
+ export * from "./registry";
3
+ export * from "./builtin-highlight";
4
+ export * from "./builtin-callout";
5
+ export * from "./builtin-kbd";
6
+ export * from "./builtin-badge";
@@ -0,0 +1,62 @@
1
+ import type { MilkdownPlugin } from "@milkdown/kit/ctx";
2
+ import type { WechatElementRenderer } from "../wechat-renderer";
3
+ import type { WechatMarkdownPlugin, WechatPluginDoc } from "./types";
4
+ import { builtinHighlightPlugin } from "./builtin-highlight";
5
+ import { builtinCalloutPlugin } from "./builtin-callout";
6
+ import { builtinKbdPlugin } from "./builtin-kbd";
7
+ import { builtinBadgePlugin } from "./builtin-badge";
8
+
9
+ const BUILTIN_PLUGINS: WechatMarkdownPlugin[] = [
10
+ builtinHighlightPlugin,
11
+ builtinCalloutPlugin,
12
+ builtinKbdPlugin,
13
+ builtinBadgePlugin,
14
+ ];
15
+
16
+ const customPlugins: WechatMarkdownPlugin[] = [];
17
+
18
+ export function registerPlugin(plugin: WechatMarkdownPlugin): void {
19
+ // Avoid duplicate plugin IDs
20
+ const existingIndex = customPlugins.findIndex((p) => p.doc.id === plugin.doc.id);
21
+ if (existingIndex >= 0) {
22
+ customPlugins[existingIndex] = plugin;
23
+ } else {
24
+ customPlugins.push(plugin);
25
+ }
26
+ }
27
+
28
+ export function getAllPlugins(): WechatMarkdownPlugin[] {
29
+ return [...BUILTIN_PLUGINS, ...customPlugins];
30
+ }
31
+
32
+ export function getPluginDocs(): WechatPluginDoc[] {
33
+ return getAllPlugins().map((p) => p.doc);
34
+ }
35
+
36
+ export function getCombinedMilkdownPlugins(): MilkdownPlugin[] {
37
+ const plugins: MilkdownPlugin[] = [];
38
+ for (const plugin of getAllPlugins()) {
39
+ plugins.push(...plugin.milkdownPlugins);
40
+ }
41
+ return plugins;
42
+ }
43
+
44
+ export function getCombinedWechatRenderers(): WechatElementRenderer[] {
45
+ const renderers: WechatElementRenderer[] = [];
46
+ for (const plugin of getAllPlugins()) {
47
+ if (plugin.wechatRenderer) {
48
+ renderers.push(plugin.wechatRenderer);
49
+ }
50
+ }
51
+ return renderers;
52
+ }
53
+
54
+ export function getCombinedDefaultCss(): string {
55
+ const cssBlocks: string[] = [];
56
+ for (const plugin of getAllPlugins()) {
57
+ if (plugin.defaultCss) {
58
+ cssBlocks.push(plugin.defaultCss);
59
+ }
60
+ }
61
+ return cssBlocks.join("\n\n");
62
+ }
@@ -0,0 +1,31 @@
1
+ import type { MilkdownPlugin } from "@milkdown/kit/ctx";
2
+ import type { WechatElementRenderer } from "../wechat-renderer";
3
+
4
+ export type WechatPluginCategory = "inline" | "block" | "container" | "component";
5
+
6
+ export interface WechatPluginDoc {
7
+ /** 插件唯一 ID,如 "callout"、"highlight"、"kbd"、"badge" */
8
+ id: string;
9
+ /** 展示名称,如 "提示卡片 (Callout)" */
10
+ name: string;
11
+ /** 插件分类 */
12
+ category: WechatPluginCategory;
13
+ /** 简明说明 */
14
+ description: string;
15
+ /** 语法示例,点击展台可直接插入当前光标处 */
16
+ sampleMarkdown: string;
17
+ /** 语法规则速查说明 */
18
+ syntaxGuide: string;
19
+ /** 支持的可调节 CSS 类名或选择器,如 [data-callout-type="warning"] */
20
+ cssSelectors?: string[];
21
+ }
22
+
23
+ export interface WechatMarkdownPlugin {
24
+ doc: WechatPluginDoc;
25
+ /** 注入到 Milkdown 的插件集合(包含 Remark 解析器、ProseMirror Schema 等) */
26
+ milkdownPlugins: MilkdownPlugin[];
27
+ /** 注入到微信 HTML 生成链路的渲染器扩展(负责结构预处理、标签替换、微信兼容) */
28
+ wechatRenderer?: WechatElementRenderer;
29
+ /** 插件内置的微信/排版默认样式 */
30
+ defaultCss?: string;
31
+ }
@@ -1,3 +1,5 @@
1
+ import { existsSync, readFileSync, writeFileSync } from "fs";
2
+ import { dirname, join } from "path";
1
3
  import {
2
4
  previewBaseUrl,
3
5
  resolvePreviewHost,
@@ -24,6 +26,10 @@ import {
24
26
  } from "./local-file";
25
27
  import { renderDashboardHtml, renderFailedEntryHtml } from "./ui";
26
28
  import { buildWechatPreviewShell } from "../index";
29
+ import { getPluginDocs } from "../plugins/registry";
30
+ import { getWechatPreviewTheme } from "../themes";
31
+ import { loadConfig, saveConfig, getPipelineConfigPath, resolveConfigRelativePath } from "../../config";
32
+ import { DIST_DIR, WECHAT_PREVIEW_DIR } from "../../runtime-paths";
27
33
  import type { PreviewRegisterInput } from "./types";
28
34
 
29
35
  export interface StartPreviewServerOptions {
@@ -87,14 +93,14 @@ export async function startPreviewServer(
87
93
  clearServerLock();
88
94
  }
89
95
 
90
- const baseUrl = previewBaseUrl(host, port);
91
-
92
- const server = Bun.serve({
96
+ let server: ReturnType<typeof Bun.serve>;
97
+ server = Bun.serve({
93
98
  hostname: host,
94
99
  port,
95
100
  async fetch(req) {
96
101
  const url = new URL(req.url);
97
102
  const { pathname } = url;
103
+ const baseUrl = previewBaseUrl(host, server?.port ?? port);
98
104
 
99
105
  if (pathname === "/api/health") {
100
106
  return json({ ok: true, service: "wechat-preview", url: baseUrl, pid: process.pid });
@@ -180,6 +186,157 @@ export async function startPreviewServer(
180
186
  }
181
187
  }
182
188
 
189
+ if (pathname === "/api/studio/config" && req.method === "GET") {
190
+ const config = loadConfig();
191
+ const configPath = getPipelineConfigPath();
192
+ const accountsData: Record<string, unknown> = {};
193
+
194
+ for (const [key, account] of Object.entries(config.wx.accounts)) {
195
+ let customCssContent = "";
196
+ if (account.customCss) {
197
+ const resolved = resolveConfigRelativePath(account.customCss, configPath);
198
+ if (resolved && existsSync(resolved)) {
199
+ try {
200
+ customCssContent = readFileSync(resolved, "utf-8");
201
+ } catch {
202
+ // ignore
203
+ }
204
+ }
205
+ }
206
+
207
+ const defaultTheme = getWechatPreviewTheme(key);
208
+
209
+ accountsData[key] = {
210
+ name: account.name || key,
211
+ customCssPath: account.customCss,
212
+ customCssContent,
213
+ theme: {
214
+ editorVars: { ...defaultTheme.editorVars, ...account.theme?.editorVars },
215
+ exportTheme: { ...defaultTheme.exportTheme, ...account.theme?.exportTheme },
216
+ syntaxPresets: account.theme?.syntaxPresets || {},
217
+ structure: account.theme?.structure || {},
218
+ },
219
+ articleTheme: account.articleTheme ?? null,
220
+ };
221
+ }
222
+
223
+ let sampleMarkdown = "";
224
+ const fixturePath = join(WECHAT_PREVIEW_DIR, "fixtures/all-nodes.md");
225
+ if (existsSync(fixturePath)) {
226
+ try {
227
+ sampleMarkdown = readFileSync(fixturePath, "utf-8");
228
+ } catch {
229
+ // ignore
230
+ }
231
+ }
232
+
233
+ return json({
234
+ defaultAccount: config.wx.defaultAccount || "default",
235
+ accounts: accountsData,
236
+ plugins: getPluginDocs(),
237
+ sampleMarkdown,
238
+ });
239
+ }
240
+
241
+ if (pathname === "/api/studio/save-config" && req.method === "POST") {
242
+ let body: any;
243
+ try {
244
+ body = await req.json();
245
+ } catch {
246
+ return json({ error: "invalid JSON body" }, 400);
247
+ }
248
+
249
+ const config = loadConfig();
250
+ const accountKey = String(body.account || config.wx.defaultAccount || "default");
251
+ if (!config.wx.accounts[accountKey]) {
252
+ config.wx.accounts[accountKey] = {
253
+ name: accountKey,
254
+ pat: "",
255
+ appId: "",
256
+ appSecret: "",
257
+ customCss: null,
258
+ theme: {
259
+ editorVars: {},
260
+ exportTheme: {},
261
+ syntaxPresets: {},
262
+ },
263
+ };
264
+ }
265
+
266
+ const account = config.wx.accounts[accountKey];
267
+ if (body.editorVars && typeof body.editorVars === "object") {
268
+ account.theme.editorVars = { ...account.theme.editorVars, ...body.editorVars };
269
+ }
270
+ if (body.exportTheme && typeof body.exportTheme === "object") {
271
+ account.theme.exportTheme = { ...account.theme.exportTheme, ...body.exportTheme };
272
+ }
273
+ if (body.syntaxPresets && typeof body.syntaxPresets === "object") {
274
+ account.theme.syntaxPresets = { ...account.theme.syntaxPresets, ...body.syntaxPresets };
275
+ }
276
+ if (body.structure && typeof body.structure === "object") {
277
+ account.theme.structure = { ...account.theme.structure, ...body.structure };
278
+ }
279
+
280
+ if (typeof body.customCss === "string") {
281
+ const configPath = getPipelineConfigPath();
282
+ let cssPath = account.customCss
283
+ ? resolveConfigRelativePath(account.customCss, configPath)
284
+ : null;
285
+
286
+ if (!cssPath) {
287
+ cssPath = join(dirname(configPath), `custom-css-${accountKey}.css`);
288
+ account.customCss = `custom-css-${accountKey}.css`;
289
+ }
290
+
291
+ try {
292
+ writeFileSync(cssPath, body.customCss, "utf-8");
293
+ } catch (err) {
294
+ return json({ error: `Failed to write custom CSS: ${String(err)}` }, 500);
295
+ }
296
+ }
297
+
298
+ try {
299
+ saveConfig(config);
300
+ return json({ ok: true, account: accountKey, message: "Configuration saved successfully" });
301
+ } catch (err) {
302
+ return json({ error: `Failed to save config: ${String(err)}` }, 500);
303
+ }
304
+ }
305
+
306
+ if (pathname === "/studio" || pathname === "/studio/" || pathname === "/studio/index.html") {
307
+ const studioHtmlPath = join(DIST_DIR, "src/wechat-preview/browser/studio/studio.html");
308
+ const fallbackPath = join(DIST_DIR, "studio.html");
309
+ const targetHtmlPath = existsSync(studioHtmlPath) ? studioHtmlPath : fallbackPath;
310
+ if (existsSync(targetHtmlPath)) {
311
+ return html(readFileSync(targetHtmlPath, "utf-8"));
312
+ }
313
+ return html("<h1>Studio not built. Run bun run build:wechat-preview first.</h1>", 500);
314
+ }
315
+
316
+ if (pathname === "/studio.js" || pathname === "/editor-export.js") {
317
+ const filePath = join(DIST_DIR, pathname.slice(1));
318
+ if (existsSync(filePath)) {
319
+ return new Response(readFileSync(filePath), {
320
+ headers: {
321
+ "content-type": "application/javascript; charset=utf-8",
322
+ "cache-control": "no-cache",
323
+ },
324
+ });
325
+ }
326
+ }
327
+
328
+ if (pathname.startsWith("/assets/")) {
329
+ const filePath = join(DIST_DIR, pathname.slice(1));
330
+ if (existsSync(filePath)) {
331
+ return new Response(readFileSync(filePath), {
332
+ headers: {
333
+ "content-type": guessContentType(filePath),
334
+ "cache-control": "public, max-age=3600",
335
+ },
336
+ });
337
+ }
338
+ }
339
+
183
340
  if (pathname === "/" || pathname === "/index.html") {
184
341
  return html(renderDashboardHtml(baseUrl));
185
342
  }
@@ -57,13 +57,13 @@ export function ensurePreviewServerDirs(): void {
57
57
  }
58
58
 
59
59
  export function resolvePreviewPort(port?: number): number {
60
- if (typeof port === "number" && Number.isFinite(port) && port > 0) {
60
+ if (typeof port === "number" && Number.isFinite(port) && port >= 0) {
61
61
  return Math.floor(port);
62
62
  }
63
63
  const env = process.env.ZZHUB_WECHAT_PREVIEW_PORT?.trim();
64
64
  if (env) {
65
65
  const n = Number(env);
66
- if (Number.isFinite(n) && n > 0) return Math.floor(n);
66
+ if (Number.isFinite(n) && n >= 0) return Math.floor(n);
67
67
  }
68
68
  return DEFAULT_PREVIEW_PORT;
69
69
  }
@@ -86,18 +86,20 @@ export async function findExistingServer(
86
86
  }
87
87
 
88
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
- };
89
+ if (port > 0) {
90
+ const url = previewBaseUrl(host, port);
91
+ if (await probeHealth(url)) {
92
+ return {
93
+ kind: "running",
94
+ lock: {
95
+ pid: lock?.pid ?? 0,
96
+ host,
97
+ port,
98
+ url,
99
+ started_at: lock?.started_at ?? new Date().toISOString(),
100
+ },
101
+ };
102
+ }
101
103
  }
102
104
 
103
105
  if (lock && !isProcessAlive(lock.pid)) {