@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
package/package.json ADDED
@@ -0,0 +1,70 @@
1
+ {
2
+ "name": "@gravitykit/block-mcp",
3
+ "version": "2.0.0-beta",
4
+ "description": "MCP server for WordPress block-level content management with preference-aware editing",
5
+ "main": "dist/index.cjs",
6
+ "bin": {
7
+ "block-mcp": "dist/index.cjs"
8
+ },
9
+ "type": "module",
10
+ "scripts": {
11
+ "build": "esbuild src/index.ts --bundle --platform=node --format=cjs --outfile=dist/index.cjs --external:supports-color --external:has-flag",
12
+ "postbuild": "bash scripts/copy-server-bundle.sh",
13
+ "start": "node dist/index.cjs",
14
+ "dev": "esbuild src/index.ts --bundle --platform=node --format=cjs --outfile=dist/index.cjs --watch --external:supports-color --external:has-flag",
15
+ "inspect": "npx @modelcontextprotocol/inspector node dist/index.cjs",
16
+ "test": "vitest run",
17
+ "test:watch": "vitest",
18
+ "test:integration": "vitest run --config vitest.integration.config.ts",
19
+ "eval": "tsx tests/evals/lib/runner.ts",
20
+ "eval:fixture-refresh": "tsx tests/evals/scripts/fetch-fixture.ts",
21
+ "prepare": "npm run build",
22
+ "prepublishOnly": "npm test"
23
+ },
24
+ "keywords": [
25
+ "mcp",
26
+ "wordpress",
27
+ "blocks",
28
+ "gutenberg",
29
+ "content-management",
30
+ "gravitykit"
31
+ ],
32
+ "author": "GravityKit",
33
+ "license": "MIT",
34
+ "repository": {
35
+ "type": "git",
36
+ "url": "git+https://github.com/GravityKit/block-mcp.git"
37
+ },
38
+ "homepage": "https://github.com/GravityKit/block-mcp#readme",
39
+ "bugs": {
40
+ "url": "https://github.com/GravityKit/block-mcp/issues"
41
+ },
42
+ "dependencies": {
43
+ "@modelcontextprotocol/sdk": "^1.0.0",
44
+ "axios": "^1.7.9"
45
+ },
46
+ "devDependencies": {
47
+ "@anthropic-ai/sdk": "^0.91.1",
48
+ "@shikijs/engine-javascript": "^4.0.2",
49
+ "@shikijs/langs": "^4.0.2",
50
+ "@types/node": "^22.0.0",
51
+ "esbuild": "^0.27.3",
52
+ "shiki": "^4.0.2",
53
+ "tsx": "^4.21.0",
54
+ "typescript": "^5.7.0",
55
+ "vitest": "^3.2.4"
56
+ },
57
+ "engines": {
58
+ "node": ">=20.0.0"
59
+ },
60
+ "files": [
61
+ "dist/",
62
+ "src/",
63
+ "LICENSE",
64
+ "README.md",
65
+ ".env.example"
66
+ ],
67
+ "publishConfig": {
68
+ "access": "public"
69
+ }
70
+ }
@@ -0,0 +1,199 @@
1
+ /**
2
+ * Canonical block tree fixtures.
3
+ *
4
+ * Plain data objects only — no logic, no imports from src.
5
+ * Shape matches the Block interface from src/types.ts.
6
+ * Import these in any test that needs realistic block data.
7
+ */
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // Leaf block types
11
+ // ---------------------------------------------------------------------------
12
+
13
+ export const paragraphBlock = {
14
+ index: 0,
15
+ top_level_counter: 0,
16
+ path: [0],
17
+ ref: 'blk_para0001',
18
+ name: 'core/paragraph',
19
+ attributes: { content: 'Hello world.' },
20
+ innerHTML: '<p>Hello world.</p>',
21
+ storage_mode: 'static' as const,
22
+ };
23
+
24
+ export const headingBlock = {
25
+ index: 1,
26
+ top_level_counter: 1,
27
+ path: [1],
28
+ ref: 'blk_head0001',
29
+ name: 'core/heading',
30
+ attributes: { level: 2, content: 'Section Title' },
31
+ innerHTML: '<h2 class="wp-block-heading">Section Title</h2>',
32
+ storage_mode: 'static' as const,
33
+ };
34
+
35
+ export const imageBlock = {
36
+ index: 2,
37
+ top_level_counter: 2,
38
+ path: [2],
39
+ ref: 'blk_img00001',
40
+ name: 'core/image',
41
+ attributes: { url: 'https://example.test/wp-content/uploads/photo.jpg', alt: 'A photo' },
42
+ innerHTML: '<figure class="wp-block-image"><img src="https://example.test/wp-content/uploads/photo.jpg" alt="A photo"/></figure>',
43
+ storage_mode: 'static' as const,
44
+ };
45
+
46
+ export const listBlock = {
47
+ index: 3,
48
+ top_level_counter: 3,
49
+ path: [3],
50
+ ref: 'blk_list0001',
51
+ name: 'core/list',
52
+ attributes: { ordered: false },
53
+ innerHTML: '<ul><li>Item A</li><li>Item B</li></ul>',
54
+ storage_mode: 'static' as const,
55
+ };
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // Dynamic block (server-rendered)
59
+ // ---------------------------------------------------------------------------
60
+
61
+ export const queryLoopBlock = {
62
+ index: 4,
63
+ top_level_counter: 4,
64
+ path: [4],
65
+ ref: 'blk_qlop0001',
66
+ name: 'core/query',
67
+ attributes: { queryId: 1, query: { perPage: 10, postType: 'post' } },
68
+ innerHTML: '',
69
+ dynamic: true,
70
+ storage_mode: 'dynamic' as const,
71
+ };
72
+
73
+ // ---------------------------------------------------------------------------
74
+ // Legacy / non-preferred blocks (server attaches preference field)
75
+ // ---------------------------------------------------------------------------
76
+
77
+ export const legacyHeadingBlock = {
78
+ index: 5,
79
+ top_level_counter: 5,
80
+ path: [5],
81
+ name: 'ugb/heading',
82
+ attributes: { text: 'Old Heading' },
83
+ innerHTML: '<div class="ugb-heading"><h2>Old Heading</h2></div>',
84
+ preference: {
85
+ tier: 'legacy' as const,
86
+ suggested_replacement: 'core/heading',
87
+ },
88
+ };
89
+
90
+ export const avoidBlock = {
91
+ index: 6,
92
+ top_level_counter: 6,
93
+ path: [6],
94
+ name: 'stackable/heading',
95
+ attributes: { text: 'Stack Heading' },
96
+ preference: {
97
+ tier: 'avoid' as const,
98
+ suggested_replacement: 'core/heading',
99
+ },
100
+ };
101
+
102
+ // ---------------------------------------------------------------------------
103
+ // Container blocks (with innerBlocks)
104
+ // ---------------------------------------------------------------------------
105
+
106
+ export const groupBlock = {
107
+ index: 7,
108
+ top_level_counter: 7,
109
+ path: [7],
110
+ ref: 'blk_grp00001',
111
+ name: 'core/group',
112
+ attributes: { tagName: 'section' },
113
+ innerHTML: '<section class="wp-block-group"></section>',
114
+ storage_mode: 'static' as const,
115
+ innerBlocks: [
116
+ {
117
+ index: 8,
118
+ path: [7, 0],
119
+ ref: 'blk_grpch001',
120
+ name: 'core/heading',
121
+ attributes: { level: 3, content: 'Inside Group' },
122
+ innerHTML: '<h3>Inside Group</h3>',
123
+ },
124
+ {
125
+ index: 9,
126
+ path: [7, 1],
127
+ ref: 'blk_grpch002',
128
+ name: 'core/paragraph',
129
+ attributes: { content: 'Group paragraph.' },
130
+ innerHTML: '<p>Group paragraph.</p>',
131
+ },
132
+ ],
133
+ };
134
+
135
+ // ---------------------------------------------------------------------------
136
+ // Dual-storage block (yoast/faq-block example)
137
+ // ---------------------------------------------------------------------------
138
+
139
+ export const dualStorageBlock = {
140
+ index: 10,
141
+ top_level_counter: 8,
142
+ path: [10],
143
+ ref: 'blk_dual0001',
144
+ name: 'yoast/faq-block',
145
+ attributes: {
146
+ questions: [
147
+ { id: 'faq-q1', question: ['What is GravityKit?'], answer: ['A plugin suite.'] },
148
+ ],
149
+ },
150
+ innerHTML: '<div class="schema-faq"><div class="schema-faq-section"><strong>What is GravityKit?</strong><p>A plugin suite.</p></div></div>',
151
+ storage_mode: 'dual' as const,
152
+ };
153
+
154
+ // ---------------------------------------------------------------------------
155
+ // Flat page: a realistic mix for get_page_blocks responses
156
+ // ---------------------------------------------------------------------------
157
+
158
+ export const mixedPageBlocks = [
159
+ paragraphBlock,
160
+ headingBlock,
161
+ imageBlock,
162
+ groupBlock,
163
+ ];
164
+
165
+ export const legacyPageBlocks = [
166
+ paragraphBlock,
167
+ legacyHeadingBlock,
168
+ avoidBlock,
169
+ ];
170
+
171
+ // ---------------------------------------------------------------------------
172
+ // SavedBlock snapshots (echoed by write operations)
173
+ // ---------------------------------------------------------------------------
174
+
175
+ export const savedParagraph = {
176
+ flat_index: 0,
177
+ block_name: 'core/paragraph',
178
+ attributes: { content: 'Hello world.' },
179
+ inner_html: '<p>Hello world.</p>',
180
+ is_dynamic: false,
181
+ ref: 'blk_para0001',
182
+ };
183
+
184
+ export const savedHeading = {
185
+ flat_index: 1,
186
+ block_name: 'core/heading',
187
+ attributes: { level: 2, content: 'Section Title' },
188
+ inner_html: '<h2 class="wp-block-heading">Section Title</h2>',
189
+ is_dynamic: false,
190
+ ref: 'blk_head0001',
191
+ };
192
+
193
+ export const savedDynamic = {
194
+ flat_index: 4,
195
+ block_name: 'core/query',
196
+ attributes: { queryId: 1 },
197
+ inner_html: '',
198
+ is_dynamic: true,
199
+ };
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Error envelope fixtures for every documented error code.
3
+ *
4
+ * Each entry maps a WP REST error code to a realistic data payload the
5
+ * plugin might return in the `data` field of the error envelope.
6
+ *
7
+ * Shape: { code: string, status: number, data: Record<string,unknown> | null }
8
+ *
9
+ * Used primarily in the error-translator coverage matrix tests.
10
+ */
11
+
12
+ export interface ErrorEnvelope {
13
+ code: string;
14
+ /** HTTP status the plugin returns for this code */
15
+ httpStatus: number;
16
+ /** Content of the `data` property in the WP error envelope */
17
+ data: Record<string, unknown> | null;
18
+ }
19
+
20
+ export const ERROR_ENVELOPES: ErrorEnvelope[] = [
21
+ // ── Auth & permissions (403) ────────────────────────────────────────────
22
+ { code: 'rest_forbidden', httpStatus: 403, data: null },
23
+ { code: 'rest_cannot_edit', httpStatus: 403, data: { post_id: 42 } },
24
+ { code: 'rest_cannot_create', httpStatus: 403, data: null },
25
+ { code: 'rest_cannot_publish', httpStatus: 403, data: null },
26
+ { code: 'rest_cannot_upload', httpStatus: 403, data: null },
27
+ { code: 'rest_cannot_assign_author', httpStatus: 403, data: null },
28
+ { code: 'uploads_disabled', httpStatus: 403, data: null },
29
+
30
+ // ── Auth (401) ──────────────────────────────────────────────────────────
31
+ { code: 'rest_cookie_invalid_nonce', httpStatus: 401, data: null },
32
+ { code: 'rest_authentication_required', httpStatus: 401, data: null },
33
+
34
+ // ── Routing (404) ───────────────────────────────────────────────────────
35
+ { code: 'rest_no_route', httpStatus: 404, data: null },
36
+
37
+ // ── Not found (404) ────────────────────────────────────────────────────
38
+ { code: 'post_not_found', httpStatus: 404, data: { post_id: 42 } },
39
+ { code: 'block_not_found', httpStatus: 404, data: { post_id: 42 } },
40
+ { code: 'ref_stale', httpStatus: 404, data: { ref: 'blk_gone0001', post_id: 42 } },
41
+ { code: 'pattern_not_found', httpStatus: 404, data: null },
42
+ { code: 'revision_not_found', httpStatus: 404, data: null },
43
+ { code: 'not_found', httpStatus: 404, data: null },
44
+ { code: 'not_found_with_post', httpStatus: 404, data: { post_id: 77 } },
45
+
46
+ // ── Precondition / concurrency (412) ────────────────────────────────────
47
+ { code: 'stale_revision', httpStatus: 412, data: null },
48
+
49
+ // ── Validation (400) ────────────────────────────────────────────────────
50
+ { code: 'legacy_block', httpStatus: 400, data: { block: 'ugb/text', suggested_replacement: 'core/paragraph' } },
51
+ { code: 'dual_storage_requires_both', httpStatus: 400, data: { block_name: 'yoast/faq-block' } },
52
+ { code: 'bound_attribute', httpStatus: 400, data: null },
53
+ { code: 'batch_too_large', httpStatus: 400, data: null },
54
+ { code: 'batch_validation_failed', httpStatus: 400, data: { errors: [{ index: 0, code: 'ref_stale' }] } },
55
+ { code: 'empty_batch', httpStatus: 400, data: null },
56
+ { code: 'block_depth_exceeded', httpStatus: 400, data: null },
57
+ { code: 'invalid_path', httpStatus: 400, data: { path: [0, 99] } },
58
+ { code: 'invalid_ref', httpStatus: 400, data: null },
59
+ { code: 'ref_not_top_level', httpStatus: 400, data: { ref: 'blk_nested' } },
60
+ { code: 'invalid_op', httpStatus: 400, data: null },
61
+ { code: 'invalid_block', httpStatus: 400, data: null },
62
+ { code: 'missing_attributes', httpStatus: 400, data: null },
63
+ { code: 'invalid_post_type', httpStatus: 400, data: null },
64
+ { code: 'invalid_status', httpStatus: 400, data: null },
65
+ { code: 'mixed_trash_payload', httpStatus: 400, data: null },
66
+ { code: 'invalid_if_match', httpStatus: 400, data: null },
67
+ { code: 'no_inner_blocks', httpStatus: 400, data: null },
68
+ { code: 'multiple_inputs', httpStatus: 400, data: null },
69
+ { code: 'disallowed_mime', httpStatus: 400, data: null },
70
+ { code: 'file_too_large', httpStatus: 400, data: null },
71
+ { code: 'invalid_url', httpStatus: 400, data: null },
72
+ { code: 'empty_pattern', httpStatus: 400, data: null },
73
+
74
+ // ── Rate limit (429) ────────────────────────────────────────────────────
75
+ { code: 'rate_limit_exceeded', httpStatus: 429, data: { post_id: 42 } },
76
+ { code: 'scan_rate_limited', httpStatus: 429, data: null },
77
+
78
+ // ── Upstream (502) ──────────────────────────────────────────────────────
79
+ { code: 'url_fetch_failed', httpStatus: 502, data: null },
80
+
81
+ // ── Server error (500) ──────────────────────────────────────────────────
82
+ { code: 'internal_error', httpStatus: 500, data: null },
83
+ { code: 'wp_insert_post_failed', httpStatus: 500, data: null },
84
+ { code: 'sideload_failed', httpStatus: 500, data: null },
85
+ { code: 'trash_failed', httpStatus: 500, data: null },
86
+ ];
87
+
88
+ /**
89
+ * Subset: codes that translateWpError() has an explicit translation for.
90
+ * Keep in sync with the switch in src/error-translator.ts.
91
+ */
92
+ export const TRANSLATED_CODES = new Set([
93
+ 'rest_no_route',
94
+ 'rest_forbidden',
95
+ 'rest_cannot_edit',
96
+ 'rest_cannot_create',
97
+ 'rest_cookie_invalid_nonce',
98
+ 'rest_authentication_required',
99
+ 'rest_post_invalid_id',
100
+ 'invalid_post_id',
101
+ 'not_found',
102
+ 'gk_block_api_invalid_ref',
103
+ 'invalid_ref',
104
+ 'path_not_found',
105
+ 'invalid_path',
106
+ 'path_out_of_bounds',
107
+ 'legacy_block',
108
+ 'static_markup_stale_risk',
109
+ 'rate_limit_exceeded',
110
+ 'mixed_trash_payload',
111
+ 'invalid_post_type',
112
+ 'invalid_status',
113
+ 'invalid_url',
114
+ 'disallowed_mime',
115
+ ]);
@@ -0,0 +1,280 @@
1
+ /**
2
+ * Canonical REST response envelopes.
3
+ *
4
+ * Plain data — shapes match the TypeScript interfaces in src/types.ts.
5
+ * Use these as mock return values in tool-layer and client-layer tests.
6
+ */
7
+
8
+ import {
9
+ savedParagraph,
10
+ savedHeading,
11
+ mixedPageBlocks,
12
+ } from './block-trees.js';
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Discovery responses
16
+ // ---------------------------------------------------------------------------
17
+
18
+ export const blockTypesResponse = {
19
+ block_types: [
20
+ {
21
+ name: 'core/paragraph',
22
+ title: 'Paragraph',
23
+ category: 'text',
24
+ description: 'Start with the basic building block.',
25
+ preference: { score: 90, tier: 'preferred' as const },
26
+ storage_mode: 'static' as const,
27
+ },
28
+ {
29
+ name: 'core/heading',
30
+ title: 'Heading',
31
+ category: 'text',
32
+ preference: { score: 90, tier: 'preferred' as const },
33
+ storage_mode: 'static' as const,
34
+ },
35
+ {
36
+ name: 'stackable/heading',
37
+ title: 'Stackable Heading',
38
+ category: 'text',
39
+ preference: { score: 10, tier: 'avoid' as const, namespace_policy: 'migrate_away' },
40
+ },
41
+ {
42
+ name: 'ugb/text',
43
+ title: 'UGB Text',
44
+ category: 'text',
45
+ preference: { score: 0, tier: 'legacy' as const, namespace_policy: 'never_use' },
46
+ },
47
+ ],
48
+ };
49
+
50
+ export const patternsResponse = {
51
+ patterns: [
52
+ {
53
+ id: 1,
54
+ name: 'Hero Section',
55
+ type: 'synced' as const,
56
+ created: '2026-01-01',
57
+ modified: '2026-04-15',
58
+ reference_count: 12,
59
+ preference: { score: 85, tier: 'recommended' as const, reasons: ['recent', 'widely-used'] },
60
+ contains_blocks: ['core/heading', 'core/paragraph', 'core/image'],
61
+ has_legacy_blocks: false,
62
+ },
63
+ {
64
+ id: 2,
65
+ name: 'Old Legacy Pattern',
66
+ type: 'synced' as const,
67
+ created: '2023-01-01',
68
+ modified: '2023-06-01',
69
+ reference_count: 2,
70
+ preference: { score: -80, tier: 'legacy' as const, reasons: ['has_legacy_blocks'] },
71
+ contains_blocks: ['ugb/text', 'ugb/heading'],
72
+ has_legacy_blocks: true,
73
+ legacy_blocks: ['ugb/text', 'ugb/heading'],
74
+ },
75
+ ],
76
+ };
77
+
78
+ export const siteUsageResponse = {
79
+ block_usage: {
80
+ 'core/paragraph': { count: 450, post_count: 120 },
81
+ 'core/heading': { count: 200, post_count: 95 },
82
+ 'stackable/heading': { count: 30, post_count: 15 },
83
+ },
84
+ namespace_totals: { core: 650, stackable: 30 },
85
+ pattern_references: {
86
+ '1': { name: 'Hero Section', refs: 12 },
87
+ },
88
+ legacy_patterns: [],
89
+ };
90
+
91
+ export const resolveUrlResponse = {
92
+ post_id: 532208,
93
+ post_type: 'download',
94
+ title: 'GravityEdit',
95
+ status: 'publish',
96
+ slug: 'gravityedit',
97
+ edit_url: 'https://example.test/wp-admin/post.php?post=532208&action=edit',
98
+ };
99
+
100
+ // ---------------------------------------------------------------------------
101
+ // Read responses
102
+ // ---------------------------------------------------------------------------
103
+
104
+ export const pageBlocksResponse = {
105
+ blocks: mixedPageBlocks,
106
+ summary: {
107
+ total_blocks: 4,
108
+ top_level_blocks: 4,
109
+ block_types: { 'core/paragraph': 1, 'core/heading': 1, 'core/image': 1, 'core/group': 1 },
110
+ sections: [],
111
+ headings: [{ path: [1], level: 2, text: 'Section Title' }],
112
+ legacy_blocks: [],
113
+ max_path_depth: 2,
114
+ },
115
+ };
116
+
117
+ export const getBlockResponse = {
118
+ success: true,
119
+ saved: savedParagraph,
120
+ };
121
+
122
+ // ---------------------------------------------------------------------------
123
+ // Write responses
124
+ // ---------------------------------------------------------------------------
125
+
126
+ export const updateBlockResponse = {
127
+ success: true,
128
+ block: {
129
+ index: 1,
130
+ name: 'core/heading',
131
+ attributes: { level: 3, content: 'Updated' },
132
+ ref: 'blk_head0001',
133
+ },
134
+ saved: savedHeading,
135
+ before_revision_id: 100,
136
+ revision_id: 101,
137
+ };
138
+
139
+ export const insertBlocksResponse = {
140
+ success: true,
141
+ inserted: [
142
+ { index: 5, top_level_counter: 5, path: [5], ref: 'blk_new00001', name: 'core/paragraph' },
143
+ ],
144
+ warnings: [],
145
+ before_revision_id: 100,
146
+ revision_id: 101,
147
+ };
148
+
149
+ export const insertBlocksWithWarningsResponse = {
150
+ success: true,
151
+ inserted: [{ index: 5, name: 'stackable/heading' }],
152
+ warnings: [
153
+ { block: 'stackable/heading', message: 'Block 5: stackable/heading (AVOID)', suggested_replacement: 'core/heading' },
154
+ ],
155
+ before_revision_id: 100,
156
+ revision_id: 101,
157
+ };
158
+
159
+ export const deleteBlockResponse = {
160
+ success: true,
161
+ removed: 1,
162
+ before_revision_id: 100,
163
+ revision_id: 101,
164
+ };
165
+
166
+ export const batchUpdateResponse = {
167
+ success: true,
168
+ count: 2,
169
+ results: [
170
+ {
171
+ batch_index: 0,
172
+ block: { index: 0, name: 'core/paragraph', attributes: { content: 'Updated 1' }, ref: 'blk_para0001' },
173
+ },
174
+ {
175
+ batch_index: 1,
176
+ block: { index: 1, name: 'core/heading', attributes: { level: 3 }, ref: 'blk_head0001' },
177
+ },
178
+ ],
179
+ before_revision_id: 100,
180
+ revision_id: 101,
181
+ };
182
+
183
+ export const replaceRangeResponse = {
184
+ success: true,
185
+ removed: 2,
186
+ inserted: [
187
+ { index: 1, name: 'core/paragraph', ref: 'blk_rpl00001' },
188
+ ],
189
+ warnings: [],
190
+ before_revision_id: 100,
191
+ revision_id: 101,
192
+ };
193
+
194
+ // ---------------------------------------------------------------------------
195
+ // Mutation responses
196
+ // ---------------------------------------------------------------------------
197
+
198
+ export const mutationUpdateAttrsResponse = {
199
+ success: true,
200
+ op: 'update-attrs' as const,
201
+ path: [1],
202
+ block: {
203
+ name: 'core/heading',
204
+ attributes: { level: 3 },
205
+ },
206
+ warnings: [],
207
+ before_revision_id: 100,
208
+ revision_id: 101,
209
+ };
210
+
211
+ export const mutationWithStaticWarning = {
212
+ success: true,
213
+ op: 'update-attrs' as const,
214
+ path: [0],
215
+ block: { name: 'core/paragraph', attributes: { content: 'New' } },
216
+ warnings: [
217
+ {
218
+ type: 'static_markup_stale_risk' as const,
219
+ block_name: 'core/paragraph',
220
+ changed_attrs: ['content'],
221
+ message: 'Updating content on a static block without new innerHTML may leave markup stale.',
222
+ },
223
+ ],
224
+ before_revision_id: 100,
225
+ revision_id: 101,
226
+ };
227
+
228
+ // ---------------------------------------------------------------------------
229
+ // Pattern responses
230
+ // ---------------------------------------------------------------------------
231
+
232
+ export const patternInsertResponse = {
233
+ success: true,
234
+ inserted: { index: 5, name: 'core/block', attributes: { ref: 1 }, synced: true },
235
+ pattern_name: 'Hero Section',
236
+ synced: true,
237
+ before_revision_id: 100,
238
+ revision_id: 101,
239
+ };
240
+
241
+ // ---------------------------------------------------------------------------
242
+ // Post lifecycle responses
243
+ // ---------------------------------------------------------------------------
244
+
245
+ export const createPostResponse = {
246
+ success: true,
247
+ id: 9999,
248
+ post_type: 'post',
249
+ status: 'draft',
250
+ title: 'New Post',
251
+ slug: 'new-post',
252
+ permalink: 'https://example.test/new-post/',
253
+ edit_link: 'https://example.test/wp-admin/post.php?post=9999&action=edit',
254
+ before_revision_id: null,
255
+ revision_id: null,
256
+ warnings: [],
257
+ };
258
+
259
+ export const updatePostResponse = {
260
+ ...createPostResponse,
261
+ status: 'publish',
262
+ transitioned_to_publish: true,
263
+ };
264
+
265
+ // ---------------------------------------------------------------------------
266
+ // Yoast responses
267
+ // ---------------------------------------------------------------------------
268
+
269
+ export const yoastSEOResponse = {
270
+ post_id: 9999,
271
+ title: 'SEO Title',
272
+ description: 'Meta description.',
273
+ canonical: '',
274
+ focus_keyword: 'gravitykit',
275
+ noindex: null,
276
+ nofollow: false,
277
+ seo_score: 78,
278
+ readability_score: 65,
279
+ inclusive_language_score: null,
280
+ };