@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/README.md
CHANGED
|
@@ -747,6 +747,8 @@ bun run src/cli.ts init ... --note-id NOTE_ID
|
|
|
747
747
|
|
|
748
748
|
## imgx 渲染子系统
|
|
749
749
|
|
|
750
|
+
封面支持 `render.cover` 配置、十套主题、背景图与透明边框,以及供 App 使用的 JSON Schema 和预览输出。详见 [封面主题与图片排版](docs/render-themes.md),可直接导入 [配置示例](examples/cover-themes.json)。
|
|
751
|
+
|
|
750
752
|
- 基于 Chrome headless 和 `@napi-rs/canvas`
|
|
751
753
|
- 模板包括 `longform-3-4`(newspic 长文)、`poster-3-4`(单页卡片)和 `wechat-cover-split`(文章封面)
|
|
752
754
|
- 主题包括 `paper-sage`(默认账号)和 `linen-news`(`ancientone` 账号)
|
|
@@ -853,7 +855,7 @@ bun install --global .
|
|
|
853
855
|
运行时依赖检查:
|
|
854
856
|
- `@napi-rs/canvas` — 图片渲染需要,懒加载,缺失时提示安装命令
|
|
855
857
|
- Chrome — HTML 导出需要,缺失时提示安装命令
|
|
856
|
-
- CJK 字体 —
|
|
858
|
+
- CJK 字体 — 随 CLI 附带,生成图片时按需读取本地文件,不触发网络下载;封面也支持本机字体名称
|
|
857
859
|
|
|
858
860
|
## 发布提供者
|
|
859
861
|
|
|
@@ -866,6 +868,13 @@ bun install --global .
|
|
|
866
868
|
|
|
867
869
|
Markdown → WeChat HTML 转换通过插件系统完成(默认使用内置 `builtin-wechat-preview` 适配器)。
|
|
868
870
|
|
|
871
|
+
## 本机多任务监控
|
|
872
|
+
|
|
873
|
+
`zzp monitor start` 按需启动共享 HTTP/SSE 服务,`monitor status` 查看状态,`monitor stop` 只停止监控。
|
|
874
|
+
CLI 独立记录执行和错误,GUI 可同时订阅多个任务。新版业务失败会退出 1,调用方仍应读取失败响应和最新任务状态。
|
|
875
|
+
|
|
876
|
+
详见 [监控接口与退出码迁移](docs/monitor.md)。
|
|
877
|
+
|
|
869
878
|
## 测试与验证
|
|
870
879
|
|
|
871
880
|
```bash
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zzclub/pipeline",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"zzp": "./src/cli.ts",
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
"release:major": "changelogen --release --major --push"
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
|
-
"@chenglou/pretext": "
|
|
27
|
+
"@chenglou/pretext": "0.0.8",
|
|
28
28
|
"@zzclub/milkdown-article-style": "0.1.1",
|
|
29
29
|
"image-size": "^2.0.2",
|
|
30
30
|
"pngjs": "^7.0.0",
|
package/src/adapter-types.ts
CHANGED
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import type { RenderAsset, RoutePrimary, WorkflowState } from "./state";
|
|
12
|
+
import type { MonitorProgress } from "./monitor/types";
|
|
13
|
+
import type { ResolvedCoverTheme } from "./schema/cover-theme";
|
|
12
14
|
|
|
13
15
|
// ── Doctor check ──────────────────────────────────────────────────
|
|
14
16
|
|
|
@@ -26,6 +28,10 @@ export interface PipelinePluginDoctorCheck {
|
|
|
26
28
|
* to runRenderArticleCli and runRenderCardCli via CLI argv.
|
|
27
29
|
*/
|
|
28
30
|
export interface ImageRenderInput {
|
|
31
|
+
/** 已解析的封面主题;旧插件可忽略。 */
|
|
32
|
+
coverTheme?: ResolvedCoverTheme;
|
|
33
|
+
/** 可选进度通知,旧插件可忽略。 */
|
|
34
|
+
onProgress?: (progress: MonitorProgress) => void;
|
|
29
35
|
/** Workflow state providing context (route, metadata, etc.) */
|
|
30
36
|
state: WorkflowState;
|
|
31
37
|
|
|
@@ -117,6 +123,8 @@ export interface ImageRenderPlugin {
|
|
|
117
123
|
* Maps to ExportMarkdownToWechatHtmlInput from wechat-preview.
|
|
118
124
|
*/
|
|
119
125
|
export interface MarkdownRenderInput {
|
|
126
|
+
/** 可选进度通知,旧插件可忽略。 */
|
|
127
|
+
onProgress?: (progress: MonitorProgress) => void;
|
|
120
128
|
/** Path to the markdown source file */
|
|
121
129
|
markdownPath: string;
|
|
122
130
|
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { defaultState, normalizeNewspicRenderSpec } from "../state";
|
|
6
|
+
import { builtinImageRenderer } from "./builtin-image-renderer";
|
|
7
|
+
|
|
8
|
+
test("multi mode without page specs renders body pages and excludes stale files", async () => {
|
|
9
|
+
const workspace = await mkdtemp(join(tmpdir(), "zzhub-auto-pages-"));
|
|
10
|
+
const previous = process.env.TEST_RENDER_CARD_STUB;
|
|
11
|
+
process.env.TEST_RENDER_CARD_STUB = "1";
|
|
12
|
+
try {
|
|
13
|
+
const outputDir = join(workspace, "images");
|
|
14
|
+
await mkdir(outputDir);
|
|
15
|
+
await writeFile(join(outputDir, "article-99.png"), "previous output");
|
|
16
|
+
const state = defaultState();
|
|
17
|
+
state.run_id = "auto-pages";
|
|
18
|
+
state.workspace_root = workspace;
|
|
19
|
+
state.route.primary = "wechat-newspic";
|
|
20
|
+
state.intent.newspic_render = normalizeNewspicRenderSpec({ pagination_mode: "multi", min_pages: 2, target_fill_ratio: 0.8 });
|
|
21
|
+
const result = await builtinImageRenderer.render({ state, route: "wechat-newspic", outputDir, title: "完整标题:副标题", bodyText: "正文应该舒适易读,不需要手工指定每一页。".repeat(24) });
|
|
22
|
+
expect(result.pageCount).toBeGreaterThanOrEqual(2);
|
|
23
|
+
expect(result.assets.filter(asset => asset.kind === "page")).toHaveLength(result.pageCount);
|
|
24
|
+
expect(result.assets.every(asset => !asset.path.endsWith("article-99.png"))).toBe(true);
|
|
25
|
+
expect((await readFile(result.assets.find(asset => asset.kind === "page")!.path)).subarray(1, 4).toString()).toBe("PNG");
|
|
26
|
+
expect(await readFile(join(outputDir, "article-99.png"), "utf8")).toBe("previous output");
|
|
27
|
+
} finally {
|
|
28
|
+
if (previous === undefined) delete process.env.TEST_RENDER_CARD_STUB;
|
|
29
|
+
else process.env.TEST_RENDER_CARD_STUB = previous;
|
|
30
|
+
await rm(workspace, { recursive: true, force: true });
|
|
31
|
+
}
|
|
32
|
+
}, 30_000);
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* when no user-provided plugin is configured.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { mkdir,
|
|
8
|
+
import { mkdir, rm, rmdir, writeFile } from "fs/promises";
|
|
9
9
|
import { join } from "path";
|
|
10
10
|
|
|
11
11
|
import { runRenderArticleCli, runRenderCardCli } from "../imgx";
|
|
@@ -17,13 +17,15 @@ import {
|
|
|
17
17
|
generateCoverTitle,
|
|
18
18
|
} from "../text";
|
|
19
19
|
import { getLongformTheme } from "../routes";
|
|
20
|
-
import { resolveWorkspacePaths } from "../config";
|
|
20
|
+
import { loadConfig, resolveWorkspacePaths } from "../config";
|
|
21
|
+
import { resolveRenderBranding } from "../render-branding";
|
|
21
22
|
import type {
|
|
22
23
|
ImageRenderPlugin,
|
|
23
24
|
ImageRenderInput,
|
|
24
25
|
ImageRenderOutput,
|
|
25
26
|
PipelinePluginDoctorCheck,
|
|
26
27
|
} from "../adapter-types";
|
|
28
|
+
import { normalizeNewspicRenderSpec } from "../state";
|
|
27
29
|
import type {
|
|
28
30
|
NewspicRenderSpec,
|
|
29
31
|
RenderAsset,
|
|
@@ -32,20 +34,6 @@ import type {
|
|
|
32
34
|
|
|
33
35
|
// ── Helpers ──────────────────────────────────────────────────────
|
|
34
36
|
|
|
35
|
-
function appendPosterVisualArgs(
|
|
36
|
-
cmdParts: string[],
|
|
37
|
-
visualParams: AccountVisualParams | null,
|
|
38
|
-
highlightWords: string[],
|
|
39
|
-
): void {
|
|
40
|
-
if (visualParams) {
|
|
41
|
-
cmdParts.push("--highlight", visualParams.highlight);
|
|
42
|
-
cmdParts.push("--bg", visualParams.bg);
|
|
43
|
-
cmdParts.push("--footer", visualParams.footer);
|
|
44
|
-
cmdParts.push("--fallback-icon", visualParams.fallback_icon);
|
|
45
|
-
}
|
|
46
|
-
cmdParts.push("--highlight-words", highlightWords.join(","));
|
|
47
|
-
}
|
|
48
|
-
|
|
49
37
|
type ResolvedPageImageSpecFile = {
|
|
50
38
|
default_image_layout: string;
|
|
51
39
|
target_fill_ratio: number;
|
|
@@ -138,29 +126,19 @@ async function renderCover(
|
|
|
138
126
|
outputDir: string,
|
|
139
127
|
visualParams: AccountVisualParams | null,
|
|
140
128
|
highlightWords: string[],
|
|
129
|
+
onProgress?: ImageRenderInput["onProgress"],
|
|
130
|
+
coverTheme?: ImageRenderInput["coverTheme"],
|
|
141
131
|
): Promise<RenderAsset> {
|
|
142
132
|
const coverOut = join(outputDir, "cover.png");
|
|
143
133
|
const route = template === "wechat-cover-split" ? "wechat-article" : "wechat-newspic";
|
|
144
|
-
|
|
145
|
-
if (
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
cmdParts.push("--bg", visualParams.bg);
|
|
150
|
-
cmdParts.push("--footer", visualParams.footer);
|
|
151
|
-
cmdParts.push("--fallback-icon", visualParams.fallback_icon);
|
|
152
|
-
}
|
|
153
|
-
cmdParts.push("--highlight-words", highlightWords.join(","));
|
|
154
|
-
cmdParts.push("--out", coverOut);
|
|
155
|
-
runRenderCardCli(cmdParts);
|
|
156
|
-
} else {
|
|
157
|
-
const cmdParts = ["--template", "poster-3-4", "--text", title];
|
|
158
|
-
appendPosterVisualArgs(cmdParts, visualParams, highlightWords);
|
|
159
|
-
cmdParts.push("--out", coverOut);
|
|
160
|
-
runRenderCardCli(cmdParts);
|
|
134
|
+
const args = ["--template", template, "--text", title, "--out", coverOut, "--highlight-words", highlightWords.join(",")];
|
|
135
|
+
if (visualParams) {
|
|
136
|
+
args.push("--footer", visualParams.footer, "--fallback-icon", visualParams.fallback_icon);
|
|
137
|
+
// 旧直接调用仍支持账号配色;已选主题使用自己的颜色。
|
|
138
|
+
if (!coverTheme) args.push("--bg", visualParams.bg, "--highlight", visualParams.highlight);
|
|
161
139
|
}
|
|
162
|
-
|
|
163
|
-
return { kind: "cover", route
|
|
140
|
+
await runRenderCardCli(args, onProgress, coverTheme);
|
|
141
|
+
return { kind: "cover", route, path: coverOut };
|
|
164
142
|
}
|
|
165
143
|
|
|
166
144
|
async function renderLongformPages(
|
|
@@ -196,6 +174,7 @@ async function renderLongformPages(
|
|
|
196
174
|
"--text-file", tempBodyPath,
|
|
197
175
|
"--out-dir", outputDir,
|
|
198
176
|
"--theme", theme,
|
|
177
|
+
"--target-fill-ratio", String(newspicRenderSpec.target_fill_ratio),
|
|
199
178
|
];
|
|
200
179
|
|
|
201
180
|
if (newspicRenderSpec.min_pages > 1) {
|
|
@@ -204,10 +183,9 @@ async function renderLongformPages(
|
|
|
204
183
|
if (newspicRenderSpec.max_pages > 0) {
|
|
205
184
|
pageParts.push("--max-pages", String(newspicRenderSpec.max_pages));
|
|
206
185
|
}
|
|
186
|
+
if (newspicRenderSpec.require_image_every_page) pageParts.push("--require-image-every-page");
|
|
207
187
|
|
|
208
|
-
if (vp
|
|
209
|
-
pageParts.push("--footer", vp.footer);
|
|
210
|
-
}
|
|
188
|
+
if (vp) pageParts.push("--footer", vp.footer, "--icon", vp.fallback_icon);
|
|
211
189
|
|
|
212
190
|
const highlightWords = input.state.route.highlight_words ?? [];
|
|
213
191
|
if (highlightWords.length > 0) {
|
|
@@ -231,7 +209,7 @@ async function renderLongformPages(
|
|
|
231
209
|
|
|
232
210
|
let result: RenderArticleResult;
|
|
233
211
|
try {
|
|
234
|
-
result = runRenderArticleCli(pageParts);
|
|
212
|
+
result = await runRenderArticleCli(pageParts, input.onProgress);
|
|
235
213
|
} finally {
|
|
236
214
|
if (!keepTempFiles) {
|
|
237
215
|
await rm(tempDir, { recursive: true, force: true });
|
|
@@ -244,20 +222,13 @@ async function renderLongformPages(
|
|
|
244
222
|
throw new Error(`newspic render constraints not satisfied: ${errors.join("; ")}`);
|
|
245
223
|
}
|
|
246
224
|
|
|
247
|
-
//
|
|
248
|
-
const
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
kind: "page",
|
|
255
|
-
route: "wechat-newspic",
|
|
256
|
-
index: i + 1,
|
|
257
|
-
path: join(outputDir, pageFiles[i]),
|
|
258
|
-
});
|
|
259
|
-
}
|
|
260
|
-
|
|
225
|
+
// 仅返回本次实际生成的页,目录中的旧页不参与结果。
|
|
226
|
+
const assets: RenderAsset[] = result.pages.map(page => ({
|
|
227
|
+
kind: "page",
|
|
228
|
+
route: "wechat-newspic",
|
|
229
|
+
index: page.page,
|
|
230
|
+
path: join(outputDir, "article-" + String(page.page).padStart(2, "0") + ".png"),
|
|
231
|
+
}));
|
|
261
232
|
return { assets, pageCount: result.pageCount, pages: result.pages };
|
|
262
233
|
}
|
|
263
234
|
|
|
@@ -265,7 +236,7 @@ async function renderLongformPages(
|
|
|
265
236
|
|
|
266
237
|
export const builtinImageRenderer: ImageRenderPlugin = {
|
|
267
238
|
name: "builtin-imgx",
|
|
268
|
-
version: "1.
|
|
239
|
+
version: "1.1.0",
|
|
269
240
|
|
|
270
241
|
async doctor(): Promise<PipelinePluginDoctorCheck[]> {
|
|
271
242
|
const checks: PipelinePluginDoctorCheck[] = [];
|
|
@@ -296,20 +267,19 @@ export const builtinImageRenderer: ImageRenderPlugin = {
|
|
|
296
267
|
// Normalize visual params: adapter input uses camelCase, state uses snake_case
|
|
297
268
|
const adapterVp = input.accountVisualParams;
|
|
298
269
|
const stateVp = state.route.account_visual_params;
|
|
299
|
-
const
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
: stateVp;
|
|
270
|
+
const branding = resolveRenderBranding(loadConfig(), state.route.account);
|
|
271
|
+
const vp: AccountVisualParams = {
|
|
272
|
+
footer: adapterVp?.footer ?? branding.footerText,
|
|
273
|
+
bg: adapterVp?.bg ?? stateVp?.bg ?? "",
|
|
274
|
+
highlight: adapterVp?.highlight ?? stateVp?.highlight ?? "",
|
|
275
|
+
fallback_icon: adapterVp?.fallbackIcon ?? branding.logo,
|
|
276
|
+
};
|
|
307
277
|
|
|
308
278
|
await mkdir(outputDir, { recursive: true });
|
|
309
279
|
|
|
310
280
|
if (route === "wechat-article") {
|
|
311
281
|
// Article: cover only (wechat-cover-split)
|
|
312
|
-
const cover = await renderCover("wechat-cover-split", title, outputDir, vp, highlightWords);
|
|
282
|
+
const cover = await renderCover("wechat-cover-split", title, outputDir, vp, highlightWords, input.onProgress, input.coverTheme);
|
|
313
283
|
return { assets: [cover], pageCount: 1, pages: [{ page: 1, imageCount: 0, imageSources: [] }] };
|
|
314
284
|
}
|
|
315
285
|
|
|
@@ -318,20 +288,19 @@ export const builtinImageRenderer: ImageRenderPlugin = {
|
|
|
318
288
|
}
|
|
319
289
|
|
|
320
290
|
// wechat-newspic: determine short vs long
|
|
291
|
+
const savedSpec = normalizeNewspicRenderSpec(state.intent.newspic_render);
|
|
321
292
|
const newspicRenderSpec: NewspicRenderSpec = {
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
target_fill_ratio: 0.8,
|
|
328
|
-
page_specs: (input.pageSpecs ?? []).map((ps) => ({
|
|
293
|
+
...savedSpec,
|
|
294
|
+
pagination_mode: input.template === "longform-3-4" ? "multi" : input.template === "poster-3-4" ? "single" : savedSpec.pagination_mode,
|
|
295
|
+
min_pages: input.minPages ?? savedSpec.min_pages,
|
|
296
|
+
max_pages: input.maxPages ?? savedSpec.max_pages,
|
|
297
|
+
page_specs: input.pageSpecs?.map(ps => ({
|
|
329
298
|
page: ps.page,
|
|
330
299
|
image_markers: ps.imageMarkers ?? [],
|
|
331
300
|
image_layout: ps.imageLayout ?? null,
|
|
332
301
|
target_fill_ratio: ps.targetFillRatio ?? null,
|
|
333
302
|
note: ps.note ?? null,
|
|
334
|
-
})),
|
|
303
|
+
})) ?? savedSpec.page_specs,
|
|
335
304
|
};
|
|
336
305
|
|
|
337
306
|
const isLong = newspicRenderSpec.pagination_mode === "multi";
|
|
@@ -339,13 +308,13 @@ export const builtinImageRenderer: ImageRenderPlugin = {
|
|
|
339
308
|
if (!isLong) {
|
|
340
309
|
// Short: single poster cover
|
|
341
310
|
const coverTitle = generateCoverTitle(title);
|
|
342
|
-
const cover = await renderCover("poster-3-4", coverTitle, outputDir, vp, highlightWords);
|
|
311
|
+
const cover = await renderCover("poster-3-4", coverTitle, outputDir, vp, highlightWords, input.onProgress, input.coverTheme);
|
|
343
312
|
return { assets: [cover], pageCount: 1, pages: [{ page: 1, imageCount: 0, imageSources: [] }] };
|
|
344
313
|
}
|
|
345
314
|
|
|
346
315
|
// Long: cover + article pages
|
|
347
316
|
const coverTitle = generateCoverTitle(title);
|
|
348
|
-
const cover = await renderCover("poster-3-4", coverTitle, outputDir, vp, highlightWords);
|
|
317
|
+
const cover = await renderCover("poster-3-4", coverTitle, outputDir, vp, highlightWords, input.onProgress, input.coverTheme);
|
|
349
318
|
|
|
350
319
|
const bodyImages = input.bodyImages ?? [];
|
|
351
320
|
const pageResult = await renderLongformPages(input, outputDir, newspicRenderSpec, bodyImages, vp);
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import { exportMarkdownToWechatHtml } from "../wechat-preview";
|
|
9
9
|
import { findChrome } from "../imgx/runtime";
|
|
10
|
+
import { notifyProgress } from "../monitor/recorder";
|
|
10
11
|
import type {
|
|
11
12
|
MarkdownRenderPlugin,
|
|
12
13
|
MarkdownRenderInput,
|
|
@@ -43,6 +44,7 @@ export const builtinMarkdownRenderer: MarkdownRenderPlugin = {
|
|
|
43
44
|
},
|
|
44
45
|
|
|
45
46
|
async render(input: MarkdownRenderInput): Promise<MarkdownRenderOutput> {
|
|
47
|
+
notifyProgress(input.onProgress, { stage: "render.html", message: "正在导出微信 HTML" });
|
|
46
48
|
const result = await exportMarkdownToWechatHtml({
|
|
47
49
|
markdownPath: input.markdownPath,
|
|
48
50
|
outPath: input.outPath,
|
|
@@ -54,6 +56,7 @@ export const builtinMarkdownRenderer: MarkdownRenderPlugin = {
|
|
|
54
56
|
timeoutMs: input.timeoutMs,
|
|
55
57
|
debugDir: input.debugDir ?? undefined,
|
|
56
58
|
});
|
|
59
|
+
notifyProgress(input.onProgress, { stage: "render.html", message: "微信 HTML 导出完成", current: 1, total: 1, unit: "files" });
|
|
57
60
|
|
|
58
61
|
return {
|
|
59
62
|
html: result.html,
|
package/src/cli.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { dirname, join } from "path";
|
|
|
5
5
|
import { fileURLToPath } from "url";
|
|
6
6
|
import { getDailyLogPath, runWithCommandLog } from "./logger";
|
|
7
7
|
import { formatUsage, getCommandRegistry } from "./plugins";
|
|
8
|
+
import { outcomeExitCode } from "./command-outcome";
|
|
8
9
|
|
|
9
10
|
const COMMANDS = getCommandRegistry();
|
|
10
11
|
|
|
@@ -37,14 +38,19 @@ async function main() {
|
|
|
37
38
|
}
|
|
38
39
|
|
|
39
40
|
try {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
41
|
+
// monitor 输出包含本机认证令牌,不能经过日志复制或监控自身。
|
|
42
|
+
const outcome = cmd === "monitor"
|
|
43
|
+
? await command.handler(args.slice(1))
|
|
44
|
+
: await runWithCommandLog(cmd, args.slice(1), () => command.handler(args.slice(1)));
|
|
45
|
+
if (outcome) {
|
|
46
|
+
process.exitCode = outcomeExitCode(outcome);
|
|
47
|
+
for (const error of outcome.errors ?? []) console.error(`[zzhub-pipeline ${cmd}] ${error.code}: ${error.message}`);
|
|
48
|
+
}
|
|
43
49
|
} catch (err) {
|
|
44
50
|
const msg = err instanceof Error ? err.message : String(err);
|
|
45
51
|
console.error(`[zzhub-pipeline ${cmd}] Error: ${msg}`);
|
|
46
52
|
console.error(`[zzhub-pipeline ${cmd}] Full log: ${getDailyLogPath()}`);
|
|
47
|
-
process.
|
|
53
|
+
process.exitCode = 1;
|
|
48
54
|
}
|
|
49
55
|
}
|
|
50
56
|
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { PublishResult } from "./state";
|
|
2
|
+
|
|
3
|
+
/** 同一次执行共用的业务结果,独立于工作流是否完成。 */
|
|
4
|
+
export interface CommandOutcome {
|
|
5
|
+
status: "success" | "skipped" | "waiting" | "partial_failure" | "failed";
|
|
6
|
+
errors?: CommandError[];
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** 可供终端和监控展示的错误,不推测可重试性。 */
|
|
10
|
+
export interface CommandError {
|
|
11
|
+
code: string;
|
|
12
|
+
message: string;
|
|
13
|
+
stage?: string;
|
|
14
|
+
route?: string;
|
|
15
|
+
account?: string;
|
|
16
|
+
cause?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** 保留已有错误码和堆栈,未知异常使用统一兜底。 */
|
|
20
|
+
export function commandError(error: unknown, code = "COMMAND_FAILED"): CommandError {
|
|
21
|
+
return {
|
|
22
|
+
code: error instanceof Error && "code" in error && typeof error.code === "string" ? error.code : code,
|
|
23
|
+
message: error instanceof Error ? error.message : String(error),
|
|
24
|
+
...(error instanceof Error && error.stack ? { cause: error.stack } : {}),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** 出口只使用 0/1;等待输入和幂等跳过都是正常完成命令。 */
|
|
29
|
+
export function outcomeExitCode(outcome: CommandOutcome): number {
|
|
30
|
+
return outcome.status === "failed" || outcome.status === "partial_failure" ? 1 : 0;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** 仅汇总本次请求目标,避免历史失败污染新的追加发布。 */
|
|
34
|
+
export function publishOutcome(results: PublishResult[], skipped = false): CommandOutcome {
|
|
35
|
+
const failures = results.filter((result) => result.status === "failed");
|
|
36
|
+
if (failures.length) {
|
|
37
|
+
return {
|
|
38
|
+
status: results.some((result) => result.status === "success") ? "partial_failure" : "failed",
|
|
39
|
+
errors: failures.map((result) => ({
|
|
40
|
+
code: "PUBLISH_TARGET_FAILED",
|
|
41
|
+
message: result.detail || "发布失败,provider 未提供原因",
|
|
42
|
+
stage: "publish",
|
|
43
|
+
route: result.route,
|
|
44
|
+
account: result.account,
|
|
45
|
+
})),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
if (results.some((result) => result.status === "handoff")) return { status: "waiting" };
|
|
49
|
+
return { status: skipped || !results.length || results.every((result) => result.status === "skipped") ? "skipped" : "success" };
|
|
50
|
+
}
|
package/src/commands/abandon.ts
CHANGED
|
@@ -17,6 +17,7 @@ import { parseArgs, optionalArg } from "../args";
|
|
|
17
17
|
import { printResult, renderAbandon } from "../output";
|
|
18
18
|
import { filterActiveTasks, getTaskByStatePath, listTasks, type ListedTask } from "../task-manager";
|
|
19
19
|
import { updateState } from "../state";
|
|
20
|
+
import type { CommandOutcome } from "../command-outcome";
|
|
20
21
|
|
|
21
22
|
// ── Result type ───────────────────────────────────────────────────────────────
|
|
22
23
|
|
|
@@ -146,7 +147,7 @@ async function runInteractive(items: ListedTask[]): Promise<ListedTask[]> {
|
|
|
146
147
|
|
|
147
148
|
// ── Command entry point ───────────────────────────────────────────────────────
|
|
148
149
|
|
|
149
|
-
export async function abandon(args: string[]): Promise<void> {
|
|
150
|
+
export async function abandon(args: string[]): Promise<void | CommandOutcome> {
|
|
150
151
|
const parsed = parseArgs(args);
|
|
151
152
|
|
|
152
153
|
if (parsed.help) {
|
|
@@ -184,7 +185,7 @@ Options:
|
|
|
184
185
|
}
|
|
185
186
|
const result = await abandonTask(task);
|
|
186
187
|
printResult([result], renderAbandon);
|
|
187
|
-
return;
|
|
188
|
+
return abandonOutcome([result]);
|
|
188
189
|
}
|
|
189
190
|
|
|
190
191
|
// ── Interactive: TTY checkbox ─────────────────────────────────────────────
|
|
@@ -199,4 +200,14 @@ Options:
|
|
|
199
200
|
|
|
200
201
|
const results = await Promise.all(chosen.map(abandonTask));
|
|
201
202
|
printResult(results, renderAbandon);
|
|
203
|
+
return abandonOutcome(results);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** 批量操作保留成功项,失败项决定本次执行结果。 */
|
|
207
|
+
function abandonOutcome(results: AbandonResult[]): CommandOutcome {
|
|
208
|
+
const failed = results.filter((result) => !result.ok);
|
|
209
|
+
return {
|
|
210
|
+
status: failed.length ? (failed.length < results.length ? "partial_failure" : "failed") : "success",
|
|
211
|
+
errors: failed.map((result) => ({ code: "ABANDON_FAILED", message: result.error || "放弃任务失败" })),
|
|
212
|
+
};
|
|
202
213
|
}
|
|
@@ -15,8 +15,9 @@ import { getTaskByStatePath } from "../task-manager";
|
|
|
15
15
|
import { parseTaskViewMode, renderTaskStatusMarkdown } from "../task-views";
|
|
16
16
|
import type { PhaseName } from "../state";
|
|
17
17
|
import { validateForPhase } from "../state";
|
|
18
|
+
import type { CommandOutcome } from "../command-outcome";
|
|
18
19
|
|
|
19
|
-
export async function checkpoint(args: string[]): Promise<void> {
|
|
20
|
+
export async function checkpoint(args: string[]): Promise<void | CommandOutcome> {
|
|
20
21
|
const parsed = parseArgs(args);
|
|
21
22
|
|
|
22
23
|
if (parsed.help) {
|
|
@@ -61,6 +62,6 @@ Options:
|
|
|
61
62
|
}
|
|
62
63
|
|
|
63
64
|
if (errors.length > 0) {
|
|
64
|
-
|
|
65
|
+
return { status: "failed", errors: errors.map((error) => ({ code: "CHECKPOINT_FAILED", message: `${error.field}: ${error.message}`, stage: phase })) };
|
|
65
66
|
}
|
|
66
67
|
}
|
package/src/commands/config.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { readFileSync } from "fs";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { CoverConfigSchema, mergeCoverSettings } from "../schema/cover-theme";
|
|
4
|
+
import { RenderBrandingSchema, RenderConfigSchema } from "../schema/config";
|
|
2
5
|
import { parseArgs, optionalArg, flagArg } from "../args";
|
|
3
6
|
import { printResult, renderConfig } from "../output";
|
|
4
7
|
import {
|
|
@@ -29,6 +32,7 @@ Options:
|
|
|
29
32
|
--json Force JSON output for scalar reads
|
|
30
33
|
--export Print full config as JSON (secrets redacted; use --raw to show all)
|
|
31
34
|
--import Path to a JSON file to merge into current config
|
|
35
|
+
--schema Print JSON Schema (--key render | render.cover | render.branding)
|
|
32
36
|
--raw Show secrets unredacted (use with --export)
|
|
33
37
|
|
|
34
38
|
Examples:
|
|
@@ -39,6 +43,14 @@ Examples:
|
|
|
39
43
|
return;
|
|
40
44
|
}
|
|
41
45
|
|
|
46
|
+
// schema 查询不依赖配置文件是否可用,便于 App 修复配置。
|
|
47
|
+
if (flagArg(parsed, "schema")) {
|
|
48
|
+
const schemas: Record<string, z.ZodType> = { render: RenderConfigSchema, "render.cover": CoverConfigSchema, "render.branding": RenderBrandingSchema };
|
|
49
|
+
const schema = schemas[optionalArg(parsed, "key") ?? ""];
|
|
50
|
+
if (!schema) throw new Error("--schema requires --key render | render.cover | render.branding");
|
|
51
|
+
printResult(z.toJSONSchema(schema), data => JSON.stringify(data, null, 2));
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
42
54
|
const config = loadConfig();
|
|
43
55
|
|
|
44
56
|
// --export
|
|
@@ -95,6 +107,7 @@ Examples:
|
|
|
95
107
|
...(isPlainObject(importedObj.plugins) ? importedObj.plugins : {}),
|
|
96
108
|
},
|
|
97
109
|
imgx: { ...config.imgx, ...(isPlainObject(importedObj.imgx) ? importedObj.imgx : {}) },
|
|
110
|
+
render: mergeCoverSettings(config.render, importedObj.render ?? {}),
|
|
98
111
|
};
|
|
99
112
|
// Soft-fill known account display names + Zod defaults / strip unknowns
|
|
100
113
|
const merged = normalizeConfig(raw);
|
|
@@ -118,7 +131,7 @@ Examples:
|
|
|
118
131
|
const currentValue = getConfigValue(config, key);
|
|
119
132
|
const redactedValue = redactConfigValue(key, currentValue);
|
|
120
133
|
if (forceJson || typeof currentValue !== "string") {
|
|
121
|
-
printResult({ key, value: redactedValue }, renderConfig);
|
|
134
|
+
printResult({ key, value: redactedValue }, forceJson ? data => JSON.stringify(data, null, 2) : renderConfig);
|
|
122
135
|
return;
|
|
123
136
|
}
|
|
124
137
|
printResult(String(redactedValue), renderConfig);
|
package/src/commands/imgx.ts
CHANGED
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
} from "../imgx";
|
|
7
7
|
import { ensureFonts } from "../runtime-paths";
|
|
8
8
|
|
|
9
|
-
type ImgxHandler = (args: string[]) =>
|
|
9
|
+
type ImgxHandler = (args: string[]) => unknown;
|
|
10
10
|
|
|
11
11
|
const IMGX_COMMANDS: Record<string, ImgxHandler> = {
|
|
12
12
|
"render-article": runRenderArticleCli,
|
|
@@ -28,9 +28,9 @@ export async function imgxCommand(args: string[]): Promise<void> {
|
|
|
28
28
|
throw new Error(`Unknown imgx subcommand: ${subcommand}`);
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
await ensureFonts();
|
|
31
|
+
if (subcommand !== "render-card" && subcommand !== "render-article") await ensureFonts();
|
|
32
32
|
try {
|
|
33
|
-
handler(args.slice(1));
|
|
33
|
+
await handler(args.slice(1));
|
|
34
34
|
} catch (err) {
|
|
35
35
|
const msg = err instanceof Error ? err.message : String(err);
|
|
36
36
|
if (msg.includes("@napi-rs/canvas") || msg.includes("Cannot find package")) {
|
|
@@ -5,6 +5,7 @@ import { parseArgs, optionalArg, requireArg } from "../args";
|
|
|
5
5
|
import { loadConfig, resolveWorkspacePaths, resolveWorkspaceRoot } from "../config";
|
|
6
6
|
import { printResult } from "../output";
|
|
7
7
|
import { resolveFullRoute } from "../routes";
|
|
8
|
+
import { resolveCoverTheme } from "../schema/cover-theme";
|
|
8
9
|
import {
|
|
9
10
|
defaultContentReview,
|
|
10
11
|
defaultState,
|
|
@@ -12,6 +13,7 @@ import {
|
|
|
12
13
|
getRunStatePath,
|
|
13
14
|
readResolvedState,
|
|
14
15
|
resetDerivedState,
|
|
16
|
+
reenterRender,
|
|
15
17
|
writeState,
|
|
16
18
|
type ContentForm,
|
|
17
19
|
type HandoffAuthoringPolicy,
|
|
@@ -23,6 +25,7 @@ import {
|
|
|
23
25
|
import { findTask, getTaskByStatePath } from "../task-manager";
|
|
24
26
|
|
|
25
27
|
interface PublishHandoffInput {
|
|
28
|
+
cover_theme?: string | null;
|
|
26
29
|
content_form: ContentForm;
|
|
27
30
|
body_path: string;
|
|
28
31
|
target_account: string;
|
|
@@ -34,6 +37,7 @@ interface PublishHandoffInput {
|
|
|
34
37
|
type WorkflowHandoffMode = "new" | "resume";
|
|
35
38
|
|
|
36
39
|
interface WorkflowHandoffInput {
|
|
40
|
+
cover_theme?: string | null;
|
|
37
41
|
mode?: WorkflowHandoffMode;
|
|
38
42
|
state_path?: string;
|
|
39
43
|
run_id?: string;
|
|
@@ -50,6 +54,7 @@ interface WorkflowHandoffInput {
|
|
|
50
54
|
}
|
|
51
55
|
|
|
52
56
|
interface ResolvedWorkflowHandoff {
|
|
57
|
+
cover_theme?: string | null;
|
|
53
58
|
source: "publish_handoff" | "workflow_handoff";
|
|
54
59
|
mode: WorkflowHandoffMode;
|
|
55
60
|
state_path?: string;
|
|
@@ -162,6 +167,7 @@ function parsePublishHandoff(raw: unknown): ResolvedWorkflowHandoff {
|
|
|
162
167
|
|
|
163
168
|
return {
|
|
164
169
|
source: "publish_handoff",
|
|
170
|
+
cover_theme: input.cover_theme === null ? null : cleanString(input.cover_theme, "publish_handoff.cover_theme"),
|
|
165
171
|
mode: "new",
|
|
166
172
|
content_form: contentForm,
|
|
167
173
|
body_path: bodyPath,
|
|
@@ -193,6 +199,7 @@ function parseWorkflowHandoff(raw: unknown): ResolvedWorkflowHandoff {
|
|
|
193
199
|
: "new");
|
|
194
200
|
const handoff: ResolvedWorkflowHandoff = {
|
|
195
201
|
source: "workflow_handoff",
|
|
202
|
+
cover_theme: input.cover_theme === null ? null : cleanString(input.cover_theme, "workflow_handoff.cover_theme"),
|
|
196
203
|
mode,
|
|
197
204
|
state_path: cleanString(input.state_path, "workflow_handoff.state_path"),
|
|
198
205
|
run_id: cleanString(input.run_id, "workflow_handoff.run_id"),
|
|
@@ -319,6 +326,11 @@ async function applyHandoffToState(
|
|
|
319
326
|
): Promise<void> {
|
|
320
327
|
const targets: Target[] = state.intent.targets.length > 0 ? state.intent.targets : ["wechat"];
|
|
321
328
|
let restartFromPrepare = false;
|
|
329
|
+
const themeChanged = handoff.cover_theme !== undefined && handoff.cover_theme !== state.intent.cover_theme;
|
|
330
|
+
if (themeChanged) {
|
|
331
|
+
resolveCoverTheme(loadConfig().render.cover, "poster-3-4", handoff.target_account ?? state.route.account, handoff.cover_theme);
|
|
332
|
+
state.intent.cover_theme = handoff.cover_theme ?? null;
|
|
333
|
+
}
|
|
322
334
|
|
|
323
335
|
const nextContentForm = handoff.content_form ?? state.intent.content_form;
|
|
324
336
|
if (handoff.content_form && handoff.content_form !== state.intent.content_form) {
|
|
@@ -409,6 +421,9 @@ async function applyHandoffToState(
|
|
|
409
421
|
|
|
410
422
|
if (restartFromPrepare) {
|
|
411
423
|
resetDerivedState(state);
|
|
424
|
+
} else if (themeChanged && state.asset_path && state.content_review.status === "passed") {
|
|
425
|
+
// 仅更换主题时保留正文、审核和 prepare 产物。
|
|
426
|
+
reenterRender(state);
|
|
412
427
|
}
|
|
413
428
|
|
|
414
429
|
if (state.handoff.review_policy === "trust_user" && state.source_body_path) {
|