@gobing-ai/knowledge-kit 0.0.34 → 0.0.36

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 (130) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/README.md +1 -1
  3. package/dist/board/collect/index.css +42 -0
  4. package/dist/board/collect/index.js +787 -0
  5. package/dist/board/daily-voice/index.css +13 -0
  6. package/dist/board/daily-voice/index.js +902 -0
  7. package/dist/board/itc/index.css +14 -0
  8. package/dist/board/itc/index.js +1026 -0
  9. package/dist/index.js +11769 -9250
  10. package/package.json +10 -9
  11. package/plugins/generations/content-gen/dist/index.js +1015 -10814
  12. package/plugins/generations/content-gen/package.json +1 -1
  13. package/plugins/generations/core-facts-gen/dist/index.js +1002 -10801
  14. package/plugins/generations/core-facts-gen/package.json +1 -1
  15. package/plugins/generations/daily-article-gen/dist/index.js +1019 -10804
  16. package/plugins/generations/daily-article-gen/package.json +1 -1
  17. package/plugins/generations/daily-article-gen/src/index.ts +32 -3
  18. package/plugins/generations/dailynews-gen/dist/index.js +1094 -10892
  19. package/plugins/generations/dailynews-gen/package.json +1 -1
  20. package/plugins/generations/dailynews-gen/src/script-builder.ts +6 -1
  21. package/plugins/generations/episode-plan-gen/dist/index.js +1124 -10923
  22. package/plugins/generations/episode-plan-gen/package.json +1 -1
  23. package/plugins/generations/gemini-tts/dist/index.js +1108 -10906
  24. package/plugins/generations/gemini-tts/package.json +1 -1
  25. package/plugins/generations/image-gen/dist/index.js +1239 -11035
  26. package/plugins/generations/image-gen/package.json +1 -1
  27. package/plugins/generations/news-report-gen/dist/index.js +1048 -10847
  28. package/plugins/generations/news-report-gen/package.json +1 -1
  29. package/plugins/generations/omni-voice-gen/plugin.json +1 -1
  30. package/plugins/generations/voice-gen/dist/index.js +1066 -10864
  31. package/plugins/generations/voice-gen/package.json +1 -1
  32. package/plugins/generations/voice-gen/src/index.ts +9 -3
  33. package/plugins/generations/voxcpm-tts/README.md +72 -0
  34. package/plugins/generations/voxcpm-tts/dist/index.js +14006 -0
  35. package/plugins/generations/voxcpm-tts/package.json +16 -0
  36. package/plugins/generations/voxcpm-tts/plugin.json +6 -0
  37. package/plugins/generations/voxcpm-tts/profiles.json +10 -0
  38. package/plugins/generations/voxcpm-tts/pyproject.toml +9 -0
  39. package/plugins/generations/voxcpm-tts/python/worker.py +353 -0
  40. package/plugins/generations/voxcpm-tts/src/index.ts +42 -0
  41. package/plugins/generations/voxcpm-tts/src/voxcpm-client.ts +765 -0
  42. package/plugins/generations/voxcpm-tts/tsconfig.json +8 -0
  43. package/plugins/generations/voxcpm-tts/uv.lock +2571 -0
  44. package/plugins/ingestions/aihot-ingest/dist/index.js +1081 -10628
  45. package/plugins/ingestions/aihot-ingest/package.json +1 -1
  46. package/plugins/ingestions/horizon-ingest/dist/index.js +1013 -10812
  47. package/plugins/ingestions/horizon-ingest/package.json +1 -1
  48. package/plugins/ingestions/karakeep-local/dist/index.js +689 -10774
  49. package/plugins/ingestions/karakeep-local/package.json +1 -1
  50. package/plugins/ingestions/last30days-ingest/dist/index.js +1011 -10810
  51. package/plugins/ingestions/last30days-ingest/package.json +1 -1
  52. package/plugins/ingestions/web-search/dist/index.js +1250 -11033
  53. package/plugins/ingestions/web-search/package.json +1 -1
  54. package/plugins/kk/README.md +29 -2
  55. package/plugins/kk/plugin.json +1 -1
  56. package/plugins/kk/schedules/cron.tmpl +9 -1
  57. package/plugins/kk/schedules/kk-serve.launchd.plist.tmpl +79 -0
  58. package/plugins/kk/schedules/launchd.plist.tmpl +11 -1
  59. package/plugins/kk/scripts/daily-stages.ts +702 -63
  60. package/plugins/kk/scripts/kk-workflow-stages.ts +116 -146
  61. package/plugins/kk/skills/content-safety/references/platforms/wechatmp.md +2 -2
  62. package/plugins/kk/skills/content-safety/references/platforms/xiaoyuzhou.md +4 -2
  63. package/plugins/kk/skills/publish/SKILL.md +8 -4
  64. package/plugins/kk/workflows/feature-verification.yaml +93 -0
  65. package/plugins/kk/workflows/judge-gated-publish-example.yaml +1 -0
  66. package/plugins/kk/workflows/kk-collect.yaml +43 -11
  67. package/plugins/kk/workflows/kk-daily-ai-voice.yaml +36 -9
  68. package/plugins/kk/workflows/kk-daily-ai-voice2.yaml +92 -18
  69. package/plugins/kk/workflows/kk-daily-safety.yaml +16 -3
  70. package/plugins/kk/workflows/kk-itc.yaml +8 -1
  71. package/plugins/kk/workflows/kk-solo-podcast.yaml +2 -0
  72. package/plugins/publishings/emdash-pub/dist/index.js +987 -10814
  73. package/plugins/publishings/emdash-pub/package.json +1 -1
  74. package/plugins/publishings/infoq-pub/dist/index.js +1928 -11444
  75. package/plugins/publishings/infoq-pub/dist/profile-check.js +20814 -0
  76. package/plugins/publishings/infoq-pub/package.json +1 -1
  77. package/plugins/publishings/infoq-pub/plugin.json +6 -2
  78. package/plugins/publishings/juejin-pub/dist/index.js +1940 -11456
  79. package/plugins/publishings/juejin-pub/dist/profile-check.js +20800 -0
  80. package/plugins/publishings/juejin-pub/package.json +1 -1
  81. package/plugins/publishings/juejin-pub/plugin.json +6 -2
  82. package/plugins/publishings/medium-pub/dist/index.js +978 -10805
  83. package/plugins/publishings/medium-pub/package.json +1 -1
  84. package/plugins/publishings/podcast-pub/dist/index.js +1016 -10843
  85. package/plugins/publishings/podcast-pub/package.json +1 -1
  86. package/plugins/publishings/qiita-pub/dist/index.js +976 -10803
  87. package/plugins/publishings/qiita-pub/package.json +1 -1
  88. package/plugins/publishings/substack-pub/dist/index.js +1932 -11448
  89. package/plugins/publishings/substack-pub/dist/profile-check.js +20890 -0
  90. package/plugins/publishings/substack-pub/package.json +1 -1
  91. package/plugins/publishings/substack-pub/plugin.json +5 -2
  92. package/plugins/publishings/substack-pub/src/profile-check.ts +36 -3
  93. package/plugins/publishings/surfdash-pub/dist/index.js +1151 -10834
  94. package/plugins/publishings/surfdash-pub/package.json +1 -1
  95. package/plugins/publishings/surfdash-pub/src/index.ts +137 -15
  96. package/plugins/publishings/surfdash-pub/src/patch.ts +144 -0
  97. package/plugins/publishings/threads-pub/dist/index.js +12394 -0
  98. package/plugins/publishings/threads-pub/package.json +17 -0
  99. package/plugins/publishings/threads-pub/plugin.json +7 -0
  100. package/plugins/publishings/threads-pub/src/index.ts +340 -0
  101. package/plugins/publishings/threads-pub/tsconfig.json +4 -0
  102. package/plugins/publishings/wechatmp-pub/dist/index.js +2638 -11650
  103. package/plugins/publishings/wechatmp-pub/dist/profile-check.js +20739 -0
  104. package/plugins/publishings/wechatmp-pub/package.json +1 -1
  105. package/plugins/publishings/wechatmp-pub/plugin.json +6 -2
  106. package/plugins/publishings/wechatmp-pub/src/enrich.ts +297 -163
  107. package/plugins/publishings/wechatmp-pub/src/flow.ts +611 -54
  108. package/plugins/publishings/wechatmp-pub/src/index.ts +24 -3
  109. package/plugins/publishings/wechatmp-pub/src/page.ts +36 -0
  110. package/plugins/publishings/wechatmp-pub/src/profile-check.ts +40 -4
  111. package/plugins/publishings/weibo-pub/dist/index.js +21345 -0
  112. package/plugins/publishings/weibo-pub/dist/profile-check.js +20817 -0
  113. package/plugins/publishings/weibo-pub/package.json +19 -0
  114. package/plugins/publishings/weibo-pub/plugin.json +12 -0
  115. package/plugins/publishings/weibo-pub/src/index.ts +868 -0
  116. package/plugins/publishings/weibo-pub/src/profile-check.ts +20 -0
  117. package/plugins/publishings/weibo-pub/tsconfig.json +4 -0
  118. package/plugins/publishings/x-pub/dist/index.js +1948 -11440
  119. package/plugins/publishings/x-pub/dist/profile-check.js +20806 -0
  120. package/plugins/publishings/x-pub/package.json +1 -1
  121. package/plugins/publishings/x-pub/plugin.json +6 -2
  122. package/plugins/publishings/xhs-pub/dist/index.js +1993 -11509
  123. package/plugins/publishings/xhs-pub/dist/profile-check.js +21023 -0
  124. package/plugins/publishings/xhs-pub/package.json +1 -1
  125. package/plugins/publishings/xhs-pub/plugin.json +6 -2
  126. package/plugins/publishings/xhs-pub/src/profile-check.ts +72 -2
  127. package/plugins/publishings/zenn-pub/dist/index.js +988 -10815
  128. package/plugins/publishings/zenn-pub/package.json +1 -1
  129. package/plugins/generations/omni-voice-gen/dist/omni-voice-gen-prr8skpb. +0 -2
  130. package/plugins/generations/omni-voice-gen/dist/omni-voice-gen.js +0 -6
