@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.
Files changed (70) hide show
  1. package/.env.example +15 -0
  2. package/LICENSE +26 -0
  3. package/README.md +592 -0
  4. package/dist/index.cjs +52721 -0
  5. package/package.json +70 -0
  6. package/src/__tests__/fixtures/block-trees.ts +199 -0
  7. package/src/__tests__/fixtures/error-envelopes.ts +115 -0
  8. package/src/__tests__/fixtures/rest-responses.ts +280 -0
  9. package/src/__tests__/helpers/mock-client.ts +185 -0
  10. package/src/__tests__/helpers/request-matchers.ts +88 -0
  11. package/src/__tests__/helpers/schema-asserts.ts +132 -0
  12. package/src/__tests__/integration/concurrency.test.ts +129 -0
  13. package/src/__tests__/integration/dual-storage.test.ts +156 -0
  14. package/src/__tests__/integration/error-envelopes.test.ts +238 -0
  15. package/src/__tests__/integration/global-setup.ts +17 -0
  16. package/src/__tests__/integration/rate-limit.test.ts +88 -0
  17. package/src/__tests__/integration/read-edit-read.test.ts +141 -0
  18. package/src/__tests__/integration/ref-stability.test.ts +175 -0
  19. package/src/__tests__/integration/setup.ts +201 -0
  20. package/src/__tests__/tools/discovery/get_pattern.test.ts +58 -0
  21. package/src/__tests__/tools/discovery/get_post_info.test.ts +100 -0
  22. package/src/__tests__/tools/discovery/get_site_usage.test.ts +41 -0
  23. package/src/__tests__/tools/discovery/list_block_types.test.ts +103 -0
  24. package/src/__tests__/tools/discovery/list_patterns.test.ts +106 -0
  25. package/src/__tests__/tools/discovery/list_posts.test.ts +47 -0
  26. package/src/__tests__/tools/discovery/resolve_url.test.ts +69 -0
  27. package/src/__tests__/tools/discovery/scan_storage_modes.test.ts +34 -0
  28. package/src/__tests__/tools/media/upload_media.test.ts +123 -0
  29. package/src/__tests__/tools/mutate/edit_block_tree.test.ts +439 -0
  30. package/src/__tests__/tools/mutate/ref_routing.test.ts +105 -0
  31. package/src/__tests__/tools/patterns/insert_pattern.test.ts +117 -0
  32. package/src/__tests__/tools/posts/create_post.test.ts +84 -0
  33. package/src/__tests__/tools/posts/update_post.test.ts +93 -0
  34. package/src/__tests__/tools/read/get_block.test.ts +96 -0
  35. package/src/__tests__/tools/read/get_page_blocks.test.ts +184 -0
  36. package/src/__tests__/tools/read/persist_refs.test.ts +35 -0
  37. package/src/__tests__/tools/terms/list_terms.test.ts +91 -0
  38. package/src/__tests__/tools/write/delete_block.test.ts +91 -0
  39. package/src/__tests__/tools/write/insert_blocks.test.ts +149 -0
  40. package/src/__tests__/tools/write/ref_routing.test.ts +177 -0
  41. package/src/__tests__/tools/write/replace_block_range.test.ts +90 -0
  42. package/src/__tests__/tools/write/rewrite_post_blocks.test.ts +126 -0
  43. package/src/__tests__/tools/write/update_block.test.ts +206 -0
  44. package/src/__tests__/tools/write/update_blocks.test.ts +173 -0
  45. package/src/__tests__/tools/yoast/yoast_bulk_update_seo.test.ts +112 -0
  46. package/src/__tests__/tools/yoast/yoast_get_seo.test.ts +78 -0
  47. package/src/__tests__/tools/yoast/yoast_update_seo.test.ts +105 -0
  48. package/src/__tests__/unit/client/ref-endpoints.test.ts +232 -0
  49. package/src/__tests__/unit/enrichers/cbp-enricher.test.ts +457 -0
  50. package/src/__tests__/unit/error-translator/translate-wp-error.test.ts +318 -0
  51. package/src/__tests__/unit/instructions.test.ts +374 -0
  52. package/src/__tests__/unit/preferences/enrich-block-list.test.ts +175 -0
  53. package/src/__tests__/unit/preferences/enrich-pattern-list.test.ts +227 -0
  54. package/src/client.ts +964 -0
  55. package/src/connect.ts +877 -0
  56. package/src/enrichers.ts +348 -0
  57. package/src/error-translator.ts +156 -0
  58. package/src/index.ts +450 -0
  59. package/src/instructions.ts +270 -0
  60. package/src/preferences.ts +273 -0
  61. package/src/tools/discovery.ts +251 -0
  62. package/src/tools/media.ts +75 -0
  63. package/src/tools/mutate.ts +243 -0
  64. package/src/tools/patterns.ts +94 -0
  65. package/src/tools/posts.ts +200 -0
  66. package/src/tools/read.ts +201 -0
  67. package/src/tools/terms.ts +44 -0
  68. package/src/tools/write.ts +542 -0
  69. package/src/tools/yoast.ts +224 -0
  70. package/src/types.ts +862 -0
