openwriter 0.29.0 → 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.
@@ -10,7 +10,7 @@
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-SAoCjUU-.js"></script>
13
+ <script type="module" crossorigin src="/assets/index-DHaZI7nA.js"></script>
14
14
  <link rel="stylesheet" crossorigin href="/assets/index-Gdw1m46J.css">
15
15
  </head>
16
16
  <body>
@@ -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 rawSamples = [];
624
+ const sampleData = [];
467
625
  for (const f of sampleFiles) {
468
626
  try {
469
- rawSamples.push(parseYamlFrontmatter(readFileSync(f, 'utf-8')));
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: defaults.image_dir,
486
- image_public_prefix: defaults.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: shape.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: 'URL prefix for images in markdown (e.g. "/blog-images")' },
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
- const imgPrefix = site.image_public_prefix.replace(/\/+$/, '');
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 `${imgPrefix}/${fn}`;
884
+ return imageRef(site.image_public_prefix, fn, style);
693
885
  });
694
- // Handle cover image from blogContext
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
- const coverFile = blogCtx.coverImage.replace(/^\/_images\//, '');
698
- imageRefs.add(coverFile);
699
- coverImagePath = `${imgPrefix}/${coverFile}`;
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>;
@@ -0,0 +1,20 @@
1
+ // Single source of truth for content-type scaffolding metadata. Maps a
2
+ // content_type to the frontmatter it needs: the `content_type` field itself
3
+ // (which owns the editor surface — adr: adr/browser-write-fidelity.md) plus the
4
+ // type's context object (tweetContext / articleContext / blogContext / …).
5
+ //
6
+ // Used by both the MCP create_document handler and the HTTP POST /api/documents
7
+ // endpoint (the "Create variant" path) so a typed empty doc is scaffolded the
8
+ // same way regardless of who creates it. adr: docs/variants.md
9
+ export function resolveTypeMeta(type, url) {
10
+ switch (type) {
11
+ case 'tweet': return { content_type: 'tweet', tweetContext: { mode: 'tweet' } };
12
+ case 'reply': return { content_type: 'reply', tweetContext: { mode: 'reply', ...(url ? { url } : {}) } };
13
+ case 'quote': return { content_type: 'quote', tweetContext: { mode: 'quote', ...(url ? { url } : {}) } };
14
+ case 'article': return { content_type: 'article', articleContext: { active: true } };
15
+ case 'linkedin': return { content_type: 'linkedin', linkedinContext: { active: true } };
16
+ case 'newsletter': return { content_type: 'newsletter', newsletterContext: { active: true } };
17
+ case 'blog': return { content_type: 'blog', blogContext: { active: true } };
18
+ default: return undefined;
19
+ }
20
+ }
@@ -8,6 +8,7 @@ import { join } from 'path';
8
8
  import matter from 'gray-matter';
9
9
  import trash from 'trash';
10
10
  import { tiptapToMarkdownChecked, markdownToTiptap } from './markdown.js';
11
+ import { resolveTypeMeta } from './content-type-meta.js';
11
12
  import { parseMarkdownContent } from './compact.js';
12
13
  import { getDocument, getTitle, getFilePath, getIsTemp, getMetadata, save, cancelDebouncedSave, setActiveDocument, registerExternalDoc, unregisterExternalDoc, getExternalDocs, cacheActiveDocument, getCachedDocument, invalidateDocCache, removePendingCacheEntry, resetDocVersion, markAsAgentStub, unmarkAgentStub, isAgentStub, } from './state.js';
13
14
  import { getDataDir, TEMP_PREFIX, ensureDataDir, filePathForTitle, tempFilePath, generateNodeId, resolveDocPath, isExternalDoc, atomicWriteFileSync, canonicalizePath } from './helpers.js';
@@ -1148,6 +1149,77 @@ export function duplicateDocument(filename, variant) {
1148
1149
  const newFilename = filePath.split(/[/\\]/).pop();
1149
1150
  return { document: getDocument(), title: getTitle(), filename: newFilename };
1150
1151
  }
1152
+ // Content types that surface an editable title/headline above the body. For
1153
+ // these, the doc's frontmatter `title` IS content (blog headline, article title,
1154
+ // newsletter subject). For every other type the title is just a sidebar label
1155
+ // and the body carries everything. adr: docs/variants.md
1156
+ const TITLE_BEARING_TYPES = new Set(['blog', 'article', 'newsletter']);
1157
+ /**
1158
+ * Create a variant of `masterFilename` retyped as `variantType`, nested under
1159
+ * the master. Field-projection model (NOT a verbatim clone — that's
1160
+ * duplicateDocument): port the fields the two types share.
1161
+ * - body: always ported.
1162
+ * - downcast (title-bearing master → body-only variant): the master's title is
1163
+ * folded into the body as its first paragraph so the headline isn't lost.
1164
+ * - the variant is scaffolded with the TARGET type's content_type + context;
1165
+ * the source's context objects (blogContext, tweetContext, …) are NOT
1166
+ * inherited — a variant is a new typed doc, not a surface clone.
1167
+ * adr: docs/variants.md
1168
+ */
1169
+ export function createVariant(masterFilename, opts) {
1170
+ cancelDebouncedSave();
1171
+ save();
1172
+ const sourcePath = resolveDocPath(masterFilename);
1173
+ if (!existsSync(sourcePath))
1174
+ throw new Error(`Document not found: ${masterFilename}`);
1175
+ const raw = readFileSync(sourcePath, 'utf-8');
1176
+ const parsed = markdownToTiptap(raw);
1177
+ const srcType = deriveContentType(parsed.metadata) || 'document';
1178
+ const tgtType = opts.variantType;
1179
+ const srcTitleBearing = TITLE_BEARING_TYPES.has(srcType);
1180
+ const tgtTitleBearing = TITLE_BEARING_TYPES.has(tgtType);
1181
+ // Body projection. Downcast (title-bearing → body-only): prepend the master's
1182
+ // title as the first paragraph so the headline survives in a surface with no
1183
+ // title field ("title becomes first line, body the next paragraph"). Otherwise
1184
+ // the body ports unchanged.
1185
+ let bodyContent = parsed.document.content || [];
1186
+ if (srcTitleBearing && !tgtTitleBearing && parsed.title) {
1187
+ bodyContent = [
1188
+ { type: 'paragraph', content: [{ type: 'text', text: parsed.title }] },
1189
+ ...bodyContent,
1190
+ ];
1191
+ }
1192
+ const bodyDoc = { ...parsed.document, content: bodyContent };
1193
+ // Title is always label-suffixed: it doubles as the filename + sidebar name,
1194
+ // so it must stay unique vs the master (a raw duplicate title would collide).
1195
+ // The title CONTENT still rides along for title-bearing targets — they render
1196
+ // it as the headline and the user trims the suffix.
1197
+ const Label = tgtType.charAt(0).toUpperCase() + tgtType.slice(1);
1198
+ let newTitle = `${parsed.title} (${Label})`;
1199
+ let filePath = filePathForTitle(newTitle);
1200
+ if (existsSync(filePath)) {
1201
+ let counter = 2;
1202
+ while (existsSync(filePathForTitle(`${parsed.title} (${Label} ${counter})`)))
1203
+ counter++;
1204
+ newTitle = `${parsed.title} (${Label} ${counter})`;
1205
+ filePath = filePathForTitle(newTitle);
1206
+ }
1207
+ // Fresh metadata: target type scaffold + variant relationship only. Source
1208
+ // context objects are intentionally dropped (see header).
1209
+ const metadata = {
1210
+ title: newTitle,
1211
+ docId: generateNodeId(),
1212
+ ...(resolveTypeMeta(tgtType) || {}),
1213
+ masterDocId: opts.masterDocId,
1214
+ variantType: tgtType,
1215
+ };
1216
+ setActiveDocument(bodyDoc, newTitle, filePath, false, undefined, metadata);
1217
+ const { markdown } = tiptapToMarkdownChecked(bodyDoc, newTitle, metadata);
1218
+ ensureDataDir();
1219
+ atomicWriteFileSync(filePath, markdown);
1220
+ const newFilename = filePath.split(/[/\\]/).pop();
1221
+ return { document: getDocument(), title: getTitle(), filename: newFilename };
1222
+ }
1151
1223
  export function getActiveFilename() {
1152
1224
  const filePath = getFilePath();
1153
1225
  // For external docs, return the full path as the identifier
@@ -14,7 +14,7 @@ import { zodToJsonSchema } from 'zod-to-json-schema';
14
14
  import { save, cancelDebouncedSave, load, getDocument, getTitle, getFilePath, getDocId, getMetadata, getStatus, updateDocument, setMetadata, applyTextEdits, isAgentLocked, getPendingDocInfo, getDocTagsByFilename, addDocTag, removeDocTag, markAllNodesAsPending, updatePendingCacheForActiveDoc, removePendingCacheEntry, clearAllCaches, stripPendingAttrs, stripPendingAttrsFromFile, setAutoAcceptOnFile, setSortRequestOnFile, clearSortRequestOnFile, bumpDocVersion, markAsAgentStub, extractText } from './state.js';
15
15
  import { syncPostHistory } from './post-sync.js';
16
16
  import { enrollManualPostForAutoplug } from './autoplug-enroll.js';
17
- import { listDocuments, switchDocument, createDocument, deleteDocument, duplicateDocument, reloadDocument, updateDocumentTitle, openFile, reorderDocs, searchDocuments, listArchivedDocuments, archiveDocument, unarchiveDocument, getActiveFilename, batchResolve, listPendingSorts } from './documents.js';
17
+ import { listDocuments, switchDocument, createDocument, deleteDocument, duplicateDocument, createVariant, reloadDocument, updateDocumentTitle, openFile, reorderDocs, searchDocuments, listArchivedDocuments, archiveDocument, unarchiveDocument, getActiveFilename, batchResolve, listPendingSorts } from './documents.js';
18
18
  import { writePromptDebug, isPromptDebugEnabled } from './prompt-debug.js';
19
19
  import { createWorkspaceRouter } from './workspace-routes.js';
20
20
  import { createLinkRouter } from './link-routes.js';
@@ -638,6 +638,25 @@ export async function startHttpServer(options = {}) {
638
638
  res.status(400).json({ error: err.message });
639
639
  }
640
640
  });
