PluginProbe
ZIP AI – AI Website Builder & AI Agent (Beta) / 0.0.7
ZIP AI – AI Website Builder & AI Agent (Beta) v0.0.7
0.0.10 0.0.9 trunk 0.0.4 0.0.5 0.0.6 0.0.7 0.0.8
zip-ai / assets / js / tools / editor / get-context / handler.js

handler.js in ZIP AI – AI Website Builder & AI Agent (Beta) 0.0.7, at assets/js/tools/editor/get-context/handler.js

415 lines 20.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * ZipWP MCP — Vibe Editing v2: editor/get-context handler.
3 *
4 * Reads a slice of the LIVE Gutenberg block tree from wp.data and returns it as
5 * structured JSON. Dispatched by the brain's AgentBrowserLoop as a `js_rpc`
6 * tool (same eager-execute path as js_hook); the result is POSTed back to the
7 * Laravel /agent/rpc-reply route so the loop resolves it IN THE SAME TURN.
8 *
9 * Input:
10 * scope (optional) — a block clientId. Present → return that block's subtree;
11 * absent → the page's top-level sections (a cheap outline the brain can then
12 * expand by re-calling with a section clientId).
13 *
14 * Returns { success, data: { scope, postId, blocks: [row] } } where each row is
15 * { clientId, blockName, text, className, path, html?, computed? } — a
16 * DESIGN-FAITHFUL view: `text` identifies the block (stripped), `html` is the
17 * rich content WITH inline markup (the design pattern, when present), `computed`
18 * is the rendered-style digest (fontSize/color/background/padding/fontWeight,
19 * best-effort) so the brain edits relative to RENDERED reality, not class
20 * strings. The clientId is the LIVE targeting handle (re-snapshotted every
21 * call); the brain never persists it across turns.
22 *
23 * @package zip-ai
24 */
25 (function () {
26 'use strict';
27
28 // Plain-text snippet for a block, so the brain can identify it by content.
29 function textOf(block) {
30 var a = block.attributes || {};
31 var raw = a.text || a.content || a.label || a.title || '';
32 return String(raw).replace(/<[^>]*>/g, '').trim().slice(0, 120);
33 }
34
35 // RICH content WITH inline markup intact — the carrier of the page's design
36 // PATTERN (per-word colour <span>s, emphasis). `text` above is stripped for
37 // identification; this is what the brain must edit so a content rewrite can
38 // re-author the SAME inline structure with new words instead of flattening
39 // it to a plain string (the headline-flattening defect). Surfaced ONLY when
40 // the content actually carries inline markup, so plain blocks stay lean.
41 function htmlOf(block) {
42 var a = block.attributes || {};
43 var raw = a.content != null ? a.content : (a.text != null ? a.text : null);
44 if (typeof raw !== 'string' || raw.indexOf('<') === -1) return null;
45 return raw.length > 600 ? raw.slice(0, 600) : raw;
46 }
47
48 // The live editor canvas document (iframe in the block editor; falls back to
49 // the main document for the no-iframe mount). Shared shape with apply-change's
50 // canvasCtx — kept minimal here (read-only lookup) to avoid a cross-handler
51 // load-order dependency.
52 function canvasDoc() {
53 var f = document.querySelector('iframe[name="editor-canvas"]');
54 return f && f.contentDocument ? f.contentDocument : document;
55 }
56
57 // The canvas node for a block (the live rendered element). Resolved ONCE per
58 // row by the caller and shared by computedOf + renderedContentOf so the
59 // [data-block] lookup runs once, not per-field. null when not mounted
60 // (off-screen/virtualized).
61 function nodeForBlock(doc, clientId) {
62 return doc.querySelector('[data-block="' + clientId + '"]');
63 }
64
65 // RENDERED design facts for a block — the load-bearing axes the brain
66 // otherwise cannot perceive (it sees authored class tokens, never the
67 // rendered px/colour). Lets "bigger" be relative to the real size and lets
68 // the brain see when an authored class did NOT move a property (a gs-*
69 // !important rule owns it). Best-effort: null when the canvas node isn't
70 // mounted (off-screen/virtualized) — the doctrine then falls back to
71 // className. Read-only; never throws.
72 function computedOf(el, win) {
73 try {
74 if (!el) return null;
75 var cs = win.getComputedStyle(el);
76 return {
77 fontSize: cs.fontSize,
78 color: cs.color,
79 backgroundColor: cs.backgroundColor,
80 padding: cs.padding,
81 fontWeight: cs.fontWeight,
82 };
83 } catch (e) {
84 return null;
85 }
86 }
87
88 // Collapse whitespace + trim — for the RENDERED DOM text, which the browser
89 // already decoded to plain text (no entities/tags). Deliberately NOT routed
90 // through an innerHTML round-trip: that would mangle a literal "<" in the text.
91 function collapseWs(s) {
92 return (s === null || s === undefined ? '' : String(s)).replace(/\s+/g, ' ').trim();
93 }
94
95 // Decode HTML entities + strip tags + collapse whitespace — for the AUTHORED
96 // attr, which may carry entities/markup. A detached element (cached, plain
97 // `document` — decoding is document-agnostic) does the exact decode the browser
98 // does; under jest (no DOM) it degrades to a tag strip. Pairs with collapseWs
99 // so authored + rendered compare like-for-like — entity/markup/whitespace
100 // differences never cause a false divergence.
101 var _decodeEl = null;
102 function normContent(s) {
103 var str = s === null || s === undefined ? '' : String(s);
104 try {
105 if (_decodeEl === null) _decodeEl = document.createElement('div');
106 _decodeEl.innerHTML = str;
107 str = _decodeEl.textContent || '';
108 } catch (e) {
109 str = str.replace(/<[^>]*>/g, '');
110 }
111 return str.replace(/\s+/g, ' ').trim();
112 }
113
114 // CONTENT OWNERSHIP (the content analog of get-styles' styleContext): the
115 // RENDERED text of a LEAF content block vs its own authored content attr.
116 // When they DIFFER, the block's own attr is a DEAD LEVER — the rendered value
117 // is owned UPSTREAM (a parent composite computes it / pushes it via block
118 // context: a countdown unit's label comes from the parent's `{unit}sLabel`).
119 // Editing the child attr then silently no-ops on render. Surfacing the
120 // rendered value (NOT a server-computed boolean — the brain compares `text`
121 // vs `rendered` and judges) lets the brain redirect the edit to the owning
122 // parent attr, WITHOUT any per-block knowledge — the render is the source of
123 // truth, exactly like styleContext's `effective`.
124 //
125 // Scoped HARD to avoid false positives: LEAF blocks only (a container's
126 // textContent is its whole subtree → would always "differ"); only TEXT attrs
127 // (content/text/label) — NOT `number`, which leaves self-format/animate
128 // (1000→"1,000", count-up mid-flight), so value-inequality there is not
129 // ownership; both sides normalized; empty/partial renders (unmounted, or
130 // mid-typewriter where rendered is a prefix of authored) are skipped. Takes the
131 // pre-resolved canvas node. Returns the normalized rendered value, or null.
132 function renderedContentOf(block, el) {
133 if ((block.innerBlocks || []).length > 0) return null; // leaf-only — never a container subtree
134 var a = block.attributes || {};
135 var authoredRaw = a.content != null ? a.content : a.text != null ? a.text : a.label != null ? a.label : null;
136 if (authoredRaw === null || authoredRaw === '') return null;
137 if (!el) return null;
138 try {
139 var rendered = collapseWs(el.textContent || '');
140 if (!rendered) return null; // unmounted / cleared (mid-stream)
141 // indexOf===0 covers BOTH equality (child owns it) and rendered being a
142 // strict prefix of authored (mid-typewriter) — either way, not shadowed.
143 if (normContent(authoredRaw).indexOf(rendered) === 0) return null;
144 return rendered.slice(0, 120);
145 } catch (e) {
146 return null;
147 }
148 }
149
150 // Current className (L2 styling). Surfaced so an apply_change.setAttributes
151 // can PRESERVE the existing utility/gs classes instead of replacing them
152 // wholesale — the brain appends to this string.
153 function classOf(block) {
154 var a = block.attributes || {};
155 return typeof a.className === 'string' ? a.className : '';
156 }
157
158 // The 8 GBS-banned visual styling props are NEVER advertised as authorable
159 // attrs (styling lives in `className` ONLY, the Spectra GBS JIT grammar). The
160 // list is the SHARED SSOT (editor-shared-utils.isBannedVisualAttr) so this +
161 // apply-change can never drift. If the shared module isn't resolved we
162 // advertise the raw registry keys (the brain's zod still rejects the 8 on
163 // write — degrade consistently, never a duplicate list).
164 function isBannedVisualAttr(key) {
165 var u = sharedEditorUtils();
166 return !!(u && typeof u.isBannedVisualAttr === 'function' && u.isBannedVisualAttr(key));
167 }
168
169 // The block's VALID structural attr keys — straight from the registry
170 // (getBlockType(blockName).attributes, the browser-side SSOT for what
171 // Gutenberg accepts) minus the 8 GBS-banned styling attrs. Surfaced so the
172 // model authors valid `attrs` first-try (e.g. counter `endNumber`, button
173 // `linkURL`, container `isRootBlock`) instead of guessing against a brain
174 // allowlist — the registry, not a hand-maintained list, decides validity.
175 // null when the type isn't registered (forked/unknown block) → row omits the
176 // field rather than advertise an empty/false set.
177 function attrKeysOf(name) {
178 var t = window.wp && window.wp.blocks && window.wp.blocks.getBlockType
179 ? window.wp.blocks.getBlockType(name)
180 : null;
181 if (!t || !t.attributes) return null;
182 var out = [];
183 Object.keys(t.attributes).forEach(function (k) {
184 if (!isBannedVisualAttr(k)) out.push(k);
185 });
186 return out;
187 }
188
189 // SET (non-default) attribute VALUES — the higher-specificity styling/layout
190 // layer that OUTRANKS className (cascade: block attribute > GBS class > block
191 // default). Surfaced so the model SEES a pinned `style.*` / `layout` / spacing
192 // attr BEFORE it sets a class that would silently no-op against it, and can
193 // clear/move it. Uses the PROPER Gutenberg notion of "comment attributes": walk
194 // the registered schema (getBlockType), drop attrs SOURCE-d from markup
195 // (content/html — those are the text, not a styling override) and attrs still at
196 // their declared `default` — exactly the set Gutenberg's serializer persists.
197 // FULLY registry-driven — no hardcoded key list: only the schema's own `source`
198 // (markup-backed) + `default` + the banned visual props filter it. So whatever
199 // is pinned (style.*, layout, spacing, …) surfaces; the model picks the ones
200 // relevant to its edit. null when nothing is overridden.
201 function setAttrsOf(block) {
202 var t = window.wp && window.wp.blocks && window.wp.blocks.getBlockType
203 ? window.wp.blocks.getBlockType(block.name)
204 : null;
205 if (!t || !t.attributes) return null;
206 var a = block.attributes || {};
207 var schema = t.attributes;
208 var out = {};
209 Object.keys(schema).forEach(function (k) {
210 var def = schema[k] || {};
211 if (def.source) return; // sourced from the markup — it's the content, not a styling override
212 if (isBannedVisualAttr(k)) return; // never authorable in attrs anyway
213 var v = a[k];
214 if (v === undefined || v === null || v === '') return;
215 if (Array.isArray(v) && v.length === 0) return;
216 if (typeof v === 'object' && !Array.isArray(v) && Object.keys(v).length === 0) return;
217 try {
218 if (JSON.stringify(v) === JSON.stringify(def.default)) return; // unchanged from default
219 } catch (e) { /* unstringifiable → treat as a real override */ }
220 out[k] = v;
221 });
222 return Object.keys(out).length ? out : null;
223 }
224
225 function rowOf(block, path) {
226 var row = {
227 clientId: block.clientId,
228 blockName: block.name,
229 text: textOf(block),
230 className: classOf(block),
231 path: path,
232 };
233 // Registry-declared valid attr keys (minus GBS-banned) so the model
234 // authors valid setAttributes.attrs first-try. Omitted for unregistered
235 // types — never advertise a guess.
236 var attrKeys = attrKeysOf(block.name);
237 if (attrKeys !== null) row.attrKeys = attrKeys;
238 // Pinned (non-default) attr VALUES — the higher-specificity layer that
239 // overrides className. Lets the model reconcile specificity before a class no-ops.
240 var setAttrs = setAttrsOf(block);
241 if (setAttrs !== null) row.setAttrs = setAttrs;
242 // Design-faithful enrichment — only present when meaningful, so the
243 // payload stays lean: `html` for blocks carrying inline markup, `computed`
244 // when the live canvas node is readable. The canvas node is resolved ONCE
245 // here and shared by computedOf + renderedContentOf (one [data-block]
246 // lookup per row, not per field).
247 var html = htmlOf(block);
248 if (html !== null) row.html = html;
249 var cdoc = canvasDoc();
250 var cel = nodeForBlock(cdoc, block.clientId);
251 var computed = computedOf(cel, cdoc.defaultView || window);
252 if (computed !== null) row.computed = computed;
253 // Content ownership: present ONLY when the rendered text differs from this
254 // leaf's own content attr — i.e. the attr is a dead lever and the value is
255 // owned upstream (a parent composite). The brain compares `text` vs
256 // `rendered` and redirects the edit to the parent's owning attr.
257 var rendered = renderedContentOf(block, cel);
258 if (rendered !== null) row.rendered = rendered;
259 return row;
260 }
261
262 // Read bounds. An unbounded subtree dump on a large/whole page can exceed the
263 // brain's compaction backstop and clear the active read with NO recovery
264 // (the brain holds only the js_rpc envelope, not this payload). Cap rows +
265 // depth + total bytes so a single read is always self-contained; a truncated
266 // read flags `truncated` and stamps per-parent `childCount`/`hasMore` so the
267 // model drills down with a narrower scope instead of getting a silent cut.
268 var MAX_CONTEXT_ROWS = 250;
269 var MAX_CONTEXT_DEPTH = 8;
270 var MAX_CONTEXT_BYTES = 48000;
271
272 // Push a row into the bounded accumulator. Returns false (caller stops) when
273 // a cap is hit. Tracks an approximate serialized size so a few heavy rows
274 // (long html / many attrKeys) trip the ceiling as readily as many light ones.
275 function pushRow(state, row) {
276 if (state.rows.length >= MAX_CONTEXT_ROWS) { state.truncated = true; return false; }
277 var sz = 200;
278 try { sz = JSON.stringify(row).length; } catch (e) { sz = 200; }
279 if (state.bytes + sz > MAX_CONTEXT_BYTES && state.rows.length > 0) {
280 state.truncated = true;
281 return false;
282 }
283 state.rows.push(row);
284 state.bytes += sz;
285 return true;
286 }
287
288 // Flatten a block + descendants into bounded rows (DFS): clientId (live
289 // handle), block name, text snippet, className, dot-path. Stops at the
290 // row/byte caps; at the depth cap it emits the node with childCount/hasMore
291 // rather than descending, so deeper blocks stay discoverable via a scoped
292 // re-read.
293 function flatten(block, path, depth, state) {
294 var inner = block.innerBlocks || [];
295 var row = rowOf(block, path);
296 if (inner.length > 0 && depth >= MAX_CONTEXT_DEPTH) {
297 row.childCount = inner.length;
298 row.hasMore = true;
299 pushRow(state, row);
300 return;
301 }
302 if (!pushRow(state, row)) return;
303 for (var i = 0; i < inner.length; i++) {
304 if (state.truncated) {
305 // The caps tripped before this parent's children were exhausted —
306 // flag it so the omitted subtree is discoverable.
307 row.childCount = inner.length;
308 row.hasMore = true;
309 return;
310 }
311 flatten(inner[i], path === '' ? String(i) : path + '.' + i, depth + 1, state);
312 }
313 }
314
315 // Shared editor utilities — resolved at call time from the ONE source
316 // (editor/shared/editor-shared-utils.js): window in the browser, require()
317 // under jest. currentPostId lives there so this handler and apply-change can
318 // never drift; a missing module degrades consistently (null).
319 function sharedEditorUtils() {
320 if (typeof window !== 'undefined' && window.zipwpEditorShared) return window.zipwpEditorShared;
321 if (typeof require === 'function') {
322 try { return require('../shared/editor-shared-utils.js'); } catch (e) { return null; }
323 }
324 return null;
325 }
326 function currentPostId() {
327 var u = sharedEditorUtils();
328 return u && u.currentPostId ? u.currentPostId() : null;
329 }
330
331 function handleGetContext(args) {
332 if (!window.wp || !window.wp.data) {
333 return { success: false, error: 'block_editor_unavailable' };
334 }
335 var sel = window.wp.data.select('core/block-editor');
336 if (!sel) {
337 return { success: false, error: 'block_editor_unavailable' };
338 }
339
340 // A scope that isn't a real clientId — empty, or a sentinel the model
341 // sometimes emits when it means "the whole page" (the literal string
342 // "null", "root", "page", …) — degrades to the page outline rather than
343 // a hard miss. The outline is the cheap top-level map the model can
344 // re-target from; a hard error here just burns a round-trip.
345 var scopeRaw = args && args.scope ? String(args.scope) : '';
346 var SCOPE_SENTINELS = { '': 1, 'null': 1, 'undefined': 1, 'root': 1, 'page': 1, '0': 1, 'none': 1, 'false': 1 };
347 var scope = SCOPE_SENTINELS[scopeRaw.toLowerCase()] ? '' : scopeRaw;
348 // Bounded accumulator (rows + running byte size + truncation flag).
349 var state = { rows: [], bytes: 0, truncated: false };
350
351 // A stale/rotated clientId — gone after a structural edit (replace/insert
352 // mint new ids), a page change, or replayed from older conversation history
353 // — must NOT dead-end the turn. A hard `scope_not_found` error makes the
354 // model re-ask the SAME dead id (wasted round-trips, duplicate-call blocks,
355 // token bloat). Instead DEGRADE to the page outline and FLAG the miss, so the
356 // model re-grounds against the live tree and re-targets in the SAME turn. The
357 // grounding tool is the model's only map; it must always return a usable one.
358 var scopeMissed = '';
359 if (scope) {
360 var root = sel.getBlock(scope);
361 if (root) {
362 flatten(root, '', 0, state);
363 } else {
364 scopeMissed = scope;
365 scope = '';
366 }
367 }
368 if (!scope) {
369 var top = sel.getBlocks() || [];
370 for (var i = 0; i < top.length; i++) {
371 // Outline read = top-level sections only (no recurse). Still
372 // row/byte-bounded so a page with hundreds of sections can't
373 // blow the read.
374 if (!pushRow(state, rowOf(top[i], String(i)))) break;
375 }
376 }
377 var blocks = state.rows;
378
379 var data = {
380 scope: scope || null,
381 postId: currentPostId(),
382 blocks: blocks,
383 };
384 // The read hit a cap (rows/bytes) — tell the model these rows are a
385 // PREFIX, so it narrows the scope (re-read a child clientId) rather than
386 // assuming it saw the whole tree. Pairs with per-parent childCount/hasMore.
387 if (state.truncated) {
388 data.truncated = true;
389 }
390 // Flag a degraded read so the model KNOWS the id it asked for is gone and
391 // these rows are the live outline to re-target from (not the requested
392 // subtree) — turns a silent substitution into an explicit re-ground signal.
393 if (scopeMissed) {
394 data.scopeNotFound = scopeMissed;
395 }
396 return { success: true, data: data };
397 }
398
399 // Register once the bridge is ready (same retry pattern as the other
400 // editor tools — the bridge mounts asynchronously).
401 function initHandler() {
402 if (window.zipwpMcp && window.zipwpMcp.registerTool) {
403 window.zipwpMcp.registerTool(
404 'editor/get-context',
405 async (args) => handleGetContext(args),
406 { previewMode: 'client' }
407 );
408 } else {
409 setTimeout(initHandler, 100);
410 }
411 }
412
413 initHandler();
414 })();
415