openwriter 0.40.0 → 0.40.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/dist/client/assets/{index-vmEPerKn.js → index-Dxbv2n2m.js} +1 -1
  2. package/dist/client/index.html +1 -1
  3. package/package.json +1 -1
  4. package/dist/plugins/authors-voice/dist/index.d.ts +0 -48
  5. package/dist/plugins/authors-voice/dist/index.js +0 -235
  6. package/dist/plugins/authors-voice/package.json +0 -24
  7. package/dist/plugins/authors-voice/skill/LICENSE +0 -21
  8. package/dist/plugins/authors-voice/skill/README.md +0 -126
  9. package/dist/plugins/authors-voice/skill/SKILL.md +0 -151
  10. package/dist/plugins/authors-voice/skill/catalog/ai-tells.md +0 -144
  11. package/dist/plugins/authors-voice/skill/catalog/anchor-prompt.md +0 -189
  12. package/dist/plugins/authors-voice/skill/catalog/author-hints.md +0 -119
  13. package/dist/plugins/authors-voice/skill/catalog/fingerprints.md +0 -175
  14. package/dist/plugins/authors-voice/skill/catalog/hurdle.md +0 -76
  15. package/dist/plugins/authors-voice/skill/catalog/post-write-audit.md +0 -105
  16. package/dist/plugins/authors-voice/skill/docs/analysis.md +0 -31
  17. package/dist/plugins/authors-voice/skill/docs/anchor-iteration.md +0 -176
  18. package/dist/plugins/authors-voice/skill/docs/api/import.md +0 -78
  19. package/dist/plugins/authors-voice/skill/docs/api/protocol.md +0 -140
  20. package/dist/plugins/authors-voice/skill/docs/api/setup.md +0 -37
  21. package/dist/plugins/authors-voice/skill/docs/api/tools.md +0 -102
  22. package/dist/plugins/authors-voice/skill/docs/api/troubleshooting.md +0 -7
  23. package/dist/plugins/authors-voice/skill/docs/apply-protocol-deep.md +0 -191
  24. package/dist/plugins/authors-voice/skill/docs/context-hygiene.md +0 -33
  25. package/dist/plugins/authors-voice/skill/docs/setup.md +0 -74
  26. package/dist/plugins/authors-voice/skill/docs/tiers.md +0 -13
  27. package/dist/plugins/authors-voice/skill/package.json +0 -35
  28. package/dist/plugins/authors-voice/skill/prompts/skeleton.md +0 -29
  29. package/dist/plugins/authors-voice/skill/voice/README.md +0 -51
  30. package/dist/plugins/authors-voice/skill/voice/corpus/.gitkeep +0 -0
  31. package/dist/plugins/github/dist/blog-tools.d.ts +0 -65
  32. package/dist/plugins/github/dist/blog-tools.js +0 -1189
  33. package/dist/plugins/github/dist/git-sync.d.ts +0 -46
  34. package/dist/plugins/github/dist/git-sync.js +0 -335
  35. package/dist/plugins/github/dist/helpers.d.ts +0 -127
  36. package/dist/plugins/github/dist/helpers.js +0 -67
  37. package/dist/plugins/github/dist/index.d.ts +0 -12
  38. package/dist/plugins/github/dist/index.js +0 -112
  39. package/dist/plugins/github/package.json +0 -24
  40. package/dist/plugins/image-gen/dist/index.d.ts +0 -35
  41. package/dist/plugins/image-gen/dist/index.js +0 -149
  42. package/dist/plugins/image-gen/package.json +0 -26
  43. package/dist/plugins/publish/dist/helpers.d.ts +0 -66
  44. package/dist/plugins/publish/dist/helpers.js +0 -199
  45. package/dist/plugins/publish/dist/index.d.ts +0 -3
  46. package/dist/plugins/publish/dist/index.js +0 -1156
  47. package/dist/plugins/publish/dist/newsletter-tools.d.ts +0 -2
  48. package/dist/plugins/publish/dist/newsletter-tools.js +0 -394
  49. package/dist/plugins/publish/package.json +0 -31
  50. package/dist/plugins/x-api/dist/index.d.ts +0 -27
  51. package/dist/plugins/x-api/dist/index.js +0 -368
  52. package/dist/plugins/x-api/dist/server-bridge.d.ts +0 -22
  53. package/dist/plugins/x-api/dist/server-bridge.js +0 -43
  54. package/dist/plugins/x-api/package.json +0 -27
  55. package/dist/server/blog-routes.js +0 -160
  56. package/dist/server/git-sync.js +0 -273
  57. package/dist/server/marks.js +0 -182
  58. package/dist/server/sync-routes.js +0 -75
  59. package/skill/docs/enrichment.md +0 -180
  60. package/skill/docs/footnotes.md +0 -178
  61. package/skill/docs/setup.md +0 -62
  62. package/skill/docs/welcome.md +0 -21
