@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
@@ -16,14 +16,14 @@
16
16
  import { parseArgs, requireArg, optionalArg, flagArg } from "../args";
17
17
  import { printResult, renderPublish } from "../output";
18
18
  import { loadConfig, resolveWorkspacePaths } from "../config";
19
- import { getPublishProvider } from "../providers";
20
19
  import {
21
20
  readState,
22
21
  validateForPhase,
23
22
  writeState,
24
- type PublishResult,
23
+ type PublishTarget,
25
24
  type RoutePrimary,
26
25
  } from "../state";
26
+ import { executePublishTargets } from "../providers/publish-core";
27
27
 
28
28
  export async function publish(args: string[]): Promise<void> {
29
29
  const parsed = parseArgs(args);
@@ -34,7 +34,7 @@ Usage: zzhub-pipeline publish [options]
34
34
 
35
35
  Options:
36
36
  --state Path to state JSON (required)
37
- --route Only publish this route (optional; default: all routes)
37
+ --route Only publish this route (optional; default: all targets)
38
38
  --dry-run Print commands without executing (optional)
39
39
  `.trim());
40
40
  return;
@@ -58,72 +58,45 @@ Options:
58
58
  );
59
59
  }
60
60
 
61
- // Collect all routes to publish
62
- const routes: RoutePrimary[] = routeOverride
63
- ? [routeOverride]
64
- : [state.route.primary, ...state.route.extras];
65
-
66
- const results: PublishResult[] = [...state.publish.results];
67
- let hasUnfinishedPublish = false;
68
-
69
- for (const route of routes) {
70
- // ── Idempotency check ──
71
- const existing = results.find((r) => r.route === route);
72
- if (
73
- existing &&
74
- existing.status === "success" &&
75
- existing.content_version === state.artifacts.content_version &&
76
- existing.render_version === state.artifacts.render_version
77
- ) {
78
- console.error(`[publish] Skipping ${route}: already published at current version`);
79
- continue;
61
+ // Determine targets
62
+ let targets: PublishTarget[];
63
+ if (routeOverride) {
64
+ targets = [{ route: routeOverride, account: state.route.account }];
65
+ } else if (state.publish_targets.length > 0) {
66
+ targets = state.publish_targets;
67
+ } else {
68
+ // Derive from route (backward compat)
69
+ targets = [{ route: state.route.primary, account: state.route.account }];
70
+ for (const extra of state.route.extras) {
71
+ targets.push({ route: extra, account: state.route.account });
80
72
  }
73
+ }
81
74
 
82
- try {
83
- const provider = getPublishProvider(route);
84
- const result = await provider({
85
- state,
86
- dryRun,
87
- config,
88
- workspacePaths,
89
- });
90
-
91
- // Upsert result
92
- const idx = results.findIndex((r) => r.route === route);
93
- if (idx >= 0) {
94
- results[idx] = result;
95
- } else {
96
- results.push(result);
97
- }
75
+ const { results, errors } = await executePublishTargets({
76
+ state,
77
+ targets,
78
+ dryRun,
79
+ config,
80
+ workspacePaths,
81
+ });
98
82
 
99
- if (result.status !== "success" && result.status !== "skipped") {
100
- hasUnfinishedPublish = true;
101
- }
102
- } catch (err) {
103
- const detail = err instanceof Error ? err.message : String(err);
104
- const failResult: PublishResult = {
105
- route,
106
- status: "failed",
107
- detail,
108
- published_at: null,
109
- content_version: state.artifacts.content_version,
110
- render_version: state.artifacts.render_version,
111
- };
112
- const idx = results.findIndex((r) => r.route === route);
113
- if (idx >= 0) {
114
- results[idx] = failResult;
115
- } else {
116
- results.push(failResult);
117
- }
118
- hasUnfinishedPublish = true;
83
+ // Upsert results into state
84
+ for (const result of results) {
85
+ const idx = state.publish.results.findIndex(
86
+ (r) => r.route === result.route && r.account === result.account,
87
+ );
88
+ if (idx >= 0) {
89
+ state.publish.results[idx] = result;
90
+ } else {
91
+ state.publish.results.push(result);
119
92
  }
120
93
  }
121
94
 
122
- // Update state
123
- state.publish.results = results;
124
-
125
- const allDone = routes.every((route) => {
126
- const r = results.find((x) => x.route === route);
95
+ // Check if all targets are done
96
+ const allDone = targets.every((target) => {
97
+ const r = state.publish.results.find(
98
+ (x) => x.route === target.route && x.account === target.account,
99
+ );
127
100
  return r && (r.status === "success" || r.status === "skipped");
128
101
  });
129
102
 
@@ -131,7 +104,7 @@ Options:
131
104
  state.mode = "done";
132
105
  state.phase.publish = { status: "done", error: null };
133
106
  state.phase.current = "done";
134
- } else if (hasUnfinishedPublish) {
107
+ } else {
135
108
  state.mode = "active";
136
109
  state.phase.publish = { status: "pending", error: null };
137
110
  state.phase.current = "publish";
@@ -139,9 +112,13 @@ Options:
139
112
 
140
113
  await writeState(statePath, state);
141
114
 
142
- printResult({
143
- publish_results: results,
115
+ const output: any = {
116
+ publish_results: state.publish.results,
144
117
  mode: state.mode,
145
118
  phase: state.phase.current,
146
- }, renderPublish);
119
+ };
120
+ if (errors.length > 0) {
121
+ output.errors = errors;
122
+ }
123
+ printResult(output, renderPublish);
147
124
  }
@@ -0,0 +1,146 @@
1
+ /**
2
+ * republish — Add publish targets and execute for new targets only.
3
+ *
4
+ * Used to publish a completed task to additional accounts/platforms
5
+ * without re-rendering.
6
+ *
7
+ * Usage:
8
+ * zzhub-pipeline republish \
9
+ * --state /path/to/state.json \
10
+ * [--account ancientone] \
11
+ * [--targets "wechat-article@ancientone,blog@default"] \
12
+ * [--dry-run]
13
+ *
14
+ * Output: Updated state with new publish.results entries
15
+ */
16
+
17
+ import { parseArgs, requireArg, optionalArg, flagArg } from "../args";
18
+ import { printResult } from "../output";
19
+ import { loadConfig, resolveWorkspacePaths } from "../config";
20
+ import {
21
+ readState,
22
+ writeState,
23
+ type PublishTarget,
24
+ type RoutePrimary,
25
+ } from "../state";
26
+ import { executePublishTargets } from "../providers/publish-core";
27
+
28
+ export async function republish(args: string[]): Promise<void> {
29
+ const parsed = parseArgs(args);
30
+
31
+ if (parsed.help) {
32
+ console.log(`
33
+ Usage: zzhub-pipeline republish [options]
34
+
35
+ Add publish targets and execute for new targets only.
36
+ Used to publish a completed task to additional accounts/platforms.
37
+
38
+ Options:
39
+ --state Path to state JSON (required)
40
+ --account Add target with state.route.primary and this account (optional)
41
+ --targets Comma-separated: route@account,route@account (optional)
42
+ --dry-run Print commands without executing (optional)
43
+
44
+ Examples:
45
+ # Add single target (same route, different account)
46
+ zzhub-pipeline republish --state path --account ancientone
47
+
48
+ # Add multiple targets
49
+ zzhub-pipeline republish --state path --targets "wechat-article@ancientone,blog@default"
50
+
51
+ # Mix both
52
+ zzhub-pipeline republish --state path --account ancientone --targets "blog@default"
53
+ `.trim());
54
+ return;
55
+ }
56
+
57
+ const statePath = requireArg(parsed, "state", "state JSON path");
58
+ const accountArg = optionalArg(parsed, "account");
59
+ const targetsArg = optionalArg(parsed, "targets");
60
+ const dryRun = flagArg(parsed, "dry-run");
61
+
62
+ const state = await readState(statePath);
63
+ const config = loadConfig();
64
+ const workspacePaths = resolveWorkspacePaths(state.workspace_root, config);
65
+
66
+ // Validate prerequisites
67
+ if (!state.asset_path) {
68
+ throw new Error("asset_path not set. Cannot republish.");
69
+ }
70
+ if (state.content_review.status !== "passed") {
71
+ throw new Error("content_review must be 'passed' before republish.");
72
+ }
73
+
74
+ // Parse new targets
75
+ const newTargets: PublishTarget[] = [];
76
+
77
+ // --account: add {state.route.primary, account}
78
+ if (accountArg) {
79
+ newTargets.push({
80
+ route: state.route.primary,
81
+ account: accountArg,
82
+ });
83
+ }
84
+
85
+ // --targets: parse "route@account,route@account,..."
86
+ if (targetsArg) {
87
+ const parts = targetsArg.split(",").map((t) => t.trim());
88
+ for (const part of parts) {
89
+ const atIdx = part.indexOf("@");
90
+ if (atIdx !== -1) {
91
+ const route = part.slice(0, atIdx).trim() as RoutePrimary;
92
+ const account = part.slice(atIdx + 1).trim();
93
+ newTargets.push({ route, account });
94
+ } else {
95
+ // No @: use state.route.primary as route, part as account
96
+ newTargets.push({
97
+ route: state.route.primary,
98
+ account: part.trim(),
99
+ });
100
+ }
101
+ }
102
+ }
103
+
104
+ if (newTargets.length === 0) {
105
+ throw new Error("No targets specified. Use --account or --targets.");
106
+ }
107
+
108
+ // Append to publish_targets (dedupe handled by executePublishTargets)
109
+ state.publish_targets = [...state.publish_targets, ...newTargets];
110
+
111
+ // Execute (executePublishTargets handles dedupe and idempotent filter)
112
+ const { results, errors } = await executePublishTargets({
113
+ state,
114
+ targets: newTargets,
115
+ dryRun,
116
+ config,
117
+ workspacePaths,
118
+ });
119
+
120
+ // Upsert results
121
+ for (const result of results) {
122
+ const idx = state.publish.results.findIndex(
123
+ (r) => r.route === result.route && r.account === result.account,
124
+ );
125
+ if (idx >= 0) {
126
+ state.publish.results[idx] = result;
127
+ } else {
128
+ state.publish.results.push(result);
129
+ }
130
+ }
131
+
132
+ // Mode stays done (or whatever it was)
133
+ state.updated_at = new Date().toISOString();
134
+
135
+ await writeState(statePath, state);
136
+
137
+ const output: any = {
138
+ publish_results: state.publish.results,
139
+ mode: state.mode,
140
+ new_targets: newTargets,
141
+ };
142
+ if (errors.length > 0) {
143
+ output.errors = errors;
144
+ }
145
+ printResult(output);
146
+ }
@@ -1,7 +1,7 @@
1
1
  import { parseArgs, requireArg, flagArg } from "../args";
2
2
  import { printResult, renderSyncBlog } from "../output";
3
3
  import { loadConfig, resolveWorkspacePaths } from "../config";
4
- import { readState } from "../state";
4
+ import { readState, updateState } from "../state";
5
5
  import { publishBlogRoute } from "../providers/blog";
6
6
 
7
7
  export async function syncBlog(args: string[]): Promise<void> {
@@ -20,13 +20,16 @@ Options:
20
20
 
21
21
  const statePath = requireArg(parsed, "state", "state JSON path");
22
22
  const dryRun = flagArg(parsed, "dry-run");
23
+
24
+ const config = loadConfig();
25
+
26
+ // Run the actual publish (may have network side effects)
23
27
  const state = await readState(statePath);
24
28
 
25
29
  if (!state.asset_path) {
26
30
  throw new Error("asset_path not set. Run prepare-finalize first.");
27
31
  }
28
32
 
29
- const config = loadConfig();
30
33
  const workspacePaths = resolveWorkspacePaths(state.workspace_root, config);
31
34
  const result = await publishBlogRoute({
32
35
  state,
@@ -35,5 +38,18 @@ Options:
35
38
  workspacePaths,
36
39
  });
37
40
 
38
- printResult(result, renderSyncBlog);
41
+ // Write result back to state
42
+ const finalState = await updateState(statePath, (s) => {
43
+ const idx = s.publish.results.findIndex((r) => r.route === "blog");
44
+ if (idx >= 0) {
45
+ s.publish.results[idx] = result;
46
+ } else {
47
+ s.publish.results.push(result);
48
+ }
49
+ });
50
+
51
+ printResult({
52
+ ...result,
53
+ mode: finalState.mode,
54
+ }, renderSyncBlog);
39
55
  }
@@ -0,0 +1,146 @@
1
+ import { describe, test, expect, beforeEach, afterEach } from "bun:test";
2
+ import { mkdtemp, rm } from "fs/promises";
3
+ import { join } from "path";
4
+ import { tmpdir } from "os";
5
+ import { addTopic, listTopics, updateTopic, scheduleTopic, retroTopic, abandonTopic } from "./topic";
6
+
7
+ describe("topic commands", () => {
8
+ let workspace: string;
9
+
10
+ beforeEach(async () => {
11
+ workspace = await mkdtemp(join(tmpdir(), "zzhub-test-"));
12
+ });
13
+
14
+ afterEach(async () => {
15
+ await rm(workspace, { recursive: true, force: true });
16
+ });
17
+
18
+ describe("addTopic", () => {
19
+ test("creates a new topic with defaults", async () => {
20
+ const result = await addTopic(workspace, {
21
+ title: "Test Topic",
22
+ });
23
+
24
+ expect(result.topic_id).toMatch(/^topic_/);
25
+ expect(result.title).toBe("Test Topic");
26
+ expect(result.status).toBe("backlog");
27
+ expect(result.priority).toBe("medium");
28
+ });
29
+
30
+ test("creates topic with all fields", async () => {
31
+ const result = await addTopic(workspace, {
32
+ title: "AI Tools",
33
+ description: "About AI",
34
+ priority: "high",
35
+ tags: ["AI", "tools"],
36
+ notes: "Some notes",
37
+ });
38
+
39
+ expect(result.priority).toBe("high");
40
+ expect(result.tags).toEqual(["AI", "tools"]);
41
+ expect(result.notes).toBe("Some notes");
42
+ });
43
+ });
44
+
45
+ describe("listTopics", () => {
46
+ test("lists all topics", async () => {
47
+ await addTopic(workspace, { title: "Topic 1" });
48
+ await addTopic(workspace, { title: "Topic 2" });
49
+
50
+ const topics = await listTopics(workspace, {});
51
+ expect(topics.length).toBe(2);
52
+ });
53
+
54
+ test("filters by status", async () => {
55
+ await addTopic(workspace, { title: "Topic 1", status: "backlog" });
56
+ await addTopic(workspace, { title: "Topic 2", status: "scheduled" });
57
+
58
+ const topics = await listTopics(workspace, { status: "backlog" });
59
+ expect(topics.length).toBe(1);
60
+ expect(topics[0].title).toBe("Topic 1");
61
+ });
62
+
63
+ test("filters by priority", async () => {
64
+ await addTopic(workspace, { title: "Topic 1", priority: "high" });
65
+ await addTopic(workspace, { title: "Topic 2", priority: "low" });
66
+
67
+ const topics = await listTopics(workspace, { priority: "high" });
68
+ expect(topics.length).toBe(1);
69
+ });
70
+
71
+ test("limits results", async () => {
72
+ await addTopic(workspace, { title: "Topic 1" });
73
+ await addTopic(workspace, { title: "Topic 2" });
74
+ await addTopic(workspace, { title: "Topic 3" });
75
+
76
+ const topics = await listTopics(workspace, { limit: 2 });
77
+ expect(topics.length).toBe(2);
78
+ });
79
+ });
80
+
81
+ describe("updateTopic", () => {
82
+ test("updates topic fields", async () => {
83
+ const topic = await addTopic(workspace, { title: "Original" });
84
+
85
+ const updated = await updateTopic(workspace, topic.topic_id, {
86
+ status: "evaluating",
87
+ ai_score: 85,
88
+ ai_reason: "Good topic",
89
+ });
90
+
91
+ expect(updated.status).toBe("evaluating");
92
+ expect(updated.ai_score).toBe(85);
93
+ expect(updated.ai_reason).toBe("Good topic");
94
+ });
95
+
96
+ test("throws if topic not found", async () => {
97
+ expect(
98
+ updateTopic(workspace, "nonexistent", { status: "scheduled" })
99
+ ).rejects.toThrow("Topic not found");
100
+ });
101
+ });
102
+
103
+ describe("scheduleTopic", () => {
104
+ test("schedules topic with date", async () => {
105
+ const topic = await addTopic(workspace, { title: "Test" });
106
+
107
+ const scheduled = await scheduleTopic(workspace, topic.topic_id, {
108
+ scheduled_date: "2026-06-15",
109
+ target_account: "default",
110
+ });
111
+
112
+ expect(scheduled.status).toBe("scheduled");
113
+ expect(scheduled.scheduled_date).toBe("2026-06-15");
114
+ expect(scheduled.target_account).toBe("default");
115
+ });
116
+ });
117
+
118
+ describe("retroTopic", () => {
119
+ test("adds retro and marks published", async () => {
120
+ const topic = await addTopic(workspace, { title: "Test" });
121
+
122
+ const retroed = await retroTopic(workspace, topic.topic_id, {
123
+ performance: "good",
124
+ lessons: "Learned something",
125
+ metrics_snapshot: { reads: 1500 },
126
+ });
127
+
128
+ expect(retroed.status).toBe("published");
129
+ expect(retroed.retro_performance).toBe("good");
130
+ expect(retroed.retro_lessons).toBe("Learned something");
131
+ });
132
+ });
133
+
134
+ describe("abandonTopic", () => {
135
+ test("marks topic as abandoned", async () => {
136
+ const topic = await addTopic(workspace, { title: "Test" });
137
+
138
+ const abandoned = await abandonTopic(workspace, topic.topic_id, {
139
+ reason: "Topic outdated",
140
+ });
141
+
142
+ expect(abandoned.status).toBe("abandoned");
143
+ expect(abandoned.notes).toContain("Topic outdated");
144
+ });
145
+ });
146
+ });