openwriter 0.29.1 → 0.30.0

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.
@@ -10,8 +10,8 @@
10
10
  <link rel="preconnect" href="https://fonts.googleapis.com" />
11
11
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
12
12
  <link href="https://fonts.googleapis.com/css2?family=Charter:ital,wght@0,400;0,700;1,400&family=Crimson+Pro:ital,wght@0,300;0,400;0,600;0,700;1,400&family=DM+Sans:ital,wght@0,400;0,500;0,600;0,700;1,400&family=DM+Serif+Display&family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600&family=Inter:wght@400;500;600;700&family=Libre+Baskerville:ital,wght@0,400;0,700;1,400&family=Literata:ital,opsz,wght@0,7..72,400;0,7..72,600;0,7..72,700;1,7..72,400&family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,600;1,6..72,400&family=Playfair+Display:wght@400;600;700;900&family=Source+Serif+4:ital,opsz,wght@0,8..60,400;0,8..60,600;0,8..60,700;1,8..60,400&family=Space+Grotesk:wght@400;500;600;700&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet" />
13
- <script type="module" crossorigin src="/assets/index-DHaZI7nA.js"></script>
14
- <link rel="stylesheet" crossorigin href="/assets/index-Gdw1m46J.css">
13
+ <script type="module" crossorigin src="/assets/index-D1naX68L.js"></script>
14
+ <link rel="stylesheet" crossorigin href="/assets/index-BtCWWQrZ.css">
15
15
  </head>
16
16
  <body>
17
17
  <div id="root"></div>
@@ -8,10 +8,14 @@
8
8
  */
9
9
  import type { Express } from 'express';
10
10
  interface PluginConfigField {
11
- type: 'string' | 'number' | 'boolean';
11
+ type: 'string' | 'number' | 'boolean' | 'select';
12
12
  required?: boolean;
13
13
  env?: string;
14
14
  description?: string;
15
+ options?: Array<{
16
+ value: string;
17
+ label: string;
18
+ }>;
15
19
  }
16
20
  interface PluginRouteContext {
17
21
  app: Express;
@@ -23,11 +23,27 @@ const plugin = {
23
23
  env: 'AV_BACKEND_URL',
24
24
  description: 'AV backend URL',
25
25
  },
26
+ // Model tier for rewrites. Sent as `modelTier` on /api/voice/* (the AV API owns the
27
+ // tier→model map + default — this is a pure pass-through of the user's pick). Blank =
28
+ // API default (strongest). Labels mirror the API's TIER_PICKER_OPTIONS.
29
+ 'model': {
30
+ type: 'select',
31
+ env: 'AV_MODEL_TIER',
32
+ description: 'Writing model',
33
+ options: [
34
+ { value: '', label: 'Default (Strongest)' },
35
+ { value: 'strongest', label: 'Strongest — Claude Opus (best quality)' },
36
+ { value: 'gemini-pro', label: 'Gemini Pro — flagship, strong + cheap' },
37
+ { value: 'balanced', label: 'Balanced — Claude Sonnet' },
38
+ { value: 'fast', label: 'Fast — Gemini Flash (cheapest)' },
39
+ ],
40
+ },
26
41
  },
