@prenta/mcp-server 0.23.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/CHANGELOG.md +583 -0
- package/LICENSE +21 -0
- package/README.md +130 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +44 -0
- package/dist/cli.js.map +1 -0
- package/dist/client.d.ts +1348 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +1019 -0
- package/dist/client.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -0
- package/dist/instructions.d.ts +13 -0
- package/dist/instructions.d.ts.map +1 -0
- package/dist/instructions.js +61 -0
- package/dist/instructions.js.map +1 -0
- package/dist/navigation.d.ts +76 -0
- package/dist/navigation.d.ts.map +1 -0
- package/dist/navigation.js +228 -0
- package/dist/navigation.js.map +1 -0
- package/dist/resources.d.ts +28 -0
- package/dist/resources.d.ts.map +1 -0
- package/dist/resources.js +88 -0
- package/dist/resources.js.map +1 -0
- package/dist/server.d.ts +25 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +90 -0
- package/dist/server.js.map +1 -0
- package/dist/tools.d.ts +25 -0
- package/dist/tools.d.ts.map +1 -0
- package/dist/tools.js +1920 -0
- package/dist/tools.js.map +1 -0
- package/package.json +43 -0
package/dist/client.js
ADDED
|
@@ -0,0 +1,1019 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thrown by `PrentaClient` on non-2xx responses. Carries the HTTP
|
|
3
|
+
* `status` so callers (e.g. `list_collections`'s OpenAPI fallback) can
|
|
4
|
+
* branch on the status code rather than pattern-matching the error
|
|
5
|
+
* message — pattern matching was rejected by review on PR #155 because
|
|
6
|
+
* a real 404 might omit the literal substring "not found" and an
|
|
7
|
+
* unrelated 5xx response might include it.
|
|
8
|
+
*/
|
|
9
|
+
export class PrentaClientError extends Error {
|
|
10
|
+
status;
|
|
11
|
+
constructor(message, status) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = 'PrentaClientError';
|
|
14
|
+
this.status = status;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
export class PrentaClient {
|
|
18
|
+
baseUrl;
|
|
19
|
+
apiKey;
|
|
20
|
+
fetchImpl;
|
|
21
|
+
constructor(opts) {
|
|
22
|
+
if (!opts.baseUrl)
|
|
23
|
+
throw new Error('baseUrl is required');
|
|
24
|
+
if (!opts.apiKey)
|
|
25
|
+
throw new Error('apiKey is required');
|
|
26
|
+
// Trim trailing slash so we can concatenate `/api/cms/...` predictably.
|
|
27
|
+
this.baseUrl = opts.baseUrl.replace(/\/+$/, '');
|
|
28
|
+
this.apiKey = opts.apiKey;
|
|
29
|
+
this.fetchImpl = opts.fetchImpl ?? fetch;
|
|
30
|
+
}
|
|
31
|
+
headers(extra) {
|
|
32
|
+
return {
|
|
33
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
34
|
+
Accept: 'application/json',
|
|
35
|
+
...extra,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
async request(method, path, body, query) {
|
|
39
|
+
let url = `${this.baseUrl}/api/cms${path}`;
|
|
40
|
+
if (query) {
|
|
41
|
+
const params = new URLSearchParams();
|
|
42
|
+
for (const [k, v] of Object.entries(query)) {
|
|
43
|
+
if (v !== undefined)
|
|
44
|
+
params.set(k, String(v));
|
|
45
|
+
}
|
|
46
|
+
const q = params.toString();
|
|
47
|
+
if (q)
|
|
48
|
+
url += `?${q}`;
|
|
49
|
+
}
|
|
50
|
+
const init = { method, headers: this.headers() };
|
|
51
|
+
if (body !== undefined) {
|
|
52
|
+
init.body = JSON.stringify(body);
|
|
53
|
+
init.headers['Content-Type'] = 'application/json';
|
|
54
|
+
}
|
|
55
|
+
const res = await this.fetchImpl(url, init);
|
|
56
|
+
const text = await res.text();
|
|
57
|
+
let parsed = null;
|
|
58
|
+
if (text) {
|
|
59
|
+
try {
|
|
60
|
+
parsed = JSON.parse(text);
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
// non-JSON response body — surface as message for the tool caller.
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (!res.ok) {
|
|
67
|
+
const msg = parsed?.error ?? `Prenta API ${method} ${path} failed: ${res.status}`;
|
|
68
|
+
throw new PrentaClientError(msg, res.status);
|
|
69
|
+
}
|
|
70
|
+
return parsed;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* POST a `multipart/form-data` body. Distinct from `request()` because:
|
|
74
|
+
*
|
|
75
|
+
* - we MUST NOT set `Content-Type` (the fetch implementation appends the
|
|
76
|
+
* multipart boundary; if we override it the server can't parse the body),
|
|
77
|
+
* - we MUST NOT `JSON.stringify(form)` — a `FormData` is sent verbatim,
|
|
78
|
+
* - the upload handler in `cms-core` rejects requests without a valid
|
|
79
|
+
* `Content-Length` header, and Node 20+ / undici populate that header
|
|
80
|
+
* automatically when the body is a `FormData`.
|
|
81
|
+
*
|
|
82
|
+
* Used exclusively by `uploadMedia` today. Kept as a thin private helper so
|
|
83
|
+
* the failure mode (multipart parse error vs JSON parse error vs HTTP
|
|
84
|
+
* error) stays observable to the caller.
|
|
85
|
+
*/
|
|
86
|
+
async requestMultipart(path, form) {
|
|
87
|
+
const url = `${this.baseUrl}/api/cms${path}`;
|
|
88
|
+
const init = {
|
|
89
|
+
method: 'POST',
|
|
90
|
+
headers: this.headers(),
|
|
91
|
+
body: form,
|
|
92
|
+
};
|
|
93
|
+
const res = await this.fetchImpl(url, init);
|
|
94
|
+
const text = await res.text();
|
|
95
|
+
let parsed = null;
|
|
96
|
+
if (text) {
|
|
97
|
+
try {
|
|
98
|
+
parsed = JSON.parse(text);
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
/* non-JSON body */
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (!res.ok) {
|
|
105
|
+
const msg = parsed?.error ?? `Prenta API POST ${path} failed: ${res.status}`;
|
|
106
|
+
throw new PrentaClientError(msg, res.status);
|
|
107
|
+
}
|
|
108
|
+
return parsed;
|
|
109
|
+
}
|
|
110
|
+
// ---- Collections ----
|
|
111
|
+
listDocuments(collection, query) {
|
|
112
|
+
return this.request('GET', `/collections/${encodeURIComponent(collection)}`, undefined, query);
|
|
113
|
+
}
|
|
114
|
+
getDocument(collection, id) {
|
|
115
|
+
return this.request('GET', `/collections/${encodeURIComponent(collection)}/${encodeURIComponent(id)}`);
|
|
116
|
+
}
|
|
117
|
+
createDocument(collection, data) {
|
|
118
|
+
return this.request('POST', `/collections/${encodeURIComponent(collection)}`, data);
|
|
119
|
+
}
|
|
120
|
+
updateDocument(collection, id, data) {
|
|
121
|
+
return this.request('PUT', `/collections/${encodeURIComponent(collection)}/${encodeURIComponent(id)}`, data);
|
|
122
|
+
}
|
|
123
|
+
deleteDocument(collection, id) {
|
|
124
|
+
return this.request('DELETE', `/collections/${encodeURIComponent(collection)}/${encodeURIComponent(id)}`);
|
|
125
|
+
}
|
|
126
|
+
// ---- Globals ----
|
|
127
|
+
getGlobal(slug) {
|
|
128
|
+
return this.request('GET', `/globals/${encodeURIComponent(slug)}`);
|
|
129
|
+
}
|
|
130
|
+
updateGlobal(slug, data) {
|
|
131
|
+
return this.request('PUT', `/globals/${encodeURIComponent(slug)}`, data);
|
|
132
|
+
}
|
|
133
|
+
// ---- Media ----
|
|
134
|
+
/**
|
|
135
|
+
* List media assets. The `GET /media` endpoint returns the asset array under
|
|
136
|
+
* `items`/`data` (and pagination fields), never `docs`. We normalize it into
|
|
137
|
+
* the same `{ data: { docs, total, page, pageSize, totalPages } }` envelope
|
|
138
|
+
* as `listDocuments` so the `list_media` MCP tool output is consistent with
|
|
139
|
+
* `list_documents` and an agent can read `docs` uniformly.
|
|
140
|
+
*/
|
|
141
|
+
async listMedia(query) {
|
|
142
|
+
const res = await this.request('GET', '/media', undefined, query);
|
|
143
|
+
const envelope = res?.data ?? {};
|
|
144
|
+
const docs = envelope.items ?? envelope.data ?? [];
|
|
145
|
+
const total = envelope.total ?? docs.length;
|
|
146
|
+
const page = envelope.page ?? query?.page ?? 1;
|
|
147
|
+
const pageSize = envelope.pageSize ?? query?.pageSize ?? docs.length;
|
|
148
|
+
const totalPages = envelope.totalPages ?? (pageSize > 0 ? Math.ceil(total / pageSize) : 1);
|
|
149
|
+
return { data: { docs, total, page, pageSize, totalPages } };
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Upload a binary asset to the CMS media library. Bytes are accepted as a
|
|
153
|
+
* `Uint8Array` / `Buffer` (the MCP tool wrapper handles base64 decoding
|
|
154
|
+
* from the JSON-RPC layer, but the client surface stays binary).
|
|
155
|
+
*
|
|
156
|
+
* The server enforces a 50 MB hard cap and validates magic bytes against
|
|
157
|
+
* the declared `mimeType` — uploading a `.exe` with `image/png` will be
|
|
158
|
+
* rejected with HTTP 415. SVGs are sanitised server-side. Pass
|
|
159
|
+
* `skipOptimize: true` to keep the original bytes; by default JPEG / PNG
|
|
160
|
+
* are recompressed to WebP.
|
|
161
|
+
*/
|
|
162
|
+
uploadMedia(input) {
|
|
163
|
+
const form = new FormData();
|
|
164
|
+
// Copy bytes into a freshly-allocated ArrayBuffer. `Buffer` (and a
|
|
165
|
+
// `Uint8Array` constructed from an externally-provided ArrayBufferLike)
|
|
166
|
+
// can be backed by a SharedArrayBuffer in some runtimes, which the
|
|
167
|
+
// strict `Blob` constructor signature refuses (`Uint8Array<ArrayBuffer>`
|
|
168
|
+
// only). The copy is O(n) and negligible compared to the network upload.
|
|
169
|
+
const ab = new ArrayBuffer(input.bytes.byteLength);
|
|
170
|
+
new Uint8Array(ab).set(input.bytes);
|
|
171
|
+
const blob = new Blob([ab], { type: input.mimeType });
|
|
172
|
+
form.append('file', blob, input.filename);
|
|
173
|
+
if (input.skipOptimize)
|
|
174
|
+
form.append('skipOptimize', 'true');
|
|
175
|
+
if (input.forceUpload)
|
|
176
|
+
form.append('forceUpload', 'true');
|
|
177
|
+
return this.requestMultipart('/media/upload', form);
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Update media metadata. Only the supplied fields are touched. `alt` maps
|
|
181
|
+
* to the DB column `altText`; `focalX` / `focalY` (range 0..1) drive
|
|
182
|
+
* smart-crop on the front end. `caption`, `description`, and `focusKeyword`
|
|
183
|
+
* round out the fields `calculateMediaSeoScore` reads, so an agent can take
|
|
184
|
+
* an asset all the way to a passing media SEO grade.
|
|
185
|
+
*/
|
|
186
|
+
updateMedia(id, body) {
|
|
187
|
+
return this.request('PUT', `/media/${encodeURIComponent(id)}`, body);
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Vision-backed alt text for a media library image. Requires the AI plugin,
|
|
191
|
+
* a vision-capable provider, and `ai.features.mediaAltText` enabled.
|
|
192
|
+
* Returns a suggestion — call `update_media({ alt })` to publish it.
|
|
193
|
+
*/
|
|
194
|
+
generateMediaAlt(body) {
|
|
195
|
+
return this.request('POST', '/ai/media-metadata', body);
|
|
196
|
+
}
|
|
197
|
+
/** Delete a media item AND its underlying blob storage object (best-effort). */
|
|
198
|
+
deleteMedia(id) {
|
|
199
|
+
return this.request('DELETE', `/media/${encodeURIComponent(id)}`);
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Bulk-update media metadata. Per-item patches (not a shared patch). Returns
|
|
203
|
+
* per-id success/failure so a large backfill can land partial results (#717).
|
|
204
|
+
*/
|
|
205
|
+
bulkUpdateMedia(items) {
|
|
206
|
+
return this.request('POST', '/media/bulk-update', { items });
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Bulk-delete media items and their blobs (best-effort). Requires
|
|
210
|
+
* `confirm: true` at the MCP tool layer; the API also requires it.
|
|
211
|
+
*/
|
|
212
|
+
bulkDeleteMedia(ids, confirm) {
|
|
213
|
+
return this.request('POST', '/media/bulk-delete', { ids, confirm });
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Recompress an existing image to WebP. Returns the updated media row plus
|
|
217
|
+
* an `optimization` summary (`{ originalSize, optimizedSize, savings, ... }`).
|
|
218
|
+
* Already-WebP files and non-images short-circuit with `alreadyOptimized: true`.
|
|
219
|
+
*/
|
|
220
|
+
optimizeMedia(id) {
|
|
221
|
+
return this.request('POST', `/media/${encodeURIComponent(id)}/optimize`);
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Move a media item into a folder, or out of every folder by passing
|
|
225
|
+
* `folderId: null`. The server validates that the target folder lives in
|
|
226
|
+
* the `media` scope.
|
|
227
|
+
*/
|
|
228
|
+
moveMediaToFolder(id, folderId) {
|
|
229
|
+
return this.request('PUT', `/media/${encodeURIComponent(id)}/folder`, { folderId });
|
|
230
|
+
}
|
|
231
|
+
// ---- Folders ----
|
|
232
|
+
/**
|
|
233
|
+
* List folders in a given scope as a nested tree (`{ ...folder, children: [...] }`).
|
|
234
|
+
* Scopes today: `'media'` for the media library, `'documents'` / `'collections'`
|
|
235
|
+
* for document folders. The `scope` query param is required by the server.
|
|
236
|
+
*/
|
|
237
|
+
listFolders(scope) {
|
|
238
|
+
return this.request('GET', '/folders', undefined, { scope });
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Create a folder. If `parentId` is supplied, it must already exist AND live
|
|
242
|
+
* in the same scope — the server refuses cross-scope reparenting.
|
|
243
|
+
*/
|
|
244
|
+
createFolder(body) {
|
|
245
|
+
return this.request('POST', '/folders', body);
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Update a folder: rename (`name`), reparent (`parentId`; pass `null` to
|
|
249
|
+
* promote to root), or reorder (`position`). The server prevents self-
|
|
250
|
+
* parenting and cross-scope reparenting.
|
|
251
|
+
*/
|
|
252
|
+
updateFolder(id, body) {
|
|
253
|
+
return this.request('PUT', `/folders/${encodeURIComponent(id)}`, body);
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Delete a folder. Refuses with HTTP 409 if the folder still contains
|
|
257
|
+
* documents, media, or sub-folders — the caller must move or delete the
|
|
258
|
+
* contents first.
|
|
259
|
+
*/
|
|
260
|
+
deleteFolder(id) {
|
|
261
|
+
return this.request('DELETE', `/folders/${encodeURIComponent(id)}`);
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Move a document into a folder, or remove it from every folder by passing
|
|
265
|
+
* `folderId: null`. Server validates that the target folder is in the
|
|
266
|
+
* documents / collections scope.
|
|
267
|
+
*/
|
|
268
|
+
moveDocumentToFolder(id, folderId) {
|
|
269
|
+
return this.request('PUT', `/documents/${encodeURIComponent(id)}/folder`, { folderId });
|
|
270
|
+
}
|
|
271
|
+
// ---- Version history ----
|
|
272
|
+
/**
|
|
273
|
+
* List immutable snapshots of a document, newest first. Each `Version`
|
|
274
|
+
* carries the full `data` payload at the time of write plus the user who
|
|
275
|
+
* made the change (joined via `relationLoadStrategy: 'join'` server-side).
|
|
276
|
+
*/
|
|
277
|
+
listVersions(collection, documentId, query) {
|
|
278
|
+
return this.request('GET', `/collections/${encodeURIComponent(collection)}/${encodeURIComponent(documentId)}/versions`, undefined, query);
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* Roll a document back to a prior version. The restore goes through the
|
|
282
|
+
* normal `updateDocument` path so it: (a) runs validation hooks, (b)
|
|
283
|
+
* creates a NEW version row capturing the restore (i.e. it never rewrites
|
|
284
|
+
* history), and (c) emits a `version_restored` event to `prenta_audit_logs`.
|
|
285
|
+
*/
|
|
286
|
+
restoreVersion(collection, documentId, versionId) {
|
|
287
|
+
return this.request('POST', `/collections/${encodeURIComponent(collection)}/${encodeURIComponent(documentId)}/versions/${encodeURIComponent(versionId)}/restore`);
|
|
288
|
+
}
|
|
289
|
+
// ---- Scheduling ----
|
|
290
|
+
/**
|
|
291
|
+
* Schedule a document to publish and/or unpublish at a future timestamp.
|
|
292
|
+
* `scheduledAt` (publish) forces `status` → `SCHEDULED` so the cron picks
|
|
293
|
+
* it up; `scheduledUnpublishAt` leaves the current status alone. The
|
|
294
|
+
* server requires at least one of the two, both must be ISO 8601 strings
|
|
295
|
+
* AND in the future, and `scheduledUnpublishAt` must be after
|
|
296
|
+
* `scheduledAt` when both are provided.
|
|
297
|
+
*/
|
|
298
|
+
schedulePublish(collection, documentId, body) {
|
|
299
|
+
return this.request('POST', `/collections/${encodeURIComponent(collection)}/${encodeURIComponent(documentId)}/schedule`, body);
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Cancel any pending publish + unpublish schedule for the document. If
|
|
303
|
+
* the document was in `SCHEDULED` (not yet published) the server drops
|
|
304
|
+
* its status back to `DRAFT`. Already-published documents stay live.
|
|
305
|
+
*/
|
|
306
|
+
unschedulePublish(collection, documentId) {
|
|
307
|
+
return this.request('DELETE', `/collections/${encodeURIComponent(collection)}/${encodeURIComponent(documentId)}/schedule`);
|
|
308
|
+
}
|
|
309
|
+
// ---- Editorial workflow ----
|
|
310
|
+
/**
|
|
311
|
+
* Read the workflow state of a document. Returns the current stage
|
|
312
|
+
* (`DRAFT` | `IN_REVIEW` | `APPROVED` | `PUBLISHED` | `ARCHIVED`), the
|
|
313
|
+
* stages the *current* user is allowed to transition to next (derived
|
|
314
|
+
* from their role), plus the reviewer ID + note if set.
|
|
315
|
+
*/
|
|
316
|
+
getWorkflowState(collection, documentId) {
|
|
317
|
+
return this.request('GET', `/collections/${encodeURIComponent(collection)}/${encodeURIComponent(documentId)}/workflow`);
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* Move a document to a new workflow stage. The server validates that the
|
|
321
|
+
* transition is allowed from the current stage AND that the caller's
|
|
322
|
+
* role permits it; if either check fails it returns HTTP 400 with the
|
|
323
|
+
* reason in the error body.
|
|
324
|
+
*/
|
|
325
|
+
transitionWorkflow(collection, documentId, body) {
|
|
326
|
+
return this.request('POST', `/collections/${encodeURIComponent(collection)}/${encodeURIComponent(documentId)}/workflow`, body);
|
|
327
|
+
}
|
|
328
|
+
// ---- Comments ----
|
|
329
|
+
/**
|
|
330
|
+
* List the comment thread attached to a document. By default returns only
|
|
331
|
+
* unresolved, non-deleted comments. `includeResolved` widens that to
|
|
332
|
+
* include resolved threads; `includeDeleted` is admin-only and surfaces
|
|
333
|
+
* soft-deleted moderation history (returns HTTP 403 for non-admin keys).
|
|
334
|
+
*/
|
|
335
|
+
listComments(documentId, query) {
|
|
336
|
+
// Server returns `{ data: CommentDTO[] }` — a plain array, NOT an
|
|
337
|
+
// envelope like `{ data: { comments, total, hasMore } }`. Encoding
|
|
338
|
+
// the array shape in the TS return type protects against future
|
|
339
|
+
// engineers / AI assistants accidentally re-wrapping it on either
|
|
340
|
+
// side of the wire.
|
|
341
|
+
return this.request('GET', `/documents/${encodeURIComponent(documentId)}/comments`, undefined,
|
|
342
|
+
// The server reads these as URL query strings. Booleans become
|
|
343
|
+
// 'true'/'false' via String(); the handler matches exactly
|
|
344
|
+
// `=== 'true'` so other truthy values are ignored.
|
|
345
|
+
query);
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* Create a comment on a document. `anchor` is optional and lets the
|
|
349
|
+
* caller pin the comment to a specific text range (used by the
|
|
350
|
+
* Prosemirror selection UI in the admin). `parentId` is set for thread
|
|
351
|
+
* replies. The server emits a `comment_created` event to
|
|
352
|
+
* `prenta_audit_logs` and fires a fire-and-forget notification dispatch.
|
|
353
|
+
*/
|
|
354
|
+
createComment(documentId, body) {
|
|
355
|
+
return this.request('POST', `/documents/${encodeURIComponent(documentId)}/comments`, body);
|
|
356
|
+
}
|
|
357
|
+
// ---- Page builder accessibility ----
|
|
358
|
+
/**
|
|
359
|
+
* Run a deterministic accessibility audit over a page-builder tree.
|
|
360
|
+
* Detects missing alt text, heading-level skips, missing form labels,
|
|
361
|
+
* missing ARIA attributes, and undersized touch targets. Returns an
|
|
362
|
+
* array of `A11yIssue` entries — each carries `autoFixable: boolean`
|
|
363
|
+
* so the caller can pair this with `fixPageA11y` for one-click cleanup.
|
|
364
|
+
*
|
|
365
|
+
* Pass the tree exactly as returned by a document's `data.tree` field;
|
|
366
|
+
* no normalisation needed.
|
|
367
|
+
*/
|
|
368
|
+
auditPageA11y(tree) {
|
|
369
|
+
return this.request('POST', '/page-builder/audit-a11y', { tree });
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* Auto-fix the accessibility issues marked `autoFixable: true` from
|
|
373
|
+
* `audit_page_a11y` and return the mutated tree plus a report
|
|
374
|
+
* (`fixedCount` / `remainingCount`). Non-auto-fixable issues are
|
|
375
|
+
* returned in `report.issues` for human follow-up. The handler emits a
|
|
376
|
+
* `settings_changed` audit event with the fix counts.
|
|
377
|
+
*/
|
|
378
|
+
fixPageA11y(tree) {
|
|
379
|
+
return this.request('POST', '/page-builder/fix-a11y', { tree });
|
|
380
|
+
}
|
|
381
|
+
// ---- Comment thread management (PR E) ----
|
|
382
|
+
/**
|
|
383
|
+
* Edit a comment body. Only the author can edit; the server returns a
|
|
384
|
+
* `forbidden` error otherwise. Resolved threads remain editable so a
|
|
385
|
+
* moderator can append context.
|
|
386
|
+
*/
|
|
387
|
+
editComment(id, body) {
|
|
388
|
+
return this.request('PATCH', `/comments/${encodeURIComponent(id)}`, {
|
|
389
|
+
body,
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
/** Mark a comment thread resolved. Requires write-role; emits `comment_resolved` audit event. */
|
|
393
|
+
resolveComment(id) {
|
|
394
|
+
return this.request('POST', `/comments/${encodeURIComponent(id)}/resolve`);
|
|
395
|
+
}
|
|
396
|
+
/** Re-open a previously resolved comment thread. Requires write-role. */
|
|
397
|
+
reopenComment(id) {
|
|
398
|
+
return this.request('POST', `/comments/${encodeURIComponent(id)}/reopen`);
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* Soft-delete a comment. Server returns `forbidden` unless the caller is
|
|
402
|
+
* the author OR an admin. Soft-deleted comments are admin-only viewable
|
|
403
|
+
* via `list_comments({ includeDeleted: true })`.
|
|
404
|
+
*/
|
|
405
|
+
deleteComment(id) {
|
|
406
|
+
return this.request('DELETE', `/comments/${encodeURIComponent(id)}`);
|
|
407
|
+
}
|
|
408
|
+
// ---- Redirects (PR E) ----
|
|
409
|
+
/** List all redirects, newest first. */
|
|
410
|
+
listRedirects() {
|
|
411
|
+
return this.request('GET', '/redirects');
|
|
412
|
+
}
|
|
413
|
+
/**
|
|
414
|
+
* Create a redirect. Admin-only. The server enforces an open-redirect
|
|
415
|
+
* defence — absolute destinations must point at a host that's either
|
|
416
|
+
* the configured `NEXT_PUBLIC_SITE_URL` or listed in
|
|
417
|
+
* `redirects.allowedExternalHosts`. Relative destinations (`/foo`) are
|
|
418
|
+
* always allowed. `statusCode` accepts 301 / 302 / 307 / 308 only and
|
|
419
|
+
* defaults to 301 on the server.
|
|
420
|
+
*/
|
|
421
|
+
createRedirect(body) {
|
|
422
|
+
return this.request('POST', '/redirects', body);
|
|
423
|
+
}
|
|
424
|
+
/** Delete a redirect by ID. Admin-only. */
|
|
425
|
+
deleteRedirect(id) {
|
|
426
|
+
return this.request('DELETE', `/redirects/${encodeURIComponent(id)}`);
|
|
427
|
+
}
|
|
428
|
+
// ---- Page templates + saved sections (PR E) ----
|
|
429
|
+
/**
|
|
430
|
+
* List page templates. Admin-only. Optional `category` filter narrows
|
|
431
|
+
* to a single template category. `collection` reorders the result so
|
|
432
|
+
* the suggested templates for that collection bubble to the top
|
|
433
|
+
* (driven by collection presets) and annotates each row with
|
|
434
|
+
* `suggested` / `suggestionRank`. When `collection` is supplied the
|
|
435
|
+
* response also carries a top-level `suggestion` summary.
|
|
436
|
+
*/
|
|
437
|
+
listPageTemplates(query) {
|
|
438
|
+
return this.request('GET', '/page-templates', undefined, query);
|
|
439
|
+
}
|
|
440
|
+
/** Fetch a single page template by ID. Admin-only. */
|
|
441
|
+
getPageTemplate(id) {
|
|
442
|
+
return this.request('GET', `/page-templates/${encodeURIComponent(id)}`);
|
|
443
|
+
}
|
|
444
|
+
/** List saved page-builder sections. Admin-only. Optional `category` filter. */
|
|
445
|
+
listSavedSections(query) {
|
|
446
|
+
return this.request('GET', '/saved-sections', undefined, query);
|
|
447
|
+
}
|
|
448
|
+
// ---- SEO aggregate (PR E) ----
|
|
449
|
+
/**
|
|
450
|
+
* Run a site-wide SEO scan over every published document. **Expensive**
|
|
451
|
+
* — touches every published row. Returns
|
|
452
|
+
* `{ total, pagesWithIssues, totalProblems, issues[] }` where each
|
|
453
|
+
* issue carries `{ documentId, title, slug, problems[] }`. The audit
|
|
454
|
+
* recommended gating this behind an explicit confirmation arg; the
|
|
455
|
+
* tool wrapper enforces that, the client is intentionally a thin
|
|
456
|
+
* pass-through.
|
|
457
|
+
*/
|
|
458
|
+
seoScanRun() {
|
|
459
|
+
return this.request('POST', '/seo/scan');
|
|
460
|
+
}
|
|
461
|
+
/**
|
|
462
|
+
* Aggregate SEO health for the dashboard — counts of missing meta
|
|
463
|
+
* descriptions / alt text plus a `topContent` array of recent
|
|
464
|
+
* published docs with light SEO scores. Cheap; safe to call on
|
|
465
|
+
* dashboard refresh.
|
|
466
|
+
*/
|
|
467
|
+
seoSummary() {
|
|
468
|
+
return this.request('GET', '/seo/summary');
|
|
469
|
+
}
|
|
470
|
+
// ---- SEO Operations Center (audit runs + issues + redirects) ----
|
|
471
|
+
/**
|
|
472
|
+
* Aggregated Overview-tab payload: site score + grade + delta, four
|
|
473
|
+
* headline metrics, open-issue summary, priority-ranked quick wins, and
|
|
474
|
+
* (gated) search-performance rows. Cheap read; derived from the last
|
|
475
|
+
* completed audit run.
|
|
476
|
+
*/
|
|
477
|
+
seoOverview() {
|
|
478
|
+
return this.request('GET', '/seo/overview');
|
|
479
|
+
}
|
|
480
|
+
/**
|
|
481
|
+
* Kick off a persistent SEO audit run. `scope` controls breadth:
|
|
482
|
+
* `full` (everything), `content`, `technical`, or `redirect`. Returns the
|
|
483
|
+
* created `SEOAuditRun` (status, issue counts, before/after site score).
|
|
484
|
+
* Heavier than `seoSummary` — persists issues for the Audit tab.
|
|
485
|
+
*/
|
|
486
|
+
runSeoAudit(scope = 'full') {
|
|
487
|
+
return this.request('POST', '/seo/audit', { scope });
|
|
488
|
+
}
|
|
489
|
+
/** List persisted SEO audit runs, newest first. */
|
|
490
|
+
listSeoAuditRuns() {
|
|
491
|
+
return this.request('GET', '/seo/audits');
|
|
492
|
+
}
|
|
493
|
+
/**
|
|
494
|
+
* List SEO issues with optional filters. `severity` (critical/warning/info),
|
|
495
|
+
* `category` (content/technical/redirects/...), `status`
|
|
496
|
+
* (open/ignored/resolved/recurring), and `auditRunId` narrow the result.
|
|
497
|
+
*/
|
|
498
|
+
listSeoIssues(query) {
|
|
499
|
+
return this.request('GET', '/seo/issues', undefined, query);
|
|
500
|
+
}
|
|
501
|
+
/** Fetch a single SEO issue by ID. */
|
|
502
|
+
getSeoIssue(id) {
|
|
503
|
+
return this.request('GET', `/seo/issues/${encodeURIComponent(id)}`);
|
|
504
|
+
}
|
|
505
|
+
/** Mark an SEO issue resolved / ignored / reopened. */
|
|
506
|
+
setSeoIssueStatus(id, action) {
|
|
507
|
+
return this.request('POST', `/seo/issues/${encodeURIComponent(id)}/${action}`);
|
|
508
|
+
}
|
|
509
|
+
/**
|
|
510
|
+
* Export a markdown SEO fix brief for dev-team handoff. Embeds cached AI
|
|
511
|
+
* content briefs and topic research when available.
|
|
512
|
+
*/
|
|
513
|
+
exportSeoFixBrief(query) {
|
|
514
|
+
const params = {};
|
|
515
|
+
if (query?.severity)
|
|
516
|
+
params.severity = query.severity;
|
|
517
|
+
if (query?.status)
|
|
518
|
+
params.status = query.status;
|
|
519
|
+
if (query?.auditRunId)
|
|
520
|
+
params.auditRunId = query.auditRunId;
|
|
521
|
+
if (query?.manualOnly === false)
|
|
522
|
+
params.manualOnly = 'false';
|
|
523
|
+
if (query?.includeContentBriefs === false)
|
|
524
|
+
params.includeContentBriefs = 'false';
|
|
525
|
+
if (query?.includeContentResearch === false)
|
|
526
|
+
params.includeContentResearch = 'false';
|
|
527
|
+
return this.request('GET', '/seo/issues/export-brief', undefined, params);
|
|
528
|
+
}
|
|
529
|
+
/** Preview a deterministic or AI-assisted inline metadata/schema fix. */
|
|
530
|
+
suggestSeoIssueFix(id) {
|
|
531
|
+
return this.request('POST', `/seo/issues/${encodeURIComponent(id)}/suggest-fix`);
|
|
532
|
+
}
|
|
533
|
+
/** Apply a previously suggested inline fix (requires matching fingerprint). */
|
|
534
|
+
applySeoIssueFix(id, fingerprint) {
|
|
535
|
+
return this.request('POST', `/seo/issues/${encodeURIComponent(id)}/apply-fix`, {
|
|
536
|
+
fingerprint,
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
/** Load a cached AI content brief for a content/heading issue. */
|
|
540
|
+
getSeoIssueContentBrief(id) {
|
|
541
|
+
return this.request('GET', `/seo/issues/${encodeURIComponent(id)}/content-brief`);
|
|
542
|
+
}
|
|
543
|
+
/** Generate (or regenerate) an AI content brief for manual content fixes. */
|
|
544
|
+
generateSeoIssueContentBrief(id) {
|
|
545
|
+
return this.request('POST', `/seo/issues/${encodeURIComponent(id)}/content-brief`);
|
|
546
|
+
}
|
|
547
|
+
/**
|
|
548
|
+
* Run topic research (thin-content) then generate a content brief in one step.
|
|
549
|
+
* Optional competitor URLs are fetched SSRF-safe (max 3).
|
|
550
|
+
*/
|
|
551
|
+
researchAndBriefSeoIssue(id, competitorUrls) {
|
|
552
|
+
return this.request('POST', `/seo/issues/${encodeURIComponent(id)}/research-and-brief`, { competitorUrls: competitorUrls?.filter(Boolean) });
|
|
553
|
+
}
|
|
554
|
+
/** Batch-generate content briefs for open issues (skips fresh cached briefs). */
|
|
555
|
+
batchGenerateSeoContentBriefs(body) {
|
|
556
|
+
return this.request('POST', '/seo/issues/batch-content-briefs', body ?? {});
|
|
557
|
+
}
|
|
558
|
+
/** List redirect rules with hit counts + stale flags. */
|
|
559
|
+
listRedirectRules() {
|
|
560
|
+
return this.request('GET', '/seo/redirects');
|
|
561
|
+
}
|
|
562
|
+
/** Redirect overview counts (active / stale / 404s / chains). */
|
|
563
|
+
redirectOverview() {
|
|
564
|
+
return this.request('GET', '/seo/redirects/overview');
|
|
565
|
+
}
|
|
566
|
+
/** Detected redirect chains and loops with safe-flatten suggestions. */
|
|
567
|
+
redirectChains() {
|
|
568
|
+
return this.request('GET', '/seo/redirects/chains');
|
|
569
|
+
}
|
|
570
|
+
/** Flatten a redirect chain to a single hop (updates the first rule's destination). */
|
|
571
|
+
flattenRedirectChain(chainId) {
|
|
572
|
+
return this.request('POST', '/seo/redirects/flatten', { chainId });
|
|
573
|
+
}
|
|
574
|
+
/** AI-generated 404 recovery suggestions awaiting review. */
|
|
575
|
+
listRedirectSuggestions() {
|
|
576
|
+
return this.request('GET', '/seo/redirects/suggestions');
|
|
577
|
+
}
|
|
578
|
+
getRedirectRecoverySettings() {
|
|
579
|
+
return this.request('GET', '/seo/redirects/recovery-settings');
|
|
580
|
+
}
|
|
581
|
+
updateRedirectRecoverySettings(body) {
|
|
582
|
+
return this.request('PUT', '/seo/redirects/recovery-settings', body);
|
|
583
|
+
}
|
|
584
|
+
/** Scan tracked 404s and generate recovery redirect suggestions. */
|
|
585
|
+
generateRedirectRecoverySuggestions() {
|
|
586
|
+
return this.request('POST', '/seo/redirects/suggest');
|
|
587
|
+
}
|
|
588
|
+
acceptRedirectSuggestion(id) {
|
|
589
|
+
return this.request('POST', `/seo/redirects/suggestions/${encodeURIComponent(id)}/accept`);
|
|
590
|
+
}
|
|
591
|
+
dismissRedirectSuggestion(id) {
|
|
592
|
+
return this.request('POST', `/seo/redirects/suggestions/${encodeURIComponent(id)}/dismiss`);
|
|
593
|
+
}
|
|
594
|
+
exportRedirectsCsv() {
|
|
595
|
+
return this.request('GET', '/seo/redirects/export');
|
|
596
|
+
}
|
|
597
|
+
importRedirectsCsv(csv) {
|
|
598
|
+
return this.request('POST', '/seo/redirects/import', { csv });
|
|
599
|
+
}
|
|
600
|
+
updateRedirectRule(id, body) {
|
|
601
|
+
return this.request('PUT', `/seo/redirects/${encodeURIComponent(id)}`, body);
|
|
602
|
+
}
|
|
603
|
+
/** Site-wide internal/external link graph snapshot (Links tab). */
|
|
604
|
+
seoLinkSnapshot() {
|
|
605
|
+
return this.request('GET', '/seo/links/snapshot');
|
|
606
|
+
}
|
|
607
|
+
/** Broken / suspicious outbound link scan (bounded batch). */
|
|
608
|
+
seoLinkHealth() {
|
|
609
|
+
return this.request('GET', '/seo/link-health');
|
|
610
|
+
}
|
|
611
|
+
seoLinkSuggest(body) {
|
|
612
|
+
return this.request('POST', '/seo/links/suggest', body);
|
|
613
|
+
}
|
|
614
|
+
/** Build or refresh the AI link index used for contextual suggestions. */
|
|
615
|
+
seoLinkAiIndex() {
|
|
616
|
+
return this.request('POST', '/seo/links/ai-index');
|
|
617
|
+
}
|
|
618
|
+
getCrawlSettings() {
|
|
619
|
+
return this.request('GET', '/seo/crawl-settings');
|
|
620
|
+
}
|
|
621
|
+
updateCrawlSettings(body) {
|
|
622
|
+
return this.request('PUT', '/seo/crawl-settings', body);
|
|
623
|
+
}
|
|
624
|
+
getRobotsTxt() {
|
|
625
|
+
return this.request('GET', '/seo/robots');
|
|
626
|
+
}
|
|
627
|
+
updateRobotsTxt(body) {
|
|
628
|
+
return this.request('PUT', '/seo/robots', { body });
|
|
629
|
+
}
|
|
630
|
+
getSitemapStatus() {
|
|
631
|
+
return this.request('GET', '/seo/sitemap/status');
|
|
632
|
+
}
|
|
633
|
+
regenerateSitemap() {
|
|
634
|
+
return this.request('POST', '/seo/sitemap/regenerate');
|
|
635
|
+
}
|
|
636
|
+
listSeoContentRecords() {
|
|
637
|
+
return this.request('GET', '/seo/content');
|
|
638
|
+
}
|
|
639
|
+
getSeoContentRecord(entityType, entityId) {
|
|
640
|
+
return this.request('GET', `/seo/content/${encodeURIComponent(entityType)}/${encodeURIComponent(entityId)}`);
|
|
641
|
+
}
|
|
642
|
+
updateSeoContentFields(entityType, entityId, body) {
|
|
643
|
+
return this.request('PUT', `/seo/content/${encodeURIComponent(entityType)}/${encodeURIComponent(entityId)}`, body);
|
|
644
|
+
}
|
|
645
|
+
aiSeoGenerateField(body) {
|
|
646
|
+
return this.request('POST', '/ai/seo/generate-field', body);
|
|
647
|
+
}
|
|
648
|
+
aiSeoExplainIssue(issueId) {
|
|
649
|
+
return this.request('POST', '/ai/seo/explain-issue', { issueId });
|
|
650
|
+
}
|
|
651
|
+
aiSeoGenerateSchema(body) {
|
|
652
|
+
return this.request('POST', '/ai/seo/generate-schema', body);
|
|
653
|
+
}
|
|
654
|
+
aiSeoSummarize() {
|
|
655
|
+
return this.request('POST', '/ai/seo/summarize');
|
|
656
|
+
}
|
|
657
|
+
// ---- Forms + health (PR E) ----
|
|
658
|
+
/**
|
|
659
|
+
* List submissions for a form. **PII-sensitive** — form submissions
|
|
660
|
+
* typically contain names, emails, free-form messages, and any other
|
|
661
|
+
* fields the form collects. Requires write-role on the API key AND
|
|
662
|
+
* an explicit `formId` (no enumeration). Paged.
|
|
663
|
+
*/
|
|
664
|
+
listFormSubmissions(formId, query) {
|
|
665
|
+
return this.request('GET', `/forms/${encodeURIComponent(formId)}/submissions`, undefined, query);
|
|
666
|
+
}
|
|
667
|
+
/**
|
|
668
|
+
* List entries from the submissions inbox (decrypted PII, attribution,
|
|
669
|
+
* read/star/archive state). `formId` is required at the tool boundary to
|
|
670
|
+
* prevent accidental cross-form enumeration. Returns
|
|
671
|
+
* `{ data: { entries, total, page, pageSize } }`.
|
|
672
|
+
*/
|
|
673
|
+
listFormEntries(query) {
|
|
674
|
+
const params = {
|
|
675
|
+
formId: query.formId,
|
|
676
|
+
page: query.page,
|
|
677
|
+
pageSize: query.pageSize,
|
|
678
|
+
spamStatus: query.spamStatus,
|
|
679
|
+
unread: query.unread === undefined ? undefined : String(query.unread),
|
|
680
|
+
starred: query.starred === undefined ? undefined : String(query.starred),
|
|
681
|
+
archived: query.archived === undefined ? undefined : String(query.archived),
|
|
682
|
+
};
|
|
683
|
+
return this.request('GET', '/forms/entries', undefined, params);
|
|
684
|
+
}
|
|
685
|
+
/**
|
|
686
|
+
* Read a single entry plus the schema version it was captured against.
|
|
687
|
+
* Returns `{ data: { entry, fields } }`. The server audits this read as
|
|
688
|
+
* `form.entry.viewed` because it exposes decrypted PII.
|
|
689
|
+
*/
|
|
690
|
+
getFormEntry(entryId) {
|
|
691
|
+
return this.request('GET', `/forms/entries/${encodeURIComponent(entryId)}`);
|
|
692
|
+
}
|
|
693
|
+
// ---- Form authoring (WS-C C1) ----
|
|
694
|
+
//
|
|
695
|
+
// The read-only form tools above (submissions / entries) let an agent
|
|
696
|
+
// INSPECT a form's data. These let it AUTHOR the form itself: create the
|
|
697
|
+
// definition, edit its metadata, version its field schema, manage the
|
|
698
|
+
// inbox state, and export. All map to the `/forms/*` admin endpoints,
|
|
699
|
+
// which require a write-role API key server-side.
|
|
700
|
+
/**
|
|
701
|
+
* List form definitions (NOT submissions). Returns
|
|
702
|
+
* `{ data: Form[], total, page, pageSize }` where each form carries its
|
|
703
|
+
* field schema plus the legacy `fieldCount` / `submissions` aliases. Use
|
|
704
|
+
* this to discover form IDs before reading entries or editing a schema.
|
|
705
|
+
*/
|
|
706
|
+
listForms(query) {
|
|
707
|
+
return this.request('GET', '/forms', undefined, query);
|
|
708
|
+
}
|
|
709
|
+
/** Fetch a single form definition by ID (metadata + field schema). */
|
|
710
|
+
getForm(id) {
|
|
711
|
+
return this.request('GET', `/forms/${encodeURIComponent(id)}`);
|
|
712
|
+
}
|
|
713
|
+
/**
|
|
714
|
+
* Create a form. Only `name` is required server-side; the slug is derived
|
|
715
|
+
* from `name` when omitted. `fields` is the initial field schema (see
|
|
716
|
+
* `saveFormSchema` for the field shape) — omit to start with an empty
|
|
717
|
+
* form and add the schema in a second call.
|
|
718
|
+
*/
|
|
719
|
+
createForm(body) {
|
|
720
|
+
return this.request('POST', '/forms', body);
|
|
721
|
+
}
|
|
722
|
+
/**
|
|
723
|
+
* Update a form's metadata. Only the supplied fields change. Note the
|
|
724
|
+
* field SCHEMA is NOT editable here — use `saveFormSchema` so each schema
|
|
725
|
+
* change is versioned.
|
|
726
|
+
*/
|
|
727
|
+
updateForm(id, body) {
|
|
728
|
+
return this.request('PATCH', `/forms/${encodeURIComponent(id)}`, body);
|
|
729
|
+
}
|
|
730
|
+
/**
|
|
731
|
+
* Delete a form. By default the server refuses (HTTP 409) when the form
|
|
732
|
+
* has submissions; pass `force: true` to delete it and its entries.
|
|
733
|
+
*/
|
|
734
|
+
deleteForm(id, force) {
|
|
735
|
+
return this.request('DELETE', `/forms/${encodeURIComponent(id)}`, undefined, force ? { force: 'true' } : undefined);
|
|
736
|
+
}
|
|
737
|
+
/** Duplicate a form (new draft copy with a fresh slug). Returns the new form. */
|
|
738
|
+
duplicateForm(id) {
|
|
739
|
+
return this.request('POST', `/forms/${encodeURIComponent(id)}/duplicate`);
|
|
740
|
+
}
|
|
741
|
+
/**
|
|
742
|
+
* Read a form's field schema plus its version history:
|
|
743
|
+
* `{ fields, activeVersionId, versions }`. Call before `saveFormSchema`
|
|
744
|
+
* to avoid clobbering the live schema.
|
|
745
|
+
*/
|
|
746
|
+
getFormSchema(id) {
|
|
747
|
+
return this.request('GET', `/forms/${encodeURIComponent(id)}/schema`);
|
|
748
|
+
}
|
|
749
|
+
/**
|
|
750
|
+
* Replace a form's field schema. Creates a NEW schema version (history is
|
|
751
|
+
* never rewritten) and points the form at it. `fields` is the full
|
|
752
|
+
* ordered field list — partial patches are not supported (read with
|
|
753
|
+
* `getFormSchema`, mutate, write back the whole array).
|
|
754
|
+
*/
|
|
755
|
+
saveFormSchema(id, fields, notes) {
|
|
756
|
+
const body = { fields };
|
|
757
|
+
if (notes !== undefined)
|
|
758
|
+
body.notes = notes;
|
|
759
|
+
return this.request('PUT', `/forms/${encodeURIComponent(id)}/schema`, body);
|
|
760
|
+
}
|
|
761
|
+
/**
|
|
762
|
+
* Update inbox state on a single submission entry (mark read/unread,
|
|
763
|
+
* star, archive). Does not touch the captured field values.
|
|
764
|
+
*/
|
|
765
|
+
updateFormEntry(entryId, patch) {
|
|
766
|
+
return this.request('PATCH', `/forms/entries/${encodeURIComponent(entryId)}`, patch);
|
|
767
|
+
}
|
|
768
|
+
/** Apply the same inbox-state patch to many entries at once. Returns `{ updated }`. */
|
|
769
|
+
bulkUpdateFormEntries(ids, patch) {
|
|
770
|
+
return this.request('POST', '/forms/entries/bulk', {
|
|
771
|
+
ids,
|
|
772
|
+
patch,
|
|
773
|
+
});
|
|
774
|
+
}
|
|
775
|
+
/** Permanently delete a submission entry (and its decrypted PII). Destructive. */
|
|
776
|
+
deleteFormEntry(entryId) {
|
|
777
|
+
return this.request('DELETE', `/forms/entries/${encodeURIComponent(entryId)}`);
|
|
778
|
+
}
|
|
779
|
+
/**
|
|
780
|
+
* Export every submission for a form as CSV. The endpoint returns a raw
|
|
781
|
+
* `text/csv` body (NOT a JSON envelope), so we read it as text rather
|
|
782
|
+
* than via `request()`. **Bulk PII egress** — the server records a
|
|
783
|
+
* `form.entries.exported` audit event. Returns the CSV text plus the
|
|
784
|
+
* server-suggested filename.
|
|
785
|
+
*/
|
|
786
|
+
async exportFormEntries(formId) {
|
|
787
|
+
const url = `${this.baseUrl}/api/cms/forms/entries/export?formId=${encodeURIComponent(formId)}`;
|
|
788
|
+
const res = await this.fetchImpl(url, { method: 'GET', headers: this.headers() });
|
|
789
|
+
const text = await res.text();
|
|
790
|
+
if (!res.ok) {
|
|
791
|
+
// Error bodies ARE JSON even though the success body is CSV.
|
|
792
|
+
let msg = `Prenta API GET /forms/entries/export failed: ${res.status}`;
|
|
793
|
+
try {
|
|
794
|
+
const parsed = JSON.parse(text);
|
|
795
|
+
if (parsed?.error)
|
|
796
|
+
msg = parsed.error;
|
|
797
|
+
}
|
|
798
|
+
catch {
|
|
799
|
+
/* non-JSON error body — keep the status-based message */
|
|
800
|
+
}
|
|
801
|
+
throw new PrentaClientError(msg, res.status);
|
|
802
|
+
}
|
|
803
|
+
const disposition = res.headers.get('content-disposition') ?? '';
|
|
804
|
+
const match = /filename="?([^"]+)"?/i.exec(disposition);
|
|
805
|
+
return { filename: match?.[1] ?? `${formId}-entries.csv`, csv: text };
|
|
806
|
+
}
|
|
807
|
+
/**
|
|
808
|
+
* Read the deployment health of the CMS. Unauthenticated on the server
|
|
809
|
+
* side (still safe to call — exposes only operational status, no
|
|
810
|
+
* tenant data). Returns `{ status, version, env, models,
|
|
811
|
+
* databaseConnected, secretConfigured }`.
|
|
812
|
+
*
|
|
813
|
+
* Recommended as an agent's first call: a `degraded` / `unhealthy`
|
|
814
|
+
* status surfaces operator-level config problems early instead of
|
|
815
|
+
* surfacing as cryptic errors deep in a workflow.
|
|
816
|
+
*/
|
|
817
|
+
healthCheck() {
|
|
818
|
+
return this.request('GET', '/health');
|
|
819
|
+
}
|
|
820
|
+
// ---- Page builder ----
|
|
821
|
+
generatePage(input) {
|
|
822
|
+
return this.request('POST', '/page-builder/generate', input);
|
|
823
|
+
}
|
|
824
|
+
generateBlock(input) {
|
|
825
|
+
return this.request('POST', '/page-builder/generate-block', input);
|
|
826
|
+
}
|
|
827
|
+
/** One-shot: generate AI page AND persist as a draft (or published) document. */
|
|
828
|
+
createPageFromPrompt(input) {
|
|
829
|
+
return this.request('POST', '/page-builder/create', input);
|
|
830
|
+
}
|
|
831
|
+
/**
|
|
832
|
+
* Generic AI content authoring for any non-page collection. Used by the
|
|
833
|
+
* per-type MCP tools (create_blog_post, create_case_study, etc.) and by the
|
|
834
|
+
* generic create_in_collection tool.
|
|
835
|
+
*/
|
|
836
|
+
createDocumentFromPrompt(collection, input) {
|
|
837
|
+
return this.request('POST', `/collections/${encodeURIComponent(collection)}/ai-create`, input);
|
|
838
|
+
}
|
|
839
|
+
// ---- AI quality (Slice F) ----
|
|
840
|
+
/**
|
|
841
|
+
* Run the composite content audit (SEO + readability + a11y + freshness)
|
|
842
|
+
* against a stored document or ad-hoc content. Deterministic — no LLM call,
|
|
843
|
+
* safe to invoke on every save.
|
|
844
|
+
*/
|
|
845
|
+
auditDocument(input) {
|
|
846
|
+
return this.request('POST', '/ai/audit-document', input);
|
|
847
|
+
}
|
|
848
|
+
/**
|
|
849
|
+
* Inline AI co-author. Wraps the plugin-ai writing assistant
|
|
850
|
+
* (rewrite/expand/compress/proofread) behind a single HTTP surface.
|
|
851
|
+
*/
|
|
852
|
+
coauthor(input) {
|
|
853
|
+
return this.request('POST', '/ai/coauthor', input);
|
|
854
|
+
}
|
|
855
|
+
/**
|
|
856
|
+
* Suggest internal links from a body of text against the published CMS
|
|
857
|
+
* documents the API key can read. Optionally scope to a single collection.
|
|
858
|
+
*/
|
|
859
|
+
suggestInternalLinks(input) {
|
|
860
|
+
return this.request('POST', '/ai/suggest-internal-links', input);
|
|
861
|
+
}
|
|
862
|
+
// ---- Schema discovery ----
|
|
863
|
+
/** Fetch the auto-generated OpenAPI spec, useful for tools listing collections + fields. */
|
|
864
|
+
getOpenApi() {
|
|
865
|
+
return this.request('GET', '/openapi.json');
|
|
866
|
+
}
|
|
867
|
+
/**
|
|
868
|
+
* List configured collections via the dedicated `/collections`
|
|
869
|
+
* discovery endpoint. Returns `{ data: CollectionMeta[] }` with one
|
|
870
|
+
* entry per configured collection (`slug`, `labels`, `type`, `fields`,
|
|
871
|
+
* `fieldCount`, `hidden`). Sourced directly from `getPrentaConfig()`
|
|
872
|
+
* server-side, so it stays accurate even when OpenAPI spec generation
|
|
873
|
+
* is missing routes. Newer endpoint — older CMS servers may return
|
|
874
|
+
* 404; `list_collections` falls back to OpenAPI in that case.
|
|
875
|
+
*/
|
|
876
|
+
listCollections() {
|
|
877
|
+
return this.request('GET', '/collections');
|
|
878
|
+
}
|
|
879
|
+
/**
|
|
880
|
+
* Lightweight user directory lookup used by the comment mention
|
|
881
|
+
* picker. Returns up to 10 active users whose name OR email matches
|
|
882
|
+
* `query` (case-insensitive `contains`). Empty / whitespace
|
|
883
|
+
* queries return an empty array — the endpoint deliberately won't
|
|
884
|
+
* dump the whole user table.
|
|
885
|
+
*
|
|
886
|
+
* Exposed via the `search_users` MCP tool so AI agents can resolve
|
|
887
|
+
* `@<userId>` mentions when drafting comments.
|
|
888
|
+
*/
|
|
889
|
+
searchUsers(query) {
|
|
890
|
+
return this.request('GET', '/users/search', undefined, { q: query });
|
|
891
|
+
}
|
|
892
|
+
/** Resolve a public URL path to its document. */
|
|
893
|
+
resolveUrl(path) {
|
|
894
|
+
return this.request('GET', '/resolve', undefined, { path });
|
|
895
|
+
}
|
|
896
|
+
/**
|
|
897
|
+
* Soft-check navigation item URLs against published documents + redirects.
|
|
898
|
+
* Returns warnings only — never blocks save. Requires cms-core with
|
|
899
|
+
* `POST /navigations/validate-targets`.
|
|
900
|
+
*/
|
|
901
|
+
validateNavigationTargets(items) {
|
|
902
|
+
return this.request('POST', '/navigations/validate-targets', { items });
|
|
903
|
+
}
|
|
904
|
+
// ---- SEO analysis (per-document) ----
|
|
905
|
+
//
|
|
906
|
+
// These mirror the five GET /seo/<scope>/:documentId endpoints in
|
|
907
|
+
// `packages/cms-core/src/api/handlers.ts`. Unlike most other CMS endpoints,
|
|
908
|
+
// these return the raw analysis object directly (no `{ data: ... }`
|
|
909
|
+
// envelope) — we keep that shape so MCP tools forward the API response as-is.
|
|
910
|
+
/** Run the deterministic SEO content analysis against a stored document. */
|
|
911
|
+
seoAnalyzeDocument(documentId) {
|
|
912
|
+
return this.request('GET', `/seo/analysis/${encodeURIComponent(documentId)}`);
|
|
913
|
+
}
|
|
914
|
+
/** Compute a Flesch-Kincaid / grade-level readability score for a document body. */
|
|
915
|
+
seoReadability(documentId) {
|
|
916
|
+
return this.request('GET', `/seo/readability/${encodeURIComponent(documentId)}`);
|
|
917
|
+
}
|
|
918
|
+
/**
|
|
919
|
+
* Suggest internal-link targets for a document. Title-keyword-based (extracts
|
|
920
|
+
* up to 5 keywords > 3 chars from the doc title, matches them against
|
|
921
|
+
* other PUBLISHED documents). Returns up to 10 suggestions. Distinct from
|
|
922
|
+
* `suggestInternalLinks` above, which is content-based and freeform.
|
|
923
|
+
*
|
|
924
|
+
* `title` and `slug` are typed nullable because the underlying Prisma
|
|
925
|
+
* `Document` schema makes them optional (`String?`) — the API forwards
|
|
926
|
+
* the raw column values, so callers must handle nulls before rendering
|
|
927
|
+
* anchor text or building target URLs.
|
|
928
|
+
*/
|
|
929
|
+
seoInternalLinks(documentId) {
|
|
930
|
+
return this.request('GET', `/seo/internal-links/${encodeURIComponent(documentId)}`);
|
|
931
|
+
}
|
|
932
|
+
/** Build the OG / Twitter / canonical meta-tag preview for a document. */
|
|
933
|
+
seoMetaPreview(documentId) {
|
|
934
|
+
return this.request('GET', `/seo/meta/${encodeURIComponent(documentId)}`);
|
|
935
|
+
}
|
|
936
|
+
/** Build the Schema.org JSON-LD graph for a document. */
|
|
937
|
+
seoSchemaGraph(documentId) {
|
|
938
|
+
return this.request('GET', `/seo/schema/${encodeURIComponent(documentId)}`);
|
|
939
|
+
}
|
|
940
|
+
// ---- Page sections (flat Page Editor model) ----
|
|
941
|
+
//
|
|
942
|
+
// These wrap the `/page-sections/*` endpoints. A page is a document in a
|
|
943
|
+
// collection that declares a `sections` JSON field; sections are an
|
|
944
|
+
// ordered list the visual Page Editor and these endpoints both read/write.
|
|
945
|
+
// Mutations go through the audited document update path server-side.
|
|
946
|
+
/**
|
|
947
|
+
* Discover the registered section types + their content/field schemas.
|
|
948
|
+
* Each entry carries `id`, `name`, `description`, `category`, `scope`,
|
|
949
|
+
* `fields` (the inspector schema), and `defaultContent` / `defaultSettings`.
|
|
950
|
+
*
|
|
951
|
+
* Pass `scope: 'page'` or `scope: 'post'` to list only the types usable on
|
|
952
|
+
* that surface (so an agent building a post template doesn't pick a
|
|
953
|
+
* page-only type, and vice versa).
|
|
954
|
+
*/
|
|
955
|
+
listSectionTypes(scope) {
|
|
956
|
+
return this.request('GET', '/page-sections/types', undefined, scope ? { scope } : undefined);
|
|
957
|
+
}
|
|
958
|
+
/**
|
|
959
|
+
* Validate section content against its type schema. Deterministic (no
|
|
960
|
+
* LLM). Returns `{ valid, errors, warnings }` where each issue is
|
|
961
|
+
* `{ sectionType, field?, message }`. An unknown `sectionType` is a hard
|
|
962
|
+
* error (no registered renderer).
|
|
963
|
+
*/
|
|
964
|
+
validateSection(input) {
|
|
965
|
+
return this.request('POST', '/page-sections/validate', input);
|
|
966
|
+
}
|
|
967
|
+
/** List a page's ordered sections (`data.sections`). */
|
|
968
|
+
listPageSections(collection, id) {
|
|
969
|
+
return this.request('GET', `/page-sections/${encodeURIComponent(collection)}/${encodeURIComponent(id)}`);
|
|
970
|
+
}
|
|
971
|
+
/**
|
|
972
|
+
* Add a section to a page. The server seeds registry defaults for
|
|
973
|
+
* `sectionType`, shallow-merges the supplied `content` / `settings`,
|
|
974
|
+
* generates an id, inserts at `position` (default end), and persists.
|
|
975
|
+
* Returns `{ section, sections, validation }`.
|
|
976
|
+
*/
|
|
977
|
+
addPageSection(collection, id, body) {
|
|
978
|
+
return this.request('POST', `/page-sections/${encodeURIComponent(collection)}/${encodeURIComponent(id)}`, body);
|
|
979
|
+
}
|
|
980
|
+
/**
|
|
981
|
+
* Update a section in place. Only supplied fields change; `content` /
|
|
982
|
+
* `settings` are shallow-merged. Returns `{ section, sections, validation }`.
|
|
983
|
+
*/
|
|
984
|
+
updatePageSection(collection, id, sectionId, body) {
|
|
985
|
+
return this.request('PATCH', `/page-sections/${encodeURIComponent(collection)}/${encodeURIComponent(id)}/${encodeURIComponent(sectionId)}`, body);
|
|
986
|
+
}
|
|
987
|
+
/** Reorder a page's sections to an explicit id order. Returns the new array. */
|
|
988
|
+
reorderPageSections(collection, id, orderedIds) {
|
|
989
|
+
return this.request('POST', `/page-sections/${encodeURIComponent(collection)}/${encodeURIComponent(id)}/reorder`, { orderedIds });
|
|
990
|
+
}
|
|
991
|
+
/** Remove a section from a page. Returns the remaining sections. */
|
|
992
|
+
removePageSection(collection, id, sectionId) {
|
|
993
|
+
return this.request('DELETE', `/page-sections/${encodeURIComponent(collection)}/${encodeURIComponent(id)}/${encodeURIComponent(sectionId)}`);
|
|
994
|
+
}
|
|
995
|
+
// ---- Post templates ----
|
|
996
|
+
//
|
|
997
|
+
// A post type's template (one per type) stores the design-driven header
|
|
998
|
+
// layout + the default section list new posts inherit. Build a template
|
|
999
|
+
// from `scope: 'post'` section types, then authors/agents create posts
|
|
1000
|
+
// that start from it.
|
|
1001
|
+
/**
|
|
1002
|
+
* Read a post type's template: `{ postType, docId, header, sections }`.
|
|
1003
|
+
* Returns an unsaved default (centered header, empty section list) when
|
|
1004
|
+
* the type has no saved template yet (`docId: null`).
|
|
1005
|
+
*/
|
|
1006
|
+
getPostTemplate(postType) {
|
|
1007
|
+
return this.request('GET', `/post-templates/${encodeURIComponent(postType)}`);
|
|
1008
|
+
}
|
|
1009
|
+
/**
|
|
1010
|
+
* Create or replace a post type's template. `header` is coerced to a full
|
|
1011
|
+
* header config (missing keys filled with defaults) and `sections` are
|
|
1012
|
+
* coerced (unknown section types dropped) so the stored template always
|
|
1013
|
+
* renders. Use only `scope: 'post'` section types in `sections`.
|
|
1014
|
+
*/
|
|
1015
|
+
savePostTemplate(postType, body) {
|
|
1016
|
+
return this.request('PUT', `/post-templates/${encodeURIComponent(postType)}`, body);
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
//# sourceMappingURL=client.js.map
|