@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,232 @@
1
+ /**
2
+ * Client-level URL routing tests for ref endpoints.
3
+ *
4
+ * These verify WordPressBlockClient builds the correct URLs and encodes
5
+ * refs properly. Uses an axios adapter to capture requests without hitting
6
+ * the network.
7
+ *
8
+ * Covers:
9
+ * - updateBlockByRef → PATCH /posts/{id}/blocks/by-ref/{ref}
10
+ * - deleteBlockByRef → DELETE /posts/{id}/blocks/by-ref/{ref}
11
+ * - deleteBlockByRef count > 1 → query param
12
+ * - deleteBlockByRef count <= 1 → no count param
13
+ * - updateBlock (index path) → PATCH /posts/{id}/blocks/{index} (no by-ref)
14
+ * - URL encoding for refs with reserved characters
15
+ * - getPageBlocks persist_refs query param (omit / false / true)
16
+ * - insertBlocks after_ref / before_ref in body
17
+ * - mutateBlockTree ref in body (path absent)
18
+ * - mutateBlockTree path in body (ref absent)
19
+ * - mutateBlockTree before_ref in move body
20
+ * - mutateBlockTree destination_ref in move body
21
+ * - Input guards: empty ref, missing post_id, missing data
22
+ */
23
+
24
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
25
+ import axios from 'axios';
26
+ import { WordPressBlockClient } from '../../../client.js';
27
+
28
+ type CapturedRequest = {
29
+ method: string;
30
+ url: string;
31
+ baseURL: string;
32
+ data?: unknown;
33
+ params?: Record<string, unknown>;
34
+ };
35
+
36
+ let captured: CapturedRequest[] = [];
37
+
38
+ function safeJsonParse(s: string): unknown {
39
+ try { return JSON.parse(s); } catch { return s; }
40
+ }
41
+
42
+ const realCreate = axios.create.bind(axios);
43
+
44
+ beforeEach(() => {
45
+ captured = [];
46
+ vi.spyOn(axios, 'create').mockImplementation((cfg: any = {}) => {
47
+ const inst = realCreate(cfg);
48
+ inst.defaults.adapter = ((config: any) => {
49
+ captured.push({
50
+ method: (config.method || 'get').toUpperCase(),
51
+ url: config.url || '',
52
+ baseURL: config.baseURL || '',
53
+ data: typeof config.data === 'string' ? safeJsonParse(config.data) : config.data,
54
+ params: config.params,
55
+ });
56
+ return Promise.resolve({
57
+ data: { success: true, blocks: [], inserted: [], removed: 0, before_revision_id: 1, revision_id: 2 },
58
+ status: 200, statusText: 'OK', headers: {}, config,
59
+ });
60
+ }) as any;
61
+ return inst;
62
+ });
63
+ });
64
+
65
+ function makeClient() {
66
+ return new WordPressBlockClient({
67
+ wordpress_url: 'https://example.test',
68
+ auth: { username: 'u', application_password: 'p p p p' },
69
+ });
70
+ }
71
+
72
+ // ── URL routing ───────────────────────────────────────────────────────────────
73
+
74
+ describe('Client — ref endpoint URL routing', () => {
75
+ it('updateBlockByRef hits /blocks/by-ref/{ref} with PATCH', async () => {
76
+ const client = makeClient();
77
+ await client.updateBlockByRef(42, 'blk_a3f2c1q9', { attributes: { level: 3 } });
78
+ const req = captured.find((r) => r.url.includes('/blocks/by-ref/'));
79
+ expect(req).toBeDefined();
80
+ expect(req!.method).toBe('PATCH');
81
+ expect(req!.url).toBe('/posts/42/blocks/by-ref/blk_a3f2c1q9');
82
+ expect(req!.data).toEqual({ attributes: { level: 3 } });
83
+ });
84
+
85
+ it('deleteBlockByRef hits /blocks/by-ref/{ref} with DELETE', async () => {
86
+ const client = makeClient();
87
+ await client.deleteBlockByRef(42, 'blk_target');
88
+ const req = captured.find((r) => r.url.includes('/blocks/by-ref/'));
89
+ expect(req).toBeDefined();
90
+ expect(req!.method).toBe('DELETE');
91
+ expect(req!.url).toBe('/posts/42/blocks/by-ref/blk_target');
92
+ });
93
+
94
+ it('deleteBlockByRef forwards count > 1 as a query param', async () => {
95
+ const client = makeClient();
96
+ await client.deleteBlockByRef(42, 'blk_target', 3);
97
+ const req = captured.find((r) => r.url.includes('/blocks/by-ref/'));
98
+ expect(req!.params).toEqual({ count: '3' });
99
+ });
100
+
101
+ it('deleteBlockByRef does not include count when count <= 1', async () => {
102
+ const client = makeClient();
103
+ await client.deleteBlockByRef(42, 'blk_target', 1);
104
+ const req = captured.find((r) => r.url.includes('/blocks/by-ref/'));
105
+ expect(req!.params).toEqual({});
106
+ });
107
+
108
+ it('updateBlock (index path) hits /blocks/{index} — no by-ref crossover', async () => {
109
+ const client = makeClient();
110
+ await client.updateBlock(42, 7, { attributes: { foo: 'bar' } });
111
+ const req = captured.find((r) => r.method === 'PATCH');
112
+ expect(req!.url).toBe('/posts/42/blocks/7');
113
+ expect(req!.url).not.toContain('by-ref');
114
+ });
115
+
116
+ it('encodeURIComponent escapes refs with reserved characters', async () => {
117
+ const client = makeClient();
118
+ await client.updateBlockByRef(42, 'weird/ref#abc', { innerHTML: '<p>x</p>' });
119
+ const req = captured.find((r) => r.url.includes('/blocks/by-ref/'));
120
+ expect(req!.url).toBe('/posts/42/blocks/by-ref/weird%2Fref%23abc');
121
+ });
122
+ });
123
+
124
+ // ── persist_refs query param ──────────────────────────────────────────────────
125
+
126
+ describe('Client — getPageBlocks persist_refs', () => {
127
+ it('omits persist_refs when not specified', async () => {
128
+ const client = makeClient();
129
+ await client.getPageBlocks(42);
130
+ const req = captured.find((r) => r.url === '/posts/42/blocks' && r.method === 'GET');
131
+ expect(req!.params).not.toHaveProperty('persist_refs');
132
+ });
133
+
134
+ it('sends persist_refs=false explicitly', async () => {
135
+ const client = makeClient();
136
+ await client.getPageBlocks(42, { persist_refs: false });
137
+ const req = captured.find((r) => r.url === '/posts/42/blocks' && r.method === 'GET');
138
+ expect(req!.params?.persist_refs).toBe('false');
139
+ });
140
+
141
+ it('sends persist_refs=true explicitly', async () => {
142
+ const client = makeClient();
143
+ await client.getPageBlocks(42, { persist_refs: true });
144
+ const req = captured.find((r) => r.url === '/posts/42/blocks' && r.method === 'GET');
145
+ expect(req!.params?.persist_refs).toBe('true');
146
+ });
147
+ });
148
+
149
+ // ── insertBlocks after_ref / before_ref body params ───────────────────────────
150
+
151
+ describe('Client — insertBlocks ref body params', () => {
152
+ it('forwards after_ref in JSON body', async () => {
153
+ const client = makeClient();
154
+ await client.insertBlocks(42, {
155
+ after_ref: 'blk_anchor',
156
+ blocks: [{ name: 'core/paragraph', innerHTML: '<p>x</p>' }],
157
+ });
158
+ const req = captured.find((r) => r.method === 'POST' && r.url === '/posts/42/blocks');
159
+ expect((req!.data as Record<string, unknown>).after_ref).toBe('blk_anchor');
160
+ });
161
+
162
+ it('forwards before_ref in JSON body', async () => {
163
+ const client = makeClient();
164
+ await client.insertBlocks(42, {
165
+ before_ref: 'blk_anchor2',
166
+ blocks: [{ name: 'core/paragraph', innerHTML: '<p>x</p>' }],
167
+ });
168
+ const req = captured.find((r) => r.method === 'POST' && r.url === '/posts/42/blocks');
169
+ expect((req!.data as Record<string, unknown>).before_ref).toBe('blk_anchor2');
170
+ });
171
+ });
172
+
173
+ // ── mutateBlockTree ref body ──────────────────────────────────────────────────
174
+
175
+ describe('Client — mutateBlockTree ref/path body', () => {
176
+ it('sends ref in body when provided (no path)', async () => {
177
+ const client = makeClient();
178
+ await client.mutateBlockTree(42, { op: 'update-attrs', ref: 'blk_target', attributes: { level: 3 } });
179
+ const req = captured.find((r) => r.method === 'POST' && r.url === '/posts/42/mutate');
180
+ const body = req!.data as Record<string, unknown>;
181
+ expect(body.ref).toBe('blk_target');
182
+ expect(body).not.toHaveProperty('path');
183
+ });
184
+
185
+ it('sends path in body when provided (no ref)', async () => {
186
+ const client = makeClient();
187
+ await client.mutateBlockTree(42, { op: 'update-attrs', path: [0, 1], attributes: { level: 3 } });
188
+ const req = captured.find((r) => r.method === 'POST' && r.url === '/posts/42/mutate');
189
+ const body = req!.data as Record<string, unknown>;
190
+ expect(body.path).toEqual([0, 1]);
191
+ expect(body).not.toHaveProperty('ref');
192
+ });
193
+
194
+ it('forwards before_ref in move body', async () => {
195
+ const client = makeClient();
196
+ await client.mutateBlockTree(42, { op: 'move', ref: 'blk_source', before_ref: 'blk_anchor' });
197
+ const req = captured.find((r) => r.method === 'POST' && r.url === '/posts/42/mutate');
198
+ expect((req!.data as Record<string, unknown>).before_ref).toBe('blk_anchor');
199
+ });
200
+
201
+ it('forwards destination_ref in move body', async () => {
202
+ const client = makeClient();
203
+ await client.mutateBlockTree(42, { op: 'move', ref: 'blk_source', destination_ref: 'blk_parent' });
204
+ const req = captured.find((r) => r.method === 'POST' && r.url === '/posts/42/mutate');
205
+ expect((req!.data as Record<string, unknown>).destination_ref).toBe('blk_parent');
206
+ });
207
+ });
208
+
209
+ // ── Input guards ──────────────────────────────────────────────────────────────
210
+
211
+ describe('Client — input guards', () => {
212
+ it('updateBlockByRef rejects empty ref', async () => {
213
+ const client = makeClient();
214
+ await expect(client.updateBlockByRef(42, '', { attributes: {} })).rejects.toThrow(/Ref is required/);
215
+ });
216
+
217
+ it('updateBlockByRef rejects missing post_id', async () => {
218
+ const client = makeClient();
219
+ await expect(client.updateBlockByRef(undefined as any, 'blk_x', { attributes: {} }))
220
+ .rejects.toThrow(/Post ID is required/);
221
+ });
222
+
223
+ it('updateBlockByRef requires attributes or innerHTML', async () => {
224
+ const client = makeClient();
225
+ await expect(client.updateBlockByRef(42, 'blk_x', {})).rejects.toThrow(/attributes or innerHTML/);
226
+ });
227
+
228
+ it('deleteBlockByRef rejects empty ref', async () => {
229
+ const client = makeClient();
230
+ await expect(client.deleteBlockByRef(42, '')).rejects.toThrow(/Ref is required/);
231
+ });
232
+ });
@@ -0,0 +1,457 @@
1
+ /**
2
+ * Unit tests for the Code Block Pro enricher (enrichBlock / enrichBlocks).
3
+ *
4
+ * The enricher is the one piece of non-trivial logic in the enrichers module:
5
+ * it generates codeHTML via shiki, infers languages, and updates innerHTML.
6
+ * All tests run against real shiki (no mocking) because the output format
7
+ * is stable and the runtime cost is acceptable for a unit suite.
8
+ *
9
+ * This file focuses on the CBP enricher. The registerBlockEnricher extension
10
+ * point is tested here too (it's part of the same module surface).
11
+ */
12
+
13
+ import { describe, it, expect, beforeAll, beforeEach, vi } from 'vitest';
14
+ import {
15
+ inferLanguage,
16
+ enrichBlock,
17
+ enrichBlocks,
18
+ registerBlockEnricher,
19
+ shikiHighlight,
20
+ type BlockDef,
21
+ } from '../../../enrichers.js';
22
+
23
+ // Shiki's first getHighlighter() call loads every grammar + theme and can take
24
+ // several seconds — especially on a loaded CI box. getHighlighter() memoises a
25
+ // single module-level promise, so only the FIRST highlight pays that cost; the
26
+ // rest reuse it. Warm it once here, with a generous hook timeout, so no
27
+ // individual test inherits the cold-start latency and trips the default 5s
28
+ // per-test timeout (the cause of an intermittent "generates codeHTML" flake).
29
+ beforeAll(async () => {
30
+ await shikiHighlight('// warm', 'javascript');
31
+ }, 30_000);
32
+
33
+ // ── inferLanguage ─────────────────────────────────────────────────────────────
34
+
35
+ describe('inferLanguage', () => {
36
+ it.each([
37
+ ['$var = "hello";', 'php'],
38
+ ['function myFunc() {}', 'php'],
39
+ ['.container { display: flex; }', 'css'],
40
+ ['const x = 1;', 'javascript'],
41
+ ['let y = [];', 'javascript'],
42
+ ['#!/bin/bash\necho hi', 'bash'],
43
+ ['{"key": "value"}', 'json'],
44
+ ['hello world plain text', 'plaintext'],
45
+ ])('infers %s → %s', (code, expected) => {
46
+ expect(inferLanguage(code)).toBe(expected);
47
+ });
48
+
49
+ it('returns plaintext for empty string', () => {
50
+ expect(inferLanguage('')).toBe('plaintext');
51
+ });
52
+ });
53
+
54
+ // ── Non-CBP pass-through ──────────────────────────────────────────────────────
55
+
56
+ describe('enrichBlock — non-CBP pass-through', () => {
57
+ it('returns original block reference unchanged for core/paragraph', async () => {
58
+ const block: BlockDef = { name: 'core/paragraph', attributes: { content: 'Hello' } };
59
+ const result = await enrichBlock(block);
60
+ expect(result).toBe(block);
61
+ });
62
+
63
+ it('returns original block for any block without a registered enricher', async () => {
64
+ const block: BlockDef = { name: 'custom/unknown-widget', attributes: {} };
65
+ const result = await enrichBlock(block);
66
+ expect(result).toBe(block);
67
+ });
68
+
69
+ it('returns original block when CBP block has no code attribute', async () => {
70
+ const block: BlockDef = {
71
+ name: 'kevinbatdorf/code-block-pro',
72
+ attributes: { language: 'php' },
73
+ };
74
+ const result = await enrichBlock(block);
75
+ expect(result).toEqual(block);
76
+ });
77
+ });
78
+
79
+ // ── CBP enrichment — happy path ───────────────────────────────────────────────
80
+
81
+ describe('enrichBlock — Code Block Pro', () => {
82
+ it('generates codeHTML attribute', async () => {
83
+ const block: BlockDef = {
84
+ name: 'kevinbatdorf/code-block-pro',
85
+ attributes: { code: 'const x = 1;', language: 'javascript' },
86
+ };
87
+ const result = await enrichBlock(block);
88
+ expect(result.attributes?.codeHTML).toBeDefined();
89
+ expect(result.attributes?.codeHTML as string).toContain('<pre class="shiki');
90
+ });
91
+
92
+ it('sets highestLineNumber to line count', async () => {
93
+ const block: BlockDef = {
94
+ name: 'kevinbatdorf/code-block-pro',
95
+ attributes: { code: 'line1\nline2\nline3', language: 'plaintext' },
96
+ };
97
+ const result = await enrichBlock(block);
98
+ expect(result.attributes?.highestLineNumber).toBe(3);
99
+ });
100
+
101
+ it('single-line code has highestLineNumber = 1', async () => {
102
+ const block: BlockDef = {
103
+ name: 'kevinbatdorf/code-block-pro',
104
+ attributes: { code: 'echo "hi";', language: 'php' },
105
+ };
106
+ const result = await enrichBlock(block);
107
+ expect(result.attributes?.highestLineNumber).toBe(1);
108
+ });
109
+
110
+ /**
111
+ * Fresh CBP blocks created via the API (e.g. edit_block_tree replace-block)
112
+ * arrive with no innerHTML. Pre-fix, the enricher only updated codeHTML and
113
+ * left innerHTML empty, which made the block render as a blank gap on the
114
+ * front-end. The enricher must build a minimal wrapper so the block is
115
+ * actually visible after save.
116
+ */
117
+ it('builds wrapper innerHTML when block has none', async () => {
118
+ const block: BlockDef = {
119
+ name: 'kevinbatdorf/code-block-pro',
120
+ attributes: { code: 'const a = 1;', language: 'javascript' },
121
+ };
122
+ const result = await enrichBlock(block);
123
+ expect(typeof result.innerHTML).toBe('string');
124
+ expect(result.innerHTML).toContain('wp-block-kevinbatdorf-code-block-pro');
125
+ expect(result.innerHTML).toContain('<pre class="shiki');
126
+ });
127
+
128
+ it('inlines wrapper style from font / colour attributes', async () => {
129
+ const block: BlockDef = {
130
+ name: 'kevinbatdorf/code-block-pro',
131
+ attributes: {
132
+ code: 'const a = 1;',
133
+ language: 'javascript',
134
+ fontFamily: 'Code-Pro-JetBrains-Mono',
135
+ fontSize: '1rem',
136
+ lineHeight: '1.25rem',
137
+ bgColor: '#0F2B62',
138
+ textColor: '#d8dee9ff',
139
+ },
140
+ };
141
+ const result = await enrichBlock(block);
142
+ expect(result.innerHTML).toContain('font-family:Code-Pro-JetBrains-Mono');
143
+ expect(result.innerHTML).toContain('font-size:1rem');
144
+ expect(result.innerHTML).toContain('background-color:#0F2B62');
145
+ expect(result.innerHTML).toContain('color:#d8dee9ff');
146
+ });
147
+
148
+ it('includes copy-textarea when copyButton is enabled', async () => {
149
+ const block: BlockDef = {
150
+ name: 'kevinbatdorf/code-block-pro',
151
+ attributes: { code: 'const a = 1;', language: 'javascript', copyButton: true },
152
+ };
153
+ const result = await enrichBlock(block);
154
+ expect(result.innerHTML).toMatch(/<textarea[^>]*>const a = 1;<\/textarea>/);
155
+ });
156
+
157
+ it('omits copy-textarea when copyButton is false', async () => {
158
+ const block: BlockDef = {
159
+ name: 'kevinbatdorf/code-block-pro',
160
+ attributes: { code: 'const a = 1;', language: 'javascript', copyButton: false },
161
+ };
162
+ const result = await enrichBlock(block);
163
+ expect(result.innerHTML).not.toContain('<textarea');
164
+ });
165
+
166
+ /**
167
+ * Regression: a literal `</textarea>` in the source code would otherwise
168
+ * close the copy-button <textarea> early and corrupt the wrapper. The
169
+ * enricher must HTML-encode `<` (and `&`, `>`) in the textarea content so
170
+ * the closing tag in the code is rendered as inert text, not parsed as a
171
+ * tag boundary.
172
+ */
173
+ it('escapes closing textarea in code payload', async () => {
174
+ const block: BlockDef = {
175
+ name: 'kevinbatdorf/code-block-pro',
176
+ attributes: { code: "const x = '</textarea>';", language: 'javascript', copyButton: true },
177
+ };
178
+ const result = await enrichBlock(block);
179
+ // Isolate just the copy-button <textarea> contents — the codeHTML <pre>
180
+ // above it legitimately escapes its own markup separately.
181
+ const match = result.innerHTML!.match(/<textarea[^>]*>([\s\S]*?)<\/textarea>/);
182
+ expect(match).not.toBeNull();
183
+ const textareaContent = match![1];
184
+ expect(textareaContent).not.toContain('</textarea>');
185
+ expect(textareaContent).toContain('&lt;/textarea&gt;');
186
+ });
187
+
188
+ /**
189
+ * Wrapper-style values come straight from caller-supplied attributes. A
190
+ * value containing `"` (whether malicious or just a quoted font-name like
191
+ * `"Helvetica Neue"`) used to break out of the style="" attribute and
192
+ * either corrupt the markup or inject active content. The enricher now
193
+ * HTML-encodes attribute values before interpolation.
194
+ */
195
+ it('escapes double-quotes in style attribute values', async () => {
196
+ const block: BlockDef = {
197
+ name: 'kevinbatdorf/code-block-pro',
198
+ attributes: {
199
+ code: 'const a = 1;',
200
+ language: 'javascript',
201
+ fontFamily: 'Arial" onerror="alert(1)',
202
+ },
203
+ };
204
+ const result = await enrichBlock(block);
205
+ expect(result.innerHTML).not.toContain('onerror="alert(1)');
206
+ expect(result.innerHTML).toContain('&quot;');
207
+ });
208
+
209
+ it('escapes special characters in className', async () => {
210
+ const block: BlockDef = {
211
+ name: 'kevinbatdorf/code-block-pro',
212
+ attributes: {
213
+ code: 'const a = 1;',
214
+ language: 'javascript',
215
+ className: 'safe" onclick="alert(1)',
216
+ },
217
+ };
218
+ const result = await enrichBlock(block);
219
+ expect(result.innerHTML).not.toContain('onclick="alert(1)');
220
+ // The literal double-quote inside the className value must be encoded
221
+ // rather than rendered raw, regardless of where the escape happens.
222
+ expect(result.innerHTML).toMatch(/class="wp-block-kevinbatdorf-code-block-pro[^"]*"/);
223
+ });
224
+
225
+ it('escapes ampersands and angle brackets in style values', async () => {
226
+ const block: BlockDef = {
227
+ name: 'kevinbatdorf/code-block-pro',
228
+ attributes: {
229
+ code: 'const a = 1;',
230
+ language: 'javascript',
231
+ bgColor: '#fff & <script>',
232
+ },
233
+ };
234
+ const result = await enrichBlock(block);
235
+ expect(result.innerHTML).not.toContain('<script>');
236
+ expect(result.innerHTML).toContain('&amp;');
237
+ expect(result.innerHTML).toContain('&lt;script&gt;');
238
+ });
239
+
240
+ it('keeps explicit php language without inference', async () => {
241
+ const block: BlockDef = {
242
+ name: 'kevinbatdorf/code-block-pro',
243
+ attributes: { code: '$x = 1;', language: 'php' },
244
+ };
245
+ const result = await enrichBlock(block);
246
+ expect(result.attributes?.language).toBe('php');
247
+ });
248
+ });
249
+
250
+ // ── CBP enrichment — language inference ──────────────────────────────────────
251
+
252
+ describe('enrichBlock — language inference', () => {
253
+ /**
254
+ * Explicit `language: 'plaintext'` is the caller saying "render this as plain
255
+ * text, no syntax highlighting." Pre-fix the enricher treated 'plaintext' as
256
+ * "no preference, infer" — so a chat prompt containing "from … from …" was
257
+ * detected as SQL and rendered with mis-coloured English words. The contract
258
+ * now is: explicit 'plaintext'/'text'/'plain'/'txt'/'none' is respected;
259
+ * inference only runs when the attribute is missing, empty, or 'auto'.
260
+ */
261
+ it('respects explicit plaintext language without inference', async () => {
262
+ const block: BlockDef = {
263
+ name: 'kevinbatdorf/code-block-pro',
264
+ attributes: { code: '.hero { color: red; }', language: 'plaintext' },
265
+ };
266
+ const result = await enrichBlock(block);
267
+ expect(result.attributes?.language).toBe('plaintext');
268
+ });
269
+
270
+ it.each(['text', 'plain', 'txt', 'none'])(
271
+ 'respects explicit %s as plaintext alias',
272
+ async (alias) => {
273
+ const block: BlockDef = {
274
+ name: 'kevinbatdorf/code-block-pro',
275
+ attributes: { code: '.hero { color: red; }', language: alias },
276
+ };
277
+ const result = await enrichBlock(block);
278
+ expect(result.attributes?.language).toBe('plaintext');
279
+ },
280
+ );
281
+
282
+ it('infers css when language attribute is missing', async () => {
283
+ const block: BlockDef = {
284
+ name: 'kevinbatdorf/code-block-pro',
285
+ attributes: { code: '.hero { color: red; }' },
286
+ };
287
+ const result = await enrichBlock(block);
288
+ expect(result.attributes?.language).toBe('css');
289
+ });
290
+
291
+ it('infers css when language is "auto"', async () => {
292
+ const block: BlockDef = {
293
+ name: 'kevinbatdorf/code-block-pro',
294
+ attributes: { code: '.hero { color: red; }', language: 'auto' },
295
+ };
296
+ const result = await enrichBlock(block);
297
+ expect(result.attributes?.language).toBe('css');
298
+ });
299
+
300
+ /**
301
+ * Regression: chat prompts pasted into a CBP block via the API used to detect
302
+ * as SQL because inferLanguage's SQL heuristic fires on the word "from" — and
303
+ * "from" appears twice in the canonical "Set up Block MCP from … from me"
304
+ * prompt. With explicit plaintext respected, the prompt renders correctly.
305
+ */
306
+ it('does not mis-detect English prose as SQL when caller passes plaintext', async () => {
307
+ const block: BlockDef = {
308
+ name: 'kevinbatdorf/code-block-pro',
309
+ attributes: {
310
+ code: 'Set up Block MCP from https://example.com on my computer. Walk me through anything you need from me — WordPress site URL, username, and Application Password.',
311
+ language: 'plaintext',
312
+ },
313
+ };
314
+ const result = await enrichBlock(block);
315
+ expect(result.attributes?.language).toBe('plaintext');
316
+ // The generated codeHTML must not contain SQL keyword tokens for the
317
+ // English words that previously got mis-coloured.
318
+ expect(result.attributes?.codeHTML).not.toContain('shiki-token-keyword');
319
+ });
320
+
321
+ it('does not override non-plaintext language with inference', async () => {
322
+ const block: BlockDef = {
323
+ name: 'kevinbatdorf/code-block-pro',
324
+ attributes: { code: '.hero { color: red; }', language: 'javascript' },
325
+ };
326
+ const result = await enrichBlock(block);
327
+ expect(result.attributes?.language).toBe('javascript');
328
+ });
329
+ });
330
+
331
+ // ── CBP enrichment — innerHTML update ────────────────────────────────────────
332
+
333
+ describe('enrichBlock — innerHTML update', () => {
334
+ it('replaces the <pre class="shiki"> portion in existing innerHTML', async () => {
335
+ const code = 'const a = 1;';
336
+ const oldPre = '<pre class="shiki old-theme"><code>old</code></pre>';
337
+ const innerHTML = `<div class="cbp-wrap">${oldPre}<textarea>${code}</textarea></div>`;
338
+ const block: BlockDef = {
339
+ name: 'kevinbatdorf/code-block-pro',
340
+ attributes: { code, language: 'javascript' },
341
+ innerHTML,
342
+ };
343
+ const result = await enrichBlock(block);
344
+ expect(result.innerHTML).toBeDefined();
345
+ expect(result.innerHTML).toContain('<pre class="shiki');
346
+ expect(result.innerHTML).not.toContain('old-theme');
347
+ });
348
+
349
+ it('updates <textarea> content with current code', async () => {
350
+ const code = 'const updated = true;';
351
+ const innerHTML = `<div><pre class="shiki old"><code></code></pre><textarea>old code</textarea></div>`;
352
+ const block: BlockDef = {
353
+ name: 'kevinbatdorf/code-block-pro',
354
+ attributes: { code, language: 'javascript' },
355
+ innerHTML,
356
+ };
357
+ const result = await enrichBlock(block);
358
+ expect(result.innerHTML).toContain(`<textarea>${code}</textarea>`);
359
+ expect(result.innerHTML).not.toContain('old code');
360
+ });
361
+ });
362
+
363
+ // ── CBP enrichment — no-op (codeHTML already current) ────────────────────────
364
+
365
+ describe('enrichBlock — no-op when already enriched', () => {
366
+ /**
367
+ * A fully-enriched CBP block has both `codeHTML` (attribute) and `innerHTML`
368
+ * (the rendered widget). Passing such a block through the enricher again
369
+ * must be a no-op — same object reference returned. If only one side is
370
+ * populated, the enricher rebuilds the missing piece so first-pass blocks
371
+ * created via the API (no innerHTML) still render correctly.
372
+ */
373
+ it('returns original block reference when codeHTML and innerHTML are already current', async () => {
374
+ const code = 'const x = 1;';
375
+ const firstPass = await enrichBlock({
376
+ name: 'kevinbatdorf/code-block-pro',
377
+ attributes: { code, language: 'javascript' },
378
+ });
379
+ const codeHTML = firstPass.attributes?.codeHTML as string;
380
+ const innerHTML = firstPass.innerHTML as string;
381
+
382
+ const block: BlockDef = {
383
+ name: 'kevinbatdorf/code-block-pro',
384
+ attributes: { code, language: 'javascript', codeHTML },
385
+ innerHTML,
386
+ };
387
+ const result = await enrichBlock(block);
388
+ expect(result).toBe(block);
389
+ });
390
+ });
391
+
392
+ // ── enrichBlocks ──────────────────────────────────────────────────────────────
393
+
394
+ describe('enrichBlocks', () => {
395
+ it('returns empty array for empty input', async () => {
396
+ expect(await enrichBlocks([])).toEqual([]);
397
+ });
398
+
399
+ it('enriches CBP blocks and passes non-CBP blocks through unchanged', async () => {
400
+ const blocks: BlockDef[] = [
401
+ { name: 'core/paragraph', attributes: { content: 'Hello' } },
402
+ { name: 'kevinbatdorf/code-block-pro', attributes: { code: 'const x = 1;', language: 'javascript' } },
403
+ ];
404
+ const results = await enrichBlocks(blocks);
405
+ expect(results).toHaveLength(2);
406
+ expect(results[0]).toBe(blocks[0]);
407
+ expect(results[1].attributes?.codeHTML).toBeDefined();
408
+ });
409
+
410
+ it('processes all CBP blocks in the array', async () => {
411
+ const blocks: BlockDef[] = [
412
+ { name: 'kevinbatdorf/code-block-pro', attributes: { code: 'const a = 1;', language: 'javascript' } },
413
+ { name: 'kevinbatdorf/code-block-pro', attributes: { code: '$x = true;', language: 'php' } },
414
+ ];
415
+ const results = await enrichBlocks(blocks);
416
+ expect(results[0].attributes?.codeHTML).toBeDefined();
417
+ expect(results[1].attributes?.codeHTML).toBeDefined();
418
+ });
419
+ });
420
+
421
+ // ── registerBlockEnricher ─────────────────────────────────────────────────────
422
+
423
+ describe('registerBlockEnricher', () => {
424
+ beforeEach(() => vi.clearAllMocks());
425
+
426
+ it('runs a custom enricher for its registered block name', async () => {
427
+ const customFn = vi.fn(async (block: BlockDef) => ({
428
+ ...block,
429
+ attributes: { ...block.attributes, custom: true },
430
+ }));
431
+ registerBlockEnricher('test/custom-enricher-reg', customFn);
432
+
433
+ const block: BlockDef = { name: 'test/custom-enricher-reg', attributes: { foo: 'bar' } };
434
+ await enrichBlock(block);
435
+ expect(customFn).toHaveBeenCalledWith(block);
436
+ });
437
+
438
+ it('applies the custom enricher result', async () => {
439
+ registerBlockEnricher('test/custom-enricher-apply', async (block: BlockDef) => ({
440
+ ...block,
441
+ attributes: { ...block.attributes, computed: 'yes' },
442
+ }));
443
+
444
+ const block: BlockDef = { name: 'test/custom-enricher-apply', attributes: { original: true } };
445
+ const result = await enrichBlock(block);
446
+ expect(result.attributes?.computed).toBe('yes');
447
+ expect(result.attributes?.original).toBe(true);
448
+ });
449
+
450
+ it('returns null (no-op) from enricher passes original block through', async () => {
451
+ registerBlockEnricher('test/noop-enricher', async () => null);
452
+
453
+ const block: BlockDef = { name: 'test/noop-enricher', attributes: {} };
454
+ const result = await enrichBlock(block);
455
+ expect(result).toBe(block);
456
+ });
457
+ });