@zzclub/pipeline 0.4.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +102 -3
  2. package/package.json +1 -1
  3. package/src/adapter-types.ts +6 -0
  4. package/src/adapters/builtin-image-renderer.ts +7 -1
  5. package/src/adapters/builtin-markdown-renderer.ts +1 -0
  6. package/src/commands/analytics.test.ts +155 -0
  7. package/src/commands/analytics.ts +223 -0
  8. package/src/commands/init.ts +42 -3
  9. package/src/commands/publish.ts +44 -67
  10. package/src/commands/republish.ts +146 -0
  11. package/src/commands/sync-blog.ts +19 -3
  12. package/src/commands/topic.test.ts +146 -0
  13. package/src/commands/topic.ts +717 -0
  14. package/src/commands/wechat-export.ts +2 -0
  15. package/src/commands/wx-draft-delete.ts +36 -0
  16. package/src/commands/wx-drafts.ts +47 -0
  17. package/src/config.ts +0 -0
  18. package/src/db.test.ts +47 -0
  19. package/src/db.ts +95 -0
  20. package/src/imgx/assets/icons/ancientone-logo.png +0 -0
  21. package/src/imgx/assets/templates/wechat-cover-split.html +1 -1
  22. package/src/imgx/render-card.ts +1 -5
  23. package/src/plugins.ts +11 -1
  24. package/src/providers/blog.ts +9 -3
  25. package/src/providers/index.test.ts +1 -1
  26. package/src/providers/index.ts +29 -5
  27. package/src/providers/publish-core.ts +124 -0
  28. package/src/providers/wechat.ts +260 -28
  29. package/src/routes.ts +1 -1
  30. package/src/schema/analytics.test.ts +54 -0
  31. package/src/schema/analytics.ts +19 -0
  32. package/src/schema/config.ts +25 -1
  33. package/src/schema/state.ts +10 -0
  34. package/src/schema/topic.test.ts +69 -0
  35. package/src/schema/topic.ts +55 -0
  36. package/src/state.ts +1 -0
  37. package/src/text.test.ts +11 -6
  38. package/src/text.ts +27 -9
  39. package/src/wechat-preview/assets/browser-dist/.vite/manifest.json +2 -7
  40. package/src/wechat-preview/assets/browser-dist/assets/{style-BYNXnUvc.css → style-vhHYSP8T.css} +1 -1
  41. package/src/wechat-preview/assets/browser-dist/editor-export.js +27 -27
  42. package/src/wechat-preview/browser/editor-export.css +248 -159
  43. package/src/wechat-preview/index.ts +28 -11
  44. package/src/wechat-preview/themes.ts +70 -24
  45. package/src/wechat-preview/wechat-formatter.test.ts +0 -1
  46. package/src/wechat-preview/wechat-formatter.ts +48 -23
  47. package/src/workflow.test.ts +294 -1
  48. package/src/wechat-preview/assets/browser-dist/assets/SweiCurveLegCJKsc-Regular-B3Ut5bwH.woff +0 -0
@@ -4,7 +4,11 @@ import { PipelineConfig, WxAccountConfig } from "../config";
4
4
 
5
5
  const TOKEN_PATH = "/api/v1/wx/cgi-bin/token";
6
6
  const MATERIAL_PATH = "/api/v1/wx/cgi-bin/material/add_material";
7
- const DRAFT_PATH = "/api/v1/wx/cgi-bin/draft/add";
7
+ const DRAFT_ADD_PATH = "/api/v1/wx/cgi-bin/draft/add";
8
+ const DRAFT_UPDATE_PATH = "/api/v1/wx/cgi-bin/draft/update";
9
+ const DRAFT_GET_PATH = "/api/v1/wx/cgi-bin/draft/get";
10
+ const DRAFT_DELETE_PATH = "/api/v1/wx/cgi-bin/draft/delete";
11
+ const BATCH_GET_PATH = "/api/v1/wx/cgi-bin/draft/batchget";
8
12
  const TOKEN_TIMEOUT = 10000;