@@ -1,1189 +0,0 @@
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
- * Resolve the deterministic cover filename from the site's naming template.
502
- * `{slug}` → post slug
503
- * `{ext}` → source extension, no dot (preserved from the original)
504
- * A template carrying a literal extension and no `{ext}` placeholder
505
- * (e.g. `og-{slug}.png`) is respected as authored. Absent template ⇒
506
- * `og-{slug}.{ext}`.
507
- */
508
- export function coverFilename(template, slug, sourceExt) {
509
- const ext = sourceExt.replace(/^\.+/, '');
510
- return (template || 'og-{slug}.{ext}')
511
- .replace(/\{slug\}/g, slug)
512
- .replace(/\{ext\}/g, ext);
513
- }
514
- /**
515
- * Build the YAML frontmatter from blogContext + site defaults.
516
- *
517
- * Order of precedence (low → high):
518
- * 1. Site `frontmatter_defaults` (e.g. `layout`, `author`, `prerender`)
519
- * 2. Generated `title` (from document title — always present)
520
- * 3. blogContext fields (description, date, author, tags, slug, draft, coverImage)
521
- *
522
- * Field-name mapping: blogContext keys are renamed via `site.frontmatter_field_map`
523
- * before emit (e.g. `date` → `publishedDate` for Astro sites).
524
- *
525
- * Top-level openwriter metadata (status, enrichmentStale, tags-as-content-type,
526
- * etc.) is NEVER passed through. Frontmatter is built ONLY from blogContext +
527
- * defaults — this is the design contract from server/blog-routes.ts.
528
- */
529
- export function buildFrontmatter(title, blogCtx, site, coverImagePath) {
530
- const fm = {};
531
- const map = site.frontmatter_field_map || {};
532
- // 1. Site defaults (lowest priority — overridable below)
533
- if (site.frontmatter_defaults) {
534
- for (const [k, v] of Object.entries(site.frontmatter_defaults)) {
535
- fm[k] = v;
536
- }
537
- }
538
- // 2. Title (always)
539
- fm.title = title;
540
- // 3. blogContext fields — apply field_map rename, skip empty values
541
- const passthrough = [
542
- { src: 'description' },
543
- { src: 'date', format: formatDate },
544
- { src: 'author' },
545
- { src: 'tags' },
546
- { src: 'category' },
547
- { src: 'slug' },
548
- { src: 'draft' },
549
- { src: 'subtitle' },
550
- { src: 'excerpt' },
551
- { src: 'coverImageAlt' },
552
- ];
553
- for (const { src, format } of passthrough) {
554
- const v = blogCtx[src];
555
- if (v == null || v === '' || (Array.isArray(v) && v.length === 0))
556
- continue;
557
- const dest = map[src] || src;
558
- fm[dest] = format ? format(v) : v;
559
- }
560
- // 4. Cover image: rewritten path takes priority over blogContext.coverImage
561
- if (coverImagePath) {
562
- const dest = map.coverImage || 'coverImage';
563
- fm[dest] = coverImagePath;
564
- }
565
- // Ensure date field exists if site expects one — derive from today
566
- const dateDest = map.date || 'date';
567
- const publishedDateDest = map.publishedDate || (map.date === 'publishedDate' ? 'publishedDate' : null);
568
- if (!fm[dateDest] && !(publishedDateDest && fm[publishedDateDest])) {
569
- fm[dateDest] = new Date().toISOString().slice(0, 10);
570
- }
571
- // Date fields emit as UNQUOTED yaml scalars (pubDate: 2026-05-31), never
572
- // quoted strings. Astro's z.date() rejects a quoted value — js-yaml parses it
573
- // as a String, not a Date — which froze a live Netlify build (a production Astro blog,
574
- // 2026-06-01). The unquoted form is ALSO accepted by z.coerce.date() and by
575
- // Jekyll/Hugo/Next (gray-matter), so it is the universally-correct emit.
576
- // adr: adr/blog-image-contract.md
577
- const dateKeys = new Set([dateDest]);
578
- if (publishedDateDest)
579
- dateKeys.add(publishedDateDest);
580
- const emitLine = (k, v) => dateKeys.has(k) && typeof v === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(v)
581
- ? `${k}: ${v}`
582
- : `${k}: ${yamlValue(v)}`;
583
- // Emit in stable order: defaults first (in their declared order),
584
- // then title, then any new keys we added
585
- const lines = [];
586
- const written = new Set();
587
- if (site.frontmatter_defaults) {
588
- for (const k of Object.keys(site.frontmatter_defaults)) {
589
- if (k in fm) {
590
- lines.push(emitLine(k, fm[k]));
591
- written.add(k);
592
- }
593
- }
594
- }
595
- if (!written.has('title')) {
596
- lines.push(`title: ${yamlValue(fm.title)}`);
597
- written.add('title');
598
- }
599
- for (const [k, v] of Object.entries(fm)) {
600
- if (written.has(k))
601
- continue;
602
- lines.push(emitLine(k, v));
603
- written.add(k);
604
- }
605
- return `---\n${lines.join('\n')}\n---\n\n`;
606
- }
607
- function stripFrontmatter(md) {
608
- return md.replace(/^---\n[\s\S]*?\n---\n+/, '').replace(/^\s*<!--\s*-->\s*$/gm, '').trim();
609
- }
610
- // ---- Tools ----
611
- export function blogTools() {
612
- return [
613
- {
614
- name: 'inspect_blog_repo',
615
- 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.',
616
- inputSchema: {
617
- type: 'object',
618
- properties: {
619
- repo_url: { type: 'string', description: 'GitHub repo URL or owner/repo shorthand (e.g. "https://github.com/user/blog" or "user/blog")' },
620
- },
621
- required: ['repo_url'],
622
- },
623
- handler: async (params) => {
624
- const repoUrl = String(params.repo_url || '').trim();
625
- if (!repoUrl)
626
- return { error: 'repo_url is required' };
627
- // Normalize to owner/repo
628
- let ownerRepo = repoUrl
629
- .replace(/^https?:\/\/github\.com\//, '')
630
- .replace(/\.git$/, '')
631
- .replace(/^github:/, '');
632
- const parts = ownerRepo.split('/').filter(Boolean);
633
- if (parts.length < 2)
634
- return { error: 'Could not parse owner/repo from repo_url' };
635
- const owner = parts[0];
636
- const repo = parts[1];
637
- const cacheRoot = join(owRoot(), '_blog-inspect-cache');
638
- mkdirSync(cacheRoot, { recursive: true });
639
- const cloneDir = join(cacheRoot, `${owner}-${repo}`);
640
- if (!(await ghAuthOk(cacheRoot))) {
641
- return { error: 'gh CLI not authenticated. Run `gh auth login` first.' };
642
- }
643
- // Refresh: remove existing then clone shallow
644
- if (existsSync(cloneDir)) {
645
- try {
646
- rmSync(cloneDir, { recursive: true, force: true });
647
- }
648
- catch { /* ignore */ }
649
- }
650
- try {
651
- await exec('gh', ['repo', 'clone', `${owner}/${repo}`, cloneDir, '--', '--depth', '1'], cacheRoot);
652
- }
653
- catch (err) {
654
- return { error: `Clone failed: ${err.message}` };
655
- }
656
- const files = walkDir(cloneDir);
657
- const framework = inferFramework(cloneDir, files);
658
- const mdBest = dirOfMostMarkdown(files, cloneDir);
659
- const detected = mdBest?.dir || '';
660
- const defaults = defaultDirsForFramework(framework, detected);
661
- // Collect multiple sample post frontmatters so we can detect constants.
662
- // Up to 10 samples to avoid pathological loops on huge repos.
663
- const sampleFiles = files
664
- .filter((f) => {
665
- const rel = f.slice(cloneDir.length + 1).replace(/\\/g, '/');
666
- return (detected ? rel.startsWith(detected + '/') : true) && /\.(md|mdx)$/i.test(rel);
667
- })
668
- .slice(0, 10);
669
- const sampleData = [];
670
- for (const f of sampleFiles) {
671
- try {
672
- const fm = parseYamlFrontmatter(readFileSync(f, 'utf-8'));
673
- const slug = basename(f).replace(/\.(md|mdx)$/i, '');
674
- sampleData.push({ fm, slug });
675
- }
676
- catch { /* skip */ }
677
- }
678
- const rawSamples = sampleData.map((s) => s.fm);
679
- const samplesAfterFilter = rawSamples.filter((s) => !looksLikeOpenwriterLeak(s));
680
- const samplesSkipped = rawSamples.length - samplesAfterFilter.length;
681
- const shape = inferFrontmatterShape(rawSamples);
682
- // Per-site image contract — inferred from real posts (path style, the
683
- // directory images live under, og-{slug} vs {slug} naming, and which
684
- // frontmatter field holds the cover). Leak samples excluded so the
685
- // old plugin's emit doesn't poison detection. adr: adr/blog-image-contract.md
686
- const conv = inferImageConventions(sampleData.filter((s) => !looksLikeOpenwriterLeak(s.fm)));
687
- const imagePublicPrefix = conv.image_public_prefix ?? defaults.image_public_prefix;
688
- const imageDir = conv.image_public_prefix
689
- ? imageDirForFramework(framework, conv.image_public_prefix)
690
- : defaults.image_dir;
691
- const imagePathStyle = conv.image_path_style ?? 'absolute';
692
- const imageNaming = conv.image_naming ?? 'og-{slug}.{ext}';
693
- // Cover field-name mapping (e.g. site uses `image:` not `coverImage:`)
694
- const fieldMap = { ...shape.field_map };
695
- if (conv.image_field && conv.image_field !== 'coverImage') {
696
- fieldMap.coverImage = conv.image_field;
697
- }
698
- const confidence = framework !== 'unknown' && detected ? 'high'
699
- : detected ? 'medium'
700
- : 'low';
701
- // site_url: prefer files committed to the repo (CNAME / wrangler route),
702
- // then fall back to the GitHub Pages API for GH-hosted sites whose served
703
- // URL lives in Pages settings rather than a committed file.
704
- const siteUrl = inferSiteUrl(cloneDir) || await inferSiteUrlFromGitHubPages(owner, repo, cacheRoot);
705
- return {
706
- owner,
707
- repo,
708
- framework,
709
- content_dir: defaults.content_dir,
710
- image_dir: imageDir,
711
- image_public_prefix: imagePublicPrefix,
712
- image_path_style: imagePathStyle,
713
- image_naming: imageNaming,
714
- frontmatter_schema: shape.schema,
715
- frontmatter_defaults: shape.defaults,
716
- frontmatter_field_map: fieldMap,
717
- // Always propose a pattern even when site_url is unknown so the user can fill in the URL
718
- site_url: siteUrl,
719
- blog_url_pattern: '/blog/{slug}/',
720
- // When site_url couldn't be derived, tell the agent to ask the user —
721
- // otherwise it ships posts with no live "View Post" link, silently.
722
- ...(siteUrl ? {} : { needs_site_url: true, site_url_hint: SITE_URL_HINT }),
723
- samples_analyzed: samplesAfterFilter.length,
724
- samples_skipped_openwriter_leak: samplesSkipped,
725
- markdown_files_found: mdBest?.count ?? 0,
726
- confidence,
727
- };
728
- },
729
- },
730
- {
731
- name: 'add_blog_site',
732
- 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.',
733
- inputSchema: {
734
- type: 'object',
735
- properties: {
736
- label: { type: 'string', description: 'User-facing name (e.g. "Personal blog")' },
737
- owner: { type: 'string', description: 'GitHub owner/user/org' },
738
- repo: { type: 'string', description: 'Repo name' },
739
- branch: { type: 'string', description: 'Branch to push to (default: main)' },
740
- content_dir: { type: 'string', description: 'Directory where post .md files live (e.g. "src/content/blog")' },
741
- image_dir: { type: 'string', description: 'Directory where image files write (e.g. "public/blog-images")' },
742
- 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.' },
743
- 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).' },
744
- 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).' },
745
- framework: { type: 'string', enum: ['astro', 'next', 'jekyll', 'hugo', 'unknown'], description: 'Site framework' },
746
- frontmatter_defaults: {
747
- type: 'object',
748
- 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.',
749
- },
750
- frontmatter_field_map: {
751
- type: 'object',
752
- description: 'Rename map: openwriter blogContext key → site frontmatter key (e.g. `{ "date": "publishedDate" }` for Astro-style sites that use `publishedDate`).',
753
- },
754
- frontmatter_schema: {
755
- type: 'array',
756
- items: { type: 'string' },
757
- description: 'List of frontmatter keys the site uses (from inspection). Stored for reference / future UI.',
758
- },
759
- site_url: {
760
- type: 'string',
761
- 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.',
762
- },
763
- blog_url_pattern: {
764
- type: 'string',
765
- 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.',
766
- },
767
- },
768
- required: ['label', 'owner', 'repo', 'content_dir', 'image_dir', 'image_public_prefix', 'framework'],
769
- },
770
- handler: async (params) => {
771
- const site = {
772
- id: randomUUID(),
773
- label: String(params.label),
774
- owner: String(params.owner),
775
- repo: String(params.repo),
776
- branch: String(params.branch || 'main'),
777
- content_dir: String(params.content_dir),
778
- image_dir: String(params.image_dir),
779
- image_public_prefix: String(params.image_public_prefix),
780
- framework: params.framework || 'unknown',
781
- };
782
- if (params.frontmatter_defaults && typeof params.frontmatter_defaults === 'object') {
783
- site.frontmatter_defaults = params.frontmatter_defaults;
784
- }
785
- if (params.frontmatter_field_map && typeof params.frontmatter_field_map === 'object') {
786
- site.frontmatter_field_map = params.frontmatter_field_map;
787
- }
788
- if (params.image_path_style === 'relative' || params.image_path_style === 'absolute') {
789
- site.image_path_style = params.image_path_style;
790
- }
791
- if (typeof params.image_naming === 'string' && params.image_naming.trim()) {
792
- site.image_naming = params.image_naming.trim();
793
- }
794
- if (Array.isArray(params.frontmatter_schema)) {
795
- site.frontmatter_schema = params.frontmatter_schema.map(String);
796
- }
797
- if (typeof params.site_url === 'string' && params.site_url.trim()) {
798
- site.site_url = params.site_url.trim().replace(/\/+$/, '');
799
- }
800
- if (typeof params.blog_url_pattern === 'string' && params.blog_url_pattern.trim()) {
801
- site.blog_url_pattern = params.blog_url_pattern.trim();
802
- }
803
- const sites = await listBlogSites();
804
- sites.push(site);
805
- await writeBlogSites(sites);
806
- return {
807
- success: true,
808
- site,
809
- // No site_url ⇒ no live link on publish. Tell the agent to follow up.
810
- ...(site.site_url ? {} : { needs_site_url: true, site_url_hint: SITE_URL_HINT }),
811
- };
812
- },
813
- },
814
- {
815
- name: 'list_blog_sites',
816
- description: 'List all registered GitHub blog sites.',
817
- inputSchema: { type: 'object', properties: {} },
818
- handler: async () => {
819
- const sites = await listBlogSites();
820
- return { sites };
821
- },
822
- },
823
- {
824
- name: 'remove_blog_site',
825
- description: 'Remove a registered blog site by id.',
826
- inputSchema: {
827
- type: 'object',
828
- properties: {
829
- id: { type: 'string', description: 'Blog site id (from list_blog_sites)' },
830
- },
831
- required: ['id'],
832
- },
833
- handler: async (params) => {
834
- const id = String(params.id);
835
- const sites = await listBlogSites();
836
- const next = sites.filter(s => s.id !== id);
837
- if (next.length === sites.length)
838
- return { error: `No blog site with id ${id}` };
839
- await writeBlogSites(next);
840
- return { success: true, removed: id };
841
- },
842
- },
843
- {
844
- name: 'edit_blog_site',
845
- 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).',
846
- inputSchema: {
847
- type: 'object',
848
- properties: {
849
- id: { type: 'string', description: 'Blog site id (from list_blog_sites)' },
850
- label: { type: 'string', description: 'User-facing name' },
851
- branch: { type: 'string', description: 'Branch to push to' },
852
- content_dir: { type: 'string', description: 'Directory where post .md files live' },
853
- image_dir: { type: 'string', description: 'Directory where image files write' },
854
- image_public_prefix: { type: 'string', description: 'Directory prefix images live under' },
855
- image_path_style: { type: 'string', enum: ['relative', 'absolute'], description: 'How image paths are written' },
856
- image_naming: { type: 'string', description: 'Cover filename template with `{slug}` + `{ext}`' },
857
- framework: { type: 'string', enum: ['astro', 'next', 'jekyll', 'hugo', 'unknown'], description: 'Site framework' },
858
- frontmatter_defaults: { type: 'object', description: 'Constants applied to every post\'s frontmatter' },
859
- frontmatter_field_map: { type: 'object', description: 'Rename map: openwriter blogContext key → site frontmatter key' },
860
- frontmatter_schema: { type: 'array', items: { type: 'string' }, description: 'List of frontmatter keys the site uses' },
861
- site_url: { type: 'string', description: 'Public base URL (e.g. "https://example.com"). Pass an empty string to clear it.' },
862
- blog_url_pattern: { type: 'string', description: 'URL path pattern with `{slug}` placeholder (e.g. "/blog/{slug}/").' },
863
- },
864
- required: ['id'],
865
- },
866
- handler: async (params) => {
867
- const id = String(params.id);
868
- const sites = await listBlogSites();
869
- const site = sites.find(s => s.id === id);
870
- if (!site)
871
- return { error: `No blog site with id ${id}` };
872
- // Plain string fields — set when a non-empty string is provided.
873
- for (const key of ['label', 'branch', 'content_dir', 'image_dir', 'image_public_prefix', 'image_naming', 'blog_url_pattern']) {
874
- if (typeof params[key] === 'string' && params[key].trim()) {
875
- site[key] = params[key].trim();
876
- }
877
- }
878
- // site_url: trim + strip trailing slash; empty string clears it.
879
- if (typeof params.site_url === 'string') {
880
- const v = params.site_url.trim().replace(/\/+$/, '');
881
- if (v)
882
- site.site_url = v;
883
- else
884
- delete site.site_url;
885
- }
886
- if (params.image_path_style === 'relative' || params.image_path_style === 'absolute') {
887
- site.image_path_style = params.image_path_style;
888
- }
889
- if (params.framework && ['astro', 'next', 'jekyll', 'hugo', 'unknown'].includes(String(params.framework))) {
890
- site.framework = params.framework;
891
- }
892
- if (params.frontmatter_defaults && typeof params.frontmatter_defaults === 'object') {
893
- site.frontmatter_defaults = params.frontmatter_defaults;
894
- }
895
- if (params.frontmatter_field_map && typeof params.frontmatter_field_map === 'object') {
896
- site.frontmatter_field_map = params.frontmatter_field_map;
897
- }
898
- if (Array.isArray(params.frontmatter_schema)) {
899
- site.frontmatter_schema = params.frontmatter_schema.map(String);
900
- }
901
- await writeBlogSites(sites);
902
- return { success: true, site };
903
- },
904
- },
905
- {
906
- name: 'post_to_blog',
907
- 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`.',
908
- inputSchema: {
909
- type: 'object',
910
- properties: {
911
- site_id: { type: 'string', description: 'Blog site id (from list_blog_sites)' },
912
- slug: { type: 'string', description: 'Filename slug (without .md). Default: slugified document title.' },
913
- commit_message: { type: 'string', description: 'Git commit message. Default: "blog: {title}".' },
914
- },
915
- required: ['site_id'],
916
- },
917
- handler: async (params) => {
918
- const siteId = String(params.site_id);
919
- const sites = await listBlogSites();
920
- const site = sites.find(s => s.id === siteId);
921
- if (!site)
922
- return { error: `No blog site with id ${siteId}` };
923
- const srv = await getServerModules();
924
- // Flush any pending writes so we read fresh state
925
- try {
926
- srv.cancelDebouncedSave();
927
- srv.save();
928
- }
929
- catch { /* ignore */ }
930
- const doc = srv.getDocument();
931
- const title = srv.getTitle();
932
- const metadata = srv.getMetadata() || {};
933
- if (!doc || !doc.content)
934
- return { error: 'No active document. Switch to a document first.' };
935
- if (!title)
936
- return { error: 'Document has no title. Set a title before publishing.' };
937
- const contentType = metadata.content_type ||
938
- (metadata.tweetContext ? 'tweet'
939
- : metadata.articleContext ? 'article'
940
- : metadata.linkedinContext ? 'linkedin'
941
- : metadata.newsletterContext ? 'newsletter'
942
- : metadata.blogContext ? 'blog'
943
- : undefined);
944
- if (contentType !== 'blog') {
945
- return {
946
- 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.`,
947
- };
948
- }
949
- const blogCtx = metadata.blogContext || {};
950
- const cloneRoot = join(owRoot(), '_blog-clones');
951
- mkdirSync(cloneRoot, { recursive: true });
952
- const clonePath = join(cloneRoot, site.id);
953
- if (!(await ghAuthOk(cloneRoot))) {
954
- return { error: 'gh CLI not authenticated. Run `gh auth login` first.' };
955
- }
956
- // Clone or refresh
957
- if (!existsSync(join(clonePath, '.git'))) {
958
- if (existsSync(clonePath)) {
959
- try {
960
- rmSync(clonePath, { recursive: true, force: true });
961
- }
962
- catch { /* ignore */ }
963
- }
964
- try {
965
- await exec('gh', ['repo', 'clone', `${site.owner}/${site.repo}`, clonePath], cloneRoot);
966
- }
967
- catch (err) {
968
- return { error: `Clone failed: ${err.message}` };
969
- }
970
- try {
971
- await exec('git', ['checkout', site.branch], clonePath);
972
- }
973
- catch { /* may already be on it */ }
974
- }
975
- else {
976
- try {
977
- await exec('git', ['fetch', 'origin', site.branch], clonePath);
978
- await exec('git', ['checkout', site.branch], clonePath);
979
- await exec('git', ['reset', '--hard', `origin/${site.branch}`], clonePath);
980
- }
981
- catch (err) {
982
- return { error: `Failed to refresh clone: ${err.message}` };
983
- }
984
- }
985
- // Build markdown body
986
- const rawMd = srv.tiptapToMarkdown(doc, title, metadata);
987
- const bodyMd = stripFrontmatter(rawMd);
988
- // Slug priority: explicit param > blogContext.slug > slugified title
989
- const slug = String(params.slug || blogCtx.slug || slugify(title));
990
- if (!slug)
991
- return { error: 'Could not derive a slug from the title.' };
992
- // Rewrite inline image refs in body, collect filenames
993
- // Per-site image contract: path style governs the leading slash on
994
- // every emitted reference (cover + inline body). adr: adr/blog-image-contract.md
995
- const style = pathStyleOf(site);
996
- // Inline body images keep their source (hash) filenames — only the
997
- // path style is normalized. Deterministic slug naming is scoped to the
998
- // COVER for now (inline naming is a noted follow-up in the ADR).
999
- const imageRefs = new Set();
1000
- const bodyRewritten = bodyMd.replace(/\/_images\/([^\s)"'<>]+)/g, (_m, fn) => {
1001
- imageRefs.add(fn);
1002
- return imageRef(site.image_public_prefix, fn, style);
1003
- });
1004
- // Cover image from blogContext → deterministic `og-{slug}.{ext}` name.
1005
- // Same doc + slug ⇒ same filename every republish (idempotent
1006
- // overwrite, no orphaned cover). Source extension is preserved.
1007
- let coverImagePath;
1008
- let coverSrcFile;
1009
- let coverDestFile;
1010
- if (typeof blogCtx.coverImage === 'string' && blogCtx.coverImage) {
1011
- coverSrcFile = blogCtx.coverImage.replace(/^\/_images\//, '');
1012
- const ext = extname(coverSrcFile) || '.png';
1013
- coverDestFile = coverFilename(site.image_naming, slug, ext);
1014
- coverImagePath = imageRef(site.image_public_prefix, coverDestFile, style);
1015
- }
1016
- // Copy images
1017
- const dataDir = srv.getDataDir();
1018
- const imageDirAbs = join(clonePath, site.image_dir);
1019
- mkdirSync(imageDirAbs, { recursive: true });
1020
- let imagesCopied = 0;
1021
- // Inline images — copied under their source filename
1022
- for (const fn of imageRefs) {
1023
- const src = join(dataDir, '_images', fn);
1024
- if (!existsSync(src))
1025
- continue;
1026
- const dst = join(imageDirAbs, fn);
1027
- mkdirSync(dirname(dst), { recursive: true });
1028
- copyFileSync(src, dst);
1029
- imagesCopied++;
1030
- }
1031
- // Cover — copied under the deterministic slug-based name
1032
- if (coverSrcFile && coverDestFile) {
1033
- const src = join(dataDir, '_images', coverSrcFile);
1034
- if (existsSync(src)) {
1035
- const dst = join(imageDirAbs, coverDestFile);
1036
- mkdirSync(dirname(dst), { recursive: true });
1037
- copyFileSync(src, dst);
1038
- imagesCopied++;
1039
- }
1040
- }
1041
- // Write the post file
1042
- const frontmatter = buildFrontmatter(title, blogCtx, site, coverImagePath);
1043
- const postRel = join(site.content_dir, `${slug}.md`);
1044
- const postAbs = join(clonePath, postRel);
1045
- mkdirSync(dirname(postAbs), { recursive: true });
1046
- writeFileSync(postAbs, frontmatter + bodyRewritten + '\n', 'utf-8');
1047
- // ── PRE-COMMIT SCHEMA GATE ──────────────────────────────────────────
1048
- // Validate the built frontmatter against the TARGET SITE's own content
1049
- // schema BEFORE anything is committed. A value outside the site's
1050
- // z.enum (e.g. category "Updates" when the schema allows only
1051
- // 'Product Updates' | 'Guides' | …) otherwise sails straight to a red
1052
- // Astro build + a silent 404 — the exact failure that motivated this
1053
- // gate (live incident 2026-06-09). The schema is read from the cloned
1054
- // repo's LIVE config every publish, never a mirrored snapshot, so it
1055
- // can't drift from the site. adr: adr/blog-publish-schema-gate.md
1056
- const gate = await srv.validateBlogFrontmatter({
1057
- repoRoot: clonePath,
1058
- contentDir: site.content_dir,
1059
- frontmatter,
1060
- });
1061
- if (!gate.ok) {
1062
- // ABORT before commit/push. Nothing was committed; this working-tree
1063
- // edit stays local and is wiped by the next publish's reset --hard.
1064
- const friendly = gate.summary
1065
- || (gate.issues || []).map((i) => i.message).join('; ')
1066
- || 'frontmatter does not match the site schema';
1067
- // Human surface: a toast fires for whoever clicked Publish, over the
1068
- // existing WS path, routed to the canonical showToast() primitive.
1069
- try {
1070
- srv.broadcastToast(`Blog publish blocked: ${friendly}`, 'error');
1071
- }
1072
- catch { /* best-effort */ }
1073
- // MCP surface: structured error so the calling agent sees exactly
1074
- // what to fix and can republish.
1075
- return {
1076
- error: `Publish blocked — ${friendly}`,
1077
- validation_failed: true,
1078
- issues: gate.issues || [],
1079
- ...(gate.configPath ? { schema_config: gate.configPath } : {}),
1080
- ...(gate.collection ? { collection: gate.collection } : {}),
1081
- hint: "Fix the frontmatter to match the site's content schema, then republish. Nothing was committed or pushed.",
1082
- };
1083
- }
1084
- // Validation could not run faithfully (non-Astro repo, unparseable
1085
- // config, or a schemaless collection). NEVER a silent skip — surface
1086
- // it. For an Astro site this is a real gap (loud error); for other
1087
- // frameworks there's simply no Astro schema to check (quiet info). The
1088
- // reason always rides back on the MCP response either way.
1089
- let validationWarning;
1090
- if (gate.skipped) {
1091
- validationWarning = `Published WITHOUT schema validation — ${gate.reason}.`;
1092
- const astroExpected = site.framework === 'astro';
1093
- try {
1094
- srv.broadcastToast(astroExpected
1095
- ? `Blog published without schema check — ${gate.reason}`
1096
- : `Blog published (no Astro schema to check on this ${site.framework} site)`, astroExpected ? 'error' : 'info');
1097
- }
1098
- catch { /* best-effort */ }
1099
- }
1100
- // Commit + push (no-op is fine — still counts as a publish for the writeback)
1101
- const commitMessage = String(params.commit_message || `blog: ${title}`);
1102
- let noChanges = false;
1103
- try {
1104
- await exec('git', ['add', '-A'], clonePath);
1105
- const status = await exec('git', ['status', '--porcelain'], clonePath);
1106
- if (!status) {
1107
- noChanges = true;
1108
- }
1109
- else {
1110
- await exec('git', ['commit', '-m', commitMessage], clonePath);
1111
- await exec('git', ['push', 'origin', site.branch], clonePath);
1112
- }
1113
- }
1114
- catch (err) {
1115
- return { error: `Git op failed: ${err.message}` };
1116
- }
1117
- let shortHash = '';
1118
- try {
1119
- shortHash = await exec('git', ['rev-parse', '--short', 'HEAD'], clonePath);
1120
- }
1121
- catch { /* ignore */ }
1122
- // Construct the live URL when the site has site_url configured.
1123
- // Pattern defaults to /blog/{slug}/ — matches the convention proposed by inspect_blog_repo.
1124
- let liveUrl;
1125
- if (site.site_url) {
1126
- const pattern = site.blog_url_pattern || '/blog/{slug}/';
1127
- const path = pattern.replace('{slug}', slug);
1128
- liveUrl = site.site_url.replace(/\/+$/, '') + (path.startsWith('/') ? path : '/' + path);
1129
- }
1130
- // Mark the doc as sent so the file-tree right-click menu surfaces
1131
- // "View Post" with a live link. This mirrors the tweetContext.lastPost /
1132
- // articleContext.lastPost / newsletterContext.lastSend pattern. Runs
1133
- // even on no-op publishes — the doc is still "on the site as of now".
1134
- // adr: adr/plugin-slot-nested-data.md (writes through setMetadata which deep-merges blogContext)
1135
- let writebackWarning;
1136
- try {
1137
- srv.setMetadata({
1138
- blogContext: {
1139
- lastPublish: {
1140
- publishedAt: new Date().toISOString(),
1141
- ...(liveUrl ? { publishedUrl: liveUrl } : {}),
1142
- ...(shortHash ? { commit: shortHash } : {}),
1143
- file: postRel.replace(/\\/g, '/'),
1144
- },
1145
- },
1146
- });
1147
- // setMetadata doesn't bump docVersion on its own — without an explicit
1148
- // bump, save()→writeToDisk hits the no-op gate (docVersion === lastSavedDocVersion
1149
- // when there's no body change) and the lastPublish writeback never lands on disk.
1150
- // Same convention mcp.ts:1112 uses for active-doc metadata writes.
1151
- srv.bumpDocVersion();
1152
- srv.save();
1153
- // Notify every connected client so the file-tree "published" ✓ + the
1154
- // "Republish to Blog" context-menu label + the compose-view "Published"
1155
- // pill flip live, with no manual reload. metadata-changed updates the
1156
- // active doc's compose view; documents-changed re-reads /api/documents
1157
- // (where blogContext.lastPublish.publishedUrl → postedUrl drives the
1158
- // file tree). Mirrors the broadcast-after-setMetadata convention the
1159
- // core MCP tools follow. adr: adr/plugin-metadata-broadcast.md
1160
- srv.broadcastMetadataChanged(srv.getMetadata());
1161
- srv.broadcastDocumentsChanged();
1162
- }
1163
- catch (err) {
1164
- writebackWarning = `Published successfully, but failed to mark doc as sent: ${err.message}`;
1165
- }
1166
- return {
1167
- success: true,
1168
- file: postRel.replace(/\\/g, '/'),
1169
- commit: shortHash,
1170
- images_committed: noChanges ? 0 : imagesCopied,
1171
- // Transparency: the exact cover path written to frontmatter + the
1172
- // filename it shipped as, so the agent/user sees what landed.
1173
- ...(coverImagePath ? { image: coverImagePath, cover_file: coverDestFile } : {}),
1174
- live_url: liveUrl,
1175
- // Transparency on the happy path: the schema gate ran and passed (or
1176
- // was loudly skipped). adr: adr/blog-publish-schema-gate.md
1177
- validated: !gate.skipped,
1178
- ...(gate.configPath ? { schema_config: gate.configPath } : {}),
1179
- ...(gate.collection ? { schema_collection: gate.collection } : {}),
1180
- message: noChanges
1181
- ? 'No changes — file already up to date. Doc marked as sent.'
1182
- : `Pushed to ${site.owner}/${site.repo}@${site.branch}`,
1183
- ...(validationWarning ? { validation_warning: validationWarning } : {}),
1184
- ...(writebackWarning ? { warning: writebackWarning } : {}),
1185
- };
1186
- },
1187
- },
1188
- ];
1189
- }