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.
- package/dist/client/assets/index-BtCWWQrZ.css +1 -0
- package/dist/client/assets/{index-DHaZI7nA.js → index-D1naX68L.js} +52 -52
- package/dist/client/index.html +2 -2
- package/dist/plugins/authors-voice/dist/index.d.ts +5 -1
- package/dist/plugins/authors-voice/dist/index.js +24 -1
- package/dist/plugins/github/dist/blog-tools.d.ts +58 -1
- package/dist/plugins/github/dist/blog-tools.js +337 -17
- package/dist/plugins/github/dist/helpers.d.ts +23 -0
- package/dist/plugins/github/dist/helpers.js +2 -0
- package/dist/plugins/publish/dist/index.js +6 -1
- package/package.json +3 -2
- package/dist/client/assets/index-Gdw1m46J.css +0 -1
package/dist/client/index.html
CHANGED
|
@@ -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-
|
|
14
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
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
|
|
651
|
+
const sampleData = [];
|
|
467
652
|
for (const f of sampleFiles) {
|
|
468
653
|
try {
|
|
469
|
-
|
|
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
|
-
|
|
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:
|
|
486
|
-
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:
|
|
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: '
|
|
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 {
|
|
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
|
-
|
|
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
|
|
984
|
+
return imageRef(site.image_public_prefix, fn, style);
|
|
693
985
|
});
|
|
694
|
-
//
|
|
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
|
-
|
|
698
|
-
|
|
699
|
-
|
|
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.'
|