@gobing-ai/knowledge-kit 0.1.10 → 0.1.11
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/.claude-plugin/marketplace.json +1 -1
- package/dist/index.js +128 -25
- package/package.json +1 -1
- package/plugins/kk/plugin.json +1 -1
- package/plugins/kk/scripts/daily-scheduled-run.ts +298 -0
- package/plugins/kk/scripts/daily-stages.ts +1057 -108
- package/plugins/kk/scripts/kk-workflow-stages.ts +76 -10
- package/plugins/kk/scripts/safety-artifact-check.ts +235 -0
- package/plugins/kk/workflows/kk-daily-ai-voice2.yaml +168 -121
- package/plugins/kk/workflows/kk-daily-safety.yaml +129 -50
- package/plugins/publishings/bilibili-pub/dist/index.js +2 -1
- package/plugins/publishings/bilibili-pub/dist/profile-check.js +2 -1
- package/plugins/publishings/cnblogs-pub/dist/index.js +2 -1
- package/plugins/publishings/cnblogs-pub/dist/profile-check.js +2 -1
- package/plugins/publishings/csdn-pub/dist/index.js +2 -1
- package/plugins/publishings/csdn-pub/dist/profile-check.js +2 -1
- package/plugins/publishings/infoq-pub/dist/index.js +2 -1
- package/plugins/publishings/infoq-pub/dist/profile-check.js +2 -1
- package/plugins/publishings/jike-pub/dist/index.js +2 -1
- package/plugins/publishings/jike-pub/dist/profile-check.js +2 -1
- package/plugins/publishings/juejin-pub/dist/index.js +2 -1
- package/plugins/publishings/juejin-pub/dist/profile-check.js +2 -1
- package/plugins/publishings/podcast-pub/dist/index.js +46 -8
- package/plugins/publishings/podcast-pub/src/index.ts +62 -5
- package/plugins/publishings/shipinhao-pub/dist/index.js +2 -1
- package/plugins/publishings/shipinhao-pub/dist/profile-check.js +2 -1
- package/plugins/publishings/sspai-pub/dist/index.js +2 -1
- package/plugins/publishings/sspai-pub/dist/profile-check.js +2 -1
- package/plugins/publishings/substack-pub/dist/index.js +2 -1
- package/plugins/publishings/substack-pub/dist/profile-check.js +2 -1
- package/plugins/publishings/surfdash-pub/dist/index.js +460 -52
- package/plugins/publishings/surfdash-pub/src/digest-check.ts +85 -19
- package/plugins/publishings/surfdash-pub/src/index.ts +589 -64
- package/plugins/publishings/wechatmp-pub/dist/index.js +933 -98
- package/plugins/publishings/wechatmp-pub/dist/profile-check.js +60 -3
- package/plugins/publishings/wechatmp-pub/src/flow.ts +1125 -85
- package/plugins/publishings/wechatmp-pub/src/index.ts +112 -30
- package/plugins/publishings/wechatmp-pub/src/page.ts +105 -0
- package/plugins/publishings/weibo-pub/dist/index.js +2 -1
- package/plugins/publishings/weibo-pub/dist/profile-check.js +2 -1
- package/plugins/publishings/x-pub/dist/index.js +2 -1
- package/plugins/publishings/x-pub/dist/profile-check.js +2 -1
- package/plugins/publishings/xhs-pub/dist/index.js +2 -1
- package/plugins/publishings/xhs-pub/dist/profile-check.js +2 -1
- package/plugins/publishings/xiaoyuzhou-pub/dist/index.js +2 -1
- package/plugins/publishings/xiaoyuzhou-pub/dist/profile-check.js +2 -1
|
@@ -1,3 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* surfdash-pub plugin — publishing transport for the Surfdash site.
|
|
3
|
+
*
|
|
4
|
+
* Listen-link backfill:
|
|
5
|
+
* Runs `bun run sd seo listen-link --backfill --write` before the site gate and commit
|
|
6
|
+
* in both the main publish path (when auto-push is on) and the patch path. Any modified
|
|
7
|
+
* files under `content/posts` are included in the scoped commit pathspec.
|
|
8
|
+
*
|
|
9
|
+
* Site gate:
|
|
10
|
+
* Before committing and pushing post content to surfdash `main` (both in the main publish
|
|
11
|
+
* path and the patch path), `runSiteGate` executes `bun run sd check digest` and
|
|
12
|
+
* `bun run check:content` in the surfdash checkout. The surfdash site gate is the authoritative
|
|
13
|
+
* validation suite before deployment.
|
|
14
|
+
*
|
|
15
|
+
* Environment variables:
|
|
16
|
+
* - `SURFDASH_LISTEN_BACKFILL`:
|
|
17
|
+
* - `off`: skips the listen-link backfill command.
|
|
18
|
+
* - `SURFDASH_DIGEST_CHECK`:
|
|
19
|
+
* - unset / default: fail-loud on gate failure; commit and push are blocked.
|
|
20
|
+
* - `warn`: echoes gate failures via `echoError` prefixed with `site gate (warn):` and continues to push.
|
|
21
|
+
* - `off`: bypasses the gate check.
|
|
22
|
+
* - `SURFDASH_AUTO_PUSH`:
|
|
23
|
+
* - '0': disables auto-commit and push (used in tests and offline runs).
|
|
24
|
+
*/
|
|
25
|
+
import { createHash } from 'node:crypto';
|
|
1
26
|
import { existsSync, readFileSync } from 'node:fs';
|
|
2
27
|
import { copyFile, cp, writeFile } from 'node:fs/promises';
|
|
3
28
|
import { dirname, extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
@@ -16,12 +41,21 @@ export { checkDigestFrontmatter, DIGEST_TAG, type DigestLocale } from './digest-
|
|
|
16
41
|
const TARGET = 'surfdash';
|
|
17
42
|
|
|
18
43
|
/** Management operations routed via `options.operation` (L1 R7; pattern from podcast-pub). */
|
|
19
|
-
const OPERATIONS = [
|
|
44
|
+
const OPERATIONS = [
|
|
45
|
+
'publish',
|
|
46
|
+
'prepare',
|
|
47
|
+
'refine-prepared',
|
|
48
|
+
'publish-prepared',
|
|
49
|
+
'scaffold-locale',
|
|
50
|
+
'sync',
|
|
51
|
+
'patch',
|
|
52
|
+
] as const;
|
|
20
53
|
type Operation = (typeof OPERATIONS)[number];
|
|
21
54
|
|
|
22
55
|
/** Locales `scaffold-locale` can scaffold; LLM translation stays in the workflow's agent step. */
|
|
23
56
|
const SCAFFOLD_LOCALES = ['en', 'ja'] as const;
|
|
24
57
|
type ScaffoldLocale = (typeof SCAFFOLD_LOCALES)[number];
|
|
58
|
+
type SupportedLocale = 'zh' | 'en' | 'ja';
|
|
25
59
|
|
|
26
60
|
/** Cover-image extensions accepted from `metadata.coverImage` (R4): anything else is rejected, not copied. */
|
|
27
61
|
const COVER_EXTS = new Set(['.png', '.jpg', '.jpeg', '.webp', '.gif']);
|
|
@@ -33,6 +67,16 @@ interface SurfdashPubOptions {
|
|
|
33
67
|
postPath?: unknown;
|
|
34
68
|
/** Draft locale to scaffold; required for `scaffold-locale`. */
|
|
35
69
|
target?: unknown;
|
|
70
|
+
/** Rebuild an existing locale draft after the zh source fingerprint changes. */
|
|
71
|
+
force?: unknown;
|
|
72
|
+
/** `publish-prepared`: YYYYMMDD run identity bound to the validated locale files. */
|
|
73
|
+
runDate?: unknown;
|
|
74
|
+
/** `refine-prepared`: ask the site-owned orchestrator to refine all three local locale files. */
|
|
75
|
+
// The plugin never writes `refined: true` itself; the site command owns all managed fields.
|
|
76
|
+
/** `publish-prepared`: SHA-256 of the prepared zh post, checked before postsurfing runs. */
|
|
77
|
+
expectedPostSha256?: unknown;
|
|
78
|
+
/** `publish-prepared`: fingerprint of the prepared zh/en/ja files, checked before postsurfing runs. */
|
|
79
|
+
expectedLocaleFingerprint?: unknown;
|
|
36
80
|
/** `patch`: podcast-hosted cover URL for `image`/`og_image` (empty → the local cover fallback). */
|
|
37
81
|
coverUrl?: unknown;
|
|
38
82
|
/** `patch`: podcast episode URL appended as the cross-link (empty → none). */
|
|
@@ -160,6 +204,21 @@ function fail(error: string): Result {
|
|
|
160
204
|
return ResultSchema.parse({ ok: false, target: TARGET, error });
|
|
161
205
|
}
|
|
162
206
|
|
|
207
|
+
type DeliveryState = 'not-sent' | 'uncertain';
|
|
208
|
+
|
|
209
|
+
/** A failed publish is replay-safe only when it was rejected before postsurfing was invoked. */
|
|
210
|
+
function failWithDeliveryState(error: string, deliveryState: DeliveryState): Result {
|
|
211
|
+
return ResultSchema.parse({ ok: false, target: TARGET, error, metadata: { deliveryState } });
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function failNotSent(error: string): Result {
|
|
215
|
+
return failWithDeliveryState(error, 'not-sent');
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function failUncertain(error: string): Result {
|
|
219
|
+
return failWithDeliveryState(error, 'uncertain');
|
|
220
|
+
}
|
|
221
|
+
|
|
163
222
|
/**
|
|
164
223
|
* Writes a Result JSON to `outputPath`, creating the directory as needed. Shared by the success
|
|
165
224
|
* path and the invalid-input path so a malformed payload still produces the spec §2.5 `ok:false`
|
|
@@ -333,6 +392,7 @@ export function requireRefined(filePath: string): string | undefined {
|
|
|
333
392
|
* undefined when ok / downgraded. `SURFDASH_DIGEST_CHECK=warn` echoes each problem and continues.
|
|
334
393
|
*/
|
|
335
394
|
export function digestCheckGate(filePath: string, locale: DigestLocale): string | undefined {
|
|
395
|
+
if (process.env.SURFDASH_DIGEST_CHECK === 'off') return undefined;
|
|
336
396
|
const problems = checkDigestFrontmatter(readFileSync(filePath, 'utf-8'), locale);
|
|
337
397
|
if (problems.length === 0) return undefined;
|
|
338
398
|
if (process.env.SURFDASH_DIGEST_CHECK === 'warn') {
|
|
@@ -342,6 +402,109 @@ export function digestCheckGate(filePath: string, locale: DigestLocale): string
|
|
|
342
402
|
return `digest frontmatter check failed: ${problems.join('; ')}`;
|
|
343
403
|
}
|
|
344
404
|
|
|
405
|
+
/**
|
|
406
|
+
* Helper to parse file paths from `git status --porcelain` output.
|
|
407
|
+
*/
|
|
408
|
+
function parsePorcelainPaths(stdout: string): string[] {
|
|
409
|
+
return stdout
|
|
410
|
+
.split('\n')
|
|
411
|
+
.map((line) => line.trimEnd())
|
|
412
|
+
.filter((line) => line.length > 3)
|
|
413
|
+
.map((line) => {
|
|
414
|
+
const raw = line.slice(3).trim();
|
|
415
|
+
const arrowIdx = raw.indexOf(' -> ');
|
|
416
|
+
const target = arrowIdx >= 0 ? raw.slice(arrowIdx + 4).trim() : raw;
|
|
417
|
+
if (target.startsWith('"') && target.endsWith('"')) {
|
|
418
|
+
return target.slice(1, -1);
|
|
419
|
+
}
|
|
420
|
+
return target;
|
|
421
|
+
})
|
|
422
|
+
.filter((p) => p.length > 0);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Runs the surfdash listen-link backfill command (`bun run sd seo listen-link --backfill --write`)
|
|
427
|
+
* with cwd = `surfdashRoot` via `spawnCli`.
|
|
428
|
+
*
|
|
429
|
+
* Scans recent non-draft digest posts and links the podcast episode URL when live.
|
|
430
|
+
* Best-effort: non-zero exit is reported via `echoError` prefixed with `listen-link backfill:`
|
|
431
|
+
* and never throws or fails publish/patch.
|
|
432
|
+
*
|
|
433
|
+
* Returns relative paths under `content/posts` modified by the backfill, by diffing
|
|
434
|
+
* `git status --porcelain -- content/posts` before and after the run.
|
|
435
|
+
* Skipped when `SURFDASH_LISTEN_BACKFILL=off`.
|
|
436
|
+
*/
|
|
437
|
+
export async function runListenLinkBackfill(surfdashRoot: string): Promise<string[]> {
|
|
438
|
+
if (process.env.SURFDASH_LISTEN_BACKFILL === 'off') {
|
|
439
|
+
return [];
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const before = await spawnCli(['git', 'status', '--porcelain', '--', 'content/posts'], surfdashRoot);
|
|
443
|
+
const beforePaths = new Set(parsePorcelainPaths(before.stdout));
|
|
444
|
+
|
|
445
|
+
const spawned = await spawnCli(['bun', 'run', 'sd', 'seo', 'listen-link', '--backfill', '--write'], surfdashRoot);
|
|
446
|
+
if (spawned.exitCode !== 0) {
|
|
447
|
+
echoError(`listen-link backfill: ${cliErrorText(spawned)}`);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const after = await spawnCli(['git', 'status', '--porcelain', '--', 'content/posts'], surfdashRoot);
|
|
451
|
+
const afterPaths = parsePorcelainPaths(after.stdout);
|
|
452
|
+
|
|
453
|
+
return [...new Set(afterPaths.filter((p) => !beforePaths.has(p)))];
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/** Outcome of {@link runSiteGate}. */
|
|
457
|
+
export type SiteGateOutcome = { ok: true } | { ok: false; error: string };
|
|
458
|
+
|
|
459
|
+
/** Commands executed by the surfdash site gate before commit and push to main. */
|
|
460
|
+
const SITE_GATE_COMMANDS: readonly (readonly string[])[] = [
|
|
461
|
+
['bun', 'run', 'sd', 'check', 'digest'],
|
|
462
|
+
['bun', 'run', 'check:content'],
|
|
463
|
+
];
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Extracts a failing post hint (path or identifier) from CLI error output if detectable.
|
|
467
|
+
*/
|
|
468
|
+
export function extractFailingPostHint(text: string): string | undefined {
|
|
469
|
+
const match = text.match(/(?:content[\\/])?posts[\\/][^\s:)]+/);
|
|
470
|
+
if (!match) return undefined;
|
|
471
|
+
return match[0].replace(/[.,;]+$/, '');
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Runs the surfdash site gates (`bun run sd check digest` then `bun run check:content`)
|
|
476
|
+
* with cwd = `surfdashRoot` via `spawnCli`.
|
|
477
|
+
*
|
|
478
|
+
* If either command exits non-zero:
|
|
479
|
+
* - With `SURFDASH_DIGEST_CHECK=warn`: echoes each line of the failure via `echoError`
|
|
480
|
+
* prefixed with `site gate (warn):` and continues.
|
|
481
|
+
* - With `SURFDASH_DIGEST_CHECK=off`: skips the gate completely.
|
|
482
|
+
* - Default (unset or any other value): fails loud with command, trimmed output, and post hint.
|
|
483
|
+
*/
|
|
484
|
+
export async function runSiteGate(surfdashRoot: string): Promise<SiteGateOutcome> {
|
|
485
|
+
if (process.env.SURFDASH_DIGEST_CHECK === 'off') {
|
|
486
|
+
return { ok: true };
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
for (const cmd of SITE_GATE_COMMANDS) {
|
|
490
|
+
const spawned = await spawnCli([...cmd], surfdashRoot);
|
|
491
|
+
if (spawned.exitCode !== 0) {
|
|
492
|
+
const output = cliErrorText(spawned);
|
|
493
|
+
const postHint = extractFailingPostHint(output);
|
|
494
|
+
const hint = postHint ? ` [failing post: ${postHint}]` : '';
|
|
495
|
+
const error = `site gate failed: ${cmd.join(' ')} (exit ${spawned.exitCode})${hint}: ${output}`;
|
|
496
|
+
if (process.env.SURFDASH_DIGEST_CHECK === 'warn') {
|
|
497
|
+
for (const line of error.split('\n')) {
|
|
498
|
+
echoError(`site gate (warn): ${line}`);
|
|
499
|
+
}
|
|
500
|
+
continue;
|
|
501
|
+
}
|
|
502
|
+
return { ok: false, error };
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
return { ok: true };
|
|
506
|
+
}
|
|
507
|
+
|
|
345
508
|
/** Outcome of {@link pushPostGit} — the union keeps fail-loud errors typed at the call site. */
|
|
346
509
|
export type GitPushOutcome = { ok: true; hash: string } | { ok: false; error: string };
|
|
347
510
|
|
|
@@ -352,8 +515,15 @@ export type GitPushOutcome = { ok: true; hash: string } | { ok: false; error: st
|
|
|
352
515
|
* Committing with a pathspec (`git commit -- <path>`) keeps unrelated staged files
|
|
353
516
|
* out of the publish commit. Fails loud on branch/commit/push errors.
|
|
354
517
|
*/
|
|
355
|
-
export async function pushPostGit(
|
|
356
|
-
|
|
518
|
+
export async function pushPostGit(
|
|
519
|
+
surfdashRoot: string,
|
|
520
|
+
relPostDir: string | string[],
|
|
521
|
+
message: string,
|
|
522
|
+
extraPaths: string[] = [],
|
|
523
|
+
): Promise<GitPushOutcome> {
|
|
524
|
+
const rawPaths = Array.isArray(relPostDir) ? [...relPostDir, ...extraPaths] : [relPostDir, ...extraPaths];
|
|
525
|
+
const paths = [...new Set(rawPaths)];
|
|
526
|
+
return commitPostPaths(surfdashRoot, paths, message);
|
|
357
527
|
}
|
|
358
528
|
/** Refuse a checkout that cannot deploy before anything is written (`deploy.yml` builds `main` only). */
|
|
359
529
|
async function assertMainBranch(surfdashRoot: string): Promise<{ ok: true } | { ok: false; error: string }> {
|
|
@@ -504,8 +674,20 @@ async function scaffoldLocale(options: SurfdashPubOptions): Promise<Result> {
|
|
|
504
674
|
}
|
|
505
675
|
|
|
506
676
|
try {
|
|
677
|
+
const force = options.force === true;
|
|
507
678
|
const spawned = await spawnCli(
|
|
508
|
-
[
|
|
679
|
+
[
|
|
680
|
+
'bun',
|
|
681
|
+
'run',
|
|
682
|
+
'sd',
|
|
683
|
+
'translate',
|
|
684
|
+
postPath.postPath,
|
|
685
|
+
'--source',
|
|
686
|
+
'zh',
|
|
687
|
+
'--target',
|
|
688
|
+
target.locale,
|
|
689
|
+
...(force ? ['--force'] : []),
|
|
690
|
+
],
|
|
509
691
|
surfdashRoot,
|
|
510
692
|
);
|
|
511
693
|
if (spawned.exitCode !== 0) {
|
|
@@ -659,6 +841,12 @@ async function patchPost(options: SurfdashPubOptions): Promise<Result> {
|
|
|
659
841
|
commitPaths.add(relative(surfdashRoot, assetsDir));
|
|
660
842
|
}
|
|
661
843
|
}
|
|
844
|
+
if (process.env.SURFDASH_AUTO_PUSH !== '0') {
|
|
845
|
+
const backfilled = await runListenLinkBackfill(surfdashRoot);
|
|
846
|
+
for (const p of backfilled) commitPaths.add(p);
|
|
847
|
+
const gate = await runSiteGate(surfdashRoot);
|
|
848
|
+
if (!gate.ok) return fail(gate.error);
|
|
849
|
+
}
|
|
662
850
|
const commit = await commitPostPaths(
|
|
663
851
|
surfdashRoot,
|
|
664
852
|
[...commitPaths],
|
|
@@ -693,95 +881,336 @@ async function patchPost(options: SurfdashPubOptions): Promise<Result> {
|
|
|
693
881
|
});
|
|
694
882
|
}
|
|
695
883
|
|
|
884
|
+
interface PreparedPost {
|
|
885
|
+
surfdashRoot: string;
|
|
886
|
+
locale: SupportedLocale;
|
|
887
|
+
dateStamp: string;
|
|
888
|
+
slug: string;
|
|
889
|
+
filePath: string;
|
|
890
|
+
relPostDir: string;
|
|
891
|
+
/** Prepared daily publishes carry all translated sibling directories into the deploy commit. */
|
|
892
|
+
relPostDirs?: string[];
|
|
893
|
+
articleUrl: string;
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
interface PreparedLocaleIdentity {
|
|
897
|
+
runDate: string;
|
|
898
|
+
postPath: string;
|
|
899
|
+
localePaths: Record<SupportedLocale, string>;
|
|
900
|
+
localeDigests: Record<SupportedLocale, string>;
|
|
901
|
+
fingerprint: string;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
function fileSha256(filePath: string): string {
|
|
905
|
+
return createHash('sha256').update(readFileSync(filePath)).digest('hex');
|
|
906
|
+
}
|
|
907
|
+
|
|
696
908
|
/**
|
|
697
|
-
*
|
|
698
|
-
*
|
|
909
|
+
* Computes the exact identity used by the daily locale gate. This is intentionally derived from
|
|
910
|
+
* bytes on disk immediately before the remote publishing FSM starts; a cached result must never
|
|
911
|
+
* satisfy a different slug, payload, or translated-locale set.
|
|
699
912
|
*/
|
|
700
|
-
|
|
701
|
-
|
|
913
|
+
function preparedLocaleIdentity(
|
|
914
|
+
postPath: string,
|
|
915
|
+
runDate: string,
|
|
916
|
+
): { ok: true; value: PreparedLocaleIdentity } | { ok: false; error: string } {
|
|
917
|
+
const enPath = deriveLocaleDraftPath(postPath, 'en');
|
|
918
|
+
const jaPath = deriveLocaleDraftPath(postPath, 'ja');
|
|
919
|
+
if (enPath === undefined || jaPath === undefined) {
|
|
920
|
+
return { ok: false, error: `prepared zh post path must contain a "/zh/" locale segment: ${postPath}` };
|
|
921
|
+
}
|
|
922
|
+
const localePaths: Record<SupportedLocale, string> = { zh: postPath, en: enPath, ja: jaPath };
|
|
923
|
+
const localeDigests = {} as Record<SupportedLocale, string>;
|
|
924
|
+
for (const locale of ['zh', 'en', 'ja'] as const) {
|
|
925
|
+
const path = localePaths[locale];
|
|
926
|
+
if (!existsSync(path)) return { ok: false, error: `${locale} locale file is missing: ${path}` };
|
|
927
|
+
try {
|
|
928
|
+
localeDigests[locale] = fileSha256(path);
|
|
929
|
+
} catch (error: unknown) {
|
|
930
|
+
return {
|
|
931
|
+
ok: false,
|
|
932
|
+
error: `${locale} locale file cannot be fingerprinted: ${error instanceof Error ? error.message : String(error)}`,
|
|
933
|
+
};
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
const identity = { runDate, postPath, localePaths, localeDigests };
|
|
937
|
+
return {
|
|
938
|
+
ok: true,
|
|
939
|
+
value: { ...identity, fingerprint: createHash('sha256').update(JSON.stringify(identity)).digest('hex') },
|
|
940
|
+
};
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
interface PreparedLocaleSet {
|
|
944
|
+
surfdashRoot: string;
|
|
945
|
+
runDate: string;
|
|
946
|
+
postPath: string;
|
|
947
|
+
slug: string;
|
|
948
|
+
localePaths: Record<SupportedLocale, string>;
|
|
949
|
+
}
|
|
702
950
|
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
951
|
+
/**
|
|
952
|
+
* Resolves the exact three-file daily post set without writing anything. Both the local refine
|
|
953
|
+
* phase and the final prepared publish use this guard so they cannot drift to different slugs or
|
|
954
|
+
* locale siblings between validation and dispatch.
|
|
955
|
+
*/
|
|
956
|
+
function resolvePreparedLocaleSet(
|
|
957
|
+
options: SurfdashPubOptions,
|
|
958
|
+
): { ok: true; value: PreparedLocaleSet } | { ok: false; result: Result } {
|
|
959
|
+
const postPath = resolvePostPath(options);
|
|
960
|
+
if ('error' in postPath) return { ok: false, result: failNotSent(postPath.error) };
|
|
706
961
|
const surfdashRoot = resolveSurfdashRoot();
|
|
707
962
|
if (!surfdashRoot) {
|
|
708
|
-
return
|
|
963
|
+
return {
|
|
964
|
+
ok: false,
|
|
965
|
+
result: failNotSent(
|
|
966
|
+
'SURFDASH_ROOT is required when POSTSURFING_BIN is not a path inside the surfdash checkout',
|
|
967
|
+
),
|
|
968
|
+
};
|
|
969
|
+
}
|
|
970
|
+
const outsideRoot = postPathOutsideRoot(postPath.postPath, surfdashRoot);
|
|
971
|
+
if (outsideRoot) return { ok: false, result: failNotSent(outsideRoot) };
|
|
972
|
+
if (!existsSync(postPath.postPath))
|
|
973
|
+
return { ok: false, result: failNotSent(`prepared post does not exist: ${postPath.postPath}`) };
|
|
974
|
+
const runDate = options.runDate;
|
|
975
|
+
if (typeof runDate !== 'string' || !/^\d{8}$/.test(runDate)) {
|
|
976
|
+
return { ok: false, result: failNotSent(`options.runDate must be YYYYMMDD, got '${String(runDate)}'`) };
|
|
977
|
+
}
|
|
978
|
+
const articlesRoot = resolve(surfdashRoot, 'content', 'posts', 'articles');
|
|
979
|
+
const relativePost = relative(articlesRoot, postPath.postPath);
|
|
980
|
+
const parts = relativePost.split(sep);
|
|
981
|
+
const slug = parts[1];
|
|
982
|
+
if (parts.length !== 3 || parts[0] !== 'zh' || parts[2] !== 'index.md' || slug === undefined || slug === '') {
|
|
983
|
+
return {
|
|
984
|
+
ok: false,
|
|
985
|
+
result: failNotSent(
|
|
986
|
+
`options.postPath must be content/posts/articles/zh/<slug>/index.md: ${postPath.postPath}`,
|
|
987
|
+
),
|
|
988
|
+
};
|
|
989
|
+
}
|
|
990
|
+
const enPath = deriveLocaleDraftPath(postPath.postPath, 'en');
|
|
991
|
+
const jaPath = deriveLocaleDraftPath(postPath.postPath, 'ja');
|
|
992
|
+
if (enPath === undefined || jaPath === undefined) {
|
|
993
|
+
return {
|
|
994
|
+
ok: false,
|
|
995
|
+
result: failNotSent(`prepared zh post path must contain a "/zh/" locale segment: ${postPath.postPath}`),
|
|
996
|
+
};
|
|
997
|
+
}
|
|
998
|
+
const localePaths: Record<SupportedLocale, string> = { zh: postPath.postPath, en: enPath, ja: jaPath };
|
|
999
|
+
for (const locale of ['zh', 'en', 'ja'] as const) {
|
|
1000
|
+
if (!existsSync(localePaths[locale])) {
|
|
1001
|
+
return { ok: false, result: failNotSent(`${locale} locale file is missing: ${localePaths[locale]}`) };
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
return { ok: true, value: { surfdashRoot, runDate, postPath: postPath.postPath, slug, localePaths } };
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
function resolvePreparedPost(
|
|
1008
|
+
options: SurfdashPubOptions,
|
|
1009
|
+
): { ok: true; prepared: PreparedPost; identity: PreparedLocaleIdentity } | { ok: false; result: Result } {
|
|
1010
|
+
const resolved = resolvePreparedLocaleSet(options);
|
|
1011
|
+
if (!resolved.ok) return resolved;
|
|
1012
|
+
const { surfdashRoot, runDate, postPath: preparedPath, slug } = resolved.value;
|
|
1013
|
+
const expectedPostSha256 = options.expectedPostSha256;
|
|
1014
|
+
if (typeof expectedPostSha256 !== 'string' || !/^[0-9a-f]{64}$/.test(expectedPostSha256)) {
|
|
1015
|
+
return { ok: false, result: failNotSent('options.expectedPostSha256 must be a lowercase SHA-256 digest') };
|
|
1016
|
+
}
|
|
1017
|
+
const expectedLocaleFingerprint = options.expectedLocaleFingerprint;
|
|
1018
|
+
if (typeof expectedLocaleFingerprint !== 'string' || !/^[0-9a-f]{64}$/.test(expectedLocaleFingerprint)) {
|
|
1019
|
+
return {
|
|
1020
|
+
ok: false,
|
|
1021
|
+
result: failNotSent('options.expectedLocaleFingerprint must be a lowercase SHA-256 digest'),
|
|
1022
|
+
};
|
|
1023
|
+
}
|
|
1024
|
+
const identity = preparedLocaleIdentity(preparedPath, runDate);
|
|
1025
|
+
if (!identity.ok) return { ok: false, result: failNotSent(identity.error) };
|
|
1026
|
+
if (identity.value.localeDigests.zh !== expectedPostSha256) {
|
|
1027
|
+
return {
|
|
1028
|
+
ok: false,
|
|
1029
|
+
result: failNotSent(`prepared zh post bytes changed after validation: ${preparedPath}`),
|
|
1030
|
+
};
|
|
1031
|
+
}
|
|
1032
|
+
if (identity.value.fingerprint !== expectedLocaleFingerprint) {
|
|
1033
|
+
return {
|
|
1034
|
+
ok: false,
|
|
1035
|
+
result: failNotSent(
|
|
1036
|
+
'prepared locale files changed after validation; refresh the locale gate before publishing',
|
|
1037
|
+
),
|
|
1038
|
+
};
|
|
1039
|
+
}
|
|
1040
|
+
const baseUrl = (process.env.SURFDASH_URL ?? 'https://surfing.salty.vip').replace(/\/$/, '');
|
|
1041
|
+
return {
|
|
1042
|
+
ok: true,
|
|
1043
|
+
prepared: {
|
|
1044
|
+
surfdashRoot,
|
|
1045
|
+
locale: 'zh',
|
|
1046
|
+
dateStamp: runDate,
|
|
1047
|
+
slug,
|
|
1048
|
+
filePath: preparedPath,
|
|
1049
|
+
relPostDir: join('content', 'posts', 'articles', 'zh', slug),
|
|
1050
|
+
relPostDirs: ['zh', 'en', 'ja'].map((locale) => join('content', 'posts', 'articles', locale, slug)),
|
|
1051
|
+
articleUrl: `${baseUrl}/zh/posts/${slug}`,
|
|
1052
|
+
},
|
|
1053
|
+
identity: identity.value,
|
|
1054
|
+
};
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
type PrepareOutcome = { ok: true; prepared: PreparedPost } | { ok: false; result: Result };
|
|
1058
|
+
|
|
1059
|
+
/**
|
|
1060
|
+
* Materializes the local post without invoking the external publishing FSM.
|
|
1061
|
+
*
|
|
1062
|
+
* Locale scaffolding and translation must happen before Surfdash's strict sync gate runs. The
|
|
1063
|
+
* normal `publish` operation uses the same preparation path before refine, CMS sync and deploy.
|
|
1064
|
+
*/
|
|
1065
|
+
async function preparePost(payload: PublishTransportPayload): Promise<PrepareOutcome> {
|
|
1066
|
+
const surfdashRoot = resolveSurfdashRoot();
|
|
1067
|
+
if (!surfdashRoot) {
|
|
1068
|
+
return {
|
|
1069
|
+
ok: false,
|
|
1070
|
+
result: failNotSent(
|
|
1071
|
+
'SURFDASH_ROOT is required when POSTSURFING_BIN is not a path inside the surfdash checkout',
|
|
1072
|
+
),
|
|
1073
|
+
};
|
|
709
1074
|
}
|
|
710
1075
|
|
|
711
|
-
const
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
if (!['zh', 'en', 'ja'].includes(locale)) {
|
|
715
|
-
return fail(`SURFDASH_LOCALE must be one of zh/en/ja, got '${locale}'`);
|
|
1076
|
+
const rawLocale = process.env.SURFDASH_LOCALE ?? 'zh';
|
|
1077
|
+
if (!['zh', 'en', 'ja'].includes(rawLocale)) {
|
|
1078
|
+
return { ok: false, result: failNotSent(`SURFDASH_LOCALE must be one of zh/en/ja, got '${rawLocale}'`) };
|
|
716
1079
|
}
|
|
717
|
-
|
|
718
|
-
// from colliding; Chinese titles slugify to '' and fall back to the bare date.
|
|
1080
|
+
const locale = rawLocale as SupportedLocale;
|
|
719
1081
|
const runDate = typeof payload.content.metadata?.runDate === 'string' ? payload.content.metadata.runDate : '';
|
|
720
1082
|
const today = new Date().toISOString().slice(0, 10).replaceAll('-', '');
|
|
721
1083
|
const dateStamp = runDate || today;
|
|
722
1084
|
const titleSlug = slugify(payload.content.title ?? '');
|
|
723
1085
|
const slug = titleSlug ? `${dateStamp}-${titleSlug}` : `${dateStamp}-post`;
|
|
724
|
-
// Validate before touching disk (R4): a `metadata.runDate` or slug that escapes the articles
|
|
725
|
-
// tree would write a post outside it, and a non-image `coverImage` would be copied into a post
|
|
726
|
-
// that pushPostGit then publishes to a public repo.
|
|
727
1086
|
if (runDate !== '' && !/^\d{8}$/.test(runDate)) {
|
|
728
|
-
return
|
|
1087
|
+
return { ok: false, result: failNotSent(`metadata.runDate must be YYYYMMDD, got '${runDate}'`) };
|
|
729
1088
|
}
|
|
730
1089
|
const articlesRoot = resolve(surfdashRoot, 'content', 'posts', 'articles');
|
|
731
1090
|
const localeRoot = resolve(articlesRoot, locale);
|
|
732
1091
|
const postDir = resolve(localeRoot, slug);
|
|
733
1092
|
if (dirname(localeRoot) !== articlesRoot || dirname(postDir) !== localeRoot) {
|
|
734
|
-
return
|
|
1093
|
+
return { ok: false, result: failNotSent(`post path escapes ${articlesRoot}`) };
|
|
735
1094
|
}
|
|
1095
|
+
|
|
736
1096
|
const coverPath = payload.content.metadata?.coverImage;
|
|
737
|
-
// R4 + 2026-10-24: a coverImage that does not exist must fail before anything is written —
|
|
738
|
-
// the old silent skip published a coverless post while the run believed it had one.
|
|
739
1097
|
if (typeof coverPath === 'string' && !existsSync(coverPath)) {
|
|
740
|
-
return
|
|
1098
|
+
return { ok: false, result: failNotSent(`metadata.coverImage not found: ${coverPath}`) };
|
|
741
1099
|
}
|
|
742
1100
|
if (typeof coverPath === 'string' && !COVER_EXTS.has(extname(coverPath).toLowerCase())) {
|
|
743
|
-
return
|
|
1101
|
+
return {
|
|
1102
|
+
ok: false,
|
|
1103
|
+
result: failNotSent(`metadata.coverImage must be one of ${[...COVER_EXTS].join(', ')}`),
|
|
1104
|
+
};
|
|
744
1105
|
}
|
|
745
|
-
// Daily digest posts are the series' public face: a missing cover ships a blank OG card.
|
|
746
|
-
// The check uses the same tag sources as mapContentToSurfingMarkdown (contentTags).
|
|
747
1106
|
if (isDigestContent(payload.content) && typeof coverPath !== 'string') {
|
|
748
|
-
return
|
|
1107
|
+
return { ok: false, result: failNotSent('digest post has no cover image (metadata.coverImage)') };
|
|
749
1108
|
}
|
|
750
1109
|
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
1110
|
+
const filePath = join(postDir, 'index.md');
|
|
1111
|
+
try {
|
|
1112
|
+
await createNodeFileSystem().ensureDir(postDir);
|
|
1113
|
+
let imageFrontmatter: string | undefined;
|
|
1114
|
+
if (typeof coverPath === 'string') {
|
|
1115
|
+
const assetsDir = join(postDir, 'assets');
|
|
1116
|
+
await createNodeFileSystem().ensureDir(assetsDir);
|
|
1117
|
+
const coverName = `cover${extname(coverPath).toLowerCase()}`;
|
|
1118
|
+
await copyFile(coverPath, join(assetsDir, coverName));
|
|
1119
|
+
imageFrontmatter = `./assets/${coverName}`;
|
|
1120
|
+
}
|
|
1121
|
+
const markdown = mapContentToSurfingMarkdown(payload.content, {
|
|
1122
|
+
image: imageFrontmatter,
|
|
1123
|
+
publishDate: runDate ? `${runDate.slice(0, 4)}-${runDate.slice(4, 6)}-${runDate.slice(6, 8)}` : undefined,
|
|
1124
|
+
});
|
|
1125
|
+
await createNodeFileSystem().writeFile(filePath, markdown);
|
|
1126
|
+
} catch (err: unknown) {
|
|
1127
|
+
return {
|
|
1128
|
+
ok: false,
|
|
1129
|
+
result: failNotSent(
|
|
1130
|
+
`Surfdash post preparation failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
1131
|
+
),
|
|
1132
|
+
};
|
|
762
1133
|
}
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
1134
|
+
|
|
1135
|
+
const baseUrl = (process.env.SURFDASH_URL ?? 'https://surfing.salty.vip').replace(/\/$/, '');
|
|
1136
|
+
const articleUrl = `${baseUrl}${locale === 'en' ? '' : `/${locale}`}/posts/${slug}`;
|
|
1137
|
+
return {
|
|
1138
|
+
ok: true,
|
|
1139
|
+
prepared: {
|
|
1140
|
+
surfdashRoot,
|
|
1141
|
+
locale,
|
|
1142
|
+
dateStamp,
|
|
1143
|
+
slug,
|
|
1144
|
+
filePath,
|
|
1145
|
+
relPostDir: join('content', 'posts', 'articles', locale, slug),
|
|
1146
|
+
articleUrl,
|
|
1147
|
+
},
|
|
1148
|
+
};
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
/** `prepare` operation: write the local post and return its path for locale scaffolding. */
|
|
1152
|
+
async function preparePostResult(payload: PublishTransportPayload): Promise<Result> {
|
|
1153
|
+
const outcome = await preparePost(payload);
|
|
1154
|
+
if (!outcome.ok) return outcome.result;
|
|
1155
|
+
return ResultSchema.parse({
|
|
1156
|
+
ok: true,
|
|
1157
|
+
target: TARGET,
|
|
1158
|
+
message: `Prepared ${outcome.prepared.locale} post at ${outcome.prepared.filePath}`,
|
|
1159
|
+
metadata: {
|
|
1160
|
+
postPath: outcome.prepared.filePath,
|
|
1161
|
+
articleUrl: outcome.prepared.articleUrl,
|
|
1162
|
+
dateStamp: outcome.prepared.dateStamp,
|
|
1163
|
+
slug: outcome.prepared.slug,
|
|
1164
|
+
},
|
|
766
1165
|
});
|
|
767
|
-
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
/** Invokes the remote publishing FSM for an already-materialized post path. */
|
|
1169
|
+
async function publishPreparedFile(
|
|
1170
|
+
prepared: PreparedPost,
|
|
1171
|
+
identity?: Pick<PreparedLocaleIdentity, 'runDate' | 'fingerprint'> & { postSha256?: string },
|
|
1172
|
+
): Promise<Result> {
|
|
1173
|
+
const postsurfingBin = process.env.POSTSURFING_BIN ?? 'postsurfing';
|
|
1174
|
+
const { surfdashRoot, locale, dateStamp, filePath, relPostDir, relPostDirs, articleUrl } = prepared;
|
|
1175
|
+
const identityMetadata = identity
|
|
1176
|
+
? {
|
|
1177
|
+
runDate: identity.runDate,
|
|
1178
|
+
localeFingerprint: identity.fingerprint,
|
|
1179
|
+
...(identity.postSha256 ? { postSha256: identity.postSha256 } : {}),
|
|
1180
|
+
}
|
|
1181
|
+
: {};
|
|
768
1182
|
|
|
769
1183
|
try {
|
|
770
|
-
await createNodeFileSystem().writeFile(filePath, markdown);
|
|
771
1184
|
const spawned = await spawnCli([postsurfingBin, 'publish', filePath], surfdashRoot);
|
|
772
1185
|
|
|
773
1186
|
if (spawned.exitCode !== 0) {
|
|
774
|
-
return
|
|
1187
|
+
return failUncertain(
|
|
775
1188
|
`postsurfing CLI failed (exit ${spawned.exitCode}): ${withEmdashTokenHint(cliErrorText(spawned))}`,
|
|
776
1189
|
);
|
|
777
1190
|
}
|
|
778
1191
|
|
|
1192
|
+
// The real Surfdash FSM runs its own refine step before CMS sync. The daily workflow runs
|
|
1193
|
+
// site-owned refine locally first, so this must be idempotent; otherwise the remote phase
|
|
1194
|
+
// would silently mutate the bytes whose fingerprint admitted this publish. Treat any
|
|
1195
|
+
// mutation as uncertain because CMS/git may already have side effects.
|
|
1196
|
+
if (identity !== undefined) {
|
|
1197
|
+
const afterRemote = preparedLocaleIdentity(filePath, identity.runDate);
|
|
1198
|
+
if (!afterRemote.ok)
|
|
1199
|
+
return failUncertain(`prepared locale files changed after remote publish: ${afterRemote.error}`);
|
|
1200
|
+
if (
|
|
1201
|
+
afterRemote.value.fingerprint !== identity.fingerprint ||
|
|
1202
|
+
(identity.postSha256 !== undefined && afterRemote.value.localeDigests.zh !== identity.postSha256)
|
|
1203
|
+
) {
|
|
1204
|
+
return failUncertain('prepared locale files changed after remote publish; reconcile before retrying');
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
|
|
779
1208
|
// Contract check (ADR-008): the site, not this plugin, owns SEO metadata.
|
|
780
1209
|
const notRefined = requireRefined(filePath);
|
|
781
|
-
if (notRefined) return
|
|
1210
|
+
if (notRefined) return failUncertain(notRefined);
|
|
782
1211
|
// Digest contract (2026-10-24): the refined values themselves must hold, before any push.
|
|
783
|
-
const digestProblem = digestCheckGate(filePath, locale
|
|
784
|
-
if (digestProblem) return
|
|
1212
|
+
const digestProblem = digestCheckGate(filePath, locale);
|
|
1213
|
+
if (digestProblem) return failUncertain(digestProblem);
|
|
785
1214
|
const refined = readRefinedSnapshot(filePath);
|
|
786
1215
|
|
|
787
1216
|
// Deploy seam: commit + push the post so deploy.yml builds it, then wait for
|
|
@@ -790,8 +1219,6 @@ async function publishPost(payload: PublishTransportPayload): Promise<Result> {
|
|
|
790
1219
|
// URL shape mirrors surfdash's localePathPrefix (surfdash/src/lib/seo.ts):
|
|
791
1220
|
// en is served at the site root, zh/ja carry a /<locale> prefix. Composing
|
|
792
1221
|
// /<locale>/posts/<slug> unconditionally 404s en posts.
|
|
793
|
-
const baseUrl = (process.env.SURFDASH_URL ?? 'https://surfing.salty.vip').replace(/\/$/, '');
|
|
794
|
-
const articleUrl = `${baseUrl}${locale === 'en' ? '' : `/${locale}`}/posts/${slug}`;
|
|
795
1222
|
if (process.env.SURFDASH_AUTO_PUSH === '0') {
|
|
796
1223
|
return ResultSchema.parse({
|
|
797
1224
|
ok: true,
|
|
@@ -800,12 +1227,26 @@ async function publishPost(payload: PublishTransportPayload): Promise<Result> {
|
|
|
800
1227
|
message: 'Published via postsurfing CLI (auto-push disabled)',
|
|
801
1228
|
// Absolute post path lets downstream steps (locale translation + CMS sync)
|
|
802
1229
|
// derive the surfdash checkout root without extra configuration.
|
|
803
|
-
metadata: {
|
|
1230
|
+
metadata: {
|
|
1231
|
+
postPath: filePath,
|
|
1232
|
+
...identityMetadata,
|
|
1233
|
+
refinedTitle: refined?.title,
|
|
1234
|
+
refinedCanonical: refined?.canonical,
|
|
1235
|
+
},
|
|
804
1236
|
});
|
|
805
1237
|
}
|
|
806
|
-
const
|
|
807
|
-
const
|
|
808
|
-
if (!
|
|
1238
|
+
const backfilled = await runListenLinkBackfill(surfdashRoot);
|
|
1239
|
+
const gate = await runSiteGate(surfdashRoot);
|
|
1240
|
+
if (!gate.ok) return failUncertain(gate.error);
|
|
1241
|
+
const commitPaths = relPostDirs ?? [relPostDir];
|
|
1242
|
+
const commitLocales: SupportedLocale[] = relPostDirs === undefined ? [locale] : ['zh', 'en', 'ja'];
|
|
1243
|
+
const pushed = await pushPostGit(
|
|
1244
|
+
surfdashRoot,
|
|
1245
|
+
commitPaths,
|
|
1246
|
+
`content: daily AI news ${dateStamp} (${commitLocales.join(', ')})`,
|
|
1247
|
+
backfilled,
|
|
1248
|
+
);
|
|
1249
|
+
if (!pushed.ok) return failUncertain(pushed.error);
|
|
809
1250
|
const live = await awaitArticleLive(articleUrl);
|
|
810
1251
|
|
|
811
1252
|
return ResultSchema.parse({
|
|
@@ -815,16 +1256,95 @@ async function publishPost(payload: PublishTransportPayload): Promise<Result> {
|
|
|
815
1256
|
message: live
|
|
816
1257
|
? 'Published, committed, pushed, and live on the site'
|
|
817
1258
|
: 'Published and pushed; URL not live within the verify window — check the surfdash deploy workflow',
|
|
818
|
-
metadata: {
|
|
1259
|
+
metadata: {
|
|
1260
|
+
postPath: filePath,
|
|
1261
|
+
...identityMetadata,
|
|
1262
|
+
gitCommit: pushed.hash,
|
|
1263
|
+
live,
|
|
1264
|
+
refinedTitle: refined?.title,
|
|
1265
|
+
},
|
|
819
1266
|
});
|
|
820
1267
|
} catch (err: unknown) {
|
|
821
|
-
return
|
|
1268
|
+
return failUncertain(`Postsurfing transport error: ${err instanceof Error ? err.message : String(err)}`);
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
/**
|
|
1273
|
+
* Publish path (behavior unchanged since E3): maps Content into surfdash markdown under
|
|
1274
|
+
* `content/posts/<collection>/<locale>/<slug>/index.md` and invokes the postsurfing CLI.
|
|
1275
|
+
*/
|
|
1276
|
+
async function publishPost(payload: PublishTransportPayload): Promise<Result> {
|
|
1277
|
+
const outcome = await preparePost(payload);
|
|
1278
|
+
if (!outcome.ok) return outcome.result;
|
|
1279
|
+
return publishPreparedFile(outcome.prepared);
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
/**
|
|
1283
|
+
* Runs the site-owned refine command over zh/en/ja without CMS, git, or remote effects. The daily
|
|
1284
|
+
* workflow fingerprints only after this operation; the real publish FSM can then revisit refine
|
|
1285
|
+
* idempotently instead of rewriting a validated locale set during its remote phase.
|
|
1286
|
+
*/
|
|
1287
|
+
async function refinePreparedPost(options: SurfdashPubOptions): Promise<Result> {
|
|
1288
|
+
const resolved = resolvePreparedLocaleSet(options);
|
|
1289
|
+
if (!resolved.ok) return resolved.result;
|
|
1290
|
+
const orchestrator = join(resolved.value.surfdashRoot, 'scripts', 'publish-orchestrator.ts');
|
|
1291
|
+
if (!existsSync(orchestrator)) return failNotSent(`Surfdash refine orchestrator is missing: ${orchestrator}`);
|
|
1292
|
+
try {
|
|
1293
|
+
for (const locale of ['zh', 'en', 'ja'] as const) {
|
|
1294
|
+
const filePath = resolved.value.localePaths[locale];
|
|
1295
|
+
const spawned = await spawnCli(
|
|
1296
|
+
[process.execPath, orchestrator, 'refine', filePath],
|
|
1297
|
+
resolved.value.surfdashRoot,
|
|
1298
|
+
);
|
|
1299
|
+
if (spawned.exitCode !== 0) {
|
|
1300
|
+
return failNotSent(
|
|
1301
|
+
`Surfdash refine failed for ${locale} (exit ${spawned.exitCode}): ${cliErrorText(spawned)}`,
|
|
1302
|
+
);
|
|
1303
|
+
}
|
|
1304
|
+
const notRefined = requireRefined(filePath);
|
|
1305
|
+
if (notRefined) return failNotSent(notRefined);
|
|
1306
|
+
const digestProblem = digestCheckGate(filePath, locale);
|
|
1307
|
+
if (digestProblem) return failNotSent(digestProblem);
|
|
1308
|
+
}
|
|
1309
|
+
} catch (error: unknown) {
|
|
1310
|
+
return failNotSent(
|
|
1311
|
+
`Surfdash refine transport error: ${error instanceof Error ? error.message : String(error)}`,
|
|
1312
|
+
);
|
|
822
1313
|
}
|
|
1314
|
+
const identity = preparedLocaleIdentity(resolved.value.postPath, resolved.value.runDate);
|
|
1315
|
+
if (!identity.ok) return failNotSent(identity.error);
|
|
1316
|
+
return ResultSchema.parse({
|
|
1317
|
+
ok: true,
|
|
1318
|
+
target: TARGET,
|
|
1319
|
+
message: 'Refined zh/en/ja via the Surfdash-owned orchestrator',
|
|
1320
|
+
metadata: {
|
|
1321
|
+
postPath: resolved.value.postPath,
|
|
1322
|
+
runDate: resolved.value.runDate,
|
|
1323
|
+
localePaths: identity.value.localePaths,
|
|
1324
|
+
localeDigests: identity.value.localeDigests,
|
|
1325
|
+
localeFingerprint: identity.value.fingerprint,
|
|
1326
|
+
},
|
|
1327
|
+
});
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
/**
|
|
1331
|
+
* Final daily voice2 operation. Unlike `publish`, this never maps Content or rewrites the prepared
|
|
1332
|
+
* zh post. It verifies the exact validated bytes and locale fingerprint immediately before invoking
|
|
1333
|
+
* postsurfing, so a regenerated same-day payload cannot silently replace the validated material.
|
|
1334
|
+
*/
|
|
1335
|
+
async function publishPreparedPost(options: SurfdashPubOptions): Promise<Result> {
|
|
1336
|
+
const outcome = resolvePreparedPost(options);
|
|
1337
|
+
if (!outcome.ok) return outcome.result;
|
|
1338
|
+
return publishPreparedFile(outcome.prepared, {
|
|
1339
|
+
runDate: outcome.identity.runDate,
|
|
1340
|
+
fingerprint: outcome.identity.fingerprint,
|
|
1341
|
+
postSha256: outcome.identity.localeDigests.zh,
|
|
1342
|
+
});
|
|
823
1343
|
}
|
|
824
1344
|
|
|
825
1345
|
/**
|
|
826
1346
|
* FileCli transport invoking postsurfing CLI for `publish` (default) and the
|
|
827
|
-
* `scaffold-locale` / `sync` surfdash mechanics via `options.operation` (L1 R7).
|
|
1347
|
+
* prepared-path / `scaffold-locale` / `sync` surfdash mechanics via `options.operation` (L1 R7).
|
|
828
1348
|
*/
|
|
829
1349
|
export class PostsurfingFileCliTransport implements PublishTransport {
|
|
830
1350
|
public readonly kind = 'file-cli';
|
|
@@ -839,6 +1359,12 @@ export class PostsurfingFileCliTransport implements PublishTransport {
|
|
|
839
1359
|
if ('error' in routed) return fail(routed.error);
|
|
840
1360
|
|
|
841
1361
|
switch (routed.operation) {
|
|
1362
|
+
case 'prepare':
|
|
1363
|
+
return preparePostResult(payload);
|
|
1364
|
+
case 'refine-prepared':
|
|
1365
|
+
return refinePreparedPost(options);
|
|
1366
|
+
case 'publish-prepared':
|
|
1367
|
+
return publishPreparedPost(options);
|
|
842
1368
|
case 'scaffold-locale':
|
|
843
1369
|
return scaffoldLocale(options);
|
|
844
1370
|
case 'sync':
|
|
@@ -865,10 +1391,9 @@ export function getPublishTransport(): PublishTransport {
|
|
|
865
1391
|
/**
|
|
866
1392
|
* Processes input Content and writes output Result via transport.
|
|
867
1393
|
*
|
|
868
|
-
* The input JSON may carry a top-level `options` object (`operation`, `postPath`,
|
|
869
|
-
*
|
|
870
|
-
* `kk executor run` callers can select
|
|
871
|
-
* (podcast-pub pattern, 0096).
|
|
1394
|
+
* The input JSON may carry a top-level `options` object (`operation`, `postPath`, `target`, and
|
|
1395
|
+
* prepared-path identity fields) alongside the Content fields; it is forwarded to the transport so
|
|
1396
|
+
* `kk executor run` callers can select locale/publish mechanics without extra flags (0096).
|
|
872
1397
|
*/
|
|
873
1398
|
export async function processPublishIO(
|
|
874
1399
|
inputPath: string,
|