@zzclub/pipeline 0.11.0 → 0.13.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 (73) hide show
  1. package/README.md +10 -1
  2. package/package.json +2 -2
  3. package/src/adapter-types.ts +8 -0
  4. package/src/adapters/builtin-image-renderer.test.ts +32 -0
  5. package/src/adapters/builtin-image-renderer.ts +42 -73
  6. package/src/adapters/builtin-markdown-renderer.ts +3 -0
  7. package/src/cli.ts +10 -4
  8. package/src/command-outcome.ts +50 -0
  9. package/src/commands/abandon.ts +13 -2
  10. package/src/commands/checkpoint.ts +3 -2
  11. package/src/commands/config.ts +14 -1
  12. package/src/commands/imgx.ts +3 -3
  13. package/src/commands/ingest-handoff.ts +15 -0
  14. package/src/commands/init.ts +5 -0
  15. package/src/commands/monitor.ts +21 -0
  16. package/src/commands/prepare-finalize.ts +2 -0
  17. package/src/commands/prepare.ts +3 -0
  18. package/src/commands/publish.ts +5 -2
  19. package/src/commands/render-theme.test.ts +115 -0
  20. package/src/commands/render.ts +34 -12
  21. package/src/commands/republish.ts +4 -1
  22. package/src/commands/sync-blog.ts +4 -2
  23. package/src/commands/wechat-export.ts +6 -3
  24. package/src/config.ts +5 -0
  25. package/src/cover-theme-presets.json +59 -0
  26. package/src/imgx/assets/browser/cover.css +25 -0
  27. package/src/imgx/assets/browser/cover.js +40 -0
  28. package/src/imgx/assets/templates/longform-3-4.html +14 -36
  29. package/src/imgx/assets/templates/poster-3-4.html +15 -394
  30. package/src/imgx/assets/templates/wechat-cover-split.html +15 -334
  31. package/src/imgx/chrome-render.test.ts +38 -0
  32. package/src/imgx/chrome-render.ts +110 -0
  33. package/src/imgx/cover.test.ts +80 -0
  34. package/src/imgx/cover.ts +130 -0
  35. package/src/imgx/inline-text.ts +56 -0
  36. package/src/imgx/longform-theme.ts +74 -114
  37. package/src/imgx/obstacle-flow.test.ts +52 -0
  38. package/src/imgx/obstacle-flow.ts +177 -0
  39. package/src/imgx/pretext-adapter.ts +3 -0
  40. package/src/imgx/pretext-runtime.ts +0 -1
  41. package/src/imgx/render-article.test.ts +26 -1
  42. package/src/imgx/render-article.ts +168 -378
  43. package/src/imgx/render-card.ts +68 -203
  44. package/src/imgx/runtime.ts +1 -91
  45. package/src/logger.test.ts +28 -1
  46. package/src/logger.ts +39 -26
  47. package/src/monitor/client.ts +60 -0
  48. package/src/monitor/recorder.ts +142 -0
  49. package/src/monitor/runtime.ts +58 -0
  50. package/src/monitor/server.ts +170 -0
  51. package/src/monitor/store.ts +235 -0
  52. package/src/monitor/types.ts +77 -0
  53. package/src/monitor.test.ts +241 -0
  54. package/src/plugins.ts +4 -1
  55. package/src/providers/blog.ts +4 -1
  56. package/src/providers/cos.ts +3 -0
  57. package/src/providers/index.ts +7 -3
  58. package/src/providers/publish-core.ts +7 -1
  59. package/src/providers/wechat.ts +4 -1
  60. package/src/render-branding.test.ts +26 -0
  61. package/src/render-branding.ts +24 -0
  62. package/src/runtime-paths.ts +12 -51
  63. package/src/schema/config.ts +15 -0
  64. package/src/schema/cover-theme.test.ts +54 -0
  65. package/src/schema/cover-theme.ts +164 -0
  66. package/src/schema/state.ts +2 -0
  67. package/src/spawn.ts +4 -2
  68. package/src/state.ts +4 -0
  69. package/src/text.test.ts +7 -7
  70. package/src/text.ts +2 -27
  71. package/src/workflow.test.ts +4 -3
  72. package/src/imgx/assets/browser/obstacle-flow.d.ts +0 -27
  73. package/src/imgx/assets/browser/obstacle-flow.js +0 -326
@@ -28,6 +28,7 @@ import { parseArgs, requireArg, optionalArg, flagArg } from "../args";
28
28
  import { printResult, renderInit } from "../output";
29
29
  import { loadConfig, resolveWorkspaceRoot } from "../config";
30
30
  import { resolveFullRoute } from "../routes";
