openwriter 0.33.1 → 0.34.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.
@@ -1038,6 +1038,59 @@ export function blogTools() {
1038
1038
  const postAbs = join(clonePath, postRel);
1039
1039
  mkdirSync(dirname(postAbs), { recursive: true });
1040
1040
  writeFileSync(postAbs, frontmatter + bodyRewritten + '\n', 'utf-8');
1041
+ // ── PRE-COMMIT SCHEMA GATE ──────────────────────────────────────────
1042
+ // Validate the built frontmatter against the TARGET SITE's own content
1043
+ // schema BEFORE anything is committed. A value outside the site's
1044
+ // z.enum (e.g. category "Updates" when the schema allows only
1045
+ // 'Product Updates' | 'Guides' | …) otherwise sails straight to a red
1046
+ // Astro build + a silent 404 — the exact failure that motivated this
1047
+ // gate (live incident 2026-06-09). The schema is read from the cloned
1048
+ // repo's LIVE config every publish, never a mirrored snapshot, so it
1049
+ // can't drift from the site. adr: adr/blog-publish-schema-gate.md
1050
+ const gate = await srv.validateBlogFrontmatter({
1051
+ repoRoot: clonePath,
1052
+ contentDir: site.content_dir,
1053
+ frontmatter,
1054
+ });
1055
+ if (!gate.ok) {
1056
+ // ABORT before commit/push. Nothing was committed; this working-tree
1057
+ // edit stays local and is wiped by the next publish's reset --hard.
1058
+ const friendly = gate.summary
1059
+ || (gate.issues || []).map((i) => i.message).join('; ')
1060
+ || 'frontmatter does not match the site schema';
1061
+ // Human surface: a toast fires for whoever clicked Publish, over the
1062
+ // existing WS path, routed to the canonical showToast() primitive.
1063
+ try {
1064
+ srv.broadcastToast(`Blog publish blocked: ${friendly}`, 'error');
1065
+ }
1066
+ catch { /* best-effort */ }
1067
+ // MCP surface: structured error so the calling agent sees exactly
1068
+ // what to fix and can republish.
1069
+ return {
1070
+ error: `Publish blocked — ${friendly}`,
1071
+ validation_failed: true,
1072
+ issues: gate.issues || [],
1073
+ ...(gate.configPath ? { schema_config: gate.configPath } : {}),
1074
+ ...(gate.collection ? { collection: gate.collection } : {}),
1075
+ hint: "Fix the frontmatter to match the site's content schema, then republish. Nothing was committed or pushed.",
1076
+ };
1077
+ }
1078
+ // Validation could not run faithfully (non-Astro repo, unparseable
1079
+ // config, or a schemaless collection). NEVER a silent skip — surface
1080
+ // it. For an Astro site this is a real gap (loud error); for other
1081
+ // frameworks there's simply no Astro schema to check (quiet info). The
1082
+ // reason always rides back on the MCP response either way.
1083
+ let validationWarning;
1084
+ if (gate.skipped) {
1085
+ validationWarning = `Published WITHOUT schema validation — ${gate.reason}.`;
1086
+ const astroExpected = site.framework === 'astro';
1087
+ try {
1088
+ srv.broadcastToast(astroExpected
1089
+ ? `Blog published without schema check — ${gate.reason}`
1090
+ : `Blog published (no Astro schema to check on this ${site.framework} site)`, astroExpected ? 'error' : 'info');
1091
+ }
1092
+ catch { /* best-effort */ }
1093
+ }
1041
1094
  // Commit + push (no-op is fine — still counts as a publish for the writeback)
1042
1095
  const commitMessage = String(params.commit_message || `blog: ${title}`);
1043
1096
  let noChanges = false;
@@ -1113,9 +1166,15 @@ export function blogTools() {
1113
1166
  // filename it shipped as, so the agent/user sees what landed.
1114
1167
  ...(coverImagePath ? { image: coverImagePath, cover_file: coverDestFile } : {}),
1115
1168
  live_url: liveUrl,
1169
+ // Transparency on the happy path: the schema gate ran and passed (or
1170
+ // was loudly skipped). adr: adr/blog-publish-schema-gate.md
1171
+ validated: !gate.skipped,
1172
+ ...(gate.configPath ? { schema_config: gate.configPath } : {}),
1173
+ ...(gate.collection ? { schema_collection: gate.collection } : {}),
1116
1174
  message: noChanges
1117
1175
  ? 'No changes — file already up to date. Doc marked as sent.'
1118
1176
  : `Pushed to ${site.owner}/${site.repo}@${site.branch}`,
1177
+ ...(validationWarning ? { validation_warning: validationWarning } : {}),
1119
1178
  ...(writebackWarning ? { warning: writebackWarning } : {}),
1120
1179
  };
1121
1180
  },