641
+ // Create variant: a retyped derivative nested under the master. Field-projection
642
+ // (body always; title folds into body on downcast; target type scaffolded) —
643
+ // NOT a verbatim clone (that's /duplicate). adr: docs/variants.md
644
+ app.post('/api/documents/variant', (req, res) => {
645
+ try {
646
+ const { filename, masterDocId, variantType } = req.body;
647
+ if (!filename || !masterDocId || !variantType) {
648
+ res.status(400).json({ error: 'filename, masterDocId, and variantType are required' });
649
+ return;
650
+ }
651
+ const result = createVariant(filename, { masterDocId, variantType });
652
+ broadcastDocumentSwitched(result.document, result.title, result.filename);
653
+ broadcastDocumentsChanged();
654
+ res.json(result);
655
+ }
656
+ catch (err) {
657
+ res.status(400).json({ error: err.message });
658
+ }
659
+ });
641
660
  app.post('/api/documents/batch-resolve', (req, res) => {
642
661
  try {
643
662
  const { filenames, action } = req.body;
@@ -14,6 +14,7 @@ import { getDocument, getWordCount, getPendingChangeCount, getTitle, getStatus,
14
14
  import { tiptapToBlocks } from './node-blocks.js';
15
15
  import { outline, peek, searchInDoc, truncateRead } from './peek-outline.js';
16
16
  import { harvestSentenceHashes, harvestCharCount } from './enrichment.js';
17
+ import { resolveTypeMeta } from './content-type-meta.js';
17
18
  import { listDocuments, switchDocument, createDocument, createDocumentFile, deleteDocument, openFile, getActiveFilename, promoteTempFile, archiveDocument, unarchiveDocument, resolveDocId, filenameByDocId, searchDocuments, listDirtyDocs, crawlDocs, enrichmentFooter, buildEnrichmentInstructions, listPendingSorts, sortFooter, buildSortInstructions, stagePendingTitle } from './documents.js';
18
19
  import { readFrontmatter, writeFrontmatter, computeBacklinksFor, invalidateBacklinksCache } from './backlinks.js';
19
20
  import { logger, generateRequestId, withRequestId } from './logger.js';
@@ -29,19 +30,6 @@ import { tiptapToMarkdown, splitFusedParagraphs } from './markdown.js';
29
30
  import { loadDocFromDisk } from './pending-overlay.js';
30
31
  import { getComments, getCommentCount, getGlobalCommentSummary, resolveComments } from './comments.js';
31
32
  import { readTasks, addTask, updateTask, removeTask } from './tasks.js';
32
- /** Map a content type string to its frontmatter metadata object. */
33
- function resolveTypeMeta(type, url) {
34
- switch (type) {
35
- case 'tweet': return { content_type: 'tweet', tweetContext: { mode: 'tweet' } };
36
- case 'reply': return { content_type: 'reply', tweetContext: { mode: 'reply', ...(url ? { url } : {}) } };
37
- case 'quote': return { content_type: 'quote', tweetContext: { mode: 'quote', ...(url ? { url } : {}) } };
38
- case 'article': return { content_type: 'article', articleContext: { active: true } };
39
- case 'linkedin': return { content_type: 'linkedin', linkedinContext: { active: true } };
40
- case 'newsletter': return { content_type: 'newsletter', newsletterContext: { active: true } };
41
- case 'blog': return { content_type: 'blog', blogContext: { active: true } };
42
- default: return undefined;
43
- }
44
- }
45
33
  /** Resolve a docId to a full document target. Fast path for active doc (zero I/O). */
46
34
  function resolveDocTarget(docId) {
47
35
  const filename = resolveDocId(docId);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openwriter",
3
- "version": "0.29.0",
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",