@gobing-ai/knowledge-kit 0.1.10 → 0.1.12
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 +2478 -1737
- package/package.json +1 -1
- package/plugins/generations/image-gen/README.md +108 -0
- package/plugins/generations/image-gen/assets/sample/cover__clean-webapp-ui.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/cover__cute.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/cover__daily-news.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/cover__editorial.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/cover__fresh.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/cover__minimalist.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/cover__photorealistic.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/cover__sketch.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/cover__technical-diagram.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/cover__vibrant.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/cover__warm.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/cover__xhs-knowledge.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/daily-news-cover__clean-webapp-ui.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/daily-news-cover__cute.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/daily-news-cover__daily-news.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/daily-news-cover__editorial.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/daily-news-cover__fresh.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/daily-news-cover__minimalist.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/daily-news-cover__photorealistic.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/daily-news-cover__sketch.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/daily-news-cover__technical-diagram.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/daily-news-cover__vibrant.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/daily-news-cover__warm.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/daily-news-cover__xhs-knowledge.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/free__clean-webapp-ui.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/free__cute.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/free__daily-news.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/free__editorial.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/free__fresh.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/free__minimalist.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/free__photorealistic.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/free__sketch.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/free__technical-diagram.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/free__vibrant.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/free__warm.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/free__xhs-knowledge.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/illustration__clean-webapp-ui.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/illustration__cute.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/illustration__daily-news.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/illustration__editorial.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/illustration__fresh.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/illustration__minimalist.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/illustration__photorealistic.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/illustration__sketch.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/illustration__technical-diagram.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/illustration__vibrant.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/illustration__warm.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/illustration__xhs-knowledge.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/xhs-card__clean-webapp-ui.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/xhs-card__cute.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/xhs-card__daily-news.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/xhs-card__editorial.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/xhs-card__fresh.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/xhs-card__minimalist.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/xhs-card__photorealistic.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/xhs-card__sketch.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/xhs-card__technical-diagram.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/xhs-card__vibrant.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/xhs-card__warm.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/xhs-card__xhs-knowledge.jpg +0 -0
- package/plugins/generations/image-gen/dist/index.js +102 -58
- package/plugins/generations/image-gen/scripts/compose-e2e-artifact.ts +5 -21
- package/plugins/generations/image-gen/scripts/gen-samples.ts +208 -0
- package/plugins/generations/image-gen/src/compose/chromium.ts +2 -3
- package/plugins/generations/image-gen/src/presets.ts +6 -3
- package/plugins/generations/image-gen/src/providers/errors.ts +16 -1
- package/plugins/generations/image-gen/src/providers/minimax.ts +46 -17
- package/plugins/ingestions/web-search/src/index.ts +1 -1
- package/plugins/kk/README.md +18 -0
- package/plugins/kk/commands/image-extract.md +1 -0
- package/plugins/kk/commands/image-generate.md +1 -0
- package/plugins/kk/commands/tell-me.md +1 -0
- package/plugins/kk/commands/workflow-run.md +7 -1
- package/plugins/kk/decisions/extract/it-topics.yaml +24 -0
- package/plugins/kk/decisions/kk-itc.yaml +6 -4
- package/plugins/kk/plugin.json +1 -1
- package/plugins/kk/publish-presets/daily-ai-voice-migration.json +146 -0
- package/plugins/kk/publish-presets/daily-ai-voice.json +11 -3
- package/plugins/kk/schedules/kk-collect.grok-bot.md.tmpl +69 -0
- package/plugins/kk/schedules/kk-daily-ai-voice3.cron.tmpl +18 -0
- package/plugins/kk/schedules/kk-daily-publish3-cn-peak.cron.tmpl +15 -5
- package/plugins/kk/schedules/kk-daily-publish3-overseas.cron.tmpl +9 -4
- package/plugins/kk/scripts/daily-scheduled-run.ts +298 -0
- package/plugins/kk/scripts/daily-stages.ts +1403 -156
- package/plugins/kk/scripts/kk-workflow-stages.ts +266 -10
- package/plugins/kk/scripts/safety-artifact-check.ts +277 -0
- package/plugins/kk/scripts/voice3-publish-window.ts +202 -0
- package/plugins/kk/skills/article-adapt/SKILL.md +1 -0
- package/plugins/kk/skills/audio-authoring/SKILL.md +1 -0
- package/plugins/kk/skills/content-judge/SKILL.md +1 -0
- package/plugins/kk/skills/content-safety/SKILL.md +1 -0
- package/plugins/kk/skills/explain-things/SKILL.md +1 -0
- package/plugins/kk/skills/image-authoring/SKILL.md +1 -0
- package/plugins/kk/skills/itc-generating/SKILL.md +1 -0
- package/plugins/kk/skills/publish/SKILL.md +1 -0
- package/plugins/kk/skills/smart-seo/SKILL.md +1 -0
- package/plugins/kk/skills/storm-research/SKILL.md +1 -0
- package/plugins/kk/skills/taste-unslop/SKILL.md +1 -0
- package/plugins/kk/skills/topic/SKILL.md +1 -0
- package/plugins/kk/skills/web-automation/SKILL.md +1 -0
- package/plugins/kk/skills/xhs-images/SKILL.md +1 -0
- package/plugins/kk/workflows/kk-daily-ai-voice2.yaml +177 -128
- package/plugins/kk/workflows/kk-daily-ai-voice3.yaml +161 -61
- package/plugins/kk/workflows/kk-daily-publish3.yaml +82 -26
- package/plugins/kk/workflows/kk-daily-safety.yaml +156 -69
- package/plugins/kk/workflows/kk-itc-author.yaml +75 -0
- package/plugins/kk/workflows/kk-itc-publish-tail.yaml +25 -3
- package/plugins/kk/workflows/kk-itc.yaml +36 -4
- 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/linkedin-pub/dist/index.js +21333 -0
- package/plugins/publishings/linkedin-pub/dist/profile-check.js +21005 -0
- package/plugins/publishings/linkedin-pub/package.json +19 -0
- package/plugins/publishings/linkedin-pub/plugin.json +12 -0
- package/plugins/publishings/linkedin-pub/src/index.ts +705 -0
- package/plugins/publishings/linkedin-pub/src/profile-check.ts +17 -0
- package/plugins/publishings/linkedin-pub/tsconfig.json +4 -0
- 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 +461 -53
- package/plugins/publishings/surfdash-pub/src/digest-check.ts +85 -19
- package/plugins/publishings/surfdash-pub/src/index.ts +592 -65
- package/plugins/publishings/threads-pub/dist/index.js +7 -0
- package/plugins/publishings/threads-pub/src/index.ts +12 -0
- package/plugins/publishings/wechatmp-pub/dist/index.js +936 -98
- package/plugins/publishings/wechatmp-pub/dist/profile-check.js +60 -3
- package/plugins/publishings/wechatmp-pub/src/flow.ts +1134 -86
- 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 +9 -1
- package/plugins/publishings/x-pub/dist/profile-check.js +2 -1
- package/plugins/publishings/x-pub/src/index.ts +11 -0
- 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) {
|
|
@@ -553,7 +735,9 @@ export function normalizeLocaleFrontmatter(markdown: string): string {
|
|
|
553
735
|
if (close < 0) return markdown;
|
|
554
736
|
const head = lines
|
|
555
737
|
.slice(1, close)
|
|
556
|
-
|
|
738
|
+
// Only the zh scaffold's canonical is stale; a sibling's own (refined) canonical must survive,
|
|
739
|
+
// or the orchestrator re-dumps the frontmatter and the byte-exact drift gate fails.
|
|
740
|
+
.filter((l) => !/^canonical\s*:.*\/zh\//.test(l))
|
|
557
741
|
.map((l) => (/^draft\s*:/.test(l) ? 'draft: false' : l));
|
|
558
742
|
return [lines[0], ...head, ...lines.slice(close)].join('\n');
|
|
559
743
|
}
|
|
@@ -659,6 +843,12 @@ async function patchPost(options: SurfdashPubOptions): Promise<Result> {
|
|
|
659
843
|
commitPaths.add(relative(surfdashRoot, assetsDir));
|
|
660
844
|
}
|
|
661
845
|
}
|
|
846
|
+
if (process.env.SURFDASH_AUTO_PUSH !== '0') {
|
|
847
|
+
const backfilled = await runListenLinkBackfill(surfdashRoot);
|
|
848
|
+
for (const p of backfilled) commitPaths.add(p);
|
|
849
|
+
const gate = await runSiteGate(surfdashRoot);
|
|
850
|
+
if (!gate.ok) return fail(gate.error);
|
|
851
|
+
}
|
|
662
852
|
const commit = await commitPostPaths(
|
|
663
853
|
surfdashRoot,
|
|
664
854
|
[...commitPaths],
|
|
@@ -693,95 +883,336 @@ async function patchPost(options: SurfdashPubOptions): Promise<Result> {
|
|
|
693
883
|
});
|
|
694
884
|
}
|
|
695
885
|
|
|
886
|
+
interface PreparedPost {
|
|
887
|
+
surfdashRoot: string;
|
|
888
|
+
locale: SupportedLocale;
|
|
889
|
+
dateStamp: string;
|
|
890
|
+
slug: string;
|
|
891
|
+
filePath: string;
|
|
892
|
+
relPostDir: string;
|
|
893
|
+
/** Prepared daily publishes carry all translated sibling directories into the deploy commit. */
|
|
894
|
+
relPostDirs?: string[];
|
|
895
|
+
articleUrl: string;
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
interface PreparedLocaleIdentity {
|
|
899
|
+
runDate: string;
|
|
900
|
+
postPath: string;
|
|
901
|
+
localePaths: Record<SupportedLocale, string>;
|
|
902
|
+
localeDigests: Record<SupportedLocale, string>;
|
|
903
|
+
fingerprint: string;
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
function fileSha256(filePath: string): string {
|
|
907
|
+
return createHash('sha256').update(readFileSync(filePath)).digest('hex');
|
|
908
|
+
}
|
|
909
|
+
|
|
696
910
|
/**
|
|
697
|
-
*
|
|
698
|
-
*
|
|
911
|
+
* Computes the exact identity used by the daily locale gate. This is intentionally derived from
|
|
912
|
+
* bytes on disk immediately before the remote publishing FSM starts; a cached result must never
|
|
913
|
+
* satisfy a different slug, payload, or translated-locale set.
|
|
699
914
|
*/
|
|
700
|
-
|
|
701
|
-
|
|
915
|
+
function preparedLocaleIdentity(
|
|
916
|
+
postPath: string,
|
|
917
|
+
runDate: string,
|
|
918
|
+
): { ok: true; value: PreparedLocaleIdentity } | { ok: false; error: string } {
|
|
919
|
+
const enPath = deriveLocaleDraftPath(postPath, 'en');
|
|
920
|
+
const jaPath = deriveLocaleDraftPath(postPath, 'ja');
|
|
921
|
+
if (enPath === undefined || jaPath === undefined) {
|
|
922
|
+
return { ok: false, error: `prepared zh post path must contain a "/zh/" locale segment: ${postPath}` };
|
|
923
|
+
}
|
|
924
|
+
const localePaths: Record<SupportedLocale, string> = { zh: postPath, en: enPath, ja: jaPath };
|
|
925
|
+
const localeDigests = {} as Record<SupportedLocale, string>;
|
|
926
|
+
for (const locale of ['zh', 'en', 'ja'] as const) {
|
|
927
|
+
const path = localePaths[locale];
|
|
928
|
+
if (!existsSync(path)) return { ok: false, error: `${locale} locale file is missing: ${path}` };
|
|
929
|
+
try {
|
|
930
|
+
localeDigests[locale] = fileSha256(path);
|
|
931
|
+
} catch (error: unknown) {
|
|
932
|
+
return {
|
|
933
|
+
ok: false,
|
|
934
|
+
error: `${locale} locale file cannot be fingerprinted: ${error instanceof Error ? error.message : String(error)}`,
|
|
935
|
+
};
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
const identity = { runDate, postPath, localePaths, localeDigests };
|
|
939
|
+
return {
|
|
940
|
+
ok: true,
|
|
941
|
+
value: { ...identity, fingerprint: createHash('sha256').update(JSON.stringify(identity)).digest('hex') },
|
|
942
|
+
};
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
interface PreparedLocaleSet {
|
|
946
|
+
surfdashRoot: string;
|
|
947
|
+
runDate: string;
|
|
948
|
+
postPath: string;
|
|
949
|
+
slug: string;
|
|
950
|
+
localePaths: Record<SupportedLocale, string>;
|
|
951
|
+
}
|
|
702
952
|
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
953
|
+
/**
|
|
954
|
+
* Resolves the exact three-file daily post set without writing anything. Both the local refine
|
|
955
|
+
* phase and the final prepared publish use this guard so they cannot drift to different slugs or
|
|
956
|
+
* locale siblings between validation and dispatch.
|
|
957
|
+
*/
|
|
958
|
+
function resolvePreparedLocaleSet(
|
|
959
|
+
options: SurfdashPubOptions,
|
|
960
|
+
): { ok: true; value: PreparedLocaleSet } | { ok: false; result: Result } {
|
|
961
|
+
const postPath = resolvePostPath(options);
|
|
962
|
+
if ('error' in postPath) return { ok: false, result: failNotSent(postPath.error) };
|
|
706
963
|
const surfdashRoot = resolveSurfdashRoot();
|
|
707
964
|
if (!surfdashRoot) {
|
|
708
|
-
return
|
|
965
|
+
return {
|
|
966
|
+
ok: false,
|
|
967
|
+
result: failNotSent(
|
|
968
|
+
'SURFDASH_ROOT is required when POSTSURFING_BIN is not a path inside the surfdash checkout',
|
|
969
|
+
),
|
|
970
|
+
};
|
|
971
|
+
}
|
|
972
|
+
const outsideRoot = postPathOutsideRoot(postPath.postPath, surfdashRoot);
|
|
973
|
+
if (outsideRoot) return { ok: false, result: failNotSent(outsideRoot) };
|
|
974
|
+
if (!existsSync(postPath.postPath))
|
|
975
|
+
return { ok: false, result: failNotSent(`prepared post does not exist: ${postPath.postPath}`) };
|
|
976
|
+
const runDate = options.runDate;
|
|
977
|
+
if (typeof runDate !== 'string' || !/^\d{8}$/.test(runDate)) {
|
|
978
|
+
return { ok: false, result: failNotSent(`options.runDate must be YYYYMMDD, got '${String(runDate)}'`) };
|
|
979
|
+
}
|
|
980
|
+
const articlesRoot = resolve(surfdashRoot, 'content', 'posts', 'articles');
|
|
981
|
+
const relativePost = relative(articlesRoot, postPath.postPath);
|
|
982
|
+
const parts = relativePost.split(sep);
|
|
983
|
+
const slug = parts[1];
|
|
984
|
+
if (parts.length !== 3 || parts[0] !== 'zh' || parts[2] !== 'index.md' || slug === undefined || slug === '') {
|
|
985
|
+
return {
|
|
986
|
+
ok: false,
|
|
987
|
+
result: failNotSent(
|
|
988
|
+
`options.postPath must be content/posts/articles/zh/<slug>/index.md: ${postPath.postPath}`,
|
|
989
|
+
),
|
|
990
|
+
};
|
|
991
|
+
}
|
|
992
|
+
const enPath = deriveLocaleDraftPath(postPath.postPath, 'en');
|
|
993
|
+
const jaPath = deriveLocaleDraftPath(postPath.postPath, 'ja');
|
|
994
|
+
if (enPath === undefined || jaPath === undefined) {
|
|
995
|
+
return {
|
|
996
|
+
ok: false,
|
|
997
|
+
result: failNotSent(`prepared zh post path must contain a "/zh/" locale segment: ${postPath.postPath}`),
|
|
998
|
+
};
|
|
999
|
+
}
|
|
1000
|
+
const localePaths: Record<SupportedLocale, string> = { zh: postPath.postPath, en: enPath, ja: jaPath };
|
|
1001
|
+
for (const locale of ['zh', 'en', 'ja'] as const) {
|
|
1002
|
+
if (!existsSync(localePaths[locale])) {
|
|
1003
|
+
return { ok: false, result: failNotSent(`${locale} locale file is missing: ${localePaths[locale]}`) };
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
return { ok: true, value: { surfdashRoot, runDate, postPath: postPath.postPath, slug, localePaths } };
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
function resolvePreparedPost(
|
|
1010
|
+
options: SurfdashPubOptions,
|
|
1011
|
+
): { ok: true; prepared: PreparedPost; identity: PreparedLocaleIdentity } | { ok: false; result: Result } {
|
|
1012
|
+
const resolved = resolvePreparedLocaleSet(options);
|
|
1013
|
+
if (!resolved.ok) return resolved;
|
|
1014
|
+
const { surfdashRoot, runDate, postPath: preparedPath, slug } = resolved.value;
|
|
1015
|
+
const expectedPostSha256 = options.expectedPostSha256;
|
|
1016
|
+
if (typeof expectedPostSha256 !== 'string' || !/^[0-9a-f]{64}$/.test(expectedPostSha256)) {
|
|
1017
|
+
return { ok: false, result: failNotSent('options.expectedPostSha256 must be a lowercase SHA-256 digest') };
|
|
1018
|
+
}
|
|
1019
|
+
const expectedLocaleFingerprint = options.expectedLocaleFingerprint;
|
|
1020
|
+
if (typeof expectedLocaleFingerprint !== 'string' || !/^[0-9a-f]{64}$/.test(expectedLocaleFingerprint)) {
|
|
1021
|
+
return {
|
|
1022
|
+
ok: false,
|
|
1023
|
+
result: failNotSent('options.expectedLocaleFingerprint must be a lowercase SHA-256 digest'),
|
|
1024
|
+
};
|
|
1025
|
+
}
|
|
1026
|
+
const identity = preparedLocaleIdentity(preparedPath, runDate);
|
|
1027
|
+
if (!identity.ok) return { ok: false, result: failNotSent(identity.error) };
|
|
1028
|
+
if (identity.value.localeDigests.zh !== expectedPostSha256) {
|
|
1029
|
+
return {
|
|
1030
|
+
ok: false,
|
|
1031
|
+
result: failNotSent(`prepared zh post bytes changed after validation: ${preparedPath}`),
|
|
1032
|
+
};
|
|
1033
|
+
}
|
|
1034
|
+
if (identity.value.fingerprint !== expectedLocaleFingerprint) {
|
|
1035
|
+
return {
|
|
1036
|
+
ok: false,
|
|
1037
|
+
result: failNotSent(
|
|
1038
|
+
'prepared locale files changed after validation; refresh the locale gate before publishing',
|
|
1039
|
+
),
|
|
1040
|
+
};
|
|
1041
|
+
}
|
|
1042
|
+
const baseUrl = (process.env.SURFDASH_URL ?? 'https://surfing.salty.vip').replace(/\/$/, '');
|
|
1043
|
+
return {
|
|
1044
|
+
ok: true,
|
|
1045
|
+
prepared: {
|
|
1046
|
+
surfdashRoot,
|
|
1047
|
+
locale: 'zh',
|
|
1048
|
+
dateStamp: runDate,
|
|
1049
|
+
slug,
|
|
1050
|
+
filePath: preparedPath,
|
|
1051
|
+
relPostDir: join('content', 'posts', 'articles', 'zh', slug),
|
|
1052
|
+
relPostDirs: ['zh', 'en', 'ja'].map((locale) => join('content', 'posts', 'articles', locale, slug)),
|
|
1053
|
+
articleUrl: `${baseUrl}/zh/posts/${slug}`,
|
|
1054
|
+
},
|
|
1055
|
+
identity: identity.value,
|
|
1056
|
+
};
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
type PrepareOutcome = { ok: true; prepared: PreparedPost } | { ok: false; result: Result };
|
|
1060
|
+
|
|
1061
|
+
/**
|
|
1062
|
+
* Materializes the local post without invoking the external publishing FSM.
|
|
1063
|
+
*
|
|
1064
|
+
* Locale scaffolding and translation must happen before Surfdash's strict sync gate runs. The
|
|
1065
|
+
* normal `publish` operation uses the same preparation path before refine, CMS sync and deploy.
|
|
1066
|
+
*/
|
|
1067
|
+
async function preparePost(payload: PublishTransportPayload): Promise<PrepareOutcome> {
|
|
1068
|
+
const surfdashRoot = resolveSurfdashRoot();
|
|
1069
|
+
if (!surfdashRoot) {
|
|
1070
|
+
return {
|
|
1071
|
+
ok: false,
|
|
1072
|
+
result: failNotSent(
|
|
1073
|
+
'SURFDASH_ROOT is required when POSTSURFING_BIN is not a path inside the surfdash checkout',
|
|
1074
|
+
),
|
|
1075
|
+
};
|
|
709
1076
|
}
|
|
710
1077
|
|
|
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}'`);
|
|
1078
|
+
const rawLocale = process.env.SURFDASH_LOCALE ?? 'zh';
|
|
1079
|
+
if (!['zh', 'en', 'ja'].includes(rawLocale)) {
|
|
1080
|
+
return { ok: false, result: failNotSent(`SURFDASH_LOCALE must be one of zh/en/ja, got '${rawLocale}'`) };
|
|
716
1081
|
}
|
|
717
|
-
|
|
718
|
-
// from colliding; Chinese titles slugify to '' and fall back to the bare date.
|
|
1082
|
+
const locale = rawLocale as SupportedLocale;
|
|
719
1083
|
const runDate = typeof payload.content.metadata?.runDate === 'string' ? payload.content.metadata.runDate : '';
|
|
720
1084
|
const today = new Date().toISOString().slice(0, 10).replaceAll('-', '');
|
|
721
1085
|
const dateStamp = runDate || today;
|
|
722
1086
|
const titleSlug = slugify(payload.content.title ?? '');
|
|
723
1087
|
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
1088
|
if (runDate !== '' && !/^\d{8}$/.test(runDate)) {
|
|
728
|
-
return
|
|
1089
|
+
return { ok: false, result: failNotSent(`metadata.runDate must be YYYYMMDD, got '${runDate}'`) };
|
|
729
1090
|
}
|
|
730
1091
|
const articlesRoot = resolve(surfdashRoot, 'content', 'posts', 'articles');
|
|
731
1092
|
const localeRoot = resolve(articlesRoot, locale);
|
|
732
1093
|
const postDir = resolve(localeRoot, slug);
|
|
733
1094
|
if (dirname(localeRoot) !== articlesRoot || dirname(postDir) !== localeRoot) {
|
|
734
|
-
return
|
|
1095
|
+
return { ok: false, result: failNotSent(`post path escapes ${articlesRoot}`) };
|
|
735
1096
|
}
|
|
1097
|
+
|
|
736
1098
|
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
1099
|
if (typeof coverPath === 'string' && !existsSync(coverPath)) {
|
|
740
|
-
return
|
|
1100
|
+
return { ok: false, result: failNotSent(`metadata.coverImage not found: ${coverPath}`) };
|
|
741
1101
|
}
|
|
742
1102
|
if (typeof coverPath === 'string' && !COVER_EXTS.has(extname(coverPath).toLowerCase())) {
|
|
743
|
-
return
|
|
1103
|
+
return {
|
|
1104
|
+
ok: false,
|
|
1105
|
+
result: failNotSent(`metadata.coverImage must be one of ${[...COVER_EXTS].join(', ')}`),
|
|
1106
|
+
};
|
|
744
1107
|
}
|
|
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
1108
|
if (isDigestContent(payload.content) && typeof coverPath !== 'string') {
|
|
748
|
-
return
|
|
1109
|
+
return { ok: false, result: failNotSent('digest post has no cover image (metadata.coverImage)') };
|
|
749
1110
|
}
|
|
750
1111
|
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
1112
|
+
const filePath = join(postDir, 'index.md');
|
|
1113
|
+
try {
|
|
1114
|
+
await createNodeFileSystem().ensureDir(postDir);
|
|
1115
|
+
let imageFrontmatter: string | undefined;
|
|
1116
|
+
if (typeof coverPath === 'string') {
|
|
1117
|
+
const assetsDir = join(postDir, 'assets');
|
|
1118
|
+
await createNodeFileSystem().ensureDir(assetsDir);
|
|
1119
|
+
const coverName = `cover${extname(coverPath).toLowerCase()}`;
|
|
1120
|
+
await copyFile(coverPath, join(assetsDir, coverName));
|
|
1121
|
+
imageFrontmatter = `./assets/${coverName}`;
|
|
1122
|
+
}
|
|
1123
|
+
const markdown = mapContentToSurfingMarkdown(payload.content, {
|
|
1124
|
+
image: imageFrontmatter,
|
|
1125
|
+
publishDate: runDate ? `${runDate.slice(0, 4)}-${runDate.slice(4, 6)}-${runDate.slice(6, 8)}` : undefined,
|
|
1126
|
+
});
|
|
1127
|
+
await createNodeFileSystem().writeFile(filePath, markdown);
|
|
1128
|
+
} catch (err: unknown) {
|
|
1129
|
+
return {
|
|
1130
|
+
ok: false,
|
|
1131
|
+
result: failNotSent(
|
|
1132
|
+
`Surfdash post preparation failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
1133
|
+
),
|
|
1134
|
+
};
|
|
762
1135
|
}
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
1136
|
+
|
|
1137
|
+
const baseUrl = (process.env.SURFDASH_URL ?? 'https://surfing.salty.vip').replace(/\/$/, '');
|
|
1138
|
+
const articleUrl = `${baseUrl}${locale === 'en' ? '' : `/${locale}`}/posts/${slug}`;
|
|
1139
|
+
return {
|
|
1140
|
+
ok: true,
|
|
1141
|
+
prepared: {
|
|
1142
|
+
surfdashRoot,
|
|
1143
|
+
locale,
|
|
1144
|
+
dateStamp,
|
|
1145
|
+
slug,
|
|
1146
|
+
filePath,
|
|
1147
|
+
relPostDir: join('content', 'posts', 'articles', locale, slug),
|
|
1148
|
+
articleUrl,
|
|
1149
|
+
},
|
|
1150
|
+
};
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
/** `prepare` operation: write the local post and return its path for locale scaffolding. */
|
|
1154
|
+
async function preparePostResult(payload: PublishTransportPayload): Promise<Result> {
|
|
1155
|
+
const outcome = await preparePost(payload);
|
|
1156
|
+
if (!outcome.ok) return outcome.result;
|
|
1157
|
+
return ResultSchema.parse({
|
|
1158
|
+
ok: true,
|
|
1159
|
+
target: TARGET,
|
|
1160
|
+
message: `Prepared ${outcome.prepared.locale} post at ${outcome.prepared.filePath}`,
|
|
1161
|
+
metadata: {
|
|
1162
|
+
postPath: outcome.prepared.filePath,
|
|
1163
|
+
articleUrl: outcome.prepared.articleUrl,
|
|
1164
|
+
dateStamp: outcome.prepared.dateStamp,
|
|
1165
|
+
slug: outcome.prepared.slug,
|
|
1166
|
+
},
|
|
766
1167
|
});
|
|
767
|
-
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
/** Invokes the remote publishing FSM for an already-materialized post path. */
|
|
1171
|
+
async function publishPreparedFile(
|
|
1172
|
+
prepared: PreparedPost,
|
|
1173
|
+
identity?: Pick<PreparedLocaleIdentity, 'runDate' | 'fingerprint'> & { postSha256?: string },
|
|
1174
|
+
): Promise<Result> {
|
|
1175
|
+
const postsurfingBin = process.env.POSTSURFING_BIN ?? 'postsurfing';
|
|
1176
|
+
const { surfdashRoot, locale, dateStamp, filePath, relPostDir, relPostDirs, articleUrl } = prepared;
|
|
1177
|
+
const identityMetadata = identity
|
|
1178
|
+
? {
|
|
1179
|
+
runDate: identity.runDate,
|
|
1180
|
+
localeFingerprint: identity.fingerprint,
|
|
1181
|
+
...(identity.postSha256 ? { postSha256: identity.postSha256 } : {}),
|
|
1182
|
+
}
|
|
1183
|
+
: {};
|
|
768
1184
|
|
|
769
1185
|
try {
|
|
770
|
-
await createNodeFileSystem().writeFile(filePath, markdown);
|
|
771
1186
|
const spawned = await spawnCli([postsurfingBin, 'publish', filePath], surfdashRoot);
|
|
772
1187
|
|
|
773
1188
|
if (spawned.exitCode !== 0) {
|
|
774
|
-
return
|
|
1189
|
+
return failUncertain(
|
|
775
1190
|
`postsurfing CLI failed (exit ${spawned.exitCode}): ${withEmdashTokenHint(cliErrorText(spawned))}`,
|
|
776
1191
|
);
|
|
777
1192
|
}
|
|
778
1193
|
|
|
1194
|
+
// The real Surfdash FSM runs its own refine step before CMS sync. The daily workflow runs
|
|
1195
|
+
// site-owned refine locally first, so this must be idempotent; otherwise the remote phase
|
|
1196
|
+
// would silently mutate the bytes whose fingerprint admitted this publish. Treat any
|
|
1197
|
+
// mutation as uncertain because CMS/git may already have side effects.
|
|
1198
|
+
if (identity !== undefined) {
|
|
1199
|
+
const afterRemote = preparedLocaleIdentity(filePath, identity.runDate);
|
|
1200
|
+
if (!afterRemote.ok)
|
|
1201
|
+
return failUncertain(`prepared locale files changed after remote publish: ${afterRemote.error}`);
|
|
1202
|
+
if (
|
|
1203
|
+
afterRemote.value.fingerprint !== identity.fingerprint ||
|
|
1204
|
+
(identity.postSha256 !== undefined && afterRemote.value.localeDigests.zh !== identity.postSha256)
|
|
1205
|
+
) {
|
|
1206
|
+
return failUncertain('prepared locale files changed after remote publish; reconcile before retrying');
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
|
|
779
1210
|
// Contract check (ADR-008): the site, not this plugin, owns SEO metadata.
|
|
780
1211
|
const notRefined = requireRefined(filePath);
|
|
781
|
-
if (notRefined) return
|
|
1212
|
+
if (notRefined) return failUncertain(notRefined);
|
|
782
1213
|
// Digest contract (2026-10-24): the refined values themselves must hold, before any push.
|
|
783
|
-
const digestProblem = digestCheckGate(filePath, locale
|
|
784
|
-
if (digestProblem) return
|
|
1214
|
+
const digestProblem = digestCheckGate(filePath, locale);
|
|
1215
|
+
if (digestProblem) return failUncertain(digestProblem);
|
|
785
1216
|
const refined = readRefinedSnapshot(filePath);
|
|
786
1217
|
|
|
787
1218
|
// Deploy seam: commit + push the post so deploy.yml builds it, then wait for
|
|
@@ -790,8 +1221,6 @@ async function publishPost(payload: PublishTransportPayload): Promise<Result> {
|
|
|
790
1221
|
// URL shape mirrors surfdash's localePathPrefix (surfdash/src/lib/seo.ts):
|
|
791
1222
|
// en is served at the site root, zh/ja carry a /<locale> prefix. Composing
|
|
792
1223
|
// /<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
1224
|
if (process.env.SURFDASH_AUTO_PUSH === '0') {
|
|
796
1225
|
return ResultSchema.parse({
|
|
797
1226
|
ok: true,
|
|
@@ -800,12 +1229,26 @@ async function publishPost(payload: PublishTransportPayload): Promise<Result> {
|
|
|
800
1229
|
message: 'Published via postsurfing CLI (auto-push disabled)',
|
|
801
1230
|
// Absolute post path lets downstream steps (locale translation + CMS sync)
|
|
802
1231
|
// derive the surfdash checkout root without extra configuration.
|
|
803
|
-
metadata: {
|
|
1232
|
+
metadata: {
|
|
1233
|
+
postPath: filePath,
|
|
1234
|
+
...identityMetadata,
|
|
1235
|
+
refinedTitle: refined?.title,
|
|
1236
|
+
refinedCanonical: refined?.canonical,
|
|
1237
|
+
},
|
|
804
1238
|
});
|
|
805
1239
|
}
|
|
806
|
-
const
|
|
807
|
-
const
|
|
808
|
-
if (!
|
|
1240
|
+
const backfilled = await runListenLinkBackfill(surfdashRoot);
|
|
1241
|
+
const gate = await runSiteGate(surfdashRoot);
|
|
1242
|
+
if (!gate.ok) return failUncertain(gate.error);
|
|
1243
|
+
const commitPaths = relPostDirs ?? [relPostDir];
|
|
1244
|
+
const commitLocales: SupportedLocale[] = relPostDirs === undefined ? [locale] : ['zh', 'en', 'ja'];
|
|
1245
|
+
const pushed = await pushPostGit(
|
|
1246
|
+
surfdashRoot,
|
|
1247
|
+
commitPaths,
|
|
1248
|
+
`content: daily AI news ${dateStamp} (${commitLocales.join(', ')})`,
|
|
1249
|
+
backfilled,
|
|
1250
|
+
);
|
|
1251
|
+
if (!pushed.ok) return failUncertain(pushed.error);
|
|
809
1252
|
const live = await awaitArticleLive(articleUrl);
|
|
810
1253
|
|
|
811
1254
|
return ResultSchema.parse({
|
|
@@ -815,16 +1258,95 @@ async function publishPost(payload: PublishTransportPayload): Promise<Result> {
|
|
|
815
1258
|
message: live
|
|
816
1259
|
? 'Published, committed, pushed, and live on the site'
|
|
817
1260
|
: 'Published and pushed; URL not live within the verify window — check the surfdash deploy workflow',
|
|
818
|
-
metadata: {
|
|
1261
|
+
metadata: {
|
|
1262
|
+
postPath: filePath,
|
|
1263
|
+
...identityMetadata,
|
|
1264
|
+
gitCommit: pushed.hash,
|
|
1265
|
+
live,
|
|
1266
|
+
refinedTitle: refined?.title,
|
|
1267
|
+
},
|
|
819
1268
|
});
|
|
820
1269
|
} catch (err: unknown) {
|
|
821
|
-
return
|
|
1270
|
+
return failUncertain(`Postsurfing transport error: ${err instanceof Error ? err.message : String(err)}`);
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
/**
|
|
1275
|
+
* Publish path (behavior unchanged since E3): maps Content into surfdash markdown under
|
|
1276
|
+
* `content/posts/<collection>/<locale>/<slug>/index.md` and invokes the postsurfing CLI.
|
|
1277
|
+
*/
|
|
1278
|
+
async function publishPost(payload: PublishTransportPayload): Promise<Result> {
|
|
1279
|
+
const outcome = await preparePost(payload);
|
|
1280
|
+
if (!outcome.ok) return outcome.result;
|
|
1281
|
+
return publishPreparedFile(outcome.prepared);
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
/**
|
|
1285
|
+
* Runs the site-owned refine command over zh/en/ja without CMS, git, or remote effects. The daily
|
|
1286
|
+
* workflow fingerprints only after this operation; the real publish FSM can then revisit refine
|
|
1287
|
+
* idempotently instead of rewriting a validated locale set during its remote phase.
|
|
1288
|
+
*/
|
|
1289
|
+
async function refinePreparedPost(options: SurfdashPubOptions): Promise<Result> {
|
|
1290
|
+
const resolved = resolvePreparedLocaleSet(options);
|
|
1291
|
+
if (!resolved.ok) return resolved.result;
|
|
1292
|
+
const orchestrator = join(resolved.value.surfdashRoot, 'scripts', 'publish-orchestrator.ts');
|
|
1293
|
+
if (!existsSync(orchestrator)) return failNotSent(`Surfdash refine orchestrator is missing: ${orchestrator}`);
|
|
1294
|
+
try {
|
|
1295
|
+
for (const locale of ['zh', 'en', 'ja'] as const) {
|
|
1296
|
+
const filePath = resolved.value.localePaths[locale];
|
|
1297
|
+
const spawned = await spawnCli(
|
|
1298
|
+
[process.execPath, orchestrator, 'refine', filePath],
|
|
1299
|
+
resolved.value.surfdashRoot,
|
|
1300
|
+
);
|
|
1301
|
+
if (spawned.exitCode !== 0) {
|
|
1302
|
+
return failNotSent(
|
|
1303
|
+
`Surfdash refine failed for ${locale} (exit ${spawned.exitCode}): ${cliErrorText(spawned)}`,
|
|
1304
|
+
);
|
|
1305
|
+
}
|
|
1306
|
+
const notRefined = requireRefined(filePath);
|
|
1307
|
+
if (notRefined) return failNotSent(notRefined);
|
|
1308
|
+
const digestProblem = digestCheckGate(filePath, locale);
|
|
1309
|
+
if (digestProblem) return failNotSent(digestProblem);
|
|
1310
|
+
}
|
|
1311
|
+
} catch (error: unknown) {
|
|
1312
|
+
return failNotSent(
|
|
1313
|
+
`Surfdash refine transport error: ${error instanceof Error ? error.message : String(error)}`,
|
|
1314
|
+
);
|
|
822
1315
|
}
|
|
1316
|
+
const identity = preparedLocaleIdentity(resolved.value.postPath, resolved.value.runDate);
|
|
1317
|
+
if (!identity.ok) return failNotSent(identity.error);
|
|
1318
|
+
return ResultSchema.parse({
|
|
1319
|
+
ok: true,
|
|
1320
|
+
target: TARGET,
|
|
1321
|
+
message: 'Refined zh/en/ja via the Surfdash-owned orchestrator',
|
|
1322
|
+
metadata: {
|
|
1323
|
+
postPath: resolved.value.postPath,
|
|
1324
|
+
runDate: resolved.value.runDate,
|
|
1325
|
+
localePaths: identity.value.localePaths,
|
|
1326
|
+
localeDigests: identity.value.localeDigests,
|
|
1327
|
+
localeFingerprint: identity.value.fingerprint,
|
|
1328
|
+
},
|
|
1329
|
+
});
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
/**
|
|
1333
|
+
* Final daily voice2 operation. Unlike `publish`, this never maps Content or rewrites the prepared
|
|
1334
|
+
* zh post. It verifies the exact validated bytes and locale fingerprint immediately before invoking
|
|
1335
|
+
* postsurfing, so a regenerated same-day payload cannot silently replace the validated material.
|
|
1336
|
+
*/
|
|
1337
|
+
async function publishPreparedPost(options: SurfdashPubOptions): Promise<Result> {
|
|
1338
|
+
const outcome = resolvePreparedPost(options);
|
|
1339
|
+
if (!outcome.ok) return outcome.result;
|
|
1340
|
+
return publishPreparedFile(outcome.prepared, {
|
|
1341
|
+
runDate: outcome.identity.runDate,
|
|
1342
|
+
fingerprint: outcome.identity.fingerprint,
|
|
1343
|
+
postSha256: outcome.identity.localeDigests.zh,
|
|
1344
|
+
});
|
|
823
1345
|
}
|
|
824
1346
|
|
|
825
1347
|
/**
|
|
826
1348
|
* FileCli transport invoking postsurfing CLI for `publish` (default) and the
|
|
827
|
-
* `scaffold-locale` / `sync` surfdash mechanics via `options.operation` (L1 R7).
|
|
1349
|
+
* prepared-path / `scaffold-locale` / `sync` surfdash mechanics via `options.operation` (L1 R7).
|
|
828
1350
|
*/
|
|
829
1351
|
export class PostsurfingFileCliTransport implements PublishTransport {
|
|
830
1352
|
public readonly kind = 'file-cli';
|
|
@@ -839,6 +1361,12 @@ export class PostsurfingFileCliTransport implements PublishTransport {
|
|
|
839
1361
|
if ('error' in routed) return fail(routed.error);
|
|
840
1362
|
|
|
841
1363
|
switch (routed.operation) {
|
|
1364
|
+
case 'prepare':
|
|
1365
|
+
return preparePostResult(payload);
|
|
1366
|
+
case 'refine-prepared':
|
|
1367
|
+
return refinePreparedPost(options);
|
|
1368
|
+
case 'publish-prepared':
|
|
1369
|
+
return publishPreparedPost(options);
|
|
842
1370
|
case 'scaffold-locale':
|
|
843
1371
|
return scaffoldLocale(options);
|
|
844
1372
|
case 'sync':
|
|
@@ -865,10 +1393,9 @@ export function getPublishTransport(): PublishTransport {
|
|
|
865
1393
|
/**
|
|
866
1394
|
* Processes input Content and writes output Result via transport.
|
|
867
1395
|
*
|
|
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).
|
|
1396
|
+
* The input JSON may carry a top-level `options` object (`operation`, `postPath`, `target`, and
|
|
1397
|
+
* prepared-path identity fields) alongside the Content fields; it is forwarded to the transport so
|
|
1398
|
+
* `kk executor run` callers can select locale/publish mechanics without extra flags (0096).
|
|
872
1399
|
*/
|
|
873
1400
|
export async function processPublishIO(
|
|
874
1401
|
inputPath: string,
|