9
13
  const UPLOAD_TIMEOUT = 60000;
10
14
  const DRAFT_TIMEOUT = 30000;
@@ -78,14 +82,19 @@ interface BaseWechatPublishInput {
78
82
  photos?: string[];
79
83
  timeout?: number;
80
84
  config: PipelineConfig;
85
+ noteId?: string | null;
86
+ nezusBaseUrl?: string | null;
87
+ nezusPat?: string | null;
81
88
  }
82
89
 
83
90
  export interface WechatDraftInput extends BaseWechatPublishInput {
84
91
  html: string;
92
+ existingDraftMediaId?: string | null;
85
93
  }
86
94
 
87
95
  export interface WechatNewspicInput extends BaseWechatPublishInput {
88
96
  content: string;
97
+ existingDraftMediaId?: string | null;
89
98
  }
90
99
 
91
100
  function requireValue(name: string, value: string | undefined): string {
@@ -112,7 +121,8 @@ function fillWxAccountConfig(source?: WxAccountConfig): WxAccountConfig {
112
121
  appId: source?.appId ?? "",
113
122
  appSecret: source?.appSecret ?? "",
114
123
  customCss: source?.customCss ?? null,
115
- };
124
+ theme: source?.theme ?? { editorVars: {}, exportTheme: {} },
125
+ } as WxAccountConfig;
116
126
  }
117
127
 