@@ -17,7 +17,27 @@ export interface ServerModules {
17
17
  broadcastSyncStatus: (status: any) => void;
18
18
  broadcastMetadataChanged: (metadata: Record<string, any>) => void;
19
19
  broadcastDocumentsChanged: () => void;
20
+ broadcastToast: (message: string, kind?: 'info' | 'error', durationMs?: number) => void;
20
21
  tiptapToMarkdown: (doc: any, title: string, metadata?: Record<string, any>) => string;
22
+ validateBlogFrontmatter: (opts: {
23
+ repoRoot: string;
24
+ contentDir: string;
25
+ frontmatter: string;
26
+ }) => Promise<BlogFrontmatterValidation>;
27
+ }
28
+ /** Result of the pre-commit schema gate. Mirrors server/blog-schema-gate.ts. */
29
+ export interface BlogFrontmatterValidation {
30
+ ok: boolean;
31
+ skipped?: boolean;
32
+ reason?: string;
33
+ configPath?: string;
34
+ collection?: string;
35
+ issues?: Array<{
36
+ field: string;
37
+ code: string;
38
+ message: string;
39
+ }>;
40
+ summary?: string;
21
41
  }
22
42
  export declare function getServerModules(): Promise<ServerModules>;
23
43
  export interface PluginConfigField {
@@ -8,23 +8,24 @@ const npmBase = new URL('../../../server/', import.meta.url).href;
8
8
  const monoBase = new URL('../../../packages/openwriter/dist/server/', import.meta.url).href;
9
9
  let _cached = null;
10
10
  async function tryImport(base) {
11
- const [helpers, state, ws, markdown] = await Promise.all([
11
+ const [helpers, state, ws, markdown, schemaGate] = await Promise.all([
12
12
  import(base + 'helpers.js'),
13
13
  import(base + 'state.js'),
14
14
  import(base + 'ws.js'),
15
15
  import(base + 'markdown.js'),
16
+ import(base + 'blog-schema-gate.js'),
16
17
  ]);
17
- return { helpers, state, ws, markdown };
18
+ return { helpers, state, ws, markdown, schemaGate };
18
19
  }
19
20
  export async function getServerModules() {
20
21
  if (_cached)
21
22
  return _cached;
22
- let helpers, state, ws, markdown;
23
+ let helpers, state, ws, markdown, schemaGate;
23
24
  try {
24
- ({ helpers, state, ws, markdown } = await tryImport(npmBase));
25
+ ({ helpers, state, ws, markdown, schemaGate } = await tryImport(npmBase));
25
26
  }
26
27
  catch {
27
- ({ helpers, state, ws, markdown } = await tryImport(monoBase));
28
+ ({ helpers, state, ws, markdown, schemaGate } = await tryImport(monoBase));
28
29
  }
29
30
  _cached = {
30
31
  getDataDir: helpers.getDataDir,
@@ -41,7 +42,9 @@ export async function getServerModules() {
41
42
  broadcastSyncStatus: ws.broadcastSyncStatus,
42
43
  broadcastMetadataChanged: ws.broadcastMetadataChanged,
43
44
  broadcastDocumentsChanged: ws.broadcastDocumentsChanged,
45
+ broadcastToast: ws.broadcastToast,
44
46
  tiptapToMarkdown: markdown.tiptapToMarkdown,
47
+ validateBlogFrontmatter: schemaGate.validateBlogFrontmatter,
45
48
  };
46
49
  return _cached;
47
50
  }
@@ -0,0 +1,318 @@
1
+ /**
2
+ * Blog publish schema gate — validate a post's frontmatter against the TARGET
3
+ * SITE's live Astro content schema BEFORE the github plugin commits/pushes.
4
+ *
5
+ * Why this exists
6
+ * ───────────────
7
+ * `post_to_blog` builds frontmatter from a doc's metadata + the site's
8
+ * `frontmatter_defaults`, then commits and pushes. Nothing checked that the
9
+ * result satisfies the site's `src/content/config.ts` Zod schema. A bad value
10
+ * (e.g. `category: "Updates"` when the schema is
11
+ * `z.enum(['Product Updates','Guides','Discord Tips','Tutorials'])`) sailed
12
+ * straight to a red Astro build — Netlify never shipped the page, and the only
13
+ * signal was a manually-discovered 404. (Live incident, 2026-06-09.)
14
+ *
15
+ * Single source of truth
16
+ * ──────────────────────
17
+ * The schema is read from the cloned repo's OWN config every publish — never a
18
+ * snapshot mirrored in OpenWriter's blog-site registry. A mirror drifts the
19
+ * moment the site changes its schema; the repo's `config.ts` cannot.
20
+ * adr: adr/blog-publish-schema-gate.md
21
+ *
22
+ * How it loads an Astro config outside Astro
23
+ * ──────────────────────────────────────────
24
+ * `src/content/config.ts` does `import { z, defineCollection } from 'astro:content'`,
25
+ * which only resolves inside the Astro runtime. We:
26
+ * 1. transpile the TS to ESM JS (esbuild when present — it ships with Vite),
27
+ * 2. strip the `astro:content` / `astro/loaders` imports and prepend a tiny
28
+ * shim header: `z` → the real `zod` (by absolute file URL so it resolves
29
+ * from any temp location), `defineCollection` → identity (preserves
30
+ * `.schema`), `reference`/`glob`/`file` → harmless stubs,
31
+ * 3. write the result to a temp `.mjs` and dynamic-import it,
32
+ * 4. read `collections[<dir>]`, resolve its `.schema` (calling the
33
+ * `({ image }) => z.object(...)` function form with an `image()` stub),
34
+ * 5. `schema.safeParse(grayMatterData)` — gray-matter yields the SAME typed
35
+ * object Astro feeds its schema (unquoted dates → real `Date`), so
36
+ * `z.date()` / `z.coerce.date()` behave identically here and at build time.
37
+ *
38
+ * Anything that prevents a faithful parse (no config, exotic imports, transpile
39
+ * failure, no matching collection, no schema) is reported as `skipped` with a
40
+ * reason — NEVER a silent pass. The caller surfaces that loudly.
41
+ */
42
+ import { existsSync, readFileSync, writeFileSync, mkdtempSync, rmSync } from 'fs';
43
+ import { join, basename } from 'path';
44
+ import { tmpdir } from 'os';
45
+ import { pathToFileURL } from 'url';
46
+ import { createRequire } from 'module';
47
+ import matter from 'gray-matter';
48
+ import { z } from 'zod';
49
+ const require = createRequire(import.meta.url);
50
+ // Config filenames Astro recognizes (v2–v4 `src/content/config.*`, v5
51
+ // `src/content.config.*`), TS + already-ESM variants.
52
+ const CONFIG_CANDIDATES = [
53
+ 'src/content/config.ts',
54
+ 'src/content/config.mts',
55
+ 'src/content/config.js',
56
+ 'src/content/config.mjs',
57
+ 'src/content.config.ts',
58
+ 'src/content.config.mts',
59
+ 'src/content.config.js',
60
+ 'src/content.config.mjs',
61
+ ];
62
+ function findContentConfig(repoRoot) {
63
+ for (const rel of CONFIG_CANDIDATES) {
64
+ const abs = join(repoRoot, rel);
65
+ if (existsSync(abs))
66
+ return abs;
67
+ }
68
+ return null;
69
+ }
70
+ /** `a`/`an` for a type word, so "must be a boolean" / "must be an array" read right. */
71
+ function article(word) {
72
+ return /^[aeiou]/i.test(word) ? 'an' : 'a';
73
+ }
74
+ /**
75
+ * Map one Zod issue to a plain-language sentence. NEITHER the MCP error nor the
76
+ * browser toast ever shows raw Zod text — this is the only place that touches
77
+ * the issue shape. Covers the codes a content schema actually produces; the
78
+ * default keeps everything else human ("<field> is invalid").
79
+ */
80
+ export function friendlyZodIssue(issue) {
81
+ const field = Array.isArray(issue?.path) && issue.path.length ? issue.path.join('.') : 'frontmatter';
82
+ const code = String(issue?.code || 'invalid');
83
+ let message;
84
+ switch (code) {
85
+ case 'invalid_enum_value': {
86
+ const opts = Array.isArray(issue.options) ? issue.options.join(', ') : '';
87
+ message = `${field} "${issue.received}" isn't allowed — pick one of: ${opts}`;
88
+ break;
89
+ }
90
+ case 'invalid_type': {
91
+ // Zod reports a missing required field as invalid_type with received "undefined".
92
+ if (issue.received === 'undefined' || issue.received === 'null') {
93
+ message = `${field} is missing`;
94
+ }
95
+ else if (issue.expected === 'date') {
96
+ message = `${field} must be a date`;
97
+ }
98
+ else {
99
+ message = `${field} must be ${article(String(issue.expected))} ${issue.expected}`;
100
+ }
101
+ break;
102
+ }
103
+ case 'invalid_string': {
104
+ if (issue.validation === 'url')
105
+ message = `${field} must be a valid URL`;
106
+ else if (issue.validation === 'email')
107
+ message = `${field} must be a valid email`;
108
+ else if (issue.validation === 'datetime')
109
+ message = `${field} must be a valid date-time`;
110
+ else if (issue.validation === 'uuid')
111
+ message = `${field} must be a valid UUID`;
112
+ else
113
+ message = `${field} has an invalid format`;
114
+ break;
115
+ }
116
+ case 'invalid_date':
117
+ message = `${field} must be a valid date`;
118
+ break;
119
+ case 'too_small': {
120
+ const t = issue.type;
121
+ const n = issue.minimum;
122
+ if (t === 'string')
123
+ message = `${field} is too short (minimum ${n} characters)`;
124
+ else if (t === 'array')
125
+ message = `${field} needs at least ${n} item${n === 1 ? '' : 's'}`;
126
+ else if (t === 'number')
127
+ message = `${field} must be at least ${n}`;
128
+ else
129
+ message = `${field} is too small`;
130
+ break;
131
+ }
132
+ case 'too_big': {
133
+ const t = issue.type;
134
+ const n = issue.maximum;
135
+ if (t === 'string')
136
+ message = `${field} is too long (maximum ${n} characters)`;
137
+ else if (t === 'array')
138
+ message = `${field} allows at most ${n} item${n === 1 ? '' : 's'}`;
139
+ else if (t === 'number')
140
+ message = `${field} must be at most ${n}`;
141
+ else
142
+ message = `${field} is too big`;
143
+ break;
144
+ }
145
+ case 'unrecognized_keys': {
146
+ const keys = Array.isArray(issue.keys) ? issue.keys.join(', ') : '';
147
+ message = `unexpected field${issue.keys?.length === 1 ? '' : 's'}: ${keys}`;
148
+ break;
149
+ }
150
+ default:
151
+ message = `${field} is invalid`;
152
+ break;
153
+ }
154
+ return { field, code, message };
155
+ }
156
+ /** esbuild ships with Vite (dev/build installs). Production npm installs may
157
+ * lack it — in that case we fall back to importing the source verbatim, which
158
+ * works for already-ESM configs and TS configs free of type-only syntax; a
159
+ * failure there surfaces as a `skipped` reason, never a silent pass.
160
+ * The specifier is held in a variable so tsc/bundlers don't hard-require it. */
161
+ async function loadEsbuild() {
162
+ try {
163
+ const spec = 'esbuild';
164
+ return await import(spec);
165
+ }
166
+ catch {
167
+ return null;
168
+ }
169
+ }
170
+ async function toEsmJs(src, isTypeScript) {
171
+ if (!isTypeScript)
172
+ return src;
173
+ const esbuild = await loadEsbuild();
174
+ if (esbuild?.transform) {
175
+ const out = await esbuild.transform(src, { loader: 'ts', format: 'esm' });
176
+ return out.code;
177
+ }
178
+ // Best-effort: a config with no TS-only syntax is valid JS once imports are
179
+ // rewritten. If it isn't, the dynamic import throws and we report `skipped`.
180
+ return src;
181
+ }
182
+ /**
183
+ * Build the self-contained ESM module text: shim header (real zod by absolute
184
+ * URL + identity/stub helpers) followed by the config with its astro imports
185
+ * stripped. `[^;]*?` spans newlines so multi-line import statements are removed
186
+ * too, stopping at the statement's terminating `;`.
187
+ */
188
+ function buildModuleSource(esmJs) {
189
+ const zodUrl = pathToFileURL(require.resolve('zod')).href;
190
+ const stripped = esmJs
191
+ .replace(/import\s+[^;]*?from\s*["']astro:content["']\s*;?/g, '')
192
+ .replace(/import\s+[^;]*?from\s*["']astro\/loaders["']\s*;?/g, '');
193
+ const header = [
194
+ `import * as __ow_zod from ${JSON.stringify(zodUrl)};`,
195
+ `const z = __ow_zod.z ?? __ow_zod.default?.z ?? __ow_zod.default;`,
196
+ `const defineCollection = (c) => c;`,
197
+ `const reference = () => z.string();`,
198
+ `const glob = () => ({});`,
199
+ `const file = () => ({});`,
200
+ ``,
201
+ ].join('\n');
202
+ return header + stripped;
203
+ }
204
+ /**
205
+ * Load the site's content collections and return the Zod schema for the
206
+ * collection that backs `contentDir`. Throws on any failure (caller converts
207
+ * to a `skipped` result). Returns `{ schema: null }` when the collection has
208
+ * no schema declared (Astro doesn't validate those — nothing to gate on).
209
+ */
210
+ async function loadCollectionSchema(configPath, contentDir) {
211
+ const isTs = /\.m?ts$/.test(configPath);
212
+ const src = readFileSync(configPath, 'utf-8');
213
+ const esmJs = await toEsmJs(src, isTs);
214
+ const modSrc = buildModuleSource(esmJs);
215
+ const dir = mkdtempSync(join(tmpdir(), 'ow-blog-gate-'));
216
+ const modPath = join(dir, 'content-config.mjs');
217
+ try {
218
+ writeFileSync(modPath, modSrc, 'utf-8');
219
+ const mod = await import(pathToFileURL(modPath).href);
220
+ const collections = mod?.collections;
221
+ if (!collections || typeof collections !== 'object') {
222
+ throw new Error('config has no `collections` export');
223
+ }
224
+ // Resolve the collection by the content dir's last segment
225
+ // (src/content/blog → "blog"); fall back to the sole collection.
226
+ const key = basename(contentDir.replace(/\\/g, '/').replace(/\/+$/, ''));
227
+ let collection = null;
228
+ let col = undefined;
229
+ if (key && collections[key]) {
230
+ collection = key;
231
+ col = collections[key];
232
+ }
233
+ else {
234
+ const keys = Object.keys(collections);
235
+ if (keys.length === 1) {
236
+ collection = keys[0];
237
+ col = collections[keys[0]];
238
+ }
239
+ }
240
+ if (!col) {
241
+ throw new Error(`no "${key}" collection in config (have: ${Object.keys(collections).join(', ') || 'none'})`);
242
+ }
243
+ let schema = col.schema;
244
+ // Astro's function form: schema: ({ image }) => z.object({...}). Call it
245
+ // with an image() stub that returns a string schema — a cover path is a
246
+ // string at the frontmatter level, which is all we validate.
247
+ if (typeof schema === 'function') {
248
+ schema = schema({ image: () => z.string() });
249
+ }
250
+ if (!schema || typeof schema.safeParse !== 'function') {
251
+ return { schema: null, collection };
252
+ }
253
+ return { schema, collection };
254
+ }
255
+ finally {
256
+ try {
257
+ rmSync(dir, { recursive: true, force: true });
258
+ }
259
+ catch { /* best-effort */ }
260
+ }
261
+ }
262
+ /**
263
+ * Validate a post's built frontmatter against the target site's live content
264
+ * schema. `frontmatter` is the YAML block the plugin is about to write (with or
265
+ * without `---` fences, with or without the body appended — gray-matter handles
266
+ * all three). `repoRoot` is the cloned site repo; `contentDir` is the site's
267
+ * post directory (e.g. `src/content/blog`).
268
+ *
269
+ * Returns `ok:true` on a clean parse, `ok:false` + friendly `issues` on a
270
+ * schema violation, or `ok:true, skipped:true` + a `reason` when validation
271
+ * could not run faithfully (no config / unparseable / no schema). It NEVER
272
+ * throws — every failure mode resolves to a result the caller can act on.
273
+ */
274
+ export async function validateBlogFrontmatter(opts) {
275
+ let data;
276
+ try {
277
+ // gray-matter parses the same way Astro's content layer does (js-yaml
278
+ // default schema → unquoted ISO dates become real Date objects).
279
+ const parsed = matter(opts.frontmatter.startsWith('---') ? opts.frontmatter : `---\n${opts.frontmatter}\n---\n`);
280
+ data = parsed.data || {};
281
+ }
282
+ catch (err) {
283
+ return { ok: true, skipped: true, reason: `could not parse built frontmatter: ${err?.message || err}` };
284
+ }
285
+ const configPath = findContentConfig(opts.repoRoot);
286
+ if (!configPath) {
287
+ return { ok: true, skipped: true, reason: 'no Astro content config found (src/content/config.* or src/content.config.*)' };
288
+ }
289
+ const configRel = configPath.slice(opts.repoRoot.length + 1).replace(/\\/g, '/');
290
+ let loaded;
291
+ try {
292
+ loaded = await loadCollectionSchema(configPath, opts.contentDir);
293
+ }
294
+ catch (err) {
295
+ return { ok: true, skipped: true, reason: `could not load ${configRel}: ${err?.message || err}`, configPath: configRel };
296
+ }
297
+ if (!loaded.schema) {
298
+ return {
299
+ ok: true,
300
+ skipped: true,
301
+ reason: `${loaded.collection ? `collection "${loaded.collection}"` : 'matched collection'} has no schema to validate against`,
302
+ configPath: configRel,
303
+ collection: loaded.collection || undefined,
304
+ };
305
+ }
306
+ const result = loaded.schema.safeParse(data);
307
+ if (result.success) {
308
+ return { ok: true, configPath: configRel, collection: loaded.collection || undefined };
309
+ }
310
+ const issues = (result.error?.issues || []).map(friendlyZodIssue);
311
+ return {
312
+ ok: false,
313
+ configPath: configRel,
314
+ collection: loaded.collection || undefined,
315
+ issues,
316
+ summary: issues.map((i) => i.message).join('; '),
317
+ };
318
+ }
package/dist/server/ws.js CHANGED
@@ -564,6 +564,21 @@ export function broadcastWorkspacesChanged() {
564
564
  ws.send(msg);
565
565
  }
566
566
  }
567
+ /**
568
+ * Fire a transient toast in every connected browser. Server-originated feedback
569
+ * for actions the human triggers indirectly (e.g. an MCP-side publish that the
570
+ * UI kicked off) — the client routes this straight to the canonical showToast()
571
+ * primitive, so it looks identical to any in-app toast. Used by post_to_blog to
572
+ * surface a schema-gate rejection to whoever clicked Publish, regardless of
573
+ * which surface (modal, compose view, or a bare agent call) started it.
574
+ */
575
+ export function broadcastToast(message, kind = 'info', durationMs) {
576
+ const msg = JSON.stringify({ type: 'toast', message, kind, ...(durationMs ? { durationMs } : {}) });
577
+ for (const ws of clients) {
578
+ if (ws.readyState === WebSocket.OPEN)
579
+ ws.send(msg);
580
+ }
581
+ }
567
582
  export function broadcastTitleChanged(title) {
568
583
  const msg = JSON.stringify({ type: 'title-changed', title });
569
584
  for (const ws of clients) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openwriter",
3
- "version": "0.33.1",
3
+ "version": "0.34.0",
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",