@akairos/pipeline 0.21.0 → 0.22.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.
@@ -0,0 +1,2 @@
1
+ export { createArticleSnapshot, validateArticleSnapshot, createArtifactManifest, hashArtifactManifest, validateArtifactManifest, rewriteArticleImages } from "./src/article-snapshot";
2
+ export type { ArticleSnapshot, ArticleSnapshotInput, ArticleSnapshotContent, BodyAssetIdentity, ArtifactManifest } from "./src/article-snapshot";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akairos/pipeline",
3
- "version": "0.21.0",
3
+ "version": "0.22.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -14,6 +14,7 @@
14
14
  "src/",
15
15
  "article-style.ts",
16
16
  "cover-snapshot.ts",
17
+ "article-snapshot.ts",
17
18
  "ARTICLE-THEMES.md",
18
19
  "THIRD-PARTY-NOTICES.md"
19
20
  ],
@@ -30,11 +31,12 @@
30
31
  },
31
32
  "dependencies": {
32
33
  "@akairos/milkdown-article-plugins": "^0.1.6",
33
- "@akairos/myno": "0.3.2",
34
- "@akairos/typeset-studio": "0.3.0",
34
+ "@akairos/myno": "0.4.1",
35
+ "@akairos/typeset-studio": "0.4.0",
35
36
  "@chenglou/pretext": "0.0.8",
36
37
  "image-size": "^2.0.2",
37
38
  "pngjs": "^7.0.0",
39
+ "remark": "^15.0.1",
38
40
  "zod": "^4.4.2"
39
41
  },
