openwriter 0.29.1 → 0.29.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -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';
|
|
@@ -207,6 +207,121 @@ function inferFrontmatterShape(rawSamples) {
|
|
|
207
207
|
}
|
|
208
208
|
return { defaults, field_map, schema: schemaOrder };
|
|
209
209
|
}
|
|
210
|
+
// ---- Image-contract inference (inspect_blog_repo) ----
|
|
211
|
+
// adr: adr/blog-image-contract.md
|
|
212
|
+
const IMAGE_FIELD_CANDIDATES = [
|
|
213
|
+
'image', 'coverImage', 'cover', 'ogImage', 'heroImage', 'featuredImage', 'thumbnail', 'banner',
|
|
214
|
+
];
|
|
215
|
+
const IMAGE_VALUE_RE = /\.(png|jpe?g|webp|gif|avif|svg)\b/i;
|
|
216
|
+
function unquoteScalar(s) {
|
|
217
|
+
const m = s.match(/^["'](.*)["']$/);
|
|
218
|
+
return m ? m[1] : s;
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Pick the frontmatter key that holds the post's cover image. Prefers the
|
|
222
|
+
* conventional names in order; the value must look like a local image path
|
|
223
|
+
* (carries an image extension). Falls back to any field whose value does.
|
|
224
|
+
*/
|
|
225
|
+
function pickImageField(fm) {
|
|
226
|
+
for (const k of IMAGE_FIELD_CANDIDATES) {
|
|
227
|
+
const raw = fm[k];
|
|
228
|
+
if (!raw || raw === '<multiline>')
|
|
229
|
+
continue;
|
|
230
|
+
const v = unquoteScalar(raw);
|
|
231
|
+
if (IMAGE_VALUE_RE.test(v))
|
|
232
|
+
return { key: k, value: v };
|
|
233
|
+
}
|
|
234
|
+
for (const [k, raw] of Object.entries(fm)) {
|
|
235
|
+
if (raw === '<multiline>')
|
|
236
|
+
continue;
|
|
237
|
+
const v = unquoteScalar(raw);
|
|
238
|
+
if (IMAGE_VALUE_RE.test(v))
|
|
239
|
+
return { key: k, value: v };
|
|
240
|
+
}
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Derive the per-site image contract from sampled posts:
|
|
245
|
+
* - `image_field` — which frontmatter key holds the cover
|
|
246
|
+
* - `image_path_style` — do values carry a leading slash?
|
|
247
|
+
* - `image_public_prefix`— the dominant directory the images live under
|
|
248
|
+
* (relative, no leading slash)
|
|
249
|
+
* - `image_naming` — `og-{slug}` vs `{slug}` filename convention
|
|
250
|
+
* (detected by the dominant basename shape)
|
|
251
|
+
* Conservative: only the dominant local-path field is analyzed; full URLs
|
|
252
|
+
* are ignored. Anything not confidently detected is left undefined so
|
|
253
|
+
* post_to_blog falls back to its documented defaults.
|
|
254
|
+
*/
|
|
255
|
+
export function inferImageConventions(samples) {
|
|
256
|
+
const hits = [];
|
|
257
|
+
for (const s of samples) {
|
|
258
|
+
const pick = pickImageField(s.fm);
|
|
259
|
+
if (pick)
|
|
260
|
+
hits.push({ ...pick, slug: s.slug });
|
|
261
|
+
}
|
|
262
|
+
if (hits.length === 0)
|
|
263
|
+
return {};
|
|
264
|
+
// Dominant cover field name
|
|
265
|
+
const fieldCounts = new Map();
|
|
266
|
+
for (const h of hits)
|
|
267
|
+
fieldCounts.set(h.key, (fieldCounts.get(h.key) || 0) + 1);
|
|
268
|
+
const image_field = [...fieldCounts.entries()].sort((a, b) => b[1] - a[1])[0][0];
|
|
269
|
+
// Only local paths of the dominant field tell us about the path contract
|
|
270
|
+
const local = hits.filter((h) => h.key === image_field && !/^https?:\/\//i.test(h.value) && !h.value.startsWith('//'));
|
|
271
|
+
if (local.length === 0)
|
|
272
|
+
return { image_field };
|
|
273
|
+
// Path style: majority leading-slash ⇒ absolute
|
|
274
|
+
const abs = local.filter((h) => h.value.startsWith('/')).length;
|
|
275
|
+
const image_path_style = abs > local.length / 2 ? 'absolute' : 'relative';
|
|
276
|
+
// Public prefix: dominant directory portion (no leading/trailing slash)
|
|
277
|
+
const dirCounts = new Map();
|
|
278
|
+
for (const h of local) {
|
|
279
|
+
const noSlash = h.value.replace(/^\/+/, '');
|
|
280
|
+
const dir = noSlash.includes('/') ? noSlash.slice(0, noSlash.lastIndexOf('/')) : '';
|
|
281
|
+
dirCounts.set(dir, (dirCounts.get(dir) || 0) + 1);
|
|
282
|
+
}
|
|
283
|
+
const topDir = [...dirCounts.entries()].sort((a, b) => b[1] - a[1])[0][0];
|
|
284
|
+
const image_public_prefix = topDir || undefined;
|
|
285
|
+
// Naming convention by dominant basename shape + extension
|
|
286
|
+
let ogCount = 0;
|
|
287
|
+
let slugCount = 0;
|
|
288
|
+
const extCounts = new Map();
|
|
289
|
+
for (const h of local) {
|
|
290
|
+
const base = h.value.replace(/^.*\//, '');
|
|
291
|
+
const ext = (base.match(/\.([a-z0-9]+)$/i)?.[1] || 'png').toLowerCase();
|
|
292
|
+
extCounts.set(ext, (extCounts.get(ext) || 0) + 1);
|
|
293
|
+
const stem = base.replace(/\.[a-z0-9]+$/i, '');
|
|
294
|
+
if (stem === h.slug)
|
|
295
|
+
slugCount++;
|
|
296
|
+
else if (stem.startsWith('og-'))
|
|
297
|
+
ogCount++;
|
|
298
|
+
}
|
|
299
|
+
const dominantExt = [...extCounts.entries()].sort((a, b) => b[1] - a[1])[0][0];
|
|
300
|
+
let image_naming;
|
|
301
|
+
if (slugCount > local.length / 2)
|
|
302
|
+
image_naming = `{slug}.${dominantExt}`;
|
|
303
|
+
else if (ogCount > local.length / 2)
|
|
304
|
+
image_naming = `og-{slug}.${dominantExt}`;
|
|
305
|
+
// else: undefined ⇒ post_to_blog default (og-{slug}.{ext})
|
|
306
|
+
return { image_field, image_path_style, image_public_prefix, image_naming };
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* Map an inferred (relative) public image prefix to the on-disk image_dir
|
|
310
|
+
* for the framework's static-asset root.
|
|
311
|
+
*/
|
|
312
|
+
function imageDirForFramework(fw, prefix) {
|
|
313
|
+
switch (fw) {
|
|
314
|
+
case 'astro':
|
|
315
|
+
case 'next':
|
|
316
|
+
return `public/${prefix}`;
|
|
317
|
+
case 'hugo':
|
|
318
|
+
return `static/${prefix}`;
|
|
319
|
+
case 'jekyll':
|
|
320
|
+
return prefix; // served from repo root
|
|
321
|
+
default:
|
|
322
|
+
return `public/${prefix}`;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
210
325
|
/**
|
|
211
326
|
* Detect the site's public URL from common static-host conventions:
|
|
212
327
|
* - `CNAME` at repo root or `public/CNAME` (GitHub Pages / Cloudflare Pages / Netlify)
|
|
@@ -320,6 +435,49 @@ function formatDate(v) {
|
|
|
320
435
|
const m = v.match(/^(\d{4}-\d{2}-\d{2})/);
|
|
321
436
|
return m ? m[1] : v;
|
|
322
437
|
}
|
|
438
|
+
// ---- Per-site image contract (path style + cover naming) ----
|
|
439
|
+
// adr: adr/blog-image-contract.md
|
|
440
|
+
//
|
|
441
|
+
// The image reference is a PER-SITE CONTRACT, not a global assumption. Two
|
|
442
|
+
// dimensions, both stored on BlogSite and inferred by inspect_blog_repo:
|
|
443
|
+
// 1. path style — does the value carry a leading slash, or does the site's
|
|
444
|
+
// template prepend one? (`image_path_style`)
|
|
445
|
+
// 2. cover filename — deterministic `og-{slug}.{ext}`, never the raw
|
|
446
|
+
// `/_images/` name, so republish is idempotent and never orphans.
|
|
447
|
+
// (`image_naming`)
|
|
448
|
+
// Absent keys ⇒ legacy behavior ("absolute", raw name) so already-correct
|
|
449
|
+
// sites never regress.
|
|
450
|
+
/** Site's effective path style. Absent ⇒ legacy "absolute". */
|
|
451
|
+
export function pathStyleOf(site) {
|
|
452
|
+
return site.image_path_style === 'relative' ? 'relative' : 'absolute';
|
|
453
|
+
}
|
|
454
|
+
/**
|
|
455
|
+
* Public reference for one image file under the site's prefix, honoring the
|
|
456
|
+
* site's path style. The prefix is normalized (leading + trailing slashes
|
|
457
|
+
* stripped) so storage is style-agnostic — `style` alone decides the leading
|
|
458
|
+
* slash, which makes the contract unambiguous regardless of how the prefix
|
|
459
|
+
* was saved (`/images/og` vs `images/og` both behave identically).
|
|
460
|
+
* relative → "images/og/x.png" absolute → "/images/og/x.png"
|
|
461
|
+
*/
|
|
462
|
+
export function imageRef(publicPrefix, file, style) {
|
|
463
|
+
const seg = (publicPrefix || '').replace(/^\/+/, '').replace(/\/+$/, '');
|
|
464
|
+
const rel = seg ? `${seg}/${file}` : file;
|
|
465
|
+
return style === 'absolute' ? `/${rel}` : rel;
|
|
466
|
+
}
|
|
467
|
+
/**
|
|
468
|
+
* Resolve the deterministic cover filename from the site's naming template.
|
|
469
|
+
* `{slug}` → post slug
|
|
470
|
+
* `{ext}` → source extension, no dot (preserved from the original)
|
|
471
|
+
* A template carrying a literal extension and no `{ext}` placeholder
|
|
472
|
+
* (e.g. `og-{slug}.png`) is respected as authored. Absent template ⇒
|
|
473
|
+
* `og-{slug}.{ext}`.
|
|
474
|
+
*/
|
|
475
|
+
export function coverFilename(template, slug, sourceExt) {
|
|
476
|
+
const ext = sourceExt.replace(/^\.+/, '');
|
|
477
|
+
return (template || 'og-{slug}.{ext}')
|
|
478
|
+
.replace(/\{slug\}/g, slug)
|
|
479
|
+
.replace(/\{ext\}/g, ext);
|
|
480
|
+
}
|
|
323
481
|
/**
|
|
324
482
|
* Build the YAML frontmatter from blogContext + site defaults.
|
|
325
483
|
*
|
|
@@ -335,7 +493,7 @@ function formatDate(v) {
|
|
|
335
493
|
* etc.) is NEVER passed through. Frontmatter is built ONLY from blogContext +
|
|
336
494
|
* defaults — this is the design contract from server/blog-routes.ts.
|
|
337
495
|
*/
|
|
338
|
-
function buildFrontmatter(title, blogCtx, site, coverImagePath) {
|
|
496
|
+
export function buildFrontmatter(title, blogCtx, site, coverImagePath) {
|
|
339
497
|
const fm = {};
|
|
340
498
|
const map = site.frontmatter_field_map || {};
|
|
341
499
|
// 1. Site defaults (lowest priority — overridable below)
|
|
@@ -463,16 +621,35 @@ export function blogTools() {
|
|
|
463
621
|
return (detected ? rel.startsWith(detected + '/') : true) && /\.(md|mdx)$/i.test(rel);
|
|
464
622
|
})
|
|
465
623
|
.slice(0, 10);
|
|
466
|
-
const
|
|
624
|
+
const sampleData = [];
|
|
467
625
|
for (const f of sampleFiles) {
|
|
468
626
|
try {
|
|
469
|
-
|
|
627
|
+
const fm = parseYamlFrontmatter(readFileSync(f, 'utf-8'));
|
|
628
|
+
const slug = basename(f).replace(/\.(md|mdx)$/i, '');
|
|
629
|
+
sampleData.push({ fm, slug });
|
|
470
630
|
}
|
|
471
631
|
catch { /* skip */ }
|
|
472
632
|
}
|
|
633
|
+
const rawSamples = sampleData.map((s) => s.fm);
|
|
473
634
|
const samplesAfterFilter = rawSamples.filter((s) => !looksLikeOpenwriterLeak(s));
|
|
474
635
|
const samplesSkipped = rawSamples.length - samplesAfterFilter.length;
|
|
475
636
|
const shape = inferFrontmatterShape(rawSamples);
|
|
637
|
+
// Per-site image contract — inferred from real posts (path style, the
|
|
638
|
+
// directory images live under, og-{slug} vs {slug} naming, and which
|
|
639
|
+
// frontmatter field holds the cover). Leak samples excluded so the
|
|
640
|
+
// old plugin's emit doesn't poison detection. adr: adr/blog-image-contract.md
|
|
641
|
+
const conv = inferImageConventions(sampleData.filter((s) => !looksLikeOpenwriterLeak(s.fm)));
|
|
642
|
+
const imagePublicPrefix = conv.image_public_prefix ?? defaults.image_public_prefix;
|
|
643
|
+
const imageDir = conv.image_public_prefix
|
|
644
|
+
? imageDirForFramework(framework, conv.image_public_prefix)
|
|
645
|
+
: defaults.image_dir;
|
|
646
|
+
const imagePathStyle = conv.image_path_style ?? 'absolute';
|
|
647
|
+
const imageNaming = conv.image_naming ?? 'og-{slug}.{ext}';
|
|
648
|
+
// Cover field-name mapping (e.g. site uses `image:` not `coverImage:`)
|
|
649
|
+
const fieldMap = { ...shape.field_map };
|
|
650
|
+
if (conv.image_field && conv.image_field !== 'coverImage') {
|
|
651
|
+
fieldMap.coverImage = conv.image_field;
|
|
652
|
+
}
|
|
476
653
|
const confidence = framework !== 'unknown' && detected ? 'high'
|
|
477
654
|
: detected ? 'medium'
|
|
478
655
|
: 'low';
|
|
@@ -482,11 +659,13 @@ export function blogTools() {
|
|
|
482
659
|
repo,
|
|
483
660
|
framework,
|
|
484
661
|
content_dir: defaults.content_dir,
|
|
485
|
-
image_dir:
|
|
486
|
-
image_public_prefix:
|
|
662
|
+
image_dir: imageDir,
|
|
663
|
+
image_public_prefix: imagePublicPrefix,
|
|
664
|
+
image_path_style: imagePathStyle,
|
|
665
|
+
image_naming: imageNaming,
|
|
487
666
|
frontmatter_schema: shape.schema,
|
|
488
667
|
frontmatter_defaults: shape.defaults,
|
|
489
|
-
frontmatter_field_map:
|
|
668
|
+
frontmatter_field_map: fieldMap,
|
|
490
669
|
// Always propose a pattern even when site_url is unknown so the user can fill in the URL
|
|
491
670
|
site_url: siteUrl,
|
|
492
671
|
blog_url_pattern: '/blog/{slug}/',
|
|
@@ -509,7 +688,9 @@ export function blogTools() {
|
|
|
509
688
|
branch: { type: 'string', description: 'Branch to push to (default: main)' },
|
|
510
689
|
content_dir: { type: 'string', description: 'Directory where post .md files live (e.g. "src/content/blog")' },
|
|
511
690
|
image_dir: { type: 'string', description: 'Directory where image files write (e.g. "public/blog-images")' },
|
|
512
|
-
image_public_prefix: { type: 'string', description: '
|
|
691
|
+
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.' },
|
|
692
|
+
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).' },
|
|
693
|
+
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
694
|
framework: { type: 'string', enum: ['astro', 'next', 'jekyll', 'hugo', 'unknown'], description: 'Site framework' },
|
|
514
695
|
frontmatter_defaults: {
|
|
515
696
|
type: 'object',
|
|
@@ -553,6 +734,12 @@ export function blogTools() {
|
|
|
553
734
|
if (params.frontmatter_field_map && typeof params.frontmatter_field_map === 'object') {
|
|
554
735
|
site.frontmatter_field_map = params.frontmatter_field_map;
|
|
555
736
|
}
|
|
737
|
+
if (params.image_path_style === 'relative' || params.image_path_style === 'absolute') {
|
|
738
|
+
site.image_path_style = params.image_path_style;
|
|
739
|
+
}
|
|
740
|
+
if (typeof params.image_naming === 'string' && params.image_naming.trim()) {
|
|
741
|
+
site.image_naming = params.image_naming.trim();
|
|
742
|
+
}
|
|
556
743
|
if (Array.isArray(params.frontmatter_schema)) {
|
|
557
744
|
site.frontmatter_schema = params.frontmatter_schema.map(String);
|
|
558
745
|
}
|
|
@@ -685,24 +872,35 @@ export function blogTools() {
|
|
|
685
872
|
if (!slug)
|
|
686
873
|
return { error: 'Could not derive a slug from the title.' };
|
|
687
874
|
// Rewrite inline image refs in body, collect filenames
|
|
688
|
-
|
|
875
|
+
// Per-site image contract: path style governs the leading slash on
|
|
876
|
+
// every emitted reference (cover + inline body). adr: adr/blog-image-contract.md
|
|
877
|
+
const style = pathStyleOf(site);
|
|
878
|
+
// Inline body images keep their source (hash) filenames — only the
|
|
879
|
+
// path style is normalized. Deterministic slug naming is scoped to the
|
|
880
|
+
// COVER for now (inline naming is a noted follow-up in the ADR).
|
|
689
881
|
const imageRefs = new Set();
|
|
690
882
|
const bodyRewritten = bodyMd.replace(/\/_images\/([^\s)"'<>]+)/g, (_m, fn) => {
|
|
691
883
|
imageRefs.add(fn);
|
|
692
|
-
return
|
|
884
|
+
return imageRef(site.image_public_prefix, fn, style);
|
|
693
885
|
});
|
|
694
|
-
//
|
|
886
|
+
// Cover image from blogContext → deterministic `og-{slug}.{ext}` name.
|
|
887
|
+
// Same doc + slug ⇒ same filename every republish (idempotent
|
|
888
|
+
// overwrite, no orphaned cover). Source extension is preserved.
|
|
695
889
|
let coverImagePath;
|
|
890
|
+
let coverSrcFile;
|
|
891
|
+
let coverDestFile;
|
|
696
892
|
if (typeof blogCtx.coverImage === 'string' && blogCtx.coverImage) {
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
893
|
+
coverSrcFile = blogCtx.coverImage.replace(/^\/_images\//, '');
|
|
894
|
+
const ext = extname(coverSrcFile) || '.png';
|
|
895
|
+
coverDestFile = coverFilename(site.image_naming, slug, ext);
|
|
896
|
+
coverImagePath = imageRef(site.image_public_prefix, coverDestFile, style);
|
|
700
897
|
}
|
|
701
898
|
// Copy images
|
|
702
899
|
const dataDir = srv.getDataDir();
|
|
703
900
|
const imageDirAbs = join(clonePath, site.image_dir);
|
|
704
901
|
mkdirSync(imageDirAbs, { recursive: true });
|
|
705
902
|
let imagesCopied = 0;
|
|
903
|
+
// Inline images — copied under their source filename
|
|
706
904
|
for (const fn of imageRefs) {
|
|
707
905
|
const src = join(dataDir, '_images', fn);
|
|
708
906
|
if (!existsSync(src))
|
|
@@ -712,6 +910,16 @@ export function blogTools() {
|
|
|
712
910
|
copyFileSync(src, dst);
|
|
713
911
|
imagesCopied++;
|
|
714
912
|
}
|
|
913
|
+
// Cover — copied under the deterministic slug-based name
|
|
914
|
+
if (coverSrcFile && coverDestFile) {
|
|
915
|
+
const src = join(dataDir, '_images', coverSrcFile);
|
|
916
|
+
if (existsSync(src)) {
|
|
917
|
+
const dst = join(imageDirAbs, coverDestFile);
|
|
918
|
+
mkdirSync(dirname(dst), { recursive: true });
|
|
919
|
+
copyFileSync(src, dst);
|
|
920
|
+
imagesCopied++;
|
|
921
|
+
}
|
|
922
|
+
}
|
|
715
923
|
// Write the post file
|
|
716
924
|
const frontmatter = buildFrontmatter(title, blogCtx, site, coverImagePath);
|
|
717
925
|
const postRel = join(site.content_dir, `${slug}.md`);
|
|
@@ -780,6 +988,9 @@ export function blogTools() {
|
|
|
780
988
|
file: postRel.replace(/\\/g, '/'),
|
|
781
989
|
commit: shortHash,
|
|
782
990
|
images_committed: noChanges ? 0 : imagesCopied,
|
|
991
|
+
// Transparency: the exact cover path written to frontmatter + the
|
|
992
|
+
// filename it shipped as, so the agent/user sees what landed.
|
|
993
|
+
...(coverImagePath ? { image: coverImagePath, cover_file: coverDestFile } : {}),
|
|
783
994
|
live_url: liveUrl,
|
|
784
995
|
message: noChanges
|
|
785
996
|
? 'No changes — file already up to date. Doc marked as sent.'
|
|
@@ -79,6 +79,27 @@ export interface BlogSite {
|
|
|
79
79
|
* Default: `/blog/{slug}/`. Used together with `site_url` to build the live URL.
|
|
80
80
|
*/
|
|
81
81
|
blog_url_pattern?: string;
|
|
82
|
+
/**
|
|
83
|
+
* How image paths are written into frontmatter + body:
|
|
84
|
+
* "relative" — no leading slash (`images/og/x.png`); the site's template
|
|
85
|
+
* prepends the slash (e.g. Astro `<img src={`/${image}`}>`).
|
|
86
|
+
* "absolute" — leading slash (`/images/og/x.png`); value used verbatim.
|
|
87
|
+
* Inferred by inspect_blog_repo from existing posts' image values. Absent ⇒
|
|
88
|
+
* "absolute" — the legacy behavior the plugin always had before this field.
|
|
89
|
+
* adr: adr/blog-image-contract.md
|
|
90
|
+
*/
|
|
91
|
+
image_path_style?: 'relative' | 'absolute';
|
|
92
|
+
/**
|
|
93
|
+
* Filename template the published COVER image is copied under. Placeholders:
|
|
94
|
+
* `{slug}` — the post slug
|
|
95
|
+
* `{ext}` — the source file's extension, no dot (preserved from the
|
|
96
|
+
* `/_images/` original)
|
|
97
|
+
* Default `og-{slug}.{ext}`. Deterministic: same doc + same slug ⇒ same
|
|
98
|
+
* cover filename on every republish (idempotent overwrite, no orphaned
|
|
99
|
+
* cover files). Inferred by inspect_blog_repo from existing posts.
|
|
100
|
+
* adr: adr/blog-image-contract.md
|
|
101
|
+
*/
|
|
102
|
+
image_naming?: string;
|
|
82
103
|
}
|
|
83
104
|
export declare function listBlogSites(): Promise<BlogSite[]>;
|
|
84
105
|
export declare function writeBlogSites(sites: BlogSite[]): Promise<void>;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openwriter",
|
|
3
|
-
"version": "0.29.
|
|
3
|
+
"version": "0.29.2",
|
|
4
4
|
"description": "The open-source writing surface for AI agents. Markdown-native editor with pending change review — your agent writes, you accept or reject.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|