@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.
- package/ARTICLE-THEMES.md +86 -0
- package/LICENSE +21 -0
- package/README.md +182 -788
- package/THIRD-PARTY-NOTICES.md +25 -0
- package/package.json +6 -2
- package/src/adapter-types.ts +3 -0
- package/src/adapters/builtin-markdown-renderer.ts +1 -0
- package/src/article-theme.test.ts +87 -0
- package/src/article-theme.ts +115 -0
- package/src/commands/abandon.test.ts +1 -1
- package/src/commands/article-theme.ts +16 -0
- package/src/commands/checkpoint.test.ts +2 -2
- package/src/commands/ingest-handoff.ts +6 -0
- package/src/commands/init.ts +10 -0
- package/src/commands/prepare.ts +5 -1
- package/src/commands/publish.ts +4 -0
- package/src/commands/reset.ts +2 -0
- package/src/commands/wechat-export.ts +4 -0
- package/src/commands/wechat-preview.ts +35 -0
- package/src/plugins.ts +2 -0
- package/src/providers/index.ts +17 -2
- package/src/providers/publish-core.ts +32 -1
- package/src/providers/wechat.test.ts +12 -0
- package/src/providers/wechat.ts +60 -39
- package/src/render-branding.ts +1 -1
- package/src/schema/config.ts +12 -0
- package/src/schema/state.ts +5 -0
- package/src/wechat-preview/assets/browser-dist/.vite/manifest.json +21 -1
- package/src/wechat-preview/assets/browser-dist/assets/style-C9-YtTIm.css +1 -0
- package/src/wechat-preview/assets/browser-dist/assets/wechat-renderer-D1jHoaM2.js +624 -0
- package/src/wechat-preview/assets/browser-dist/editor-export.js +1 -512
- package/src/wechat-preview/assets/browser-dist/src/wechat-preview/browser/studio/studio.html +293 -0
- package/src/wechat-preview/assets/browser-dist/studio.js +332 -0
- package/src/wechat-preview/browser/editor-export.ts +17 -2
- package/src/wechat-preview/browser/highlight.ts +2 -0
- package/src/wechat-preview/browser/studio/color-palettes.test.ts +31 -0
- package/src/wechat-preview/browser/studio/color-palettes.ts +115 -0
- package/src/wechat-preview/browser/studio/studio.css +638 -0
- package/src/wechat-preview/browser/studio/studio.html +292 -0
- package/src/wechat-preview/browser/studio/studio.ts +721 -0
- package/src/wechat-preview/browser/studio/syntax-presets.test.ts +74 -0
- package/src/wechat-preview/browser/studio/syntax-presets.ts +590 -0
- package/src/wechat-preview/fixtures/all-nodes.md +38 -0
- package/src/wechat-preview/index.ts +36 -5
- package/src/wechat-preview/plugins/builtin-badge.ts +152 -0
- package/src/wechat-preview/plugins/builtin-callout.ts +261 -0
- package/src/wechat-preview/plugins/builtin-highlight.ts +46 -0
- package/src/wechat-preview/plugins/builtin-kbd.ts +133 -0
- package/src/wechat-preview/plugins/index.test.ts +313 -0
- package/src/wechat-preview/plugins/index.ts +6 -0
- package/src/wechat-preview/plugins/registry.ts +62 -0
- package/src/wechat-preview/plugins/types.ts +31 -0
- package/src/wechat-preview/server/http.ts +160 -3
- package/src/wechat-preview/server/paths.ts +2 -2
- package/src/wechat-preview/server/singleton.ts +14 -12
- package/src/wechat-preview/server/studio.test.ts +92 -0
- package/src/wechat-preview/server/ui.ts +1 -0
- package/src/wechat-preview/wechat-export.integration.test.ts +9 -1
- package/src/wechat-preview/wechat-renderer.ts +72 -7
- 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.
|
|
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
|
}
|
package/src/adapter-types.ts
CHANGED
|
@@ -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<
|
|
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<
|
|
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<
|
|
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);
|
package/src/commands/init.ts
CHANGED
|
@@ -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"),
|
package/src/commands/prepare.ts
CHANGED
|
@@ -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
|
-
|
|
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 ──
|
package/src/commands/publish.ts
CHANGED
|
@@ -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) {
|
package/src/commands/reset.ts
CHANGED
|
@@ -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 },
|
package/src/providers/index.ts
CHANGED
|
@@ -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
|
-
|
|
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,
|