@@ -0,0 +1,348 @@
1
+ /**
2
+ * Block enrichers — transform block attributes before they reach the REST API.
3
+ *
4
+ * An enricher receives a block definition and returns an updated one. The
5
+ * registry is keyed by fully-qualified block name so each block type can have
6
+ * its own enrichment logic.
7
+ *
8
+ * Built-in: kevinbatdorf/code-block-pro — derives codeHTML from code + language
9
+ * so callers never have to hand-write syntax-highlighted HTML.
10
+ *
11
+ * Extension:
12
+ * import { registerBlockEnricher } from './enrichers.js';
13
+ * registerBlockEnricher('my-plugin/snippet', async (block) => { ... });
14
+ */
15
+
16
+ // Fine-grained Shiki: importing from 'shiki' inlines the full bundle (~200
17
+ // grammars + the oniguruma WASM, ~11 MB). We only highlight the curated set
18
+ // below, so we pull Shiki's core, the WASM-free JavaScript regex engine, and
19
+ // just those grammars statically — bundling only what we use keeps dist/ small
20
+ // and self-contained (the same bundle ships in the .mcpb, which has no node_modules).
21
+ import { createHighlighterCore, createCssVariablesTheme } from 'shiki/core';
22
+ import type { HighlighterCore } from 'shiki/core';
23
+ import { createJavaScriptRegexEngine } from '@shikijs/engine-javascript';
24
+ import lang_php from '@shikijs/langs/php';
25
+ import lang_javascript from '@shikijs/langs/javascript';
26
+ import lang_typescript from '@shikijs/langs/typescript';
27
+ import lang_css from '@shikijs/langs/css';
28
+ import lang_html from '@shikijs/langs/html';
29
+ import lang_json from '@shikijs/langs/json';
30
+ import lang_xml from '@shikijs/langs/xml';
31
+ import lang_sql from '@shikijs/langs/sql';
32
+ import lang_bash from '@shikijs/langs/bash';
33
+ import lang_shell from '@shikijs/langs/shell';
34
+ import lang_python from '@shikijs/langs/python';
35
+ import lang_ruby from '@shikijs/langs/ruby';
36
+ import lang_yaml from '@shikijs/langs/yaml';
37
+ import lang_markdown from '@shikijs/langs/markdown';
38
+ import lang_ini from '@shikijs/langs/ini';
39
+ import lang_diff from '@shikijs/langs/diff';
40
+ import lang_dockerfile from '@shikijs/langs/dockerfile';
41
+ import lang_nginx from '@shikijs/langs/nginx';
42
+
43
+ // ── Types ─────────────────────────────────────────────────────────────────────
44
+
45
+ export type BlockDef = {
46
+ name: string;
47
+ attributes?: Record<string, unknown>;
48
+ innerBlocks?: BlockDef[];
49
+ innerHTML?: string;
50
+ };
51
+
52
+ /** Return the updated block, or null to leave it unchanged. */
53
+ export type EnricherFn = (block: BlockDef) => Promise<BlockDef | null>;
54
+
55
+ // ── Registry ──────────────────────────────────────────────────────────────────
56
+
57
+ const _registry = new Map<string, EnricherFn>();
58
+
59
+ export function registerBlockEnricher(blockName: string, fn: EnricherFn): void {
60
+ _registry.set(blockName, fn);
61
+ }
62
+
63
+ /** Apply the registered enricher for the block's type, recursing into innerBlocks. */
64
+ export async function enrichBlock(block: BlockDef): Promise<BlockDef> {
65
+ const fn = _registry.get(block.name);
66
+ const enriched = fn ? (await fn(block)) ?? block : block;
67
+ if (!enriched.innerBlocks?.length) return enriched;
68
+ return { ...enriched, innerBlocks: await enrichBlocks(enriched.innerBlocks) };
69
+ }
70
+
71
+ // Concurrency cap for enrichBlocks. Most enrichers (notably Shiki
72
+ // highlighting) are CPU-bound; an unbounded Promise.all on a code-heavy
73
+ // post can fan out 30+ simultaneous highlight calls and stall the event
74
+ // loop. 8 is a reasonable default; tunable via BLOCK_MCP_ENRICH_CONCURRENCY
75
+ // for sites that want different trade-offs (1 = serial; 32+ = old behavior).
76
+ const ENRICH_CONCURRENCY: number = (() => {
77
+ const raw = parseInt(process.env.BLOCK_MCP_ENRICH_CONCURRENCY ?? '', 10);
78
+ return Number.isFinite(raw) && raw >= 1 ? raw : 8;
79
+ })();
80
+
81
+ /**
82
+ * Run an async mapper over an array with a concurrency cap. Preserves input
83
+ * order in the output. No external dep — we ship one place that needs this.
84
+ */
85
+ async function mapWithLimit<T, R>(
86
+ items: T[],
87
+ limit: number,
88
+ mapper: (item: T, index: number) => Promise<R>,
89
+ ): Promise<R[]> {
90
+ if (limit >= items.length || limit <= 0) {
91
+ return Promise.all(items.map((item, i) => mapper(item, i)));
92
+ }
93
+ const out: R[] = new Array(items.length);
94
+ let next = 0;
95
+ async function worker() {
96
+ while (true) {
97
+ const i = next++;
98
+ if (i >= items.length) return;
99
+ out[i] = await mapper(items[i], i);
100
+ }
101
+ }
102
+ const workers = Array.from({ length: limit }, () => worker());
103
+ await Promise.all(workers);
104
+ return out;
105
+ }
106
+
107
+ /** Enrich an array of blocks (and their descendants) with bounded concurrency. */
108
+ export async function enrichBlocks(blocks: BlockDef[]): Promise<BlockDef[]> {
109
+ return mapWithLimit(blocks, ENRICH_CONCURRENCY, enrichBlock);
110
+ }
111
+
112
+ // ── Shiki singleton ───────────────────────────────────────────────────────────
113
+
114
+ // The curated Shiki grammars bundled into the highlighter — only these are
115
+ // inlined (see the static imports at the top of the file). Loading a grammar
116
+ // also pulls in its embedded sub-grammars (e.g. php → html/css/js/sql), so the
117
+ // effective coverage is wider than the list. Any language not covered here
118
+ // falls back to plaintext in shikiHighlight(). Adding a language is a two-line
119
+ // change: `import lang_x from '@shikijs/langs/x'` above, then add it here.
120
+ const SHIKI_LANGS = [
121
+ lang_php,
122
+ lang_javascript,
123
+ lang_typescript,
124
+ lang_css,
125
+ lang_html,
126
+ lang_json,
127
+ lang_xml,
128
+ lang_sql,
129
+ lang_bash,
130
+ lang_shell,
131
+ lang_python,
132
+ lang_ruby,
133
+ lang_yaml,
134
+ lang_markdown,
135
+ lang_ini,
136
+ lang_diff,
137
+ lang_dockerfile,
138
+ lang_nginx,
139
+ ];
140
+
141
+ // Singleton promise (not bare references): on a page with N code blocks,
142
+ // enrichBlocks() fires N concurrent calls into Promise.all. The original
143
+ // `if (_hl) return { hl, langs: _hlLangs! }` had a race where caller A
144
+ // finished setting `_hl` but had not yet set `_hlLangs` when caller B
145
+ // passed the guard, blowing up the non-null assertion downstream.
146
+ // A single in-flight promise dedupes the work and serialises field reads.
147
+ let _hlPromise: Promise<{
148
+ hl: HighlighterCore;
149
+ langs: Set<string>;
150
+ }> | null = null;
151
+
152
+ async function getHighlighter() {
153
+ if (_hlPromise) return _hlPromise;
154
+ _hlPromise = (async () => {
155
+ const theme = createCssVariablesTheme({ name: 'css-variables', variablePrefix: '--shiki-' });
156
+ const hl = await createHighlighterCore({
157
+ themes: [theme],
158
+ langs: SHIKI_LANGS,
159
+ engine: createJavaScriptRegexEngine({ forgiving: true }),
160
+ });
161
+ return { hl, langs: new Set(hl.getLoadedLanguages()) };
162
+ })();
163
+ return _hlPromise;
164
+ }
165
+
166
+ /**
167
+ * Render code as a syntax-highlighted <pre> block using the css-variables
168
+ * Shiki theme, with variable names normalised to match Code Block Pro's
169
+ * expected --shiki-color-text / --shiki-color-background convention.
170
+ *
171
+ * @param themeName Optional theme name to use as the <pre> class instead of
172
+ * "css-variables". CBP custom themes (gravitykit-dark, etc.)
173
+ * are registered as css-variables themes in the browser — the
174
+ * class name must match so block validation passes.
175
+ */
176
+ export async function shikiHighlight(code: string, language: string, themeName?: string): Promise<string> {
177
+ const { hl, langs } = await getHighlighter();
178
+ const lang = langs.has(language) ? language : 'plaintext';
179
+ let html = hl.codeToHtml(code, { lang, theme: 'css-variables' })
180
+ .replace(/var\(--shiki-foreground\)/g, 'var(--shiki-color-text)')
181
+ .replace(/var\(--shiki-background\)/g, 'var(--shiki-color-background)');
182
+ if (themeName && themeName !== 'css-variables') {
183
+ html = html.replace(/(<pre[^>]*class="shiki) css-variables(")/, `$1 ${themeName}$2`);
184
+ }
185
+ return html;
186
+ }
187
+
188
+ // ── Language inference ────────────────────────────────────────────────────────
189
+
190
+ const _langRules: Array<{ testFn: (code: string) => boolean; language: string }> = [];
191
+
192
+ /** Add a custom language inference rule, checked before built-in heuristics. */
193
+ export function addLanguageRule(testFn: (code: string) => boolean, language: string): void {
194
+ _langRules.push({ testFn, language });
195
+ }
196
+
197
+ export function inferLanguage(code: string): string {
198
+ const head = code.slice(0, 4000);
199
+
200
+ for (const { testFn, language } of _langRules) {
201
+ if (testFn(head)) return language;
202
+ }
203
+
204
+ // The `=>\s*['"]?\w/` signal was here previously and matched JS arrow
205
+ // functions returning strings (e.g. `(x) => "hello"`), causing JS code
206
+ // to be misclassified as PHP. Each remaining signal is individually
207
+ // meaningful — `$var =` and `$var->` aren't legitimate JS patterns —
208
+ // so the threshold stays at 1.
209
+ const phpSignals = [
210
+ /<\?php\b/, /\?>/,
211
+ /\bnamespace\s+[\w\\]+\s*;/, /\buse\s+[\w\\]+(?:\s+as\s+\w+)?\s*;/,
212
+ /\b(?:public|private|protected)\s+(?:static\s+)?function\s+\w+/,
213
+ /\bstatic\s+function\s+\w+/,
214
+ /\bfunction\s+\w+\s*\([^)]*\)\s*\{/,
215
+ /\b(?:do_action|apply_filters|add_action|add_filter|register_(?:post_type|taxonomy|setting|rest_route|block_type)|add_shortcode|wp_(?:enqueue|register)_(?:script|style)|get_(?:option|post_meta|user_meta)|update_(?:option|post_meta|user_meta))\s*\(/,
216
+ /->\w+\s*\(/, /\(\s*(?:int|string|bool|float|array|object|self|static)\s*\)\s*\$\w+/,
217
+ /\$\w+\s*=/, /\$\w+\s*->/, /\barray\s*\(/,
218
+ ];
219
+ if (phpSignals.reduce((n, re) => n + (re.test(head) ? 1 : 0), 0) >= 1) return 'php';
220
+ if (/^<!DOCTYPE\s|<html[\s>]|<body[\s>]|<div\s[^>]*>|<p>[\s\S]*<\/p>/i.test(head)) return 'html';
221
+ if (/<script\b|<style\b/i.test(head)) return 'html';
222
+ if (/[.#@][\w-]+\s*\{|:\s*[^;{}\n]+;|@media\s|@import\s/.test(head)) return 'css';
223
+ if (/\b(SELECT|INSERT|UPDATE|DELETE|FROM|WHERE|JOIN)\b/i.test(head) && /\b(FROM|WHERE)\b/i.test(head)) return 'sql';
224
+ if (/^\s*(const|let|var|function|import|export|=>)\b/.test(head) || /console\.log\s*\(/.test(head)) return 'javascript';
225
+ if (/^\s*\{[\s\S]*?"[\w-]+"\s*:/.test(head)) return 'json';
226
+ if (/^\s*#!?\/bin\/(?:bash|sh)|^\s*\$\s+\w/.test(head)) return 'bash';
227
+
228
+ return 'plaintext';
229
+ }
230
+
231
+ // ── Built-in: kevinbatdorf/code-block-pro ────────────────────────────────────
232
+
233
+ /**
234
+ * Encode a string for safe use inside an HTML attribute value (double-quoted
235
+ * context: `class="…"`, `style="…"`). Covers the five characters that change
236
+ * meaning inside quoted attributes — `&`, `<`, `>`, `"`, `'` — by mapping each
237
+ * to its named or numeric entity.
238
+ *
239
+ * Callers MUST quote the resulting value with `"` (not single quotes) — the
240
+ * apostrophe escape uses `&#39;` rather than `&apos;` for older-browser
241
+ * compatibility, and the rest assume the surrounding quote is `"`.
242
+ */
243
+ function escapeAttr(value: string): string {
244
+ return value
245
+ .replace(/&/g, '&amp;')
246
+ .replace(/</g, '&lt;')
247
+ .replace(/>/g, '&gt;')
248
+ .replace(/"/g, '&quot;')
249
+ .replace(/'/g, '&#39;');
250
+ }
251
+
252
+ registerBlockEnricher('kevinbatdorf/code-block-pro', async (block) => {
253
+ const attrs = block.attributes ?? {};
254
+ const code = attrs.code as string | undefined;
255
+ if (!code) return null;
256
+
257
+ // Three caller intents on `language`:
258
+ // • Missing / '' / 'auto' → run inferLanguage()
259
+ // • 'plaintext' (and aliases) → render as plaintext, NO inference
260
+ // • Any other string → use it verbatim (caller knows the language)
261
+ //
262
+ // Earlier the enricher collapsed missing + 'plaintext' into "infer", which
263
+ // surprised callers passing 'plaintext' for prose. A chat prompt with the
264
+ // word "from" appearing twice tripped the SQL signal in inferLanguage() and
265
+ // rendered as syntax-highlighted SQL.
266
+ const rawLangAttr = typeof attrs.language === 'string' ? (attrs.language as string).trim() : '';
267
+ const rawLang = rawLangAttr.toLowerCase();
268
+ const PLAINTEXT_ALIASES = new Set(['plaintext', 'text', 'plain', 'txt', 'none']);
269
+ const shouldInfer = rawLang === '' || rawLang === 'auto';
270
+ const lang = shouldInfer
271
+ ? inferLanguage(code)
272
+ : (PLAINTEXT_ALIASES.has(rawLang) ? 'plaintext' : rawLang);
273
+
274
+ const { langs } = await getHighlighter();
275
+ const effectiveLang = langs.has(lang) ? lang : 'plaintext';
276
+
277
+ const themeName = (attrs.theme as string | undefined) || undefined;
278
+ const codeHTML = await shikiHighlight(code, effectiveLang, themeName);
279
+ const highestLineNumber = code.split('\n').length;
280
+ const incomingInnerHTML = block.innerHTML ?? '';
281
+ // Bail out only when nothing meaningful has changed AND innerHTML is already
282
+ // populated. An empty incomingInnerHTML always falls through so the wrapper
283
+ // gets built below, even if codeHTML matches a previously-stored attribute.
284
+ if (codeHTML === attrs.codeHTML && lang === rawLang && incomingInnerHTML !== '') {
285
+ return null;
286
+ }
287
+
288
+ const updatedAttrs = { ...attrs, language: lang, codeHTML, highestLineNumber };
289
+
290
+ // Encode `&`, `<`, `>` before injecting raw source code into the
291
+ // copy-button <textarea>'s text content. A literal `</textarea>` in the
292
+ // source would otherwise close the element early and corrupt innerHTML.
293
+ const encodedCode = code
294
+ .replace(/&/g, '&amp;')
295
+ .replace(/</g, '&lt;')
296
+ .replace(/>/g, '&gt;');
297
+
298
+ // CBP is dual-storage: codeHTML lives both as an attribute (for the editor)
299
+ // and inside innerHTML (the rendered widget served on the front-end).
300
+ // • innerHTML already exists → replace the <pre class="shiki"> portion
301
+ // in-place and re-sync the copy-button <textarea> contents.
302
+ // • innerHTML is empty → build a minimal wrapper from scratch so the
303
+ // block actually renders. Without this branch, a CBP block inserted via
304
+ // the API saves the codeHTML attribute but emits no visible HTML — the
305
+ // post renders a blank gap where the code should be.
306
+ let updatedInnerHTML: string;
307
+ if (incomingInnerHTML !== '') {
308
+ updatedInnerHTML = incomingInnerHTML.replace(
309
+ /<pre class="shiki[\s\S]*?<\/pre>/,
310
+ codeHTML,
311
+ );
312
+ updatedInnerHTML = updatedInnerHTML.replace(
313
+ /(<textarea[^>]*>)([\s\S]*?)(<\/textarea>)/,
314
+ (_m, open, _old, close) => `${open}${encodedCode}${close}`,
315
+ );
316
+ } else {
317
+ // Mirror CBP's save() inline style attribute. Without these the wrapper
318
+ // falls back to theme defaults and the code uses the surrounding font /
319
+ // colour, breaking visual parity with editor-created blocks.
320
+ //
321
+ // Every value is HTML-encoded before interpolation. Caller-supplied
322
+ // attributes can contain quotes, angle brackets, or ampersands either by
323
+ // accident (a font-name like `"Helvetica Neue"`) or by intent (an
324
+ // attacker-controlled className that breaks out of the attribute with
325
+ // `foo" onclick="…`). The encoder collapses all five
326
+ // attribute-significant characters to entities.
327
+ const styleParts: string[] = [];
328
+ if (typeof attrs.fontFamily === 'string') styleParts.push(`font-family:${escapeAttr(attrs.fontFamily)}`);
329
+ if (typeof attrs.fontSize === 'string') styleParts.push(`font-size:${escapeAttr(attrs.fontSize)}`);
330
+ if (typeof attrs.lineHeight === 'string') styleParts.push(`line-height:${escapeAttr(attrs.lineHeight)}`);
331
+ if (typeof attrs.bgColor === 'string') styleParts.push(`background-color:${escapeAttr(attrs.bgColor)}`);
332
+ if (typeof attrs.textColor === 'string') styleParts.push(`color:${escapeAttr(attrs.textColor)}`);
333
+ const styleAttr = styleParts.length ? ` style="${styleParts.join(';')}"` : '';
334
+ const classNameExtra = typeof attrs.className === 'string' && (attrs.className as string).trim() !== ''
335
+ ? ` ${escapeAttr((attrs.className as string).trim())}`
336
+ : '';
337
+ const copyTextarea = attrs.copyButton
338
+ ? `<textarea style="display:none" aria-hidden="true">${encodedCode}</textarea>`
339
+ : '';
340
+ updatedInnerHTML = `<div class="wp-block-kevinbatdorf-code-block-pro${classNameExtra}"${styleAttr}>${codeHTML}${copyTextarea}</div>`;
341
+ }
342
+
343
+ return {
344
+ ...block,
345
+ attributes: updatedAttrs,
346
+ innerHTML: updatedInnerHTML,
347
+ };
348
+ });
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Agent-friendly translations of WordPress REST error codes.
3
+ *
4
+ * The WP REST stack returns error codes like `rest_post_invalid_id` or
5
+ * `rest_no_route`. Those are useful to a developer reading server logs but
6
+ * useless to an LLM trying to recover and continue. This module maps the
7
+ * codes we actually see in the wild to short, actionable messages that
8
+ * suggest the agent's next step.
9
+ *
10
+ * The original code is always preserved on the thrown Error (`wpCode`) so
11
+ * agents that *do* want to pattern-match on the raw code still can.
12
+ */
13
+
14
+ interface ErrorContextHints {
15
+ /** WordPress post ID involved in the error, when applicable. */
16
+ post_id?: number;
17
+ /** gk_ref UUID that was rejected, when applicable. */
18
+ ref?: string;
19
+ /** Block path (e.g. [0, 2, 1]) that was rejected, when applicable. */
20
+ path?: number[];
21
+ /** Block name involved in a legacy/avoid rejection. */
22
+ block?: string;
23
+ /** Replacement block name suggested by the preference engine. */
24
+ suggested_replacement?: string;
25
+ /** Status from the data envelope, sometimes present even on success. */
26
+ status?: number;
27
+ /** Some endpoints return `block_name` instead of `block`. */
28
+ block_name?: string;
29
+ /** Allow extra fields without typing every one. */
30
+ [key: string]: unknown;
31
+ }
32
+
33
+ /**
34
+ * Pull a small set of well-known fields out of an arbitrary WP error data
35
+ * payload. Yoast/REST/our plugin all use slightly different shapes; this
36
+ * normalizer just looks for the keys we care about.
37
+ */
38
+ function extractHints(data: unknown): ErrorContextHints {
39
+ if (!data || typeof data !== 'object') return {};
40
+ const d = data as Record<string, unknown>;
41
+ const hints: ErrorContextHints = { ...d };
42
+
43
+ if (typeof d.post_id === 'number') hints.post_id = d.post_id;
44
+ if (typeof d.ref === 'string') hints.ref = d.ref;
45
+ if (Array.isArray(d.path) && d.path.every((p): p is number => typeof p === 'number' && Number.isFinite(p))) {
46
+ hints.path = d.path;
47
+ }
48
+ if (typeof d.block === 'string') hints.block = d.block;
49
+ if (typeof d.block_name === 'string') hints.block_name = d.block_name;
50
+ if (typeof d.suggested_replacement === 'string') hints.suggested_replacement = d.suggested_replacement;
51
+ if (typeof d.status === 'number') hints.status = d.status;
52
+
53
+ return hints;
54
+ }
55
+
56
+ /**
57
+ * Translate a WordPress REST error into an agent-actionable hint.
58
+ *
59
+ * Returns null when we don't have a translation for this code — callers
60
+ * should fall back to the raw `message` from the response body.
61
+ */
62
+ export function translateWpError(code: string | undefined, data: unknown): string | null {
63
+ if (!code) return null;
64
+ const hints = extractHints(data);
65
+ const blockName = hints.block ?? hints.block_name;
66
+
67
+ switch (code) {
68
+ // ── Routing / auth ─────────────────────────────────────────────
69
+ case 'rest_no_route':
70
+ return 'REST route not found at this site. Confirm the gk-block-api plugin is active and the WORDPRESS_URL is correct.';
71
+
72
+ case 'rest_forbidden':
73
+ case 'rest_cannot_edit':
74
+ case 'rest_cannot_create':
75
+ return 'Permission denied. The Application Password\'s user lacks the required capability (typically `edit_posts`, or `edit_post` on this specific post).';
76
+
77
+ case 'rest_cookie_invalid_nonce':
78
+ case 'rest_authentication_required':
79
+ return 'Authentication failed. Confirm WORDPRESS_USER and WORDPRESS_APP_PASSWORD are set to a valid Application Password (not a regular login password).';
80
+
81
+ // ── Post lookup ────────────────────────────────────────────────
82
+ case 'rest_post_invalid_id':
83
+ case 'invalid_post_id': {
84
+ const target = hints.post_id !== undefined ? `Post ${hints.post_id}` : 'Post';
85
+ return `${target} not found. List pages with \`list_posts\` to find the right ID.`;
86
+ }
87
+
88
+ case 'not_found':
89
+ // Generic 404 from our own handlers (post / pattern / media missing).
90
+ return hints.post_id
91
+ ? `Post ${hints.post_id} not found. It may have been deleted, or the ID is wrong.`
92
+ : 'Resource not found. It may have been deleted, or the ID is wrong.';
93
+
94
+ // ── Block ref / path resolution ────────────────────────────────
95
+ case 'gk_block_api_invalid_ref':
96
+ case 'invalid_ref':
97
+ return `Block ref \`${hints.ref ?? '?'}\` not found in post ${hints.post_id ?? '?'}. The post may have been edited since you last fetched it — call \`get_page_blocks\` again to get the current refs.`;
98
+
99
+ case 'path_not_found':
100
+ case 'invalid_path':
101
+ return `Block path ${formatPath(hints.path)} doesn't address an existing block. Re-fetch the post with \`get_page_blocks\` to get current paths — paths shift when blocks are added or removed.`;
102
+
103
+ case 'path_out_of_bounds':
104
+ return `Block path ${formatPath(hints.path)} is out of bounds. The post has fewer blocks than expected — re-fetch with \`get_page_blocks\` for current state.`;
105
+
106
+ // ── Block tier / preference enforcement ────────────────────────
107
+ case 'legacy_block':
108
+ return blockName
109
+ ? `${blockName} is in a namespace this site has configured as legacy. Use ${hints.suggested_replacement ?? 'a core block instead'}.`
110
+ : 'Legacy block rejected. Use a core block (or a higher-tier alternative) instead.';
111
+
112
+ case 'inner_html_required': {
113
+ const attrs = Array.isArray(hints.source_bound_attributes)
114
+ ? hints.source_bound_attributes.join(', ')
115
+ : 'one or more';
116
+ return blockName
117
+ ? `${blockName} stores attribute(s) [${attrs}] in HTML markup. Include \`innerHTML\` matching the attribute value (e.g. \`<p>{content}</p>\` for core/paragraph) — without it the saved block is self-closing and Gutenberg reports "Block contains unexpected or invalid content" on next edit.`
118
+ : `Block stores source-bound attribute(s) [${attrs}] in HTML markup but no \`innerHTML\` was provided. The saved block would be self-closing and Gutenberg would flag it as invalid on next edit.`;
119
+ }
120
+
121
+ case 'static_markup_stale_risk':
122
+ return 'Updating attributes on a static block without new innerHTML may leave its rendered markup stale. Pass `innerHTML` alongside `attributes`, or use a dynamic block.';
123
+
124
+ // ── Rate limiting ──────────────────────────────────────────────
125
+ case 'rate_limit_exceeded': {
126
+ const where = hints.post_id !== undefined ? `on post ${hints.post_id} ` : '';
127
+ return `Too many writes ${where}in the last minute. Wait ~60s before retrying, or batch your edits into a single \`edit_block_tree\` call.`;
128
+ }
129
+
130
+ // ── v1.2 post lifecycle ────────────────────────────────────────
131
+ case 'mixed_trash_payload':
132
+ return '`status: "trash"` cannot be combined with other fields. Trash the post in one call, then update other fields after.';
133
+
134
+ case 'invalid_post_type':
135
+ return 'Post type not allowed by this site\'s gk_block_api_post_types_allowlist option. Ask the site admin to add it, or pick a supported type.';
136
+
137
+ case 'invalid_status':
138
+ return 'Post status not allowed. Valid values: draft, pending, publish, future, private. To trash, call update_post with status:"trash" (on its own, not combined with other fields).';
139
+
140
+ // ── Media uploads ──────────────────────────────────────────────
141
+ case 'invalid_url':
142
+ return 'URL rejected by SSRF guard. Hostnames pointing at private/loopback/cloud-metadata IPs are blocked. Use a publicly reachable URL.';
143
+
144
+ case 'disallowed_mime':
145
+ return 'File MIME type isn\'t in WordPress\'s allowed-uploads list. Convert to PNG/JPG/WEBP for images, MP4 for video, etc.';
146
+
147
+ default:
148
+ return null;
149
+ }
150
+ }
151
+
152
+ /** Render a path array as `[0, 2, 1]` for error messages. */
153
+ function formatPath(path: number[] | undefined): string {
154
+ if (!path || path.length === 0) return '?';
155
+ return `[${path.join(', ')}]`;
156
+ }