@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
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-site `serverInfo.instructions` assembly (BLOCK-19).
|
|
3
|
+
*
|
|
4
|
+
* The MCP server ships a hard-coded baseline string in `BASELINE`. At
|
|
5
|
+
* startup, the server fetches an admin-editable addendum from the
|
|
6
|
+
* connected WordPress site via `GET /gk-block-api/v1/instructions` and
|
|
7
|
+
* appends it to the baseline. The combined string is passed to the
|
|
8
|
+
* `McpServer` constructor's `instructions` field, where the MCP SDK
|
|
9
|
+
* surfaces it to clients during the initialize handshake.
|
|
10
|
+
*
|
|
11
|
+
* Two opt-out paths:
|
|
12
|
+
*
|
|
13
|
+
* - The fetch wraps every step in try/catch and falls back to baseline-
|
|
14
|
+
* only on any failure (DNS, timeout, 404, 5xx, malformed JSON). The
|
|
15
|
+
* server never blocks startup on the fetch.
|
|
16
|
+
* - The `BLOCK_MCP_INSTRUCTIONS_OFF=1` env var skips the fetch entirely
|
|
17
|
+
* — useful for offline testing and isolation.
|
|
18
|
+
*
|
|
19
|
+
* Threat model: the remote addendum comes from the WP options table and
|
|
20
|
+
* is sanitized server-side, but a compromised WP install could still
|
|
21
|
+
* push malicious instructions. This module performs defense-in-depth
|
|
22
|
+
* sanitization (length cap, control-char strip, suspicious-pattern
|
|
23
|
+
* filter) before passing the value to the SDK. The primary control
|
|
24
|
+
* remains "don't connect Block MCP to a WP site you don't trust."
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import axios, { AxiosError } from 'axios';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The baseline instructions string.
|
|
31
|
+
*
|
|
32
|
+
* This is the canonical, version-controlled guidance every MCP client
|
|
33
|
+
* receives, regardless of which WordPress site the server connects to.
|
|
34
|
+
* Site-specific rules go in the WP option served by `/instructions`.
|
|
35
|
+
*
|
|
36
|
+
* Kept verbatim in sync with the inline literal that previously lived
|
|
37
|
+
* at `src/index.ts:102-107` so existing clients see no change in
|
|
38
|
+
* behaviour when no addendum is set.
|
|
39
|
+
*/
|
|
40
|
+
export const BASELINE = `Block-level WordPress CRUD. URL → post_id is resolved server-side — pass URLs directly to get_page_blocks / resolve_url; never shell out to curl or wp-json.
|
|
41
|
+
|
|
42
|
+
After a write, the response already includes the canonical post-save snapshot (\`saved.inner_html\` + \`saved.attributes\` on update_block; \`saved\` per result on update_blocks with \`verbose:true\`). Use that for verification — do not fetch the public page to confirm edits. If you need a single-block re-read later, call get_block(ref) — same shape, no extra plumbing.
|
|
43
|
+
|
|
44
|
+
Tier policy is per-site config, surfaced inline (block.preference) and via list_block_types. Read block-mcp://agent-guide for the editing workflow.`;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Server-side cap on the addendum length. Mirrors `Instructions::MAX_LENGTH`
|
|
48
|
+
* in the PHP plugin. Truncation is silent — `McpServer` doesn't care, and
|
|
49
|
+
* surfacing an error would block startup over a value that's already on
|
|
50
|
+
* the wire.
|
|
51
|
+
*/
|
|
52
|
+
export const MAX_ADDENDUM_LENGTH = 2000;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Timeout for the addendum fetch. Short, because we don't want startup
|
|
56
|
+
* to feel laggy when the site is offline — falling back to baseline-only
|
|
57
|
+
* is preferable to hanging.
|
|
58
|
+
*/
|
|
59
|
+
const FETCH_TIMEOUT_MS = 3000;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Hard cap on the response body axios accepts from `/instructions`.
|
|
63
|
+
*
|
|
64
|
+
* The expected payload is `{ addendum, length, max_length, updated_at }`
|
|
65
|
+
* where `addendum` is at most `MAX_ADDENDUM_LENGTH` characters. With
|
|
66
|
+
* 4-byte UTF-8 codepoints and the JSON envelope, the realistic ceiling
|
|
67
|
+
* is well under 10 KB. Capping at 16 KB gives generous headroom while
|
|
68
|
+
* making a compromised or malicious WP site unable to push an unbounded
|
|
69
|
+
* stream of bytes at us (slowloris-style DoS, memory pressure).
|
|
70
|
+
*
|
|
71
|
+
* The HTTP-layer cap is the primary defense; `sanitizeAddendum` is the
|
|
72
|
+
* secondary defense. Both stay in place.
|
|
73
|
+
*/
|
|
74
|
+
const FETCH_MAX_BYTES = 16 * 1024;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Env var that disables the fetch entirely. Useful for offline tests,
|
|
78
|
+
* isolation, or when running against an internal site whose admin you
|
|
79
|
+
* don't trust to manage the addendum.
|
|
80
|
+
*/
|
|
81
|
+
const OFF_ENV_VAR = 'BLOCK_MCP_INSTRUCTIONS_OFF';
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Response envelope from `GET /gk-block-api/v1/instructions`.
|
|
85
|
+
*
|
|
86
|
+
* `length` and `max_length` are advisory — the TypeScript side re-checks
|
|
87
|
+
* the actual addendum string and enforces its own truncation.
|
|
88
|
+
*/
|
|
89
|
+
export interface InstructionsResponse {
|
|
90
|
+
addendum: string;
|
|
91
|
+
length?: number;
|
|
92
|
+
max_length?: number;
|
|
93
|
+
updated_at?: number;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Sanitize a remote addendum before handing it to the MCP SDK.
|
|
98
|
+
*
|
|
99
|
+
* Defense in depth — the PHP side already does most of this, but we
|
|
100
|
+
* cannot assume the WordPress install has the plugin version that
|
|
101
|
+
* sanitizes on the read path. Steps:
|
|
102
|
+
*
|
|
103
|
+
* 1. Cast to string; non-strings (objects, arrays, null) return empty.
|
|
104
|
+
* 2. Strip ASCII C0 control characters except `\t` (tab), `\n` (LF),
|
|
105
|
+
* `\r` (CR) — same set the PHP side keeps.
|
|
106
|
+
* 3. Strip the DEL character (`\x7F`).
|
|
107
|
+
* 4. Strip the unicode Bidi override and zero-width characters that
|
|
108
|
+
* have been used in prompt-injection PoCs to hide text from human
|
|
109
|
+
* review while still being visible to the LLM.
|
|
110
|
+
* 5. Normalize CRLF/CR to LF.
|
|
111
|
+
* 6. Trim outer whitespace.
|
|
112
|
+
* 7. Truncate to `MAX_ADDENDUM_LENGTH` characters.
|
|
113
|
+
*/
|
|
114
|
+
export function sanitizeAddendum(input: unknown): string {
|
|
115
|
+
if (typeof input !== 'string') {
|
|
116
|
+
return '';
|
|
117
|
+
}
|
|
118
|
+
let s = input;
|
|
119
|
+
|
|
120
|
+
// ASCII C0/C1 control chars except tab/LF/CR + DEL.
|
|
121
|
+
s = s.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '');
|
|
122
|
+
|
|
123
|
+
// Unicode Bidi overrides + zero-width chars. These render invisibly in
|
|
124
|
+
// some clients but still reach the LLM tokenizer, making them a
|
|
125
|
+
// prompt-injection vector. Stripped via explicit escape sequences so
|
|
126
|
+
// the source is readable and the set is unambiguous.
|
|
127
|
+
//
|
|
128
|
+
// U+200B ZERO WIDTH SPACE
|
|
129
|
+
// U+200C ZERO WIDTH NON-JOINER
|
|
130
|
+
// U+200D ZERO WIDTH JOINER
|
|
131
|
+
// U+2060 WORD JOINER
|
|
132
|
+
// U+FEFF ZERO WIDTH NO-BREAK SPACE / BOM
|
|
133
|
+
// U+202A..U+202E LRE/RLE/PDF/LRO/RLO Bidi overrides
|
|
134
|
+
// U+2066..U+2069 LRI/RLI/FSI/PDI Bidi isolates
|
|
135
|
+
s = s.replace(/[\u200B-\u200D\u2060\uFEFF\u202A-\u202E\u2066-\u2069]/g, '');
|
|
136
|
+
|
|
137
|
+
// CRLF / CR → LF.
|
|
138
|
+
s = s.replace(/\r\n?/g, '\n');
|
|
139
|
+
|
|
140
|
+
s = s.trim();
|
|
141
|
+
|
|
142
|
+
// Count and slice by Unicode code points, NOT UTF-16 code units, so an
|
|
143
|
+
// astral character (emoji, rare CJK, math symbol) is never split mid
|
|
144
|
+
// surrogate pair — which would leave the tail as an unpaired surrogate
|
|
145
|
+
// that downstream JSON serializers either reject or mangle. Matches
|
|
146
|
+
// the server's `mb_strlen($s, 'UTF-8')` semantics.
|
|
147
|
+
const codePoints = Array.from(s);
|
|
148
|
+
if (codePoints.length > MAX_ADDENDUM_LENGTH) {
|
|
149
|
+
s = codePoints.slice(0, MAX_ADDENDUM_LENGTH).join('');
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return s;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Combine the baseline with an optional addendum into the final string
|
|
157
|
+
* passed to `McpServer`. When the addendum is empty, returns the
|
|
158
|
+
* baseline unchanged — clients that don't customize see no marker
|
|
159
|
+
* polluting the handshake.
|
|
160
|
+
*
|
|
161
|
+
* Two newlines between baseline and addendum so markdown renderers in
|
|
162
|
+
* MCP clients see them as separate paragraphs / sections.
|
|
163
|
+
*/
|
|
164
|
+
export function combineInstructions(baseline: string, addendum: string): string {
|
|
165
|
+
const clean = addendum.trim();
|
|
166
|
+
if (clean.length === 0) {
|
|
167
|
+
return baseline;
|
|
168
|
+
}
|
|
169
|
+
return `${baseline}\n\n${clean}`;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Fetch the addendum from the WordPress site's `/instructions` endpoint.
|
|
174
|
+
*
|
|
175
|
+
* Public, unauthenticated request (the endpoint is public-by-design).
|
|
176
|
+
* On any failure — network error, non-200 response, malformed JSON,
|
|
177
|
+
* missing addendum field — returns an empty string. Logs the cause to
|
|
178
|
+
* stderr so admins can debug, but never throws.
|
|
179
|
+
*
|
|
180
|
+
* Honors `BLOCK_MCP_INSTRUCTIONS_OFF=1` by returning empty immediately
|
|
181
|
+
* (no HTTP call).
|
|
182
|
+
*
|
|
183
|
+
* @param wordpressUrl Base URL of the WordPress site (no trailing slash
|
|
184
|
+
* enforced — the function normalizes both forms).
|
|
185
|
+
*/
|
|
186
|
+
export async function fetchAddendum(wordpressUrl: string): Promise<string> {
|
|
187
|
+
if (process.env[OFF_ENV_VAR] === '1') {
|
|
188
|
+
return '';
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Normalize trailing slash and resolve relative to wp-json.
|
|
192
|
+
const base = wordpressUrl.replace(/\/+$/, '');
|
|
193
|
+
const url = `${base}/wp-json/gk-block-api/v1/instructions`;
|
|
194
|
+
|
|
195
|
+
try {
|
|
196
|
+
const response = await axios.get<InstructionsResponse>(url, {
|
|
197
|
+
timeout: FETCH_TIMEOUT_MS,
|
|
198
|
+
// Disable axios's automatic redirect following entirely. Without
|
|
199
|
+
// this, a compromised or misconfigured WP site could redirect us
|
|
200
|
+
// to a different origin (an exfil endpoint, an SSRF target on the
|
|
201
|
+
// internal network, etc.). Admins should configure
|
|
202
|
+
// WORDPRESS_URL with the canonical scheme + host so the first
|
|
203
|
+
// hop returns 200 directly. If the site needs an HTTP → HTTPS
|
|
204
|
+
// redirect, fix the env var instead of relying on axios to
|
|
205
|
+
// follow it for us.
|
|
206
|
+
//
|
|
207
|
+
// The corollary is a 3xx response now surfaces as an axios
|
|
208
|
+
// error and we fall back to baseline-only. Logged to stderr.
|
|
209
|
+
maxRedirects: 0,
|
|
210
|
+
// Hard cap the response body size. Primary defense against an
|
|
211
|
+
// unbounded payload — `sanitizeAddendum` still truncates after,
|
|
212
|
+
// but we never want raw bytes past this cap to hit our process.
|
|
213
|
+
maxContentLength: FETCH_MAX_BYTES,
|
|
214
|
+
// Lower-case Accept so caches see a stable Vary key.
|
|
215
|
+
headers: {
|
|
216
|
+
Accept: 'application/json',
|
|
217
|
+
},
|
|
218
|
+
// We treat 2xx as success; 3xx (any redirect) and 4xx/5xx fall
|
|
219
|
+
// back to empty via the catch block.
|
|
220
|
+
validateStatus: (status) => status >= 200 && status < 300,
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
if (!response.data || typeof response.data !== 'object') {
|
|
224
|
+
console.error(
|
|
225
|
+
`[block-mcp] /instructions returned non-object payload; using baseline only.`
|
|
226
|
+
);
|
|
227
|
+
return '';
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return sanitizeAddendum(response.data.addendum);
|
|
231
|
+
} catch (err) {
|
|
232
|
+
// Don't crash startup — empty addendum, log to stderr, continue.
|
|
233
|
+
const message = formatFetchError(err);
|
|
234
|
+
console.error(`[block-mcp] Failed to fetch /instructions (${message}); using baseline only.`);
|
|
235
|
+
return '';
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Convenience helper: fetch + combine in one call. The default entry
|
|
241
|
+
* point used by `src/index.ts`.
|
|
242
|
+
*/
|
|
243
|
+
export async function getInstructions(wordpressUrl: string): Promise<string> {
|
|
244
|
+
const addendum = await fetchAddendum(wordpressUrl);
|
|
245
|
+
return combineInstructions(BASELINE, addendum);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Map axios / network errors into a short stderr message. Kept private
|
|
250
|
+
* because callers shouldn't depend on the exact wording.
|
|
251
|
+
*/
|
|
252
|
+
function formatFetchError(err: unknown): string {
|
|
253
|
+
if (axios.isAxiosError(err)) {
|
|
254
|
+
const axiosErr = err as AxiosError;
|
|
255
|
+
if (axiosErr.code === 'ECONNABORTED' || axiosErr.message.includes('timeout')) {
|
|
256
|
+
return `timeout after ${FETCH_TIMEOUT_MS}ms`;
|
|
257
|
+
}
|
|
258
|
+
if (axiosErr.response) {
|
|
259
|
+
return `HTTP ${axiosErr.response.status}`;
|
|
260
|
+
}
|
|
261
|
+
if (axiosErr.code) {
|
|
262
|
+
return axiosErr.code;
|
|
263
|
+
}
|
|
264
|
+
return axiosErr.message;
|
|
265
|
+
}
|
|
266
|
+
if (err instanceof Error) {
|
|
267
|
+
return err.message;
|
|
268
|
+
}
|
|
269
|
+
return String(err);
|
|
270
|
+
}
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side Preference Enrichment
|
|
3
|
+
*
|
|
4
|
+
* Functions that add natural-language annotations and AI-friendly context
|
|
5
|
+
* to raw API responses. The WordPress plugin (gk-block-api) is the single
|
|
6
|
+
* source of truth for which namespaces are legacy, avoid, etc. — driven by
|
|
7
|
+
* admin-editable preferences (`wp_options.gk_block_api_preferences`) and
|
|
8
|
+
* extensible via the WordPress filter system.
|
|
9
|
+
*
|
|
10
|
+
* This module deliberately holds NO hardcoded namespace lists or replacement
|
|
11
|
+
* maps. It reads `block.preference.tier` and `block.preference.suggested_replacement`
|
|
12
|
+
* fields the server attaches to non-preferred blocks. Sites that want
|
|
13
|
+
* different policies just edit their Preferences config — no code change.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type {
|
|
17
|
+
BlockType,
|
|
18
|
+
Pattern,
|
|
19
|
+
Block,
|
|
20
|
+
PreferenceWarning,
|
|
21
|
+
} from './types.js';
|
|
22
|
+
|
|
23
|
+
// ============================================
|
|
24
|
+
// Helpers
|
|
25
|
+
// ============================================
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Extract the namespace from a fully-qualified block name.
|
|
29
|
+
*
|
|
30
|
+
* @param blockName - e.g. "filter/testimonial-wall"
|
|
31
|
+
* @returns Namespace string, e.g. "filter"
|
|
32
|
+
*/
|
|
33
|
+
function getNamespace(blockName: string): string {
|
|
34
|
+
return blockName.split('/')[0] ?? blockName;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Extract the short name (after the slash) from a fully-qualified block name.
|
|
39
|
+
*
|
|
40
|
+
* @param blockName - e.g. "filter/testimonial-wall"
|
|
41
|
+
* @returns Short name string, e.g. "testimonial-wall", or the full name if no slash.
|
|
42
|
+
*/
|
|
43
|
+
function getShortName(blockName: string): string {
|
|
44
|
+
const parts = blockName.split('/');
|
|
45
|
+
return parts[1] ?? parts[0];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ============================================
|
|
49
|
+
// Enrichment Functions
|
|
50
|
+
// ============================================
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Annotate a list of page blocks with preference warnings.
|
|
54
|
+
*
|
|
55
|
+
* Reads the `block.preference.tier` field the server attaches to non-preferred
|
|
56
|
+
* blocks. No client-side namespace list — whatever the server flags as legacy
|
|
57
|
+
* gets a warning here.
|
|
58
|
+
*
|
|
59
|
+
* @param blocks - Parsed blocks from a page
|
|
60
|
+
* @returns Object with blocks, warnings array, and a summary string
|
|
61
|
+
*/
|
|
62
|
+
export function enrichBlockList(blocks: Block[]): {
|
|
63
|
+
blocks: Block[];
|
|
64
|
+
warnings: PreferenceWarning[];
|
|
65
|
+
summary: string;
|
|
66
|
+
} {
|
|
67
|
+
const warnings: PreferenceWarning[] = [];
|
|
68
|
+
|
|
69
|
+
// Walk the full tree so legacy/avoid blocks nested inside core/group,
|
|
70
|
+
// core/columns, etc. surface in the warning summary too. The previous
|
|
71
|
+
// single-level loop missed them, letting deprecated namespaces hide
|
|
72
|
+
// a level deep.
|
|
73
|
+
walkBlocksForPreferences(blocks, warnings);
|
|
74
|
+
|
|
75
|
+
const summary = warnings.length > 0
|
|
76
|
+
? `Found ${warnings.length} non-preferred block(s) on this page:\n${warnings.map((w) => ` - ${w.message}`).join('\n')}`
|
|
77
|
+
: 'All blocks on this page use preferred or acceptable namespaces.';
|
|
78
|
+
|
|
79
|
+
return { blocks, warnings, summary };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function walkBlocksForPreferences(blocks: Block[], warnings: PreferenceWarning[]): void {
|
|
83
|
+
for (const block of blocks) {
|
|
84
|
+
const tier = block.preference?.tier;
|
|
85
|
+
if (tier === 'legacy' || tier === 'avoid') {
|
|
86
|
+
const replacement = block.preference?.suggested_replacement;
|
|
87
|
+
const verb = tier === 'legacy' ? 'LEGACY — do not use' : 'AVOID';
|
|
88
|
+
warnings.push({
|
|
89
|
+
block: block.name,
|
|
90
|
+
message: replacement
|
|
91
|
+
? `Block ${block.index}: ${block.name} (${verb} — use ${replacement} instead)`
|
|
92
|
+
: `Block ${block.index}: ${block.name} (${verb})`,
|
|
93
|
+
suggested_replacement: replacement,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
if (block.innerBlocks?.length) {
|
|
97
|
+
walkBlocksForPreferences(block.innerBlocks, warnings);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Sort patterns by preference score and add a recommendation summary.
|
|
104
|
+
*
|
|
105
|
+
* Returns patterns ordered best-first with a natural-language summary
|
|
106
|
+
* grouping them into recommended, acceptable, and avoid tiers.
|
|
107
|
+
*
|
|
108
|
+
* @param patterns - Patterns from the API
|
|
109
|
+
* @returns Sorted patterns with a summary string
|
|
110
|
+
*/
|
|
111
|
+
export function enrichPatternList(patterns: Pattern[]): {
|
|
112
|
+
patterns: Pattern[];
|
|
113
|
+
summary: string;
|
|
114
|
+
} {
|
|
115
|
+
// Sort descending by score for stable display ordering.
|
|
116
|
+
const sorted = [...patterns].sort(
|
|
117
|
+
(a, b) => b.preference.score - a.preference.score
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
// Classify by tier only — the server is the source of truth and already
|
|
121
|
+
// applied policy. Mixing in score-based fallbacks (the old logic) caused
|
|
122
|
+
// mis-bucketing: an `avoid`-tier pattern with score 5 leaked into the
|
|
123
|
+
// recommended bucket; a `recommended`-tier pattern with negative score
|
|
124
|
+
// was double-counted. Trust the tier.
|
|
125
|
+
const recommended = sorted.filter((p) => p.preference.tier === 'recommended');
|
|
126
|
+
const avoid = sorted.filter((p) => p.preference.tier === 'avoid' || p.preference.tier === 'legacy');
|
|
127
|
+
|
|
128
|
+
const lines: string[] = [];
|
|
129
|
+
|
|
130
|
+
if (recommended.length > 0) {
|
|
131
|
+
lines.push('RECOMMENDED patterns:');
|
|
132
|
+
for (const p of recommended) {
|
|
133
|
+
const blockInfo = p.contains_blocks.length > 0
|
|
134
|
+
? `, uses ${p.contains_blocks.slice(0, 3).map(getNamespace).filter((v, i, a) => a.indexOf(v) === i).join('/')} blocks`
|
|
135
|
+
: '';
|
|
136
|
+
lines.push(
|
|
137
|
+
` Recommended: "${p.name}" (score: ${p.preference.score}, ${p.type}${blockInfo})`
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (avoid.length > 0) {
|
|
143
|
+
lines.push('AVOID patterns (contain non-preferred blocks):');
|
|
144
|
+
for (const p of avoid) {
|
|
145
|
+
const legacyInfo = p.legacy_blocks && p.legacy_blocks.length > 0
|
|
146
|
+
? `, contains ${p.legacy_blocks.slice(0, 3).join(', ')}`
|
|
147
|
+
: '';
|
|
148
|
+
lines.push(
|
|
149
|
+
` Avoid: "${p.name}" (score: ${p.preference.score}${legacyInfo})`
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const summary = lines.length > 0
|
|
155
|
+
? lines.join('\n')
|
|
156
|
+
: 'No patterns found matching the criteria.';
|
|
157
|
+
|
|
158
|
+
return { patterns: sorted, summary };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Group block types by preference tier with natural-language guidance.
|
|
163
|
+
*
|
|
164
|
+
* Tier classification comes from the server (which reads from the
|
|
165
|
+
* Preferences config). No hardcoded namespace tables here.
|
|
166
|
+
*
|
|
167
|
+
* @param types - Block types from the API
|
|
168
|
+
* @returns Grouped types with a guidance summary string
|
|
169
|
+
*/
|
|
170
|
+
export function enrichBlockTypes(types: BlockType[]): {
|
|
171
|
+
block_types: BlockType[];
|
|
172
|
+
guidance: string;
|
|
173
|
+
} {
|
|
174
|
+
const preferred: BlockType[] = [];
|
|
175
|
+
const acceptable: BlockType[] = [];
|
|
176
|
+
const avoid: BlockType[] = [];
|
|
177
|
+
const legacy: BlockType[] = [];
|
|
178
|
+
|
|
179
|
+
for (const t of types) {
|
|
180
|
+
switch (t.preference.tier) {
|
|
181
|
+
case 'preferred':
|
|
182
|
+
preferred.push(t);
|
|
183
|
+
break;
|
|
184
|
+
case 'acceptable':
|
|
185
|
+
acceptable.push(t);
|
|
186
|
+
break;
|
|
187
|
+
case 'avoid':
|
|
188
|
+
avoid.push(t);
|
|
189
|
+
break;
|
|
190
|
+
case 'legacy':
|
|
191
|
+
legacy.push(t);
|
|
192
|
+
break;
|
|
193
|
+
default:
|
|
194
|
+
acceptable.push(t);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const lines: string[] = [];
|
|
199
|
+
|
|
200
|
+
if (preferred.length > 0) {
|
|
201
|
+
const grouped = groupByNamespace(preferred);
|
|
202
|
+
for (const [ns, blocks] of Object.entries(grouped)) {
|
|
203
|
+
const names = blocks.map((t) => getShortName(t.name)).join(', ');
|
|
204
|
+
lines.push(`PREFERRED (${ns}/): ${names}`);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
if (acceptable.length > 0) {
|
|
208
|
+
const grouped = groupByNamespace(acceptable);
|
|
209
|
+
for (const [ns, blocks] of Object.entries(grouped)) {
|
|
210
|
+
const names = blocks.map((t) => getShortName(t.name)).join(', ');
|
|
211
|
+
lines.push(`ACCEPTABLE (${ns}/): ${names}`);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
if (avoid.length > 0) {
|
|
215
|
+
const grouped = groupByNamespace(avoid);
|
|
216
|
+
for (const [ns, blocks] of Object.entries(grouped)) {
|
|
217
|
+
const mappings = blocks.map((t) => {
|
|
218
|
+
const replacement = t.preference.replacement;
|
|
219
|
+
const shortName = getShortName(t.name);
|
|
220
|
+
return replacement ? `${shortName} -> use ${replacement}` : shortName;
|
|
221
|
+
});
|
|
222
|
+
lines.push(`AVOID (${ns}/): ${mappings.join(', ')}`);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
if (legacy.length > 0) {
|
|
226
|
+
const grouped = groupByNamespace(legacy);
|
|
227
|
+
for (const [ns, blocks] of Object.entries(grouped)) {
|
|
228
|
+
const mappings = blocks.map((t) => {
|
|
229
|
+
const replacement = t.preference.replacement;
|
|
230
|
+
const shortName = getShortName(t.name);
|
|
231
|
+
return replacement ? `${shortName} -> use ${replacement}` : shortName;
|
|
232
|
+
});
|
|
233
|
+
lines.push(`LEGACY — DO NOT USE (${ns}/): ${mappings.join(', ')}`);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const guidance = lines.join('\n');
|
|
238
|
+
|
|
239
|
+
return { block_types: types, guidance };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Format a preference warning into a single human-readable line.
|
|
244
|
+
*
|
|
245
|
+
* @param warning - The preference warning to format
|
|
246
|
+
* @returns Formatted warning string
|
|
247
|
+
*/
|
|
248
|
+
export function formatPreferenceWarning(warning: PreferenceWarning): string {
|
|
249
|
+
if (warning.suggested_replacement) {
|
|
250
|
+
return `WARNING: ${warning.block} is non-preferred. Use ${warning.suggested_replacement} instead.`;
|
|
251
|
+
}
|
|
252
|
+
return `WARNING: ${warning.message}`;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ============================================
|
|
256
|
+
// Helpers
|
|
257
|
+
// ============================================
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Group block types by their namespace.
|
|
261
|
+
*
|
|
262
|
+
* @param types - Array of block types
|
|
263
|
+
* @returns Object keyed by namespace
|
|
264
|
+
*/
|
|
265
|
+
function groupByNamespace(types: BlockType[]): Record<string, BlockType[]> {
|
|
266
|
+
const groups: Record<string, BlockType[]> = {};
|
|
267
|
+
for (const t of types) {
|
|
268
|
+
const ns = getNamespace(t.name);
|
|
269
|
+
if (!groups[ns]) groups[ns] = [];
|
|
270
|
+
groups[ns].push(t);
|
|
271
|
+
}
|
|
272
|
+
return groups;
|
|
273
|
+
}
|