@zzclub/pipeline 0.12.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 +3 -1
- package/package.json +2 -2
- package/src/adapter-types.ts +3 -0
- package/src/adapters/builtin-image-renderer.test.ts +32 -0
- package/src/adapters/builtin-image-renderer.ts +41 -73
- 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/render-theme.test.ts +115 -0
- package/src/commands/render.ts +27 -9
- 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 +165 -382
- package/src/imgx/render-card.ts +64 -204
- package/src/imgx/runtime.ts +0 -90
- 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/text.test.ts +7 -7
- package/src/text.ts +2 -27
- package/src/workflow.test.ts +2 -1
- 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
|
|
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
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import type { RenderAsset, RoutePrimary, WorkflowState } from "./state";
|
|
12
12
|
import type { MonitorProgress } from "./monitor/types";
|
|
13
|
+
import type { ResolvedCoverTheme } from "./schema/cover-theme";
|
|
13
14
|
|
|
14
15
|
// ── Doctor check ──────────────────────────────────────────────────
|
|
15
16
|
|
|
@@ -27,6 +28,8 @@ export interface PipelinePluginDoctorCheck {
|
|
|
27
28
|
* to runRenderArticleCli and runRenderCardCli via CLI argv.
|
|
28
29
|
*/
|
|
29
30
|
export interface ImageRenderInput {
|
|
31
|
+
/** 已解析的封面主题;旧插件可忽略。 */
|
|
32
|
+
coverTheme?: ResolvedCoverTheme;
|
|
30
33
|
/** 可选进度通知,旧插件可忽略。 */
|
|
31
34
|
onProgress?: (progress: MonitorProgress) => void;
|
|
32
35
|
/** Workflow state providing context (route, metadata, etc.) */
|
|
@@ -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;
|
|
@@ -139,29 +127,18 @@ async function renderCover(
|
|
|
139
127
|
visualParams: AccountVisualParams | null,
|
|
140
128
|
highlightWords: string[],
|
|
141
129
|
onProgress?: ImageRenderInput["onProgress"],
|
|
130
|
+
coverTheme?: ImageRenderInput["coverTheme"],
|
|
142
131
|
): Promise<RenderAsset> {
|
|
143
132
|
const coverOut = join(outputDir, "cover.png");
|
|
144
133
|
const route = template === "wechat-cover-split" ? "wechat-article" : "wechat-newspic";
|
|
145
|
-
|
|
146
|
-
if (
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
cmdParts.push("--bg", visualParams.bg);
|
|
151
|
-
cmdParts.push("--footer", visualParams.footer);
|
|
152
|
-
cmdParts.push("--fallback-icon", visualParams.fallback_icon);
|
|
153
|
-
}
|
|
154
|
-
cmdParts.push("--highlight-words", highlightWords.join(","));
|
|
155
|
-
cmdParts.push("--out", coverOut);
|
|
156
|
-
runRenderCardCli(cmdParts, onProgress);
|
|
157
|
-
} else {
|
|
158
|
-
const cmdParts = ["--template", "poster-3-4", "--text", title];
|
|
159
|
-
appendPosterVisualArgs(cmdParts, visualParams, highlightWords);
|
|
160
|
-
cmdParts.push("--out", coverOut);
|
|
161
|
-
runRenderCardCli(cmdParts, onProgress);
|
|
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);
|
|
162
139
|
}
|
|
163
|
-
|
|
164
|
-
return { kind: "cover", route
|
|
140
|
+
await runRenderCardCli(args, onProgress, coverTheme);
|
|
141
|
+
return { kind: "cover", route, path: coverOut };
|
|
165
142
|
}
|
|
166
143
|
|
|
167
144
|
async function renderLongformPages(
|
|
@@ -197,6 +174,7 @@ async function renderLongformPages(
|
|
|
197
174
|
"--text-file", tempBodyPath,
|
|
198
175
|
"--out-dir", outputDir,
|
|
199
176
|
"--theme", theme,
|
|
177
|
+
"--target-fill-ratio", String(newspicRenderSpec.target_fill_ratio),
|
|
200
178
|
];
|
|
201
179
|
|
|
202
180
|
if (newspicRenderSpec.min_pages > 1) {
|
|
@@ -205,10 +183,9 @@ async function renderLongformPages(
|
|
|
205
183
|
if (newspicRenderSpec.max_pages > 0) {
|
|
206
184
|
pageParts.push("--max-pages", String(newspicRenderSpec.max_pages));
|
|
207
185
|
}
|
|
186
|
+
if (newspicRenderSpec.require_image_every_page) pageParts.push("--require-image-every-page");
|
|
208
187
|
|
|
209
|
-
if (vp
|
|
210
|
-
pageParts.push("--footer", vp.footer);
|
|
211
|
-
}
|
|
188
|
+
if (vp) pageParts.push("--footer", vp.footer, "--icon", vp.fallback_icon);
|
|
212
189
|
|
|
213
190
|
const highlightWords = input.state.route.highlight_words ?? [];
|
|
214
191
|
if (highlightWords.length > 0) {
|
|
@@ -232,7 +209,7 @@ async function renderLongformPages(
|
|
|
232
209
|
|
|
233
210
|
let result: RenderArticleResult;
|
|
234
211
|
try {
|
|
235
|
-
result = runRenderArticleCli(pageParts, input.onProgress);
|
|
212
|
+
result = await runRenderArticleCli(pageParts, input.onProgress);
|
|
236
213
|
} finally {
|
|
237
214
|
if (!keepTempFiles) {
|
|
238
215
|
await rm(tempDir, { recursive: true, force: true });
|
|
@@ -245,20 +222,13 @@ async function renderLongformPages(
|
|
|
245
222
|
throw new Error(`newspic render constraints not satisfied: ${errors.join("; ")}`);
|
|
246
223
|
}
|
|
247
224
|
|
|
248
|
-
//
|
|
249
|
-
const
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
kind: "page",
|
|
256
|
-
route: "wechat-newspic",
|
|
257
|
-
index: i + 1,
|
|
258
|
-
path: join(outputDir, pageFiles[i]),
|
|
259
|
-
});
|
|
260
|
-
}
|
|
261
|
-
|
|
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
|
+
}));
|
|
262
232
|
return { assets, pageCount: result.pageCount, pages: result.pages };
|
|
263
233
|
}
|
|
264
234
|
|
|
@@ -266,7 +236,7 @@ async function renderLongformPages(
|
|
|
266
236
|
|
|
267
237
|
export const builtinImageRenderer: ImageRenderPlugin = {
|
|
268
238
|
name: "builtin-imgx",
|
|
269
|
-
version: "1.
|
|
239
|
+
version: "1.1.0",
|
|
270
240
|
|
|
271
241
|
async doctor(): Promise<PipelinePluginDoctorCheck[]> {
|
|
272
242
|
const checks: PipelinePluginDoctorCheck[] = [];
|
|
@@ -297,20 +267,19 @@ export const builtinImageRenderer: ImageRenderPlugin = {
|
|
|
297
267
|
// Normalize visual params: adapter input uses camelCase, state uses snake_case
|
|
298
268
|
const adapterVp = input.accountVisualParams;
|
|
299
269
|
const stateVp = state.route.account_visual_params;
|
|
300
|
-
const
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
: 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
|
+
};
|
|
308
277
|
|
|
309
278
|
await mkdir(outputDir, { recursive: true });
|
|
310
279
|
|
|
311
280
|
if (route === "wechat-article") {
|
|
312
281
|
// Article: cover only (wechat-cover-split)
|
|
313
|
-
const cover = await renderCover("wechat-cover-split", title, outputDir, vp, highlightWords, input.onProgress);
|
|
282
|
+
const cover = await renderCover("wechat-cover-split", title, outputDir, vp, highlightWords, input.onProgress, input.coverTheme);
|
|
314
283
|
return { assets: [cover], pageCount: 1, pages: [{ page: 1, imageCount: 0, imageSources: [] }] };
|
|
315
284
|
}
|
|
316
285
|
|
|
@@ -319,20 +288,19 @@ export const builtinImageRenderer: ImageRenderPlugin = {
|
|
|
319
288
|
}
|
|
320
289
|
|
|
321
290
|
// wechat-newspic: determine short vs long
|
|
291
|
+
const savedSpec = normalizeNewspicRenderSpec(state.intent.newspic_render);
|
|
322
292
|
const newspicRenderSpec: NewspicRenderSpec = {
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
target_fill_ratio: 0.8,
|
|
329
|
-
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 => ({
|
|
330
298
|
page: ps.page,
|
|
331
299
|
image_markers: ps.imageMarkers ?? [],
|
|
332
300
|
image_layout: ps.imageLayout ?? null,
|
|
333
301
|
target_fill_ratio: ps.targetFillRatio ?? null,
|
|
334
302
|
note: ps.note ?? null,
|
|
335
|
-
})),
|
|
303
|
+
})) ?? savedSpec.page_specs,
|
|
336
304
|
};
|
|
337
305
|
|
|
338
306
|
const isLong = newspicRenderSpec.pagination_mode === "multi";
|
|
@@ -340,13 +308,13 @@ export const builtinImageRenderer: ImageRenderPlugin = {
|
|
|
340
308
|
if (!isLong) {
|
|
341
309
|
// Short: single poster cover
|
|
342
310
|
const coverTitle = generateCoverTitle(title);
|
|
343
|
-
const cover = await renderCover("poster-3-4", coverTitle, outputDir, vp, highlightWords, input.onProgress);
|
|
311
|
+
const cover = await renderCover("poster-3-4", coverTitle, outputDir, vp, highlightWords, input.onProgress, input.coverTheme);
|
|
344
312
|
return { assets: [cover], pageCount: 1, pages: [{ page: 1, imageCount: 0, imageSources: [] }] };
|
|
345
313
|
}
|
|
346
314
|
|
|
347
315
|
// Long: cover + article pages
|
|
348
316
|
const coverTitle = generateCoverTitle(title);
|
|
349
|
-
const cover = await renderCover("poster-3-4", coverTitle, outputDir, vp, highlightWords, input.onProgress);
|
|
317
|
+
const cover = await renderCover("poster-3-4", coverTitle, outputDir, vp, highlightWords, input.onProgress, input.coverTheme);
|
|
350
318
|
|
|
351
319
|
const bodyImages = input.bodyImages ?? [];
|
|
352
320
|
const pageResult = await renderLongformPages(input, outputDir, newspicRenderSpec, bodyImages, vp);
|
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) {
|
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,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
|
+
});
|