@zzclub/pipeline 0.13.2 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/ARTICLE-THEMES.md +86 -0
  2. package/LICENSE +21 -0
  3. package/README.md +182 -788
  4. package/THIRD-PARTY-NOTICES.md +25 -0
  5. package/package.json +6 -2
  6. package/src/adapter-types.ts +3 -0
  7. package/src/adapters/builtin-markdown-renderer.ts +1 -0
  8. package/src/article-theme.test.ts +87 -0
  9. package/src/article-theme.ts +115 -0
  10. package/src/commands/abandon.test.ts +1 -1
  11. package/src/commands/article-theme.ts +16 -0
  12. package/src/commands/checkpoint.test.ts +2 -2
  13. package/src/commands/ingest-handoff.ts +6 -0
  14. package/src/commands/init.ts +10 -0
  15. package/src/commands/prepare.ts +5 -1
  16. package/src/commands/publish.ts +4 -0
  17. package/src/commands/reset.ts +2 -0
  18. package/src/commands/wechat-export.ts +4 -0
  19. package/src/commands/wechat-preview.ts +35 -0
  20. package/src/plugins.ts +2 -0
  21. package/src/providers/index.ts +17 -2
  22. package/src/providers/publish-core.ts +32 -1
  23. package/src/providers/wechat.test.ts +12 -0
  24. package/src/providers/wechat.ts +60 -39
  25. package/src/render-branding.ts +1 -1
  26. package/src/schema/config.ts +12 -0
  27. package/src/schema/state.ts +5 -0
  28. package/src/wechat-preview/assets/browser-dist/.vite/manifest.json +21 -1
  29. package/src/wechat-preview/assets/browser-dist/assets/style-C9-YtTIm.css +1 -0
  30. package/src/wechat-preview/assets/browser-dist/assets/wechat-renderer-D1jHoaM2.js +624 -0
  31. package/src/wechat-preview/assets/browser-dist/editor-export.js +1 -512
  32. package/src/wechat-preview/assets/browser-dist/src/wechat-preview/browser/studio/studio.html +293 -0
  33. package/src/wechat-preview/assets/browser-dist/studio.js +332 -0
  34. package/src/wechat-preview/browser/editor-export.ts +17 -2
  35. package/src/wechat-preview/browser/highlight.ts +2 -0
  36. package/src/wechat-preview/browser/studio/color-palettes.test.ts +31 -0
  37. package/src/wechat-preview/browser/studio/color-palettes.ts +115 -0
  38. package/src/wechat-preview/browser/studio/studio.css +638 -0
  39. package/src/wechat-preview/browser/studio/studio.html +292 -0
  40. package/src/wechat-preview/browser/studio/studio.ts +721 -0
  41. package/src/wechat-preview/browser/studio/syntax-presets.test.ts +74 -0
  42. package/src/wechat-preview/browser/studio/syntax-presets.ts +590 -0
  43. package/src/wechat-preview/fixtures/all-nodes.md +38 -0
  44. package/src/wechat-preview/index.ts +36 -5
  45. package/src/wechat-preview/plugins/builtin-badge.ts +152 -0
  46. package/src/wechat-preview/plugins/builtin-callout.ts +261 -0
  47. package/src/wechat-preview/plugins/builtin-highlight.ts +46 -0
  48. package/src/wechat-preview/plugins/builtin-kbd.ts +133 -0
  49. package/src/wechat-preview/plugins/index.test.ts +313 -0
  50. package/src/wechat-preview/plugins/index.ts +6 -0
  51. package/src/wechat-preview/plugins/registry.ts +62 -0
  52. package/src/wechat-preview/plugins/types.ts +31 -0
  53. package/src/wechat-preview/server/http.ts +160 -3
  54. package/src/wechat-preview/server/paths.ts +2 -2
  55. package/src/wechat-preview/server/singleton.ts +14 -12
  56. package/src/wechat-preview/server/studio.test.ts +92 -0
  57. package/src/wechat-preview/server/ui.ts +1 -0
  58. package/src/wechat-preview/wechat-export.integration.test.ts +9 -1
  59. package/src/wechat-preview/wechat-renderer.ts +72 -7
  60. package/src/workflow.test.ts +1 -0
@@ -0,0 +1,25 @@
1
+ # Bundled article styles
2
+
3
+ The browser renderer includes `@zzclub/milkdown-article-style` (`article.css` and `tokens-default.css`), distributed under the following license. Other dependencies retain their own licenses. Commercial article templates are separate packages and are not covered by this license.
4
+
5
+ MIT License
6
+
7
+ Copyright (c) 2026 aatrooox
8
+
9
+ Permission is hereby granted, free of charge, to any person obtaining a copy
10
+ of this software and associated documentation files (the "Software"), to deal
11
+ in the Software without restriction, including without limitation the rights
12
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
13
+ copies of the Software, and to permit persons to whom the Software is
14
+ furnished to do so, subject to the following conditions:
15
+
16
+ The above copyright notice and this permission notice shall be included in all
17
+ copies or substantial portions of the Software.
18
+
19
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
20
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
21
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
22
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
23
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
24
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
25
+ SOFTWARE.
package/package.json CHANGED
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "name": "@zzclub/pipeline",
3
- "version": "0.13.2",
3
+ "version": "0.16.0",
4
4
  "type": "module",