31
+ import { resolveCoverTheme } from "../schema/cover-theme";
31
32
  import {
32
33
  parseAccountName,
33
34
  parseContentForm,
@@ -60,6 +61,7 @@ Options:
60
61
  --account Explicit account override (optional)
61
62
  --newspic-render-spec-file JSON file for newspic pagination / page-image intent (optional)
62
63
  --style-hint e.g. fact_report (optional)
64
+ --cover-theme Cover theme ID (optional)
63
65
  --requires-research Flag
64
66
  --requires-style Flag
65
67
  --requires-render Flag
@@ -76,11 +78,13 @@ Options:
76
78
  const intentText = optionalArg(parsed, "intent-text") ?? "";
77
79
  const accountOverride = optionalArg(parsed, "account");
78
80
  const styleHint = optionalArg(parsed, "style-hint") ?? null;
81
+ const coverTheme = parsed["cover-theme"] === undefined ? null : requireArg(parsed, "cover-theme", "cover theme ID").trim();
79
82
  const newspicRenderSpecFile = optionalArg(parsed, "newspic-render-spec-file");
80
83
  const existingDraftMediaId = optionalArg(parsed, "existing-draft-media-id") ?? null;
81
84
  const noteId = optionalArg(parsed, "note-id") ?? null;
82
85
  const config = loadConfig();
83
86
  const workspace = resolveWorkspaceRoot(optionalArg(parsed, "workspace"), config);
87
+ if (coverTheme !== null) resolveCoverTheme(config.render.cover, "poster-3-4", accountOverride, coverTheme);
84
88
 
85
89
  const defaultAccount = parseAccountName(accountOverride ?? "default");
86
90
  const parsedTargets = parsePublishTargets(targetsRaw, {
@@ -126,6 +130,7 @@ Options:
126
130
  intent_text: intentText || null,
127
131
  explicit_constraints: [],
128
132
  style_hint: styleHint,
133
+ cover_theme: coverTheme,
129
134
  newspic_render: newspicRender,
130
135
  requires: {
131
136
  research: flagArg(parsed, "requires-research"),
@@ -0,0 +1,21 @@
1
+ import { parseArgs } from "../args";
2
+ import { printHelp, printResult } from "../output";
3
+ import { monitorHealth, readMonitorDescriptor, startMonitor, stopMonitor } from "../monitor/client";
4
+ import { serveMonitor } from "../monitor/server";
5
+
6
+ /** 服务管理不进入业务日志,防止描述文件令牌被日志复制。 */
7
+ export async function monitor(args: string[]): Promise<void> {
8
+ const parsed = parseArgs(args);
9
+ const action = (JSON.parse(String(parsed._)) as string[])[0] || "status";
10
+ if (parsed.help) { printHelp("Usage: zzp monitor start | serve | status | stop\nLocal HTTP/SSE monitoring; business commands run independently."); return; }
11
+ if (action === "start") { printResult(await startMonitor()); return; }
12
+ if (action === "serve") { const server = await serveMonitor(); printResult(server?.descriptor || await startMonitor()); return; }
13
+ if (action === "stop") { printResult({ stopped: await stopMonitor() }); return; }
14
+ if (action === "status") {
15
+ const descriptor = readMonitorDescriptor();
16
+ const running = !!descriptor && await monitorHealth(descriptor);
17
+ printResult({ running, ...(running ? { url: descriptor.url, instance_id: descriptor.instance_id, version: 1 } : {}) });
18
+ return;
19
+ }
20
+ throw new Error(`Unknown monitor action: ${action}`);
21
+ }
@@ -32,6 +32,7 @@ import {
32
32
  getCanonicalStatePath,
33
33
  } from "../state";
34
34
  import { loadTaskState } from "../task-manager";
35
+ import { reportProgress } from "../monitor/recorder";
35
36
  import {
36
37
  extractHighlightWords,
37
38
  buildFrontmatter,
@@ -330,6 +331,7 @@ Options:
330
331
  });
331
332
 
332
333
  // Write post.md
334
+ reportProgress({ stage: "prepare.save", message: "保存文章和工作流状态" });
333
335
  const postContent = `${frontmatter}\n\n${removePageMarkers(body)}`;
334
336
  const postPath = join(assetPath, "post.md");
335
337
  const postTempPath = `${postPath}.${process.pid}.${crypto.randomUUID()}.tmp`;
@@ -35,6 +35,7 @@ import {
35
35
  writeState,
36
36
  } from "../state";
37
37
  import { loadTaskState } from "../task-manager";
38
+ import { reportProgress } from "../monitor/recorder";
38
39
  import { resolveFullRoute } from "../routes";
39
40
  import { resolveAuthoring, hasStyleRequest } from "../profiles";
40
41
  import { parseAccountName, parseRoutePrimary } from "../publish-targets";
@@ -200,6 +201,7 @@ Options:
200
201
  state.intent.requires.publish = state.intent.task_kind === "publish";
201
202
 
202
203
  // ── Step 2: Author select ──
204
+ reportProgress({ stage: "prepare.author", message: "确定写作规则" });
203
205
  const styleRequest =
204
206
  isStyleRequest || hasStyleRequest(intentText);
205
207
 
@@ -219,6 +221,7 @@ Options:
219
221
  // In the current workflow, Writer/Style already own the LLM rewrite.
220
222
  // Prepare records the authoring snapshot, then always formats the final body.
221
223
  const bodyFormatted = formatArticle(cleanBody);
224
+ reportProgress({ stage: "prepare.metadata", message: "整理文章元信息" });
222
225
 
223
226
  // ── Step 4: Asset meta ──
224
227
  // Determine title
@@ -34,8 +34,9 @@ import {
34
34
  upsertPublishResult,
35
35
  } from "../providers/publish-core";
36
36
  import { loadTaskState } from "../task-manager";
37
+ import { publishOutcome, type CommandOutcome } from "../command-outcome";
37
38
 
38
- export async function publish(args: string[]): Promise<void> {
39
+ export async function publish(args: string[]): Promise<void | CommandOutcome> {
39
40
  const parsed = parseArgs(args);
40
41
 
41
42
  if (parsed.help) {
@@ -119,7 +120,7 @@ Options:
119
120
  dry_run: true,
120
121
  ...(errors.length > 0 ? { errors } : {}),
121
122
  }, renderPublish);
122
- return;
123
+ return publishOutcome(results, true);
123
124
  }
124
125
 
125
126
  // Check if all targets are done
@@ -151,6 +152,8 @@ Options:
151
152
  output.errors = errors;
152
153
  }
153
154
  printResult(output, renderPublish);
155
+ return publishOutcome(state.publish.results.filter((result) => targets.some((target) =>
156
+ target.route === result.route && target.account === result.account)), results.length === 0);
154
157
  } finally {
155
158
  await releaseOperationLock();
156
159
  }
@@ -0,0 +1,115 @@
1
+ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
2
+ import { mkdtemp, mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import { loadConfig, normalizeConfig, saveConfig } from "../config";
6
+ import { defaultState, readState, writeState } from "../state";
7
+ import { resolveFullRoute } from "../routes";
8
+ import { init } from "./init";
9
+ import { ingestHandoff } from "./ingest-handoff";
10
+ import { render } from "./render";
11
+ import { configCommand } from "./config";
12
+
13
+ let workspace: string;
14
+ let previousConfig: string | undefined;
15
+ let output: ReturnType<typeof spyOn>;
16
+ beforeEach(async () => {
17
+ workspace = await mkdtemp(join(tmpdir(), "zzhub-cover-contract-"));
18
+ previousConfig = process.env.ZZHUB_PIPELINE_CONFIG;
19
+ process.env.ZZHUB_PIPELINE_CONFIG = join(workspace, "config.json");
20
+ saveConfig(normalizeConfig({}));
21
+ output = spyOn(console, "log").mockImplementation(() => {});
22
+ });
23
+ afterEach(async () => {
24
+ output.mockRestore();
25
+ if (previousConfig === undefined) delete process.env.ZZHUB_PIPELINE_CONFIG;
26
+ else process.env.ZZHUB_PIPELINE_CONFIG = previousConfig;
27
+ await rm(workspace, { recursive: true, force: true });
28
+ });
29
+
30
+ async function preparedTask() {
31
+ const assetPath = join(workspace, "posts", "2026-09-07-cover");
32
+ await mkdir(assetPath, { recursive: true });
33
+ await writeFile(join(assetPath, "post.md"), "完整正文保持不变。", "utf8");
34
+ const state = defaultState();
35
+ state.run_id = "cover-contract";
36
+ state.workspace_root = workspace;
37
+ state.asset_path = assetPath;
38
+ state.state_path = join(assetPath, "workflow-state.json");
39
+ state.route = resolveFullRoute("发公众号文章", { account: "default", contentForm: "article", targets: ["wechat"] });
40
+ state.intent.intent_text = "发公众号文章";
41
+ state.intent.content_form = "article";
42
+ state.intent.targets = ["wechat"];
43
+ state.intent.cover_theme = "business";
44
+ state.metadata = { ...state.metadata, title: "完整标题:保留副标题", slug: "cover", date: "2026-09-07" };
45
+ state.content_review.status = "passed";
46
+ state.phase.prepare.status = "done";
47
+ await writeState(state.state_path, state);
48
+ return state;
49
+ }
50
+
51
+ describe("cover CLI contract", () => {
52
+ test("init persists the selected theme and rejects unknown themes", async () => {
53
+ const args = ["--workspace", workspace, "--task-kind", "publish", "--content-form", "article", "--targets", "wechat", "--content-origin", "user"];
54
+ await init([...args, "--cover-theme", "business"]);
55
+ const runs = join(workspace, ".zzhub-media", "runs");
56
+ const file = (await readdir(runs)).find(name => name.endsWith(".json"))!;
57
+ expect((await readState(join(runs, file))).intent.cover_theme).toBe("business");
58
+ await expect(init([...args, "--cover-theme", "missing"])).rejects.toThrow("主题不存在");
59
+ });
60
+
61
+ test("a theme-only handoff retains reviewed content and reenters render", async () => {
62
+ const state = await preparedTask();
63
+ state.phase.current = "done";
64
+ state.phase.render.status = "done";
65
+ state.mode = "done";
66
+ await writeState(state.state_path, state);
67
+ const handoff = join(workspace, "handoff.json");
68
+ await writeFile(handoff, JSON.stringify({ workflow_handoff: { mode: "resume", state_path: state.state_path, cover_theme: "technology" } }));
69
+ await ingestHandoff(["--file", handoff, "--workspace", workspace]);
70
+ const result = await readState(state.state_path);
71
+ expect(result.intent.cover_theme).toBe("technology");
72
+ expect(result.asset_path).toBe(state.asset_path);
73
+ expect(result.content_review.status).toBe("passed");
74
+ expect(result.phase.prepare.status).toBe("done");
75
+ expect(result.phase.current).toBe("render");
76
+ expect(await readFile(join(state.asset_path, "post.md"), "utf8")).toBe("完整正文保持不变。");
77
+ });
78
+
79
+ test("render forwards the latest theme to an existing plugin and command choice wins", async () => {
80
+ const state = await preparedTask();
81
+ const pluginPath = join(workspace, "plugin.mjs");
82
+ await writeFile(pluginPath, 'import {writeFile} from "node:fs/promises"; import {join} from "node:path"; export default {name:"contract-probe",async render(input){await writeFile(join(input.state.asset_path,"received.json"),JSON.stringify(input));const path=join(input.state.asset_path,"cover.png");await writeFile(path,"stub");return {assets:[{kind:"cover",route:input.route,path}],pageCount:1,pages:[]}}};');
83
+ const config = loadConfig();
84
+ config.plugins.imageRenderer = pluginPath;
85
+ config.render.cover.themes.business!.typography.title.fontSize = 101;
86
+ config.render.cover.themes.business!.colors.accent = "#123789";
87
+ config.render.branding.logo = "https://example.com/custom-logo.png";
88
+ config.render.branding.footerText = "公众号:自定义名称";
89
+ saveConfig(config);
90
+ await render(["--state", state.state_path]);
91
+ let received = JSON.parse(await readFile(join(state.asset_path, "received.json"), "utf8"));
92
+ expect(received.coverTheme.id).toBe("business");
93
+ expect(received.coverTheme.typography.title.fontSize).toBe(88);
94
+ expect(received.coverTheme.colors.accent).toBe("#123789");
95
+ expect(received.title).toBe(state.metadata.title);
96
+ expect(received.accountVisualParams.footer).toBe("公众号:自定义名称");
97
+ expect(received.accountVisualParams.fallbackIcon).toBe("https://example.com/custom-logo.png");
98
+ await render(["--state", state.state_path, "--cover-theme", "minimal"]);
99
+ received = JSON.parse(await readFile(join(state.asset_path, "received.json"), "utf8"));
100
+ expect(received.coverTheme.id).toBe("minimal");
101
+ expect((await readState(state.state_path)).intent.cover_theme).toBe("minimal");
102
+ expect((await readState(state.state_path)).images.plan.cover_theme).toBe("minimal");
103
+ });
104
+
105
+ test("config import merges one theme and schema works even with a broken config file", async () => {
106
+ const path = join(workspace, "import.json");
107
+ await writeFile(path, JSON.stringify({ render: { cover: { themes: { business: { colors: { accent: "#135790" } } } } } }));
108
+ await configCommand(["--import", path]);
109
+ expect(loadConfig().render.cover.themes.business!.colors.accent).toBe("#135790");
110
+ expect(Object.keys(loadConfig().render.cover.themes)).toHaveLength(10);
111
+ await writeFile(process.env.ZZHUB_PIPELINE_CONFIG!, "invalid json");
112
+ await configCommand(["--schema", "--key", "render.cover"]);
113
+ expect(output.mock.calls.at(-1)?.[0]).toContain('"defaultTheme"');
114
+ });
115
+ });
@@ -20,12 +20,15 @@ import { join } from "path";
20
20
  import { parseArgs, requireArg, flagArg } from "../args";
21
21
  import { printResult, renderRender } from "../output";
22
22
  import { loadConfig } from "../config";
23
+ import { resolveRenderBranding } from "../render-branding";
24
+ import { resolveCoverTheme } from "../schema/cover-theme";
23
25
  import { resolveImageRenderer } from "../adapter-loader";
24
26
  import {
25
27
  acquireStateOperationLock,
26
28
  defaultBodyInputs,
27
29
  normalizeNewspicRenderSpec,
28
30
  readResolvedState,
31
+ reenterRender,
29
32
  validateForPhase,
30
33
  writeState,
31
34
  type NewspicRenderSpec,
@@ -38,6 +41,8 @@ import {
38
41
  } from "../text";
39
42
  import { getLongformTheme } from "../routes";
40
43
  import { collectNewspicRequiredMarkers } from "../workflow-materials";
44
+ import type { CommandOutcome } from "../command-outcome";
45
+ import { reportProgress } from "../monitor/recorder";
41
46
 
42
47
  function getNewspicRenderSpec(state: WorkflowState): NewspicRenderSpec {
43
48
  const spec = normalizeNewspicRenderSpec(state.intent.newspic_render);
@@ -47,7 +52,7 @@ function getNewspicRenderSpec(state: WorkflowState): NewspicRenderSpec {
47
52
  };
48
53
  }
49
54
 
50
- export async function render(args: string[]): Promise<void> {
55
+ export async function render(args: string[]): Promise<void | CommandOutcome> {
51
56
  const parsed = parseArgs(args);
52
57
 
53
58
  if (parsed.help) {
@@ -57,6 +62,7 @@ Usage: zzhub-pipeline render [options]
57
62
  Options:
58
63
  --state Path to state JSON (required)
59
64
  --skip-render Plan only, don't invoke renderer (optional)
65
+ --cover-theme Cover theme ID; saves the task choice (optional)
60
66
  `.trim());
61
67
  return;
62
68
  }
@@ -86,6 +92,20 @@ Options:
86
92
  // ── Image Plan ──────────────────────────────────────────────────
87
93
 
88
94
  const routePrimary = state.route.primary;
95
+ const config = loadConfig();
96
+ const requestedTheme = parsed["cover-theme"] === undefined ? undefined : requireArg(parsed, "cover-theme", "cover theme ID").trim();
97
+ const coverTheme = routePrimary === "blog" ? undefined : resolveCoverTheme(
98
+ config.render.cover,
99
+ routePrimary === "wechat-article" ? "wechat-cover-split" : "poster-3-4",
100
+ state.route.account,
101
+ requestedTheme ?? state.intent.cover_theme,
102
+ );
103
+ if (requestedTheme !== undefined && requestedTheme !== state.intent.cover_theme) {
104
+ if (!coverTheme) throw new Error("blog 路由不需要封面主题");
105
+ state.intent.cover_theme = requestedTheme;
106
+ reenterRender(state);
107
+ await writeState(statePath, state);
108
+ }
89
109
 
90
110
  if (routePrimary === "blog") {
91
111
  // Blog doesn't need images by default
@@ -141,6 +161,7 @@ Options:
141
161
  template,
142
162
  cover_template: coverTemplate,
143
163
  cover_title: coverTitle,
164
+ cover_theme: coverTheme?.id ?? null,
144
165
  output_dir: outputDir,
145
166
  preview_required: false,
146
167
  status: "planned",
@@ -182,7 +203,7 @@ Options:
182
203
  message: `Waiting for ${markers.length} body image(s)`,
183
204
  phase: state.phase.current,
184
205
  }, renderRender);
185
- return;
206
+ return { status: "waiting" };
186
207
  }
187
208
  } else if (state.images.body_inputs.scope === "newspic-longform") {
188
209
  state.images.body_inputs = defaultBodyInputs();
@@ -196,13 +217,13 @@ Options:
196
217
  skip_render: true,
197
218
  phase: state.phase.current,
198
219
  }, renderRender);
199
- return;
220
+ return { status: "skipped" };
200
221
  }
201
222
 
202
223
  // ── Invoke image renderer adapter ───────────────────────────────
203
224
 
204
- const config = loadConfig();
205
225
  const imageRenderer = await resolveImageRenderer(config);
226
+ reportProgress({ stage: "render.adapter", message: "开始生成图片" });
206
227
 
207
228
  const bodyImages = (
208
229
  state.images.body_inputs.scope === "newspic-longform" &&
@@ -211,7 +232,10 @@ Options:
211
232
  ? state.images.body_inputs.received
212
233
  : [];
213
234
 
235
+ const branding = resolveRenderBranding(config, state.route.account);
214
236
  const renderResult = await imageRenderer.render({
237
+ coverTheme,
238
+ onProgress: reportProgress,
215
239
  state,
216
240
  bodyText: cleanBody,
217
241
  outputDir,
@@ -227,14 +251,12 @@ Options:
227
251
  bodyImages,
228
252
  minPages: newspicRenderSpec.min_pages,
229
253
  maxPages: newspicRenderSpec.max_pages,
230
- accountVisualParams: state.route.account_visual_params
231
- ? {
232
- footer: state.route.account_visual_params.footer,
233
- bg: state.route.account_visual_params.bg,
234
- highlight: state.route.account_visual_params.highlight,
235
- fallbackIcon: state.route.account_visual_params.fallback_icon,
236
- }
237
- : undefined,
254
+ accountVisualParams: {
255
+ footer: branding.footerText,
256
+ bg: state.route.account_visual_params?.bg,
257
+ highlight: state.route.account_visual_params?.highlight,
258
+ fallbackIcon: branding.logo,
259
+ },
238
260
  theme: routePrimary === "wechat-newspic" ? getLongformTheme(state.route.account) : undefined,
239
261
  template,
240
262
  });
@@ -36,8 +36,9 @@ import {
36
36
  upsertPublishResult,
37
37
  } from "../providers/publish-core";
38
38
  import { loadTaskState } from "../task-manager";
39
+ import { publishOutcome, type CommandOutcome } from "../command-outcome";
39
40
 
40
- export async function republish(args: string[]): Promise<void> {
41
+ export async function republish(args: string[]): Promise<void | CommandOutcome> {
41
42
  const parsed = parseArgs(args);
42
43
 
43
44
  if (parsed.help) {
@@ -182,6 +183,8 @@ Examples:
182
183
  output.errors = errors;
183
184
  }
184
185
  printResult(output);
186
+ return publishOutcome(dryRun ? results : state.publish.results.filter((result) => newTargets.some((target) =>
187
+ target.route === result.route && target.account === result.account)), dryRun || results.length === 0);
185
188
  } finally {
186
189
  await releaseOperationLock();
187
190
  }
@@ -8,8 +8,9 @@ import {
8
8
  } from "../state";
9
9
  import { loadTaskState } from "../task-manager";
10
10
  import { publishBlogRoute } from "../providers/blog";
11
+ import { publishOutcome, type CommandOutcome } from "../command-outcome";
11
12
 
12
- export async function syncBlog(args: string[]): Promise<void> {
13
+ export async function syncBlog(args: string[]): Promise<void | CommandOutcome> {
13
14
  const parsed = parseArgs(args);
14
15
 
15
16
  if (parsed.help) {
@@ -50,7 +51,7 @@ Options:
50
51
 
51
52
  if (dryRun) {
52
53
  printResult({ ...result, mode: state.mode }, renderSyncBlog);
53
- return;
54
+ return publishOutcome([result], true);
54
55
  }
55
56
 
56
57
  // Write result back to state
@@ -67,6 +68,7 @@ Options:
67
68
  ...result,
68
69
  mode: finalState.mode,
69
70
  }, renderSyncBlog);
71
+ return publishOutcome([result]);
70
72
  } finally {
71
73
  await releaseOperationLock();
72
74
  }
@@ -2,6 +2,8 @@ import { spawn } from "child_process";
2
2
  import { resolve } from "path";
3
3
  import { flagArg, optionalArg, parseArgs, requireArg } from "../args";
4
4
  import { printResult, renderWechatExport } from "../output";
5
+ import { commandError, type CommandOutcome } from "../command-outcome";
6
+ import { reportProgress } from "../monitor/recorder";
5
7
  import { loadConfig, resolveConfigRelativePath } from "../config";
6
8
  import { resolveMarkdownRenderer } from "../adapter-loader";
7
9
  import { WechatExportError } from "../wechat-preview";
@@ -54,7 +56,7 @@ async function openUrl(url: string): Promise<void> {
54
56
  }
55
57
  }
56
58
 
57
- export async function wechatExport(args: string[]): Promise<void> {
59
+ export async function wechatExport(args: string[]): Promise<void | CommandOutcome> {
58
60
  const parsed = parseArgs(args);
59
61
 
60
62
  if (parsed.help) {
@@ -114,6 +116,7 @@ ${CSS_DEMO}
114
116
 
115
117
  try {
116
118
  const result = await markdownRenderer.render({
119
+ onProgress: reportProgress,
117
120
  markdownPath,
118
121
  outPath,
119
122
  account,
@@ -154,6 +157,7 @@ ${CSS_DEMO}
154
157
  if (open && preview_url) await openUrl(preview_url);
155
158
  } else {
156
159
  preview_register_error = reg.error;
160
+ console.warn(`微信预览登记失败:${reg.error || "未知原因"}`);
157
161
  }
158
162
  }
159
163
 
@@ -197,8 +201,7 @@ ${CSS_DEMO}
197
201
  renderWechatExport,
198
202
  );
199
203
  if (open && reg.preview_url) await openUrl(reg.preview_url);
200
- process.exitCode = 1;
201
- return;
204
+ return { status: "failed", errors: [commandError(error, error.kind)] };
202
205
  }
203
206
  }
204
207
  throw error;
package/src/config.ts CHANGED
@@ -10,6 +10,7 @@ import {
10
10
  import { homedir, platform } from "os";
11
11
  import { dirname, isAbsolute, join, resolve } from "path";
12
12
  import { PipelineConfigSchema } from "./schema/config";
13
+ import { mergeCoverSettings } from "./schema/cover-theme";
13
14
 
14
15
  import type {
15
16
  PipelineConfig,
@@ -196,6 +197,7 @@ export function normalizeConfig(value: unknown, legacyValue?: unknown): Pipeline
196
197
  cos: mergedCos,
197
198
  plugins: mergedPlugins,
198
199
  imgx: mergedImgx,
200
+ render: mergeCoverSettings(legacy.render ?? {}, source.render ?? {}),
199
201
  });
200
202
  }
201
203
 
@@ -403,6 +405,9 @@ export function setConfigValue(
403
405
  ): PipelineConfig {
404
406
  const next = JSON.parse(JSON.stringify(config)) as PipelineConfig;
405
407
  const segments = key.split(".").filter(Boolean);
408
+ if (segments.some((segment) => ["__proto__", "constructor", "prototype"].includes(segment))) {
409
+ throw new Error("invalid config key");
410
+ }
406
411
  if (segments.length === 0) {
407
412
  throw new Error("config key cannot be empty");
408
413
  }
@@ -0,0 +1,59 @@
1
+ {
2
+ "business": {
3
+ "name": "商务", "description": "浅灰与深蓝,左对齐标题与细线",
4
+ "colors": { "text": "#13243A", "accent": "#2457A7", "muted": "#586A80" },
5
+ "background": { "fill": { "type": "solid", "color": "#F1F5F9" } },
6
+ "decoration": { "pattern": "lines", "color": "#2457A7", "opacity": 0.18 }
7
+ },
8
+ "technology": {
9
+ "name": "科技", "description": "深蓝渐变与青色网格",
10
+ "colors": { "text": "#EAF4FF", "accent": "#67E8F9", "muted": "#A6BDD8" },
11
+ "background": { "fill": { "type": "linear", "angle": 135, "stops": [{ "color": "#0B1027", "offset": 0 }, { "color": "#182B5B", "offset": 100 }] } },
12
+ "decoration": { "pattern": "grid", "color": "#67E8F9", "opacity": 0.1, "size": 48 }
13
+ },
14
+ "minimal": {
15
+ "name": "简约", "description": "白底黑字,宽留白",
16
+ "colors": { "text": "#18181B", "accent": "#52525B", "muted": "#71717A" },
17
+ "background": { "fill": { "type": "solid", "color": "#FFFFFF" } },
18
+ "layout": { "safeArea": { "left": 108, "right": 108, "top": 108, "bottom": 72 } }
19
+ },
20
+ "solid": {
21
+ "name": "纯色", "description": "钴蓝与白色大字,黄色强调",
22
+ "colors": { "text": "#FFFFFF", "accent": "#FFE08A", "muted": "#D9E1FF" },
23
+ "background": { "fill": { "type": "solid", "color": "#3155D9" } },
24
+ "layout": { "textAlign": "center" }
25
+ },
26
+ "cute": {
27
+ "name": "可爱", "description": "粉黄渐变、手写字与轻盈圆点",
28
+ "typography": { "title": { "fontFamily": "LXGWWenKai", "fontWeight": 700 }, "subtitle": { "fontFamily": "LXGWWenKai", "fontSize": 56, "minFontSize": 40, "fontWeight": 400, "lineHeight": 1.4 } },
29
+ "colors": { "text": "#623C47", "accent": "#D64B78", "muted": "#856675" },
30
+ "background": { "fill": { "type": "linear", "angle": 150, "stops": [{ "color": "#FFF1F4", "offset": 0 }, { "color": "#FFF6DF", "offset": 100 }] } },
31
+ "decoration": { "pattern": "dots", "color": "#E4658C", "opacity": 0.12, "size": 58 },
32
+ "layout": { "textAlign": "center" }
33
+ },
34
+ "serious": {
35
+ "name": "严肃", "description": "米白纸面、墨色宋体与暗红强调",
36
+ "typography": { "title": { "fontFamily": "LXGWNeoZhiSongPlus", "fontWeight": 700 } },
37
+ "colors": { "text": "#202020", "accent": "#8A302B", "muted": "#655E54" },
38
+ "background": { "fill": { "type": "solid", "color": "#F3F0E8" } },
39
+ "decoration": { "pattern": "lines", "color": "#8A302B", "opacity": 0.18 }
40
+ },
41
+ "abstract": {
42
+ "name": "抽象", "description": "淡紫与冰蓝,边缘错位圆环",
43
+ "colors": { "text": "#302456", "accent": "#7653D5", "muted": "#695A87" },
44
+ "background": { "fill": { "type": "radial", "position": { "x": 20, "y": 10 }, "stops": [{ "color": "#EEE6FF", "offset": 0 }, { "color": "#E1F2FF", "offset": 100 }] } },
45
+ "decoration": { "pattern": "rings", "color": "#7653D5", "opacity": 0.16, "size": 180 }
46
+ },
47
+ "entertainment": {
48
+ "name": "娱乐", "description": "明黄底色、深紫大字与粉色几何装饰",
49
+ "colors": { "text": "#241341", "accent": "#B52D74", "muted": "#62532B" },
50
+ "background": { "fill": { "type": "solid", "color": "#FFF06A" } },
51
+ "decoration": { "pattern": "burst", "color": "#ED4F9A", "opacity": 0.32, "size": 144 }
52
+ },
53
+ "fresh-sage": { "name": "清新绿", "description": "早早集市原有绿色配色" },
54
+ "soft-rose": {
55
+ "name": "柔和粉", "description": "古一账号原有粉色配色",
56
+ "colors": { "text": "#1A1A1A", "accent": "#CA6093", "muted": "#795C6D" },
57
+ "background": { "fill": { "type": "solid", "color": "#FAF5F8" } }
58
+ }
59
+ }
@@ -0,0 +1,25 @@
1
+ * { box-sizing: border-box; }
2
+ html, body { margin: 0; }
3
+ .cover { width: var(--width); height: var(--height); position: relative; overflow: hidden; background: var(--background); color: var(--text); }
4
+ .layer { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; }
5
+ .pattern { opacity: var(--decoration-opacity); }
6
+ .pattern-lines { border-top: 2px solid var(--decoration); border-bottom: 2px solid var(--decoration); inset: 24px; width: auto; height: auto; }
7
+ .pattern-grid { background-image: linear-gradient(var(--decoration) 1px, transparent 1px), linear-gradient(90deg, var(--decoration) 1px, transparent 1px); background-size: var(--decoration-size) var(--decoration-size); }
8
+ .pattern-dots { background-image: radial-gradient(circle, var(--decoration) 2px, transparent 3px); background-size: var(--decoration-size) var(--decoration-size); }
9
+ .pattern-rings { background: repeating-radial-gradient(circle at 105% 8%, transparent 0, transparent calc(var(--decoration-size) - 3px), var(--decoration) calc(var(--decoration-size) - 2px), var(--decoration) var(--decoration-size)); }
10
+ .pattern-burst { background: repeating-conic-gradient(from 15deg at 105% -5%, var(--decoration) 0deg 8deg, transparent 8deg 28deg); mask-image: radial-gradient(circle at 100% 0, black 0, transparent 65%); }
11
+ .copy { position: absolute; inset: 0 auto 0 0; width: var(--copy-width); }
12
+ .text-area { position: absolute; top: var(--top); right: var(--right); bottom: calc(var(--bottom) + 80px); left: var(--left); display: flex; flex-direction: column; justify-content: var(--vertical); text-align: var(--align); }
13
+ .text-stack { flex: 0 0 auto; width: 100%; }
14
+ .title, .subtitle { margin: 0; white-space: pre-wrap; text-wrap: balance; overflow-wrap: anywhere; }
15
+ .title { font-family: var(--title-font), sans-serif; font-size: var(--title-size); font-weight: var(--title-weight); line-height: var(--title-leading); letter-spacing: var(--title-tracking); }
16
+ .subtitle { margin-top: var(--block-gap); font-family: var(--subtitle-font), serif; font-size: var(--subtitle-size); font-weight: var(--subtitle-weight); line-height: var(--subtitle-leading); letter-spacing: var(--subtitle-tracking); }
17
+ .subtitle:empty { display: none; }
18
+ .highlight { color: var(--accent); }
19
+ .signature { position: absolute; left: var(--left); right: var(--right); bottom: var(--bottom); display: flex; align-items: center; gap: 12px; color: var(--muted); }
20
+ .signature span { min-width: 0; overflow-wrap: anywhere; font-family: var(--footer-font), sans-serif; font-size: var(--footer-size); font-weight: var(--footer-weight); line-height: var(--footer-leading); letter-spacing: var(--footer-tracking); }
21
+ .signature img { flex: 0 0 auto; width: 36px; height: 36px; border-radius: 50%; object-fit: contain; }
22
+ .logo-panel { display: none; }
23
+ .cover-banner .signature img { display: none; }
24
+ .cover-banner .logo-panel { display: flex; position: absolute; right: 0; top: 0; width: 400px; height: 400px; align-items: center; justify-content: center; }
25
+ .logo-panel img { width: 216px; height: 216px; border-radius: 40px; object-fit: contain; }
@@ -0,0 +1,40 @@
1
+ // 字号测量和最终成图都使用同一个 DOM,避免二次换行偏差。
2
+ window.__zzhubRenderReady = (async function fitCover() {
3
+ try {
4
+ const config = JSON.parse(document.getElementById("cover-config").textContent);
5
+ const cover = document.querySelector(".cover");
6
+ const area = document.querySelector(".text-area");
7
+ const stack = document.querySelector(".text-stack");
8
+ const signature = document.querySelector(".signature");
9
+ const title = document.querySelector(".title");
10
+ const subtitle = document.querySelector(".subtitle");
11
+ // 大型 CJK 字体必须加载完成,不能用回退字体计算字号。
12
+ await Promise.all(Object.values(config.typography).map(font => document.fonts.load(`${font.fontWeight} ${font.fontSize}px "${font.fontFamily}"`)));
13
+ await document.fonts.ready;
14
+ await Promise.all([...document.images].map(image => image.decode()));
15
+ area.style.bottom = `${config.layout.safeArea.bottom + signature.getBoundingClientRect().height + 32}px`;
16
+ if (area.clientWidth <= 0 || area.clientHeight <= 0) throw new Error("封面安全区无法容纳文字和署名");
17
+ function fit(ratio) {
18
+ for (const role of ["title", "subtitle"]) {
19
+ const font = config.typography[role];
20
+ cover.style.setProperty(`--${role}-size`, `${font.minFontSize + (font.fontSize - font.minFontSize) * ratio}px`);
21
+ }
22
+ return stack.getBoundingClientRect().height <= area.clientHeight + 0.5
23
+ && title.scrollWidth <= title.clientWidth + 1
24
+ && subtitle.scrollWidth <= subtitle.clientWidth + 1;
25
+ }
26
+ if (!fit(0)) throw new Error("完整标题在最小字号下仍超出安全区,请缩短标题或调整主题字号、安全区");
27
+ let low = 0;
28
+ let high = 1;
29
+ for (let iteration = 0; iteration < 12; iteration++) {
30
+ const middle = (low + high) / 2;
31
+ if (fit(middle)) low = middle;
32
+ else high = middle;
33
+ }
34
+ fit(low);
35
+ document.documentElement.dataset.coverStatus = "ready";
36
+ } catch (error) {
37
+ document.documentElement.dataset.coverStatus = "error";
38
+ document.documentElement.dataset.coverError = encodeURIComponent(error instanceof Error ? error.message : String(error));
39
+ }
40
+ })();