@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.
- package/README.md +10 -1
- package/package.json +2 -2
- package/src/adapter-types.ts +8 -0
- package/src/adapters/builtin-image-renderer.test.ts +32 -0
- package/src/adapters/builtin-image-renderer.ts +42 -73
- package/src/adapters/builtin-markdown-renderer.ts +3 -0
- package/src/cli.ts +10 -4
- package/src/command-outcome.ts +50 -0
- package/src/commands/abandon.ts +13 -2
- package/src/commands/checkpoint.ts +3 -2
- package/src/commands/config.ts +14 -1
- package/src/commands/imgx.ts +3 -3
- package/src/commands/ingest-handoff.ts +15 -0
- package/src/commands/init.ts +5 -0
- package/src/commands/monitor.ts +21 -0
- package/src/commands/prepare-finalize.ts +2 -0
- package/src/commands/prepare.ts +3 -0
- package/src/commands/publish.ts +5 -2
- package/src/commands/render-theme.test.ts +115 -0
- package/src/commands/render.ts +34 -12
- package/src/commands/republish.ts +4 -1
- package/src/commands/sync-blog.ts +4 -2
- package/src/commands/wechat-export.ts +6 -3
- package/src/config.ts +5 -0
- package/src/cover-theme-presets.json +59 -0
- package/src/imgx/assets/browser/cover.css +25 -0
- package/src/imgx/assets/browser/cover.js +40 -0
- package/src/imgx/assets/templates/longform-3-4.html +14 -36
- package/src/imgx/assets/templates/poster-3-4.html +15 -394
- package/src/imgx/assets/templates/wechat-cover-split.html +15 -334
- package/src/imgx/chrome-render.test.ts +38 -0
- package/src/imgx/chrome-render.ts +110 -0
- package/src/imgx/cover.test.ts +80 -0
- package/src/imgx/cover.ts +130 -0
- package/src/imgx/inline-text.ts +56 -0
- package/src/imgx/longform-theme.ts +74 -114
- package/src/imgx/obstacle-flow.test.ts +52 -0
- package/src/imgx/obstacle-flow.ts +177 -0
- package/src/imgx/pretext-adapter.ts +3 -0
- package/src/imgx/pretext-runtime.ts +0 -1
- package/src/imgx/render-article.test.ts +26 -1
- package/src/imgx/render-article.ts +168 -378
- package/src/imgx/render-card.ts +68 -203
- package/src/imgx/runtime.ts +1 -91
- package/src/logger.test.ts +28 -1
- package/src/logger.ts +39 -26
- package/src/monitor/client.ts +60 -0
- package/src/monitor/recorder.ts +142 -0
- package/src/monitor/runtime.ts +58 -0
- package/src/monitor/server.ts +170 -0
- package/src/monitor/store.ts +235 -0
- package/src/monitor/types.ts +77 -0
- package/src/monitor.test.ts +241 -0
- package/src/plugins.ts +4 -1
- package/src/providers/blog.ts +4 -1
- package/src/providers/cos.ts +3 -0
- package/src/providers/index.ts +7 -3
- package/src/providers/publish-core.ts +7 -1
- package/src/providers/wechat.ts +4 -1
- package/src/render-branding.test.ts +26 -0
- package/src/render-branding.ts +24 -0
- package/src/runtime-paths.ts +12 -51
- package/src/schema/config.ts +15 -0
- package/src/schema/cover-theme.test.ts +54 -0
- package/src/schema/cover-theme.ts +164 -0
- package/src/schema/state.ts +2 -0
- package/src/spawn.ts +4 -2
- package/src/state.ts +4 -0
- package/src/text.test.ts +7 -7
- package/src/text.ts +2 -27
- package/src/workflow.test.ts +4 -3
- package/src/imgx/assets/browser/obstacle-flow.d.ts +0 -27
- package/src/imgx/assets/browser/obstacle-flow.js +0 -326
package/src/commands/init.ts
CHANGED
|
@@ -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`;
|
package/src/commands/prepare.ts
CHANGED
|
@@ -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
|
package/src/commands/publish.ts
CHANGED
|
@@ -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
|
+
});
|
package/src/commands/render.ts
CHANGED
|
@@ -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:
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
|
|
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
|
+
})();
|