40
42
  "optionalDependencies": {
@@ -0,0 +1,39 @@
1
+ import { expect, test } from "bun:test";
2
+ import { createArticleSnapshot, validateArticleSnapshot, rewriteArticleImages, createArtifactManifest, hashArtifactManifest, validateArtifactManifest } from "../article-snapshot";
3
+
4
+ const asset = { id: "31161ca7-af98-4a84-adc7-c60885a3bd0f", sha256: "a".repeat(64), contentType: "image/png" as const, byteLength: 100, width: 10, height: 10 };
5
+ const input = { documentId: "521b9c37-1d74-4f23-a0ad-1801ad2f3669", documentRevision: 1, title: "预览", account: "fixed-account", body: `![描述](asset:${asset.id} "图注")`, bodyAssets: [asset] };
6
+
7
+ test("完整文章输入 hash 绑定修订、正文/账号及素材,拒绝未托管/过量图片", () => {
8
+ const snapshot = createArticleSnapshot(input);
9
+ expect(validateArticleSnapshot(snapshot, input)).toEqual(snapshot);
10
+ expect(() => validateArticleSnapshot(snapshot, { ...input, title: "变化" })).toThrow("input hash");
11
+ expect(createArticleSnapshot({ ...input, documentRevision: 2 }).inputHash).not.toBe(snapshot.inputHash);
12
+ for (const body of ["![外图](https://example.test/a.png)", "<img src='asset:bad'>", Array(21).fill(input.body).join("\n")]) expect(() => createArticleSnapshot({ ...input, body })).toThrow();
13
+ expect(() => createArticleSnapshot({ ...input, bodyAssets: [asset, asset] })).toThrow("Duplicate");
14
+ expect(() => validateArticleSnapshot({ ...snapshot, schemaVersion: 2 }, input)).toThrow();
15
+ });
16
+
17
+ test("图片引用转换保定义图注、描述与代码/普通链接/共享definition", () => {
18
+ const source = '[普通][shared]\n\n![描述][shared]\n\n[shared]: /api/assets/body/image "定义图注"\n\n```md\n![代码](/api/assets/body/image)\n```';
19
+ const rewritten = rewriteArticleImages(source, () => `asset:${asset.id}`);
20
+ expect(rewritten).toContain(`![描述](asset:${asset.id} "定义图注")`);
21
+ expect(rewritten).toContain('[shared]: /api/assets/body/image "定义图注"');
22
+ expect(rewritten).toContain('[普通][shared]');
23
+ expect(rewritten).toContain('![代码](/api/assets/body/image)');
24
+ const brackets = rewriteArticleImages('![左\\[右\\]尾][shared]\n\n[shared]: /image "图注"', () => `asset:${asset.id}`);
25
+ expect(brackets).toContain(`![左\\[右\\]尾](asset:${asset.id} "图注")`);
26
+ expect(createArticleSnapshot({ ...input, body: brackets }).inputHash).toBeTruthy();
27
+ });
28
+
29
+ test("产物 manifest 严格固定key/type和双hash身份", () => {
30
+ const manifest = createArtifactManifest({ snapshotHash: "b".repeat(64), runtime: { pipeline: "0.22.0", myno: "0.4.1", typeset: "0.4.0" }, artifacts: [
31
+ { key: "cover", kind: "cover", contentType: "image/png", byteLength: 1, sha256: "c".repeat(64) },
32
+ { key: "article", kind: "html", contentType: "text/html", byteLength: 2, sha256: "d".repeat(64) },
33
+ { key: `media_${asset.id}`, kind: "media", contentType: "image/png", byteLength: 100, sha256: asset.sha256 },
34
+ ] });
35
+ expect(validateArtifactManifest(manifest, hashArtifactManifest(manifest))).toEqual(manifest);
36
+ expect(() => validateArtifactManifest(manifest, "0".repeat(64))).toThrow();
37
+ expect(() => validateArtifactManifest({ ...manifest, path: "/private/html" })).toThrow();
38
+ expect(() => createArtifactManifest({ ...manifest, artifacts: [...manifest.artifacts, manifest.artifacts[0]!] })).toThrow();
39
+ });
@@ -0,0 +1,121 @@
1
+ import { createHash } from "node:crypto";
2
+ import { remark } from "remark";
3
+ import { z } from "zod";
4
+ import { validateArticleStyleSnapshot, type ArticleStyleSnapshot } from "./article-style";
5
+ import { validateCoverSnapshot, type CoverSnapshot } from "./cover-snapshot";
6
+
7
+ const hash = z.string().regex(/^[a-f\d]{64}$/);
8
+ const uuid = z.string().uuid().transform(value => value.toLowerCase());
9
+ export const BodyAssetIdentitySchema = z.object({
10
+ id: uuid, sha256: hash, contentType: z.enum(["image/png", "image/jpeg", "image/webp"]),
11
+ byteLength: z.number().int().positive().max(5 * 1024 * 1024),
12
+ width: z.number().int().positive(), height: z.number().int().positive(),
13
+ }).strict().refine(value => value.width * value.height <= 32_000_000, "Body image exceeds 32 megapixels");
14
+ export type BodyAssetIdentity = z.infer<typeof BodyAssetIdentitySchema>;
15
+
16
+ export interface ArticleSnapshotContent {
17
+ title: string; body: string; account: string;
18
+ articleStyle?: ArticleStyleSnapshot; coverSnapshot?: CoverSnapshot;
19
+ }
20
+ export interface ArticleSnapshotInput extends ArticleSnapshotContent {
21
+ documentId: string; documentRevision: number; bodyAssets: BodyAssetIdentity[];
22
+ }
23
+ const ArticleSnapshotSchema = z.object({
24
+ schemaVersion: z.literal(1), documentId: uuid, documentRevision: z.number().int().positive(),
25
+ bodyAssets: z.array(BodyAssetIdentitySchema).max(20), inputHash: hash,
26
+ }).strict();
27
+ export type ArticleSnapshot = z.infer<typeof ArticleSnapshotSchema>;
28
+
29
+ interface ImageNode { type: string; url?: string; identifier?: string; alt?: string; title?: string | null; value?: string; position?: { start: { offset?: number }; end: { offset?: number } }; children?: ImageNode[] }
30
+
31
+ /** 只遍历 Markdown 图片节点,代码和普通链接不参与资产绑定。 */
32
+ export function rewriteArticleImages(body: string, resolveImage: (source: string) => string): string {
33
+ const tree = remark().parse(body) as ImageNode;
34
+ const definitions = new Map<string, ImageNode>();
35
+ const collect = (node: ImageNode) => { if (node.type === "definition") definitions.set(node.identifier!, node); node.children?.forEach(collect); };
36
+ collect(tree);
37
+ const edits: Array<{ start: number; end: number; text: string }> = [];
38
+ let count = 0;
39
+ const visit = (node: ImageNode) => {
40
+ if (node.type === "html" && /<img\b/i.test(node.value || "")) throw new Error("Raw HTML images must be converted to managed Markdown images");
41
+ if (node.type === "image" || node.type === "imageReference") {
42
+ if (++count > 20) throw new Error("Article supports at most 20 visible images");
43
+ const definition = definitions.get(node.identifier!);
44
+ const source = node.url ?? definition?.url;
45
+ if (!source) throw new Error("Unresolved image reference");
46
+ const mapped = resolveImage(source);
47
+ if (mapped !== source) {
48
+ const start = node.position?.start.offset, end = node.position?.end.offset;
49
+ if (start === undefined || end === undefined) throw new Error("Image source position is unavailable");
50
+ const escape = (text: string) => text.replace(/\\/g, "\\\\").replace(/\[/g, "\\[").replace(/\]/g, "\\]");
51
+ const originalTitle = node.title ?? definition?.title;
52
+ const title = originalTitle ? ` "${originalTitle.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"` : "";
53
+ edits.push({ start, end, text: `![${escape(node.alt || "")}](${mapped}${title})` });
54
+ }
55
+ }
56
+ node.children?.forEach(visit);
57
+ };
58
+ visit(tree);
59
+ for (const edit of edits.sort((a, b) => b.start - a.start)) body = body.slice(0, edit.start) + edit.text + body.slice(edit.end);
60
+ return body;
61
+ }
62
+
63
+ export function canonicalHash(value: unknown): string {
64
+ const normalize = (item: any): any => Array.isArray(item) ? item.map(normalize) : item && typeof item === "object"
65
+ ? Object.fromEntries(Object.keys(item).sort().filter(key => item[key] !== undefined).map(key => [key, normalize(item[key])])) : item;
66
+ return createHash("sha256").update(JSON.stringify(normalize(value))).digest("hex");
67
+ }
68
+
69
+ /** 输入 hash 包含文章、账号固定身份、配置内容与素材清单,不重复保存大字段。 */
70
+ export function createArticleSnapshot(value: ArticleSnapshotInput): ArticleSnapshot {
71
+ const content = z.object({ title: z.string().trim().min(1).max(255), body: z.string().min(1), account: z.string().regex(/^[a-zA-Z0-9_.-]+$/) }).parse(value);
72
+ if (Buffer.byteLength(content.body) > 2_097_152) throw new Error("Article body exceeds 2 MiB");
73
+ const header = ArticleSnapshotSchema.omit({ inputHash: true }).parse({ schemaVersion: 1, documentId: value.documentId, documentRevision: value.documentRevision, bodyAssets: value.bodyAssets });
74
+ header.bodyAssets.sort((a, b) => a.id.localeCompare(b.id));
75
+ const identities = new Map(header.bodyAssets.map(asset => [asset.id, asset]));
76
+ if (identities.size !== header.bodyAssets.length) throw new Error("Duplicate body asset id");
77
+ const uniqueBytes = new Map(header.bodyAssets.map(asset => [asset.sha256, asset.byteLength]));
78
+ if ([...uniqueBytes.values()].reduce((sum, bytes) => sum + bytes, 0) > 40 * 1024 * 1024) throw new Error("Body assets exceed 40 MiB");
79
+ const used = new Set<string>();
80
+ rewriteArticleImages(content.body, source => {
81
+ const id = /^asset:([a-f\d-]+)$/.exec(source)?.[1];
82
+ if (!id || !identities.has(id)) throw new Error("Article contains an unmanaged image");
83
+ used.add(id); return source;
84
+ });
85
+ if (used.size !== identities.size) throw new Error("Body asset is not referenced by the article");
86
+ const articleStyle = value.articleStyle ? validateArticleStyleSnapshot(value.articleStyle) : undefined;
87
+ const coverSnapshot = value.coverSnapshot ? validateCoverSnapshot(value.coverSnapshot) : undefined;
88
+ return { ...header, inputHash: canonicalHash({ ...header, ...content, articleStyle, coverSnapshot }) };
89
+ }
90
+
91
+ export function validateArticleSnapshot(value: unknown, content: ArticleSnapshotContent): ArticleSnapshot {
92
+ const input = ArticleSnapshotSchema.parse(value);
93
+ const snapshot = createArticleSnapshot({ ...content, ...input });
94
+ if (snapshot.inputHash !== input.inputHash) throw new Error("Article input hash mismatch");
95
+ return snapshot;
96
+ }
97
+
98
+ const ArtifactSchema = z.object({
99
+ key: z.string().regex(/^(article|cover|media_[a-f\d-]{36})$/), kind: z.enum(["html", "cover", "media"]),
100
+ contentType: z.enum(["text/html", "image/png", "image/jpeg", "image/webp", "image/gif"]), byteLength: z.number().int().positive().max(50 * 1024 * 1024), sha256: hash,
101
+ }).strict();
102
+ const ArtifactManifestSchema = z.object({
103
+ schemaVersion: z.literal(1), snapshotHash: hash, artifacts: z.array(ArtifactSchema).min(2).max(42),
104
+ runtime: z.object({ pipeline: z.string().min(1), myno: z.string().min(1), typeset: z.string().min(1) }).strict(),
105
+ }).strict().superRefine((value, ctx) => {
106
+ const keys = value.artifacts.map(item => item.key);
107
+ if (new Set(keys).size !== keys.length || !keys.includes("article") || !keys.includes("cover")) ctx.addIssue({ code: "custom", message: "Artifact keys must be unique and contain article/cover" });
108
+ for (const item of value.artifacts) if (item.key === "article" ? item.kind !== "html" || item.contentType !== "text/html" : item.key === "cover" ? item.kind !== "cover" || item.contentType !== "image/png" : item.kind !== "media" || item.contentType === "text/html") ctx.addIssue({ code: "custom", message: "Artifact key and type mismatch" });
109
+ });
110
+ export type ArtifactManifest = z.infer<typeof ArtifactManifestSchema>;
111
+ export function createArtifactManifest(value: Omit<ArtifactManifest, "schemaVersion">): ArtifactManifest {
112
+ const manifest = ArtifactManifestSchema.parse({ schemaVersion: 1, ...value });
113
+ manifest.artifacts.sort((a, b) => a.key.localeCompare(b.key));
114
+ return manifest;
115
+ }
116
+ export function hashArtifactManifest(value: ArtifactManifest): string { return canonicalHash(ArtifactManifestSchema.parse(value)); }
117
+ export function validateArtifactManifest(value: unknown, expectedHash?: string): ArtifactManifest {
118
+ const manifest = ArtifactManifestSchema.parse(value);
119
+ if (expectedHash && hashArtifactManifest(manifest) !== expectedHash) throw new Error("Artifact manifest hash mismatch");
120
+ return manifest;
121
+ }
@@ -0,0 +1,17 @@
1
+ import { parseArgs, requireArg } from "../args";
2
+ import { loadTaskState } from "../task-manager";
3
+ import { loadConfig, resolveWorkspacePaths } from "../config";
4
+ import { readArticleTheme } from "../article-theme";
5
+ import { exportWechatArticle } from "../providers/article-export";
6
+ import { printResult } from "../output";
7
+
8
+ /** 在发布检查点只导出本地 HTML,预览绝不调用发布 provider。 */
9
+ export async function articleExport(args: string[]): Promise<void> {
10
+ const parsed = parseArgs(args);
11
+ const { state } = await loadTaskState(requireArg(parsed, "state", "state JSON path"));
12
+ if (state.route.primary !== "wechat-article" || state.phase.render.status !== "done" || !state.asset_path) throw new Error("Article preview requires a rendered article run");
13
+ const config = loadConfig();
14
+ const articleTheme = state.intent.article_theme ? await readArticleTheme(state.intent.article_theme) : undefined;
15
+ const result = await exportWechatArticle({ state, config, workspacePaths: resolveWorkspacePaths(state.workspace_root, config), account: state.route.account, articleTheme });
16
+ printResult({ htmlPath: result.rendered.htmlPath, coverPath: result.photos[0], assetPath: state.asset_path });
17
+ }
@@ -40,6 +40,7 @@ import {
40
40
  stripLeadingTitleHeading,
41
41
  removePageMarkers,
42
42
  } from "../text";
43
+ import { rewriteArticleImages } from "../article-snapshot";
43
44
 
44
45
  function isExternalAssetRef(value: string): boolean {
45
46
  return /^(https?:|data:|blob:|\/\/)/i.test(value);
@@ -133,7 +134,12 @@ async function materializeInlineAssets(options: {
133
134
  return targetRelativePath;
134
135
  };
135
136
 
136
- let rewritten = options.body.replace(
137
+ let rewritten = process.env.ZZHUB_PIPELINE_ARTICLE_SNAPSHOT === "1"
138
+ ? rewriteArticleImages(options.body, source => {
139
+ const target = registerAsset(source);
140
+ return target ? formatRelativeAssetRef(target) : source;
141
+ })
142
+ : options.body.replace(
137
143
  /!\[([^\]]*)\]\((?:<([^>]+)>|([^\s)]+))(\s+(?:"[^"]*"|'[^']*'))?\)/g,
138
144
  (match, alt, angleSrc, bareSrc, titleSuffix) => {
139
145
  const targetRelativePath = registerAsset(String(angleSrc ?? bareSrc ?? ""));
@@ -147,7 +153,7 @@ async function materializeInlineAssets(options: {
147
153
  },
148
154
  );
149
155
 
150
- rewritten = rewritten.replace(
156
+ if (process.env.ZZHUB_PIPELINE_ARTICLE_SNAPSHOT !== "1") rewritten = rewritten.replace(
151
157
  /(<img\b[^>]*\bsrc=["'])([^"']+)(["'][^>]*>)/gi,
152
158
  (match, prefix, src, suffix) => {
153
159
  const targetRelativePath = registerAsset(String(src ?? ""));
package/src/plugins.ts CHANGED
@@ -30,6 +30,7 @@ import { wxDraftDelete } from "./commands/wx-draft-delete";
30
30
  import type { CommandOutcome } from "./command-outcome";
31
31
  import { monitor } from "./commands/monitor";
32
32
  import { typesetAssetsSync } from "./commands/typeset-assets-sync";
33
+ import { articleExport } from "./commands/article-export";
33
34
 
34
35
  export interface CommandDefinition {
35
36
  name: string;
@@ -76,6 +77,7 @@ export function getCommandPlugins(): CommandPlugin[] {
76
77
  { name: "imgx", summary: "Run bundled imgx renderer subcommands", plugin: "ops", handler: imgxCommand },
77
78
  { name: "article-theme", summary: "Inspect or install a local article theme", plugin: "ops", handler: articleTheme },
78
79
  { name: "wechat-export", summary: "Render markdown to WeChat HTML with bundled preview styles", plugin: "ops", handler: wechatExport },
80
+ { name: "article-export", summary: "Export a rendered article locally without publishing", plugin: "ops", handler: articleExport },
79
81
  { name: "wechat-preview", summary: "Local singleton server to preview exported WeChat HTML", plugin: "ops", handler: wechatPreview },
80
82
  { name: "cos-upload", summary: "Upload a local image to configured COS CDN", plugin: "ops", handler: cosUpload },
81
83
  { name: "typeset-assets-sync", summary: "Upload Studio template assets and build a runtime config package", plugin: "ops", handler: typesetAssetsSync },
@@ -0,0 +1,68 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFile, writeFile } from "node:fs/promises";
3
+ import { dirname, join, resolve } from "node:path";
4
+ import { resolveMarkdownRenderer } from "../adapter-loader";
5
+ import { resolveConfigRelativePath, type PipelineConfig, type ResolvedWorkspacePaths } from "../config";
6
+ import type { ArticleThemePackage } from "../article-theme";
7
+ import type { WorkflowState } from "../state";
8
+ import { injectIllustrationImages } from "../text";
9
+ import { reportProgress } from "../monitor/recorder";
10
+ import { validateArtifactManifest, type ArtifactManifest } from "../article-snapshot";
11
+ import type { MarkdownRenderOutput } from "../adapter-types";
12
+
13
+ /** 文章导出只做本地排版,预览与直接发布复用;没有微信上传操作。 */
14
+ export async function exportWechatArticle({ state, config, workspacePaths, account, articleTheme }: {
15
+ state: WorkflowState; config: PipelineConfig; workspacePaths: ResolvedWorkspacePaths; account: string; articleTheme?: ArticleThemePackage;
16
+ }): Promise<{ html: string; photos: string[]; rendered: MarkdownRenderOutput; exportPostPath: string }> {
17
+ let exportPostPath = join(state.asset_path, "post.md");
18
+ const cover = state.images.render_assets.find(asset => asset.route === "wechat-article" && asset.kind === "cover");
19
+ const photos = cover ? [cover.path] : [];
20
+ if (state.images.body_inputs.scope === "article" && state.images.body_inputs.status === "ready") {
21
+ const body = injectIllustrationImages(await readFile(exportPostPath, "utf8"), state.images.body_inputs.received);
22
+ exportPostPath = join(state.asset_path, "post-wechat-draft.md");
23
+ await writeFile(exportPostPath, body);
24
+ photos.push(...state.images.body_inputs.received.map(image => image.path));
25
+ }
26
+ const renderer = await resolveMarkdownRenderer(config);
27
+ const wxAccount = config.wx.accounts[account] ?? config.wx.accounts[config.wx.defaultAccount];
28
+ const standalone = articleTheme?.manifest.css !== undefined;
29
+ const rendered = await renderer.render({ onProgress: reportProgress, markdownPath: exportPostPath,
30
+ outPath: workspacePaths.zotepadExportHtml, account, title: state.metadata.title,
31
+ customCss: standalone ? null : resolveConfigRelativePath(wxAccount?.customCss),
32
+ articleThemePath: articleTheme?.path, themeOverrides: standalone ? undefined : wxAccount?.theme });
33
+ return { html: rendered.html, photos, rendered, exportPostPath };
34
+ }
35
+
36
+ export interface FrozenArticleArtifacts {
37
+ manifest: ArtifactManifest;
38
+ manifestHash: string;
39
+ files: Record<string, string>;
40
+ }
41
+
42
+ /** 私有 manifest 是唯一文件白名单;确认发布只读已验证产物,不再导出或渲染。 */
43
+ export async function readFrozenArticleArtifacts(manifestPath: string): Promise<{ html: string; photos: string[]; rendered?: undefined; exportPostPath: string }> {
44
+ const stored = JSON.parse(await readFile(manifestPath, "utf8")) as FrozenArticleArtifacts;
45
+ if (stored.manifestHash !== process.env.ZZHUB_PIPELINE_ARTIFACT_MANIFEST_HASH || stored.manifest.snapshotHash !== process.env.ZZHUB_PIPELINE_ARTICLE_SNAPSHOT_HASH) throw new Error("Frozen artifact identity does not match the confirmed job");
46
+ const manifest = validateArtifactManifest(stored.manifest, stored.manifestHash);
47
+ const root = dirname(resolve(manifestPath));
48
+ const bytes = new Map<string, Buffer>();
49
+ const paths = new Map<string, string>();
50
+ for (const artifact of manifest.artifacts) {
51
+ const name = stored.files[artifact.key];
52
+ if (!name || name.includes("/") || name.includes("\\") || name === "." || name === "..") throw new Error("Invalid private artifact file");
53
+ const path = join(root, name);
54
+ const data = await readFile(path);
55
+ if (data.byteLength !== artifact.byteLength || createHash("sha256").update(data).digest("hex") !== artifact.sha256) throw new Error("Frozen article artifact hash mismatch");
56
+ bytes.set(artifact.key, data); paths.set(artifact.key, path);
57
+ }
58
+ let html = bytes.get("article")!.toString("utf8");
59
+ html = await new HTMLRewriter().on("img", { element(element) {
60
+ const source = element.getAttribute("src") || "";
61
+ const id = /^asset:([a-f\d-]{36})$/.exec(source)?.[1];
62
+ const path = id ? paths.get(`media_${id}`) : undefined;
63
+ if (!path) throw new Error("Frozen HTML contains an unmanaged image");
64
+ element.setAttribute("src", path);
65
+ if (element.hasAttribute("data-src")) element.setAttribute("data-src", path);
66
+ } }).transform(new Response(html)).text();
67
+ return { html, photos: [paths.get("cover")!, ...manifest.artifacts.filter(artifact => artifact.kind === "media").map(artifact => paths.get(artifact.key)!)], exportPostPath: paths.get("article")! };
68
+ }
@@ -3,17 +3,14 @@ import { createHash } from "node:crypto";
3
3
  import { readFile, writeFile } from "fs/promises";
4
4
  import { join } from "path";
5
5
  import {
6
- resolveConfigRelativePath,
7
6
  type PipelineConfig,
8
7
  type ResolvedWorkspacePaths,
9
8
  } from "../config";
10
9
  import { PublishResult, RenderAsset, RoutePrimary, WorkflowState } from "../state";
11
10
  import {
12
- injectIllustrationImages,
13
11
  prepareBodyForNewspic,
14
12
  } from "../text";
15
- import { resolveMarkdownRenderer } from "../adapter-loader";
16
- import { reportProgress } from "../monitor/recorder";
13
+ import { exportWechatArticle, readFrozenArticleArtifacts } from "./article-export";
17
14
  import { publishBlogRoute } from "./blog";
18
15
  import { createWechatDraft, createWechatNewspic, extractImageUrls, mergePhotoLists } from "./wechat";
19
16
 
@@ -36,70 +33,22 @@ async function publishWechatArticleRoute({
36
33
  accountOverride,
37
34
  articleTheme,
38
35
  }: PublishRouteContext): Promise<PublishResult> {
39
- const postPath = join(state.asset_path, "post.md");
40
- let exportPostPath = postPath;
41
- const cover = (state.images.render_assets as RenderAsset[]).find(
42
- (asset) => asset.route === "wechat-article" && asset.kind === "cover",
43
- );
44
-
45
- const photos: string[] = [];
46
- if (cover) {
47
- photos.push(cover.path);
48
- }
49
- if (
50
- state.images.body_inputs.scope === "article" &&
51
- state.images.body_inputs.status === "ready"
52
- ) {
53
- const postContent = await readFile(postPath, "utf-8");
54
- const draftContent = injectIllustrationImages(
55
- postContent,
56
- state.images.body_inputs.received,
57
- );
58
- exportPostPath = join(state.asset_path, "post-wechat-draft.md");
59
- if (!dryRun) {
60
- await writeFile(exportPostPath, draftContent, "utf-8");
61
- }
62
-
63
- for (const img of state.images.body_inputs.received) {
64
- photos.push(img.path);
65
- }
66
- }
67
-
68
36
  const account = accountOverride || state.route.account;
69
-
70
37
  if (dryRun) {
71
- console.error(`[dry-run] Zotepad export: ${exportPostPath}`);
72
- console.error(`[dry-run] Wechat draft: ${state.metadata.title}`);
73
- return {
74
- route: "wechat-article",
75
- account,
76
- status: "skipped",
77
- detail: "dry-run",
78
- published_at: null,
79
- content_version: state.artifacts.content_version,
80
- render_version: state.artifacts.render_version,
81
- };
38
+ return { route: "wechat-article", account, status: "skipped", detail: "dry-run", published_at: null,
39
+ content_version: state.artifacts.content_version, render_version: state.artifacts.render_version };
82
40
  }
83
41
 
84
42
  try {
85
- const markdownRenderer = await resolveMarkdownRenderer(config);
86
- const wxAccount = config.wx.accounts[account] ?? config.wx.accounts[config.wx.defaultAccount];
87
- // 独立正文配置与发布账号的凭据、默认 CSS 分离。
88
- const standalone = articleTheme?.manifest.css !== undefined;
89
- const rendered = await markdownRenderer.render({
90
- onProgress: reportProgress,
91
- markdownPath: exportPostPath,
92
- outPath: workspacePaths.zotepadExportHtml,
93
- account,
94
- title: state.metadata.title,
95
- customCss: standalone ? null : resolveConfigRelativePath(wxAccount?.customCss),
96
- articleThemePath: articleTheme?.path,
97
- themeOverrides: standalone ? undefined : wxAccount?.theme,
98
- });
99
- const { html } = rendered;
43
+ if (process.env.ZZHUB_PIPELINE_PREVIEW_ONLY === "1") throw new Error("Preview operation cannot publish or upload WeChat images");
44
+ const frozenPath = process.env.ZZHUB_PIPELINE_ARTICLE_ARTIFACTS;
45
+ const prepared = frozenPath
46
+ ? await readFrozenArticleArtifacts(frozenPath)
47
+ : await exportWechatArticle({ state, config, workspacePaths, account, articleTheme });
48
+ const { html, photos, rendered, exportPostPath } = prepared;
100
49
 
101
50
  // Register with local preview server unless explicitly disabled.
102
- if (process.env.ZZHUB_WECHAT_PREVIEW_ON_PUBLISH !== "0") {
51
+ if (rendered && process.env.ZZHUB_WECHAT_PREVIEW_ON_PUBLISH !== "0") {
103
52
  try {
104
53
  const { registerPreviewEntry } = await import("../wechat-preview/server");
105
54
  const registration = await registerPreviewEntry(
@@ -131,6 +80,7 @@ async function publishWechatArticleRoute({
131
80
  title: state.metadata.title,
132
81
  html,
133
82
  photos,
83
+ ...(frozenPath ? { explicitPhotos: true } : {}),
134
84
  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"),
135
85
  config,
136
86
  existingDraftMediaId: state.intent.existing_draft_media_id,
@@ -139,6 +139,8 @@ interface BaseWechatPublishInput {
139
139
  }
140
140
 
141
141
  export interface WechatDraftInput extends BaseWechatPublishInput {
142
+ /** 冻结产物已给出真实图片清单,禁止从代码文本再猜图片来源。 */
143
+ explicitPhotos?: boolean;
142
144
  html: string;
143
145
  existingDraftMediaId?: string | null;
144
146
  }
@@ -642,7 +644,7 @@ export function calculatePicCrop(width: number, height: number): { pic_crop_235_
642
644
 
643
645
  export async function createWechatDraft(input: WechatDraftInput): Promise<Record<string, unknown>> {
644
646
  const runtime = getWxRuntimeConfig(input.config, input.account);
645
- const fallbackPhotos = extractImageUrls(input.html);
647
+ const fallbackPhotos = input.explicitPhotos ? [] : extractImageUrls(input.html);
646
648
  const photos = parsePhotos(input.photos);
647
649
  const finalPhotos = mergePhotoLists(photos, fallbackPhotos);
648
650
  if (finalPhotos.length === 0) {