@@ -12,6 +12,6 @@
12
12
  "@gobing-ai/ts-utils": "catalog:"
13
13
  },
14
14
  "devDependencies": {
15
- "@types/bun": "1.3.14"
15
+ "@types/bun": "1.4.3"
16
16
  }
17
17
  }
@@ -1,17 +1,18 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
2
  import { copyFile, cp, writeFile } from 'node:fs/promises';
3
- import { dirname, extname, isAbsolute, join, resolve, sep } from 'node:path';
3
+ import { dirname, extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
4
4
  import { parseArgs } from 'node:util';
5
5
  import { type Content, ContentSchema, type Result, ResultSchema } from '@gobing-ai/kk-core';
6
6
  import type { PublishTransport, PublishTransportPayload } from '@gobing-ai/publish-harness';
7
7
  import { createNodeFileSystem } from '@gobing-ai/ts-runtime';
8
8
  import { echoError } from '@gobing-ai/ts-utils';
9
+ import { type PatchPostOutcome, patchPostFiles } from './patch';
9
10
 
10
11
  /** Result target name for this plugin. */
11
12
  const TARGET = 'surfdash';
12
13
 
13
14
  /** Management operations routed via `options.operation` (L1 R7; pattern from podcast-pub). */
14
- const OPERATIONS = ['publish', 'scaffold-locale', 'sync'] as const;
15
+ const OPERATIONS = ['publish', 'scaffold-locale', 'sync', 'patch'] as const;
15
16
  type Operation = (typeof OPERATIONS)[number];
16
17
 
17
18
  /** Locales `scaffold-locale` can scaffold; LLM translation stays in the workflow's agent step. */
@@ -24,10 +25,18 @@ const COVER_EXTS = new Set(['.png', '.jpg', '.jpeg', '.webp', '.gif']);
24
25
  /** Target-specific options carried on the invoke payload's `options` channel. */
25
26
  interface SurfdashPubOptions {
26
27
  operation?: unknown;
27
- /** zh `index.md` absolute path inside the surfdash tree; required for `scaffold-locale` and `sync`. */
28
+ /** zh `index.md` absolute path inside the surfdash tree; required for `scaffold-locale`, `sync` and `patch`. */
28
29
  postPath?: unknown;
29
30
  /** Draft locale to scaffold; required for `scaffold-locale`. */
30
31
  target?: unknown;
32
+ /** `patch`: podcast-hosted cover URL for `image`/`og_image` (empty → the local cover fallback). */
33
+ coverUrl?: unknown;
34
+ /** `patch`: podcast episode URL appended as the cross-link (empty → none). */
35
+ showNotesUrl?: unknown;
36
+ /** `patch`: this run's rendered cover, used when `coverUrl` is empty. */
37
+ localCover?: unknown;
38
+ /** `patch`: `YYYY-MM-DD` episode date for the cross-link text. */
39
+ dateIso?: unknown;
31
40
  }
32
41
 
33
42
  /**
@@ -81,9 +90,16 @@ export function mapContentToSurfingMarkdown(content: Content, options: MapConten
81
90
  : Array.isArray(content.metadata?.keywords)
82
91
  ? content.metadata.keywords
83
92
  : [];
84
- const tags = rawTags
85
- .filter((t): t is string => typeof t === 'string' && t.trim().length > 0)
86
- .map((t: string) => slugify(t));
93
+ // `slugify` drops non-ASCII, so a Chinese tag (`硬件发布`) collapsed to '' and shipped as an empty
94
+ // YAML tag — the site renders it as a blank chip (seen on 20261011-ai-2026-10-11). Case variants
95
+ // also collapsed to the same slug twice (`AI` + `ai`), so the set dedupes.
96
+ const tags = [
97
+ ...new Set(
98
+ rawTags
99
+ .filter((t): t is string => typeof t === 'string' && t.trim().length > 0)
100
+ .map((t: string) => slugify(t)),
101
+ ),
102
+ ].filter((tag) => tag.length > 0);
87
103
 
88
104
  const isoDate = options.publishDate ?? new Date().toISOString().slice(0, 10);
89
105
  const firstRefUrl = content.references?.[0]?.url;
@@ -250,6 +266,10 @@ export type GitPushOutcome = { ok: true; hash: string } | { ok: false; error: st
250
266
  * out of the publish commit. Fails loud on branch/commit/push errors.
251
267
  */
252
268
  export async function pushPostGit(surfdashRoot: string, relPostDir: string, message: string): Promise<GitPushOutcome> {
269
+ return commitPostPaths(surfdashRoot, [relPostDir], message);
270
+ }
271
+ /** Refuse a checkout that cannot deploy before anything is written (`deploy.yml` builds `main` only). */
272
+ async function assertMainBranch(surfdashRoot: string): Promise<{ ok: true } | { ok: false; error: string }> {
253
273
  const branch = await spawnCli(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], surfdashRoot);
254
274
  if (branch.exitCode !== 0) return { ok: false, error: `surfdash git branch check failed: ${cliErrorText(branch)}` };
255
275
  const branchName = branch.stdout.trim();
@@ -259,14 +279,34 @@ export async function pushPostGit(surfdashRoot: string, relPostDir: string, mess
259
279
  error: `surfdash checkout is on '${branchName}', not 'main' — deploy.yml only builds main; refusing to push from a feature branch`,
260
280
  };
261
281
  }
