@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/enrichers.ts
ADDED
|
@@ -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 `'` rather than `'` 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, '&')
|
|
246
|
+
.replace(/</g, '<')
|
|
247
|
+
.replace(/>/g, '>')
|
|
248
|
+
.replace(/"/g, '"')
|
|
249
|
+
.replace(/'/g, ''');
|
|
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, '&')
|
|
295
|
+
.replace(/</g, '<')
|
|
296
|
+
.replace(/>/g, '>');
|
|
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
|
+
}
|