27
42
  registerRoutes(ctx) {
28
43
  const backendUrl = ctx.config['backend-url'] || process.env.AV_BACKEND_URL || 'https://authors-voice.com';
29
44
  const apiKey = ctx.config['api-key'] || process.env.AV_API_KEY || '';
30
45
  const debugEnabled = process.env.AV_DEBUG === '1' || process.env.AV_DEBUG === 'true';
46
+ const modelTier = ctx.config['model'] || process.env.AV_MODEL_TIER || '';
31
47
  const authHeaders = () => {
32
48
  const h = { 'Content-Type': 'application/json' };
33
49
  if (apiKey)
@@ -39,6 +55,13 @@ const plugin = {
39
55
  return body;
40
56
  return { ...body, debug: true };
41
57
  };
58
+ // Inject the user's model-tier pick. Pure pass-through: the AV API validates the slug
59
+ // and falls back to its own default if absent/unknown. Blank → nothing injected.
60
+ const withModel = (body) => {
61
+ if (!modelTier || !body || typeof body !== 'object')
62
+ return body;
63
+ return { ...body, modelTier };
64
+ };
42
65
  // Wildcard proxy for /api/voice/* routes. Pure pass-through: the AV API owns the
43
66
  // engine choice (v1/v2) via its own AV_DEFAULT_ENGINE setting, so the plugin injects
44
67
  // nothing but the optional owner-only dev debug flag.
@@ -46,7 +69,7 @@ const plugin = {
46
69
  try {
47
70
  const subPath = req.params[0] || '';
48
71
  const targetUrl = `${backendUrl}/api/voice/${subPath}`;
49
- const body = withDebug(req.body);
72
+ const body = withModel(withDebug(req.body));
50
73
  console.log(`[AV Plugin] ${req.method} ${req.path} → ${targetUrl}`);
51
74
  const upstream = await fetch(targetUrl, {
52
75
  method: 'POST',
@@ -4,5 +4,62 @@
4
4
  * Auth: piggybacks on the user's existing `gh auth login`.
5
5
  * Persistence: blogSites in plugins['@openwriter/plugin-github'].blogSites.
6
6
  */
7
- import { type PluginMcpTool } from './helpers.js';
7
+ import { type BlogSite, type PluginMcpTool } from './helpers.js';
8
+ /**
9
+ * Derive the per-site image contract from sampled posts:
10
+ * - `image_field` — which frontmatter key holds the cover
11
+ * - `image_path_style` — do values carry a leading slash?
12
+ * - `image_public_prefix`— the dominant directory the images live under
13
+ * (relative, no leading slash)
14
+ * - `image_naming` — `og-{slug}` vs `{slug}` filename convention
15
+ * (detected by the dominant basename shape)
16
+ * Conservative: only the dominant local-path field is analyzed; full URLs
17
+ * are ignored. Anything not confidently detected is left undefined so
18
+ * post_to_blog falls back to its documented defaults.
19
+ */
20
+ export declare function inferImageConventions(samples: Array<{
21
+ fm: Record<string, string>;
22
+ slug: string;
23
+ }>): {
24
+ image_field?: string;
25
+ image_path_style?: 'relative' | 'absolute';
26
+ image_public_prefix?: string;
27
+ image_naming?: string;
28
+ };
29
+ /** Site's effective path style. Absent ⇒ legacy "absolute". */
30
+ export declare function pathStyleOf(site: Pick<BlogSite, 'image_path_style'>): 'relative' | 'absolute';
31
+ /**
32
+ * Public reference for one image file under the site's prefix, honoring the
33
+ * site's path style. The prefix is normalized (leading + trailing slashes
34
+ * stripped) so storage is style-agnostic — `style` alone decides the leading
35
+ * slash, which makes the contract unambiguous regardless of how the prefix
36
+ * was saved (`/images/og` vs `images/og` both behave identically).
37
+ * relative → "images/og/x.png" absolute → "/images/og/x.png"
38
+ */
39
+ export declare function imageRef(publicPrefix: string, file: string, style: 'relative' | 'absolute'): string;
40
+ /**
41
+ * Resolve the deterministic cover filename from the site's naming template.
42
+ * `{slug}` → post slug
43
+ * `{ext}` → source extension, no dot (preserved from the original)
44
+ * A template carrying a literal extension and no `{ext}` placeholder
45
+ * (e.g. `og-{slug}.png`) is respected as authored. Absent template ⇒
46
+ * `og-{slug}.{ext}`.
47
+ */
48
+ export declare function coverFilename(template: string | undefined, slug: string, sourceExt: string): string;
49
+ /**
50
+ * Build the YAML frontmatter from blogContext + site defaults.
51
+ *
52
+ * Order of precedence (low → high):
53
+ * 1. Site `frontmatter_defaults` (e.g. `layout`, `author`, `prerender`)
54
+ * 2. Generated `title` (from document title — always present)
55
+ * 3. blogContext fields (description, date, author, tags, slug, draft, coverImage)
56
+ *
57
+ * Field-name mapping: blogContext keys are renamed via `site.frontmatter_field_map`
58
+ * before emit (e.g. `date` → `publishedDate` for Astro sites).
59
+ *
60
+ * Top-level openwriter metadata (status, enrichmentStale, tags-as-content-type,
61
+ * etc.) is NEVER passed through. Frontmatter is built ONLY from blogContext +
62
+ * defaults — this is the design contract from server/blog-routes.ts.
63
+ */
64
+ export declare function buildFrontmatter(title: string, blogCtx: Record<string, any>, site: BlogSite, coverImagePath?: string): string;
8
65
  export declare function blogTools(): PluginMcpTool[];
@@ -6,7 +6,7 @@
6
6
  */
7
7
  import { execFile } from 'child_process';
8
8
  import { existsSync, mkdirSync, readFileSync, copyFileSync, writeFileSync, readdirSync, statSync, rmSync } from 'fs';
9
- import { join, extname, dirname } from 'path';
9
+ import { join, extname, dirname, basename } from 'path';
10
10
  import { randomUUID } from 'crypto';
11
11
  import { homedir } from 'os';
12
12
  import { getServerModules, listBlogSites, writeBlogSites, } from './helpers.js';
@@ -31,6 +31,33 @@ async function ghAuthOk(cwd) {
31
31
  return false;
32
32
  }
33
33
  }
34
+ /**
35
+ * Last-resort site-URL detection via the GitHub Pages API. `inferSiteUrl`
36
+ * only reads files committed to the repo (CNAME, wrangler route); this
37
+ * catches GitHub Pages sites whose served URL — custom domain or the
38
+ * `<owner>.github.io/<repo>` default — lives in Pages settings, not a file.
39
+ * Returns the canonical served base URL (trailing slash stripped), or
40
+ * undefined when Pages is not enabled / not accessible. Credential-free
41
+ * (rides the existing `gh auth`); other hosts (Cloudflare Pages, Vercel,
42
+ * Netlify) configure the domain in their dashboard and can't be derived
43
+ * here — those rely on the user supplying site_url.
44
+ */
45
+ async function inferSiteUrlFromGitHubPages(owner, repo, cwd) {
46
+ try {
47
+ const out = await exec('gh', ['api', `repos/${owner}/${repo}/pages`, '--jq', '.html_url'], cwd);
48
+ const url = out.split('\n')[0].trim();
49
+ if (/^https?:\/\//i.test(url))
50
+ return url.replace(/\/+$/, '');
51
+ }
52
+ catch { /* Pages not enabled, or no access — fall through */ }
53
+ return undefined;
54
+ }
55
+ // Shared hint surfaced when site_url couldn't be determined, so the agent
56
+ // knows to ask the user rather than silently shipping posts with no live link.
57
+ const SITE_URL_HINT = 'Could not auto-detect the public site URL (no CNAME, wrangler route, or GitHub Pages config — ' +
58
+ 'common for Cloudflare Pages / Vercel / Netlify sites whose domain lives in the host dashboard). ' +
59
+ 'Ask the user for the public base URL (e.g. https://example.com) and set it via site_url. ' +
60
+ 'Without it, published posts get no clickable "View Post" link (only the commit/file).';
34
61
  function slugify(s) {
35
62
  return s.toLowerCase().replace(/['"]/g, '').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 80);
36
63
  }
@@ -207,6 +234,121 @@ function inferFrontmatterShape(rawSamples) {
207
234
  }
208
235
  return { defaults, field_map, schema: schemaOrder };
209
236
  }
237
+ // ---- Image-contract inference (inspect_blog_repo) ----
238
+ // adr: adr/blog-image-contract.md
239
+ const IMAGE_FIELD_CANDIDATES = [
240
+ 'image', 'coverImage', 'cover', 'ogImage', 'heroImage', 'featuredImage', 'thumbnail', 'banner',
241
+ ];
242
+ const IMAGE_VALUE_RE = /\.(png|jpe?g|webp|gif|avif|svg)\b/i;
243
+ function unquoteScalar(s) {
244
+ const m = s.match(/^["'](.*)["']$/);
245
+ return m ? m[1] : s;
246
+ }
247
+ /**
248
+ * Pick the frontmatter key that holds the post's cover image. Prefers the
249
+ * conventional names in order; the value must look like a local image path
250
+ * (carries an image extension). Falls back to any field whose value does.
251
+ */
252
+ function pickImageField(fm) {
253
+ for (const k of IMAGE_FIELD_CANDIDATES) {
254
+ const raw = fm[k];
255
+ if (!raw || raw === '<multiline>')
256
+ continue;
257
+ const v = unquoteScalar(raw);
258
+ if (IMAGE_VALUE_RE.test(v))
259
+ return { key: k, value: v };
260
+ }
261
+ for (const [k, raw] of Object.entries(fm)) {
262
+ if (raw === '<multiline>')
263
+ continue;
264
+ const v = unquoteScalar(raw);
265
+ if (IMAGE_VALUE_RE.test(v))
266
+ return { key: k, value: v };
267
+ }
268
+ return null;
269
+ }
270
+ /**
271
+ * Derive the per-site image contract from sampled posts:
272
+ * - `image_field` — which frontmatter key holds the cover
273
+ * - `image_path_style` — do values carry a leading slash?
274
+ * - `image_public_prefix`— the dominant directory the images live under
275
+ * (relative, no leading slash)
276
+ * - `image_naming` — `og-{slug}` vs `{slug}` filename convention
277
+ * (detected by the dominant basename shape)
278
+ * Conservative: only the dominant local-path field is analyzed; full URLs
279
+ * are ignored. Anything not confidently detected is left undefined so
280
+ * post_to_blog falls back to its documented defaults.
281
+ */
282
+ export function inferImageConventions(samples) {
283
+ const hits = [];
284
+ for (const s of samples) {
285
+ const pick = pickImageField(s.fm);
286
+ if (pick)
287
+ hits.push({ ...pick, slug: s.slug });
288
+ }
289
+ if (hits.length === 0)
290
+ return {};
291
+ // Dominant cover field name
292
+ const fieldCounts = new Map();
293
+ for (const h of hits)
294
+ fieldCounts.set(h.key, (fieldCounts.get(h.key) || 0) + 1);
295
+ const image_field = [...fieldCounts.entries()].sort((a, b) => b[1] - a[1])[0][0];
296
+ // Only local paths of the dominant field tell us about the path contract
297
+ const local = hits.filter((h) => h.key === image_field && !/^https?:\/\//i.test(h.value) && !h.value.startsWith('//'));
298
+ if (local.length === 0)
299
+ return { image_field };
300
+ // Path style: majority leading-slash ⇒ absolute
301
+ const abs = local.filter((h) => h.value.startsWith('/')).length;
302
+ const image_path_style = abs > local.length / 2 ? 'absolute' : 'relative';
303
+ // Public prefix: dominant directory portion (no leading/trailing slash)
304
+ const dirCounts = new Map();
305
+ for (const h of local) {
306
+ const noSlash = h.value.replace(/^\/+/, '');
307
+ const dir = noSlash.includes('/') ? noSlash.slice(0, noSlash.lastIndexOf('/')) : '';
308
+ dirCounts.set(dir, (dirCounts.get(dir) || 0) + 1);
309
+ }
310
+ const topDir = [...dirCounts.entries()].sort((a, b) => b[1] - a[1])[0][0];
311
+ const image_public_prefix = topDir || undefined;
312
+ // Naming convention by dominant basename shape + extension
313
+ let ogCount = 0;
314
+ let slugCount = 0;
315
+ const extCounts = new Map();
316
+ for (const h of local) {
317
+ const base = h.value.replace(/^.*\//, '');
318
+ const ext = (base.match(/\.([a-z0-9]+)$/i)?.[1] || 'png').toLowerCase();
319
+ extCounts.set(ext, (extCounts.get(ext) || 0) + 1);
320
+ const stem = base.replace(/\.[a-z0-9]+$/i, '');
321
+ if (stem === h.slug)
322
+ slugCount++;
323
+ else if (stem.startsWith('og-'))
324
+ ogCount++;
325
+ }
326
+ const dominantExt = [...extCounts.entries()].sort((a, b) => b[1] - a[1])[0][0];
327
+ let image_naming;
328
+ if (slugCount > local.length / 2)
329
+ image_naming = `{slug}.${dominantExt}`;
330
+ else if (ogCount > local.length / 2)
331
+ image_naming = `og-{slug}.${dominantExt}`;
332
+ // else: undefined ⇒ post_to_blog default (og-{slug}.{ext})
333
+ return { image_field, image_path_style, image_public_prefix, image_naming };
334
+ }
335
+ /**
336
+ * Map an inferred (relative) public image prefix to the on-disk image_dir
337
+ * for the framework's static-asset root.
338
+ */
339
+ function imageDirForFramework(fw, prefix) {
340
+ switch (fw) {
341
+ case 'astro':
342
+ case 'next':
343
+ return `public/${prefix}`;
344
+ case 'hugo':
345
+ return `static/${prefix}`;
346
+ case 'jekyll':
347
+ return prefix; // served from repo root
348
+ default:
349
+ return `public/${prefix}`;
350
+ }
351
+ }
210
352
  /**
211
353
  * Detect the site's public URL from common static-host conventions:
212
354
  * - `CNAME` at repo root or `public/CNAME` (GitHub Pages / Cloudflare Pages / Netlify)
@@ -320,6 +462,49 @@ function formatDate(v) {
320
462
  const m = v.match(/^(\d{4}-\d{2}-\d{2})/);
321
463
  return m ? m[1] : v;
322
464
  }
465
+ // ---- Per-site image contract (path style + cover naming) ----
466
+ // adr: adr/blog-image-contract.md
467
+ //
468
+ // The image reference is a PER-SITE CONTRACT, not a global assumption. Two
469
+ // dimensions, both stored on BlogSite and inferred by inspect_blog_repo:
470
+ // 1. path style — does the value carry a leading slash, or does the site's
471
+ // template prepend one? (`image_path_style`)
472
+ // 2. cover filename — deterministic `og-{slug}.{ext}`, never the raw
473
+ // `/_images/` name, so republish is idempotent and never orphans.
474
+ // (`image_naming`)
475
+ // Absent keys ⇒ legacy behavior ("absolute", raw name) so already-correct
476
+ // sites never regress.
477
+ /** Site's effective path style. Absent ⇒ legacy "absolute". */
478
+ export function pathStyleOf(site) {
479
+ return site.image_path_style === 'relative' ? 'relative' : 'absolute';
480
+ }
481
+ /**
482
+ * Public reference for one image file under the site's prefix, honoring the
483
+ * site's path style. The prefix is normalized (leading + trailing slashes
484
+ * stripped) so storage is style-agnostic — `style` alone decides the leading
485
+ * slash, which makes the contract unambiguous regardless of how the prefix
486
+ * was saved (`/images/og` vs `images/og` both behave identically).
487
+ * relative → "images/og/x.png" absolute → "/images/og/x.png"
488
+ */
489
+ export function imageRef(publicPrefix, file, style) {
490
+ const seg = (publicPrefix || '').replace(/^\/+/, '').replace(/\/+$/, '');
491
+ const rel = seg ? `${seg}/${file}` : file;
492
+ return style === 'absolute' ? `/${rel}` : rel;
493
+ }
494
+ /**
495
+ * Resolve the deterministic cover filename from the site's naming template.
496
+ * `{slug}` → post slug
497
+ * `{ext}` → source extension, no dot (preserved from the original)
498
+ * A template carrying a literal extension and no `{ext}` placeholder
499
+ * (e.g. `og-{slug}.png`) is respected as authored. Absent template ⇒
500
+ * `og-{slug}.{ext}`.
501
+ */
502
+ export function coverFilename(template, slug, sourceExt) {
503
+ const ext = sourceExt.replace(/^\.+/, '');
504
+ return (template || 'og-{slug}.{ext}')
505
+ .replace(/\{slug\}/g, slug)
506
+ .replace(/\{ext\}/g, ext);
507
+ }
323
508
  /**
324
509
  * Build the YAML frontmatter from blogContext + site defaults.
325
510
  *
@@ -335,7 +520,7 @@ function formatDate(v) {
335
520
  * etc.) is NEVER passed through. Frontmatter is built ONLY from blogContext +
336
521
  * defaults — this is the design contract from server/blog-routes.ts.
337
522
  */
338
- function buildFrontmatter(title, blogCtx, site, coverImagePath) {
523
+ export function buildFrontmatter(title, blogCtx, site, coverImagePath) {
339
524
  const fm = {};
340
525
  const map = site.frontmatter_field_map || {};
341
526
  // 1. Site defaults (lowest priority — overridable below)
@@ -463,33 +648,60 @@ export function blogTools() {
463
648
  return (detected ? rel.startsWith(detected + '/') : true) && /\.(md|mdx)$/i.test(rel);
464
649
  })
465
650
  .slice(0, 10);
466
- const rawSamples = [];
651
+ const sampleData = [];
467
652
  for (const f of sampleFiles) {
468
653
  try {
469
- rawSamples.push(parseYamlFrontmatter(readFileSync(f, 'utf-8')));
654
+ const fm = parseYamlFrontmatter(readFileSync(f, 'utf-8'));
655
+ const slug = basename(f).replace(/\.(md|mdx)$/i, '');
656
+ sampleData.push({ fm, slug });
470
657
  }
471
658
  catch { /* skip */ }
472
659
  }
660
+ const rawSamples = sampleData.map((s) => s.fm);
473
661
  const samplesAfterFilter = rawSamples.filter((s) => !looksLikeOpenwriterLeak(s));
474
662
  const samplesSkipped = rawSamples.length - samplesAfterFilter.length;
475
663
  const shape = inferFrontmatterShape(rawSamples);
664
+ // Per-site image contract — inferred from real posts (path style, the
665
+ // directory images live under, og-{slug} vs {slug} naming, and which
666
+ // frontmatter field holds the cover). Leak samples excluded so the
667
+ // old plugin's emit doesn't poison detection. adr: adr/blog-image-contract.md
668
+ const conv = inferImageConventions(sampleData.filter((s) => !looksLikeOpenwriterLeak(s.fm)));
669
+ const imagePublicPrefix = conv.image_public_prefix ?? defaults.image_public_prefix;
670
+ const imageDir = conv.image_public_prefix
671
+ ? imageDirForFramework(framework, conv.image_public_prefix)
672
+ : defaults.image_dir;
673
+ const imagePathStyle = conv.image_path_style ?? 'absolute';
674
+ const imageNaming = conv.image_naming ?? 'og-{slug}.{ext}';
675
+ // Cover field-name mapping (e.g. site uses `image:` not `coverImage:`)
676
+ const fieldMap = { ...shape.field_map };
677
+ if (conv.image_field && conv.image_field !== 'coverImage') {
678
+ fieldMap.coverImage = conv.image_field;
679
+ }
476
680
  const confidence = framework !== 'unknown' && detected ? 'high'
477
681
  : detected ? 'medium'
478
682
  : 'low';
479
- const siteUrl = inferSiteUrl(cloneDir);
683
+ // site_url: prefer files committed to the repo (CNAME / wrangler route),
684
+ // then fall back to the GitHub Pages API for GH-hosted sites whose served
685
+ // URL lives in Pages settings rather than a committed file.
686
+ const siteUrl = inferSiteUrl(cloneDir) || await inferSiteUrlFromGitHubPages(owner, repo, cacheRoot);
480
687
  return {
481
688
  owner,
482
689
  repo,
483
690
  framework,
484
691
  content_dir: defaults.content_dir,
485
- image_dir: defaults.image_dir,
486
- image_public_prefix: defaults.image_public_prefix,
692
+ image_dir: imageDir,
693
+ image_public_prefix: imagePublicPrefix,
694
+ image_path_style: imagePathStyle,
695
+ image_naming: imageNaming,
487
696
  frontmatter_schema: shape.schema,
488
697
  frontmatter_defaults: shape.defaults,
489
- frontmatter_field_map: shape.field_map,
698
+ frontmatter_field_map: fieldMap,
490
699
  // Always propose a pattern even when site_url is unknown so the user can fill in the URL
491
700
  site_url: siteUrl,
492
701
  blog_url_pattern: '/blog/{slug}/',
702
+ // When site_url couldn't be derived, tell the agent to ask the user —
703
+ // otherwise it ships posts with no live "View Post" link, silently.
704
+ ...(siteUrl ? {} : { needs_site_url: true, site_url_hint: SITE_URL_HINT }),
493
705
  samples_analyzed: samplesAfterFilter.length,
494
706
  samples_skipped_openwriter_leak: samplesSkipped,
495
707
  markdown_files_found: mdBest?.count ?? 0,
@@ -509,7 +721,9 @@ export function blogTools() {
509
721
  branch: { type: 'string', description: 'Branch to push to (default: main)' },
510
722
  content_dir: { type: 'string', description: 'Directory where post .md files live (e.g. "src/content/blog")' },
511
723
  image_dir: { type: 'string', description: 'Directory where image files write (e.g. "public/blog-images")' },
512
- image_public_prefix: { type: 'string', description: 'URL prefix for images in markdown (e.g. "/blog-images")' },
724
+ 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.' },
725
+ 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).' },
726
+ 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).' },
513
727
  framework: { type: 'string', enum: ['astro', 'next', 'jekyll', 'hugo', 'unknown'], description: 'Site framework' },
514
728
  frontmatter_defaults: {
515
729
  type: 'object',
@@ -526,7 +740,7 @@ export function blogTools() {
526
740
  },
527
741
  site_url: {
528
742
  type: 'string',
529
- description: 'Public base URL of the site (e.g. "https://example.com"). Used to construct the live URL surfaced after publish. inspect_blog_repo proposes this from CNAME / wrangler.toml when found.',
743
+ 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.',
530
744
  },
531
745
  blog_url_pattern: {
532
746
  type: 'string',
@@ -553,6 +767,12 @@ export function blogTools() {
553
767
  if (params.frontmatter_field_map && typeof params.frontmatter_field_map === 'object') {
554
768
  site.frontmatter_field_map = params.frontmatter_field_map;
555
769
  }
770
+ if (params.image_path_style === 'relative' || params.image_path_style === 'absolute') {
771
+ site.image_path_style = params.image_path_style;
772
+ }
773
+ if (typeof params.image_naming === 'string' && params.image_naming.trim()) {
774
+ site.image_naming = params.image_naming.trim();
775
+ }
556
776
  if (Array.isArray(params.frontmatter_schema)) {
557
777
  site.frontmatter_schema = params.frontmatter_schema.map(String);
558
778
  }
@@ -565,7 +785,12 @@ export function blogTools() {
565
785
  const sites = await listBlogSites();
566
786
  sites.push(site);
567
787
  await writeBlogSites(sites);
568
- return { success: true, site };
788
+ return {
789
+ success: true,
790
+ site,
791
+ // No site_url ⇒ no live link on publish. Tell the agent to follow up.
792
+ ...(site.site_url ? {} : { needs_site_url: true, site_url_hint: SITE_URL_HINT }),
793
+ };
569
794
  },
570
795
  },
571
796
  {
@@ -597,6 +822,68 @@ export function blogTools() {
597
822
  return { success: true, removed: id };
598
823
  },
599
824
  },
825
+ {
826
+ name: 'edit_blog_site',
827
+ 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).',
828
+ inputSchema: {
829
+ type: 'object',
830
+ properties: {
831
+ id: { type: 'string', description: 'Blog site id (from list_blog_sites)' },
832
+ label: { type: 'string', description: 'User-facing name' },
833
+ branch: { type: 'string', description: 'Branch to push to' },
834
+ content_dir: { type: 'string', description: 'Directory where post .md files live' },
835
+ image_dir: { type: 'string', description: 'Directory where image files write' },
836
+ image_public_prefix: { type: 'string', description: 'Directory prefix images live under' },
837
+ image_path_style: { type: 'string', enum: ['relative', 'absolute'], description: 'How image paths are written' },
838
+ image_naming: { type: 'string', description: 'Cover filename template with `{slug}` + `{ext}`' },
839
+ framework: { type: 'string', enum: ['astro', 'next', 'jekyll', 'hugo', 'unknown'], description: 'Site framework' },
840
+ frontmatter_defaults: { type: 'object', description: 'Constants applied to every post\'s frontmatter' },
841
+ frontmatter_field_map: { type: 'object', description: 'Rename map: openwriter blogContext key → site frontmatter key' },
842
+ frontmatter_schema: { type: 'array', items: { type: 'string' }, description: 'List of frontmatter keys the site uses' },
843
+ site_url: { type: 'string', description: 'Public base URL (e.g. "https://example.com"). Pass an empty string to clear it.' },
844
+ blog_url_pattern: { type: 'string', description: 'URL path pattern with `{slug}` placeholder (e.g. "/blog/{slug}/").' },
845
+ },
846
+ required: ['id'],
847
+ },
848
+ handler: async (params) => {
849
+ const id = String(params.id);
850
+ const sites = await listBlogSites();
851
+ const site = sites.find(s => s.id === id);
852
+ if (!site)
853
+ return { error: `No blog site with id ${id}` };
854
+ // Plain string fields — set when a non-empty string is provided.
855
+ for (const key of ['label', 'branch', 'content_dir', 'image_dir', 'image_public_prefix', 'image_naming', 'blog_url_pattern']) {
856
+ if (typeof params[key] === 'string' && params[key].trim()) {
857
+ site[key] = params[key].trim();
858
+ }
859
+ }
860
+ // site_url: trim + strip trailing slash; empty string clears it.
861
+ if (typeof params.site_url === 'string') {
862
+ const v = params.site_url.trim().replace(/\/+$/, '');
863
+ if (v)
864
+ site.site_url = v;
865
+ else
866
+ delete site.site_url;
867
+ }
868
+ if (params.image_path_style === 'relative' || params.image_path_style === 'absolute') {
869
+ site.image_path_style = params.image_path_style;
870
+ }
871
+ if (params.framework && ['astro', 'next', 'jekyll', 'hugo', 'unknown'].includes(String(params.framework))) {
872
+ site.framework = params.framework;
873
+ }
874
+ if (params.frontmatter_defaults && typeof params.frontmatter_defaults === 'object') {
875
+ site.frontmatter_defaults = params.frontmatter_defaults;
876
+ }
877
+ if (params.frontmatter_field_map && typeof params.frontmatter_field_map === 'object') {
878
+ site.frontmatter_field_map = params.frontmatter_field_map;
879
+ }
880
+ if (Array.isArray(params.frontmatter_schema)) {
881
+ site.frontmatter_schema = params.frontmatter_schema.map(String);
882
+ }
883
+ await writeBlogSites(sites);
884
+ return { success: true, site };
885
+ },
886
+ },
600
887
  {
601
888
  name: 'post_to_blog',
602
889
  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`.',
@@ -685,24 +972,35 @@ export function blogTools() {
685
972
  if (!slug)
686
973
  return { error: 'Could not derive a slug from the title.' };
687
974
  // Rewrite inline image refs in body, collect filenames
688
- const imgPrefix = site.image_public_prefix.replace(/\/+$/, '');
975
+ // Per-site image contract: path style governs the leading slash on
976
+ // every emitted reference (cover + inline body). adr: adr/blog-image-contract.md
977
+ const style = pathStyleOf(site);
978
+ // Inline body images keep their source (hash) filenames — only the
979
+ // path style is normalized. Deterministic slug naming is scoped to the
980
+ // COVER for now (inline naming is a noted follow-up in the ADR).
689
981
  const imageRefs = new Set();
690
982
  const bodyRewritten = bodyMd.replace(/\/_images\/([^\s)"'<>]+)/g, (_m, fn) => {
691
983
  imageRefs.add(fn);
692
- return `${imgPrefix}/${fn}`;
984
+ return imageRef(site.image_public_prefix, fn, style);
693
985
  });
694
- // Handle cover image from blogContext
986
+ // Cover image from blogContext → deterministic `og-{slug}.{ext}` name.
987
+ // Same doc + slug ⇒ same filename every republish (idempotent
988
+ // overwrite, no orphaned cover). Source extension is preserved.
695
989
  let coverImagePath;
990
+ let coverSrcFile;
991
+ let coverDestFile;
696
992
  if (typeof blogCtx.coverImage === 'string' && blogCtx.coverImage) {
697
- const coverFile = blogCtx.coverImage.replace(/^\/_images\//, '');
698
- imageRefs.add(coverFile);
699
- coverImagePath = `${imgPrefix}/${coverFile}`;
993
+ coverSrcFile = blogCtx.coverImage.replace(/^\/_images\//, '');
994
+ const ext = extname(coverSrcFile) || '.png';
995
+ coverDestFile = coverFilename(site.image_naming, slug, ext);
996
+ coverImagePath = imageRef(site.image_public_prefix, coverDestFile, style);
700
997
  }
701
998
  // Copy images
702
999
  const dataDir = srv.getDataDir();
703
1000
  const imageDirAbs = join(clonePath, site.image_dir);
704
1001
  mkdirSync(imageDirAbs, { recursive: true });
705
1002
  let imagesCopied = 0;
1003
+ // Inline images — copied under their source filename
706
1004
  for (const fn of imageRefs) {
707
1005
  const src = join(dataDir, '_images', fn);
708
1006
  if (!existsSync(src))
@@ -712,6 +1010,16 @@ export function blogTools() {
712
1010
  copyFileSync(src, dst);
713
1011
  imagesCopied++;
714
1012
  }
1013
+ // Cover — copied under the deterministic slug-based name
1014
+ if (coverSrcFile && coverDestFile) {
1015
+ const src = join(dataDir, '_images', coverSrcFile);
1016
+ if (existsSync(src)) {
1017
+ const dst = join(imageDirAbs, coverDestFile);
1018
+ mkdirSync(dirname(dst), { recursive: true });
1019
+ copyFileSync(src, dst);
1020
+ imagesCopied++;
1021
+ }
1022
+ }
715
1023
  // Write the post file
716
1024
  const frontmatter = buildFrontmatter(title, blogCtx, site, coverImagePath);
717
1025
  const postRel = join(site.content_dir, `${slug}.md`);
@@ -771,6 +1079,15 @@ export function blogTools() {
771
1079
  // Same convention mcp.ts:1112 uses for active-doc metadata writes.
772
1080
  srv.bumpDocVersion();
773
1081
  srv.save();
1082
+ // Notify every connected client so the file-tree "published" ✓ + the
1083
+ // "Republish to Blog" context-menu label + the compose-view "Published"
1084
+ // pill flip live, with no manual reload. metadata-changed updates the
1085
+ // active doc's compose view; documents-changed re-reads /api/documents
1086
+ // (where blogContext.lastPublish.publishedUrl → postedUrl drives the
1087
+ // file tree). Mirrors the broadcast-after-setMetadata convention the
1088
+ // core MCP tools follow. adr: adr/plugin-metadata-broadcast.md
1089
+ srv.broadcastMetadataChanged(srv.getMetadata());
1090
+ srv.broadcastDocumentsChanged();
774
1091
  }
775
1092
  catch (err) {
776
1093
  writebackWarning = `Published successfully, but failed to mark doc as sent: ${err.message}`;
@@ -780,6 +1097,9 @@ export function blogTools() {
780
1097
  file: postRel.replace(/\\/g, '/'),
781
1098
  commit: shortHash,
782
1099
  images_committed: noChanges ? 0 : imagesCopied,
1100
+ // Transparency: the exact cover path written to frontmatter + the
1101
+ // filename it shipped as, so the agent/user sees what landed.
1102
+ ...(coverImagePath ? { image: coverImagePath, cover_file: coverDestFile } : {}),
783
1103
  live_url: liveUrl,
784
1104
  message: noChanges
785
1105
  ? 'No changes — file already up to date. Doc marked as sent.'