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.
- package/dist/client/assets/index-BPDt3Psd.js +215 -0
- package/dist/client/assets/index-Be_l2OOL.css +1 -0
- package/dist/client/index.html +2 -2
- package/dist/plugins/authors-voice/dist/index.d.ts +7 -0
- package/dist/plugins/authors-voice/dist/index.js +70 -3
- package/dist/plugins/github/dist/blog-tools.js +59 -0
- package/dist/plugins/github/dist/helpers.d.ts +20 -0
- package/dist/plugins/github/dist/helpers.js +8 -5
- package/dist/server/blog-schema-gate.js +318 -0
- package/dist/server/ws.js +15 -0
- package/package.json +1 -1
- package/dist/client/assets/index-BFw23tzV.css +0 -1
- package/dist/client/assets/index-BtwskMLu.js +0 -215
|
@@ -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.
|
|
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",
|