@gravitykit/block-mcp 2.0.0-beta
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/.env.example +15 -0
- package/LICENSE +26 -0
- package/README.md +592 -0
- package/dist/index.cjs +52721 -0
- package/package.json +70 -0
- package/src/__tests__/fixtures/block-trees.ts +199 -0
- package/src/__tests__/fixtures/error-envelopes.ts +115 -0
- package/src/__tests__/fixtures/rest-responses.ts +280 -0
- package/src/__tests__/helpers/mock-client.ts +185 -0
- package/src/__tests__/helpers/request-matchers.ts +88 -0
- package/src/__tests__/helpers/schema-asserts.ts +132 -0
- package/src/__tests__/integration/concurrency.test.ts +129 -0
- package/src/__tests__/integration/dual-storage.test.ts +156 -0
- package/src/__tests__/integration/error-envelopes.test.ts +238 -0
- package/src/__tests__/integration/global-setup.ts +17 -0
- package/src/__tests__/integration/rate-limit.test.ts +88 -0
- package/src/__tests__/integration/read-edit-read.test.ts +141 -0
- package/src/__tests__/integration/ref-stability.test.ts +175 -0
- package/src/__tests__/integration/setup.ts +201 -0
- package/src/__tests__/tools/discovery/get_pattern.test.ts +58 -0
- package/src/__tests__/tools/discovery/get_post_info.test.ts +100 -0
- package/src/__tests__/tools/discovery/get_site_usage.test.ts +41 -0
- package/src/__tests__/tools/discovery/list_block_types.test.ts +103 -0
- package/src/__tests__/tools/discovery/list_patterns.test.ts +106 -0
- package/src/__tests__/tools/discovery/list_posts.test.ts +47 -0
- package/src/__tests__/tools/discovery/resolve_url.test.ts +69 -0
- package/src/__tests__/tools/discovery/scan_storage_modes.test.ts +34 -0
- package/src/__tests__/tools/media/upload_media.test.ts +123 -0
- package/src/__tests__/tools/mutate/edit_block_tree.test.ts +439 -0
- package/src/__tests__/tools/mutate/ref_routing.test.ts +105 -0
- package/src/__tests__/tools/patterns/insert_pattern.test.ts +117 -0
- package/src/__tests__/tools/posts/create_post.test.ts +84 -0
- package/src/__tests__/tools/posts/update_post.test.ts +93 -0
- package/src/__tests__/tools/read/get_block.test.ts +96 -0
- package/src/__tests__/tools/read/get_page_blocks.test.ts +184 -0
- package/src/__tests__/tools/read/persist_refs.test.ts +35 -0
- package/src/__tests__/tools/terms/list_terms.test.ts +91 -0
- package/src/__tests__/tools/write/delete_block.test.ts +91 -0
- package/src/__tests__/tools/write/insert_blocks.test.ts +149 -0
- package/src/__tests__/tools/write/ref_routing.test.ts +177 -0
- package/src/__tests__/tools/write/replace_block_range.test.ts +90 -0
- package/src/__tests__/tools/write/rewrite_post_blocks.test.ts +126 -0
- package/src/__tests__/tools/write/update_block.test.ts +206 -0
- package/src/__tests__/tools/write/update_blocks.test.ts +173 -0
- package/src/__tests__/tools/yoast/yoast_bulk_update_seo.test.ts +112 -0
- package/src/__tests__/tools/yoast/yoast_get_seo.test.ts +78 -0
- package/src/__tests__/tools/yoast/yoast_update_seo.test.ts +105 -0
- package/src/__tests__/unit/client/ref-endpoints.test.ts +232 -0
- package/src/__tests__/unit/enrichers/cbp-enricher.test.ts +457 -0
- package/src/__tests__/unit/error-translator/translate-wp-error.test.ts +318 -0
- package/src/__tests__/unit/instructions.test.ts +374 -0
- package/src/__tests__/unit/preferences/enrich-block-list.test.ts +175 -0
- package/src/__tests__/unit/preferences/enrich-pattern-list.test.ts +227 -0
- package/src/client.ts +964 -0
- package/src/connect.ts +877 -0
- package/src/enrichers.ts +348 -0
- package/src/error-translator.ts +156 -0
- package/src/index.ts +450 -0
- package/src/instructions.ts +270 -0
- package/src/preferences.ts +273 -0
- package/src/tools/discovery.ts +251 -0
- package/src/tools/media.ts +75 -0
- package/src/tools/mutate.ts +243 -0
- package/src/tools/patterns.ts +94 -0
- package/src/tools/posts.ts +200 -0
- package/src/tools/read.ts +201 -0
- package/src/tools/terms.ts +44 -0
- package/src/tools/write.ts +542 -0
- package/src/tools/yoast.ts +224 -0
- package/src/types.ts +862 -0
package/src/client.ts
ADDED
|
@@ -0,0 +1,964 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WordPress Block API Client
|
|
3
|
+
*
|
|
4
|
+
* HTTP client for the gk-block-api WordPress REST plugin.
|
|
5
|
+
* Handles authentication via Application Passwords and provides
|
|
6
|
+
* typed methods for every REST endpoint.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import axios, { AxiosInstance, AxiosError, AxiosRequestConfig } from 'axios';
|
|
10
|
+
import { translateWpError } from './error-translator.js';
|
|
11
|
+
|
|
12
|
+
/** Max retry attempts for transient server / network errors. */
|
|
13
|
+
const MAX_RETRIES = 2;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Verbs safe to retry without risking duplicate or wrong work.
|
|
17
|
+
*
|
|
18
|
+
* DELETE is intentionally NOT here: `delete_block` deletes by flat index, so if
|
|
19
|
+
* the server commits the delete but the response is lost (timeout / 502), a
|
|
20
|
+
* replay removes the *next* block (indices shift after the first delete). The
|
|
21
|
+
* delete_block tool mirrors this with `idempotentHint: false`.
|
|
22
|
+
*/
|
|
23
|
+
const IDEMPOTENT_METHODS = new Set(['get', 'head', 'options']);
|
|
24
|
+
|
|
25
|
+
/** Sleep for `ms` milliseconds. */
|
|
26
|
+
function sleep(ms: number): Promise<void> {
|
|
27
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Backoff delay (ms) for retry `attempt` (1-indexed). Exponential with jitter:
|
|
32
|
+
* attempt 1 → ~500ms ± 25%
|
|
33
|
+
* attempt 2 → ~1500ms ± 25%
|
|
34
|
+
* Jitter avoids thundering-herd retries from many MCP clients hitting the
|
|
35
|
+
* same WP host simultaneously.
|
|
36
|
+
*/
|
|
37
|
+
function backoffMs(attempt: number): number {
|
|
38
|
+
const base = 500 * 3 ** (attempt - 1);
|
|
39
|
+
const jitter = base * 0.25 * (Math.random() * 2 - 1);
|
|
40
|
+
return Math.round(base + jitter);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Decide whether an axios error is retryable.
|
|
45
|
+
*
|
|
46
|
+
* Policy:
|
|
47
|
+
* - 429 (rate limited) → retry on any method. The WP plugin returns 429
|
|
48
|
+
* BEFORE doing any work, so a retry is safe even for writes.
|
|
49
|
+
* - 502 / 503 / 504 (server overload / gateway issues) → retry only
|
|
50
|
+
* idempotent methods. The server may or may not have processed the
|
|
51
|
+
* request; replaying a write could double-apply.
|
|
52
|
+
* - Network errors with no response (ECONNRESET, ETIMEDOUT, ENETUNREACH)
|
|
53
|
+
* → retry idempotent methods only.
|
|
54
|
+
*
|
|
55
|
+
* Anything else is a real error that the caller needs to see.
|
|
56
|
+
*/
|
|
57
|
+
export function isRetryable(error: AxiosError): boolean {
|
|
58
|
+
const method = (error.config?.method ?? 'get').toLowerCase();
|
|
59
|
+
const idempotent = IDEMPOTENT_METHODS.has(method);
|
|
60
|
+
|
|
61
|
+
if (error.response) {
|
|
62
|
+
const status = error.response.status;
|
|
63
|
+
if (status === 429) return true;
|
|
64
|
+
if ((status === 502 || status === 503 || status === 504) && idempotent) return true;
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// No response — network-level failure.
|
|
69
|
+
const code = error.code;
|
|
70
|
+
if (code === 'ECONNREFUSED') return false; // wrong URL / WP down — don't retry blindly
|
|
71
|
+
if (code === 'ECONNRESET' || code === 'ETIMEDOUT' || code === 'ENETUNREACH' || code === 'EAI_AGAIN') {
|
|
72
|
+
return idempotent;
|
|
73
|
+
}
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
import type {
|
|
77
|
+
BlockMCPConfig,
|
|
78
|
+
BlockType,
|
|
79
|
+
Pattern,
|
|
80
|
+
Block,
|
|
81
|
+
SiteUsage,
|
|
82
|
+
BlockUpdateResponse,
|
|
83
|
+
BlockWriteResponse,
|
|
84
|
+
BlockDeleteResponse,
|
|
85
|
+
BlockReplaceRangeResponse,
|
|
86
|
+
BlockBatchUpdateItem,
|
|
87
|
+
BlockBatchUpdateResponse,
|
|
88
|
+
GetBlockResponse,
|
|
89
|
+
StorageModeScanResult,
|
|
90
|
+
PatternInsertResponse,
|
|
91
|
+
MutationRequest,
|
|
92
|
+
MutationResponse,
|
|
93
|
+
ResolveUrlResponse,
|
|
94
|
+
CreatePostRequest,
|
|
95
|
+
UpdatePostRequest,
|
|
96
|
+
PostMutationResponse,
|
|
97
|
+
ListTermsRequest,
|
|
98
|
+
ListTermsResponse,
|
|
99
|
+
UploadMediaRequest,
|
|
100
|
+
UploadMediaResponse,
|
|
101
|
+
YoastSEOMeta,
|
|
102
|
+
YoastUpdateRequest,
|
|
103
|
+
YoastBulkUpdateItem,
|
|
104
|
+
YoastBulkUpdateResponse,
|
|
105
|
+
} from './types.js';
|
|
106
|
+
|
|
107
|
+
/** Response wrapper for block type listing. */
|
|
108
|
+
interface BlockTypesResponse {
|
|
109
|
+
block_types: BlockType[];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Response wrapper for pattern listing. */
|
|
113
|
+
interface PatternsResponse {
|
|
114
|
+
patterns: Pattern[];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Response wrapper for page blocks. */
|
|
118
|
+
interface PageBlocksResponse {
|
|
119
|
+
blocks: Block[];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* WordPress Block API client.
|
|
124
|
+
*
|
|
125
|
+
* Wraps the gk-block-api/v1 REST endpoints with typed methods,
|
|
126
|
+
* Basic Auth via Application Passwords, and meaningful error handling.
|
|
127
|
+
*/
|
|
128
|
+
export class WordPressBlockClient {
|
|
129
|
+
private client: AxiosInstance;
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Create a new WordPress Block API client.
|
|
133
|
+
*
|
|
134
|
+
* @param config - MCP server configuration with URL and credentials
|
|
135
|
+
*/
|
|
136
|
+
constructor(config: BlockMCPConfig) {
|
|
137
|
+
const { wordpress_url, auth } = config;
|
|
138
|
+
|
|
139
|
+
if (!wordpress_url) {
|
|
140
|
+
throw new Error('WordPress site URL is required (WORDPRESS_URL)');
|
|
141
|
+
}
|
|
142
|
+
if (!auth) {
|
|
143
|
+
throw new Error('WordPress authentication credentials are required (WORDPRESS_USER, WORDPRESS_APP_PASSWORD)');
|
|
144
|
+
}
|
|
145
|
+
if (!auth.username) {
|
|
146
|
+
throw new Error('WordPress API username is required (WORDPRESS_USER)');
|
|
147
|
+
}
|
|
148
|
+
if (!auth.application_password) {
|
|
149
|
+
throw new Error('WordPress Application Password is required (WORDPRESS_APP_PASSWORD)');
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Build base64-encoded Basic Auth header
|
|
153
|
+
const credentials = Buffer.from(
|
|
154
|
+
`${auth.username}:${auth.application_password}`
|
|
155
|
+
).toString('base64');
|
|
156
|
+
|
|
157
|
+
const trimmed = wordpress_url.replace(/\/+$/, '');
|
|
158
|
+
const baseURL = `${trimmed}/wp-json/gk-block-api/v1`;
|
|
159
|
+
|
|
160
|
+
this.client = axios.create({
|
|
161
|
+
baseURL,
|
|
162
|
+
headers: {
|
|
163
|
+
Authorization: `Basic ${credentials}`,
|
|
164
|
+
Accept: 'application/json',
|
|
165
|
+
'Content-Type': 'application/json',
|
|
166
|
+
'User-Agent': 'GravityKit Block MCP Server (https://github.com/GravityKit/block-mcp)',
|
|
167
|
+
},
|
|
168
|
+
timeout: 30000,
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// Response interceptor: retry transient errors with exponential backoff,
|
|
172
|
+
// then format any final error so it carries wpCode/wpData/wpStatus for
|
|
173
|
+
// the server-level catch in src/index.ts.
|
|
174
|
+
this.client.interceptors.response.use(
|
|
175
|
+
(r) => r,
|
|
176
|
+
async (error: AxiosError) => {
|
|
177
|
+
const config = error.config as (AxiosRequestConfig & { __retryCount?: number }) | undefined;
|
|
178
|
+
if (config && isRetryable(error)) {
|
|
179
|
+
const attempt = (config.__retryCount ?? 0) + 1;
|
|
180
|
+
if (attempt <= MAX_RETRIES) {
|
|
181
|
+
config.__retryCount = attempt;
|
|
182
|
+
await sleep(backoffMs(attempt));
|
|
183
|
+
return this.client.request(config);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
throw this.formatError(error);
|
|
187
|
+
},
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Format an Axios error into a thrown Error that carries the full
|
|
193
|
+
* WordPress `{ code, message, data }` payload, not just the message.
|
|
194
|
+
*
|
|
195
|
+
* Without this, agents lose the actionable hints the PHP plugin attaches
|
|
196
|
+
* to errors (e.g. `legacy_block` carries `suggested_replacement`,
|
|
197
|
+
* `policy_resource`, `namespace`; `dual_storage_requires_both` carries
|
|
198
|
+
* `block`, `storage_mode`). The Error message is human-readable; the
|
|
199
|
+
* `wpCode` and `wpData` properties expose the structured payload to the
|
|
200
|
+
* server-level catch in src/index.ts so it can surface them in the
|
|
201
|
+
* tool response.
|
|
202
|
+
*
|
|
203
|
+
* @param error - The Axios error to format
|
|
204
|
+
* @returns Error to throw
|
|
205
|
+
*/
|
|
206
|
+
private formatError(error: AxiosError): Error {
|
|
207
|
+
if (error.response) {
|
|
208
|
+
const { status, data } = error.response;
|
|
209
|
+
const body = data as Record<string, unknown> | string | undefined;
|
|
210
|
+
|
|
211
|
+
let detail = 'Unknown error';
|
|
212
|
+
let code: string | undefined;
|
|
213
|
+
let wpData: unknown = null;
|
|
214
|
+
|
|
215
|
+
if (body && typeof body === 'object') {
|
|
216
|
+
if ('message' in body) detail = String(body.message);
|
|
217
|
+
else if ('error' in body) detail = String(body.error);
|
|
218
|
+
if ('code' in body && typeof body.code === 'string') code = body.code;
|
|
219
|
+
if ('data' in body) wpData = body.data;
|
|
220
|
+
} else if (typeof body === 'string') {
|
|
221
|
+
detail = body;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Replace HTTP-shaped detail with an agent-actionable hint when we
|
|
225
|
+
// recognize the code. Original code/data still flow through wpCode /
|
|
226
|
+
// wpData so callers can pattern-match the raw form if they want to.
|
|
227
|
+
const hint = translateWpError(code, wpData);
|
|
228
|
+
const message = hint
|
|
229
|
+
? `Block API Error (${status}): ${hint}${code ? ` (${code})` : ''}`
|
|
230
|
+
: `Block API Error (${status}): ${detail}`;
|
|
231
|
+
|
|
232
|
+
const err = new Error(message) as Error & {
|
|
233
|
+
wpCode?: string;
|
|
234
|
+
wpData?: unknown;
|
|
235
|
+
wpStatus?: number;
|
|
236
|
+
};
|
|
237
|
+
err.wpCode = code;
|
|
238
|
+
err.wpData = wpData;
|
|
239
|
+
err.wpStatus = status;
|
|
240
|
+
return err;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (error.code === 'ECONNREFUSED') {
|
|
244
|
+
return new Error('Block API Error: Connection refused. Is the WordPress site reachable?');
|
|
245
|
+
}
|
|
246
|
+
if (error.code === 'ETIMEDOUT') {
|
|
247
|
+
return new Error('Block API Error: Request timed out after 30 seconds.');
|
|
248
|
+
}
|
|
249
|
+
return new Error(`Block API Error: ${error.message}`);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ============================================
|
|
253
|
+
// Registry & Discovery
|
|
254
|
+
// ============================================
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Get registered block types with preference + storage_mode metadata.
|
|
258
|
+
*
|
|
259
|
+
* @param params - Optional filters (namespace, category, tier, storage_mode,
|
|
260
|
+
* preferred_only, search, usage_only).
|
|
261
|
+
* @returns Array of block types
|
|
262
|
+
*/
|
|
263
|
+
async getBlockTypes(params?: {
|
|
264
|
+
namespace?: string;
|
|
265
|
+
category?: string;
|
|
266
|
+
preferred_only?: boolean;
|
|
267
|
+
tier?: 'preferred' | 'acceptable' | 'avoid' | 'legacy';
|
|
268
|
+
storage_mode?: 'static' | 'dynamic' | 'dual';
|
|
269
|
+
search?: string;
|
|
270
|
+
usage_only?: boolean;
|
|
271
|
+
}): Promise<BlockTypesResponse> {
|
|
272
|
+
const queryParams: Record<string, string> = {};
|
|
273
|
+
if (params?.namespace) queryParams.namespace = params.namespace;
|
|
274
|
+
if (params?.category) queryParams.category = params.category;
|
|
275
|
+
if (params?.preferred_only) queryParams.preferred_only = 'true';
|
|
276
|
+
if (params?.tier) queryParams.tier = params.tier;
|
|
277
|
+
if (params?.storage_mode) queryParams.storage_mode = params.storage_mode;
|
|
278
|
+
if (params?.search) queryParams.search = params.search;
|
|
279
|
+
if (params?.usage_only) queryParams.usage_only = 'true';
|
|
280
|
+
|
|
281
|
+
const response = await this.client.get<BlockTypesResponse>('/block-types', {
|
|
282
|
+
params: queryParams,
|
|
283
|
+
});
|
|
284
|
+
return response.data;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Get all patterns with preference scoring.
|
|
289
|
+
*
|
|
290
|
+
* @param params - Optional filters and pagination
|
|
291
|
+
* @returns Array of patterns
|
|
292
|
+
*/
|
|
293
|
+
async getPatterns(params?: {
|
|
294
|
+
q?: string;
|
|
295
|
+
synced?: boolean;
|
|
296
|
+
min_score?: number;
|
|
297
|
+
category?: string;
|
|
298
|
+
limit?: number;
|
|
299
|
+
order_by?: string;
|
|
300
|
+
refresh?: boolean;
|
|
301
|
+
}): Promise<PatternsResponse> {
|
|
302
|
+
const queryParams: Record<string, string> = {};
|
|
303
|
+
if (params?.q) queryParams.q = params.q;
|
|
304
|
+
if (params?.synced !== undefined) queryParams.synced = String(params.synced);
|
|
305
|
+
if (params?.min_score !== undefined) queryParams.min_score = String(params.min_score);
|
|
306
|
+
if (params?.category) queryParams.category = params.category;
|
|
307
|
+
if (params?.limit !== undefined) queryParams.limit = String(params.limit);
|
|
308
|
+
if (params?.order_by) queryParams.order_by = params.order_by;
|
|
309
|
+
if (params?.refresh) queryParams.refresh = 'true';
|
|
310
|
+
|
|
311
|
+
const response = await this.client.get<PatternsResponse>('/patterns', {
|
|
312
|
+
params: queryParams,
|
|
313
|
+
});
|
|
314
|
+
return response.data;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Get a single pattern by ID with its full parsed block content.
|
|
319
|
+
*
|
|
320
|
+
* @param id - Pattern ID (post ID for synced, name for registered)
|
|
321
|
+
* @returns Pattern details
|
|
322
|
+
*/
|
|
323
|
+
async getPattern(id: number | string): Promise<Pattern> {
|
|
324
|
+
const response = await this.client.get<Pattern>(
|
|
325
|
+
`/patterns/${encodeURIComponent(String(id))}`
|
|
326
|
+
);
|
|
327
|
+
return response.data;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Search patterns by name or keyword.
|
|
332
|
+
*
|
|
333
|
+
* @param query - Search term
|
|
334
|
+
* @returns Matching patterns
|
|
335
|
+
*/
|
|
336
|
+
async searchPatterns(query: string): Promise<PatternsResponse> {
|
|
337
|
+
if (!query) {
|
|
338
|
+
throw new Error('Search query is required');
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const response = await this.client.get<PatternsResponse>('/patterns/search', {
|
|
342
|
+
params: { q: query },
|
|
343
|
+
});
|
|
344
|
+
return response.data;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Resolve a URL or path to a WordPress post ID.
|
|
349
|
+
*
|
|
350
|
+
* Accepts any URL on the site (full URL or path). Handles all post types,
|
|
351
|
+
* permalinks, and pretty URLs via url_to_postid().
|
|
352
|
+
*
|
|
353
|
+
* @param url - Full URL or path (e.g. "/products/gravityedit/")
|
|
354
|
+
* @returns Post ID, type, title, status, slug, and edit URL
|
|
355
|
+
*/
|
|
356
|
+
async resolveUrl(url: string): Promise<ResolveUrlResponse> {
|
|
357
|
+
if (!url) {
|
|
358
|
+
throw new Error('URL is required');
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const response = await this.client.get<ResolveUrlResponse>('/resolve', {
|
|
362
|
+
params: { url },
|
|
363
|
+
});
|
|
364
|
+
return response.data;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Scan all published content and classify every distinct block name as
|
|
369
|
+
* static / dynamic / dual. Persists results to a WP option so subsequent
|
|
370
|
+
* `get_page_blocks` annotations and dual-storage enforcement use the
|
|
371
|
+
* live classification instead of the filter defaults.
|
|
372
|
+
*
|
|
373
|
+
* Slow (walks every published post). Run once after install or when
|
|
374
|
+
* significantly changing the site's block-using content.
|
|
375
|
+
*/
|
|
376
|
+
async scanStorageModes(): Promise<StorageModeScanResult> {
|
|
377
|
+
const response = await this.client.post<StorageModeScanResult>('/storage-modes/scan', {});
|
|
378
|
+
return response.data;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Get site-wide block and pattern usage statistics.
|
|
383
|
+
*
|
|
384
|
+
* @param refresh - If true, bust the transient cache and regenerate stats
|
|
385
|
+
* @returns Usage statistics
|
|
386
|
+
*/
|
|
387
|
+
async getSiteUsage(refresh?: boolean): Promise<SiteUsage> {
|
|
388
|
+
const params: Record<string, string> = {};
|
|
389
|
+
if (refresh) params.refresh = 'true';
|
|
390
|
+
|
|
391
|
+
const response = await this.client.get<SiteUsage>('/site-usage', { params });
|
|
392
|
+
return response.data;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// ============================================
|
|
396
|
+
// Page Block CRUD
|
|
397
|
+
// ============================================
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Get all blocks on a page as structured JSON.
|
|
401
|
+
*
|
|
402
|
+
* @param postId - WordPress post/page ID
|
|
403
|
+
* @param params - Optional query parameters (fields, render, search, block_name)
|
|
404
|
+
* @returns Array of parsed blocks
|
|
405
|
+
*/
|
|
406
|
+
async getPageBlocks(
|
|
407
|
+
postId: number,
|
|
408
|
+
params?: {
|
|
409
|
+
fields?: string;
|
|
410
|
+
render?: boolean;
|
|
411
|
+
search?: string;
|
|
412
|
+
block_name?: string;
|
|
413
|
+
outline?: boolean;
|
|
414
|
+
summary_only?: boolean;
|
|
415
|
+
include_legacy_paths?: boolean;
|
|
416
|
+
/** When true (default), missing gk_refs are assigned and persisted. Pass false to skip the silent write side effect. */
|
|
417
|
+
persist_refs?: boolean;
|
|
418
|
+
}
|
|
419
|
+
): Promise<PageBlocksResponse> {
|
|
420
|
+
if (postId === undefined || postId === null) {
|
|
421
|
+
throw new Error('Post ID is required');
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const queryParams: Record<string, string> = {};
|
|
425
|
+
if (params?.fields) queryParams.fields = params.fields;
|
|
426
|
+
if (params?.render) queryParams.render = 'true';
|
|
427
|
+
if (params?.search) queryParams.search = params.search;
|
|
428
|
+
if (params?.block_name) queryParams.block_name = params.block_name;
|
|
429
|
+
if (params?.outline) queryParams.outline = 'true';
|
|
430
|
+
if (params?.summary_only) queryParams.summary_only = 'true';
|
|
431
|
+
if (params?.include_legacy_paths) queryParams.include_legacy_paths = 'true';
|
|
432
|
+
// Forward both true and false explicitly when set, so tool-layer intent
|
|
433
|
+
// matches what reaches the server. The server default (true) only kicks in
|
|
434
|
+
// when the param is omitted entirely.
|
|
435
|
+
if (params?.persist_refs === false) queryParams.persist_refs = 'false';
|
|
436
|
+
else if (params?.persist_refs === true) queryParams.persist_refs = 'true';
|
|
437
|
+
|
|
438
|
+
const response = await this.client.get<PageBlocksResponse>(
|
|
439
|
+
`/posts/${postId}/blocks`,
|
|
440
|
+
{ params: queryParams }
|
|
441
|
+
);
|
|
442
|
+
return response.data;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Search posts by title/content with filters. Cheap WP_Query lookup —
|
|
447
|
+
* returns post stubs with no block parsing.
|
|
448
|
+
*/
|
|
449
|
+
async findPosts(
|
|
450
|
+
params?: import('./types.js').FindPostsParams
|
|
451
|
+
): Promise<import('./types.js').FindPostsResponse> {
|
|
452
|
+
const queryParams: Record<string, string> = {};
|
|
453
|
+
if (params?.search) queryParams.search = params.search;
|
|
454
|
+
if (params?.post_type) queryParams.post_type = params.post_type;
|
|
455
|
+
if (params?.post_status) queryParams.post_status = params.post_status;
|
|
456
|
+
if (params?.per_page !== undefined) queryParams.per_page = String(params.per_page);
|
|
457
|
+
if (params?.page !== undefined) queryParams.page = String(params.page);
|
|
458
|
+
|
|
459
|
+
const response = await this.client.get<import('./types.js').FindPostsResponse>(
|
|
460
|
+
'/find-posts',
|
|
461
|
+
{ params: queryParams }
|
|
462
|
+
);
|
|
463
|
+
return response.data;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Look up a single post's metadata by post_id, url, or slug+post_type.
|
|
468
|
+
* Returns title, status, permalink, modified, parent, author, etc.
|
|
469
|
+
* No block parsing — cheap.
|
|
470
|
+
*/
|
|
471
|
+
async getPostInfo(
|
|
472
|
+
params: import('./types.js').PostInfoParams
|
|
473
|
+
): Promise<import('./types.js').PostInfoResponse> {
|
|
474
|
+
if (
|
|
475
|
+
(params.post_id === undefined || params.post_id === null) &&
|
|
476
|
+
!params.url &&
|
|
477
|
+
!params.slug
|
|
478
|
+
) {
|
|
479
|
+
throw new Error('post_info requires one of: post_id, url, or slug');
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
const queryParams: Record<string, string> = {};
|
|
483
|
+
if (params.post_id !== undefined && params.post_id !== null) {
|
|
484
|
+
queryParams.post_id = String(params.post_id);
|
|
485
|
+
}
|
|
486
|
+
if (params.url) queryParams.url = params.url;
|
|
487
|
+
if (params.slug) queryParams.slug = params.slug;
|
|
488
|
+
if (params.post_type) queryParams.post_type = params.post_type;
|
|
489
|
+
|
|
490
|
+
const response = await this.client.get<import('./types.js').PostInfoResponse>(
|
|
491
|
+
'/post-info',
|
|
492
|
+
{ params: queryParams }
|
|
493
|
+
);
|
|
494
|
+
return response.data;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Update a single block's attributes and/or innerHTML.
|
|
499
|
+
*
|
|
500
|
+
* @param postId - WordPress post/page ID
|
|
501
|
+
* @param index - Zero-based block index
|
|
502
|
+
* @param data - Partial attributes and/or innerHTML to update
|
|
503
|
+
* @returns Updated block details with revision ID
|
|
504
|
+
*/
|
|
505
|
+
async updateBlock(
|
|
506
|
+
postId: number,
|
|
507
|
+
index: number,
|
|
508
|
+
data: { attributes?: Record<string, unknown>; innerHTML?: string }
|
|
509
|
+
): Promise<BlockUpdateResponse> {
|
|
510
|
+
if (postId === undefined || postId === null) throw new Error('Post ID is required');
|
|
511
|
+
if (index < 0) throw new Error('Block index must be non-negative');
|
|
512
|
+
if (!data.attributes && !data.innerHTML) {
|
|
513
|
+
throw new Error('At least one of attributes or innerHTML must be provided');
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const response = await this.client.patch<BlockUpdateResponse>(
|
|
517
|
+
`/posts/${postId}/blocks/${index}`,
|
|
518
|
+
data
|
|
519
|
+
);
|
|
520
|
+
return response.data;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* Update a single block by its stable gk_ref instead of a flat index.
|
|
525
|
+
* Refs survive sibling shifts so chained mutations don't go stale.
|
|
526
|
+
*
|
|
527
|
+
* @param postId - WordPress post/page ID
|
|
528
|
+
* @param ref - Stable ref (e.g. "blk_a3f2c1q9") from get_page_blocks
|
|
529
|
+
* @param data - Partial attributes and/or innerHTML
|
|
530
|
+
* @returns 404 ref_stale if the ref no longer matches any block.
|
|
531
|
+
*/
|
|
532
|
+
async updateBlockByRef(
|
|
533
|
+
postId: number,
|
|
534
|
+
ref: string,
|
|
535
|
+
data: { attributes?: Record<string, unknown>; innerHTML?: string }
|
|
536
|
+
): Promise<BlockUpdateResponse> {
|
|
537
|
+
if (postId === undefined || postId === null) throw new Error('Post ID is required');
|
|
538
|
+
if (!ref || typeof ref !== 'string') throw new Error('Ref is required');
|
|
539
|
+
if (!data.attributes && !data.innerHTML) {
|
|
540
|
+
throw new Error('At least one of attributes or innerHTML must be provided');
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
const response = await this.client.patch<BlockUpdateResponse>(
|
|
544
|
+
`/posts/${postId}/blocks/by-ref/${encodeURIComponent(ref)}`,
|
|
545
|
+
data
|
|
546
|
+
);
|
|
547
|
+
return response.data;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* Apply N independent block updates atomically in ONE WordPress revision.
|
|
552
|
+
*
|
|
553
|
+
* Each item targets one block by stable `ref` (recommended) or `flat_index`,
|
|
554
|
+
* with `attributes` and/or `innerHTML` to apply. Validation is all-or-nothing:
|
|
555
|
+
* if any item is invalid (stale ref, out-of-range index, dual-storage
|
|
556
|
+
* rejection, duplicate target), the whole batch fails with HTTP 400 and an
|
|
557
|
+
* itemized `errors` payload — no partial writes ever hit disk.
|
|
558
|
+
*
|
|
559
|
+
* Counts as ONE write against the per-post rate limit. Server caps batch
|
|
560
|
+
* size to prevent the rate-limit exemption from being abused.
|
|
561
|
+
*
|
|
562
|
+
* @param postId WordPress post/page ID.
|
|
563
|
+
* @param updates Update items (1..MAX_BATCH_SIZE).
|
|
564
|
+
* @returns Per-item results plus the single revision ID.
|
|
565
|
+
*/
|
|
566
|
+
async updateBlocksBatch(
|
|
567
|
+
postId: number,
|
|
568
|
+
updates: BlockBatchUpdateItem[],
|
|
569
|
+
options: { verbose?: boolean } = {}
|
|
570
|
+
): Promise<BlockBatchUpdateResponse> {
|
|
571
|
+
if (postId === undefined || postId === null) throw new Error('Post ID is required');
|
|
572
|
+
if (!Array.isArray(updates) || updates.length === 0) {
|
|
573
|
+
throw new Error('updates must be a non-empty array');
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
const body: { updates: BlockBatchUpdateItem[]; verbose?: boolean } = { updates };
|
|
577
|
+
if (options.verbose) body.verbose = true;
|
|
578
|
+
|
|
579
|
+
const response = await this.client.post<BlockBatchUpdateResponse>(
|
|
580
|
+
`/posts/${postId}/blocks/batch-update`,
|
|
581
|
+
body
|
|
582
|
+
);
|
|
583
|
+
return response.data;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* Fetch a single block by stable ref or flat index. Returns the canonical
|
|
588
|
+
* `saved` snapshot — same shape that write endpoints echo, so verification
|
|
589
|
+
* reads use the identical contract as the writes that produced them.
|
|
590
|
+
*
|
|
591
|
+
* Lighter than getPageBlocks() when you only need one block. Use this when
|
|
592
|
+
* you want to confirm the current state of a known ref before chaining an
|
|
593
|
+
* edit, not to discover what's on the page.
|
|
594
|
+
*
|
|
595
|
+
* @param postId WordPress post/page ID.
|
|
596
|
+
* @param target Either `{ ref }` or `{ flatIndex }`. Exactly one required.
|
|
597
|
+
* @returns { success, saved } where `saved` mirrors update_block's saved.
|
|
598
|
+
*/
|
|
599
|
+
async getBlock(
|
|
600
|
+
postId: number,
|
|
601
|
+
target: { ref?: string; flatIndex?: number }
|
|
602
|
+
): Promise<GetBlockResponse> {
|
|
603
|
+
if (postId === undefined || postId === null) throw new Error('Post ID is required');
|
|
604
|
+
const hasRef = typeof target.ref === 'string' && target.ref !== '';
|
|
605
|
+
const hasIdx = typeof target.flatIndex === 'number';
|
|
606
|
+
if (hasRef === hasIdx) {
|
|
607
|
+
throw new Error('Provide exactly one of ref or flatIndex');
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
const params: Record<string, string | number> = {};
|
|
611
|
+
if (hasRef) params.ref = target.ref!;
|
|
612
|
+
else params.flat_index = target.flatIndex!;
|
|
613
|
+
|
|
614
|
+
const response = await this.client.get<GetBlockResponse>(`/posts/${postId}/block`, { params });
|
|
615
|
+
return response.data;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* Insert one or more blocks at a specific position.
|
|
620
|
+
*
|
|
621
|
+
* @param postId - WordPress post/page ID
|
|
622
|
+
* @param data - Insertion position and blocks to insert
|
|
623
|
+
* @returns Inserted blocks with new indices, warnings, and revision ID
|
|
624
|
+
*/
|
|
625
|
+
async insertBlocks(
|
|
626
|
+
postId: number,
|
|
627
|
+
data: {
|
|
628
|
+
after?: number | 'start';
|
|
629
|
+
before?: number;
|
|
630
|
+
after_ref?: string;
|
|
631
|
+
before_ref?: string;
|
|
632
|
+
blocks: Array<{
|
|
633
|
+
name: string;
|
|
634
|
+
attributes?: Record<string, unknown>;
|
|
635
|
+
innerHTML?: string;
|
|
636
|
+
}>;
|
|
637
|
+
}
|
|
638
|
+
): Promise<BlockWriteResponse> {
|
|
639
|
+
if (postId === undefined || postId === null) throw new Error('Post ID is required');
|
|
640
|
+
if (!data.blocks || data.blocks.length === 0) {
|
|
641
|
+
throw new Error('At least one block is required');
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
const response = await this.client.post<BlockWriteResponse>(
|
|
645
|
+
`/posts/${postId}/blocks`,
|
|
646
|
+
data
|
|
647
|
+
);
|
|
648
|
+
return response.data;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* Remove a block (or consecutive blocks) at a position.
|
|
653
|
+
*
|
|
654
|
+
* @param postId - WordPress post/page ID
|
|
655
|
+
* @param index - Zero-based block index to remove
|
|
656
|
+
* @param count - Number of consecutive blocks to remove (default 1)
|
|
657
|
+
* @returns Deletion confirmation with revision ID
|
|
658
|
+
*/
|
|
659
|
+
async deleteBlock(
|
|
660
|
+
postId: number,
|
|
661
|
+
index: number,
|
|
662
|
+
count?: number
|
|
663
|
+
): Promise<BlockDeleteResponse> {
|
|
664
|
+
if (postId === undefined || postId === null) throw new Error('Post ID is required');
|
|
665
|
+
if (index < 0) throw new Error('Block index must be non-negative');
|
|
666
|
+
|
|
667
|
+
const params: Record<string, string> = {};
|
|
668
|
+
if (count && count > 1) params.count = String(count);
|
|
669
|
+
|
|
670
|
+
const response = await this.client.delete<BlockDeleteResponse>(
|
|
671
|
+
`/posts/${postId}/blocks/${index}`,
|
|
672
|
+
{ params }
|
|
673
|
+
);
|
|
674
|
+
return response.data;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/**
|
|
678
|
+
* Delete one or more blocks identified by the leading block's stable gk_ref.
|
|
679
|
+
*
|
|
680
|
+
* @param postId - WordPress post/page ID
|
|
681
|
+
* @param ref - Stable ref of the first block to remove
|
|
682
|
+
* @param count - Consecutive blocks to remove (default 1)
|
|
683
|
+
*/
|
|
684
|
+
async deleteBlockByRef(
|
|
685
|
+
postId: number,
|
|
686
|
+
ref: string,
|
|
687
|
+
count?: number
|
|
688
|
+
): Promise<BlockDeleteResponse> {
|
|
689
|
+
if (postId === undefined || postId === null) throw new Error('Post ID is required');
|
|
690
|
+
if (!ref || typeof ref !== 'string') throw new Error('Ref is required');
|
|
691
|
+
|
|
692
|
+
const params: Record<string, string> = {};
|
|
693
|
+
if (count && count > 1) params.count = String(count);
|
|
694
|
+
|
|
695
|
+
const response = await this.client.delete<BlockDeleteResponse>(
|
|
696
|
+
`/posts/${postId}/blocks/by-ref/${encodeURIComponent(ref)}`,
|
|
697
|
+
{ params }
|
|
698
|
+
);
|
|
699
|
+
return response.data;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* Atomically replace a range of top-level blocks with a new shape, in a
|
|
704
|
+
* single revision. Distinct from `replaceAllBlocks` (which rewrites the
|
|
705
|
+
* entire post).
|
|
706
|
+
*
|
|
707
|
+
* @param postId - WordPress post/page ID
|
|
708
|
+
* @param data - { start, count, blocks } range descriptor
|
|
709
|
+
* @returns Result with `removed`, `inserted[]`, warnings, revision IDs
|
|
710
|
+
*/
|
|
711
|
+
async replaceBlocksRange(
|
|
712
|
+
postId: number,
|
|
713
|
+
data: {
|
|
714
|
+
start: number;
|
|
715
|
+
count: number;
|
|
716
|
+
blocks: Array<{
|
|
717
|
+
name: string;
|
|
718
|
+
attributes?: Record<string, unknown>;
|
|
719
|
+
innerHTML?: string;
|
|
720
|
+
}>;
|
|
721
|
+
}
|
|
722
|
+
): Promise<BlockReplaceRangeResponse> {
|
|
723
|
+
if (postId === undefined || postId === null) throw new Error('Post ID is required');
|
|
724
|
+
if (typeof data.start !== 'number' || data.start < 0) {
|
|
725
|
+
throw new Error('start must be a non-negative integer');
|
|
726
|
+
}
|
|
727
|
+
if (typeof data.count !== 'number' || data.count < 0) {
|
|
728
|
+
throw new Error('count must be a non-negative integer');
|
|
729
|
+
}
|
|
730
|
+
if (!Array.isArray(data.blocks)) {
|
|
731
|
+
throw new Error('blocks must be an array (may be empty for a pure delete)');
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
const response = await this.client.post<BlockReplaceRangeResponse>(
|
|
735
|
+
`/posts/${postId}/blocks/replace`,
|
|
736
|
+
data
|
|
737
|
+
);
|
|
738
|
+
return response.data;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
/**
|
|
742
|
+
* Replace all blocks on a page (full rewrite).
|
|
743
|
+
*
|
|
744
|
+
* Creates a revision before overwriting. Validates all block names and
|
|
745
|
+
* warns on legacy/avoid-tier blocks.
|
|
746
|
+
*
|
|
747
|
+
* @param postId - WordPress post/page ID
|
|
748
|
+
* @param blocks - Complete array of blocks for the page
|
|
749
|
+
* @returns Written blocks with revision ID
|
|
750
|
+
*/
|
|
751
|
+
async replaceAllBlocks(
|
|
752
|
+
postId: number,
|
|
753
|
+
blocks: Array<{
|
|
754
|
+
name: string;
|
|
755
|
+
attributes?: Record<string, unknown>;
|
|
756
|
+
innerHTML?: string;
|
|
757
|
+
}>
|
|
758
|
+
): Promise<BlockWriteResponse> {
|
|
759
|
+
if (postId === undefined || postId === null) throw new Error('Post ID is required');
|
|
760
|
+
if (!blocks || blocks.length === 0) {
|
|
761
|
+
throw new Error('At least one block is required for a full rewrite');
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
const response = await this.client.put<BlockWriteResponse>(
|
|
765
|
+
`/posts/${postId}/blocks`,
|
|
766
|
+
{ blocks }
|
|
767
|
+
);
|
|
768
|
+
return response.data;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
// ============================================
|
|
772
|
+
// Block Tree Mutation
|
|
773
|
+
// ============================================
|
|
774
|
+
|
|
775
|
+
/**
|
|
776
|
+
* Perform a structural mutation on a nested block tree.
|
|
777
|
+
*
|
|
778
|
+
* @param postId - WordPress post/page ID
|
|
779
|
+
* @param data - Mutation request with operation, path, and operation-specific fields
|
|
780
|
+
* @returns Mutation result with revision IDs and optional warnings
|
|
781
|
+
*/
|
|
782
|
+
async mutateBlockTree(postId: number, data: MutationRequest): Promise<MutationResponse> {
|
|
783
|
+
if (postId === undefined || postId === null) {
|
|
784
|
+
throw new Error('Post ID is required');
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
const response = await this.client.post<MutationResponse>(
|
|
788
|
+
`/posts/${postId}/mutate`,
|
|
789
|
+
data
|
|
790
|
+
);
|
|
791
|
+
return response.data;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
// ============================================
|
|
795
|
+
// Revert Operations
|
|
796
|
+
// ============================================
|
|
797
|
+
|
|
798
|
+
/**
|
|
799
|
+
* Revert a post to a specific revision.
|
|
800
|
+
*
|
|
801
|
+
* @param postId - WordPress post/page ID
|
|
802
|
+
* @param revisionId - Revision ID to restore
|
|
803
|
+
* @returns Revert result with revision IDs
|
|
804
|
+
*/
|
|
805
|
+
async revertToRevision(postId: number, revisionId: number): Promise<unknown> {
|
|
806
|
+
if (postId === undefined || postId === null) {
|
|
807
|
+
throw new Error('Post ID is required');
|
|
808
|
+
}
|
|
809
|
+
const response = await this.client.post(`/posts/${postId}/revert`, { revision_id: revisionId });
|
|
810
|
+
return response.data;
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
// ============================================
|
|
814
|
+
// Pattern Operations
|
|
815
|
+
// ============================================
|
|
816
|
+
|
|
817
|
+
/**
|
|
818
|
+
* Insert a pattern at a position on a page.
|
|
819
|
+
*
|
|
820
|
+
* @param postId - WordPress post/page ID
|
|
821
|
+
* @param data - Pattern ID, insertion position, and sync mode
|
|
822
|
+
* @returns Inserted pattern details with revision ID
|
|
823
|
+
*/
|
|
824
|
+
async insertPattern(
|
|
825
|
+
postId: number,
|
|
826
|
+
data: {
|
|
827
|
+
pattern_id: number | string;
|
|
828
|
+
after?: number;
|
|
829
|
+
before?: number;
|
|
830
|
+
synced?: boolean;
|
|
831
|
+
}
|
|
832
|
+
): Promise<PatternInsertResponse> {
|
|
833
|
+
if (postId === undefined || postId === null) throw new Error('Post ID is required');
|
|
834
|
+
if (data.pattern_id === undefined || data.pattern_id === null) throw new Error('Pattern ID is required');
|
|
835
|
+
|
|
836
|
+
const response = await this.client.post<PatternInsertResponse>(
|
|
837
|
+
`/posts/${postId}/insert-pattern`,
|
|
838
|
+
data
|
|
839
|
+
);
|
|
840
|
+
return response.data;
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
// ──────────────────────────────────────────────────────────
|
|
844
|
+
// v1.2 — Docs lifecycle
|
|
845
|
+
// ──────────────────────────────────────────────────────────
|
|
846
|
+
|
|
847
|
+
/**
|
|
848
|
+
* Create a new post or page.
|
|
849
|
+
*
|
|
850
|
+
* @param data - Title (required), plus optional status, content/blocks,
|
|
851
|
+
* terms, parent, slug, etc.
|
|
852
|
+
* @returns The created post's ID, slug, permalink, edit link, revision.
|
|
853
|
+
*/
|
|
854
|
+
async createPost(data: CreatePostRequest): Promise<PostMutationResponse> {
|
|
855
|
+
if (!data.title || data.title.trim() === '') {
|
|
856
|
+
throw new Error('create_post: a non-empty "title" is required');
|
|
857
|
+
}
|
|
858
|
+
if (data.content !== undefined && Array.isArray(data.blocks)) {
|
|
859
|
+
throw new Error('create_post: "content" and "blocks" are mutually exclusive');
|
|
860
|
+
}
|
|
861
|
+
const response = await this.client.post<PostMutationResponse>('/posts', data);
|
|
862
|
+
return response.data;
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
/**
|
|
866
|
+
* Update post metadata, status, or terms. Block content edits stay on the
|
|
867
|
+
* per-block tools (update_block / mutate_block_tree / replace_all_blocks).
|
|
868
|
+
*
|
|
869
|
+
* Use `status: trash` to trash; any non-trash status untrashes a trashed post.
|
|
870
|
+
*/
|
|
871
|
+
async updatePost(postId: number, data: UpdatePostRequest): Promise<PostMutationResponse> {
|
|
872
|
+
if (postId === undefined || postId === null) {
|
|
873
|
+
throw new Error('update_post: post_id is required');
|
|
874
|
+
}
|
|
875
|
+
const response = await this.client.patch<PostMutationResponse>(`/posts/${postId}`, data);
|
|
876
|
+
return response.data;
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
/** List terms in a taxonomy (default: category). */
|
|
880
|
+
async listTerms(args: ListTermsRequest = {}): Promise<ListTermsResponse> {
|
|
881
|
+
const response = await this.client.get<ListTermsResponse>('/terms', { params: args });
|
|
882
|
+
return response.data;
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
/**
|
|
886
|
+
* Upload an item to the WordPress media library.
|
|
887
|
+
*
|
|
888
|
+
* Three input modes (exactly one of `path`, `url`, or `data_base64`):
|
|
889
|
+
* - `path`: local filesystem path on the MCP host. Read and POSTed as
|
|
890
|
+
* multipart/form-data. The MCP process must have read access.
|
|
891
|
+
* - `url`: WordPress fetches the URL server-side (sideload, 25 MB cap).
|
|
892
|
+
* - `data_base64`: base64-encoded contents. Requires `filename`.
|
|
893
|
+
*/
|
|
894
|
+
async uploadMedia(args: UploadMediaRequest): Promise<UploadMediaResponse> {
|
|
895
|
+
const modes = (['path', 'url', 'data_base64'] as const).filter(
|
|
896
|
+
(k) => typeof args[k] === 'string' && (args[k] as string).length > 0,
|
|
897
|
+
);
|
|
898
|
+
if (modes.length === 0) {
|
|
899
|
+
throw new Error('upload_media: provide one of "path", "url", or "data_base64"');
|
|
900
|
+
}
|
|
901
|
+
if (modes.length > 1) {
|
|
902
|
+
throw new Error(`upload_media: only one of path/url/data_base64 (got ${modes.join(', ')})`);
|
|
903
|
+
}
|
|
904
|
+
if (args.data_base64 && !args.filename) {
|
|
905
|
+
throw new Error('upload_media: "filename" is required with "data_base64"');
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
if (args.path) {
|
|
909
|
+
const fs = await import('node:fs/promises');
|
|
910
|
+
const path = await import('node:path');
|
|
911
|
+
const data = await fs.readFile(args.path);
|
|
912
|
+
const filename = args.filename ?? path.basename(args.path);
|
|
913
|
+
const form = new FormData();
|
|
914
|
+
form.append('file', new Blob([new Uint8Array(data)]), filename);
|
|
915
|
+
if (args.title) form.append('title', args.title);
|
|
916
|
+
if (args.alt_text) form.append('alt_text', args.alt_text);
|
|
917
|
+
if (args.caption) form.append('caption', args.caption);
|
|
918
|
+
if (args.description) form.append('description', args.description);
|
|
919
|
+
if (typeof args.post_id === 'number') form.append('post_id', String(args.post_id));
|
|
920
|
+
|
|
921
|
+
// axios sets the multipart Content-Type and boundary automatically.
|
|
922
|
+
const response = await this.client.post<UploadMediaResponse>('/media', form);
|
|
923
|
+
return response.data;
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
// url or data_base64 ride as JSON.
|
|
927
|
+
const response = await this.client.post<UploadMediaResponse>('/media', args);
|
|
928
|
+
return response.data;
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
// ──────────────────────────────────────────────────────────
|
|
932
|
+
// v1.3 — Yoast SEO metadata (gk-block-api/v1/yoast/...)
|
|
933
|
+
//
|
|
934
|
+
// Backed by Yoast_Bridge inside gk-block-api itself. Routes register only
|
|
935
|
+
// when Yoast SEO is active; absent Yoast you'll get 404 rest_no_route.
|
|
936
|
+
// ──────────────────────────────────────────────────────────
|
|
937
|
+
|
|
938
|
+
/** Read all Yoast SEO metadata for a post. */
|
|
939
|
+
async getYoastSEO(postId: number): Promise<YoastSEOMeta> {
|
|
940
|
+
if (postId === undefined || postId === null) {
|
|
941
|
+
throw new Error('yoast_get_seo: post_id is required');
|
|
942
|
+
}
|
|
943
|
+
const response = await this.client.get<YoastSEOMeta>(`/yoast/${postId}`);
|
|
944
|
+
return response.data;
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
/** Partial update of Yoast SEO fields on a single post. */
|
|
948
|
+
async updateYoastSEO(postId: number, fields: YoastUpdateRequest): Promise<YoastSEOMeta> {
|
|
949
|
+
if (postId === undefined || postId === null) {
|
|
950
|
+
throw new Error('yoast_update_seo: post_id is required');
|
|
951
|
+
}
|
|
952
|
+
const response = await this.client.patch<YoastSEOMeta>(`/yoast/${postId}`, fields);
|
|
953
|
+
return response.data;
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
/** Batch-update Yoast SEO fields on multiple posts. Order preserved in response. */
|
|
957
|
+
async bulkUpdateYoastSEO(posts: YoastBulkUpdateItem[]): Promise<YoastBulkUpdateResponse> {
|
|
958
|
+
if (!Array.isArray(posts) || posts.length === 0) {
|
|
959
|
+
throw new Error('yoast_bulk_update_seo: non-empty `posts` array is required');
|
|
960
|
+
}
|
|
961
|
+
const response = await this.client.patch<YoastBulkUpdateResponse>('/yoast/bulk', { posts });
|
|
962
|
+
return response.data;
|
|
963
|
+
}
|
|
964
|
+
}
|