262
- const add = await spawnCli(['git', 'add', '--', relPostDir], surfdashRoot);
282
+ return { ok: true };
283
+ }
284
+
285
+ /**
286
+ * Commits several paths of the surfdash checkout and pushes them to `main` — the plural form of
287
+ * {@link pushPostGit} the post-patch needs (it writes the posts and any copied cover). Same
288
+ * pathspec-scoped commit, so unrelated staged work is never swept in, and the same idempotent retry:
289
+ * content identical to HEAD stages nothing and only pushes when `origin/main` lacks the paths.
290
+ */
291
+ export async function commitPostPaths(
292
+ surfdashRoot: string,
293
+ relPaths: string[],
294
+ message: string,
295
+ push = true,
296
+ ): Promise<GitPushOutcome> {
297
+ if (relPaths.length === 0) return { ok: true, hash: 'unchanged' };
298
+ const branch = await assertMainBranch(surfdashRoot);
299
+ if (!branch.ok) return branch;
300
+ const add = await spawnCli(['git', 'add', '--', ...relPaths], surfdashRoot);
263
301
  if (add.exitCode !== 0) return { ok: false, error: `surfdash git add failed: ${cliErrorText(add)}` };
264
- // Idempotent retry: post content identical to HEAD stages nothing. Only push when
265
- // origin lacks commits touching this post; a clean post means it's already live.
266
- const staged = await spawnCli(['git', 'diff', '--cached', '--quiet', '--', relPostDir], surfdashRoot);
302
+ const staged = await spawnCli(['git', 'diff', '--cached', '--quiet', '--', ...relPaths], surfdashRoot);
267
303
  if (staged.exitCode === 0) {
304
+ if (!push) {
305
+ const hash = await spawnCli(['git', 'rev-parse', '--short', 'HEAD'], surfdashRoot);
306
+ return { ok: true, hash: hash.stdout.trim() || 'unknown' };
307
+ }
268
308
  const unpushed = await spawnCli(
269
- ['git', 'diff', '--quiet', 'origin/main..HEAD', '--', relPostDir],
309
+ ['git', 'diff', '--quiet', 'origin/main..HEAD', '--', ...relPaths],
270
310
  surfdashRoot,
271
311
  );
272
312
  if (unpushed.exitCode === 0) {
@@ -274,14 +314,18 @@ export async function pushPostGit(surfdashRoot: string, relPostDir: string, mess
274
314
  return { ok: true, hash: hash.stdout.trim() || 'unknown' };
275
315
  }
276
316
  } else {
277
- const commit = await spawnCli(['git', 'commit', '-m', message, '--', relPostDir], surfdashRoot);
317
+ const commit = await spawnCli(['git', 'commit', '-m', message, '--', ...relPaths], surfdashRoot);
278
318
  if (commit.exitCode !== 0) return { ok: false, error: `surfdash git commit failed: ${cliErrorText(commit)}` };
279
319
  }
280
- const push = await spawnCli(['git', 'push', 'origin', 'main'], surfdashRoot);
281
- if (push.exitCode !== 0) {
320
+ if (!push) {
321
+ const hash = await spawnCli(['git', 'rev-parse', '--short', 'HEAD'], surfdashRoot);
322
+ return { ok: true, hash: hash.stdout.trim() || 'unknown' };
323
+ }
324
+ const pushed = await spawnCli(['git', 'push', 'origin', 'main'], surfdashRoot);
325
+ if (pushed.exitCode !== 0) {
282
326
  return {
283
327
  ok: false,
284
- error: `surfdash git push failed (deploy.yml deploys on push to main): ${cliErrorText(push)}`,
328
+ error: `surfdash git push failed (deploy.yml deploys on push to main): ${cliErrorText(pushed)}`,
285
329
  };
286
330
  }
287
331
  const hash = await spawnCli(['git', 'rev-parse', '--short', 'HEAD'], surfdashRoot);
@@ -433,6 +477,82 @@ async function syncPost(options: SurfdashPubOptions): Promise<Result> {
433
477
  }
434
478
  }
435
479
 
480
+ /** `options.<key>` when it is a string, trimmed; else ''. `patch`'s four optional fields read this way. */
481
+ function strOption(options: SurfdashPubOptions, key: 'coverUrl' | 'showNotesUrl' | 'localCover' | 'dateIso'): string {
482
+ const raw = options[key];
483
+ return typeof raw === 'string' ? raw.trim() : '';
484
+ }
485
+
486
+ /**
487
+ * `patch` — post-publish healing for one published episode (2026-10-12; moved here from the kk stage
488
+ * `daily-patch-surfdash`, which owned the publisher's work): the podcast-hosted cover URL in
489
+ * `image`/`og_image`, the per-locale podcast cross-link, the local cover's `assets/` fallback, the
490
+ * EmDash re-sync that makes CMS cards carry a servable image, then a pathspec commit + push.
491
+ *
492
+ * Ordering is load-bearing: the branch guard runs before anything is written (a checkout that cannot
493
+ * deploy must not be patched, and the failure leaves the tree untouched), and the re-sync runs even
494
+ * when no file changed, so a re-run heals rows an earlier run left stale.
495
+ */
496
+ async function patchPost(options: SurfdashPubOptions): Promise<Result> {
497
+ const postPath = resolvePostPath(options);
498
+ if ('error' in postPath) return fail(postPath.error);
499
+ const surfdashRoot = resolveSurfdashRoot();
500
+ if (!surfdashRoot) {
501
+ return fail('SURFDASH_ROOT is required when POSTSURFING_BIN is not a path inside the surfdash checkout');
502
+ }
503
+ const outsideRoot = postPathOutsideRoot(postPath.postPath, surfdashRoot);
504
+ if (outsideRoot) return fail(outsideRoot);
505
+ const branch = await assertMainBranch(surfdashRoot);
506
+ if (!branch.ok) return fail(branch.error);
507
+
508
+ let outcome: PatchPostOutcome;
509
+ try {
510
+ outcome = await patchPostFiles({
511
+ postPath: postPath.postPath,
512
+ coverUrl: strOption(options, 'coverUrl'),
513
+ showNotesUrl: strOption(options, 'showNotesUrl'),
514
+ localCover: strOption(options, 'localCover'),
515
+ dateIso: strOption(options, 'dateIso'),
516
+ });
517
+ } catch (error: unknown) {
518
+ return fail(`patch error: ${error instanceof Error ? error.message : String(error)}`);
519
+ }
520
+ if (outcome.changed === 0) echoError(`patch: no post bytes changed for ${outcome.slug}`);
521
+ const synced = await syncPost({ postPath: postPath.postPath });
522
+ const commit = await commitPostPaths(
523
+ surfdashRoot,
524
+ outcome.written.map((path) => relative(surfdashRoot, path)),
525
+ `feat: cover + podcast cross-link for ${outcome.slug} (kk daily patch)`,
526
+ process.env.SURFDASH_AUTO_PUSH !== '0',
527
+ );
528
+ if (!commit.ok) return fail(`patch: ${commit.error}`);
529
+ const metadata = {
530
+ postPath: postPath.postPath,
531
+ changed: outcome.changed,
532
+ locales: Object.keys(outcome.images),
533
+ absentLocales: outcome.absent,
534
+ cmsResync: synced.ok ? 'ok' : 'failed',
535
+ gitCommit: commit.hash,
536
+ };
537
+ if (!synced.ok) {
538
+ // The posts are patched and committed; only the CMS rows keep the unservable cover. Reported
539
+ // as a failure so the completeness report says `cms-resync=failed`, with `metadata.cmsResync`
540
+ // letting the caller treat it as the soft channel it is.
541
+ return ResultSchema.parse({
542
+ ok: false,
543
+ target: TARGET,
544
+ error: `cms re-sync failed: ${synced.error ?? 'ok=false'}`,
545
+ metadata,
546
+ });
547
+ }
548
+ return ResultSchema.parse({
549
+ ok: true,
550
+ target: TARGET,
551
+ message: `patched ${outcome.changed} post(s) for ${outcome.slug}`,
552
+ metadata,
553
+ });
554
+ }
555
+
436
556
  /**
437
557
  * Publish path (behavior unchanged since E3): maps Content into surfdash markdown under
438
558
  * `content/posts/<collection>/<locale>/<slug>/index.md` and invokes the postsurfing CLI.
@@ -565,6 +685,8 @@ export class PostsurfingFileCliTransport implements PublishTransport {
565
685
  return scaffoldLocale(options);
566
686
  case 'sync':
567
687
  return syncPost(options);
688
+ case 'patch':
689
+ return patchPost(options);
568
690
  case 'publish':
569
691
  return publishPost(payload);
570
692
  }
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Post-publish patching for a published daily episode (2026-10-12).
3
+ *
4
+ * Moved here from the kk stage `daily-patch-surfdash`, which owned the publisher's work: the
5
+ * podcast-hosted cover URL in `image`/`og_image`, the per-locale podcast cross-link, the local
6
+ * cover's `assets/` fallback, and the EmDash re-sync that makes CMS cards carry a servable image
7
+ * (the pre-patch `./assets/cover.png` returns 404 from the deployed worker — prod feedback
8
+ * 2026-09-29 / 2026-10-02 / 2026-10-11). Living in the publisher means every path that publishes a
9
+ * post can heal it: the voice1 workflow, the voice2 DAG and a hand run call one operation.
10
+ *
11
+ * This module owns only the file work (deterministic, no network); the caller (`./index`) owns the
12
+ * branch guard, the CMS re-sync and the git commit, in that order.
13
+ */
14
+ import { basename, dirname, join, sep } from 'node:path';
15
+ import { createNodeFileSystem } from '@gobing-ai/ts-runtime';
16
+
17
+ /** The locales a daily post carries; zh is the primary whose directory anchors the other two. */
18
+ export const PATCH_LOCALES = ['zh', 'en', 'ja'] as const;
19
+ export type PatchLocale = (typeof PATCH_LOCALES)[number];
20
+
21
+ /** Everything {@link patchPostFiles} needs; all paths absolute. */
22
+ export interface PatchPostRequest {
23
+ /** Absolute `<root>/content/posts/articles/zh/<slug>/index.md` — the primary post. */
24
+ postPath: string;
25
+ /** Podcast-hosted cover URL; empty → the local cover is copied into `assets/` instead. */
26
+ coverUrl: string;
27
+ /** Podcast episode URL for the cross-link line; empty → no cross-link. */
28
+ showNotesUrl: string;
29
+ /** This run's rendered cover, the fallback when `coverUrl` is empty. */
30
+ localCover: string;
31
+ /** `YYYY-MM-DD` episode date used in the cross-link text. */
32
+ dateIso: string;
33
+ }
34
+
35
+ /** What the file pass changed; the caller commits exactly {@link PatchPostOutcome.written}. */
36
+ export interface PatchPostOutcome {
37
+ /** Absolute paths written (posts plus any copied cover). */
38
+ written: string[];
39
+ /** Files whose bytes changed (not the copied cover). */
40
+ changed: number;
41
+ /** Locales with no published post on disk, in request order. */
42
+ absent: PatchLocale[];
43
+ /** Locale → the frontmatter image value the patch settled on. */
44
+ images: Record<string, string>;
45
+ /** The primary post's directory name, for the commit message. */
46
+ slug: string;
47
+ }
48
+
49
+ /**
50
+ * `…/articles/zh/<slug>/index.md` → the sibling locale's `index.md`, with the LAST `/zh/` segment
51
+ * swapped. Undefined when the path carries no `/zh/` segment, which the caller reports as absent.
52
+ */
53
+ export function localeIndexPath(postPath: string, locale: PatchLocale): string | undefined {
54
+ if (locale === 'zh') return postPath;
55
+ const marker = `${sep}zh${sep}`;
56
+ const idx = postPath.lastIndexOf(marker);
57
+ if (idx < 0) return undefined;
58
+ return `${postPath.slice(0, idx)}${sep}${locale}${sep}${postPath.slice(idx + marker.length)}`;
59
+ }
60
+
61
+ /**
62
+ * The podcast cross-link line per locale (task 0189 R4: the column's episode-title pattern, not the
63
+ * retired news-bulletin name).
64
+ */
65
+ export function crossLinkFor(locale: PatchLocale, dateIso: string, showNotesUrl: string): string {
66
+ const links: Record<PatchLocale, string> = {
67
+ zh: `🎧 本期已同步制作为播客节目,[点击收听《每日AI乱炖 · ${dateIso}》](${showNotesUrl})。`,
68
+ en: `🎧 This episode is also available as a podcast: [listen to 每日AI乱炖 · ${dateIso}](${showNotesUrl}).`,
69
+ ja: `🎧 本エピソードはポッドキャストでも配信中:[每日AI乱炖 · ${dateIso} を聴く](${showNotesUrl})。`,
70
+ };
71
+ return links[locale];
72
+ }
73
+
74
+ /** The closing `---` of a document's frontmatter, or -1 when there is none. */
75
+ function frontmatterEnd(markdown: string): number {
76
+ return markdown.indexOf('\n---', 3);
77
+ }
78
+
79
+ /**
80
+ * Upsert one single-line `key: 'value'` in a document's frontmatter.
81
+ *
82
+ * The regex consumes folded-scalar continuation lines (`og_image: >-\n https://…`) so multi-line
83
+ * publish output is replaced whole, and the replacement is a function so `$&`/`$1` in the value are
84
+ * never expanded. Scoped to the frontmatter, so a body line starting with `image:` is never
85
+ * rewritten. A key that is absent is inserted after `publishDate`.
86
+ */
87
+ export function upsertFrontmatterKey(markdown: string, key: string, value: string): string {
88
+ const end = frontmatterEnd(markdown);
89
+ if (end < 0) return markdown;
90
+ const fm = markdown.slice(0, end);
91
+ const body = markdown.slice(end);
92
+ const line = `${key}: '${value.replaceAll("'", "''")}'`;
93
+ const re = new RegExp(`^${key}:.*(?:\\n[ \\t]+.*)*`, 'm');
94
+ const next = re.test(fm) ? fm.replace(re, () => line) : fm.replace(/^publishDate: .*$/m, (m0) => `${m0}\n${line}`);
95
+ return next + body;
96
+ }
97
+
98
+ /**
99
+ * Patch the published posts of one episode: cover URL (or the local cover's `assets/` fallback),
100
+ * then the podcast cross-link. Idempotent per file — a post that already carries the values keeps
101
+ * its bytes and is not committed.
102
+ */
103
+ export async function patchPostFiles(request: PatchPostRequest): Promise<PatchPostOutcome> {
104
+ const fs = createNodeFileSystem();
105
+ const written: string[] = [];
106
+ const absent: PatchLocale[] = [];
107
+ const images: Record<string, string> = {};
108
+ let changed = 0;
109
+ for (const locale of PATCH_LOCALES) {
110
+ const indexMd = localeIndexPath(request.postPath, locale);
111
+ if (indexMd === undefined || !(await fs.exists(indexMd))) {
112
+ absent.push(locale);
113
+ continue;
114
+ }
115
+ const dir = dirname(indexMd);
116
+ const before = await fs.readFile(indexMd);
117
+ let md = before;
118
+ const end = frontmatterEnd(md);
119
+ const fm = end < 0 ? md : md.slice(0, end);
120
+ if (request.coverUrl !== '') {
121
+ md = upsertFrontmatterKey(md, 'image', request.coverUrl);
122
+ md = upsertFrontmatterKey(md, 'og_image', request.coverUrl);
123
+ images[locale] = request.coverUrl;
124
+ } else if (!/^image:/m.test(fm) && (await fs.exists(request.localCover))) {
125
+ // Fallback for a run without a podcast cover: ship the rendered cover with the post.
126
+ const asset = join(dir, 'assets', 'cover.png');
127
+ await fs.ensureDir(dirname(asset));
128
+ await fs.copy(request.localCover, asset);
129
+ written.push(asset);
130
+ md = upsertFrontmatterKey(md, 'image', './assets/cover.png');
131
+ md = upsertFrontmatterKey(md, 'og_image', './assets/cover.png');
132
+ images[locale] = './assets/cover.png';
133
+ }
134
+ if (request.showNotesUrl !== '' && !md.includes(request.showNotesUrl)) {
135
+ md = `${md.replace(/\s*$/, '')}\n\n${crossLinkFor(locale, request.dateIso, request.showNotesUrl)}\n`;
136
+ }
137
+ if (md !== before) {
138
+ await fs.writeFile(indexMd, md);
139
+ written.push(indexMd);
140
+ changed += 1;
141
+ }
142
+ }
143
+ return { written, changed, absent, images, slug: basename(dirname(request.postPath)) };
144
+ }