openwriter 0.40.1 → 0.40.2
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/dist/plugins/authors-voice/dist/index.d.ts +48 -0
- package/dist/plugins/authors-voice/dist/index.js +235 -0
- package/dist/plugins/authors-voice/package.json +24 -0
- package/dist/plugins/authors-voice/skill/LICENSE +21 -0
- package/dist/plugins/authors-voice/skill/README.md +126 -0
- package/dist/plugins/authors-voice/skill/SKILL.md +151 -0
- package/dist/plugins/authors-voice/skill/catalog/ai-tells.md +144 -0
- package/dist/plugins/authors-voice/skill/catalog/anchor-prompt.md +189 -0
- package/dist/plugins/authors-voice/skill/catalog/author-hints.md +119 -0
- package/dist/plugins/authors-voice/skill/catalog/fingerprints.md +175 -0
- package/dist/plugins/authors-voice/skill/catalog/hurdle.md +76 -0
- package/dist/plugins/authors-voice/skill/catalog/post-write-audit.md +105 -0
- package/dist/plugins/authors-voice/skill/docs/analysis.md +31 -0
- package/dist/plugins/authors-voice/skill/docs/anchor-iteration.md +176 -0
- package/dist/plugins/authors-voice/skill/docs/api/import.md +78 -0
- package/dist/plugins/authors-voice/skill/docs/api/protocol.md +140 -0
- package/dist/plugins/authors-voice/skill/docs/api/setup.md +37 -0
- package/dist/plugins/authors-voice/skill/docs/api/tools.md +102 -0
- package/dist/plugins/authors-voice/skill/docs/api/troubleshooting.md +7 -0
- package/dist/plugins/authors-voice/skill/docs/apply-protocol-deep.md +191 -0
- package/dist/plugins/authors-voice/skill/docs/context-hygiene.md +33 -0
- package/dist/plugins/authors-voice/skill/docs/setup.md +74 -0
- package/dist/plugins/authors-voice/skill/docs/tiers.md +13 -0
- package/dist/plugins/authors-voice/skill/package.json +35 -0
- package/dist/plugins/authors-voice/skill/prompts/skeleton.md +29 -0
- package/dist/plugins/authors-voice/skill/voice/README.md +51 -0
- package/dist/plugins/authors-voice/skill/voice/corpus/.gitkeep +0 -0
- package/dist/plugins/github/dist/blog-tools.d.ts +84 -0
- package/dist/plugins/github/dist/blog-tools.js +1208 -0
- package/dist/plugins/github/dist/git-sync.d.ts +46 -0
- package/dist/plugins/github/dist/git-sync.js +335 -0
- package/dist/plugins/github/dist/helpers.d.ts +127 -0
- package/dist/plugins/github/dist/helpers.js +67 -0
- package/dist/plugins/github/dist/index.d.ts +12 -0
- package/dist/plugins/github/dist/index.js +112 -0
- package/dist/plugins/github/package.json +24 -0
- package/dist/plugins/image-gen/dist/index.d.ts +35 -0
- package/dist/plugins/image-gen/dist/index.js +149 -0
- package/dist/plugins/image-gen/package.json +26 -0
- package/dist/plugins/publish/dist/helpers.d.ts +66 -0
- package/dist/plugins/publish/dist/helpers.js +199 -0
- package/dist/plugins/publish/dist/index.d.ts +3 -0
- package/dist/plugins/publish/dist/index.js +1156 -0
- package/dist/plugins/publish/dist/newsletter-tools.d.ts +2 -0
- package/dist/plugins/publish/dist/newsletter-tools.js +394 -0
- package/dist/plugins/publish/package.json +31 -0
- package/dist/plugins/x-api/dist/index.d.ts +27 -0
- package/dist/plugins/x-api/dist/index.js +368 -0
- package/dist/plugins/x-api/dist/server-bridge.d.ts +22 -0
- package/dist/plugins/x-api/dist/server-bridge.js +43 -0
- package/dist/plugins/x-api/package.json +27 -0
- package/package.json +1 -1
- package/skill/docs/enrichment.md +180 -0
- package/skill/docs/footnotes.md +178 -0
- package/skill/docs/setup.md +62 -0
- package/skill/docs/welcome.md +21 -0
|
@@ -0,0 +1,1208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Blog publishing MCP tools — local git ops, no Worker, no PAT.
|
|
3
|
+
*
|
|
4
|
+
* Auth: piggybacks on the user's existing `gh auth login`.
|
|
5
|
+
* Persistence: blogSites in plugins['@openwriter/plugin-github'].blogSites.
|
|
6
|
+
*/
|
|
7
|
+
import { execFile } from 'child_process';
|
|
8
|
+
import { existsSync, mkdirSync, readFileSync, copyFileSync, writeFileSync, readdirSync, statSync, rmSync } from 'fs';
|
|
9
|
+
import { join, extname, dirname, basename } from 'path';
|
|
10
|
+
import { randomUUID } from 'crypto';
|
|
11
|
+
import { homedir } from 'os';
|
|
12
|
+
import { getServerModules, listBlogSites, writeBlogSites, } from './helpers.js';
|
|
13
|
+
const NETWORK_TIMEOUT = 60000;
|
|
14
|
+
// SECURITY (MCP-1): no shell. `git`/`gh` are spawned directly via execFile
|
|
15
|
+
// with an argv array, so every element — including attacker-influenced blog
|
|
16
|
+
// config values (owner/repo/branch) and slugs — is passed to the program as a
|
|
17
|
+
// single literal argument and is NEVER interpreted by a shell. Do NOT add
|
|
18
|
+
// `shell: true` or hand-roll arg quoting here: that reintroduces OS command
|
|
19
|
+
// injection. If a future call genuinely needs shell features, whitelist-
|
|
20
|
+
// validate the inputs instead.
|
|
21
|
+
function exec(cmd, args, cwd, timeout = NETWORK_TIMEOUT) {
|
|
22
|
+
return new Promise((resolve, reject) => {
|
|
23
|
+
execFile(cmd, args, { cwd, timeout, maxBuffer: 16 * 1024 * 1024 }, (err, stdout, stderr) => {
|
|
24
|
+
if (err)
|
|
25
|
+
reject(new Error(stderr?.trim() || err.message));
|
|
26
|
+
else
|
|
27
|
+
resolve(stdout.trim());
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
async function ghAuthOk(cwd) {
|
|
32
|
+
try {
|
|
33
|
+
await exec('gh', ['auth', 'status'], cwd);
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Last-resort site-URL detection via the GitHub Pages API. `inferSiteUrl`
|
|
42
|
+
* only reads files committed to the repo (CNAME, wrangler route); this
|
|
43
|
+
* catches GitHub Pages sites whose served URL — custom domain or the
|
|
44
|
+
* `<owner>.github.io/<repo>` default — lives in Pages settings, not a file.
|
|
45
|
+
* Returns the canonical served base URL (trailing slash stripped), or
|
|
46
|
+
* undefined when Pages is not enabled / not accessible. Credential-free
|
|
47
|
+
* (rides the existing `gh auth`); other hosts (Cloudflare Pages, Vercel,
|
|
48
|
+
* Netlify) configure the domain in their dashboard and can't be derived
|
|
49
|
+
* here — those rely on the user supplying site_url.
|
|
50
|
+
*/
|
|
51
|
+
async function inferSiteUrlFromGitHubPages(owner, repo, cwd) {
|
|
52
|
+
try {
|
|
53
|
+
const out = await exec('gh', ['api', `repos/${owner}/${repo}/pages`, '--jq', '.html_url'], cwd);
|
|
54
|
+
const url = out.split('\n')[0].trim();
|
|
55
|
+
if (/^https?:\/\//i.test(url))
|
|
56
|
+
return url.replace(/\/+$/, '');
|
|
57
|
+
}
|
|
58
|
+
catch { /* Pages not enabled, or no access — fall through */ }
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
// Shared hint surfaced when site_url couldn't be determined, so the agent
|
|
62
|
+
// knows to ask the user rather than silently shipping posts with no live link.
|
|
63
|
+
const SITE_URL_HINT = 'Could not auto-detect the public site URL (no CNAME, wrangler route, or GitHub Pages config — ' +
|
|
64
|
+
'common for Cloudflare Pages / Vercel / Netlify sites whose domain lives in the host dashboard). ' +
|
|
65
|
+
'Ask the user for the public base URL (e.g. https://example.com) and set it via site_url. ' +
|
|
66
|
+
'Without it, published posts get no clickable "View Post" link (only the commit/file).';
|
|
67
|
+
function slugify(s) {
|
|
68
|
+
return s.toLowerCase().replace(/['"]/g, '').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 80);
|
|
69
|
+
}
|
|
70
|
+
function owRoot() {
|
|
71
|
+
return join(homedir(), '.openwriter');
|
|
72
|
+
}
|
|
73
|
+
// ---- inspect_blog_repo helpers ----
|
|
74
|
+
function walkDir(root, max = 5000) {
|
|
75
|
+
const out = [];
|
|
76
|
+
const queue = [root];
|
|
77
|
+
while (queue.length && out.length < max) {
|
|
78
|
+
const dir = queue.shift();
|
|
79
|
+
let entries;
|
|
80
|
+
try {
|
|
81
|
+
entries = readdirSync(dir);
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
for (const name of entries) {
|
|
87
|
+
if (name === '.git' || name === 'node_modules')
|
|
88
|
+
continue;
|
|
89
|
+
const full = join(dir, name);
|
|
90
|
+
let st;
|
|
91
|
+
try {
|
|
92
|
+
st = statSync(full);
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (st.isDirectory())
|
|
98
|
+
queue.push(full);
|
|
99
|
+
else
|
|
100
|
+
out.push(full);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return out;
|
|
104
|
+
}
|
|
105
|
+
function dirOfMostMarkdown(files, cloneRoot) {
|
|
106
|
+
const counts = new Map();
|
|
107
|
+
for (const f of files) {
|
|
108
|
+
if (extname(f).toLowerCase() !== '.md' && extname(f).toLowerCase() !== '.mdx')
|
|
109
|
+
continue;
|
|
110
|
+
const rel = f.slice(cloneRoot.length + 1).replace(/\\/g, '/');
|
|
111
|
+
const dir = rel.includes('/') ? rel.slice(0, rel.lastIndexOf('/')) : '';
|
|
112
|
+
// Skip README.md at root, etc.
|
|
113
|
+
if (!dir)
|
|
114
|
+
continue;
|
|
115
|
+
counts.set(dir, (counts.get(dir) || 0) + 1);
|
|
116
|
+
}
|
|
117
|
+
let best = null;
|
|
118
|
+
for (const [dir, count] of counts) {
|
|
119
|
+
if (!best || count > best.count)
|
|
120
|
+
best = { dir, count };
|
|
121
|
+
}
|
|
122
|
+
return best;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Parse one frontmatter block into key→raw-string-value pairs.
|
|
126
|
+
* Top-level only — array-on-next-line and nested objects are returned as
|
|
127
|
+
* "<multiline>" sentinel so they're treated as varying.
|
|
128
|
+
*/
|
|
129
|
+
function parseYamlFrontmatter(raw) {
|
|
130
|
+
const m = raw.match(/^---\s*\n([\s\S]*?)\n---/);
|
|
131
|
+
if (!m)
|
|
132
|
+
return {};
|
|
133
|
+
const out = {};
|
|
134
|
+
const lines = m[1].split('\n');
|
|
135
|
+
for (let i = 0; i < lines.length; i++) {
|
|
136
|
+
const line = lines[i];
|
|
137
|
+
const kv = line.match(/^([A-Za-z_][\w-]*)\s*:\s*(.*)$/);
|
|
138
|
+
if (!kv)
|
|
139
|
+
continue;
|
|
140
|
+
const [, key, rest] = kv;
|
|
141
|
+
const trimmed = rest.trim();
|
|
142
|
+
if (trimmed === '' || trimmed === '|' || trimmed === '>') {
|
|
143
|
+
// Multi-line scalar / list-on-next-line — mark as variable
|
|
144
|
+
out[key] = '<multiline>';
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
out[key] = trimmed;
|
|
148
|
+
}
|
|
149
|
+
return out;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Detect frontmatter that was clearly written by an older version of the
|
|
153
|
+
* openwriter github plugin (and therefore leaks openwriter-internal fields).
|
|
154
|
+
* Those files would otherwise pollute the constant-detection across samples,
|
|
155
|
+
* so the inspector excludes them.
|
|
156
|
+
*
|
|
157
|
+
* Fingerprint markers (any one is sufficient):
|
|
158
|
+
* - `enrichmentStale` field present (openwriter-only)
|
|
159
|
+
* - `status: draft` + `slug` + `date:` with ISO-with-time (old plugin's emit)
|
|
160
|
+
* - top-level `tags` set to a single openwriter content-type token
|
|
161
|
+
* (e.g. `tags: [blog]`)
|
|
162
|
+
*/
|
|
163
|
+
function looksLikeOpenwriterLeak(fm) {
|
|
164
|
+
if ('enrichmentStale' in fm)
|
|
165
|
+
return true;
|
|
166
|
+
if (fm.status === 'draft' && 'slug' in fm && fm.date && /T\d{2}:\d{2}/.test(fm.date)) {
|
|
167
|
+
return true;
|
|
168
|
+
}
|
|
169
|
+
const t = fm.tags;
|
|
170
|
+
if (t && /^\[\s*["']?(blog|tweet|article|linkedin|newsletter)["']?\s*\]$/i.test(t.trim())) {
|
|
171
|
+
return true;
|
|
172
|
+
}
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Inspect multiple sample post frontmatters and propose:
|
|
177
|
+
* - `frontmatter_defaults`: fields with the SAME value across all posts
|
|
178
|
+
* - `frontmatter_field_map`: rename map from openwriter standard names
|
|
179
|
+
* to whatever the site actually uses (e.g. `date` → `publishedDate`)
|
|
180
|
+
* - `frontmatter_schema`: union of all keys seen
|
|
181
|
+
*
|
|
182
|
+
* Files that look like openwriter-leak frontmatter (the OLD plugin's
|
|
183
|
+
* output) are excluded from analysis so they don't poison the constants.
|
|
184
|
+
*
|
|
185
|
+
* The detection is conservative — only fields present in ≥2 samples with
|
|
186
|
+
* an identical value become defaults. Single-sample fields are skipped
|
|
187
|
+
* to avoid baking per-post variations in.
|
|
188
|
+
*/
|
|
189
|
+
function inferFrontmatterShape(rawSamples) {
|
|
190
|
+
// Filter out openwriter-leak files so they don't pollute defaults detection
|
|
191
|
+
const samples = rawSamples.filter((s) => !looksLikeOpenwriterLeak(s));
|
|
192
|
+
if (samples.length === 0)
|
|
193
|
+
return { defaults: {}, field_map: {}, schema: [] };
|
|
194
|
+
// Schema = union of all keys (preserve insertion order from first sample)
|
|
195
|
+
const schemaOrder = [];
|
|
196
|
+
const seen = new Set();
|
|
197
|
+
for (const s of samples) {
|
|
198
|
+
for (const k of Object.keys(s)) {
|
|
199
|
+
if (!seen.has(k)) {
|
|
200
|
+
seen.add(k);
|
|
201
|
+
schemaOrder.push(k);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
// Defaults: key present in ALL samples with identical, non-empty,
|
|
206
|
+
// non-multiline value
|
|
207
|
+
const defaults = {};
|
|
208
|
+
for (const k of schemaOrder) {
|
|
209
|
+
const values = samples.map((s) => s[k]);
|
|
210
|
+
if (values.some((v) => v === undefined))
|
|
211
|
+
continue;
|
|
212
|
+
if (values.some((v) => v === '<multiline>'))
|
|
213
|
+
continue;
|
|
214
|
+
const first = values[0];
|
|
215
|
+
if (!first)
|
|
216
|
+
continue;
|
|
217
|
+
if (!values.every((v) => v === first))
|
|
218
|
+
continue;
|
|
219
|
+
// Skip per-post fields that are NEVER constants in practice
|
|
220
|
+
if (['title', 'description', 'slug', 'date', 'publishedDate', 'pubDate', 'coverImage', 'coverImageAlt', 'tags', 'category', 'categories'].includes(k))
|
|
221
|
+
continue;
|
|
222
|
+
// Unquote double-quoted strings
|
|
223
|
+
const unq = first.match(/^"(.*)"$/);
|
|
224
|
+
let parsed = unq ? unq[1] : first;
|
|
225
|
+
if (parsed === 'true')
|
|
226
|
+
parsed = true;
|
|
227
|
+
else if (parsed === 'false')
|
|
228
|
+
parsed = false;
|
|
229
|
+
else if (/^-?\d+$/.test(parsed))
|
|
230
|
+
parsed = Number(parsed);
|
|
231
|
+
defaults[k] = parsed;
|
|
232
|
+
}
|
|
233
|
+
// Field map: detect which date field the site uses
|
|
234
|
+
const field_map = {};
|
|
235
|
+
if (seen.has('publishedDate') && !seen.has('date')) {
|
|
236
|
+
field_map.date = 'publishedDate';
|
|
237
|
+
}
|
|
238
|
+
else if (seen.has('pubDate') && !seen.has('date')) {
|
|
239
|
+
field_map.date = 'pubDate';
|
|
240
|
+
}
|
|
241
|
+
return { defaults, field_map, schema: schemaOrder };
|
|
242
|
+
}
|
|
243
|
+
// ---- Image-contract inference (inspect_blog_repo) ----
|
|
244
|
+
// adr: adr/blog-image-contract.md
|
|
245
|
+
const IMAGE_FIELD_CANDIDATES = [
|
|
246
|
+
'image', 'coverImage', 'cover', 'ogImage', 'heroImage', 'featuredImage', 'thumbnail', 'banner',
|
|
247
|
+
];
|
|
248
|
+
const IMAGE_VALUE_RE = /\.(png|jpe?g|webp|gif|avif|svg)\b/i;
|
|
249
|
+
function unquoteScalar(s) {
|
|
250
|
+
const m = s.match(/^["'](.*)["']$/);
|
|
251
|
+
return m ? m[1] : s;
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Pick the frontmatter key that holds the post's cover image. Prefers the
|
|
255
|
+
* conventional names in order; the value must look like a local image path
|
|
256
|
+
* (carries an image extension). Falls back to any field whose value does.
|
|
257
|
+
*/
|
|
258
|
+
function pickImageField(fm) {
|
|
259
|
+
for (const k of IMAGE_FIELD_CANDIDATES) {
|
|
260
|
+
const raw = fm[k];
|
|
261
|
+
if (!raw || raw === '<multiline>')
|
|
262
|
+
continue;
|
|
263
|
+
const v = unquoteScalar(raw);
|
|
264
|
+
if (IMAGE_VALUE_RE.test(v))
|
|
265
|
+
return { key: k, value: v };
|
|
266
|
+
}
|
|
267
|
+
for (const [k, raw] of Object.entries(fm)) {
|
|
268
|
+
if (raw === '<multiline>')
|
|
269
|
+
continue;
|
|
270
|
+
const v = unquoteScalar(raw);
|
|
271
|
+
if (IMAGE_VALUE_RE.test(v))
|
|
272
|
+
return { key: k, value: v };
|
|
273
|
+
}
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Derive the per-site image contract from sampled posts:
|
|
278
|
+
* - `image_field` — which frontmatter key holds the cover
|
|
279
|
+
* - `image_path_style` — do values carry a leading slash?
|
|
280
|
+
* - `image_public_prefix`— the dominant directory the images live under
|
|
281
|
+
* (relative, no leading slash)
|
|
282
|
+
* - `image_naming` — `og-{slug}` vs `{slug}` filename convention
|
|
283
|
+
* (detected by the dominant basename shape)
|
|
284
|
+
* Conservative: only the dominant local-path field is analyzed; full URLs
|
|
285
|
+
* are ignored. Anything not confidently detected is left undefined so
|
|
286
|
+
* post_to_blog falls back to its documented defaults.
|
|
287
|
+
*/
|
|
288
|
+
export function inferImageConventions(samples) {
|
|
289
|
+
const hits = [];
|
|
290
|
+
for (const s of samples) {
|
|
291
|
+
const pick = pickImageField(s.fm);
|
|
292
|
+
if (pick)
|
|
293
|
+
hits.push({ ...pick, slug: s.slug });
|
|
294
|
+
}
|
|
295
|
+
if (hits.length === 0)
|
|
296
|
+
return {};
|
|
297
|
+
// Dominant cover field name
|
|
298
|
+
const fieldCounts = new Map();
|
|
299
|
+
for (const h of hits)
|
|
300
|
+
fieldCounts.set(h.key, (fieldCounts.get(h.key) || 0) + 1);
|
|
301
|
+
const image_field = [...fieldCounts.entries()].sort((a, b) => b[1] - a[1])[0][0];
|
|
302
|
+
// Only local paths of the dominant field tell us about the path contract
|
|
303
|
+
const local = hits.filter((h) => h.key === image_field && !/^https?:\/\//i.test(h.value) && !h.value.startsWith('//'));
|
|
304
|
+
if (local.length === 0)
|
|
305
|
+
return { image_field };
|
|
306
|
+
// Path style: majority leading-slash ⇒ absolute
|
|
307
|
+
const abs = local.filter((h) => h.value.startsWith('/')).length;
|
|
308
|
+
const image_path_style = abs > local.length / 2 ? 'absolute' : 'relative';
|
|
309
|
+
// Public prefix: dominant directory portion (no leading/trailing slash)
|
|
310
|
+
const dirCounts = new Map();
|
|
311
|
+
for (const h of local) {
|
|
312
|
+
const noSlash = h.value.replace(/^\/+/, '');
|
|
313
|
+
const dir = noSlash.includes('/') ? noSlash.slice(0, noSlash.lastIndexOf('/')) : '';
|
|
314
|
+
dirCounts.set(dir, (dirCounts.get(dir) || 0) + 1);
|
|
315
|
+
}
|
|
316
|
+
const topDir = [...dirCounts.entries()].sort((a, b) => b[1] - a[1])[0][0];
|
|
317
|
+
const image_public_prefix = topDir || undefined;
|
|
318
|
+
// Naming convention by dominant basename shape + extension
|
|
319
|
+
let ogCount = 0;
|
|
320
|
+
let slugCount = 0;
|
|
321
|
+
const extCounts = new Map();
|
|
322
|
+
for (const h of local) {
|
|
323
|
+
const base = h.value.replace(/^.*\//, '');
|
|
324
|
+
const ext = (base.match(/\.([a-z0-9]+)$/i)?.[1] || 'png').toLowerCase();
|
|
325
|
+
extCounts.set(ext, (extCounts.get(ext) || 0) + 1);
|
|
326
|
+
const stem = base.replace(/\.[a-z0-9]+$/i, '');
|
|
327
|
+
if (stem === h.slug)
|
|
328
|
+
slugCount++;
|
|
329
|
+
else if (stem.startsWith('og-'))
|
|
330
|
+
ogCount++;
|
|
331
|
+
}
|
|
332
|
+
const dominantExt = [...extCounts.entries()].sort((a, b) => b[1] - a[1])[0][0];
|
|
333
|
+
let image_naming;
|
|
334
|
+
if (slugCount > local.length / 2)
|
|
335
|
+
image_naming = `{slug}.${dominantExt}`;
|
|
336
|
+
else if (ogCount > local.length / 2)
|
|
337
|
+
image_naming = `og-{slug}.${dominantExt}`;
|
|
338
|
+
// else: undefined ⇒ post_to_blog default (og-{slug}.{ext})
|
|
339
|
+
return { image_field, image_path_style, image_public_prefix, image_naming };
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* Map an inferred (relative) public image prefix to the on-disk image_dir
|
|
343
|
+
* for the framework's static-asset root.
|
|
344
|
+
*/
|
|
345
|
+
function imageDirForFramework(fw, prefix) {
|
|
346
|
+
switch (fw) {
|
|
347
|
+
case 'astro':
|
|
348
|
+
case 'next':
|
|
349
|
+
return `public/${prefix}`;
|
|
350
|
+
case 'hugo':
|
|
351
|
+
return `static/${prefix}`;
|
|
352
|
+
case 'jekyll':
|
|
353
|
+
return prefix; // served from repo root
|
|
354
|
+
default:
|
|
355
|
+
return `public/${prefix}`;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
359
|
+
* Detect the site's public URL from common static-host conventions:
|
|
360
|
+
* - `CNAME` at repo root or `public/CNAME` (GitHub Pages / Cloudflare Pages / Netlify)
|
|
361
|
+
* - `wrangler.toml` `routes` (Cloudflare Workers)
|
|
362
|
+
* - `netlify.toml` `[[redirects]]` to= field with a full URL
|
|
363
|
+
* - GitHub Pages default `<owner>.github.io/<repo>/` is NOT proposed — too often wrong
|
|
364
|
+
* when a custom domain is in play; user can fill in if they want it.
|
|
365
|
+
*/
|
|
366
|
+
function inferSiteUrl(cloneRoot) {
|
|
367
|
+
const tryRead = (rel) => {
|
|
368
|
+
const p = join(cloneRoot, rel);
|
|
369
|
+
if (!existsSync(p))
|
|
370
|
+
return undefined;
|
|
371
|
+
try {
|
|
372
|
+
return readFileSync(p, 'utf-8').trim();
|
|
373
|
+
}
|
|
374
|
+
catch {
|
|
375
|
+
return undefined;
|
|
376
|
+
}
|
|
377
|
+
};
|
|
378
|
+
// CNAME files (single line with domain, no scheme)
|
|
379
|
+
for (const rel of ['CNAME', 'public/CNAME', 'static/CNAME', 'src/CNAME']) {
|
|
380
|
+
const v = tryRead(rel);
|
|
381
|
+
if (v) {
|
|
382
|
+
const host = v.split('\n')[0].trim().replace(/^https?:\/\//, '').replace(/\/+$/, '');
|
|
383
|
+
if (/^[a-z0-9.-]+\.[a-z]{2,}$/i.test(host))
|
|
384
|
+
return `https://${host}`;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
// wrangler.toml — look for `routes = ["https://..."]` or `route = "..."`
|
|
388
|
+
const wrangler = tryRead('wrangler.toml');
|
|
389
|
+
if (wrangler) {
|
|
390
|
+
const m = wrangler.match(/route[s]?\s*=\s*\[?\s*["']https?:\/\/([^"'/*]+)/);
|
|
391
|
+
if (m)
|
|
392
|
+
return `https://${m[1].replace(/\/$/, '')}`;
|
|
393
|
+
}
|
|
394
|
+
return undefined;
|
|
395
|
+
}
|
|
396
|
+
function inferFramework(cloneRoot, files) {
|
|
397
|
+
const has = (rel) => existsSync(join(cloneRoot, rel));
|
|
398
|
+
if (has('astro.config.mjs') || has('astro.config.ts') || has('astro.config.js') ||
|
|
399
|
+
files.some(f => /astro\.config\.(mjs|ts|js)$/.test(f)))
|
|
400
|
+
return 'astro';
|
|
401
|
+
if (has('next.config.js') || has('next.config.mjs') || has('next.config.ts'))
|
|
402
|
+
return 'next';
|
|
403
|
+
if (has('_config.yml'))
|
|
404
|
+
return 'jekyll';
|
|
405
|
+
if (has('hugo.toml') || has('config.toml') || has('hugo.yaml') || has('config.yaml'))
|
|
406
|
+
return 'hugo';
|
|
407
|
+
return 'unknown';
|
|
408
|
+
}
|
|
409
|
+
function defaultDirsForFramework(fw, detectedContentDir) {
|
|
410
|
+
switch (fw) {
|
|
411
|
+
case 'astro':
|
|
412
|
+
return {
|
|
413
|
+
content_dir: detectedContentDir || 'src/content/blog',
|
|
414
|
+
image_dir: 'public/blog-images',
|
|
415
|
+
image_public_prefix: '/blog-images',
|
|
416
|
+
};
|
|
417
|
+
case 'next':
|
|
418
|
+
return {
|
|
419
|
+
content_dir: detectedContentDir || 'posts',
|
|
420
|
+
image_dir: 'public/blog-images',
|
|
421
|
+
image_public_prefix: '/blog-images',
|
|
422
|
+
};
|
|
423
|
+
case 'jekyll':
|
|
424
|
+
return {
|
|
425
|
+
content_dir: '_posts',
|
|
426
|
+
image_dir: 'assets/images',
|
|
427
|
+
image_public_prefix: '/assets/images',
|
|
428
|
+
};
|
|
429
|
+
case 'hugo':
|
|
430
|
+
return {
|
|
431
|
+
content_dir: detectedContentDir || 'content/posts',
|
|
432
|
+
image_dir: 'static/images',
|
|
433
|
+
image_public_prefix: '/images',
|
|
434
|
+
};
|
|
435
|
+
default:
|
|
436
|
+
return {
|
|
437
|
+
content_dir: detectedContentDir || 'posts',
|
|
438
|
+
image_dir: 'public/images',
|
|
439
|
+
image_public_prefix: '/images',
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
// ---- post_to_blog helpers ----
|
|
444
|
+
/**
|
|
445
|
+
* Strict double-quoted YAML emission for scalars — matches the style of
|
|
446
|
+
* a typical Astro blog's existing posts. Arrays are inline-square-bracket JSON for
|
|
447
|
+
* compactness. Booleans + numbers emit bare.
|
|
448
|
+
*/
|
|
449
|
+
function yamlValue(v) {
|
|
450
|
+
if (v == null)
|
|
451
|
+
return '""';
|
|
452
|
+
if (typeof v === 'boolean' || typeof v === 'number')
|
|
453
|
+
return String(v);
|
|
454
|
+
if (Array.isArray(v))
|
|
455
|
+
return '[' + v.map((x) => yamlValue(x)).join(', ') + ']';
|
|
456
|
+
if (typeof v === 'object')
|
|
457
|
+
return JSON.stringify(v);
|
|
458
|
+
return JSON.stringify(String(v));
|
|
459
|
+
}
|
|
460
|
+
/**
|
|
461
|
+
* Format a date for frontmatter. If the value already matches YYYY-MM-DD,
|
|
462
|
+
* pass through; if it's an ISO string with time, slice to date only;
|
|
463
|
+
* otherwise return as-is.
|
|
464
|
+
*/
|
|
465
|
+
function formatDate(v) {
|
|
466
|
+
if (typeof v !== 'string')
|
|
467
|
+
return String(v ?? '');
|
|
468
|
+
const m = v.match(/^(\d{4}-\d{2}-\d{2})/);
|
|
469
|
+
return m ? m[1] : v;
|
|
470
|
+
}
|
|
471
|
+
// ---- Per-site image contract (path style + cover naming) ----
|
|
472
|
+
// adr: adr/blog-image-contract.md
|
|
473
|
+
//
|
|
474
|
+
// The image reference is a PER-SITE CONTRACT, not a global assumption. Two
|
|
475
|
+
// dimensions, both stored on BlogSite and inferred by inspect_blog_repo:
|
|
476
|
+
// 1. path style — does the value carry a leading slash, or does the site's
|
|
477
|
+
// template prepend one? (`image_path_style`)
|
|
478
|
+
// 2. cover filename — deterministic `og-{slug}.{ext}`, never the raw
|
|
479
|
+
// `/_images/` name, so republish is idempotent and never orphans.
|
|
480
|
+
// (`image_naming`)
|
|
481
|
+
// Absent keys ⇒ legacy behavior ("absolute", raw name) so already-correct
|
|
482
|
+
// sites never regress.
|
|
483
|
+
/** Site's effective path style. Absent ⇒ legacy "absolute". */
|
|
484
|
+
export function pathStyleOf(site) {
|
|
485
|
+
return site.image_path_style === 'relative' ? 'relative' : 'absolute';
|
|
486
|
+
}
|
|
487
|
+
/**
|
|
488
|
+
* Public reference for one image file under the site's prefix, honoring the
|
|
489
|
+
* site's path style. The prefix is normalized (leading + trailing slashes
|
|
490
|
+
* stripped) so storage is style-agnostic — `style` alone decides the leading
|
|
491
|
+
* slash, which makes the contract unambiguous regardless of how the prefix
|
|
492
|
+
* was saved (`/images/og` vs `images/og` both behave identically).
|
|
493
|
+
* relative → "images/og/x.png" absolute → "/images/og/x.png"
|
|
494
|
+
*/
|
|
495
|
+
export function imageRef(publicPrefix, file, style) {
|
|
496
|
+
const seg = (publicPrefix || '').replace(/^\/+/, '').replace(/\/+$/, '');
|
|
497
|
+
const rel = seg ? `${seg}/${file}` : file;
|
|
498
|
+
return style === 'absolute' ? `/${rel}` : rel;
|
|
499
|
+
}
|
|
500
|
+
/**
|
|
501
|
+
* Rewrite inline `/_images/` references in the post BODY to their published
|
|
502
|
+
* public paths, collecting the referenced filenames for copying.
|
|
503
|
+
*
|
|
504
|
+
* Body references ALWAYS emit an absolute (leading-slash) path, regardless of
|
|
505
|
+
* the site's `image_path_style`. That style contract exists for the
|
|
506
|
+
* FRONTMATTER cover only, whose value is rendered through the site's template
|
|
507
|
+
* — a relative-style site's layout prepends the slash itself (e.g. Astro
|
|
508
|
+
* `<img src={`/${image}`}>`). A raw markdown body image has no template: a
|
|
509
|
+
* slashless path is treated by Astro/Vite as an ESM import (`Rollup failed to
|
|
510
|
+
* resolve import "images/og/x.png"`) and red-builds the site, while a
|
|
511
|
+
* root-absolute path resolves against the public dir on every static
|
|
512
|
+
* framework. Do NOT collapse body refs back into the cover's path style.
|
|
513
|
+
* adr: adr/blog-image-contract.md
|
|
514
|
+
*/
|
|
515
|
+
export function rewriteBodyImages(bodyMd, publicPrefix) {
|
|
516
|
+
const imageRefs = new Set();
|
|
517
|
+
const body = bodyMd.replace(/\/_images\/([^\s)"'<>]+)/g, (_m, fn) => {
|
|
518
|
+
imageRefs.add(fn);
|
|
519
|
+
return imageRef(publicPrefix, fn, 'absolute');
|
|
520
|
+
});
|
|
521
|
+
return { body, imageRefs };
|
|
522
|
+
}
|
|
523
|
+
/**
|
|
524
|
+
* Resolve the deterministic cover filename from the site's naming template.
|
|
525
|
+
* `{slug}` → post slug
|
|
526
|
+
* `{ext}` → source extension, no dot (preserved from the original)
|
|
527
|
+
* A template carrying a literal extension and no `{ext}` placeholder
|
|
528
|
+
* (e.g. `og-{slug}.png`) is respected as authored. Absent template ⇒
|
|
529
|
+
* `og-{slug}.{ext}`.
|
|
530
|
+
*/
|
|
531
|
+
export function coverFilename(template, slug, sourceExt) {
|
|
532
|
+
const ext = sourceExt.replace(/^\.+/, '');
|
|
533
|
+
return (template || 'og-{slug}.{ext}')
|
|
534
|
+
.replace(/\{slug\}/g, slug)
|
|
535
|
+
.replace(/\{ext\}/g, ext);
|
|
536
|
+
}
|
|
537
|
+
/**
|
|
538
|
+
* Build the YAML frontmatter from blogContext + site defaults.
|
|
539
|
+
*
|
|
540
|
+
* Order of precedence (low → high):
|
|
541
|
+
* 1. Site `frontmatter_defaults` (e.g. `layout`, `author`, `prerender`)
|
|
542
|
+
* 2. Generated `title` (from document title — always present)
|
|
543
|
+
* 3. blogContext fields (description, date, author, tags, slug, draft, coverImage)
|
|
544
|
+
*
|
|
545
|
+
* Field-name mapping: blogContext keys are renamed via `site.frontmatter_field_map`
|
|
546
|
+
* before emit (e.g. `date` → `publishedDate` for Astro sites).
|
|
547
|
+
*
|
|
548
|
+
* Top-level openwriter metadata (status, enrichmentStale, tags-as-content-type,
|
|
549
|
+
* etc.) is NEVER passed through. Frontmatter is built ONLY from blogContext +
|
|
550
|
+
* defaults — this is the design contract from server/blog-routes.ts.
|
|
551
|
+
*/
|
|
552
|
+
export function buildFrontmatter(title, blogCtx, site, coverImagePath) {
|
|
553
|
+
const fm = {};
|
|
554
|
+
const map = site.frontmatter_field_map || {};
|
|
555
|
+
// 1. Site defaults (lowest priority — overridable below)
|
|
556
|
+
if (site.frontmatter_defaults) {
|
|
557
|
+
for (const [k, v] of Object.entries(site.frontmatter_defaults)) {
|
|
558
|
+
fm[k] = v;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
// 2. Title (always)
|
|
562
|
+
fm.title = title;
|
|
563
|
+
// 3. blogContext fields — apply field_map rename, skip empty values
|
|
564
|
+
const passthrough = [
|
|
565
|
+
{ src: 'description' },
|
|
566
|
+
{ src: 'date', format: formatDate },
|
|
567
|
+
{ src: 'author' },
|
|
568
|
+
{ src: 'tags' },
|
|
569
|
+
{ src: 'category' },
|
|
570
|
+
{ src: 'slug' },
|
|
571
|
+
{ src: 'draft' },
|
|
572
|
+
{ src: 'subtitle' },
|
|
573
|
+
{ src: 'excerpt' },
|
|
574
|
+
{ src: 'coverImageAlt' },
|
|
575
|
+
];
|
|
576
|
+
for (const { src, format } of passthrough) {
|
|
577
|
+
const v = blogCtx[src];
|
|
578
|
+
if (v == null || v === '' || (Array.isArray(v) && v.length === 0))
|
|
579
|
+
continue;
|
|
580
|
+
const dest = map[src] || src;
|
|
581
|
+
fm[dest] = format ? format(v) : v;
|
|
582
|
+
}
|
|
583
|
+
// 4. Cover image: rewritten path takes priority over blogContext.coverImage
|
|
584
|
+
if (coverImagePath) {
|
|
585
|
+
const dest = map.coverImage || 'coverImage';
|
|
586
|
+
fm[dest] = coverImagePath;
|
|
587
|
+
}
|
|
588
|
+
// Ensure date field exists if site expects one — derive from today
|
|
589
|
+
const dateDest = map.date || 'date';
|
|
590
|
+
const publishedDateDest = map.publishedDate || (map.date === 'publishedDate' ? 'publishedDate' : null);
|
|
591
|
+
if (!fm[dateDest] && !(publishedDateDest && fm[publishedDateDest])) {
|
|
592
|
+
fm[dateDest] = new Date().toISOString().slice(0, 10);
|
|
593
|
+
}
|
|
594
|
+
// Date fields emit as UNQUOTED yaml scalars (pubDate: 2026-05-31), never
|
|
595
|
+
// quoted strings. Astro's z.date() rejects a quoted value — js-yaml parses it
|
|
596
|
+
// as a String, not a Date — which froze a live Netlify build (a production Astro blog,
|
|
597
|
+
// 2026-06-01). The unquoted form is ALSO accepted by z.coerce.date() and by
|
|
598
|
+
// Jekyll/Hugo/Next (gray-matter), so it is the universally-correct emit.
|
|
599
|
+
// adr: adr/blog-image-contract.md
|
|
600
|
+
const dateKeys = new Set([dateDest]);
|
|
601
|
+
if (publishedDateDest)
|
|
602
|
+
dateKeys.add(publishedDateDest);
|
|
603
|
+
const emitLine = (k, v) => dateKeys.has(k) && typeof v === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(v)
|
|
604
|
+
? `${k}: ${v}`
|
|
605
|
+
: `${k}: ${yamlValue(v)}`;
|
|
606
|
+
// Emit in stable order: defaults first (in their declared order),
|
|
607
|
+
// then title, then any new keys we added
|
|
608
|
+
const lines = [];
|
|
609
|
+
const written = new Set();
|
|
610
|
+
if (site.frontmatter_defaults) {
|
|
611
|
+
for (const k of Object.keys(site.frontmatter_defaults)) {
|
|
612
|
+
if (k in fm) {
|
|
613
|
+
lines.push(emitLine(k, fm[k]));
|
|
614
|
+
written.add(k);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
if (!written.has('title')) {
|
|
619
|
+
lines.push(`title: ${yamlValue(fm.title)}`);
|
|
620
|
+
written.add('title');
|
|
621
|
+
}
|
|
622
|
+
for (const [k, v] of Object.entries(fm)) {
|
|
623
|
+
if (written.has(k))
|
|
624
|
+
continue;
|
|
625
|
+
lines.push(emitLine(k, v));
|
|
626
|
+
written.add(k);
|
|
627
|
+
}
|
|
628
|
+
return `---\n${lines.join('\n')}\n---\n\n`;
|
|
629
|
+
}
|
|
630
|
+
function stripFrontmatter(md) {
|
|
631
|
+
return md.replace(/^---\n[\s\S]*?\n---\n+/, '').replace(/^\s*<!--\s*-->\s*$/gm, '').trim();
|
|
632
|
+
}
|
|
633
|
+
// ---- Tools ----
|
|
634
|
+
export function blogTools() {
|
|
635
|
+
return [
|
|
636
|
+
{
|
|
637
|
+
name: 'inspect_blog_repo',
|
|
638
|
+
description: 'Clone a GitHub blog repo (shallow) to a local cache and infer its framework, content directory, image directory, and frontmatter schema. Read-only — produces a config preview you can feed to add_blog_site.',
|
|
639
|
+
inputSchema: {
|
|
640
|
+
type: 'object',
|
|
641
|
+
properties: {
|
|
642
|
+
repo_url: { type: 'string', description: 'GitHub repo URL or owner/repo shorthand (e.g. "https://github.com/user/blog" or "user/blog")' },
|
|
643
|
+
},
|
|
644
|
+
required: ['repo_url'],
|
|
645
|
+
},
|
|
646
|
+
handler: async (params) => {
|
|
647
|
+
const repoUrl = String(params.repo_url || '').trim();
|
|
648
|
+
if (!repoUrl)
|
|
649
|
+
return { error: 'repo_url is required' };
|
|
650
|
+
// Normalize to owner/repo
|
|
651
|
+
let ownerRepo = repoUrl
|
|
652
|
+
.replace(/^https?:\/\/github\.com\//, '')
|
|
653
|
+
.replace(/\.git$/, '')
|
|
654
|
+
.replace(/^github:/, '');
|
|
655
|
+
const parts = ownerRepo.split('/').filter(Boolean);
|
|
656
|
+
if (parts.length < 2)
|
|
657
|
+
return { error: 'Could not parse owner/repo from repo_url' };
|
|
658
|
+
const owner = parts[0];
|
|
659
|
+
const repo = parts[1];
|
|
660
|
+
const cacheRoot = join(owRoot(), '_blog-inspect-cache');
|
|
661
|
+
mkdirSync(cacheRoot, { recursive: true });
|
|
662
|
+
const cloneDir = join(cacheRoot, `${owner}-${repo}`);
|
|
663
|
+
if (!(await ghAuthOk(cacheRoot))) {
|
|
664
|
+
return { error: 'gh CLI not authenticated. Run `gh auth login` first.' };
|
|
665
|
+
}
|
|
666
|
+
// Refresh: remove existing then clone shallow
|
|
667
|
+
if (existsSync(cloneDir)) {
|
|
668
|
+
try {
|
|
669
|
+
rmSync(cloneDir, { recursive: true, force: true });
|
|
670
|
+
}
|
|
671
|
+
catch { /* ignore */ }
|
|
672
|
+
}
|
|
673
|
+
try {
|
|
674
|
+
await exec('gh', ['repo', 'clone', `${owner}/${repo}`, cloneDir, '--', '--depth', '1'], cacheRoot);
|
|
675
|
+
}
|
|
676
|
+
catch (err) {
|
|
677
|
+
return { error: `Clone failed: ${err.message}` };
|
|
678
|
+
}
|
|
679
|
+
const files = walkDir(cloneDir);
|
|
680
|
+
const framework = inferFramework(cloneDir, files);
|
|
681
|
+
const mdBest = dirOfMostMarkdown(files, cloneDir);
|
|
682
|
+
const detected = mdBest?.dir || '';
|
|
683
|
+
const defaults = defaultDirsForFramework(framework, detected);
|
|
684
|
+
// Collect multiple sample post frontmatters so we can detect constants.
|
|
685
|
+
// Up to 10 samples to avoid pathological loops on huge repos.
|
|
686
|
+
const sampleFiles = files
|
|
687
|
+
.filter((f) => {
|
|
688
|
+
const rel = f.slice(cloneDir.length + 1).replace(/\\/g, '/');
|
|
689
|
+
return (detected ? rel.startsWith(detected + '/') : true) && /\.(md|mdx)$/i.test(rel);
|
|
690
|
+
})
|
|
691
|
+
.slice(0, 10);
|
|
692
|
+
const sampleData = [];
|
|
693
|
+
for (const f of sampleFiles) {
|
|
694
|
+
try {
|
|
695
|
+
const fm = parseYamlFrontmatter(readFileSync(f, 'utf-8'));
|
|
696
|
+
const slug = basename(f).replace(/\.(md|mdx)$/i, '');
|
|
697
|
+
sampleData.push({ fm, slug });
|
|
698
|
+
}
|
|
699
|
+
catch { /* skip */ }
|
|
700
|
+
}
|
|
701
|
+
const rawSamples = sampleData.map((s) => s.fm);
|
|
702
|
+
const samplesAfterFilter = rawSamples.filter((s) => !looksLikeOpenwriterLeak(s));
|
|
703
|
+
const samplesSkipped = rawSamples.length - samplesAfterFilter.length;
|
|
704
|
+
const shape = inferFrontmatterShape(rawSamples);
|
|
705
|
+
// Per-site image contract — inferred from real posts (path style, the
|
|
706
|
+
// directory images live under, og-{slug} vs {slug} naming, and which
|
|
707
|
+
// frontmatter field holds the cover). Leak samples excluded so the
|
|
708
|
+
// old plugin's emit doesn't poison detection. adr: adr/blog-image-contract.md
|
|
709
|
+
const conv = inferImageConventions(sampleData.filter((s) => !looksLikeOpenwriterLeak(s.fm)));
|
|
710
|
+
const imagePublicPrefix = conv.image_public_prefix ?? defaults.image_public_prefix;
|
|
711
|
+
const imageDir = conv.image_public_prefix
|
|
712
|
+
? imageDirForFramework(framework, conv.image_public_prefix)
|
|
713
|
+
: defaults.image_dir;
|
|
714
|
+
const imagePathStyle = conv.image_path_style ?? 'absolute';
|
|
715
|
+
const imageNaming = conv.image_naming ?? 'og-{slug}.{ext}';
|
|
716
|
+
// Cover field-name mapping (e.g. site uses `image:` not `coverImage:`)
|
|
717
|
+
const fieldMap = { ...shape.field_map };
|
|
718
|
+
if (conv.image_field && conv.image_field !== 'coverImage') {
|
|
719
|
+
fieldMap.coverImage = conv.image_field;
|
|
720
|
+
}
|
|
721
|
+
const confidence = framework !== 'unknown' && detected ? 'high'
|
|
722
|
+
: detected ? 'medium'
|
|
723
|
+
: 'low';
|
|
724
|
+
// site_url: prefer files committed to the repo (CNAME / wrangler route),
|
|
725
|
+
// then fall back to the GitHub Pages API for GH-hosted sites whose served
|
|
726
|
+
// URL lives in Pages settings rather than a committed file.
|
|
727
|
+
const siteUrl = inferSiteUrl(cloneDir) || await inferSiteUrlFromGitHubPages(owner, repo, cacheRoot);
|
|
728
|
+
return {
|
|
729
|
+
owner,
|
|
730
|
+
repo,
|
|
731
|
+
framework,
|
|
732
|
+
content_dir: defaults.content_dir,
|
|
733
|
+
image_dir: imageDir,
|
|
734
|
+
image_public_prefix: imagePublicPrefix,
|
|
735
|
+
image_path_style: imagePathStyle,
|
|
736
|
+
image_naming: imageNaming,
|
|
737
|
+
frontmatter_schema: shape.schema,
|
|
738
|
+
frontmatter_defaults: shape.defaults,
|
|
739
|
+
frontmatter_field_map: fieldMap,
|
|
740
|
+
// Always propose a pattern even when site_url is unknown so the user can fill in the URL
|
|
741
|
+
site_url: siteUrl,
|
|
742
|
+
blog_url_pattern: '/blog/{slug}/',
|
|
743
|
+
// When site_url couldn't be derived, tell the agent to ask the user —
|
|
744
|
+
// otherwise it ships posts with no live "View Post" link, silently.
|
|
745
|
+
...(siteUrl ? {} : { needs_site_url: true, site_url_hint: SITE_URL_HINT }),
|
|
746
|
+
samples_analyzed: samplesAfterFilter.length,
|
|
747
|
+
samples_skipped_openwriter_leak: samplesSkipped,
|
|
748
|
+
markdown_files_found: mdBest?.count ?? 0,
|
|
749
|
+
confidence,
|
|
750
|
+
};
|
|
751
|
+
},
|
|
752
|
+
},
|
|
753
|
+
{
|
|
754
|
+
name: 'add_blog_site',
|
|
755
|
+
description: 'Register a GitHub blog repo as a publishing target. Use inspect_blog_repo first to discover sensible defaults — including the frontmatter_defaults and frontmatter_field_map it proposes, which match what the site\'s existing posts use.',
|
|
756
|
+
inputSchema: {
|
|
757
|
+
type: 'object',
|
|
758
|
+
properties: {
|
|
759
|
+
label: { type: 'string', description: 'User-facing name (e.g. "Personal blog")' },
|
|
760
|
+
owner: { type: 'string', description: 'GitHub owner/user/org' },
|
|
761
|
+
repo: { type: 'string', description: 'Repo name' },
|
|
762
|
+
branch: { type: 'string', description: 'Branch to push to (default: main)' },
|
|
763
|
+
content_dir: { type: 'string', description: 'Directory where post .md files live (e.g. "src/content/blog")' },
|
|
764
|
+
image_dir: { type: 'string', description: 'Directory where image files write (e.g. "public/blog-images")' },
|
|
765
|
+
image_public_prefix: { type: 'string', description: 'Directory prefix images live under (e.g. "images/og" or "/blog-images"). The leading slash is governed by image_path_style, not by how this is stored.' },
|
|
766
|
+
image_path_style: { type: 'string', enum: ['relative', 'absolute'], description: 'How image paths are written: "relative" = no leading slash (`images/og/x.png`; the template prepends one), "absolute" = leading slash (`/images/og/x.png`; used verbatim). inspect_blog_repo infers this from existing posts. Default: "absolute" (legacy).' },
|
|
767
|
+
image_naming: { type: 'string', description: 'Cover filename template with `{slug}` + `{ext}` placeholders. Default "og-{slug}.{ext}". Deterministic: same doc+slug ⇒ same filename every republish (no orphaned covers).' },
|
|
768
|
+
framework: { type: 'string', enum: ['astro', 'next', 'jekyll', 'hugo', 'unknown'], description: 'Site framework' },
|
|
769
|
+
frontmatter_defaults: {
|
|
770
|
+
type: 'object',
|
|
771
|
+
description: 'Constants applied to every post\'s frontmatter (e.g. `{ "layout": "../../layouts/BlogPost.astro", "author": "...", "prerender": true }`). Detected by inspect_blog_repo as fields with constant values across existing posts.',
|
|
772
|
+
},
|
|
773
|
+
frontmatter_field_map: {
|
|
774
|
+
type: 'object',
|
|
775
|
+
description: 'Rename map: openwriter blogContext key → site frontmatter key (e.g. `{ "date": "publishedDate" }` for Astro-style sites that use `publishedDate`).',
|
|
776
|
+
},
|
|
777
|
+
frontmatter_schema: {
|
|
778
|
+
type: 'array',
|
|
779
|
+
items: { type: 'string' },
|
|
780
|
+
description: 'List of frontmatter keys the site uses (from inspection). Stored for reference / future UI.',
|
|
781
|
+
},
|
|
782
|
+
site_url: {
|
|
783
|
+
type: 'string',
|
|
784
|
+
description: 'Public base URL of the site (e.g. "https://example.com"). Used to construct the live "View Post" URL surfaced after publish. inspect_blog_repo proposes this from CNAME / wrangler.toml / the GitHub Pages API when found; for Cloudflare Pages / Vercel / Netlify (domain configured in the host dashboard) it can\'t be auto-detected — ask the user and pass it here. If omitted, the response returns needs_site_url so you know to follow up; backfill later with edit_blog_site.',
|
|
785
|
+
},
|
|
786
|
+
blog_url_pattern: {
|
|
787
|
+
type: 'string',
|
|
788
|
+
description: 'URL path pattern for a blog post with `{slug}` placeholder (e.g. "/blog/{slug}/"). Default: "/blog/{slug}/". Combined with site_url to build the live URL stored on the doc after publish.',
|
|
789
|
+
},
|
|
790
|
+
},
|
|
791
|
+
required: ['label', 'owner', 'repo', 'content_dir', 'image_dir', 'image_public_prefix', 'framework'],
|
|
792
|
+
},
|
|
793
|
+
handler: async (params) => {
|
|
794
|
+
const site = {
|
|
795
|
+
id: randomUUID(),
|
|
796
|
+
label: String(params.label),
|
|
797
|
+
owner: String(params.owner),
|
|
798
|
+
repo: String(params.repo),
|
|
799
|
+
branch: String(params.branch || 'main'),
|
|
800
|
+
content_dir: String(params.content_dir),
|
|
801
|
+
image_dir: String(params.image_dir),
|
|
802
|
+
image_public_prefix: String(params.image_public_prefix),
|
|
803
|
+
framework: params.framework || 'unknown',
|
|
804
|
+
};
|
|
805
|
+
if (params.frontmatter_defaults && typeof params.frontmatter_defaults === 'object') {
|
|
806
|
+
site.frontmatter_defaults = params.frontmatter_defaults;
|
|
807
|
+
}
|
|
808
|
+
if (params.frontmatter_field_map && typeof params.frontmatter_field_map === 'object') {
|
|
809
|
+
site.frontmatter_field_map = params.frontmatter_field_map;
|
|
810
|
+
}
|
|
811
|
+
if (params.image_path_style === 'relative' || params.image_path_style === 'absolute') {
|
|
812
|
+
site.image_path_style = params.image_path_style;
|
|
813
|
+
}
|
|
814
|
+
if (typeof params.image_naming === 'string' && params.image_naming.trim()) {
|
|
815
|
+
site.image_naming = params.image_naming.trim();
|
|
816
|
+
}
|
|
817
|
+
if (Array.isArray(params.frontmatter_schema)) {
|
|
818
|
+
site.frontmatter_schema = params.frontmatter_schema.map(String);
|
|
819
|
+
}
|
|
820
|
+
if (typeof params.site_url === 'string' && params.site_url.trim()) {
|
|
821
|
+
site.site_url = params.site_url.trim().replace(/\/+$/, '');
|
|
822
|
+
}
|
|
823
|
+
if (typeof params.blog_url_pattern === 'string' && params.blog_url_pattern.trim()) {
|
|
824
|
+
site.blog_url_pattern = params.blog_url_pattern.trim();
|
|
825
|
+
}
|
|
826
|
+
const sites = await listBlogSites();
|
|
827
|
+
sites.push(site);
|
|
828
|
+
await writeBlogSites(sites);
|
|
829
|
+
return {
|
|
830
|
+
success: true,
|
|
831
|
+
site,
|
|
832
|
+
// No site_url ⇒ no live link on publish. Tell the agent to follow up.
|
|
833
|
+
...(site.site_url ? {} : { needs_site_url: true, site_url_hint: SITE_URL_HINT }),
|
|
834
|
+
};
|
|
835
|
+
},
|
|
836
|
+
},
|
|
837
|
+
{
|
|
838
|
+
name: 'list_blog_sites',
|
|
839
|
+
description: 'List all registered GitHub blog sites.',
|
|
840
|
+
inputSchema: { type: 'object', properties: {} },
|
|
841
|
+
handler: async () => {
|
|
842
|
+
const sites = await listBlogSites();
|
|
843
|
+
return { sites };
|
|
844
|
+
},
|
|
845
|
+
},
|
|
846
|
+
{
|
|
847
|
+
name: 'remove_blog_site',
|
|
848
|
+
description: 'Remove a registered blog site by id.',
|
|
849
|
+
inputSchema: {
|
|
850
|
+
type: 'object',
|
|
851
|
+
properties: {
|
|
852
|
+
id: { type: 'string', description: 'Blog site id (from list_blog_sites)' },
|
|
853
|
+
},
|
|
854
|
+
required: ['id'],
|
|
855
|
+
},
|
|
856
|
+
handler: async (params) => {
|
|
857
|
+
const id = String(params.id);
|
|
858
|
+
const sites = await listBlogSites();
|
|
859
|
+
const next = sites.filter(s => s.id !== id);
|
|
860
|
+
if (next.length === sites.length)
|
|
861
|
+
return { error: `No blog site with id ${id}` };
|
|
862
|
+
await writeBlogSites(next);
|
|
863
|
+
return { success: true, removed: id };
|
|
864
|
+
},
|
|
865
|
+
},
|
|
866
|
+
{
|
|
867
|
+
name: 'edit_blog_site',
|
|
868
|
+
description: 'Update fields on an already-registered blog site by id. Only the fields you pass change; everything else is left intact. The common use is backfilling site_url / blog_url_pattern after registration so published posts get a live "View Post" link (e.g. for Cloudflare Pages / Vercel / Netlify sites where the domain couldn\'t be auto-detected).',
|
|
869
|
+
inputSchema: {
|
|
870
|
+
type: 'object',
|
|
871
|
+
properties: {
|
|
872
|
+
id: { type: 'string', description: 'Blog site id (from list_blog_sites)' },
|
|
873
|
+
label: { type: 'string', description: 'User-facing name' },
|
|
874
|
+
branch: { type: 'string', description: 'Branch to push to' },
|
|
875
|
+
content_dir: { type: 'string', description: 'Directory where post .md files live' },
|
|
876
|
+
image_dir: { type: 'string', description: 'Directory where image files write' },
|
|
877
|
+
image_public_prefix: { type: 'string', description: 'Directory prefix images live under' },
|
|
878
|
+
image_path_style: { type: 'string', enum: ['relative', 'absolute'], description: 'How image paths are written' },
|
|
879
|
+
image_naming: { type: 'string', description: 'Cover filename template with `{slug}` + `{ext}`' },
|
|
880
|
+
framework: { type: 'string', enum: ['astro', 'next', 'jekyll', 'hugo', 'unknown'], description: 'Site framework' },
|
|
881
|
+
frontmatter_defaults: { type: 'object', description: 'Constants applied to every post\'s frontmatter' },
|
|
882
|
+
frontmatter_field_map: { type: 'object', description: 'Rename map: openwriter blogContext key → site frontmatter key' },
|
|
883
|
+
frontmatter_schema: { type: 'array', items: { type: 'string' }, description: 'List of frontmatter keys the site uses' },
|
|
884
|
+
site_url: { type: 'string', description: 'Public base URL (e.g. "https://example.com"). Pass an empty string to clear it.' },
|
|
885
|
+
blog_url_pattern: { type: 'string', description: 'URL path pattern with `{slug}` placeholder (e.g. "/blog/{slug}/").' },
|
|
886
|
+
},
|
|
887
|
+
required: ['id'],
|
|
888
|
+
},
|
|
889
|
+
handler: async (params) => {
|
|
890
|
+
const id = String(params.id);
|
|
891
|
+
const sites = await listBlogSites();
|
|
892
|
+
const site = sites.find(s => s.id === id);
|
|
893
|
+
if (!site)
|
|
894
|
+
return { error: `No blog site with id ${id}` };
|
|
895
|
+
// Plain string fields — set when a non-empty string is provided.
|
|
896
|
+
for (const key of ['label', 'branch', 'content_dir', 'image_dir', 'image_public_prefix', 'image_naming', 'blog_url_pattern']) {
|
|
897
|
+
if (typeof params[key] === 'string' && params[key].trim()) {
|
|
898
|
+
site[key] = params[key].trim();
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
// site_url: trim + strip trailing slash; empty string clears it.
|
|
902
|
+
if (typeof params.site_url === 'string') {
|
|
903
|
+
const v = params.site_url.trim().replace(/\/+$/, '');
|
|
904
|
+
if (v)
|
|
905
|
+
site.site_url = v;
|
|
906
|
+
else
|
|
907
|
+
delete site.site_url;
|
|
908
|
+
}
|
|
909
|
+
if (params.image_path_style === 'relative' || params.image_path_style === 'absolute') {
|
|
910
|
+
site.image_path_style = params.image_path_style;
|
|
911
|
+
}
|
|
912
|
+
if (params.framework && ['astro', 'next', 'jekyll', 'hugo', 'unknown'].includes(String(params.framework))) {
|
|
913
|
+
site.framework = params.framework;
|
|
914
|
+
}
|
|
915
|
+
if (params.frontmatter_defaults && typeof params.frontmatter_defaults === 'object') {
|
|
916
|
+
site.frontmatter_defaults = params.frontmatter_defaults;
|
|
917
|
+
}
|
|
918
|
+
if (params.frontmatter_field_map && typeof params.frontmatter_field_map === 'object') {
|
|
919
|
+
site.frontmatter_field_map = params.frontmatter_field_map;
|
|
920
|
+
}
|
|
921
|
+
if (Array.isArray(params.frontmatter_schema)) {
|
|
922
|
+
site.frontmatter_schema = params.frontmatter_schema.map(String);
|
|
923
|
+
}
|
|
924
|
+
await writeBlogSites(sites);
|
|
925
|
+
return { success: true, site };
|
|
926
|
+
},
|
|
927
|
+
},
|
|
928
|
+
{
|
|
929
|
+
name: 'post_to_blog',
|
|
930
|
+
description: 'Publish the active OpenWriter document to a registered GitHub blog site via local git ops (clone-or-pull, write file + images, commit, push). Auth uses your existing `gh auth login`.',
|
|
931
|
+
inputSchema: {
|
|
932
|
+
type: 'object',
|
|
933
|
+
properties: {
|
|
934
|
+
site_id: { type: 'string', description: 'Blog site id (from list_blog_sites)' },
|
|
935
|
+
slug: { type: 'string', description: 'Filename slug (without .md). Default: slugified document title.' },
|
|
936
|
+
commit_message: { type: 'string', description: 'Git commit message. Default: "blog: {title}".' },
|
|
937
|
+
},
|
|
938
|
+
required: ['site_id'],
|
|
939
|
+
},
|
|
940
|
+
handler: async (params) => {
|
|
941
|
+
const siteId = String(params.site_id);
|
|
942
|
+
const sites = await listBlogSites();
|
|
943
|
+
const site = sites.find(s => s.id === siteId);
|
|
944
|
+
if (!site)
|
|
945
|
+
return { error: `No blog site with id ${siteId}` };
|
|
946
|
+
const srv = await getServerModules();
|
|
947
|
+
// Flush any pending writes so we read fresh state
|
|
948
|
+
try {
|
|
949
|
+
srv.cancelDebouncedSave();
|
|
950
|
+
srv.save();
|
|
951
|
+
}
|
|
952
|
+
catch { /* ignore */ }
|
|
953
|
+
const doc = srv.getDocument();
|
|
954
|
+
const title = srv.getTitle();
|
|
955
|
+
const metadata = srv.getMetadata() || {};
|
|
956
|
+
if (!doc || !doc.content)
|
|
957
|
+
return { error: 'No active document. Switch to a document first.' };
|
|
958
|
+
if (!title)
|
|
959
|
+
return { error: 'Document has no title. Set a title before publishing.' };
|
|
960
|
+
const contentType = metadata.content_type ||
|
|
961
|
+
(metadata.tweetContext ? 'tweet'
|
|
962
|
+
: metadata.articleContext ? 'article'
|
|
963
|
+
: metadata.linkedinContext ? 'linkedin'
|
|
964
|
+
: metadata.newsletterContext ? 'newsletter'
|
|
965
|
+
: metadata.blogContext ? 'blog'
|
|
966
|
+
: undefined);
|
|
967
|
+
if (contentType !== 'blog') {
|
|
968
|
+
return {
|
|
969
|
+
error: `Active document is content_type "${contentType || 'untyped'}", not "blog". post_to_blog only publishes blog docs. Create a blog doc (sidebar → "+ New" → Blog) and switch to it before posting.`,
|
|
970
|
+
};
|
|
971
|
+
}
|
|
972
|
+
const blogCtx = metadata.blogContext || {};
|
|
973
|
+
const cloneRoot = join(owRoot(), '_blog-clones');
|
|
974
|
+
mkdirSync(cloneRoot, { recursive: true });
|
|
975
|
+
const clonePath = join(cloneRoot, site.id);
|
|
976
|
+
if (!(await ghAuthOk(cloneRoot))) {
|
|
977
|
+
return { error: 'gh CLI not authenticated. Run `gh auth login` first.' };
|
|
978
|
+
}
|
|
979
|
+
// Clone or refresh
|
|
980
|
+
if (!existsSync(join(clonePath, '.git'))) {
|
|
981
|
+
if (existsSync(clonePath)) {
|
|
982
|
+
try {
|
|
983
|
+
rmSync(clonePath, { recursive: true, force: true });
|
|
984
|
+
}
|
|
985
|
+
catch { /* ignore */ }
|
|
986
|
+
}
|
|
987
|
+
try {
|
|
988
|
+
await exec('gh', ['repo', 'clone', `${site.owner}/${site.repo}`, clonePath], cloneRoot);
|
|
989
|
+
}
|
|
990
|
+
catch (err) {
|
|
991
|
+
return { error: `Clone failed: ${err.message}` };
|
|
992
|
+
}
|
|
993
|
+
try {
|
|
994
|
+
await exec('git', ['checkout', site.branch], clonePath);
|
|
995
|
+
}
|
|
996
|
+
catch { /* may already be on it */ }
|
|
997
|
+
}
|
|
998
|
+
else {
|
|
999
|
+
try {
|
|
1000
|
+
await exec('git', ['fetch', 'origin', site.branch], clonePath);
|
|
1001
|
+
await exec('git', ['checkout', site.branch], clonePath);
|
|
1002
|
+
await exec('git', ['reset', '--hard', `origin/${site.branch}`], clonePath);
|
|
1003
|
+
}
|
|
1004
|
+
catch (err) {
|
|
1005
|
+
return { error: `Failed to refresh clone: ${err.message}` };
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
// Build markdown body
|
|
1009
|
+
const rawMd = srv.tiptapToMarkdown(doc, title, metadata);
|
|
1010
|
+
const bodyMd = stripFrontmatter(rawMd);
|
|
1011
|
+
// Slug priority: explicit param > blogContext.slug > slugified title
|
|
1012
|
+
const slug = String(params.slug || blogCtx.slug || slugify(title));
|
|
1013
|
+
if (!slug)
|
|
1014
|
+
return { error: 'Could not derive a slug from the title.' };
|
|
1015
|
+
// Per-site image contract: `image_path_style` governs the FRONTMATTER
|
|
1016
|
+
// cover only. Inline BODY refs are always absolute — see
|
|
1017
|
+
// rewriteBodyImages. adr: adr/blog-image-contract.md
|
|
1018
|
+
const style = pathStyleOf(site);
|
|
1019
|
+
// Inline body images keep their source (hash) filenames — only the
|
|
1020
|
+
// path is rewritten. Deterministic slug naming is scoped to the
|
|
1021
|
+
// COVER for now (inline naming is a noted follow-up in the ADR).
|
|
1022
|
+
const { body: bodyRewritten, imageRefs } = rewriteBodyImages(bodyMd, site.image_public_prefix);
|
|
1023
|
+
// Cover image from blogContext → deterministic `og-{slug}.{ext}` name.
|
|
1024
|
+
// Same doc + slug ⇒ same filename every republish (idempotent
|
|
1025
|
+
// overwrite, no orphaned cover). Source extension is preserved.
|
|
1026
|
+
let coverImagePath;
|
|
1027
|
+
let coverSrcFile;
|
|
1028
|
+
let coverDestFile;
|
|
1029
|
+
if (typeof blogCtx.coverImage === 'string' && blogCtx.coverImage) {
|
|
1030
|
+
coverSrcFile = blogCtx.coverImage.replace(/^\/_images\//, '');
|
|
1031
|
+
const ext = extname(coverSrcFile) || '.png';
|
|
1032
|
+
coverDestFile = coverFilename(site.image_naming, slug, ext);
|
|
1033
|
+
coverImagePath = imageRef(site.image_public_prefix, coverDestFile, style);
|
|
1034
|
+
}
|
|
1035
|
+
// Copy images
|
|
1036
|
+
const dataDir = srv.getDataDir();
|
|
1037
|
+
const imageDirAbs = join(clonePath, site.image_dir);
|
|
1038
|
+
mkdirSync(imageDirAbs, { recursive: true });
|
|
1039
|
+
let imagesCopied = 0;
|
|
1040
|
+
// Inline images — copied under their source filename
|
|
1041
|
+
for (const fn of imageRefs) {
|
|
1042
|
+
const src = join(dataDir, '_images', fn);
|
|
1043
|
+
if (!existsSync(src))
|
|
1044
|
+
continue;
|
|
1045
|
+
const dst = join(imageDirAbs, fn);
|
|
1046
|
+
mkdirSync(dirname(dst), { recursive: true });
|
|
1047
|
+
copyFileSync(src, dst);
|
|
1048
|
+
imagesCopied++;
|
|
1049
|
+
}
|
|
1050
|
+
// Cover — copied under the deterministic slug-based name
|
|
1051
|
+
if (coverSrcFile && coverDestFile) {
|
|
1052
|
+
const src = join(dataDir, '_images', coverSrcFile);
|
|
1053
|
+
if (existsSync(src)) {
|
|
1054
|
+
const dst = join(imageDirAbs, coverDestFile);
|
|
1055
|
+
mkdirSync(dirname(dst), { recursive: true });
|
|
1056
|
+
copyFileSync(src, dst);
|
|
1057
|
+
imagesCopied++;
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
// Write the post file
|
|
1061
|
+
const frontmatter = buildFrontmatter(title, blogCtx, site, coverImagePath);
|
|
1062
|
+
const postRel = join(site.content_dir, `${slug}.md`);
|
|
1063
|
+
const postAbs = join(clonePath, postRel);
|
|
1064
|
+
mkdirSync(dirname(postAbs), { recursive: true });
|
|
1065
|
+
writeFileSync(postAbs, frontmatter + bodyRewritten + '\n', 'utf-8');
|
|
1066
|
+
// ── PRE-COMMIT SCHEMA GATE ──────────────────────────────────────────
|
|
1067
|
+
// Validate the built frontmatter against the TARGET SITE's own content
|
|
1068
|
+
// schema BEFORE anything is committed. A value outside the site's
|
|
1069
|
+
// z.enum (e.g. category "Updates" when the schema allows only
|
|
1070
|
+
// 'Product Updates' | 'Guides' | …) otherwise sails straight to a red
|
|
1071
|
+
// Astro build + a silent 404 — the exact failure that motivated this
|
|
1072
|
+
// gate (live incident 2026-06-09). The schema is read from the cloned
|
|
1073
|
+
// repo's LIVE config every publish, never a mirrored snapshot, so it
|
|
1074
|
+
// can't drift from the site. adr: adr/blog-publish-schema-gate.md
|
|
1075
|
+
const gate = await srv.validateBlogFrontmatter({
|
|
1076
|
+
repoRoot: clonePath,
|
|
1077
|
+
contentDir: site.content_dir,
|
|
1078
|
+
frontmatter,
|
|
1079
|
+
});
|
|
1080
|
+
if (!gate.ok) {
|
|
1081
|
+
// ABORT before commit/push. Nothing was committed; this working-tree
|
|
1082
|
+
// edit stays local and is wiped by the next publish's reset --hard.
|
|
1083
|
+
const friendly = gate.summary
|
|
1084
|
+
|| (gate.issues || []).map((i) => i.message).join('; ')
|
|
1085
|
+
|| 'frontmatter does not match the site schema';
|
|
1086
|
+
// Human surface: a toast fires for whoever clicked Publish, over the
|
|
1087
|
+
// existing WS path, routed to the canonical showToast() primitive.
|
|
1088
|
+
try {
|
|
1089
|
+
srv.broadcastToast(`Blog publish blocked: ${friendly}`, 'error');
|
|
1090
|
+
}
|
|
1091
|
+
catch { /* best-effort */ }
|
|
1092
|
+
// MCP surface: structured error so the calling agent sees exactly
|
|
1093
|
+
// what to fix and can republish.
|
|
1094
|
+
return {
|
|
1095
|
+
error: `Publish blocked — ${friendly}`,
|
|
1096
|
+
validation_failed: true,
|
|
1097
|
+
issues: gate.issues || [],
|
|
1098
|
+
...(gate.configPath ? { schema_config: gate.configPath } : {}),
|
|
1099
|
+
...(gate.collection ? { collection: gate.collection } : {}),
|
|
1100
|
+
hint: "Fix the frontmatter to match the site's content schema, then republish. Nothing was committed or pushed.",
|
|
1101
|
+
};
|
|
1102
|
+
}
|
|
1103
|
+
// Validation could not run faithfully (non-Astro repo, unparseable
|
|
1104
|
+
// config, or a schemaless collection). NEVER a silent skip — surface
|
|
1105
|
+
// it. For an Astro site this is a real gap (loud error); for other
|
|
1106
|
+
// frameworks there's simply no Astro schema to check (quiet info). The
|
|
1107
|
+
// reason always rides back on the MCP response either way.
|
|
1108
|
+
let validationWarning;
|
|
1109
|
+
if (gate.skipped) {
|
|
1110
|
+
validationWarning = `Published WITHOUT schema validation — ${gate.reason}.`;
|
|
1111
|
+
const astroExpected = site.framework === 'astro';
|
|
1112
|
+
try {
|
|
1113
|
+
srv.broadcastToast(astroExpected
|
|
1114
|
+
? `Blog published without schema check — ${gate.reason}`
|
|
1115
|
+
: `Blog published (no Astro schema to check on this ${site.framework} site)`, astroExpected ? 'error' : 'info');
|
|
1116
|
+
}
|
|
1117
|
+
catch { /* best-effort */ }
|
|
1118
|
+
}
|
|
1119
|
+
// Commit + push (no-op is fine — still counts as a publish for the writeback)
|
|
1120
|
+
const commitMessage = String(params.commit_message || `blog: ${title}`);
|
|
1121
|
+
let noChanges = false;
|
|
1122
|
+
try {
|
|
1123
|
+
await exec('git', ['add', '-A'], clonePath);
|
|
1124
|
+
const status = await exec('git', ['status', '--porcelain'], clonePath);
|
|
1125
|
+
if (!status) {
|
|
1126
|
+
noChanges = true;
|
|
1127
|
+
}
|
|
1128
|
+
else {
|
|
1129
|
+
await exec('git', ['commit', '-m', commitMessage], clonePath);
|
|
1130
|
+
await exec('git', ['push', 'origin', site.branch], clonePath);
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
catch (err) {
|
|
1134
|
+
return { error: `Git op failed: ${err.message}` };
|
|
1135
|
+
}
|
|
1136
|
+
let shortHash = '';
|
|
1137
|
+
try {
|
|
1138
|
+
shortHash = await exec('git', ['rev-parse', '--short', 'HEAD'], clonePath);
|
|
1139
|
+
}
|
|
1140
|
+
catch { /* ignore */ }
|
|
1141
|
+
// Construct the live URL when the site has site_url configured.
|
|
1142
|
+
// Pattern defaults to /blog/{slug}/ — matches the convention proposed by inspect_blog_repo.
|
|
1143
|
+
let liveUrl;
|
|
1144
|
+
if (site.site_url) {
|
|
1145
|
+
const pattern = site.blog_url_pattern || '/blog/{slug}/';
|
|
1146
|
+
const path = pattern.replace('{slug}', slug);
|
|
1147
|
+
liveUrl = site.site_url.replace(/\/+$/, '') + (path.startsWith('/') ? path : '/' + path);
|
|
1148
|
+
}
|
|
1149
|
+
// Mark the doc as sent so the file-tree right-click menu surfaces
|
|
1150
|
+
// "View Post" with a live link. This mirrors the tweetContext.lastPost /
|
|
1151
|
+
// articleContext.lastPost / newsletterContext.lastSend pattern. Runs
|
|
1152
|
+
// even on no-op publishes — the doc is still "on the site as of now".
|
|
1153
|
+
// adr: adr/plugin-slot-nested-data.md (writes through setMetadata which deep-merges blogContext)
|
|
1154
|
+
let writebackWarning;
|
|
1155
|
+
try {
|
|
1156
|
+
srv.setMetadata({
|
|
1157
|
+
blogContext: {
|
|
1158
|
+
lastPublish: {
|
|
1159
|
+
publishedAt: new Date().toISOString(),
|
|
1160
|
+
...(liveUrl ? { publishedUrl: liveUrl } : {}),
|
|
1161
|
+
...(shortHash ? { commit: shortHash } : {}),
|
|
1162
|
+
file: postRel.replace(/\\/g, '/'),
|
|
1163
|
+
},
|
|
1164
|
+
},
|
|
1165
|
+
});
|
|
1166
|
+
// setMetadata doesn't bump docVersion on its own — without an explicit
|
|
1167
|
+
// bump, save()→writeToDisk hits the no-op gate (docVersion === lastSavedDocVersion
|
|
1168
|
+
// when there's no body change) and the lastPublish writeback never lands on disk.
|
|
1169
|
+
// Same convention mcp.ts:1112 uses for active-doc metadata writes.
|
|
1170
|
+
srv.bumpDocVersion();
|
|
1171
|
+
srv.save();
|
|
1172
|
+
// Notify every connected client so the file-tree "published" ✓ + the
|
|
1173
|
+
// "Republish to Blog" context-menu label + the compose-view "Published"
|
|
1174
|
+
// pill flip live, with no manual reload. metadata-changed updates the
|
|
1175
|
+
// active doc's compose view; documents-changed re-reads /api/documents
|
|
1176
|
+
// (where blogContext.lastPublish.publishedUrl → postedUrl drives the
|
|
1177
|
+
// file tree). Mirrors the broadcast-after-setMetadata convention the
|
|
1178
|
+
// core MCP tools follow. adr: adr/plugin-metadata-broadcast.md
|
|
1179
|
+
srv.broadcastMetadataChanged(srv.getMetadata());
|
|
1180
|
+
srv.broadcastDocumentsChanged();
|
|
1181
|
+
}
|
|
1182
|
+
catch (err) {
|
|
1183
|
+
writebackWarning = `Published successfully, but failed to mark doc as sent: ${err.message}`;
|
|
1184
|
+
}
|
|
1185
|
+
return {
|
|
1186
|
+
success: true,
|
|
1187
|
+
file: postRel.replace(/\\/g, '/'),
|
|
1188
|
+
commit: shortHash,
|
|
1189
|
+
images_committed: noChanges ? 0 : imagesCopied,
|
|
1190
|
+
// Transparency: the exact cover path written to frontmatter + the
|
|
1191
|
+
// filename it shipped as, so the agent/user sees what landed.
|
|
1192
|
+
...(coverImagePath ? { image: coverImagePath, cover_file: coverDestFile } : {}),
|
|
1193
|
+
live_url: liveUrl,
|
|
1194
|
+
// Transparency on the happy path: the schema gate ran and passed (or
|
|
1195
|
+
// was loudly skipped). adr: adr/blog-publish-schema-gate.md
|
|
1196
|
+
validated: !gate.skipped,
|
|
1197
|
+
...(gate.configPath ? { schema_config: gate.configPath } : {}),
|
|
1198
|
+
...(gate.collection ? { schema_collection: gate.collection } : {}),
|
|
1199
|
+
message: noChanges
|
|
1200
|
+
? 'No changes — file already up to date. Doc marked as sent.'
|
|
1201
|
+
: `Pushed to ${site.owner}/${site.repo}@${site.branch}`,
|
|
1202
|
+
...(validationWarning ? { validation_warning: validationWarning } : {}),
|
|
1203
|
+
...(writebackWarning ? { warning: writebackWarning } : {}),
|
|
1204
|
+
};
|
|
1205
|
+
},
|
|
1206
|
+
},
|
|
1207
|
+
];
|
|
1208
|
+
}
|