@gobing-ai/knowledge-kit 0.1.8 → 0.1.9
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/board/collect/index.js +725 -1101
- package/dist/board/daily-voice/index.js +722 -101
- package/dist/board/itc/index.js +13 -0
- package/dist/index.js +1792 -102
- package/package.json +1 -1
- package/plugins/ingestions/horizon-ingest/dist/index.js +24 -1
- package/plugins/ingestions/horizon-ingest/src/index.ts +34 -0
- package/plugins/kk/config.example.yaml +8 -0
- package/plugins/kk/plugin.json +1 -1
- package/plugins/kk/publish-presets/daily-ai-voice.json +123 -0
- package/plugins/kk/schedules/kk-daily-publish3-cn-peak.cron.tmpl +20 -0
- package/plugins/kk/schedules/kk-daily-publish3-overseas.cron.tmpl +17 -0
- package/plugins/kk/scripts/daily-stages.ts +580 -78
- package/plugins/kk/scripts/kk-workflow-stages.ts +11 -1
- package/plugins/kk/scripts/topic-boundary.json +18 -83
- package/plugins/kk/workflows/kk-collect.yaml +2 -0
- package/plugins/kk/workflows/kk-daily-ai-voice2.yaml +75 -6
- package/plugins/kk/workflows/kk-daily-ai-voice3.yaml +842 -0
- package/plugins/kk/workflows/kk-daily-publish3.yaml +282 -0
- package/plugins/publishings/podcast-pub/README.md +65 -0
- package/plugins/publishings/podcast-pub/dist/index.js +1709 -274
- package/plugins/publishings/podcast-pub/plugin.json +1 -1
- package/plugins/publishings/podcast-pub/src/backfill.ts +465 -0
- package/plugins/publishings/podcast-pub/src/index.ts +211 -34
- package/plugins/publishings/podcast-pub/src/microfeed-client.ts +32 -3
- package/plugins/publishings/podcast-pub/src/seo.ts +1081 -0
- package/plugins/publishings/podcast-pub/src/show-notes.ts +71 -6
- package/plugins/publishings/surfdash-pub/dist/index.js +66 -5
- package/plugins/publishings/surfdash-pub/src/index.ts +94 -10
|
@@ -12,6 +12,14 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import type { Doc } from '@gobing-ai/kk-core';
|
|
15
|
+
import {
|
|
16
|
+
type PlanItem,
|
|
17
|
+
type PodcastSeoConfig,
|
|
18
|
+
SEO_DEFAULTS,
|
|
19
|
+
showNotesSummaryParagraph,
|
|
20
|
+
type TimelineStyle,
|
|
21
|
+
warn,
|
|
22
|
+
} from './seo';
|
|
15
23
|
|
|
16
24
|
/** Escapes text content and attribute values (`&`, `<`, `>`, `"`). */
|
|
17
25
|
function escapeHtml(value: string): string {
|
|
@@ -133,7 +141,7 @@ function isGeneratedTimeline(block: string): boolean {
|
|
|
133
141
|
* input. `ponytail`: no JS — timestamp deep-linking depends on the theme's player
|
|
134
142
|
* API; add when microfeed exposes one.
|
|
135
143
|
*/
|
|
136
|
-
function renderTimelineHtml(docs: Doc[], offsets: number[], articleUrl: string): string {
|
|
144
|
+
function renderTimelineHtml(docs: Doc[], offsets: number[], articleUrl: string, style: TimelineStyle): string {
|
|
137
145
|
const rows = docs
|
|
138
146
|
.map((d, i) => {
|
|
139
147
|
const title = escapeHtml(d.title ?? `条目 ${i + 1}`);
|
|
@@ -152,11 +160,15 @@ function renderTimelineHtml(docs: Doc[], offsets: number[], articleUrl: string):
|
|
|
152
160
|
const articleTail = isHttpUrl(articleUrl)
|
|
153
161
|
? `<p>完整图文版:<a href="${escapeHtml(articleUrl)}" target="_blank" rel="noopener">${escapeHtml('本期完整文章')}</a></p>`
|
|
154
162
|
: `<p>${escapeHtml('本期完整文章')}</p>`;
|
|
163
|
+
const styleBlock = style === 'omit' ? '' : `${TIMELINE_STYLE}\n`;
|
|
164
|
+
// `after` (default) keeps the style inside the markers but after the list, so content_text
|
|
165
|
+
// does not open on CSS. `before` is the pre-D9 position, for a byte-identical legacy render.
|
|
166
|
+
const before = style === 'before' ? styleBlock : '';
|
|
167
|
+
const after = style === 'after' ? styleBlock : '';
|
|
155
168
|
return `<!--kk:timeline-->
|
|
156
|
-
${
|
|
157
|
-
<div class="kk-tl">${rows}
|
|
169
|
+
${before}<div class="kk-tl">${rows}
|
|
158
170
|
</div>
|
|
159
|
-
${articleTail}
|
|
171
|
+
${after}${articleTail}
|
|
160
172
|
<!--/kk:timeline-->`;
|
|
161
173
|
}
|
|
162
174
|
|
|
@@ -277,6 +289,22 @@ export interface ShowNotesRenderOptions {
|
|
|
277
289
|
* matched monotonically against segment transcriptions; otherwise `--:--`.
|
|
278
290
|
*/
|
|
279
291
|
audioSegments?: Array<{ duration?: number; transcription?: string }>;
|
|
292
|
+
/** R4. Omit to keep the default (summary on). `false` reproduces the pre-D9 body. */
|
|
293
|
+
summaryParagraph?: boolean;
|
|
294
|
+
/** R4. `after` moves `<style>` below the timeline; `before` is the legacy position; `omit` drops it. */
|
|
295
|
+
timelineStyle?: TimelineStyle;
|
|
296
|
+
seriesName?: string;
|
|
297
|
+
showName?: string;
|
|
298
|
+
descriptionMin?: number;
|
|
299
|
+
descriptionMax?: number;
|
|
300
|
+
descriptionSource?: PodcastSeoConfig['descriptionSource'];
|
|
301
|
+
descriptionPattern?: string;
|
|
302
|
+
/** Playback seconds for the `(约 N 分钟)` clause. Segment durations are the fallback. */
|
|
303
|
+
durationSeconds?: number;
|
|
304
|
+
/** Link text for the summary cross-link. Falls back to 本期完整文章. */
|
|
305
|
+
surfingTitle?: string;
|
|
306
|
+
refinedDescription?: string;
|
|
307
|
+
summaryClauses?: string[];
|
|
280
308
|
}
|
|
281
309
|
|
|
282
310
|
/** Per-item 1–2-sentence briefing: `metadata.brief` first, body sentences as fallback. */
|
|
@@ -298,7 +326,7 @@ function itemBrief(d: Doc): string {
|
|
|
298
326
|
* a `[来源:<label>](<sourceUri>)` link per item, and the full-article footer.
|
|
299
327
|
* Downstream HTML rendering stays with {@link renderShowNotesHtml}.
|
|
300
328
|
*/
|
|
301
|
-
export function renderShowNotesMarkdown(docs: Doc[], opts: ShowNotesRenderOptions): string {
|
|
329
|
+
export function renderShowNotesMarkdown(docs: Doc[], opts: ShowNotesRenderOptions, warnings?: string[]): string {
|
|
302
330
|
const slugify = (t: string): string => {
|
|
303
331
|
const s = t
|
|
304
332
|
.toLowerCase()
|
|
@@ -323,6 +351,9 @@ export function renderShowNotesMarkdown(docs: Doc[], opts: ShowNotesRenderOption
|
|
|
323
351
|
postSlug.length > 0
|
|
324
352
|
? `${baseUrl}${localePrefix}/posts/${postSlug}`
|
|
325
353
|
: `${baseUrl}/zh/posts/${opts.runDate}-${slugify(opts.title)}`;
|
|
354
|
+
if (opts.briefMode === true && postSlug.length === 0) {
|
|
355
|
+
warn('brief mode requested but ARTICLE_POST_PATH is empty; using legacy layout', warnings);
|
|
356
|
+
}
|
|
326
357
|
const briefMode = opts.briefMode === true && postSlug.length > 0;
|
|
327
358
|
const sourceLabel = (d: Doc): string => {
|
|
328
359
|
const m = d.metadata ?? {};
|
|
@@ -335,10 +366,44 @@ export function renderShowNotesMarkdown(docs: Doc[], opts: ShowNotesRenderOption
|
|
|
335
366
|
};
|
|
336
367
|
const offsets = computeAudioOffsets(docs, opts.audioSegments);
|
|
337
368
|
const lines: string[] = [`# ${opts.title}`, ''];
|
|
369
|
+
const summaryOn = opts.summaryParagraph !== false;
|
|
370
|
+
if (summaryOn) {
|
|
371
|
+
const segmentSeconds = opts.audioSegments?.reduce((sum, segment) => sum + (segment.duration ?? 0), 0);
|
|
372
|
+
const durationSeconds =
|
|
373
|
+
opts.durationSeconds ?? (segmentSeconds && segmentSeconds > 0 ? segmentSeconds : undefined);
|
|
374
|
+
const plan: PlanItem[] = docs.map((doc) => {
|
|
375
|
+
const title = doc.title?.trim() ?? '';
|
|
376
|
+
const brief = itemBrief(doc);
|
|
377
|
+
return brief.length > 0 ? { title, brief } : { title };
|
|
378
|
+
});
|
|
379
|
+
const config: PodcastSeoConfig = {
|
|
380
|
+
...SEO_DEFAULTS,
|
|
381
|
+
...(opts.seriesName ? { seriesName: opts.seriesName } : {}),
|
|
382
|
+
...(opts.showName ? { showName: opts.showName } : {}),
|
|
383
|
+
...(opts.descriptionMin !== undefined ? { descriptionMin: opts.descriptionMin } : {}),
|
|
384
|
+
...(opts.descriptionMax !== undefined ? { descriptionMax: opts.descriptionMax } : {}),
|
|
385
|
+
...(opts.descriptionSource ? { descriptionSource: opts.descriptionSource } : {}),
|
|
386
|
+
...(opts.descriptionPattern ? { descriptionPattern: opts.descriptionPattern } : {}),
|
|
387
|
+
summaryParagraph: true,
|
|
388
|
+
timelineStyle: opts.timelineStyle ?? 'after',
|
|
389
|
+
};
|
|
390
|
+
const summary = showNotesSummaryParagraph({
|
|
391
|
+
config,
|
|
392
|
+
runDate: opts.runDate,
|
|
393
|
+
...(durationSeconds !== undefined ? { durationSeconds } : {}),
|
|
394
|
+
plan,
|
|
395
|
+
...(opts.refinedDescription ? { refinedDescription: opts.refinedDescription } : {}),
|
|
396
|
+
...(opts.summaryClauses ? { summaryClauses: opts.summaryClauses } : {}),
|
|
397
|
+
articleUrl,
|
|
398
|
+
linkText: opts.surfingTitle?.trim() || '本期完整文章',
|
|
399
|
+
});
|
|
400
|
+
for (const line of summary.warnings) warn(line.replace(/^WARN podcast-pub:\s*/, ''), warnings);
|
|
401
|
+
lines.push(summary.markdown, '');
|
|
402
|
+
}
|
|
338
403
|
if (briefMode) {
|
|
339
404
|
// Timeline layout (2026-09-16 feedback): real audio offsets left, title + briefing
|
|
340
405
|
// right, article link last — the whole brief body is one pre-built HTML block.
|
|
341
|
-
lines.push(renderTimelineHtml(docs, offsets, articleUrl), '');
|
|
406
|
+
lines.push(renderTimelineHtml(docs, offsets, articleUrl, opts.timelineStyle ?? 'after'), '');
|
|
342
407
|
} else {
|
|
343
408
|
docs.forEach((d, i) => {
|
|
344
409
|
const body = (d.body ?? '').trim();
|
|
@@ -12473,15 +12473,12 @@ function mapContentToSurfingMarkdown(content, options = {}) {
|
|
|
12473
12473
|
...new Set(rawTags.filter((t) => typeof t === "string" && t.trim().length > 0).map((t) => normalizeTag(t)))
|
|
12474
12474
|
].filter((tag) => tag.length > 0);
|
|
12475
12475
|
const isoDate = options.publishDate ?? new Date().toISOString().slice(0, 10);
|
|
12476
|
-
const firstRefUrl = content.references?.[0]?.url;
|
|
12477
|
-
const canonical = typeof firstRefUrl === "string" ? firstRefUrl : typeof content.metadata?.canonicalUrl === "string" ? content.metadata.canonicalUrl : undefined;
|
|
12478
12476
|
const draft = typeof content.metadata?.published === "boolean" ? !content.metadata.published : false;
|
|
12479
12477
|
const frontmatterObj = {
|
|
12480
12478
|
title,
|
|
12481
12479
|
description: description ?? "",
|
|
12482
12480
|
tags,
|
|
12483
12481
|
publishDate: isoDate,
|
|
12484
|
-
...canonical ? { canonical } : {},
|
|
12485
12482
|
draft,
|
|
12486
12483
|
...options.image ? { image: options.image, og_image: options.image } : {}
|
|
12487
12484
|
};
|
|
@@ -12568,6 +12565,35 @@ function cliErrorText(spawned) {
|
|
|
12568
12565
|
function withEmdashTokenHint(errText) {
|
|
12569
12566
|
return /INVALID_TOKEN|API error 401/.test(errText) ? `${errText} \u2014 hint: regenerate the EmDash token (surfing.salty.vip admin console) and update EMDASH_API_TOKEN in ~/.config/kk/config.yaml` : errText;
|
|
12570
12567
|
}
|
|
12568
|
+
function readRefinedSnapshot(filePath) {
|
|
12569
|
+
if (!existsSync2(filePath))
|
|
12570
|
+
return;
|
|
12571
|
+
const raw = readFileSync2(filePath, "utf-8");
|
|
12572
|
+
const block = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
12573
|
+
const fm = block?.[1] ?? "";
|
|
12574
|
+
if (!fm || !/^refined:\s*true$/m.test(fm))
|
|
12575
|
+
return;
|
|
12576
|
+
const read = (key) => {
|
|
12577
|
+
const m = fm.match(new RegExp(`^${key}:\\s*(?:>-?
|
|
12578
|
+
((?:[ \\t]+.*
|
|
12579
|
+
?)+)|(.*))$`, "m"));
|
|
12580
|
+
if (!m)
|
|
12581
|
+
return;
|
|
12582
|
+
const value = (m[1] ?? m[2] ?? "").replace(/\n[ \t]+/g, " ").trim();
|
|
12583
|
+
return value.replace(/^["']|["']$/g, "") || undefined;
|
|
12584
|
+
};
|
|
12585
|
+
return {
|
|
12586
|
+
title: read("title"),
|
|
12587
|
+
seoTitle: read("seo_title"),
|
|
12588
|
+
description: read("description"),
|
|
12589
|
+
canonical: read("canonical")
|
|
12590
|
+
};
|
|
12591
|
+
}
|
|
12592
|
+
function requireRefined(filePath) {
|
|
12593
|
+
if (readRefinedSnapshot(filePath))
|
|
12594
|
+
return;
|
|
12595
|
+
return `surfdash refine step did not run for ${filePath} (no \`refined: true\`): the publishing pipeline is broken \u2014 the post would ship with generator-supplied SEO metadata`;
|
|
12596
|
+
}
|
|
12571
12597
|
async function pushPostGit(surfdashRoot, relPostDir, message) {
|
|
12572
12598
|
return commitPostPaths(surfdashRoot, [relPostDir], message);
|
|
12573
12599
|
}
|
|
@@ -12584,12 +12610,40 @@ async function assertMainBranch(surfdashRoot) {
|
|
|
12584
12610
|
}
|
|
12585
12611
|
return { ok: true };
|
|
12586
12612
|
}
|
|
12613
|
+
async function fastForwardToOrigin(surfdashRoot) {
|
|
12614
|
+
const fetched = await spawnCli(["git", "fetch", "--quiet", "origin", "main"], surfdashRoot);
|
|
12615
|
+
if (fetched.exitCode !== 0)
|
|
12616
|
+
return { ok: true };
|
|
12617
|
+
const counts = await spawnCli(["git", "rev-list", "--left-right", "--count", "origin/main...HEAD"], surfdashRoot);
|
|
12618
|
+
if (counts.exitCode !== 0)
|
|
12619
|
+
return { ok: true };
|
|
12620
|
+
const [behindRaw = "0", aheadRaw = "0"] = counts.stdout.trim().split(/\s+/);
|
|
12621
|
+
const behind = Number(behindRaw);
|
|
12622
|
+
const ahead = Number(aheadRaw);
|
|
12623
|
+
if (!Number.isFinite(behind) || !Number.isFinite(ahead) || behind === 0)
|
|
12624
|
+
return { ok: true };
|
|
12625
|
+
if (ahead > 0) {
|
|
12626
|
+
return {
|
|
12627
|
+
ok: false,
|
|
12628
|
+
error: `surfdash checkout diverged from origin/main (${ahead} local, ${behind} remote commit(s)): reconcile it by hand before publishing`
|
|
12629
|
+
};
|
|
12630
|
+
}
|
|
12631
|
+
const merged = await spawnCli(["git", "merge", "--ff-only", "--quiet", "origin/main"], surfdashRoot);
|
|
12632
|
+
if (merged.exitCode !== 0) {
|
|
12633
|
+
return { ok: false, error: `surfdash fast-forward to origin/main failed: ${cliErrorText(merged)}` };
|
|
12634
|
+
}
|
|
12635
|
+
echoError(`surfdash: fast-forwarded ${behind} commit(s) to origin/main before writing`);
|
|
12636
|
+
return { ok: true };
|
|
12637
|
+
}
|
|
12587
12638
|
async function commitPostPaths(surfdashRoot, relPaths, message, push = true) {
|
|
12588
12639
|
if (relPaths.length === 0)
|
|
12589
12640
|
return { ok: true, hash: "unchanged" };
|
|
12590
12641
|
const branch = await assertMainBranch(surfdashRoot);
|
|
12591
12642
|
if (!branch.ok)
|
|
12592
12643
|
return branch;
|
|
12644
|
+
const base = await fastForwardToOrigin(surfdashRoot);
|
|
12645
|
+
if (!base.ok)
|
|
12646
|
+
return base;
|
|
12593
12647
|
const add = await spawnCli(["git", "add", "--", ...relPaths], surfdashRoot);
|
|
12594
12648
|
if (add.exitCode !== 0)
|
|
12595
12649
|
return { ok: false, error: `surfdash git add failed: ${cliErrorText(add)}` };
|
|
@@ -12843,6 +12897,10 @@ async function publishPost(payload) {
|
|
|
12843
12897
|
if (spawned.exitCode !== 0) {
|
|
12844
12898
|
return fail(`postsurfing CLI failed (exit ${spawned.exitCode}): ${withEmdashTokenHint(cliErrorText(spawned))}`);
|
|
12845
12899
|
}
|
|
12900
|
+
const notRefined = requireRefined(filePath);
|
|
12901
|
+
if (notRefined)
|
|
12902
|
+
return fail(notRefined);
|
|
12903
|
+
const refined = readRefinedSnapshot(filePath);
|
|
12846
12904
|
const baseUrl = (process.env.SURFDASH_URL ?? "https://surfing.salty.vip").replace(/\/$/, "");
|
|
12847
12905
|
const articleUrl = `${baseUrl}${locale === "en" ? "" : `/${locale}`}/posts/${slug}`;
|
|
12848
12906
|
if (process.env.SURFDASH_AUTO_PUSH === "0") {
|
|
@@ -12851,7 +12909,7 @@ async function publishPost(payload) {
|
|
|
12851
12909
|
target: TARGET,
|
|
12852
12910
|
url: articleUrl,
|
|
12853
12911
|
message: "Published via postsurfing CLI (auto-push disabled)",
|
|
12854
|
-
metadata: { postPath: filePath }
|
|
12912
|
+
metadata: { postPath: filePath, refinedTitle: refined?.title, refinedCanonical: refined?.canonical }
|
|
12855
12913
|
});
|
|
12856
12914
|
}
|
|
12857
12915
|
const relPostDir = join2("content", "posts", "articles", locale, slug);
|
|
@@ -12864,7 +12922,7 @@ async function publishPost(payload) {
|
|
|
12864
12922
|
target: TARGET,
|
|
12865
12923
|
url: articleUrl,
|
|
12866
12924
|
message: live ? "Published, committed, pushed, and live on the site" : "Published and pushed; URL not live within the verify window \u2014 check the surfdash deploy workflow",
|
|
12867
|
-
metadata: { postPath: filePath, gitCommit: pushed.hash, live }
|
|
12925
|
+
metadata: { postPath: filePath, gitCommit: pushed.hash, live, refinedTitle: refined?.title }
|
|
12868
12926
|
});
|
|
12869
12927
|
} catch (err) {
|
|
12870
12928
|
return fail(`Postsurfing transport error: ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -12944,6 +13002,7 @@ export {
|
|
|
12944
13002
|
PostsurfingFileCliTransport,
|
|
12945
13003
|
awaitArticleLive,
|
|
12946
13004
|
commitPostPaths,
|
|
13005
|
+
fastForwardToOrigin,
|
|
12947
13006
|
getPublishTransport,
|
|
12948
13007
|
main2 as main,
|
|
12949
13008
|
mapContentToSurfingMarkdown,
|
|
@@ -12951,6 +13010,8 @@ export {
|
|
|
12951
13010
|
normalizeTag,
|
|
12952
13011
|
processPublishIO,
|
|
12953
13012
|
pushPostGit,
|
|
13013
|
+
readRefinedSnapshot,
|
|
13014
|
+
requireRefined,
|
|
12954
13015
|
slugify2 as slugify,
|
|
12955
13016
|
withEmdashTokenHint
|
|
12956
13017
|
};
|
|
@@ -120,23 +120,22 @@ export function mapContentToSurfingMarkdown(content: Content, options: MapConten
|
|
|
120
120
|
].filter((tag) => tag.length > 0);
|
|
121
121
|
|
|
122
122
|
const isoDate = options.publishDate ?? new Date().toISOString().slice(0, 10);
|
|
123
|
-
const firstRefUrl = content.references?.[0]?.url;
|
|
124
|
-
const canonical =
|
|
125
|
-
typeof firstRefUrl === 'string'
|
|
126
|
-
? firstRefUrl
|
|
127
|
-
: typeof content.metadata?.canonicalUrl === 'string'
|
|
128
|
-
? content.metadata.canonicalUrl
|
|
129
|
-
: undefined;
|
|
130
123
|
const draft = typeof content.metadata?.published === 'boolean' ? !content.metadata.published : false;
|
|
131
124
|
|
|
132
125
|
// Strict surfdash PostSchema (surfdash/src/content/schema.ts): publishDate (ISO date) is
|
|
133
126
|
// required; unknown keys (`date`, `canonical_url`, `published`) are rejected.
|
|
127
|
+
//
|
|
128
|
+
// `canonical` is deliberately NOT written here. The canonical URL is the site's own route
|
|
129
|
+
// for this slug, which only the site can compute (locale prefix + collection segment); the
|
|
130
|
+
// previous `references[0].url` fallback pointed the canonical at an unrelated *external*
|
|
131
|
+
// source and would have dropped the post out of the sitemap if surfdash's refine step had
|
|
132
|
+
// not overwritten it. See docs/00_ADR.md ADR-008: the generator supplies content, the site
|
|
133
|
+
// supplies identity.
|
|
134
134
|
const frontmatterObj: Record<string, unknown> = {
|
|
135
135
|
title,
|
|
136
136
|
description: description ?? '',
|
|
137
137
|
tags,
|
|
138
138
|
publishDate: isoDate,
|
|
139
|
-
...(canonical ? { canonical } : {}),
|
|
140
139
|
draft,
|
|
141
140
|
...(options.image ? { image: options.image, og_image: options.image } : {}),
|
|
142
141
|
};
|
|
@@ -273,6 +272,49 @@ export function withEmdashTokenHint(errText: string): string {
|
|
|
273
272
|
: errText;
|
|
274
273
|
}
|
|
275
274
|
|
|
275
|
+
/**
|
|
276
|
+
* Post-conditions the site guarantees after its publishing pipeline ran.
|
|
277
|
+
*
|
|
278
|
+
* The publisher does not derive SEO metadata any more (ADR-008): surfdash's refine step owns
|
|
279
|
+
* `refined`, `seo_title`, `canonical` and the digest title/description. Verifying the marker
|
|
280
|
+
* here turns a silently-regressed site pipeline into a loud publisher failure instead of a post
|
|
281
|
+
* that ships with a date-only title — which is exactly how the 2026-10-21 regression went
|
|
282
|
+
* unnoticed.
|
|
283
|
+
*/
|
|
284
|
+
export interface RefinedSnapshot {
|
|
285
|
+
title?: string;
|
|
286
|
+
seoTitle?: string;
|
|
287
|
+
description?: string;
|
|
288
|
+
canonical?: string;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Reads the frontmatter scalar fields the site owns; undefined when refine never ran. */
|
|
292
|
+
export function readRefinedSnapshot(filePath: string): RefinedSnapshot | undefined {
|
|
293
|
+
if (!existsSync(filePath)) return undefined;
|
|
294
|
+
const raw = readFileSync(filePath, 'utf-8');
|
|
295
|
+
const block = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
296
|
+
const fm = block?.[1] ?? '';
|
|
297
|
+
if (!fm || !/^refined:\s*true$/m.test(fm)) return undefined;
|
|
298
|
+
const read = (key: string): string | undefined => {
|
|
299
|
+
const m = fm.match(new RegExp(`^${key}:\\s*(?:>-?\n((?:[ \\t]+.*\n?)+)|(.*))$`, 'm'));
|
|
300
|
+
if (!m) return undefined;
|
|
301
|
+
const value = (m[1] ?? m[2] ?? '').replace(/\n[ \t]+/g, ' ').trim();
|
|
302
|
+
return value.replace(/^["']|["']$/g, '') || undefined;
|
|
303
|
+
};
|
|
304
|
+
return {
|
|
305
|
+
title: read('title'),
|
|
306
|
+
seoTitle: read('seo_title'),
|
|
307
|
+
description: read('description'),
|
|
308
|
+
canonical: read('canonical'),
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** Error text when the site pipeline did not mark the post refined, else undefined. */
|
|
313
|
+
export function requireRefined(filePath: string): string | undefined {
|
|
314
|
+
if (readRefinedSnapshot(filePath)) return undefined;
|
|
315
|
+
return `surfdash refine step did not run for ${filePath} (no \`refined: true\`): the publishing pipeline is broken — the post would ship with generator-supplied SEO metadata`;
|
|
316
|
+
}
|
|
317
|
+
|
|
276
318
|
/** Outcome of {@link pushPostGit} — the union keeps fail-loud errors typed at the call site. */
|
|
277
319
|
export type GitPushOutcome = { ok: true; hash: string } | { ok: false; error: string };
|
|
278
320
|
|
|
@@ -300,6 +342,41 @@ async function assertMainBranch(surfdashRoot: string): Promise<{ ok: true } | {
|
|
|
300
342
|
return { ok: true };
|
|
301
343
|
}
|
|
302
344
|
|
|
345
|
+
/**
|
|
346
|
+
* Fast-forward the checkout onto `origin/main` before anything is written (2026-10-22).
|
|
347
|
+
*
|
|
348
|
+
* Three consecutive publish days failed with `! [rejected] main -> main (fetch first)`: this
|
|
349
|
+
* checkout sat behind a remote another machine had pushed to, so the publish commit landed on a
|
|
350
|
+
* stale base and the push was rejected — after the article had already been written and committed,
|
|
351
|
+
* and after the podcast episode had already published, so the run closed `partial`/`failed` while a
|
|
352
|
+
* real episode was live. A fast-forward is lossless (HEAD moves only when local carries no commit of
|
|
353
|
+
* its own), which is exactly the case here; a DIVERGED checkout is the operator's call and fails
|
|
354
|
+
* loud with both counts. An unreachable remote is not fatal: the fetch error is swallowed so the
|
|
355
|
+
* push below reports the real reason.
|
|
356
|
+
*/
|
|
357
|
+
export async function fastForwardToOrigin(surfdashRoot: string): Promise<{ ok: true } | { ok: false; error: string }> {
|
|
358
|
+
const fetched = await spawnCli(['git', 'fetch', '--quiet', 'origin', 'main'], surfdashRoot);
|
|
359
|
+
if (fetched.exitCode !== 0) return { ok: true };
|
|
360
|
+
const counts = await spawnCli(['git', 'rev-list', '--left-right', '--count', 'origin/main...HEAD'], surfdashRoot);
|
|
361
|
+
if (counts.exitCode !== 0) return { ok: true };
|
|
362
|
+
const [behindRaw = '0', aheadRaw = '0'] = counts.stdout.trim().split(/\s+/);
|
|
363
|
+
const behind = Number(behindRaw);
|
|
364
|
+
const ahead = Number(aheadRaw);
|
|
365
|
+
if (!Number.isFinite(behind) || !Number.isFinite(ahead) || behind === 0) return { ok: true };
|
|
366
|
+
if (ahead > 0) {
|
|
367
|
+
return {
|
|
368
|
+
ok: false,
|
|
369
|
+
error: `surfdash checkout diverged from origin/main (${ahead} local, ${behind} remote commit(s)): reconcile it by hand before publishing`,
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
const merged = await spawnCli(['git', 'merge', '--ff-only', '--quiet', 'origin/main'], surfdashRoot);
|
|
373
|
+
if (merged.exitCode !== 0) {
|
|
374
|
+
return { ok: false, error: `surfdash fast-forward to origin/main failed: ${cliErrorText(merged)}` };
|
|
375
|
+
}
|
|
376
|
+
echoError(`surfdash: fast-forwarded ${behind} commit(s) to origin/main before writing`);
|
|
377
|
+
return { ok: true };
|
|
378
|
+
}
|
|
379
|
+
|
|
303
380
|
/**
|
|
304
381
|
* Commits several paths of the surfdash checkout and pushes them to `main` — the plural form of
|
|
305
382
|
* {@link pushPostGit} the post-patch needs (it writes the posts and any copied cover). Same
|
|
@@ -315,6 +392,8 @@ export async function commitPostPaths(
|
|
|
315
392
|
if (relPaths.length === 0) return { ok: true, hash: 'unchanged' };
|
|
316
393
|
const branch = await assertMainBranch(surfdashRoot);
|
|
317
394
|
if (!branch.ok) return branch;
|
|
395
|
+
const base = await fastForwardToOrigin(surfdashRoot);
|
|
396
|
+
if (!base.ok) return base;
|
|
318
397
|
const add = await spawnCli(['git', 'add', '--', ...relPaths], surfdashRoot);
|
|
319
398
|
if (add.exitCode !== 0) return { ok: false, error: `surfdash git add failed: ${cliErrorText(add)}` };
|
|
320
399
|
const staged = await spawnCli(['git', 'diff', '--cached', '--quiet', '--', ...relPaths], surfdashRoot);
|
|
@@ -653,6 +732,11 @@ async function publishPost(payload: PublishTransportPayload): Promise<Result> {
|
|
|
653
732
|
);
|
|
654
733
|
}
|
|
655
734
|
|
|
735
|
+
// Contract check (ADR-008): the site, not this plugin, owns SEO metadata.
|
|
736
|
+
const notRefined = requireRefined(filePath);
|
|
737
|
+
if (notRefined) return fail(notRefined);
|
|
738
|
+
const refined = readRefinedSnapshot(filePath);
|
|
739
|
+
|
|
656
740
|
// Deploy seam: commit + push the post so deploy.yml builds it, then wait for
|
|
657
741
|
// the live URL — downstream show-notes advertise a real page, not a 404.
|
|
658
742
|
// Tests (and offline runs) opt out via SURFDASH_AUTO_PUSH=0.
|
|
@@ -669,7 +753,7 @@ async function publishPost(payload: PublishTransportPayload): Promise<Result> {
|
|
|
669
753
|
message: 'Published via postsurfing CLI (auto-push disabled)',
|
|
670
754
|
// Absolute post path lets downstream steps (locale translation + CMS sync)
|
|
671
755
|
// derive the surfdash checkout root without extra configuration.
|
|
672
|
-
metadata: { postPath: filePath },
|
|
756
|
+
metadata: { postPath: filePath, refinedTitle: refined?.title, refinedCanonical: refined?.canonical },
|
|
673
757
|
});
|
|
674
758
|
}
|
|
675
759
|
const relPostDir = join('content', 'posts', 'articles', locale, slug);
|
|
@@ -684,7 +768,7 @@ async function publishPost(payload: PublishTransportPayload): Promise<Result> {
|
|
|
684
768
|
message: live
|
|
685
769
|
? 'Published, committed, pushed, and live on the site'
|
|
686
770
|
: 'Published and pushed; URL not live within the verify window — check the surfdash deploy workflow',
|
|
687
|
-
metadata: { postPath: filePath, gitCommit: pushed.hash, live },
|
|
771
|
+
metadata: { postPath: filePath, gitCommit: pushed.hash, live, refinedTitle: refined?.title },
|
|
688
772
|
});
|
|
689
773
|
} catch (err: unknown) {
|
|
690
774
|
return fail(`Postsurfing transport error: ${err instanceof Error ? err.message : String(err)}`);
|