5
+ "license": "MIT",
5
6
  "bin": {
6
7
  "zzp": "./src/cli.ts",
7
8
  "zzhub-pipeline": "./src/cli.ts"
@@ -10,7 +11,9 @@
10
11
  "access": "public"
11
12
  },
12
13
  "files": [
13
- "src/"
14
+ "src/",
15
+ "ARTICLE-THEMES.md",
16
+ "THIRD-PARTY-NOTICES.md"
14
17
  ],
15
18
  "scripts": {
16
19
  "dev": "bun run src/cli.ts",
@@ -40,6 +43,7 @@
40
43
  "@types/pngjs": "^6.0.5",
41
44
  "changelogen": "^0.6.2",
42
45
  "juice": "11.1.1",
46
+ "remark-highlight-mark": "^1.4.0",
43
47
  "typescript": "^5.7.0",
44
48
  "vite": "^7.1.10"
45
49
  }
@@ -143,6 +143,9 @@ export interface MarkdownRenderInput {
143
143
  /** Path to custom CSS file for style overrides */
144
144
  customCss?: string | null;
145
145
 
146
+ /** 可选的离线正文模板,旧插件可以忽略。 */
147
+ articleThemePath?: string | null;
148
+
146
149
  /** Workflow state for context */
147
150
  state?: WorkflowState;
148
151
 
@@ -52,6 +52,7 @@ export const builtinMarkdownRenderer: MarkdownRenderPlugin = {
52
52
  title: input.title,
53
53
  previewShellOutPath: input.previewShellOutPath,
54
54
  customCss: input.customCss,
55
+ articleThemePath: input.articleThemePath,
55
56
  themeOverrides: input.themeOverrides,
56
57
  timeoutMs: input.timeoutMs,
57
58
  debugDir: input.debugDir ?? undefined,
@@ -0,0 +1,87 @@
1
+ import { afterEach, expect, test } from "bun:test";
2
+ import { mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { readArticleTheme, snapshotArticleTheme } from "./article-theme";
6
+ import { filterIdempotent } from "./providers/publish-core";
7
+
8
+ const roots: string[] = [];
9
+ afterEach(async () => { for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }); });
10
+
11
+ async function fixture() {
12
+ const root = await mkdtemp(join(tmpdir(), "zzp-theme-"));
13
+ roots.push(root);
14
+ const manifest = { schemaVersion: 1, engineVersion: 1, id: "test", name: "样板", version: "1.0.0", assets: ["asset.png"] };
15
+ for (const [name, content] of Object.entries({ "manifest.json": JSON.stringify(manifest), "article.css": "p { color: red; }", "example.md": "==亮点==", "preview.html": "<p>预览</p>", "LICENSE.txt": "test only", "asset.png": "asset-1" })) await writeFile(join(root, name), content);
16
+ return { root, manifest };
17
+ }
18
+
19
+ test("离线包校验、并发安装与样式/素材版本失效", async () => {
20
+ const { root } = await fixture();
21
+ const theme = await readArticleTheme(root);
22
+ const [a, b] = await Promise.all([snapshotArticleTheme(theme, join(root, "installed")), snapshotArticleTheme(theme, join(root, "installed"))]);
23
+ expect(a.path).toBe(b.path);
24
+ expect((await readArticleTheme(a.path)).hash).toBe(theme.hash);
25
+ await writeFile(join(root, "asset.png"), "asset-2");
26
+ expect((await readArticleTheme(root)).hash).not.toBe(theme.hash);
27
+ await writeFile(join(root, "article.css"), "p { color: blue; }");
28
+ const changed = await readArticleTheme(root);
29
+ expect(changed.hash).not.toBe(theme.hash);
30
+ expect(await readFile(join(a.path, "..", "article.css"), "utf8")).toContain("red");
31
+ const target = { route: "wechat-article" as const, account: "default" };
32
+ const result = { ...target, status: "success" as const, content_version: 1, render_version: 1, article_theme_hash: theme.hash };
33
+ expect(filterIdempotent([target], [result as any], 1, 1, new Map([["wechat-article@default", theme.hash]]))).toHaveLength(0);
34
+ expect(filterIdempotent([target], [result as any], 1, 1, new Map([["wechat-article@default", changed.hash]]))).toHaveLength(1);
35
+ expect(filterIdempotent([target], [{ ...result, article_theme_hash: undefined } as any], 1, 1)).toHaveLength(0);
36
+ });
37
+
38
+ test("越界、符号链接、网络样式与不兼容版本不能导入", async () => {
39
+ const { root, manifest } = await fixture();
40
+ for (const patch of [{ assets: ["../outside"] }, { schemaVersion: 2 }, { engineVersion: 2 }, { editorVars: { "--brand": "url(file:///secret)" } }]) {
41
+ await writeFile(join(root, "manifest.json"), JSON.stringify({ ...manifest, ...patch }));
42
+ await expect(readArticleTheme(root)).rejects.toThrow();
43
+ }
44
+ await writeFile(join(root, "manifest.json"), JSON.stringify(manifest));
45
+ await rm(join(root, "asset.png"));
46
+ await symlink(join(root, "LICENSE.txt"), join(root, "asset.png"));
47
+ await expect(readArticleTheme(root)).rejects.toThrow("符号链接");
48
+ await rm(join(root, "asset.png"));
49
+ await writeFile(join(root, "asset.png"), "asset");
50
+ for (const css of ["@import 'https://example.com/css';", String.raw`p { background: u\72l(file:///secret); }`, "p { background: url/**/(file:///secret); }"]) {
51
+ await writeFile(join(root, "article.css"), css);
52
+ await expect(readArticleTheme(root)).rejects.toThrow("外部资源");
53
+ }
54
+ });
55
+
56
+ test("结果未知阻止普通重发,明确重置才建立新发布尝试", async () => {
57
+ const { root } = await fixture();
58
+ const { defaultState, readState, writeState } = await import("./state");
59
+ const { PipelineConfigSchema } = await import("./schema/config");
60
+ const { resolveWorkspacePaths } = await import("./config");
61
+ const { executePublishTargets } = await import("./providers/publish-core");
62
+ const { reset } = await import("./commands/reset");
63
+ const state = defaultState();
64
+ state.run_id = "unknown-result";
65
+ state.workspace_root = root;
66
+ state.state_path = join(root, "state.json");
67
+ state.intent.content_form = "article";
68
+ state.asset_path = root;
69
+ state.publish.results = [{ route: "wechat-article", account: "default", status: "failed", detail: "WX_RESULT_UNKNOWN", published_at: null, content_version: 0, render_version: 0 }];
70
+ const config = PipelineConfigSchema.parse({});
71
+ const result = await executePublishTargets({ state, targets: [{ route: "wechat-article", account: "default" }], dryRun: true, config, workspacePaths: resolveWorkspacePaths(root, config) });
72
+ expect(result.errors[0]?.error).toContain("WX_RESULT_UNKNOWN");
73
+ state.intent.article_theme = join(root, "missing-template");
74
+ const invalidTheme = await executePublishTargets({ state, targets: [{ route: "wechat-article", account: "default" }], dryRun: true, config, workspacePaths: resolveWorkspacePaths(root, config) });
75
+ expect(invalidTheme.errors[0]?.error).toContain("WX_RESULT_UNKNOWN");
76
+ state.intent.article_theme = null;
77
+ await writeState(state.state_path, state);
78
+ await reset(["--state", state.state_path, "--mode", "publish"]);
79
+ const retried = await readState(state.state_path);
80
+ expect(retried.publish.attempt).toBe(1);
81
+ expect(retried.publish.results).toHaveLength(0);
82
+ config.wx.defaultAccount = "main";
83
+ config.wx.accounts = { main: { ...config.wx.accounts.default!, articleTheme: root } };
84
+ const fallback = await executePublishTargets({ state: retried, targets: [{ route: "wechat-article", account: "default" }], dryRun: true, config, workspacePaths: resolveWorkspacePaths(root, config) });
85
+ expect(fallback.errors).toHaveLength(0);
86
+ expect(fallback.results[0]?.article_theme_hash).toBe((await readArticleTheme(root)).hash);
87
+ });
@@ -0,0 +1,115 @@
1
+ import { createHash } from "node:crypto";
2
+ import { lstat, mkdir, mkdtemp, readFile, realpath, rename, rm, writeFile } from "node:fs/promises";
3
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
4
+ import { z } from "zod";
5
+
6
+ /** 模板只包含声明、CSS 与素材,不执行客户包中的脚本。 */
7
+ export const ArticleThemeSchema = z.object({
8
+ schemaVersion: z.literal(1),
9
+ engineVersion: z.literal(1),
10
+ id: z.string().regex(/^[a-z0-9][a-z0-9_-]{0,63}$/),
11
+ name: z.string().min(1).max(80),
12
+ version: z.string().regex(/^\d+\.\d+\.\d+$/),
13
+ description: z.string().max(500).default(""),
14
+ stylesheet: z.string().default("article.css"),
15
+ sample: z.string().default("example.md"),
16
+ preview: z.string().default("preview.html"),
17
+ license: z.string().default("LICENSE.txt"),
18
+ assets: z.array(z.string()).max(100).default([]),
19
+ editorVars: z.record(z.string(), z.string()).default({}),
20
+ exportTheme: z.record(z.string(), z.string()).default({}),
21
+ structure: z.object({
22
+ numberedHeadings: z.boolean().default(false),
23
+ headingLabel: z.string().max(30).default(""),
24
+ quoteLabel: z.string().max(30).default(""),
25
+ headerImage: z.string().nullable().default(null),
26
+ }).default({ numberedHeadings: false, headingLabel: "", quoteLabel: "", headerImage: null }),
27
+ }).strict();
28
+
29
+ export type ArticleThemeManifest = z.infer<typeof ArticleThemeSchema>;
30
+ export interface ArticleThemePackage {
31
+ manifest: ArticleThemeManifest;
32
+ path: string;
33
+ hash: string;
34
+ css: string;
35
+ files: Map<string, Uint8Array>;
36
+ }
37
+
38
+ /** 限制所有资源在包内,拒绝符号链接、目录越界及过大文件。 */
39
+ export async function readArticleTheme(inputPath: string): Promise<ArticleThemePackage> {
40
+ const entry = resolve(inputPath);
41
+ const entryStat = await lstat(entry);
42
+ if (entryStat.isSymbolicLink()) throw new Error("模板入口不能是符号链接");
43
+ const manifestPath = entryStat.isDirectory() ? join(entry, "manifest.json") : entry;
44
+ const root = await realpath(dirname(manifestPath));
45
+ const files = new Map<string, Uint8Array>();
46
+ let total = 0;
47
+ async function read(name: string): Promise<Uint8Array> {
48
+ if (!name || isAbsolute(name) || name.split(/[\\/]/).includes("..") || name.includes("\\")) throw new Error("模板资源必须位于包内");
49
+ const target = resolve(root, name);
50
+ const rel = relative(root, target);
51
+ if (!rel || rel.startsWith(`..${sep}`) || isAbsolute(rel)) throw new Error("模板资源路径越界");
52
+ let current = root;
53
+ for (const part of rel.split(sep)) {
54
+ current = join(current, part);
55
+ if ((await lstat(current)).isSymbolicLink()) throw new Error("模板资源不能是符号链接");
56
+ }
57
+ const stat = await lstat(target);
58
+ if (!stat.isFile() || stat.size > 5 * 1024 * 1024) throw new Error("模板单个资源不能超过 5 MiB");
59
+ const bytes = await readFile(target);
60
+ total += bytes.byteLength;
61
+ if (total > 20 * 1024 * 1024) throw new Error("模板资源合计不能超过 20 MiB");
62
+ files.set(name, bytes);
63
+ return bytes;
64
+ }
65
+ const manifestName = basename(manifestPath);
66
+ const manifest = ArticleThemeSchema.parse(JSON.parse(new TextDecoder().decode(await read(manifestName))));
67
+ const paths = [manifest.stylesheet, manifest.sample, manifest.preview, manifest.license, ...manifest.assets];
68
+ if (manifest.structure.headerImage) paths.push(manifest.structure.headerImage);
69
+ for (const name of new Set(paths)) await read(name);
70
+ const css = new TextDecoder().decode(files.get(manifest.stylesheet));
71
+ // 模板无需 CSS 转义;拒绝用转义或注释隐藏的网络/文件资源。
72
+ const unsafeCss = (value: string) => /\\|@import\b|url\s*\(|expression\s*\(/i.test(value.replace(/\/\*[\s\S]*?\*\//g, ""));
73
+ if (Object.keys(manifest.editorVars).some(key => !/^--[a-zA-Z0-9_-]+$/.test(key))) throw new Error("模板变量名无效");
74
+ for (const value of [...Object.values(manifest.editorVars), ...Object.values(manifest.exportTheme)]) {
75
+ if (value.length > 8192 || unsafeCss(value)) throw new Error("模板变量不能包含外部资源或 CSS 转义");
76
+ }
77
+ if (manifest.structure.headerImage && !/\.(png|jpe?g|gif|webp)$/i.test(manifest.structure.headerImage)) throw new Error("模板页首素材仅支持 PNG、JPEG、GIF、WebP");
78
+ if (css.length > 256_000 || unsafeCss(css)) throw new Error("模板 CSS 不支持外部资源、CSS 转义或可执行表达式");
79
+ const digest = createHash("sha256").update(JSON.stringify(manifest)).update(css);
80
+ for (const name of [...new Set([...manifest.assets, ...(manifest.structure.headerImage ? [manifest.structure.headerImage] : [])])].sort()) {
81
+ digest.update(name).update(files.get(name)!);
82
+ }
83
+ return { manifest, path: join(root, manifestName), css, files, hash: digest.digest("hex") };
84
+ }
85
+
86
+ /** 内容寻址的只读副本,运行中修改原模板不会改变当前发布结果。 */
87
+ export async function snapshotArticleTheme(theme: ArticleThemePackage, destination: string): Promise<ArticleThemePackage> {
88
+ await mkdir(destination, { recursive: true });
89
+ const root = join(await realpath(destination), `${theme.manifest.id}-${theme.hash}`);
90
+ try {
91
+ const existing = await readArticleTheme(root);
92
+ if (existing.hash !== theme.hash) throw new Error("已安装的模板被修改,请重新导入到新的目录");
93
+ return existing;
94
+ } catch (error) {
95
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
96
+ }
97
+ const temporary = await mkdtemp(join(dirname(root), ".import-"));
98
+ try {
99
+ for (const [name, bytes] of theme.files) {
100
+ const target = join(temporary, name);
101
+ await mkdir(dirname(target), { recursive: true });
102
+ await writeFile(target, bytes);
103
+ }
104
+ await writeFile(join(temporary, "manifest.json"), JSON.stringify(theme.manifest, null, 2));
105
+ try { await rename(temporary, root); }
106
+ catch (error) {
107
+ if (!["EEXIST", "ENOTEMPTY"].includes((error as NodeJS.ErrnoException).code || "")) throw error;
108
+ if ((await readArticleTheme(root)).hash !== theme.hash) throw new Error("已安装的模板内容不一致");
109
+ }
110
+ } finally {
111
+ await rm(temporary, { recursive: true, force: true });
112
+ }
113
+ const entry = join(root, "manifest.json");
114
+ return { ...theme, path: entry };
115
+ }
@@ -13,7 +13,7 @@ async function makeTempDir(prefix: string): Promise<string> {
13
13
  return mkdtemp(join(tmpdir(), prefix));
14
14
  }
15
15
 
16
- async function captureJsonOutput<T>(fn: () => Promise<void>): Promise<T> {
16
+ async function captureJsonOutput<T>(fn: () => Promise<unknown>): Promise<T> {
17
17
  const original = console.log;
18
18
  const lines: string[] = [];
19
19
  console.log = (...args: unknown[]) => {
@@ -0,0 +1,16 @@
1
+ import { parseArgs, optionalArg, requireArg } from "../args";
2
+ import { readArticleTheme, snapshotArticleTheme } from "../article-theme";
3
+ import { printResult } from "../output";
4
+
5
+ /** 校验或安装离线模板,供 CLI 和桌面端共用。 */
6
+ export async function articleTheme(args: string[]): Promise<void> {
7
+ const parsed = parseArgs(args);
8
+ if (parsed.help) {
9
+ console.log("Usage: zzp article-theme --path <模板目录或 manifest.json> [--install <本地模板库>]\n只校验或复制模板,不联网、不发布。");
10
+ return;
11
+ }
12
+ let theme = await readArticleTheme(requireArg(parsed, "path", "template path"));
13
+ const destination = optionalArg(parsed, "install");
14
+ if (destination) theme = await snapshotArticleTheme(theme, destination);
15
+ printResult({ ...theme.manifest, path: theme.path, hash: theme.hash });
16
+ }
@@ -10,7 +10,7 @@ async function makeTempDir(prefix: string): Promise<string> {
10
10
  return mkdtemp(join(tmpdir(), prefix));
11
11
  }
12
12
 
13
- async function captureJsonOutput<T>(fn: () => Promise<void>): Promise<T> {
13
+ async function captureJsonOutput<T>(fn: () => Promise<unknown>): Promise<T> {
14
14
  const original = console.log;
15
15
  const lines: string[] = [];
16
16
  console.log = (...args: unknown[]) => {
@@ -24,7 +24,7 @@ async function captureJsonOutput<T>(fn: () => Promise<void>): Promise<T> {
24
24
  return JSON.parse(lines.join("\n").trim()) as T;
25
25
  }
26
26
 
27
- async function captureTextOutput(fn: () => Promise<void>): Promise<string> {
27
+ async function captureTextOutput(fn: () => Promise<unknown>): Promise<string> {
28
28
  const original = console.log;
29
29
  const lines: string[] = [];
30
30
  console.log = (...args: unknown[]) => {
@@ -26,6 +26,7 @@ import { findTask, getTaskByStatePath } from "../task-manager";
26
26
 
27
27
  interface PublishHandoffInput {
28
28
  cover_theme?: string | null;
29
+ article_theme?: string | null;
29
30
  content_form: ContentForm;
30
31
  body_path: string;
31
32
  target_account: string;
@@ -38,6 +39,7 @@ type WorkflowHandoffMode = "new" | "resume";
38
39
 
39
40
  interface WorkflowHandoffInput {
40
41
  cover_theme?: string | null;
42
+ article_theme?: string | null;
41
43
  mode?: WorkflowHandoffMode;
42
44
  state_path?: string;
43
45
  run_id?: string;
@@ -55,6 +57,7 @@ interface WorkflowHandoffInput {
55
57
 
56
58
  interface ResolvedWorkflowHandoff {
57
59
  cover_theme?: string | null;
60
+ article_theme?: string | null;
58
61
  source: "publish_handoff" | "workflow_handoff";
59
62
  mode: WorkflowHandoffMode;
60
63
  state_path?: string;
@@ -167,6 +170,7 @@ function parsePublishHandoff(raw: unknown): ResolvedWorkflowHandoff {
167
170
 
168
171
  return {
169
172
  source: "publish_handoff",
173
+ article_theme: input.article_theme === null ? null : cleanString(input.article_theme, "article_theme"),
170
174
  cover_theme: input.cover_theme === null ? null : cleanString(input.cover_theme, "publish_handoff.cover_theme"),
171
175
  mode: "new",
172
176
  content_form: contentForm,
@@ -199,6 +203,7 @@ function parseWorkflowHandoff(raw: unknown): ResolvedWorkflowHandoff {
199
203
  : "new");
200
204
  const handoff: ResolvedWorkflowHandoff = {
201
205
  source: "workflow_handoff",
206
+ article_theme: input.article_theme === null ? null : cleanString(input.article_theme, "article_theme"),
202
207
  cover_theme: input.cover_theme === null ? null : cleanString(input.cover_theme, "workflow_handoff.cover_theme"),
203
208
  mode,
204
209
  state_path: cleanString(input.state_path, "workflow_handoff.state_path"),
@@ -326,6 +331,7 @@ async function applyHandoffToState(
326
331
  ): Promise<void> {
327
332
  const targets: Target[] = state.intent.targets.length > 0 ? state.intent.targets : ["wechat"];
328
333
  let restartFromPrepare = false;
334
+ if (handoff.article_theme !== undefined) state.intent.article_theme = handoff.article_theme ? resolve(handoff.article_theme) : null;
329
335
  const themeChanged = handoff.cover_theme !== undefined && handoff.cover_theme !== state.intent.cover_theme;
330
336
  if (themeChanged) {
331
337
  resolveCoverTheme(loadConfig().render.cover, "poster-3-4", handoff.target_account ?? state.route.account, handoff.cover_theme);
@@ -24,6 +24,8 @@
24
24
  */
25
25
 
26
26
  import { readFile } from "fs/promises";
27
+ import { resolve } from "node:path";
28
+ import { z } from "zod";
27
29
  import { parseArgs, requireArg, optionalArg, flagArg } from "../args";
28
30
  import { printResult, renderInit } from "../output";
29
31
  import { loadConfig, resolveWorkspaceRoot } from "../config";
@@ -66,6 +68,7 @@ Options:
66
68
  --requires-style Flag
67
69
  --requires-render Flag
68
70
  --requires-publish Flag
71
+ --newspic-file Confirmed newspic text and photos JSON file (optional)
69
72
  --existing-draft-media-id Update existing WeChat draft instead of creating new one (optional)
70
73
  `.trim());
71
74
  return;
@@ -83,6 +86,12 @@ Options:
83
86
  const existingDraftMediaId = optionalArg(parsed, "existing-draft-media-id") ?? null;
84
87
  const noteId = optionalArg(parsed, "note-id") ?? null;
85
88
  const config = loadConfig();
89
+ const newspicFile = optionalArg(parsed, "newspic-file");
90
+ if (newspicFile) {
91
+ if (contentForm !== "newspic") throw new Error("newspic-file requires content form newspic");
92
+ z.object({ content: z.string().trim().min(1).max(100_000), photos: z.array(z.string().min(1)).max(20) }).strict()
93
+ .parse(JSON.parse(await readFile(newspicFile, "utf8")));
94
+ }
86
95
  const workspace = resolveWorkspaceRoot(optionalArg(parsed, "workspace"), config);
87
96
  if (coverTheme !== null) resolveCoverTheme(config.render.cover, "poster-3-4", accountOverride, coverTheme);
88
97
 
@@ -132,6 +141,7 @@ Options:
132
141
  style_hint: styleHint,
133
142
  cover_theme: coverTheme,
134
143
  newspic_render: newspicRender,
144
+ ...(newspicFile ? { newspic_file: resolve(newspicFile) } : {}),
135
145
  requires: {
136
146
  research: flagArg(parsed, "requires-research"),
137
147
  style: flagArg(parsed, "requires-style"),
@@ -197,7 +197,11 @@ Options:
197
197
  // requires.research is intentional (set at init from the user request) and is
198
198
  // intentionally not recomputed here.
199
199
  const allRoutes = [route.primary, ...route.extras];
200
- state.intent.requires.render = allRoutes.some((r) => r !== "blog");
200
+ // 只有用户明确上传的配图才跳过生成;空配图继续默认渲染流程。
201
+ const hasUploadedPhotos = state.intent.newspic_file
202
+ ? JSON.parse(await readFile(state.intent.newspic_file, "utf8")).photos.length > 0
203
+ : false;
204
+ state.intent.requires.render = allRoutes.some((r) => r !== "blog" && !(r === "wechat-newspic" && hasUploadedPhotos));
201
205
  state.intent.requires.publish = state.intent.task_kind === "publish";
202
206
 
203
207
  // ── Step 2: Author select ──
@@ -13,6 +13,7 @@
13
13
  * Output: Updated state with publish.results
14
14
  */
15
15
 
16
+ import { resolve } from "node:path";
16
17
  import { parseArgs, requireArg, optionalArg, flagArg } from "../args";
17
18
  import { printResult, renderPublish } from "../output";
18
19
  import { loadConfig, resolveWorkspacePaths } from "../config";
@@ -46,6 +47,7 @@ Usage: zzhub-pipeline publish [options]
46
47
  Options:
47
48
  --state Path to state JSON (required)
48
49
  --route Only publish this route (optional; default: all targets)
50
+ --article-theme Local article theme package (optional)
49
51
  --dry-run Print commands without executing (optional)
50
52
  `.trim());
51
53
  return;
@@ -65,6 +67,8 @@ Options:
65
67
  const statePath = resolved.path;
66
68
  const state = resolved.state;
67
69
  const config = loadConfig();
70
+ const articleTheme = optionalArg(parsed, "article-theme");
71
+ if (articleTheme) state.intent.article_theme = resolve(articleTheme);
68
72
  const workspacePaths = resolveWorkspacePaths(state.workspace_root, config);
69
73
 
70
74
  if (!state.asset_path) {
@@ -121,6 +121,8 @@ Modes:
121
121
 
122
122
  case "publish":
123
123
  reenterPublish(state);
124
+ // 用户核对草稿后明确重置,才生成新的请求幂等身份。
125
+ state.publish.attempt = (state.publish.attempt ?? 0) + 1;
124
126
  state.publish.results = state.publish.results.filter(
125
127
  (result) =>
126
128
  result.content_version !== state.artifacts.content_version ||
@@ -69,6 +69,8 @@ Options:
69
69
  --account Account/theme key (optional; default: default)
70
70
  --title Page title for the render shell (optional)
71
71
  --preview-shell-out Path to exact final-HTML preview (optional)
72
+ --article-theme Local article theme package (optional)
73
+ --summary-only Omit HTML from stdout; still write the complete output file
72
74
  --custom-css CSS override path; replaces account customCss (optional)
73
75
  --timeout-ms Real browser wait limit in ms (default: 15000)
74
76
  --debug-dir Write intermediate artifacts for debugging (optional)
@@ -123,6 +125,7 @@ ${CSS_DEMO}
123
125
  title,
124
126
  previewShellOutPath,
125
127
  customCss,
128
+ articleThemePath: optionalArg(parsed, "article-theme") ? resolve(optionalArg(parsed, "article-theme")!) : resolveConfigRelativePath(wxAccount?.articleTheme),
126
129
  themeOverrides: wxAccount?.theme,
127
130
  timeoutMs: timeoutMs !== undefined && Number.isFinite(timeoutMs) ? timeoutMs : undefined,
128
131
  debugDir: debugDir ? resolve(process.cwd(), debugDir) : undefined,
@@ -166,6 +169,7 @@ ${CSS_DEMO}
166
169
  printResult(
167
170
  {
168
171
  ...resultRest,
172
+ ...(flagArg(parsed, "summary-only") ? { html: undefined } : {}),
169
173
  preview_url,
170
174
  preview_dashboard_url,
171
175
  preview_register_error,
@@ -31,6 +31,7 @@ Usage: zzhub-pipeline wechat-preview <subcommand> [options]
31
31
 
32
32
  Subcommands:
33
33
  serve Start the singleton local preview server (or print existing URL)
34
+ studio Start server and open WeChat Visual Studio in browser
34
35
  status Show whether the preview server is running
35
36
  open Open the dashboard in a browser
36
37
  list List registered preview entries
@@ -95,6 +96,40 @@ export async function wechatPreview(args: string[]): Promise<void> {
95
96
  return;
96
97
  }
97
98
 
99
+ if (sub === "studio") {
100
+ const host = optionalArg(parsed, "host");
101
+ const portRaw = optionalArg(parsed, "port");
102
+ const port = portRaw ? Number(portRaw) : undefined;
103
+ const force = flagArg(parsed, "force");
104
+
105
+ const result = await startPreviewServer({
106
+ host,
107
+ port: port !== undefined && Number.isFinite(port) ? port : undefined,
108
+ reuseExisting: !force,
109
+ });
110
+
111
+ const studioUrl = `${result.url}/studio`;
112
+ if (!flagArg(parsed, "no-open")) {
113
+ await openUrl(studioUrl);
114
+ }
115
+
116
+ printResult({
117
+ action: "studio",
118
+ url: studioUrl,
119
+ host: result.host,
120
+ port: result.port,
121
+ reused: result.reused,
122
+ message: `WeChat Visual Studio opened at ${studioUrl}`,
123
+ });
124
+
125
+ if (!result.reused) {
126
+ await new Promise<void>(() => {
127
+ // blocked until SIGINT/SIGTERM
128
+ });
129
+ }
130
+ return;
131
+ }
132
+
98
133
  if (sub === "status") {
99
134
  const host = optionalArg(parsed, "host");
100
135
  const portRaw = optionalArg(parsed, "port");
package/src/plugins.ts CHANGED
@@ -22,6 +22,7 @@ import { review } from "./commands/review";
22
22
  import { status } from "./commands/status";
23
23
  import { syncBlog } from "./commands/sync-blog";
24
24
  import { tasks } from "./commands/tasks";
25
+ import { articleTheme } from "./commands/article-theme";
25
26
  import { wechatExport } from "./commands/wechat-export";
26
27
  import { wechatPreview } from "./commands/wechat-preview";
27
28
  import { wxDrafts } from "./commands/wx-drafts";
@@ -72,6 +73,7 @@ export function getCommandPlugins(): CommandPlugin[] {
72
73
  { name: "sync-blog", summary: "Copy post.md to blog repo, publish, and record result in state JSON", plugin: "ops", handler: syncBlog },
73
74
  { name: "republish", summary: "Publish completed task to additional accounts/platforms", plugin: "ops", handler: republish },
74
75
  { name: "imgx", summary: "Run bundled imgx renderer subcommands", plugin: "ops", handler: imgxCommand },
76
+ { name: "article-theme", summary: "Inspect or install a local article theme", plugin: "ops", handler: articleTheme },
75
77
  { name: "wechat-export", summary: "Render markdown to WeChat HTML with bundled preview styles", plugin: "ops", handler: wechatExport },
76
78
  { name: "wechat-preview", summary: "Local singleton server to preview exported WeChat HTML", plugin: "ops", handler: wechatPreview },
77
79
  { name: "cos-upload", summary: "Upload a local image to configured COS CDN", plugin: "ops", handler: cosUpload },
@@ -1,3 +1,5 @@
1
+ import type { ArticleThemePackage } from "../article-theme";
2
+ import { createHash } from "node:crypto";
1
3
  import { readFile, writeFile } from "fs/promises";
2
4
  import { join } from "path";
3
5
  import {
@@ -21,6 +23,7 @@ export interface PublishRouteContext {
21
23
  config: PipelineConfig;
22
24
  workspacePaths: ResolvedWorkspacePaths;
23
25
  accountOverride?: string;
26
+ articleTheme?: ArticleThemePackage;
24
27
  }
25
28
 
26
29
  export type PublishProvider = (ctx: PublishRouteContext) => Promise<PublishResult>;
@@ -31,6 +34,7 @@ async function publishWechatArticleRoute({
31
34
  config,
32
35
  workspacePaths,
33
36
  accountOverride,
37
+ articleTheme,
34
38
  }: PublishRouteContext): Promise<PublishResult> {
35
39
  const postPath = join(state.asset_path, "post.md");
36
40
  let exportPostPath = postPath;
@@ -87,6 +91,7 @@ async function publishWechatArticleRoute({
87
91
  account,
88
92
  title: state.metadata.title,
89
93
  customCss: resolveConfigRelativePath(wxAccount?.customCss),
94
+ articleThemePath: articleTheme?.path,
90
95
  themeOverrides: wxAccount?.theme,
91
96
  });
92
97
  const { html } = rendered;
@@ -124,6 +129,7 @@ async function publishWechatArticleRoute({
124
129
  title: state.metadata.title,
125
130
  html,
126
131
  photos,
132
+ idempotencyKey: createHash("sha256").update(JSON.stringify([state.run_id, state.publish.attempt ?? 0, account, state.artifacts.content_version, state.artifacts.render_version, articleTheme?.hash, state.intent.existing_draft_media_id])).digest("hex"),
127
133
  config,
128
134
  existingDraftMediaId: state.intent.existing_draft_media_id,
129
135
  noteId: state.intent.note_id,
@@ -162,8 +168,15 @@ async function publishWechatNewspicRoute({
162
168
  }: PublishRouteContext): Promise<PublishResult> {
163
169
  const postPath = join(state.asset_path, "post.md");
164
170
  const postContent = await readFile(postPath, "utf-8");
171
+ // 副本正文原样发送,手动配图优先,空配图使用生成的封面和内容页。
172
+ const snapshot = state.intent.newspic_file
173
+ ? JSON.parse(await readFile(state.intent.newspic_file, "utf8")) as { content: string; photos: string[] }
174
+ : null;
165
175
  const bodyImageUrls = extractImageUrls(postContent);
166
- const cleanContent = prepareBodyForNewspic(postContent);
176
+ let cleanContent = snapshot?.content ?? prepareBodyForNewspic(postContent);
177
+ // 旧入口没有独立配文时使用短摘录,全文已保存在内容图中。
178
+ if (!snapshot && Buffer.byteLength(cleanContent, "utf8") > 2048)
179
+ cleanContent = `${Array.from(cleanContent).slice(0, 500).join("")}…`;
167
180
  const cleanPath = join(state.asset_path, "post-clean.md");
168
181
  if (!dryRun) {
169
182
  await writeFile(cleanPath, cleanContent, "utf-8");
@@ -178,7 +191,7 @@ async function publishWechatNewspicRoute({
178
191
  });
179
192
 
180
193
  const renderPhotos = assets.map((asset) => asset.path);
181
- const photos = mergePhotoLists(renderPhotos, bodyImageUrls);
194
+ const photos = snapshot ? (snapshot.photos.length ? snapshot.photos : renderPhotos) : mergePhotoLists(renderPhotos, bodyImageUrls);
182
195
 
183
196
  const account = accountOverride || state.route.account;
184
197
 
@@ -198,10 +211,12 @@ async function publishWechatNewspicRoute({
198
211
 
199
212
  try {
200
213
  const response = await createWechatNewspic({
214
+ idempotencyKey: createHash("sha256").update(JSON.stringify([state.run_id, state.publish.attempt ?? 0, account, "newspic", state.artifacts.content_version, state.artifacts.render_version, state.intent.existing_draft_media_id])).digest("hex"),
201
215
  account,
202
216
  title: state.metadata.title,
203
217
  content: cleanContent,
204
218
  photos,
219
+ explicitPhotos: !!snapshot,
205
220
  config,
206
221
  existingDraftMediaId: state.intent.existing_draft_media_id,
207
222
  noteId: state.intent.note_id,