@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.
- package/article-snapshot.ts +2 -0
- package/package.json +5 -3
- package/src/article-snapshot.test.ts +39 -0
- package/src/article-snapshot.ts +121 -0
- package/src/commands/article-export.ts +17 -0
- package/src/commands/prepare-finalize.ts +8 -2
- package/src/plugins.ts +2 -0
- package/src/providers/article-export.ts +68 -0
- package/src/providers/index.ts +11 -61
- package/src/providers/wechat.ts +3 -1
|
@@ -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.
|
|
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.
|
|
34
|
-
"@akairos/typeset-studio": "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: ``, 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 ["", "<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\n```';
|
|
19
|
+
const rewritten = rewriteArticleImages(source, () => `asset:${asset.id}`);
|
|
20
|
+
expect(rewritten).toContain(``);
|
|
21
|
+
expect(rewritten).toContain('[shared]: /api/assets/body/image "定义图注"');
|
|
22
|
+
expect(rewritten).toContain('[普通][shared]');
|
|
23
|
+
expect(rewritten).toContain('');
|
|
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: `` });
|
|
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 =
|
|
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
|
+
}
|
package/src/providers/index.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
72
|
-
|
|
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
|
-
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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,
|
package/src/providers/wechat.ts
CHANGED
|
@@ -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) {
|