118
128
  function trimTrailingSlash(value: string): string {
@@ -178,12 +188,17 @@ export function mergePhotoLists(...lists: string[][]): string[] {
178
188
 
179
189
  export function extractImageUrls(input: string): string[] {
180
190
  const urls = new Set<string>();
181
- const markdownImageRegex = /!\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g;
191
+ // Handle both standard ![alt](url) and angle-bracket ![alt](<url>) syntax.
192
+ // Angle brackets allow URLs with spaces per CommonMark spec.
193
+ // Match markdown images: ![alt](url) and ![alt](<url with spaces>)
194
+ // Second alternative uses lazy match so titles like ![alt](url "title") still work.
195
+ const markdownImageRegex = /!\[[^\]]*\]\((?:<([^>]+)>|([^)]+?))(?:\s+"[^"]*")?\)/g;
182
196
  const htmlImageRegex = /<img[^>]+src=["']([^"']+)["'][^>]*>/g;
183
197
  let match = markdownImageRegex.exec(input);
184
198
  while (match) {
185
- if (match[1]) {
186
- urls.add(match[1]);
199
+ const url = match[1] || match[2];
200
+ if (url) {
201
+ urls.add(url);
187
202
  }
188
203
  match = markdownImageRegex.exec(input);
189
204
  }
@@ -194,7 +209,12 @@ export function extractImageUrls(input: string): string[] {
194
209
  }
195
210
  match = htmlImageRegex.exec(input);
196
211
  }
197
- return [...urls];
212
+ // Filter out URLs that look like HTML fragments (unescaped code block content
213
+ // that was incorrectly matched by the image regex patterns).
214
+ return [...urls].filter((url) => {
215
+ if (url.startsWith('<') || url.includes('/>')) return false;
216
+ return true;
217
+ });
198
218
  }
199
219
 
200
220
  export function replaceImageUrls(html: string, imageUrlMap: Record<string, string>): string {
@@ -326,7 +346,8 @@ async function resolvePhotoPayload(
326
346
  }
327
347
 
328
348
  if (photoUrl.startsWith("http://") || photoUrl.startsWith("https://")) {
329
- const response = await fetch(photoUrl);
349
+ const safeUrl = photoUrl.includes(" ") ? photoUrl.replace(/ /g, "%20") : photoUrl;
350
+ const response = await fetch(safeUrl);
330
351
  if (!response.ok) {
331
352
  throw new Error(`Failed to download image: HTTP ${response.status}`);
332
353
  }
@@ -339,8 +360,9 @@ async function resolvePhotoPayload(
339
360
  }
340
361
 
341
362
  if (photoUrl.startsWith("file://")) {
342
- const url = new URL(photoUrl);
343
- return resolveFilePayload(url.pathname, index);
363
+ const safeUrl = photoUrl.replace(/ /g, "%20");
364
+ const url = new URL(safeUrl);
365
+ return resolveFilePayload(decodeURIComponent(url.pathname), index);
344
366
  }
345
367
 
346
368
  return resolveFilePayload(photoUrl, index);
@@ -350,6 +372,14 @@ function resolveFilePayload(
350
372
  filePath: string,
351
373
  index: number,
352
374
  ): { blob: Blob; filename: string } {
375
+ // Detect HTML fragments incorrectly extracted as image URLs (defense-in-depth)
376
+ if (filePath.startsWith("<") && (filePath.includes("style=") || filePath.includes("</") || filePath.includes("/>"))) {
377
+ throw new Error(
378
+ `Image URL appears to be HTML content, not a valid file path or URL. ` +
379
+ `This usually means extractImageUrls matched unescaped HTML from a code block. ` +
380
+ `Got: ${filePath.slice(0, 120)}`,
381
+ );
382
+ }
353
383
  if (!existsSync(filePath)) {
354
384
  throw new Error(`File not found: ${filePath}`);
355
385
  }
@@ -459,15 +489,21 @@ export async function createWechatDraft(input: WechatDraftInput): Promise<Record
459
489
  const uploadResult = await uploadPhotos(runtime, accessToken, finalPhotos, uploadTimeout);
460
490
  const replacedHtml = replaceImageUrls(input.html, uploadResult.imageUrlMap);
461
491
 
462
- const response = await requestJson(
463
- `${runtime.baseUrl}${DRAFT_PATH}`,
464
- {
465
- method: "POST",
466
- headers: {
467
- "Content-Type": "application/json",
468
- Authorization: `Bearer ${runtime.pat}`,
469
- },
470
- body: {
492
+ const isUpdate = !!input.existingDraftMediaId;
493
+ const draftPath = isUpdate ? DRAFT_UPDATE_PATH : DRAFT_ADD_PATH;
494
+ const draftBody = isUpdate
495
+ ? {
496
+ access_token: accessToken,
497
+ media_id: input.existingDraftMediaId,
498
+ index: 0,
499
+ articles: {
500
+ article_type: "news",
501
+ title: input.title,
502
+ content: replacedHtml,
503
+ thumb_media_id: uploadResult.coverMediaId,
504
+ },
505
+ }
506
+ : {
471
507
  access_token: accessToken,
472
508
  articles: [
473
509
  {
@@ -477,11 +513,34 @@ export async function createWechatDraft(input: WechatDraftInput): Promise<Record
477
513
  thumb_media_id: uploadResult.coverMediaId,
478
514
  },
479
515
  ],
516
+ };
517
+
518
+ const response = await requestJson(
519
+ `${runtime.baseUrl}${draftPath}`,
520
+ {
521
+ method: "POST",
522
+ headers: {
523
+ "Content-Type": "application/json",
524
+ Authorization: `Bearer ${runtime.pat}`,
480
525
  },
526
+ body: draftBody,
481
527
  },
482
528
  draftTimeout,
483
529
  );
484
530
 
531
+ const draftMediaId = isUpdate ? input.existingDraftMediaId : (response as any)?.data?.media_id || (response as any)?.media_id || null;
532
+
533
+ // ── Callback to Nezus to persist media_id ──
534
+ if (draftMediaId && input.noteId && input.nezusBaseUrl && input.nezusPat) {
535
+ await notifyNezusPublishResult({
536
+ nezusBaseUrl: input.nezusBaseUrl,
537
+ nezusPat: input.nezusPat,
538
+ noteId: input.noteId,
539
+ wechatMediaId: draftMediaId,
540
+ publishType: "article",
541
+ });
542
+ }
543
+
485
544
  return {
486
545
  account: runtime.accountName,
487
546
  articleType: "news",
@@ -489,6 +548,8 @@ export async function createWechatDraft(input: WechatDraftInput): Promise<Record
489
548
  photosCount: finalPhotos.length,
490
549
  totalUploaded: uploadResult.totalUploaded,
491
550
  coverMediaId: uploadResult.coverMediaId,
551
+ isUpdate,
552
+ draftMediaId,
492
553
  response,
493
554
  };
494
555
  }
@@ -509,18 +570,26 @@ export async function createWechatNewspic(input: WechatNewspicInput): Promise<Re
509
570
  const uploadResult = await uploadPhotos(runtime, accessToken, finalPhotos, uploadTimeout);
510
571
 
511
572
  const imageInfo = {
512
- image_list: uploadResult.uploadedMedia.map((item) => ({ image_media_id: item.mediaId })),
573
+ image_list: uploadResult.uploadedMedia
574
+ .map((item) => ({ image_media_id: item.mediaId })),
513
575
  };
514
576
 
515
- const response = await requestJson(
516
- `${runtime.baseUrl}${DRAFT_PATH}`,
517
- {
518
- method: "POST",
519
- headers: {
520
- "Content-Type": "application/json",
521
- Authorization: `Bearer ${runtime.pat}`,
522
- },
523
- body: {
577
+ const isUpdate = !!input.existingDraftMediaId;
578
+ const draftPath = isUpdate ? DRAFT_UPDATE_PATH : DRAFT_ADD_PATH;
579
+ const draftBody = isUpdate
580
+ ? {
581
+ access_token: accessToken,
582
+ media_id: input.existingDraftMediaId,
583
+ index: 0,
584
+ articles: {
585
+ article_type: "newspic",
586
+ title: input.title,
587
+ content: input.content,
588
+ thumb_media_id: uploadResult.coverMediaId,
589
+ image_info: imageInfo,
590
+ },
591
+ }
592
+ : {
524
593
  access_token: accessToken,
525
594
  articles: [
526
595
  {
@@ -531,11 +600,34 @@ export async function createWechatNewspic(input: WechatNewspicInput): Promise<Re
531
600
  image_info: imageInfo,
532
601
  },
533
602
  ],
603
+ };
604
+
605
+ const response = await requestJson(
606
+ `${runtime.baseUrl}${draftPath}`,
607
+ {
608
+ method: "POST",
609
+ headers: {
610
+ "Content-Type": "application/json",
611
+ Authorization: `Bearer ${runtime.pat}`,
534
612
  },
613
+ body: draftBody,
535
614
  },
536
615
  draftTimeout,
537
616
  );
538
617
 
618
+ const draftMediaId = isUpdate ? input.existingDraftMediaId : (response as any)?.data?.media_id || (response as any)?.media_id || null;
619
+
620
+ // ── Callback to Nezus to persist media_id ──
621
+ if (draftMediaId && input.noteId && input.nezusBaseUrl && input.nezusPat) {
622
+ await notifyNezusPublishResult({
623
+ nezusBaseUrl: input.nezusBaseUrl,
624
+ nezusPat: input.nezusPat,
625
+ noteId: input.noteId,
626
+ wechatMediaId: draftMediaId,
627
+ publishType: "newspic",
628
+ });
629
+ }
630
+
539
631
  return {
540
632
  account: runtime.accountName,
541
633
  articleType: "newspic",
@@ -543,6 +635,146 @@ export async function createWechatNewspic(input: WechatNewspicInput): Promise<Re
543
635
  photosCount: finalPhotos.length,
544
636
  totalUploaded: uploadResult.totalUploaded,
545
637
  coverMediaId: uploadResult.coverMediaId,
638
+ isUpdate,
639
+ draftMediaId,
640
+ response,
641
+ };
642
+ }
643
+
644
+ // ── Nezus callback ────────────────────────────────────────────────
645
+
646
+ async function notifyNezusPublishResult(params: {
647
+ nezusBaseUrl: string;
648
+ nezusPat: string;
649
+ noteId: string;
650
+ wechatMediaId: string;
651
+ publishType: string;
652
+ }): Promise<void> {
653
+ try {
654
+ await requestJson(
655
+ `${params.nezusBaseUrl}/api/v1/notes/${params.noteId}/publish-result`,
656
+ {
657
+ method: "POST",
658
+ headers: {
659
+ "Content-Type": "application/json",
660
+ Authorization: `Bearer ${params.nezusPat}`,
661
+ },
662
+ body: {
663
+ wechatMediaId: params.wechatMediaId,
664
+ publishType: params.publishType,
665
+ },
666
+ },
667
+ 10000,
668
+ );
669
+ console.error(`[nezus] publish-result callback OK for note ${params.noteId}`);
670
+ } catch (err) {
671
+ // Non-fatal: the draft was created successfully even if the callback failed
672
+ console.error(`[nezus] publish-result callback failed for note ${params.noteId}:`, err);
673
+ }
674
+ }
675
+
676
+ // ── Draft management ──────────────────────────────────────────────
677
+
678
+ export interface WxDraftListInput {
679
+ account?: string;
680
+ limit?: number;
681
+ offset?: number;
682
+ config: PipelineConfig;
683
+ }
684
+
685
+ export interface WxDraftGetInput {
686
+ account?: string;
687
+ mediaId: string;
688
+ config: PipelineConfig;
689
+ }
690
+
691
+ export interface WxDraftDeleteInput {
692
+ account?: string;
693
+ mediaId: string;
694
+ config: PipelineConfig;
695
+ }
696
+
697
+ export async function getWxDraftList(input: WxDraftListInput): Promise<Record<string, unknown>> {
698
+ const runtime = getWxRuntimeConfig(input.config, input.account);
699
+ const tokenTimeout = resolveTimeout(TOKEN_TIMEOUT, runtime.timeout);
700
+ const accessToken = await fetchAccessToken(runtime, tokenTimeout);
701
+
702
+ const response = await requestJson(
703
+ `${runtime.baseUrl}${BATCH_GET_PATH}`,
704
+ {
705
+ method: "POST",
706
+ headers: {
707
+ "Content-Type": "application/json",
708
+ Authorization: `Bearer ${runtime.pat}`,
709
+ },
710
+ body: {
711
+ access_token: accessToken,
712
+ offset: input.offset ?? 0,
713
+ count: input.limit ?? 20,
714
+ no_content: true,
715
+ },
716
+ },
717
+ resolveTimeout(30000, runtime.timeout),
718
+ );
719
+
720
+ return {
721
+ account: runtime.accountName,
722
+ response,
723
+ };
724
+ }
725
+
726
+ export async function getWxDraft(input: WxDraftGetInput): Promise<Record<string, unknown>> {
727
+ const runtime = getWxRuntimeConfig(input.config, input.account);
728
+ const tokenTimeout = resolveTimeout(TOKEN_TIMEOUT, runtime.timeout);
729
+ const accessToken = await fetchAccessToken(runtime, tokenTimeout);
730
+
731
+ const response = await requestJson(
732
+ `${runtime.baseUrl}${DRAFT_GET_PATH}`,
733
+ {
734
+ method: "POST",
735
+ headers: {
736
+ "Content-Type": "application/json",
737
+ Authorization: `Bearer ${runtime.pat}`,
738
+ },
739
+ body: {
740
+ access_token: accessToken,
741
+ media_id: input.mediaId,
742
+ },
743
+ },
744
+ resolveTimeout(30000, runtime.timeout),
745
+ );
746
+
747
+ return {
748
+ account: runtime.accountName,
749
+ mediaId: input.mediaId,
750
+ response,
751
+ };
752
+ }
753
+
754
+ export async function deleteWxDraft(input: WxDraftDeleteInput): Promise<Record<string, unknown>> {
755
+ const runtime = getWxRuntimeConfig(input.config, input.account);
756
+ const tokenTimeout = resolveTimeout(TOKEN_TIMEOUT, runtime.timeout);
757
+ const accessToken = await fetchAccessToken(runtime, tokenTimeout);
758
+
759
+ const response = await requestJson(
760
+ `${runtime.baseUrl}${DRAFT_DELETE_PATH}`,
761
+ {
762
+ method: "POST",
763
+ headers: {
764
+ "Content-Type": "application/json",
765
+ Authorization: `Bearer ${runtime.pat}`,
766
+ },
767
+ body: {
768
+ access_token: accessToken,
769
+ media_id: input.mediaId,
770
+ },
771
+ },
772
+ resolveTimeout(30000, runtime.timeout),
773
+ );
774
+
775
+ return {
776
+ account: runtime.accountName,
777
+ mediaId: input.mediaId,
546
778
  response,
547
779
  };
548
780
  }
package/src/routes.ts CHANGED
@@ -148,7 +148,7 @@ const VISUAL_PARAMS: Record<string, AccountVisualParams> = {
148
148
  footer: "公众号 · 古一软件",
149
149
  bg: "#faf5f8",
150
150
  highlight: "#ca6093",
151
- fallback_icon: "assets/icons/logo.png",
151
+ fallback_icon: "assets/icons/ancientone-logo.png",
152
152
  },
153
153
  };
154
154
 
@@ -0,0 +1,54 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { AnalyticsSchema } from "./analytics";
3
+
4
+ describe("AnalyticsSchema", () => {
5
+ test("parses minimal analytics", () => {
6
+ const input = {
7
+ run_id: "run_001",
8
+ title: "Test Article",
9
+ publish_date: "2026-06-11",
10
+ recorded_at: "2026-06-11T10:00:00Z",
11
+ };
12
+
13
+ const result = AnalyticsSchema.parse(input);
14
+ expect(result.run_id).toBe("run_001");
15
+ expect(result.reads).toBe(0);
16
+ expect(result.likes).toBe(0);
17
+ });
18
+
19
+ test("parses full analytics", () => {
20
+ const input = {
21
+ run_id: "run_002",
22
+ topic_id: "topic_001",
23
+ title: "AI Tools",
24
+ publish_date: "2026-06-11",
25
+ reads: 1500,
26
+ likes: 45,
27
+ favorites: 23,
28
+ shares: 12,
29
+ comments: 8,
30
+ notes: "Good performance",
31
+ recorded_at: "2026-06-11T10:00:00Z",
32
+ };
33
+
34
+ const result = AnalyticsSchema.parse(input);
35
+ expect(result.reads).toBe(1500);
36
+ expect(result.topic_id).toBe("topic_001");
37
+ });
38
+
39
+ test("defaults metrics to 0", () => {
40
+ const input = {
41
+ run_id: "run_003",
42
+ title: "Test",
43
+ publish_date: "2026-06-11",
44
+ recorded_at: "2026-06-11T10:00:00Z",
45
+ };
46
+
47
+ const result = AnalyticsSchema.parse(input);
48
+ expect(result.reads).toBe(0);
49
+ expect(result.likes).toBe(0);
50
+ expect(result.favorites).toBe(0);
51
+ expect(result.shares).toBe(0);
52
+ expect(result.comments).toBe(0);
53
+ });
54
+ });
@@ -0,0 +1,19 @@
1
+ import { z } from "zod";
2
+
3
+ export const AnalyticsSchema = z.object({
4
+ run_id: z.string(),
5
+ topic_id: z.string().nullable().default(null),
6
+ title: z.string(),
7
+ publish_date: z.string(),
8
+
9
+ reads: z.number().int().default(0),
10
+ likes: z.number().int().default(0),
11
+ favorites: z.number().int().default(0),
12
+ shares: z.number().int().default(0),
13
+ comments: z.number().int().default(0),
14
+
15
+ notes: z.string().nullable().default(null),
16
+ recorded_at: z.string(),
17
+ });
18
+
19
+ export type Analytics = z.infer<typeof AnalyticsSchema>;
@@ -32,11 +32,33 @@ function withObjectDefault<T extends z.ZodObject<any>>(schema: T) {
32
32
 
33
33
  // ── Sub-schemas ───────────────────────────────────────────────────
34
34
 
35
+ const WechatExportThemeOverridesSchema = z.object({
36
+ containerStyle: z.string().optional(),
37
+ footerText: z.string().optional(),
38
+ footerStyle: z.string().optional(),
39
+ fontFamily: z.string().optional(),
40
+ bodyColor: z.string().optional(),
41
+ mutedColor: z.string().optional(),
42
+ primaryColor: z.string().optional(),
43
+ dividerColor: z.string().optional(),
44
+ blockquoteBorderColor: z.string().optional(),
45
+ bodyLineHeight: z.string().optional(),
46
+ bodyLetterSpacing: z.string().optional(),
47
+ });
48
+
49
+ const WechatThemeOverridesSchema = withObjectDefault(
50
+ z.object({
51
+ editorVars: z.record(z.string(), z.string()).default({}),
52
+ exportTheme: withObjectDefault(WechatExportThemeOverridesSchema),
53
+ }),
54
+ );
55
+
35
56
  const WxAccountConfigSchema = z.object({
36
57
  pat: z.string().default(""),
37
58
  appId: z.string().default(""),
38
59
  appSecret: z.string().default(""),
39
60
  customCss: z.string().nullable().default(null),
61
+ theme: WechatThemeOverridesSchema,
40
62
  });
41
63
 
42
64
  const WxConfigSchema = withObjectDefault(
@@ -52,7 +74,7 @@ const WxConfigSchema = withObjectDefault(
52
74
  defaultAccount: trimmedNonEmptyString("default"),
53
75
  accounts: z
54
76
  .record(z.string(), WxAccountConfigSchema)
55
- .default({ default: { pat: "", appId: "", appSecret: "", customCss: null } }),
77
+ .default({ default: { pat: "", appId: "", appSecret: "", customCss: null, theme: { editorVars: {}, exportTheme: {} } } }),
56
78
  }),
57
79
  );
58
80
 
@@ -127,6 +149,8 @@ export type PipelineCommandConfig = z.infer<typeof PipelineCommandConfigSchema>;
127
149
  export type CosConfig = z.infer<typeof CosConfigSchema>;
128
150
  export type PluginsConfig = z.infer<typeof PluginsConfigSchema>;
129
151
  export type ImgxConfig = z.infer<typeof ImgxConfigSchema>;
152
+ export type WechatExportThemeOverrides = z.infer<typeof WechatExportThemeOverridesSchema>;
153
+ export type WechatThemeOverrides = z.infer<typeof WechatThemeOverridesSchema>;
130
154
 
131
155
  // ── Schema instances for reuse ────────────────────────────────────
132
156
 
@@ -260,6 +260,7 @@ const ImagesSchema = withObjectDefault(
260
260
 
261
261
  const PublishResultSchema = z.object({
262
262
  route: RoutePrimarySchema,
263
+ account: trimmedString("default"),
263
264
  status: PublishResultStatusSchema,
264
265
  detail: z.string().nullable().default(null),
265
266
  published_at: z.string().nullable().default(null),
@@ -273,6 +274,11 @@ const PublishResultsSchema = withObjectDefault(
273
274
  }),
274
275
  );
275
276
 
277
+ const PublishTargetSchema = z.object({
278
+ route: RoutePrimarySchema,
279
+ account: trimmedString("default"),
280
+ });
281
+
276
282
  const ContentReviewSchema = withObjectDefault(
277
283
  z.object({
278
284
  status: ContentReviewStatusSchema,
@@ -303,6 +309,8 @@ const IntentSchema = withObjectDefault(
303
309
  style_hint: z.string().nullable().default(null),
304
310
  newspic_render: NewspicRenderSpecSchema.nullable().default(null),
305
311
  requires: IntentRequiresSchema,
312
+ existing_draft_media_id: z.string().nullable().default(null),
313
+ note_id: z.string().nullable().default(null),
306
314
  }),
307
315
  );
308
316
 
@@ -327,6 +335,7 @@ export const WorkflowStateSchema = z.object({
327
335
  artifacts: ArtifactsSchema,
328
336
  images: ImagesSchema,
329
337
  publish: PublishResultsSchema,
338
+ publish_targets: z.array(PublishTargetSchema).default([]),
330
339
  content_review: ContentReviewSchema,
331
340
  redo_hint: z.string().nullable().default(null),
332
341
  });
@@ -368,6 +377,7 @@ export type Images = z.infer<typeof ImagesSchema>;
368
377
  export type PublishResultStatus = z.infer<typeof PublishResultStatusSchema>;
369
378
  export type PublishResult = z.infer<typeof PublishResultSchema>;
370
379
  export type PublishResults = z.infer<typeof PublishResultsSchema>;
380
+ export type PublishTarget = z.infer<typeof PublishTargetSchema>;
371
381
  export type ContentReviewStatus = z.infer<typeof ContentReviewStatusSchema>;
372
382
  export type ContentReview = z.infer<typeof ContentReviewSchema>;
373
383
  export type HandoffResearchPolicy = z.infer<typeof HandoffResearchPolicySchema>;
@@ -0,0 +1,69 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { TopicSchema } from "./topic";
3
+
4
+ describe("TopicSchema", () => {
5
+ test("parses minimal topic", () => {
6
+ const input = {
7
+ topic_id: "topic_001",
8
+ title: "Test Topic",
9
+ created_at: "2026-06-11T00:00:00Z",
10
+ updated_at: "2026-06-11T00:00:00Z",
11
+ };
12
+
13
+ const result = TopicSchema.parse(input);
14
+ expect(result.topic_id).toBe("topic_001");
15
+ expect(result.status).toBe("backlog");
16
+ expect(result.priority).toBe("medium");
17
+ });
18
+
19
+ test("parses full topic", () => {
20
+ const input = {
21
+ topic_id: "topic_002",
22
+ title: "AI Tools",
23
+ description: "About AI tools",
24
+ priority: "high",
25
+ tags: ["AI", "tools"],
26
+ notes: "Some notes",
27
+ status: "scheduled",
28
+ ai_score: 85,
29
+ ai_reason: "Good topic",
30
+ scheduled_date: "2026-06-15",
31
+ target_account: "default",
32
+ run_id: "run_001",
33
+ retro_performance: "good",
34
+ retro_lessons: "Learned something",
35
+ retro_metrics_snapshot: { reads: 1500 },
36
+ created_at: "2026-06-11T00:00:00Z",
37
+ updated_at: "2026-06-11T00:00:00Z",
38
+ };
39
+
40
+ const result = TopicSchema.parse(input);
41
+ expect(result.priority).toBe("high");
42
+ expect(result.tags).toEqual(["AI", "tools"]);
43
+ expect(result.ai_score).toBe(85);
44
+ });
45
+
46
+ test("rejects invalid priority", () => {
47
+ const input = {
48
+ topic_id: "topic_003",
49
+ title: "Test",
50
+ priority: "urgent",
51
+ created_at: "2026-06-11T00:00:00Z",
52
+ updated_at: "2026-06-11T00:00:00Z",
53
+ };
54
+
55
+ expect(() => TopicSchema.parse(input)).toThrow();
56
+ });
57
+
58
+ test("rejects invalid ai_score", () => {
59
+ const input = {
60
+ topic_id: "topic_004",
61
+ title: "Test",
62
+ ai_score: 150,
63
+ created_at: "2026-06-11T00:00:00Z",
64
+ updated_at: "2026-06-11T00:00:00Z",
65
+ };
66
+
67
+ expect(() => TopicSchema.parse(input)).toThrow();
68
+ });
69
+ });