PluginProbe
ZIP AI – AI Website Builder & AI Agent / 0.0.13
ZIP AI – AI Website Builder & AI Agent v0.0.13
0.0.14 0.0.13 0.0.12 0.0.11 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 / apply-change / handler.js

handler.js in ZIP AI – AI Website Builder & AI Agent 0.0.13, at assets/js/tools/editor/apply-change/handler.js

2,487 lines 123.8 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/apply-change handler.
3 *
4 * The WRITE sibling of editor/get-context. The brain's AgentBrowserLoop dispatches
5 * a `js_rpc` envelope carrying a serialized Gutenberg EXECUTION PLAN; this handler
6 * runs each operation against the LIVE Gutenberg tree via wp.data dispatch, then
7 * returns a structured reply that the bridge POSTs to /agent/rpc-reply so the loop
8 * folds it IN THE SAME TURN.
9 *
10 * Contract:
11 * args = { version, post_id, operations:[ { function, ...named args } ] }
12 * Each operation maps to ONE-OR-MORE real wp.data.dispatch('core/block-editor')[function](...)
13 * calls — one per op, except moveBlocksToPosition's reorder ('order') and
14 * multi-parent forms, which fan out to N native moveBlocksToPosition dispatches.
15 * Allowed functions: updateBlockAttributes | insertBlocks | removeBlocks |
16 * moveBlocksToPosition | replaceBlocks | replaceInnerBlocks | duplicateBlocks |
17 * selectBlock. The brain drives them with its native Gutenberg API knowledge.
18 * - updateBlockAttributes PRESERVES clientId (read-merge-dispatch; className
19 * verbatim — the GBS JIT renders it; NO style translation).
20 * - insertBlocks / replace* MINT clientIds (createBlock stamps them synchronously)
21 * → returned in applied[].new_client_ids so the brain can target them next turn.
22 * - moveBlocksToPosition PRESERVES clientIds (a splice). insert/move accept a
23 * before|after anchor that resolves to root+index here.
24 * post_id two-tab guard: abort ALL on mismatch (returned as a `refused` reply,
25 * ok:true, so the LLM sees the real reason — not "browser unreachable").
26 * Partial-apply: a per-op failure (stale clientId) records into failed[] by index
27 * and continues; earlier ops stay applied. Targeting is liveness-checked here;
28 * the brain holds no durable handle.
29 *
30 * @package
31 */
32 ( function () {
33 'use strict';
34
35 function isPlainObject( v ) {
36 return v !== null && typeof v === 'object' && ! Array.isArray( v );
37 }
38
39 // L3 boundary — per-block visual styling lives in `className` ONLY (the
40 // Spectra GBS JIT grammar); these flat visual attrs are NEVER allowed on a
41 // block. The brain's zod denylist is the contract owner; we re-enforce
42 // client-side (defense in depth) so a stale/forked brain bundle can't paint
43 // banned attrs into the live tree. The 8-prop list is the SHARED SSOT
44 // (editor-shared-utils.isBannedVisualAttr) so this + get-context can never
45 // drift. If the shared module isn't resolved we don't strip — the brain's zod
46 // already rejected the 8 upstream, so we degrade consistently (never a 3rd copy).
47 function isBannedVisualAttr( key ) {
48 const u = sharedEditorUtils();
49 return !! ( u && typeof u.isBannedVisualAttr === 'function' && u.isBannedVisualAttr( key ) );
50 }
51 function stripBannedAttrs( attrs ) {
52 if ( ! isPlainObject( attrs ) ) {
53 return attrs;
54 }
55 const clean = {};
56 Object.keys( attrs ).forEach( function ( k ) {
57 if ( ! isBannedVisualAttr( k ) ) {
58 clean[ k ] = attrs[ k ];
59 } else {
60 console.warn( '[ZIP AI:apply-change] dropped banned visual attr "%s" — styling belongs in className', k );
61 }
62 } );
63 return clean;
64 }
65
66 // Recursively strip banned visual attrs from an already-PARSED block tree
67 // (wp.blocks.parse output). The section_markup insert path bypasses toBlock,
68 // so this is where the block path's per-block stripBannedAttrs is reapplied —
69 // and the ONLY guard on that path (the vibe-editor door never reaches
70 // @bsf/wp-importer's fail-closed validator). Mutates each block's attributes.
71 function stripBannedAttrsDeep( block ) {
72 if ( ! block || typeof block !== 'object' ) {
73 return;
74 }
75 if ( block.attributes ) {
76 block.attributes = stripBannedAttrs( block.attributes );
77 }
78 if ( Array.isArray( block.innerBlocks ) ) {
79 block.innerBlocks.forEach( stripBannedAttrsDeep );
80 }
81 }
82
83 // Shared editor utilities — resolved at call time from the ONE source
84 // (editor/shared/editor-shared-utils.js): window in the browser, require()
85 // under jest. currentPostId (post_id guard) lives there so this handler and
86 // get-context can never drift. A missing module degrades consistently (null)
87 // — never a spurious post_id refusal.
88 function sharedEditorUtils() {
89 if ( typeof window !== 'undefined' && window.zipwpEditorShared ) {
90 return window.zipwpEditorShared;
91 }
92 if ( typeof require === 'function' ) {
93 try {
94 return require( '../shared/editor-shared-utils.js' );
95 } catch ( e ) {
96 return null;
97 }
98 }
99 return null;
100 }
101 function currentPostId() {
102 const u = sharedEditorUtils();
103 return u && u.currentPostId ? u.currentPostId() : null;
104 }
105
106 // Liveness (GAP-C / G34): the clientId must resolve against the live tree.
107 // Throws a typed miss so the caller records it in failed[] and never mutates
108 // the wrong block (a stale id must NEVER default to "top").
109 function liveness( sel, id ) {
110 const block = sel.getBlock( id );
111 if ( ! block ) {
112 throw new Error( 'stale_client_id:' + id );
113 }
114 return block;
115 }
116
117 // Block-safety guard (SCENARIO-003): a MUTATING op must not touch a block the
118 // editor has LOCKED, nor escape THIS page by editing synced-pattern
119 // (core/block) inner content or template-locked content. Gutenberg's own lock
120 // selectors are authoritative — canEditBlock / canRemoveBlock / canMoveBlock
121 // already fold in template lock, content lock, and synced-pattern instance
122 // locks (the instance's inner blocks are content-locked). A blocked op throws
123 // a typed miss → failed[], so the brain surfaces it (and can tell the user)
124 // instead of silently clobbering shared/locked content. Degrades to ALLOW when
125 // a selector is absent (older Gutenberg) — never a false block.
126 const MUTATION_KIND = {
127 updateBlockAttributes: 'edit',
128 removeBlocks: 'remove',
129 replaceBlocks: 'remove',
130 replaceInnerBlocks: 'edit',
131 moveBlocksToPosition: 'move',
132 };
133 function assertMutable( sel, id, kind ) {
134 if ( ! id || ! sel.getBlock( id ) ) {
135 return;
136 } // stale / missing — liveness owns that miss
137 const can = kind === 'remove' ? sel.canRemoveBlock
138 : kind === 'move' ? sel.canMoveBlock
139 : sel.canEditBlock;
140 if ( typeof can === 'function' && can.call( sel, id ) === false ) {
141 throw new Error( 'locked_block:' + id );
142 }
143 }
144
145 // Insert-time lock guard (GBR-2): insertBlocks / duplicateBlocks ADD a block to
146 // a CONTAINER — a case canEdit/Remove/Move (assertMutable) does NOT cover, so
147 // those two ops previously bypassed the lock check. getTemplateLock on the
148 // destination root is authoritative: 'all' (fully locked) and 'insert' (no
149 // add/remove/move) both forbid the insertion. Throws a typed miss → failed[] so
150 // the brain surfaces it instead of relying on the dispatch to reject. Degrades
151 // to ALLOW when the selector is absent (older Gutenberg) — never a false block.
152 function assertInsertable( sel, root ) {
153 if ( typeof sel.getTemplateLock !== 'function' ) {
154 return;
155 }
156 // eslint-disable-next-line eqeqeq
157 const lock = sel.getTemplateLock( root == null ? '' : root );
158 if ( lock === 'all' || lock === 'insert' ) {
159 throw new Error( 'locked_block:' + ( root || 'root' ) );
160 }
161 }
162
163 // ALLOWED-CHILD guard. A parent may restrict which block types it accepts
164 // (`allowedBlocks` in block.json — spectra/buttons takes ONLY spectra/button).
165 // Gutenberg does not throw on a rejected child: `insertBlocks` mints a clientId
166 // and silently drops the block, so the reply carried `new_client_ids` and the
167 // structural check then reported `landed:false` with no reason attached. Turn
168 // 469d8aa1: "add a paragraph below for disclaimer" with a Button selected
169 // inserted a spectra/content NEXT TO that button — inside spectra/buttons — so
170 // it could never land. It was retried once, failed identically, and the turn
171 // told the user "The disclaimer paragraph has been added below the buttons."
172 //
173 // Fail LOUD instead, and hand back the fact the model needs to place it well:
174 // which type was refused, by which parent, and the nearest ancestor that WOULD
175 // take it. The model decides where it belongs — or asks the user — rather than
176 // guessing again into the same wall. Degrades to ALLOW when the editor doesn't
177 // expose canInsertBlockType, matching assertInsertable's posture.
178 function acceptingAncestor( sel, root, name ) {
179 if ( typeof sel.getBlockParents !== 'function' ) {
180 return null;
181 }
182 // Nearest ancestor first (getBlockParents returns top-first), then the
183 // page root — one selector call, no arbitrary hop cap.
184 const chain = root ? sel.getBlockParents( root ).slice().reverse() : [];
185 chain.push( '' ); // page root
186 for ( let i = 0; i < chain.length; i++ ) {
187 const parent = chain[ i ];
188 if ( sel.canInsertBlockType( name, parent || undefined ) ) {
189 return parent || 'root';
190 }
191 }
192 return null;
193 }
194 function assertAllowedChildren( sel, root, blocks ) {
195 if ( typeof sel.canInsertBlockType !== 'function' ) {
196 return;
197 }
198 // eslint-disable-next-line eqeqeq
199 const target = root == null ? undefined : root;
200 for ( let i = 0; i < blocks.length; i++ ) {
201 const name = blocks[ i ] && blocks[ i ].name;
202 if ( ! name || sel.canInsertBlockType( name, target ) ) {
203 continue;
204 }
205 const parentBlock = target ? sel.getBlock( target ) : null;
206 const parentName = parentBlock ? parentBlock.name : 'the page root';
207 const where = acceptingAncestor( sel, target, name );
208 throw new Error(
209 'child_not_allowed:' + name + ' cannot go inside ' + parentName +
210 ( where ? ' — the nearest container that accepts it is ' + where : '' ) +
211 '. Place it there, or ask the user where they want it.'
212 );
213 }
214 }
215
216 // M2 — move-DESTINATION lock guard. canMoveBlock (assertMutable 'move')
217 // folds in the SOURCE parent's lock only; moving INTO a fully-locked
218 // container bypassed every check. templateLock semantics: 'all' forbids any
219 // structural change inside the container; 'insert' forbids add/remove but
220 // PERMITS moving existing children, so only 'all' blocks a move-in here.
221 // Same degrade-to-ALLOW posture as assertInsertable.
222 function assertMoveDestination( sel, root ) {
223 if ( typeof sel.getTemplateLock !== 'function' ) {
224 return;
225 }
226 // eslint-disable-next-line eqeqeq
227 if ( sel.getTemplateLock( root == null ? '' : root ) === 'all' ) {
228 throw new Error( 'locked_block:' + ( root || 'root' ) );
229 }
230 }
231
232 // ── Subtree scope-lock (selection confinement — SaaS remediation ①) ───────
233 // The brain stamps `scope_lock` (the selected container's clientId) on the
234 // envelope ONLY when the turn bound a SUBTREE scope (a CONTAINER is selected
235 // and the request is NOT page-wide). Every MUTATING op must then act on the
236 // locked container or a DESCENDANT of it — editing / inserting / moving into
237 // a DIFFERENT section is refused (→ failed[], so the brain surfaces it and
238 // can tell the user). This is the tree-aware AIRTIGHT half of "stay inside
239 // the selected element"; the brain gate is the soft (outline-only) half.
240 // selectBlock is exempt (navigation, not a mutation). Degrades to ALLOW when
241 // getBlockParents is absent (older Gutenberg) — never a false block; the
242 // caller also drops a stale (non-live) lock id before enforcing.
243 const SCOPE_EXEMPT_FUNCTIONS = { selectBlock: true };
244 // `id` is undefined/null when a field simply wasn't provided (no constraint
245 // from it); '' is the PAGE ROOT (Gutenberg's own rootClientId convention)
246 // and must NOT short-circuit true — the page root is never inside a
247 // subtree lock.
248 function isWithinScope( sel, id, lockId ) {
249 if ( id === undefined || id === null || id === lockId ) {
250 return true;
251 }
252 if ( id === '' ) {
253 return false;
254 }
255 if ( typeof sel.getBlockParents !== 'function' ) {
256 return true;
257 } // can't tell → allow
258 const parents = sel.getBlockParents( id ) || [];
259 return parents.indexOf( lockId ) !== -1;
260 }
261 // Every clientId a mutating op acts ON or places relative to (the surface a
262 // "roam" would land on): clientIds[], order[], rootClientId, toRootClientId,
263 // and the before/after placement anchors.
264 function scopeGoverningIds( op ) {
265 let ids = [];
266 if ( Array.isArray( op.clientIds ) ) {
267 ids = ids.concat( op.clientIds );
268 }
269 if ( Array.isArray( op.order ) ) {
270 ids = ids.concat( op.order );
271 }
272 [ 'rootClientId', 'toRootClientId', 'before', 'after', 'clientId' ].forEach( function ( k ) {
273 // eslint-disable-next-line eqeqeq
274 if ( op[ k ] != null ) {
275 ids.push( op[ k ] );
276 }
277 } );
278 return ids.filter( function ( x ) {
279 return typeof x === 'string' && x !== '';
280 } );
281 }
282 // The destination container insertBlocks/moveBlocksToPosition will ACTUALLY
283 // land in — mirrors runOp's own resolution exactly (an anchor resolves to
284 // its PARENT via resolveAnchor; an omitted INSERT root defaults to the PAGE
285 // ROOT, ''; an omitted MOVE destination keeps the target's current parent,
286 // so it imposes no destination constraint) — so the scope check can never
287 // see a different landing spot
288 // than the one that really applies. scopeGoverningIds() above checks the
289 // RAW anchor/root id (is the id itself in scope); this checks where that
290 // id/omission actually RESOLVES to, which is a different question for two
291 // reasons: an anchor's parent can be outside scope even when the anchor
292 // itself is in scope (before/after the locked container = its parent, one
293 // level UP), and an OMITTED destination has no raw field for
294 // scopeGoverningIds() to flag at all. What an omission resolves to now
295 // depends on the op: an omitted INSERT root is the page root (never in
296 // scope); an omitted MOVE keeps each target's current parent, so it adds no
297 // destination constraint; an omitted `order` parent is the one those blocks
298 // share, which IS resolved here and scope-checked for real.
299 function resolvedDestinationOf( sel, op ) {
300 const anchor = anchorOf( op );
301 if ( anchor ) {
302 if ( ! anchor.id || ! sel.getBlock( anchor.id ) ) {
303 return undefined;
304 } // stale anchor — liveness() surfaces this separately
305 return resolveAnchor( sel, anchor.id, anchor.position ).parent;
306 }
307 if ( op.function === 'insertBlocks' ) {
308 // eslint-disable-next-line eqeqeq
309 return op.rootClientId == null ? '' : op.rootClientId;
310 }
311 if ( op.function === 'moveBlocksToPosition' ) {
312 // Mirrors runOp's destination resolution EXACTLY — the invariant this
313 // helper exists for. Explicit null = page root. Omitted:
314 // • the `order` form re-orders inside the parent those blocks SHARE, so
315 // resolve it (orderParentOf) and scope-check it for real. Returning
316 // undefined here would skip the check while runOp still landed
317 // somewhere concrete — the exact drift this helper must not have.
318 // • the targeted form keeps each target in its OWN parent
319 // (KEEP_CURRENT_PARENT), which imposes no single destination, and the
320 // targets themselves are already checked by scopeGoverningIds.
321 if ( op.toRootClientId === null ) {
322 return '';
323 }
324 if ( op.toRootClientId === undefined ) {
325 return op.order ? orderParentOf( sel, op.order ) : undefined;
326 }
327 return op.toRootClientId;
328 }
329 if ( op.function === 'duplicateBlocks' ) {
330 // A duplicate lands as a SIBLING of the original, so its destination is
331 // the original's PARENT. Without this, duplicating the locked container
332 // itself passed the raw-id check (id === lockId) but the copy escaped to
333 // the lock's parent, outside scope.
334 const first = op.clientIds && op.clientIds[ 0 ];
335 return first ? ( sel.getBlockRootClientId( first ) || '' ) : undefined;
336 }
337 return undefined; // op has no destination-shaped field
338 }
339 function assertWithinScope( sel, op, lockId ) {
340 // Applying a PICKED section — the brain stamps `section_add` on every op
341 // whose section_ref it resolved, whether that lands as a page-level ADD
342 // (insertBlocks) or as a redesign of an existing one (replaceBlocks /
343 // replaceInnerBlocks). Exempt from the subtree lock, exactly as the brain's
344 // selection-binding guard does: the picker PINNED its target when it opened,
345 // so a click elsewhere while the previews rendered must not redirect the
346 // replace. Narrow by design — the marker rides only a brain-resolved ref;
347 // free-hand edits, moves, and inline-`blocks` inserts stay confined.
348 if ( ! lockId || SCOPE_EXEMPT_FUNCTIONS[ op.function ] || op.section_add === true ) {
349 return;
350 }
351 const ids = scopeGoverningIds( op );
352 if ( ids.length === 0 ) {
353 // A mutating op with no resolved target = a page-root op (e.g. insert
354 // at root). That lands OUTSIDE the selected container.
355 throw new Error( 'out_of_scope: this edit has no in-section target (it would land at the page root), but you are scoped to the selected container ' + lockId + ' — insert/edit INSIDE it, or tell the user if a different section is intended.' );
356 }
357 for ( let i = 0; i < ids.length; i++ ) {
358 if ( ! isWithinScope( sel, ids[ i ], lockId ) ) {
359 throw new Error( 'out_of_scope: target ' + ids[ i ] + ' is outside the selected container ' + lockId + ' (and its children). You are scoped to that section — edit inside it, or tell the user if a different section is intended.' );
360 }
361 }
362 const dest = resolvedDestinationOf( sel, op );
363 // Duplicating the SELECTED container itself lands the copy as its sibling
364 // (in the parent, one level outside the lock) — that IS the user's intent
365 // when they select a section and say "duplicate this". Exempt ONLY that
366 // exact case (the first duplicated id is the lock root, matching how
367 // resolvedDestinationOf derives `dest`); every other out-of-scope
368 // destination — a DIFFERENT block copied outside — still throws.
369 const duplicatingLockRoot = op.function === 'duplicateBlocks' &&
370 Array.isArray( op.clientIds ) && op.clientIds[ 0 ] === lockId;
371 if ( dest !== undefined && ! duplicatingLockRoot && ! isWithinScope( sel, dest, lockId ) ) {
372 throw new Error( 'out_of_scope: this would land at ' + ( dest === '' ? 'the page root' : dest ) + ', outside the selected container ' + lockId + ' — insert/move INSIDE it, or tell the user if a different section is intended.' );
373 }
374 }
375
376 // The block's editable-content attribute(s), from the block REGISTRY — no
377 // hardcoded per-type table. Core blocks declare content via a `source` of
378 // 'rich-text'/'html' (core/heading→content, core/button→text). Spectra blocks
379 // store content in a CUSTOM attribute with no standard source, so we fall
380 // back to a declared `content`/`text` attribute (the Spectra convention,
381 // e.g. spectra/content→text). Block types with neither (image, spacer) get
382 // an empty list and are left untouched.
383 function richTextAttrsOf( name ) {
384 const t = window.wp.blocks && window.wp.blocks.getBlockType
385 ? window.wp.blocks.getBlockType( name )
386 : null;
387 const attrs = t && t.attributes ? t.attributes : {};
388 const out = [];
389 for ( const k in attrs ) {
390 if ( ! Object.prototype.hasOwnProperty.call( attrs, k ) ) {
391 continue;
392 }
393 const src = attrs[ k ] && attrs[ k ].source;
394 if ( src === 'rich-text' || src === 'html' ) {
395 out.push( k );
396 }
397 }
398 if ( out.length === 0 ) {
399 [ 'content', 'text' ].forEach( function ( k ) {
400 if ( Object.prototype.hasOwnProperty.call( attrs, k ) ) {
401 out.push( k );
402 }
403 } );
404 }
405 return out;
406 }
407
408 // The block's REGISTRY attribute schema — the SSOT for which attrs are valid
409 // on a given block type. getBlockType(name).attributes is the exact set
410 // Gutenberg itself validates against; an attr absent from this set is one
411 // updateBlockAttributes would silently ignore. Returns null when the type
412 // isn't registered (a forked/unknown block) — caller then can't partition, so
413 // it applies everything (degrade open, never block a write on a missing type).
414 function registeredAttrKeysOf( name ) {
415 const t = window.wp.blocks && window.wp.blocks.getBlockType
416 ? window.wp.blocks.getBlockType( name )
417 : null;
418 if ( ! t || ! t.attributes ) {
419 return null;
420 }
421 return Object.keys( t.attributes );
422 }
423
424 // Partition an incoming attrs object against the block's REGISTRY schema:
425 // { valid, unknown }. `valid` is the subset whose keys exist in
426 // getBlockType(name).attributes (the keys Gutenberg will actually accept);
427 // `unknown` is the keys with no schema entry (Gutenberg would silently drop
428 // them). `className` is always valid (Gutenberg's universal block-support
429 // attr, not always re-declared per type). When the type isn't registered we
430 // can't partition — return everything as valid (degrade open). Surfacing
431 // `unknown` in the reply lets the brain LEARN that attr X isn't valid for
432 // block Y, instead of inferring success from a silent ignore.
433 function partitionAttrs( name, attrs ) {
434 const valid = {};
435 const unknown = [];
436 if ( ! isPlainObject( attrs ) ) {
437 return { valid, unknown };
438 }
439 const registered = registeredAttrKeysOf( name );
440 Object.keys( attrs ).forEach( function ( k ) {
441 if ( registered === null || k === 'className' || registered.indexOf( k ) !== -1 ) {
442 valid[ k ] = attrs[ k ];
443 } else {
444 unknown.push( k );
445 }
446 } );
447 return { valid, unknown };
448 }
449
450 // Route a generic content/text value onto the block's real rich-text attr(s).
451 // `content`/`text` are universal authoring ALIASES — the model writes one and
452 // we copy it onto whatever rich-text attr the block actually declares (e.g.
453 // spectra/content -> `text`, core/heading -> `content`). After routing, DROP
454 // any consumed alias the block does NOT itself declare, so a `content` the
455 // model sent for a `text`-block isn't later flagged as an unknown attr (it was
456 // applied, just under the real key) — a false-positive that would mis-fire the
457 // unknown_attrs nudge.
458 // Rich-text attrs that hold an ATTRIBUTION rather than the block's body copy, so
459 // a generic `content`/`text` rewrite must never be routed into them (core/quote
460 // and core/pullquote declare `citation` alongside `value`).
461 const ATTRIBUTION_RICH_TEXT_ATTRS = [ 'citation' ];
462
463 function placeContent( name, attributes ) {
464 const a = Object.assign( {}, attributes || {} );
465 const body = typeof a.content === 'string' && a.content !== '' ? a.content
466 : typeof a.text === 'string' && a.text !== '' ? a.text : undefined;
467 if ( body === undefined ) {
468 return a;
469 }
470 const targets = richTextAttrsOf( name );
471 // Route to ONE attr, never every rich-text attr the block declares. A block
472 // with two (core/quote and core/pullquote both declare `value` AND `citation`
473 // as source:html) got the SAME body copied into both, so "rewrite this quote"
474 // silently overwrote the attribution with the quote text — a content loss that
475 // reports clean, on both the insert and update paths.
476 // Prefer the alias the MODEL actually wrote when the block declares it, else
477 // the block's first NON-ATTRIBUTION rich-text attr. For every
478 // single-rich-text-attr block this resolves to exactly what the fan-out
479 // produced, so nothing else moves.
480 const usedAlias = typeof a.content === 'string' && a.content !== '' ? 'content' : 'text';
481 let primary;
482 if ( targets.indexOf( usedAlias ) !== -1 ) {
483 primary = usedAlias;
484 } else {
485 // Taking targets[0] alone would trust REGISTRY ENUMERATION ORDER to put the
486 // body attr ahead of the attribution attr. core/quote and core/pullquote
487 // happen to declare `value` first, so it is right for them — but a block
488 // (third-party, or a future core revision) declaring the attribution first
489 // would route the rewrite body straight into it: the same silent
490 // content-swap this routing exists to prevent, one block type over. Skip
491 // the known attribution attrs explicitly instead of relying on position.
492 for ( let ti = 0; ti < targets.length; ti++ ) {
493 if ( ATTRIBUTION_RICH_TEXT_ATTRS.indexOf( targets[ ti ] ) === -1 ) {
494 primary = targets[ ti ];
495 break;
496 }
497 }
498 // Every rich-text attr IS an attribution attr (no body attr on this block)
499 // — fall back to the first so the write still lands somewhere rather than
500 // being silently dropped.
501 if ( primary === undefined ) {
502 primary = targets[ 0 ];
503 }
504 }
505 if ( primary !== undefined && ( a[ primary ] === undefined || a[ primary ] === '' ) ) {
506 a[ primary ] = body;
507 }
508 [ 'content', 'text' ].forEach( function ( alias ) {
509 if ( targets.indexOf( alias ) === -1 ) {
510 delete a[ alias ];
511 }
512 } );
513 return a;
514 }
515
516 // Plain text the block renders — its rich-text attrs concatenated, tags
517 // stripped. Used for the effectiveness check + the result_text the reply
518 // surfaces so the brain SEES what actually landed.
519 function renderedTextOf( block ) {
520 const attrs = ( block && block.attributes ) || {};
521 let s = '';
522 richTextAttrsOf( block && block.name ).forEach( function ( k ) {
523 // Core rich-text attrs become RichTextData OBJECTS after createBlock
524 // (not strings); String() yields their text. Spectra stores plain
525 // strings. A `typeof === "string"` check misses the core case and
526 // falsely fires assertEffectiveContent's empty_content (live-found).
527 const v = attrs[ k ];
528 if ( v !== null && v !== undefined && v !== '' ) {
529 s += ' ' + String( v );
530 }
531 } );
532 return s.replace( /<[^>]*>/g, '' ).trim();
533 }
534
535 function intendedContent( spec ) {
536 const a = ( spec && spec.attributes ) || {};
537 return ( typeof a.content === 'string' && a.content !== '' ) ||
538 ( typeof a.text === 'string' && a.text !== '' );
539 }
540
541 // A CONTENT rewrite that would DROP inline markup — the "make it 4/5" flatten.
542 // True when the block's CURRENT value carries real inline markup (a tag, not a
543 // bare entity) and the incoming value is PLAIN text. get-context surfaces the
544 // rich `html` precisely so a rewrite re-authors the SAME inline structure; a
545 // plain string instead silently strips the design pattern (the large number's
546 // styling, an emphasis span, per-word colours). PURE so a unit test locks it.
547 const CONTENT_TAG_RE = /<[a-z!/][^>]*>/i;
548 function contentWouldFlatten( oldVal, newVal ) {
549 // Only a PLAIN-text overwrite can flatten. A core RichTextData old value is
550 // an object whose String() yields its HTML — coerce so both block families
551 // are covered; the incoming model value is always a plain JSON string.
552 if ( typeof newVal !== 'string' ) {
553 return false;
554 }
555 const oldStr = ( oldVal === null || oldVal === undefined ) ? '' : String( oldVal );
556 return CONTENT_TAG_RE.test( oldStr ) && ! CONTENT_TAG_RE.test( newVal );
557 }
558
559 // Fail CLOSED before dispatch when an updateBlockAttributes content write would
560 // flatten the block's existing inline markup. The message steers recovery to
561 // the correct path (re-author the rich `html` from get_context, keeping the
562 // inline structure) rather than letting a lossy plain-text write report a false
563 // success. Symmetric with assertEffectiveContent's empty_content guard.
564 function assertNoContentFlatten( block, normAttrs ) {
565 if ( ! block || ! normAttrs ) {
566 return;
567 }
568 const contentKeys = richTextAttrsOf( block.name );
569 const current = block.attributes || {};
570 contentKeys.forEach( function ( key ) {
571 if ( ! Object.prototype.hasOwnProperty.call( normAttrs, key ) ) {
572 return;
573 }
574 if ( contentWouldFlatten( current[ key ], normAttrs[ key ] ) ) {
575 throw new Error(
576 'content_flatten:' + block.name + ':' + key +
577 '. This block\'s content carries inline formatting that a plain-text value would DROP' +
578 ' (the design pattern: styled spans / emphasis). Re-read the block with editor__get_context' +
579 ' and rewrite its `html` (the rich value) with your new words INSIDE the same inline structure,' +
580 ' not a flat string.',
581 );
582 }
583 } );
584 }
585
586 // A spec that ASKED for text content but produced an EMPTY block is a silent
587 // content loss — fail it LOUD (before dispatch) so the brain never reports a
588 // false success on an empty insert/replace. Generalized: it compares INTENT
589 // (the spec carried content) against RESULT (the built block renders no text
590 // and has no children) — never a per-block-type rule.
591 function assertEffectiveContent( specs, blocks ) {
592 ( specs || [] ).forEach( function ( spec, i ) {
593 const blk = blocks[ i ];
594 if ( ! intendedContent( spec ) || ! blk ) {
595 return;
596 }
597 const hasChildren = blk.innerBlocks && blk.innerBlocks.length > 0;
598 if ( renderedTextOf( blk ) === '' && ! hasChildren ) {
599 throw new Error( 'empty_content:' + ( spec.name || 'block' ) );
600 }
601 } );
602 }
603
604 // JSON block-spec → Gutenberg block (recursive). NOT the markup-string path.
605 // Applies ONLY the attrs the block's registry declares (partitionAttrs, the
606 // getBlockType SSOT) and pushes the keys it doesn't into `unknownSink` (when
607 // provided) — recursively across innerBlocks. The brain folds every attr
608 // through with no allowlist; this is where insert/replace decide validity and
609 // report back what they dropped, exactly like the setAttributes path (real
610 // Gutenberg's createBlock already sanitizes undeclared attrs away — silently;
611 // collecting them here is what makes the drop visible to the brain).
612 function toBlock( spec, unknownSink, iconSink ) {
613 // Insert-time block-name gate (SaaS remediation plan E2E-F1,
614 // live-observed 2026-06-11): an UNREGISTERED name must fail the op
615 // typed-and-loud — createBlock would otherwise mint a missing-block
616 // placeholder ("Unsupported block") the user has to delete by hand
617 // (the model invented `core/input`/`core/textarea` and the page
618 // collected junk until read-back). Same feedback philosophy as
619 // unknown attrs: structured, model-actionable, never silent.
620 if ( ! ( window.wp.blocks &&
621 window.wp.blocks.getBlockType &&
622 window.wp.blocks.getBlockType( spec.name ) ) ) {
623 throw new Error(
624 'unregistered_block_type: "' + spec.name + '" is not a registered block on this site — ' +
625 'never invent block names. Use registered blocks (spectra/container, spectra/content, ' +
626 'core/paragraph, …) or embed a real form via { "name": "srfm/form", "attrs": { "id": <formId> } }.'
627 );
628 }
629 const inner = ( spec.innerBlocks || [] ).map( function ( s ) {
630 return toBlock( s, unknownSink, iconSink );
631 } );
632 // S4 (PR #282): strip the 8 GBS-banned visual attrs FIRST, exactly like the
633 // updateBlockAttributes path (normalizePlanAttrs) — so insert/replace can't
634 // paint a banned visual attr into a freshly-minted block even if a stale/
635 // forked brain bundle folds one through. Defense-in-depth behind the brain
636 // blockSpec zod; symmetric with the update path.
637 const parts = partitionAttrs( spec.name, placeContent( spec.name, stripBannedAttrs( spec.attributes ) ) );
638 if ( unknownSink && parts.unknown.length ) {
639 Array.prototype.push.apply( unknownSink, parts.unknown );
640 }
641 // Icon-name resolution — same boundary rule as the update path, so a
642 // freshly-minted block can't be born with an inert icon value either.
643 resolveIconAttrs( parts.valid, spec.name, iconSink );
644 return window.wp.blocks.createBlock( spec.name, parts.valid, inner );
645 }
646
647 // updateBlockAttributes shallow-merges at the TOP key only, so deep-merge
648 // plain-object values (layout, responsiveControls) to keep sibling nested keys.
649 function deepMerge( cur, partial ) {
650 const out = Object.assign( {}, cur );
651 for ( const k in partial ) {
652 if ( ! Object.prototype.hasOwnProperty.call( partial, k ) ) {
653 continue;
654 }
655 const pv = partial[ k ];
656 const cv = cur ? cur[ k ] : undefined;
657 out[ k ] = isPlainObject( pv ) && isPlainObject( cv ) ? deepMerge( cv, pv ) : pv;
658 }
659 return out;
660 }
661
662 // INVARIANT — gs-* identity is immutable on a className write. updateBlockAttributes
663 // replaces className wholesale, so an incoming string keeps the block's CURRENT gs-*
664 // tokens verbatim and contributes only its NON-gs (utility) tokens; foreign gs-* are
665 // dropped. Prevents the Edit-5 clobber (a paragraph's `gs-0a6168-text` overwritten by
666 // a heading's `gs-ae2ba6-h1`) — the executor owns identity, the model owns utilities.
667 function reconcileClassName( currentClassName, incomingClassName ) {
668 const isGs = function ( t ) {
669 return t.indexOf( 'gs-' ) === 0;
670 };
671 const toTokens = function ( s ) {
672 // eslint-disable-next-line eqeqeq
673 return String( s == null ? '' : s ).trim().split( /\s+/ ).filter( Boolean );
674 };
675 const curGs = toTokens( currentClassName ).filter( isGs );
676 const incUtil = toTokens( incomingClassName ).filter( function ( t ) {
677 return ! isGs( t );
678 } );
679 const seen = Object.create( null );
680 return curGs.concat( incUtil ).filter( function ( t ) {
681 if ( seen[ t ] ) {
682 return false;
683 }
684 seen[ t ] = true;
685 return true;
686 } ).join( ' ' );
687 }
688
689 // INVARIANT, INSERT SIDE — the same rule as reconcileClassName above, applied to
690 // blocks being CREATED. "The executor owns identity, the model owns utilities"
691 // held only for updateBlockAttributes, because that path has a CURRENT className
692 // to preserve. An insert has none, so identity was whatever the model typed — and
693 // a `gs-…` token is an opaque hash it has to hand-copy from a get_context read
694 // several steps earlier. Turn f5999cca: both existing buttons were
695 // `pep-button gs-238780-pep-button`; the authored one was `pep-button`. It landed
696 // near-black and pill-shaped beside its navy siblings, because the gs- token IS
697 // the styling and the readable half paints nothing.
698 //
699 // So: adopt identity from the nearest sibling of the SAME block name, walking the
700 // two subtrees in parallel. The model's non-gs (utility) tokens ride along exactly
701 // as they do on an update. No block-type knowledge, no prompt instruction to
702 // remember — a new peer simply cannot land without the design identity its
703 // neighbours carry. Degrades silently: no sibling, no match, or a divergent shape
704 // leaves the block exactly as authored.
705 // (Posture note: this executor-owned identity rewrite is the opposite of
706 // unknown_icons' apply-and-report — deliberate: gs- identity is the
707 // update-path invariant the executor owns; icon values are model content.)
708 function adoptSiblingIdentity( sel, root, blocks ) {
709 if (
710 typeof sel.getBlockOrder !== 'function' ||
711 typeof sel.getBlockName !== 'function' ||
712 typeof sel.getBlock !== 'function'
713 ) {
714 return;
715 }
716 // getBlockOrder + per-id name probe is O(siblings); getBlocks(root)
717 // materialized the ENTIRE destination tree (114-section pages) just to
718 // take the first same-name sibling.
719 // eslint-disable-next-line eqeqeq
720 const order = sel.getBlockOrder( root == null || root === '' ? undefined : root ) || [];
721 blocks.forEach( function ( b ) {
722 const matchId = order.find( function ( id ) {
723 return sel.getBlockName( id ) === b.name;
724 } );
725 if ( matchId ) {
726 adoptFrom( sel.getBlock( matchId ), b );
727 }
728 } );
729 }
730 function hasGsIdentity( block ) {
731 const cls = block && block.attributes && block.attributes.className;
732 return typeof cls === 'string' && /(^|\s)gs-/.test( cls );
733 }
734 function adoptFrom( from, to ) {
735 // Adoption supplies identity a block LACKS. A block that already carries
736 // its own `gs-` token is a DESIGNED artifact — a whole section off the
737 // resolved-markup path, whose persisted GBS rules are keyed to exactly
738 // that token. Overwriting it with a neighbour's (reconcileClassName keeps
739 // the CURRENT gs- and drops the incoming one) made the placed section and
740 // its whole subtree wear the neighbour's styling while its own rules
741 // matched nothing: add a designed section beside an existing
742 // same-type one and it rendered as the neighbour, not as the design the
743 // user chose. Returning here also stops the positional subtree walk,
744 // which is what carried the corruption down into the inner blocks.
745 if ( hasGsIdentity( to ) ) {
746 return;
747 }
748 const fromCls = from.attributes && from.attributes.className;
749 if ( typeof fromCls === 'string' && fromCls.indexOf( 'gs-' ) !== -1 ) {
750 to.attributes = to.attributes || {};
751 to.attributes.className = reconcileClassName( fromCls, to.attributes.className );
752 }
753 const fromKids = from.innerBlocks || [];
754 const toKids = to.innerBlocks || [];
755 for ( let i = 0; i < toKids.length; i++ ) {
756 // Pair by position, then by name — a divergent shape stops the walk.
757 const peer = fromKids[ i ] && fromKids[ i ].name === toKids[ i ].name
758 ? fromKids[ i ]
759 : fromKids.filter( function ( k ) {
760 return k && k.name === toKids[ i ].name;
761 } )[ 0 ];
762 if ( peer ) {
763 adoptFrom( peer, toKids[ i ] );
764 }
765 }
766 }
767
768 // Every listed block must actually be a child of `parent`. A re-order is only
769 // meaningful among SIBLINGS, and Gutenberg does NOT refuse a non-child: with
770 // from === to it takes its same-parent branch and computes
771 // `subState.indexOf(clientIds[0])`, which is -1 for a stranger. The resulting
772 // moveTo(order, -1, i) splices from the END, so the container's LAST child is
773 // displaced while the block the plan actually named never moves — and the op
774 // reports success. Refuse instead, on BOTH order paths: the derived parent
775 // (below) and the caller-supplied one, which had no check at all.
776 function assertOrderSiblings( sel, order, parent ) {
777 ( order || [] ).forEach( function ( id ) {
778 if ( ( sel.getBlockRootClientId( id ) || '' ) !== parent ) {
779 throw new Error(
780 'order_parent_mismatch: block ' + id + ' is not a child of the container being ' +
781 're-ordered, so there is no single container to re-order these in. Re-order only ' +
782 'siblings, or move them one at a time.'
783 );
784 }
785 } );
786 }
787
788 // The parent a bare `order` re-order should happen INSIDE — the parent the listed
789 // blocks already share. '' (the page root) is a legitimate answer for a top-level
790 // re-order. Falls back to '' only if the first id can't be resolved, which
791 // liveness() surfaces separately.
792 function orderParentOf( sel, order ) {
793 const ids = order || [];
794 const first = ids[ 0 ];
795 if ( ! first ) {
796 return '';
797 }
798 // Adopting the first id's parent when the others don't share it would
799 // RELOCATE them into it — the very re-parenting this resolution exists to
800 // prevent. (The brain now requires an explicit parent, so the derived path
801 // is only reachable from an older or hand-built envelope.)
802 const parent = sel.getBlockRootClientId( first ) || '';
803 assertOrderSiblings( sel, ids, parent );
804 return parent;
805 }
806
807 // Realize an explicit child order within `parent` (reorder). Place each id at
808 // its target index in sequence — earlier indices are already correct, so each
809 // move lands the next block at position i.
810 function reorderChildren( sel, dis, parent, order ) {
811 // M2 — the order form previously bypassed EVERY lock check (its targets
812 // ride `order`, not `clientIds`, so the runOp pre-check resolved no
813 // targets). canMoveBlock per child folds in the parent's template/content
814 // locks — a locked container's reorder now fails typed instead of
815 // silently applying.
816 order.forEach( function ( id ) {
817 liveness( sel, id );
818 assertMutable( sel, id, 'move' );
819 } );
820 // Same omitted-vs-null distinction the move path makes (KEEP_CURRENT_PARENT).
821 // Collapsing both to '' re-parented EVERY listed block to the page root, so a
822 // plain "re-order these cards" un-nested the whole row. Explicit null still
823 // means the page root; omitted means "the parent these blocks already share".
824 let root;
825 if ( parent === null ) {
826 root = '';
827 assertOrderSiblings( sel, order, root );
828 } else if ( parent === undefined ) {
829 root = orderParentOf( sel, order );
830 } else {
831 root = parent;
832 // An EXPLICIT parent was trusted blindly, so a plan naming a container
833 // the blocks don't belong to silently mangled that container instead of
834 // failing (see assertOrderSiblings). Same check, same refusal.
835 assertOrderSiblings( sel, order, root );
836 }
837 for ( let i = 0; i < order.length; i++ ) {
838 // Undo coalescing: each move after the first merges into the same
839 // undo level so the whole reorder reverts in one ⌘Z. (The caller
840 // already marked the first dispatch when this isn't the batch's
841 // first change.)
842 if ( i > 0 && typeof dis.__unstableMarkNextChangeAsNotPersistent === 'function' ) {
843 dis.__unstableMarkNextChangeAsNotPersistent();
844 }
845 dis.moveBlocksToPosition( [ order[ i ] ], root, root, i );
846 }
847 }
848
849 // L5 — THE single documented use of a private Gutenberg API in this
850 // codebase. `__unstableMarkNextChangeAsNotPersistent` coalesces undo levels
851 // (one ⌘Z per plan) and keeps typewriter ticks out of the undo stack.
852 // DEGRADATION CONTRACT: it is feature-checked everywhere; if a future
853 // Gutenberg removes/renames it, every call becomes a no-op and behaviour
854 // degrades to MORE undo levels (one per dispatch) — correct, just noisier.
855 // Nothing may ever depend on it for correctness, only for undo ergonomics.
856 function markNonPersistent( dis ) {
857 if ( dis && typeof dis.__unstableMarkNextChangeAsNotPersistent === 'function' ) {
858 dis.__unstableMarkNextChangeAsNotPersistent();
859 }
860 }
861
862 // H4 — saving is LOCKED while a typewriter stream is live. The persistent
863 // full text is applied by runOp BEFORE the stream starts, but the stream
864 // then clears the attr and re-types it word-by-word — so for words×28ms the
865 // LIVE edited attribute is partial. A manual Ctrl+S or the 60s autosave in
866 // that window serializes the truncated text into the post (non-persistent
867 // suppresses the undo level, NOT what getEditedPostContent reads). Lock
868 // via the core/editor store while ANY stream is active; unlock on drain.
869 // Feature-checked: an editor without lockPostSaving keeps today's window
870 // (no false lock, no throw).
871 const TYPEWRITER_SAVE_LOCK = 'zipwp-vibe-typewriter';
872 function lockSavingForTypewriter() {
873 try {
874 const ed = window.wp && window.wp.data && window.wp.data.dispatch( 'core/editor' );
875 if ( ed && typeof ed.lockPostSaving === 'function' ) {
876 ed.lockPostSaving( TYPEWRITER_SAVE_LOCK );
877 }
878 } catch ( e ) { /* never break the apply on a lock failure */ }
879 }
880 function unlockSavingForTypewriter() {
881 try {
882 const ed = window.wp && window.wp.data && window.wp.data.dispatch( 'core/editor' );
883 if ( ed && typeof ed.unlockPostSaving === 'function' ) {
884 ed.unlockPostSaving( TYPEWRITER_SAVE_LOCK );
885 }
886 } catch ( e ) { /* unlock is best-effort; the lock name is idempotent */ }
887 }
888
889 // --- Typewriter (streaming text effect) -------------------------------
890 // ANY text the agent writes — a rewrite (updateBlockAttributes) OR new copy in
891 // an inserted/replaced block — STREAMS in word-by-word instead of snapping.
892 // Ms between words; named single cadence source.
893 const TYPEWRITER_WORD_MS = 28;
894
895 // Animation off when the host can't animate (no matchMedia — e.g. jsdom under
896 // jest) or the user prefers reduced motion. Keeps the persistent full content.
897 function typewriterDisabled() {
898 try {
899 return ! window.matchMedia ||
900 window.matchMedia( '(prefers-reduced-motion: reduce)' ).matches;
901 } catch ( e ) {
902 return true;
903 }
904 }
905
906 // Current rich-text content of a block (the first content attr that HAS text),
907 // '' if none. The op-agnostic text identity we diff before/after an op.
908 function contentOf( blk ) {
909 const cattr = firstRichTextWithContent( blk );
910 return cattr ? String( blk.attributes[ cattr ] ) : '';
911 }
912
913 // Every clientId an op NAMES as an operand — generic id-bearing fields ONLY, no
914 // switch on op.function. The before/after text diff over these (+ newly minted
915 // ids) is what decides what streams, so no operation is ever special-cased.
916 function opOperandIds( op ) {
917 let ids = [];
918 if ( op ) {
919 if ( Array.isArray( op.clientIds ) ) {
920 ids = ids.concat( op.clientIds );
921 }
922 // eslint-disable-next-line eqeqeq
923 if ( op.rootClientId != null ) {
924 ids.push( op.rootClientId );
925 }
926 // eslint-disable-next-line eqeqeq
927 if ( op.clientId != null ) {
928 ids.push( op.clientId );
929 }
930 }
931 return ids;
932 }
933
934 // First rich-text content attr of a block that actually HAS text — used to find
935 // what to stream in a freshly inserted/replaced block.
936 function firstRichTextWithContent( blk ) {
937 const cattrs = richTextAttrsOf( blk.name );
938 for ( let i = 0; i < cattrs.length; i++ ) {
939 const k = cattrs[ i ];
940 const v = ( blk.attributes || {} )[ k ];
941 if ( typeof v === 'string' && v.trim() ) {
942 return k;
943 }
944 }
945 return null;
946 }
947
948 // Active streams + the SINGLE shared ticker that advances them all together, so
949 // a BULK operation (many blocks at once) types CONCURRENTLY off one timer.
950 let _twStreams = [];
951 let _twTimer = null;
952 function _twTick() {
953 let typing = false;
954 for ( let s = 0; s < _twStreams.length; s++ ) {
955 const st = _twStreams[ s ];
956 if ( st.i >= st.tokens.length ) {
957 continue;
958 }
959 // Liveness re-check: the block can vanish mid-stream (a later op in the
960 // same plan removed/replaced it, or the user deleted it). Writing to a
961 // gone block is a wasted dispatch that can warn — drop the stream.
962 if ( st.sel && ! st.sel.getBlock( st.id ) ) {
963 st.i = st.tokens.length; continue;
964 }
965 // H4 — a throw from updateBlockAttributes must NOT escape the tick:
966 // it would skip the drain/unlock below and leave post-saving locked
967 // for the rest of the session (the whole page becomes unsavable).
968 // The persistent full content is already applied by the caller, so a
969 // failed cosmetic type-in is safe to abandon — drop just that stream.
970 try {
971 st.acc += st.tokens[ st.i ];
972 st.i++;
973 markNonPersistent( st.dis ); // VISUAL only — never an undo level
974 const a = {};
975 a[ st.contentAttr ] = st.acc;
976 st.dis.updateBlockAttributes( st.id, a );
977 } catch ( e ) {
978 console.warn( '[ZIP AI] typewriter stream aborted for block', st.id, e ); // eslint-disable-line no-console -- intentional error surfacing
979 st.i = st.tokens.length; // mark done so the drain path runs
980 continue;
981 }
982 if ( st.i < st.tokens.length ) {
983 typing = true;
984 }
985 }
986 if ( typing ) {
987 _twTimer = setTimeout( _twTick, TYPEWRITER_WORD_MS ); return;
988 }
989 _twStreams = [];
990 _twTimer = null;
991 // H4 — every stream drained: the live attrs hold full text again, so
992 // saving is safe. (Unlock here, the ONLY drain point.)
993 unlockSavingForTypewriter();
994 }
995
996 // VISUAL typewriter overlay for block `id`'s content attr. The REAL change (the
997 // full `target` content) is ALREADY applied PERSISTENTLY by the caller; this
998 // only adds a non-persistent type-in, so it NEVER touches undo and works the
999 // same for a rewrite or a freshly-inserted block. Clears the content
1000 // SYNCHRONOUSLY (same frame as the op → no flash of the full text) then types
1001 // it back word-by-word off the shared ticker. No-op when animation is disabled
1002 // or the text is too short (leaves the persistent full content).
1003 function typewriterStream( dis, sel, id, contentAttr, target ) {
1004 if ( typewriterDisabled() ) {
1005 return;
1006 }
1007 // eslint-disable-next-line eqeqeq
1008 const tokens = String( target != null ? target : '' ).split( /(\s+)/ );
1009 if ( tokens.length <= 2 ) {
1010 return;
1011 }
1012 // Same-block dedup: if a stream for this block+attr is already active (an
1013 // earlier op in the plan touched the same block), DROP it — two tickers
1014 // writing one block's content fight and garble. Last write wins.
1015 _twStreams = _twStreams.filter( function ( st ) {
1016 return ! ( st.id === id && st.contentAttr === contentAttr );
1017 } );
1018 // H4 — clear FIRST, then take the save-lock. If markNonPersistent or the
1019 // clear dispatch throws we have NOT locked yet, so the exception
1020 // propagates cleanly with no lock to leak (the earlier version locked
1021 // first, so a throwing clear left post-saving locked for the whole
1022 // session — the exact bug this guards). Once the clear succeeds the live
1023 // attribute is blank; lock on the very next statement (no async gap, so
1024 // no save can interleave) and hold it until _twTick drains + unlocks.
1025 // Idempotent per lock name, so a bulk plan's many streams lock once — and
1026 // if a later stream's clear throws, an already-running ticker still owns
1027 // the lock and releases it on drain.
1028 markNonPersistent( dis );
1029 const clear = {};
1030 clear[ contentAttr ] = '';
1031 dis.updateBlockAttributes( id, clear );
1032 lockSavingForTypewriter();
1033 _twStreams.push( { dis, sel, id, contentAttr, tokens, i: 0, acc: '' } );
1034 if ( ! _twTimer ) {
1035 _twTimer = setTimeout( _twTick, TYPEWRITER_WORD_MS );
1036 }
1037 }
1038
1039 // Snapshot the rich-text content of block subtrees by clientId (BEFORE an op),
1040 // into `out`. Recurses innerBlocks so a container op also captures its children.
1041 function snapshotContent( sel, ids, out ) {
1042 ids.forEach( function ( cid ) {
1043 const root = sel.getBlock( cid );
1044 if ( ! root ) {
1045 return;
1046 }
1047 ( function walk( b ) {
1048 out[ b.clientId ] = contentOf( b );
1049 if ( b.innerBlocks ) {
1050 b.innerBlocks.forEach( walk );
1051 }
1052 }( root ) );
1053 } );
1054 }
1055
1056 // SSOT — the ONLY place streaming is decided, AFTER an op: stream every
1057 // rich-text block (within the op's operand subtrees OR newly minted) whose text
1058 // CHANGED vs the pre-op snapshot, or is NEW (no snapshot). It's a pure text
1059 // diff — op.function is NEVER inspected, so a rewrite, an insert, a replace, or
1060 // any future operation all get the effect identically; a move / remove / style-
1061 // only change leaves the text equal and streams nothing.
1062 function streamChangedText( dis, sel, ids, before ) {
1063 if ( typewriterDisabled() ) {
1064 return;
1065 }
1066 const seen = {};
1067 ids.forEach( function ( cid ) {
1068 const root = sel.getBlock( cid );
1069 if ( ! root ) {
1070 return;
1071 }
1072 ( function walk( b ) {
1073 if ( seen[ b.clientId ] ) {
1074 return;
1075 }
1076 seen[ b.clientId ] = true;
1077 const cattr = firstRichTextWithContent( b );
1078 if ( cattr ) {
1079 const now = String( b.attributes[ cattr ] );
1080 if ( now && now !== ( before[ b.clientId ] || '' ) ) {
1081 typewriterStream( dis, sel, b.clientId, cattr, now );
1082 }
1083 }
1084 if ( b.innerBlocks ) {
1085 b.innerBlocks.forEach( walk );
1086 }
1087 }( root ) );
1088 } );
1089 }
1090
1091 // Apply ONE change. Returns { new_client_ids?, result_text?, deferRebase? }
1092 // or throws a typed Error. `isAnchor` (batch's first applied change) controls
1093 // the streaming-text undo anchor; it's irrelevant to the non-animated kinds.
1094 // Resolve an anchor-relative placement (insert/move "before"/"after" a
1095 // sibling block) into the {parent, index} the dispatch API needs — using the
1096 // LIVE store (getBlockRootClientId + getBlockIndex). This is why the MODEL no
1097 // longer computes parent/index: it just names the neighbour and the layer that
1098 // OWNS the tree resolves the coordinates (closing the parent:null move-to-page-
1099 // root failure). A stale anchor throws the typed stale_client_id miss via
1100 // liveness → failed[]. parent is '' for page root (matches getBlockRootClientId
1101 // + the move toRoot convention). `position` is 'before' | 'after'.
1102 function resolveAnchor( sel, anchorId, position ) {
1103 liveness( sel, anchorId );
1104 return {
1105 parent: sel.getBlockRootClientId( anchorId ) || '',
1106 index: sel.getBlockIndex( anchorId ) + ( position === 'after' ? 1 : 0 ),
1107 };
1108 }
1109
1110 // Pull the anchor (before/after) off a change, if present. before/after are
1111 // mutually exclusive (the brain superRefine enforces it); after wins only if
1112 // both somehow arrive.
1113 function anchorOf( c ) {
1114 if ( c.after !== undefined ) {
1115 return { id: c.after, position: 'after' };
1116 }
1117 if ( c.before !== undefined ) {
1118 return { id: c.before, position: 'before' };
1119 }
1120 return null;
1121 }
1122
1123 // Blast radius of a DESTRUCTIVE op — the reverse edge the editor otherwise
1124 // never computes. Returns the JS on OTHER blocks that targets an anchor inside
1125 // the removed subtree (their `getElementById(deletedAnchor)` goes null and
1126 // THROWS, killing that block's whole spectraCustomJS IIFE — not a clean no-op).
1127 // SURFACED, not blocked: the model relays it to the user, and native undo /
1128 // "don't save" restores the deleted block. Computed BEFORE the dispatch (while
1129 // the blocks still exist) via the shared dependents SSOT.
1130 // The removed blocks' OWN subtree (ids + all descendants), walked via
1131 // getBlockOrder. NOT sel.getClientIdsWithDescendants( clientIds ) — that WP
1132 // selector ignores its argument and returns EVERY page id, so `subtree`
1133 // covered the whole page, anchorDependents excluded everything, and
1134 // orphaned_js was ALWAYS []; the impact warning never fired in production.
1135 function subtreeOf( sel, clientIds ) {
1136 const out = [];
1137 const stack = ( clientIds || [] ).slice();
1138 while ( stack.length ) {
1139 const id = stack.pop();
1140 out.push( id );
1141 const kids = sel.getBlockOrder ? sel.getBlockOrder( id ) : [];
1142 for ( let i = 0; i < kids.length; i++ ) {
1143 stack.push( kids[ i ] );
1144 }
1145 }
1146 return out;
1147 }
1148 function removalImpact( sel, clientIds ) {
1149 const u = sharedEditorUtils();
1150 if ( ! u || ! u.anchorDependents || ! clientIds || ! clientIds.length ) {
1151 return undefined;
1152 }
1153 const subtree = subtreeOf( sel, clientIds );
1154 const anchors = [];
1155 subtree.forEach( function ( id ) {
1156 const a = sel.getBlockAttributes ? sel.getBlockAttributes( id ) : null;
1157 if ( a && typeof a.anchor === 'string' && a.anchor !== '' ) {
1158 anchors.push( a.anchor );
1159 }
1160 } );
1161 if ( ! anchors.length ) {
1162 return undefined;
1163 }
1164 const orphaned = u.anchorDependents( sel, anchors, subtree );
1165 return orphaned.length ? { orphaned_js: orphaned } : undefined;
1166 }
1167
1168 // --- Computed-effect verification: DELETED 2026-08-21 ------------------
1169 // It asked "did the PAINTED style move?" and treated the answer as a verdict
1170 // on whether the user's request was met. Those are different questions, and
1171 // they diverge exactly when the property ALREADY held the requested value —
1172 // "make this bold" on font-weight:700 text measures as unchanged and was
1173 // reported as a failure. See the note at the former phase-1/2 site below.
1174 // canvasCtx survives: phantom-animation detection still reads the canvas.
1175 function canvasCtx() {
1176 const iframe = document.querySelector( 'iframe[name="editor-canvas"]' );
1177 return iframe && iframe.contentDocument
1178 ? { doc: iframe.contentDocument, win: iframe.contentWindow }
1179 : { doc: document, win: window };
1180 }
1181 // ── Phantom-animation detection (the live-found `animate-bounce` no-op) ─────
1182 // An `animate-<ident>` utility compiles to a `.animate-<ident>{ animation:
1183 // <ident> … }` RULE even when NO `@keyframes <ident>` is registered on the
1184 // site (the JIT's animate-<ident> fallback + a Tailwind default like `bounce`
1185 // that the GBS system never defines). So the class "applies" and
1186 // classNameHasLiveUtility sees a rule — but with the keyframes missing the
1187 // browser paints NOTHING. That is why a bundled `border-red-500 animate-bounce`
1188 // reported applied (the border moved computed style) while the bounce silently
1189 // did nothing. We detect it from the RESOLVED cascade, not the token: read the
1190 // element's computed `animation-name` and flag any name (≠ `none`) that has no
1191 // matching `@keyframes` rule. Resolved-name based, so it's blind to how the
1192 // class maps to a keyframe (preset vs fallback) and never false-flags a
1193 // preset whose keyframes DO exist.
1194
1195 // Does the canvas define `@keyframes <ident>`? Read via the CSSOM
1196 // (CSSKeyframesRule — rule type 7 — carries a `.name`), walking @media groups,
1197 // and skipping unreadable (cross-origin) sheets. Pure over (doc, ident).
1198 function hasKeyframesRule( doc, ident ) {
1199 if ( ! doc || ! ident ) {
1200 return false;
1201 }
1202 function walk( rules ) {
1203 for ( let i = 0; i < rules.length; i++ ) {
1204 const r = rules[ i ];
1205 // CSSRule.KEYFRAMES_RULE === 7.
1206 if ( r && r.type === 7 && r.name === ident ) {
1207 return true;
1208 }
1209 // Recurse into ANY grouping rule that carries child rules — @media
1210 // (4) and @supports (12) plus @layer / @container, whose legacy
1211 // numeric `type` is 0 and would otherwise be skipped, hiding
1212 // keyframes nested inside them. (Skip type 7 — its children are the
1213 // keyframe steps, already handled by the name check above.)
1214 if ( r && r.type !== 7 && r.cssRules && walk( r.cssRules ) ) {
1215 return true;
1216 }
1217 }
1218 return false;
1219 }
1220 // Scan both regular sheets and constructable adoptedStyleSheets (Gutenberg
1221 // / Spectra inject some canvas CSS this way; doc.styleSheets omits them).
1222 const styleSheets = ( doc && doc.styleSheets ) || [];
1223 const adopted = ( doc && doc.adoptedStyleSheets ) || [];
1224 const sheets = [];
1225 for ( let i = 0; i < styleSheets.length; i++ ) {
1226 sheets.push( styleSheets[ i ] );
1227 }
1228 for ( let i = 0; i < adopted.length; i++ ) {
1229 sheets.push( adopted[ i ] );
1230 }
1231 for ( let s = 0; s < sheets.length; s++ ) {
1232 let rules;
1233 try {
1234 rules = sheets[ s ].cssRules || sheets[ s ].rules;
1235 } catch ( e ) {
1236 continue;
1237 } // cross-origin
1238 if ( rules && walk( rules ) ) {
1239 return true;
1240 }
1241 }
1242 return false;
1243 }
1244
1245 // Names in a computed `animation-name` value that have NO `@keyframes` rule →
1246 // phantom motion (paints nothing). Pure over (animationNameValue, doc) so a
1247 // unit test locks it. `none` and empty are ignored; duplicates collapse.
1248 function missingKeyframeNames( animationNameValue, doc ) {
1249 if ( typeof animationNameValue !== 'string' ) {
1250 return [];
1251 }
1252 const names = animationNameValue.split( ',' )
1253 .map( function ( n ) {
1254 return n.trim();
1255 } )
1256 .filter( function ( n ) {
1257 return n && n !== 'none';
1258 } );
1259 const missing = [];
1260 for ( let i = 0; i < names.length; i++ ) {
1261 if ( missing.indexOf( names[ i ] ) === -1 && ! hasKeyframesRule( doc, names[ i ] ) ) {
1262 missing.push( names[ i ] );
1263 }
1264 }
1265 return missing;
1266 }
1267
1268 // The phantom animation names actually resolved on a block (its computed
1269 // animation-name minus any that have keyframes). '' / unreadable node → [].
1270 function phantomAnimationsOnBlock( clientId ) {
1271 const ctx = canvasCtx();
1272 const el = ctx.doc.querySelector( '[data-block="' + clientId + '"]' );
1273 if ( ! el ) {
1274 return [];
1275 }
1276 const cs = ctx.win.getComputedStyle( el );
1277 return missingKeyframeNames( cs.getPropertyValue( 'animation-name' ), ctx.doc );
1278 }
1279
1280 // boostUtilitySpecificity + utilityRuleBody REMOVED — their own stated
1281 // REMOVAL CONDITION ("once editor-side utilities reach parity with the
1282 // server JIT") is met by ensureLiveUtilityCss below, which injects the
1283 // server compiler's OWN output (×5 base + media-wrapped responsive
1284 // variants) for every token a plan touches. The boost was also the root
1285 // cause of the stacked-grid-until-reload bug: it re-emitted BASE tokens
1286 // (e.g. grid-cols-1) at ×5 specificity WITHOUT their media context, so a
1287 // boosted base beat its md:/lg: variant siblings at every viewport width
1288 // until a reload dropped the session-scoped boost element. Any lingering
1289 // `zipwp-gbs-editor-boost` element from an older session dies on reload.
1290
1291 // ── LIVE-JIT for tokens the canvas has never seen ─────────────────────────
1292 // ROOT CAUSE this closes: the per-post JIT stylesheet is compiled from SAVED
1293 // content at editor load (spectra-blocks Engine::enqueue_jit_for_current_post
1294 // → JitCache::get_for_post). Blocks inserted programmatically AFTER load
1295 // (Vibe Editing section inserts) can carry utility tokens with NO rule
1296 // anywhere in the canvas — they render unstyled (grids stack) until the next
1297 // save + reload. Fix at the source of truth: ask Spectra's OWN compiler
1298 // (REST /spectra-blocks/v1/global-styles/jit-compile — the same JitCompiler
1299 // that runs at save) for the missing tokens' CSS and inject it into the
1300 // canvas BEFORE the settle/no-op check reads computed styles, so the live
1301 // preview and the verification both converge to saved-page truth instantly.
1302 // Session-deduped per token; network-tolerant (the rpc reply must never
1303 // hang on this — hard 1500ms cap, failure = canvas converges on save).
1304 const _liveJitRequested = {}; // token → 1 (session-scoped request dedupe)
1305 function collectPlanClassNames( ops ) {
1306 const out = [];
1307 function fromBlocks( blocks ) {
1308 if ( ! blocks ) {
1309 return;
1310 }
1311 for ( let b = 0; b < blocks.length; b++ ) {
1312 const blk = blocks[ b ];
1313 if ( ! blk || typeof blk !== 'object' ) {
1314 continue;
1315 }
1316 const cn = blk.attributes && blk.attributes.className;
1317 if ( typeof cn === 'string' && cn.trim() !== '' ) {
1318 out.push( cn );
1319 }
1320 fromBlocks( blk.innerBlocks );
1321 }
1322 }
1323 for ( let i = 0; i < ops.length; i++ ) {
1324 const op = ops[ i ] || {};
1325 if ( op.attributes && typeof op.attributes.className === 'string' ) {
1326 out.push( op.attributes.className );
1327 }
1328 fromBlocks( op.blocks );
1329 }
1330 return out;
1331 }
1332 function ensureLiveUtilityCss( classNames ) {
1333 try {
1334 const ctx = canvasCtx();
1335 // Compile EVERY touched token (session-deduped) — not only the ones
1336 // with no live rule. The live/static sheets carry tokens at MIXED
1337 // specificities (static 0,2,0; persisted dynamic ×5), and partial
1338 // injection breaks the responsive cascade: a base token already
1339 // present at ×5 (e.g. grid-cols-1) beats a freshly-injected
1340 // md:/lg: variant unless the variant arrives at the SAME server
1341 // parity alongside it. Server output is the SSOT cascade — inject
1342 // it wholesale.
1343 const missing = [];
1344 for ( let c = 0; c < classNames.length; c++ ) {
1345 const toks = String( classNames[ c ] ).trim().split( /\s+/ );
1346 for ( let t = 0; t < toks.length; t++ ) {
1347 const tok = toks[ t ];
1348 if ( ! tok || tok.indexOf( 'gs-' ) === 0 || _liveJitRequested[ tok ] ) {
1349 continue;
1350 }
1351 _liveJitRequested[ tok ] = 1;
1352 missing.push( tok );
1353 }
1354 }
1355 if ( ! missing.length ) {
1356 return Promise.resolve();
1357 }
1358 const apiFetch = window.wp && window.wp.apiFetch;
1359 if ( ! apiFetch ) {
1360 return Promise.resolve();
1361 }
1362 const fetchP = apiFetch( {
1363 path: '/spectra-blocks/v1/global-styles/jit-compile',
1364 method: 'POST',
1365 data: { class_strings: [ missing.join( ' ' ) ] },
1366 } ).then( function ( res ) {
1367 const css = res && typeof res.css === 'string' ? res.css : '';
1368 if ( ! css ) {
1369 return;
1370 }
1371 let styleEl = ctx.doc.getElementById( 'zipwp-gbs-live-jit' );
1372 if ( ! styleEl ) {
1373 styleEl = ctx.doc.createElement( 'style' );
1374 styleEl.id = 'zipwp-gbs-live-jit';
1375 ( ctx.doc.head || ctx.doc.documentElement ).appendChild( styleEl );
1376 }
1377 styleEl.textContent = ( styleEl.textContent || '' ) + '\n' + css;
1378 } ).catch( function () {
1379 // Un-mark on FAILURE so a later plan retries. Marking at collection
1380 // time is what dedupes a burst inside one plan, but keeping the mark
1381 // after a failed request meant one transient REST error (or a site
1382 // whose spectra-blocks predates the jit-compile route, where EVERY
1383 // request 404s) left those tokens uncompiled for the whole editor
1384 // session. Two user-visible consequences, not one: the section renders
1385 // unstyled until save+reload, AND classNameHasLiveUtility then reads a
1386 // correctly-applied className as a no-op — so the paint check reports a
1387 // false no-op and the brain nudges the model to "fix" working styling.
1388 // Not un-marked on the 1500ms cap: that request is still in flight and
1389 // will inject when it lands, so a retry there would just duplicate it.
1390 for ( let m = 0; m < missing.length; m++ ) {
1391 delete _liveJitRequested[ missing[ m ] ];
1392 }
1393 } );
1394 const capP = new Promise( function ( resolve ) {
1395 setTimeout( resolve, 1500 );
1396 } );
1397 return Promise.race( [ fetchP, capP ] );
1398 } catch ( e ) {
1399 return Promise.resolve();
1400 }
1401 }
1402
1403 // ── Persist a generated section's GBS class bodies (DEFERRED to Save) ─────
1404 // A generate_section insert carries `args.styles` (section-scoped schema-v1
1405 // buckets: classes/wrapperStyles/mediaQuery) authored with the site's SEMANTIC
1406 // tokens (var(--primary)/var(--heading)/…). ensureLiveUtilityCss above cannot
1407 // help — it SKIPS gs-* tokens because the JIT only compiles the utility ramp.
1408 // These custom class bodies do NOT ride the block className, so they need the
1409 // page GBS store — but writing it NOW would persist to the DB before the user
1410 // Saves (breaking the "nothing hits the DB until Save" contract and orphaning
1411 // CSS if they discard). So we QUEUE it (shared.queueSectionGbsForSave): it
1412 // RENDERS + injects the CSS for a live PREVIEW immediately (a pure compile — no
1413 // DB write) and flushes the payload to the store only when the editor completes
1414 // a real Save. Best-effort: a preview failure logs and the section converges on
1415 // Save; it NEVER fails the insert.
1416 function persistSectionStyles( styles, postId ) {
1417 try {
1418 const apiFetch = window.wp && window.wp.apiFetch;
1419 const u = sharedEditorUtils();
1420 if ( ! apiFetch || ! u || ! u.queueSectionGbsForSave ) {
1421 return Promise.resolve();
1422 }
1423 const persistP = u.queueSectionGbsForSave( apiFetch, styles, postId ).catch( function ( e ) {
1424 // eslint-disable-next-line no-console -- developer signal; the section still inserts + persists on Save
1425 console.warn( '[ZIP AI:apply-change] section GBS preview failed — section may render unstyled until Save', e );
1426 } );
1427 // The preview render folds into the reply-blocking liveCssReady below. A
1428 // REST *rejection* is caught above, but a stalled/hung socket never
1429 // settles, which would hold the apply_change reply until the brain's RPC
1430 // timeout (the false-timeout -> duplicate-content failure the settle
1431 // tuning avoids). Cap it the same way ensureLiveUtilityCss caps its fetch
1432 // so the reply always fires; the paint still converges on Save.
1433 const capP = new Promise( function ( resolve ) {
1434 setTimeout( resolve, 1500 );
1435 } );
1436 return Promise.race( [ persistP, capP ] );
1437 } catch ( e ) {
1438 return Promise.resolve();
1439 }
1440 }
1441
1442 // ── Serialized Gutenberg execution plan ({ version, operations[] }). One
1443 // operation = ONE real wp.data.dispatch('core/block-editor') function + named
1444 // args, keyed on op.function. Each op reuses the shared guards
1445 // (createBlock/placeContent/partitionAttrs/stripBannedAttrs/resolveAnchor/
1446 // liveness/deepMerge). The brain zod already coerced grid + ran the GBS
1447 // denylist; we re-run them (defense-in-depth) so a direct/forked caller can't
1448 // crash the canvas or paint banned props.
1449
1450 function coerceGridLayoutLengthAttrs( attrs ) {
1451 if ( ! isPlainObject( attrs ) ) {
1452 return attrs;
1453 }
1454 function coerce( layout ) {
1455 if ( ! isPlainObject( layout ) || typeof layout.minimumColumnWidth !== 'number' ) {
1456 return layout;
1457 }
1458 const c = {};
1459 for ( const k in layout ) {
1460 if ( Object.prototype.hasOwnProperty.call( layout, k ) ) {
1461 c[ k ] = layout[ k ];
1462 }
1463 }
1464 c.minimumColumnWidth = layout.minimumColumnWidth + 'px';
1465 return c;
1466 }
1467 const out = {};
1468 for ( const k in attrs ) {
1469 if ( Object.prototype.hasOwnProperty.call( attrs, k ) ) {
1470 out[ k ] = attrs[ k ];
1471 }
1472 }
1473 if ( 'layout' in out ) {
1474 out.layout = coerce( out.layout );
1475 }
1476 if ( isPlainObject( out.responsiveControls ) ) {
1477 const rc = {};
1478 for ( const bp in out.responsiveControls ) {
1479 if ( ! Object.prototype.hasOwnProperty.call( out.responsiveControls, bp ) ) {
1480 continue;
1481 }
1482 const v = out.responsiveControls[ bp ];
1483 rc[ bp ] = ( isPlainObject( v ) && 'layout' in v )
1484 ? Object.assign( {}, v, { layout: coerce( v.layout ) } )
1485 : v;
1486 }
1487 out.responsiveControls = rc;
1488 }
1489 return out;
1490 }
1491
1492 // Image-attribute routing: a source tool (find-assets / import-media /
1493 // ai_generate_image) yields { id, url } and the agent sets a UNIFORM
1494 // { url, id, alt } on the target block. Map that to the block's REAL image
1495 // attribute so it lands regardless of block type — most importantly a Spectra
1496 // container BACKGROUND, which lives in `background:{ type:'image', media:{
1497 // id, url, type:'image' } }` (NOT a bare `url`, and NOT the `overlay*` keys —
1498 // those are a separate tint ON TOP of the background). This mirrors exactly
1499 // what Spectra's own Background control writes (components/background onSelect).
1500 // Keyed off the block's REGISTERED schema (not a hardcoded name): a native
1501 // image block (has `url`) keeps {url,id,alt}; a block exposing a `background`
1502 // object gets the background-media shape. deepMerge at apply time preserves
1503 // the container's other background keys (position/size/repeat). Conservative:
1504 // only fires on a bare image url/id with no agent-supplied `background` object.
1505 // Pull an { id, url } image ref out of a candidate object (any of the
1506 // url/id-bearing shapes weak models emit).
1507 function pickImageRef( o ) {
1508 if ( ! isPlainObject( o ) ) {
1509 return null;
1510 }
1511 const url = typeof o.url === 'string' && o.url !== '' ? o.url : undefined;
1512 const id = typeof o.id === 'number'
1513 ? o.id
1514 : ( typeof o.id === 'string' && o.id !== '' ? o.id : undefined );
1515 return url !== undefined || id !== undefined ? { url, id } : null;
1516 }
1517
1518 // Find the image the agent intends, from EITHER a bare { url, id } (the
1519 // contract) OR a mis-built `background` object — models routinely invent the
1520 // old UAGB shape (`background.backgroundImageDesktop`) or guess `media`/`image`
1521 // sub-keys. We extract the ref from any of them so the SHAPE the model used
1522 // can't break the write. Overlay keys are intentionally NOT consulted — an
1523 // overlay is a separate tint, not the background.
1524 function extractImageRef( attrs ) {
1525 const bare = pickImageRef( attrs );
1526 if ( bare ) {
1527 return bare;
1528 }
1529 const bg = attrs.background;
1530 if ( isPlainObject( bg ) ) {
1531 const keys = [ 'media', 'backgroundImageDesktop', 'image', 'imageDesktop', 'backgroundImage' ];
1532 for ( let i = 0; i < keys.length; i++ ) {
1533 const ref = pickImageRef( bg[ keys[ i ] ] );
1534 if ( ref ) {
1535 return ref;
1536 }
1537 }
1538 }
1539 return null;
1540 }
1541
1542 function normalizeImageAttrs( name, attrs, currentAttrs ) {
1543 if ( ! isPlainObject( attrs ) ) {
1544 return attrs;
1545 }
1546 const registered = registeredAttrKeysOf( name );
1547 if ( registered === null ) {
1548 return attrs;
1549 } // unknown block — degrade open
1550 // Native image block (core/image, spectra/image) takes {url,id,alt} as-is.
1551 if ( registered.indexOf( 'url' ) !== -1 ) {
1552 return attrs;
1553 }
1554 // Background-capable container (Spectra): the bg image lives in
1555 // background.{type:'image', media:{id,url,type:'image'}} — mirror exactly
1556 // what Spectra's Background control writes. We REBUILD it from the
1557 // extracted ref (ignoring the model's invented sub-keys), so any input
1558 // shape lands. deepMerge at apply preserves the block's other background
1559 // keys (position/size/repeat).
1560 if ( registered.indexOf( 'background' ) === -1 ) {
1561 return attrs;
1562 }
1563 const ref = extractImageRef( attrs );
1564 if ( ref === null ) {
1565 return attrs;
1566 } // no image intent (pure style/overlay edit)
1567 const out = {};
1568 Object.keys( attrs ).forEach( function ( k ) {
1569 if ( k !== 'url' && k !== 'id' && k !== 'alt' && k !== 'background' ) {
1570 out[ k ] = attrs[ k ];
1571 }
1572 } );
1573 function freshMedia() {
1574 const m = { type: 'image' };
1575 if ( ref.id !== undefined ) {
1576 m.id = ref.id;
1577 }
1578 if ( ref.url !== undefined ) {
1579 m.url = ref.url;
1580 }
1581 return m;
1582 }
1583 out.background = { type: 'image', media: freshMedia() };
1584 // A Spectra container ALSO holds per-breakpoint background overrides under
1585 // responsiveControls.{lg,md,sm}.background. The renderer reads the active
1586 // device's override FIRST, so a base-only update leaves the old image
1587 // showing at any breakpoint that has its own image background. Mirror the
1588 // new image into every responsive breakpoint that CURRENTLY has an image
1589 // background (don't create one where there isn't, and don't touch a
1590 // color/gradient/video override). deepMerge at apply preserves each
1591 // breakpoint's other keys (layout/height).
1592 const rc = currentAttrs && currentAttrs.responsiveControls;
1593 if ( isPlainObject( rc ) ) {
1594 // Merge the mirror ON TOP of any responsiveControls the agent set in
1595 // THIS op (out already carries it from the copy above) — replacing it
1596 // wholesale would drop a same-op RC edit (e.g. responsiveControls.md.layout).
1597 const rcOut = isPlainObject( out.responsiveControls ) ? Object.assign( {}, out.responsiveControls ) : {};
1598 let mirrored = false;
1599 Object.keys( rc ).forEach( function ( device ) {
1600 const dbg = rc[ device ] && rc[ device ].background;
1601 if ( isPlainObject( dbg ) && dbg.type === 'image' ) {
1602 // Per-device shallow merge: keep the agent's other per-device
1603 // keys (layout/height) for this breakpoint, swap the background.
1604 rcOut[ device ] = Object.assign( {}, rcOut[ device ], {
1605 background: { type: 'image', media: freshMedia() },
1606 } );
1607 mirrored = true;
1608 }
1609 } );
1610 if ( mirrored ) {
1611 out.responsiveControls = rcOut;
1612 }
1613 }
1614 return out;
1615 }
1616
1617 // ── Icon-name resolution (the write boundary) ────────────────────────
1618 // Icon-valued attributes (`icon`, `hoverIcon`, any block) render via a
1619 // DICTIONARY lookup (spectra_blocks_info.uagb_svg_icons — the Font Awesome
1620 // free set keyed by bare slug) but the attribute TYPE is an open string,
1621 // so ANY value "applies". A FontAwesome CLASS string
1622 // ('fa-solid fa-arrow-right') — the grammar the authored-HTML section path
1623 // legitimately uses — therefore applies cleanly and renders NOTHING
1624 // (RenderSVG's lookup misses and returns null). Live false positive, turn
1625 // a226671f: "Added an arrow icon…", canvas unchanged.
1626 // Resolve at the ONE attrs boundary, symmetric with reconcileClassName:
1627 // exact library key → FA5 polyfill → the class string's last token minus
1628 // its fa- prefix. Resolvable → the value is REWRITTEN to the library key
1629 // (the write then paints). Unresolvable → the write still applies and the
1630 // name is reported as `unknown_icons` — a registry FACT, same family as
1631 // unknown_attrs; the model decides. Raw SVG strings ('<svg…'), uploaded-
1632 // icon OBJECTS, and empty strings (icon clear) pass through untouched; a
1633 // missing icon registry (non-editor context, jest) skips resolution
1634 // entirely — this never blocks or fails a write.
1635 // Icon-valued attrs are read off the block REGISTRY, never a hand list:
1636 // every icon attr in the library declares type ["string","object"] with an
1637 // icon-ish key (18 attrs / 14 blocks). No registry → no resolution.
1638 function iconAttrKeysOf( blockName ) {
1639 const bt = window.wp && window.wp.blocks && window.wp.blocks.getBlockType &&
1640 window.wp.blocks.getBlockType( blockName );
1641 const attrs = bt && bt.attributes;
1642 if ( ! attrs ) {
1643 return [];
1644 }
1645 return Object.keys( attrs ).filter( function ( k ) {
1646 const t = attrs[ k ] && attrs[ k ].type;
1647 return Array.isArray( t ) && t.indexOf( 'string' ) !== -1 &&
1648 t.indexOf( 'object' ) !== -1 && /icon/i.test( k );
1649 } );
1650 }
1651 function resolveIconName( value ) {
1652 const info = typeof window !== 'undefined' ? window.spectra_blocks_info : null;
1653 const lib = info && info.uagb_svg_icons;
1654 if ( ! lib || typeof value !== 'string' || ! value || value.indexOf( '<svg' ) !== -1 ) {
1655 return null; // not a resolvable name write — leave untouched
1656 }
1657 if ( lib[ value ] ) {
1658 return value;
1659 }
1660 const poly = info.font_awesome_5_polyfill || {};
1661 if ( poly[ value ] && lib[ poly[ value ] ] ) {
1662 return poly[ value ];
1663 }
1664 const tokens = value.trim().split( /\s+/ );
1665 const last = tokens[ tokens.length - 1 ].replace( /^fa-/, '' );
1666 if ( last !== value ) {
1667 if ( lib[ last ] ) {
1668 return last;
1669 }
1670 if ( poly[ last ] && lib[ poly[ last ] ] ) {
1671 return poly[ last ];
1672 }
1673 }
1674 return ''; // a name the library does not contain
1675 }
1676 function resolveIconAttrs( attrs, blockName, iconSink ) {
1677 const iconKeys = iconAttrKeysOf( blockName );
1678 for ( let i = 0; i < iconKeys.length; i++ ) {
1679 const key = iconKeys[ i ];
1680 if ( ! Object.prototype.hasOwnProperty.call( attrs, key ) ) {
1681 continue;
1682 }
1683 const resolved = resolveIconName( attrs[ key ] );
1684 if ( resolved === null ) {
1685 continue;
1686 }
1687 if ( resolved === '' ) {
1688 if ( iconSink ) {
1689 iconSink.push( { block: blockName, attr: key, value: attrs[ key ] } );
1690 }
1691 } else if ( resolved !== attrs[ key ] ) {
1692 attrs[ key ] = resolved;
1693 }
1694 }
1695 return attrs;
1696 }
1697
1698 // Normalize a bare attributes object for updateBlockAttributes: GBS strip +
1699 // grid coerce + content routing + image routing + registry partition
1700 // (collects unknown_attrs) + icon-name resolution (collects unknown_icons).
1701 // `currentAttrs` (the live block's attributes) lets image routing mirror a
1702 // container background into its responsive overrides.
1703 function normalizePlanAttrs( name, attributes, sink, currentAttrs, iconSink ) {
1704 let a = stripBannedAttrs( attributes || {} );
1705 a = coerceGridLayoutLengthAttrs( a );
1706 a = placeContent( name, a );
1707 a = normalizeImageAttrs( name, a, currentAttrs );
1708 const parts = partitionAttrs( name, a );
1709 if ( sink ) {
1710 Array.prototype.push.apply( sink, parts.unknown );
1711 }
1712 return resolveIconAttrs( parts.valid, name, iconSink );
1713 }
1714
1715 // Sentinel destination meaning "keep each target in its OWN current parent"
1716 // (a pure re-order). Used when the plan carried an `index` but no explicit
1717 // `toRootClientId`: '' is the PAGE ROOT in Gutenberg, so defaulting an omitted
1718 // destination to '' silently RELOCATED a nested block out to the top level.
1719 // The brain now rejects that plan shape outright; this keeps an older or
1720 // hand-built envelope from doing damage here.
1721 const KEEP_CURRENT_PARENT = { __keepCurrentParent: true };
1722
1723 // Move targets grouped by source root (multi-parent safe; preserves input order
1724 // at the destination by advancing insertAt per group). Shared shape with the
1725 // 5-kind move case. `toRoot` may be KEEP_CURRENT_PARENT, in which case each
1726 // group moves within its own parent.
1727 function moveTargetsGrouped( sel, dis, targets, toRoot, insertAt ) {
1728 const groups = {};
1729 const groupOrder = [];
1730 ( targets || [] ).forEach( function ( id ) {
1731 const fromRoot = sel.getBlockRootClientId( id ) || '';
1732 if ( ! Object.prototype.hasOwnProperty.call( groups, fromRoot ) ) {
1733 groups[ fromRoot ] = [];
1734 groupOrder.push( fromRoot );
1735 }
1736 groups[ fromRoot ].push( id );
1737 } );
1738 let moveIdx = 0;
1739 groupOrder.forEach( function ( fromRoot ) {
1740 if ( moveIdx > 0 && typeof dis.__unstableMarkNextChangeAsNotPersistent === 'function' ) {
1741 dis.__unstableMarkNextChangeAsNotPersistent();
1742 }
1743 moveIdx++;
1744 const grp = groups[ fromRoot ];
1745 const dest = toRoot === KEEP_CURRENT_PARENT ? fromRoot : toRoot;
1746 dis.moveBlocksToPosition( grp, fromRoot, dest, insertAt );
1747 // Advance ONLY when every group lands in the same container. The
1748 // accumulator exists to preserve input order at a SHARED destination;
1749 // under KEEP_CURRENT_PARENT each group lands in its own parent, so
1750 // advancing measures group 2's index against a container group 1 never
1751 // touched ({clientIds:['a','b'], index:0} across two parents put `b` at
1752 // index 1 of its own parent instead of the requested 0). An OMITTED
1753 // index must also stay omitted — `undefined + n` is NaN, which
1754 // dispatches a garbage position for every group after the first (that
1755 // half predates the sentinel and hit any multi-parent move that carried
1756 // no explicit index).
1757 if ( toRoot !== KEEP_CURRENT_PARENT && insertAt !== undefined ) {
1758 insertAt += grp.length;
1759 }
1760 } );
1761 }
1762
1763 // Parse an op's AUTHORITATIVE resolved section markup, if it carries any.
1764 //
1765 // `section_markup` is present when the brain materialized the section against
1766 // the live site — real SureForms/SureDonation ids and re-hosted image urls. The
1767 // `op.blocks` array is the PRE-resolution snapshot (formId:0, remote urls) and
1768 // is stale once that happened, so whenever markup is present it WINS.
1769 //
1770 // Shared by insertBlocks, replaceBlocks and replaceInnerBlocks. It used to be
1771 // inline in the insert case only, which is why placing a form-bearing section
1772 // as a REVAMP shipped an unconfigured placeholder form while the identical
1773 // section ADDED came out working.
1774 //
1775 // Returns an array of blocks, or undefined to fall back to the op.blocks path.
1776 // Throws only for an unregistered block type — a real, typed op failure.
1777 function parseResolvedSectionMarkup( op ) {
1778 const markup = typeof op.section_markup === 'string' ? op.section_markup.trim() : '';
1779 if ( markup === '' || ! window.wp.blocks || typeof window.wp.blocks.parse !== 'function' ) {
1780 return undefined;
1781 }
1782 let blocks;
1783 // A parse throw is very unlikely (wp.blocks.parse is tolerant), but if it
1784 // does, DON'T fail the op — fall back to the op.blocks path.
1785 try {
1786 blocks = window.wp.blocks.parse( markup ).filter( function ( b ) {
1787 return b && b.name;
1788 } );
1789 } catch ( e ) {
1790 return undefined;
1791 }
1792 if ( ! Array.isArray( blocks ) || ! blocks.length ) {
1793 return undefined;
1794 }
1795 // F1: wp.blocks.parse maps an UNREGISTERED name to core/missing (which HAS a
1796 // name, so it survived the filter) — the unregistered_block_type gate toBlock()
1797 // enforces on the block path is bypassed here. Fail typed-and-loud instead of
1798 // planting an "Unsupported block" placeholder. Outside the try so it is a real
1799 // op failure, not swallowed into the fallback.
1800 const missing = blocks.filter( function ( b ) {
1801 return b.name === 'core/missing';
1802 } );
1803 if ( missing.length ) {
1804 const orig = missing[ 0 ].attributes && missing[ 0 ].attributes.originalName;
1805 throw new Error(
1806 'unregistered_block_type: "' + ( orig || 'unknown' ) +
1807 '" is not registered on this site — the section cannot be placed.'
1808 );
1809 }
1810 // F2: the markup path bypasses toBlock → stripBannedAttrs. Reapply it here
1811 // (symmetric with the block path, S4/PR#282) so a spectra/* block in resolved
1812 // markup can't paint a GBS-banned visual attr onto the page + GBS store.
1813 blocks.forEach( stripBannedAttrsDeep );
1814 return blocks;
1815 }
1816
1817 // Apply ONE operation. Returns { new_client_ids?, result_text?, unknown_attrs? }
1818 // or throws a typed Error (→ failed[]). The allowlist gate (defense-in-depth;
1819 // the brain zod already gated) is the default case.
1820 function runOp( sel, dis, op ) {
1821 // Block-safety pre-check for mutating ops (locked / synced / template).
1822 const mutKind = MUTATION_KIND[ op.function ];
1823 if ( mutKind ) {
1824 // eslint-disable-next-line eqeqeq
1825 const mutTargets = op.clientIds || ( op.rootClientId != null ? [ op.rootClientId ] : [] );
1826 mutTargets.forEach( function ( id ) {
1827 assertMutable( sel, id, mutKind );
1828 } );
1829 }
1830 switch ( op.function ) {
1831 case 'updateBlockAttributes': {
1832 const ids = op.clientIds || [];
1833 ids.forEach( function ( id ) {
1834 liveness( sel, id );
1835 } );
1836 const sinkU = [];
1837 const iconSinkU = [];
1838 // Normalize + fail-closed pre-checks for ALL targets BEFORE any
1839 // dispatch, so a rejected write (e.g. a content flatten) fails the
1840 // whole op with NO partial apply — the model gets a clean typed
1841 // error, not a half-applied bulk edit.
1842 const planned = ids.map( function ( id ) {
1843 const blk = sel.getBlock( id );
1844 const norm = normalizePlanAttrs( blk.name, op.attributes, sinkU, blk.attributes, iconSinkU );
1845 // FAIL CLOSED — a plain-text content write must not silently
1846 // strip the block's inline formatting (the "make it 4/5" defect).
1847 assertNoContentFlatten( blk, norm );
1848 // gs-* identity is immutable on a className write (anti-clobber):
1849 // keep the block's own gs- tokens, apply only the model's utilities.
1850 if ( typeof norm.className === 'string' ) {
1851 norm.className = reconcileClassName(
1852 blk.attributes && blk.attributes.className,
1853 norm.className,
1854 );
1855 }
1856 return { id, blk, norm };
1857 } );
1858 // Apply the final attrs (persistent). The typewriter flourish is
1859 // added uniformly afterwards by the runPlan before/after text diff
1860 // (streamChangedText, SSOT) — never per-op here.
1861 planned.forEach( function ( p, j ) {
1862 // L2 — coalesce a BULK restyle into one undo level: only the
1863 // first target's dispatch is persistent (whether THAT one is
1864 // persistent is the caller's op-level decision); the rest
1865 // merge into it, matching reorderChildren/moveTargetsGrouped.
1866 if ( j > 0 ) {
1867 markNonPersistent( dis );
1868 }
1869 dis.updateBlockAttributes( p.id, deepMerge( p.blk.attributes || {}, p.norm ) );
1870 } );
1871 return {
1872 unknown_attrs: sinkU.length ? sinkU : undefined,
1873 unknown_icons: iconSinkU.length ? iconSinkU : undefined,
1874 };
1875 }
1876 case 'insertBlocks': {
1877 const insAnchor = anchorOf( op );
1878 let insRoot = op.rootClientId;
1879 let insIndex = op.index;
1880 if ( insAnchor ) {
1881 const ra = resolveAnchor( sel, insAnchor.id, insAnchor.position );
1882 insRoot = ra.parent === '' ? null : ra.parent;
1883 insIndex = ra.index;
1884 // eslint-disable-next-line eqeqeq
1885 } else if ( op.rootClientId != null ) {
1886 liveness( sel, op.rootClientId );
1887 }
1888 assertInsertable( sel, insRoot ); // GBR-2 — destination container lock
1889 const sinkI = [];
1890 const iconSinkI = [];
1891 // Resolved markup WINS when present (see parseResolvedSectionMarkup).
1892 // assertEffectiveContent is skipped on that path — it aligns specs to
1893 // blocks by index, and the parsed blocks don't index-align with the
1894 // stale op.blocks specs.
1895 let insBlocks = parseResolvedSectionMarkup( op );
1896 if ( ! insBlocks || insBlocks.length === 0 ) {
1897 // No resolved markup (plain section, no bound site, or it parsed
1898 // to nothing) → the proven block-object path, unchanged.
1899 insBlocks = ( op.blocks || [] ).map( function ( s ) {
1900 return toBlock( s, sinkI, iconSinkI );
1901 } );
1902 assertEffectiveContent( op.blocks, insBlocks );
1903 }
1904 adoptSiblingIdentity( sel, insRoot, insBlocks );
1905 // Refuse a child the destination will not accept, BEFORE dispatch —
1906 // Gutenberg would mint ids and drop the block silently.
1907 assertAllowedChildren( sel, insRoot, insBlocks );
1908 // eslint-disable-next-line eqeqeq
1909 dis.insertBlocks( insBlocks, insIndex, insRoot == null ? undefined : insRoot );
1910 return {
1911 new_client_ids: insBlocks.map( function ( b ) {
1912 return b.clientId;
1913 } ),
1914 result_text: insBlocks.map( renderedTextOf ),
1915 unknown_attrs: sinkI.length ? sinkI : undefined,
1916 unknown_icons: iconSinkI.length ? iconSinkI : undefined,
1917 };
1918 }
1919 case 'removeBlocks': {
1920 ( op.clientIds || [] ).forEach( function ( id ) {
1921 liveness( sel, id );
1922 } );
1923 const rmImpact = removalImpact( sel, op.clientIds );
1924 dis.removeBlocks( op.clientIds );
1925 return rmImpact ? { impact: rmImpact } : {};
1926 }
1927 case 'moveBlocksToPosition': {
1928 if ( op.order ) {
1929 reorderChildren( sel, dis, op.toRootClientId, op.order ); return {};
1930 }
1931 ( op.clientIds || [] ).forEach( function ( id ) {
1932 liveness( sel, id );
1933 } );
1934 const mvAnchor = anchorOf( op );
1935 let toRoot, insertAt;
1936 if ( mvAnchor ) {
1937 const rm = resolveAnchor( sel, mvAnchor.id, mvAnchor.position );
1938 toRoot = rm.parent;
1939 insertAt = rm.index;
1940 } else {
1941 // Distinguish an EXPLICIT page-root destination (null) from an
1942 // OMITTED one (undefined). Collapsing both to '' meant a plan
1943 // carrying only `{clientIds, index}` relocated nested blocks to
1944 // the PAGE ROOT — a silent, destructive un-nesting. An omitted
1945 // destination now means "re-order within the current parent".
1946 if ( op.toRootClientId === null ) {
1947 toRoot = '';
1948 } else if ( op.toRootClientId === undefined ) {
1949 toRoot = KEEP_CURRENT_PARENT;
1950 } else {
1951 toRoot = op.toRootClientId;
1952 }
1953 insertAt = op.index;
1954 }
1955 // M2 — the destination container must accept a move-in
1956 // (canMoveBlock above only folds in the SOURCE parent's lock).
1957 // A keep-current-parent move never crosses a container boundary, so
1958 // there is no new destination lock to check.
1959 if ( toRoot !== KEEP_CURRENT_PARENT ) {
1960 assertMoveDestination( sel, toRoot );
1961 }
1962 moveTargetsGrouped( sel, dis, op.clientIds, toRoot, insertAt );
1963 return {};
1964 }
1965 case 'replaceBlocks': {
1966 ( op.clientIds || [] ).forEach( function ( id ) {
1967 liveness( sel, id );
1968 } );
1969 const sinkR = [];
1970 const iconSinkR = [];
1971 // Resolved markup WINS (same as replaceInnerBlocks): an inner-improve
1972 // section_ref ships real SureForms ids + re-hosted image urls in
1973 // section_markup; parse that so the swapped-in block matches what the
1974 // add path produces, falling back to the block objects otherwise.
1975 let repBlocks = parseResolvedSectionMarkup( op );
1976 if ( ! repBlocks || repBlocks.length === 0 ) {
1977 repBlocks = ( op.blocks || [] ).map( function ( s ) {
1978 return toBlock( s, sinkR, iconSinkR );
1979 } );
1980 assertEffectiveContent( op.blocks, repBlocks );
1981 }
1982 const repImpact = removalImpact( sel, op.clientIds );
1983 dis.replaceBlocks( op.clientIds, repBlocks );
1984 return {
1985 new_client_ids: repBlocks.map( function ( b ) {
1986 return b.clientId;
1987 } ),
1988 result_text: repBlocks.map( renderedTextOf ),
1989 unknown_attrs: sinkR.length ? sinkR : undefined,
1990 unknown_icons: iconSinkR.length ? iconSinkR : undefined,
1991 impact: repImpact,
1992 };
1993 }
1994 case 'replaceInnerBlocks': {
1995 liveness( sel, op.rootClientId );
1996 // Structurally this ADDS/REMOVES the container's children — the
1997 // same class of change insertBlocks/duplicateBlocks need GBR-2
1998 // for (assertMutable's 'edit' kind above only covers editing the
1999 // container's OWN attributes, not manipulating its children).
2000 // Previously missing here, so a templateLock:'all'/'insert'
2001 // container's content could be wiped via this op alone.
2002 assertInsertable( sel, op.rootClientId );
2003 // The OLD children are removed by this op — surface any JS elsewhere
2004 // that targets an anchor inside them (computed before the swap).
2005 const innerRemovedImpact = removalImpact( sel, sel.getBlockOrder ? sel.getBlockOrder( op.rootClientId ) : [] );
2006 const sinkN = [];
2007 const iconSinkN = [];
2008 // Resolved markup WINS here too. Without this, placing a section as a
2009 // REVAMP shipped an unconfigured placeholder form while the identical
2010 // section ADDED came out working — the same content, two outcomes,
2011 // decided only by which op the model happened to pick.
2012 let innerBlocks = parseResolvedSectionMarkup( op );
2013 if ( ! innerBlocks || innerBlocks.length === 0 ) {
2014 innerBlocks = ( op.blocks || [] ).map( function ( s ) {
2015 return toBlock( s, sinkN, iconSinkN );
2016 } );
2017 // Same empty-content guard insertBlocks/replaceBlocks apply — a
2018 // spec that asked for text but built an empty block must fail
2019 // loud, not silently succeed. No-ops when op.blocks is legitimately
2020 // [] (clearing the container is the intentional, allowed case).
2021 // Skipped on the markup path for the same index-alignment reason
2022 // as insertBlocks.
2023 assertEffectiveContent( op.blocks, innerBlocks );
2024 }
2025 dis.replaceInnerBlocks( op.rootClientId, innerBlocks );
2026 return {
2027 new_client_ids: innerBlocks.map( function ( b ) {
2028 return b.clientId;
2029 } ),
2030 result_text: innerBlocks.map( renderedTextOf ),
2031 unknown_attrs: sinkN.length ? sinkN : undefined,
2032 unknown_icons: iconSinkN.length ? iconSinkN : undefined,
2033 impact: innerRemovedImpact,
2034 };
2035 }
2036 case 'duplicateBlocks': {
2037 // Group by ORIGINAL parent, like moveTargetsGrouped already does for
2038 // moveBlocksToPosition — native duplicateBlocks assumes a single-
2039 // parent selection (mirrors real UI multi-select, which can only
2040 // span one parent), so cross-parent clientIds in ONE call misplace
2041 // later duplicates into the FIRST group's parent (found live
2042 // 2026-07-14: duplicating a tab's trigger+panel together landed the
2043 // panel copy inside the trigger row, not beside its sibling panels).
2044 // Dispatching per root — one native call per parent — keeps each
2045 // duplicate next to its own original.
2046 //
2047 // Snapshotting each root's child order BEFORE its dispatch also
2048 // recovers the NEW clientIds (Gutenberg mints them internally — we
2049 // never see them from the dispatch call itself) by diffing after.
2050 // Without this, a "duplicate this, then customize the copy" plan
2051 // (e.g. add a tab) has no way to target the copy in a follow-up op.
2052 const groups = {};
2053 const groupOrder = [];
2054 // Drop a selected block whose ANCESTOR is also selected — duplicating
2055 // the ancestor already copies it, so keeping it mints a redundant copy.
2056 const selectedIds = op.clientIds || [];
2057 const topLevelIds = selectedIds.filter( function ( id ) {
2058 const parents = typeof sel.getBlockParents === 'function' ? sel.getBlockParents( id ) || [] : [];
2059 return ! parents.some( function ( p ) {
2060 return selectedIds.indexOf( p ) !== -1;
2061 } );
2062 } );
2063 topLevelIds.forEach( function ( id ) {
2064 liveness( sel, id );
2065 const root = sel.getBlockRootClientId( id ) || '';
2066 // GBR-2 — a duplicate lands as a sibling, so the PARENT container
2067 // must allow insertion (template-locked parent → refuse).
2068 assertInsertable( sel, root );
2069 if ( ! Object.prototype.hasOwnProperty.call( groups, root ) ) {
2070 groups[ root ] = { ids: [], before: sel.getBlockOrder( root ).slice() };
2071 groupOrder.push( root );
2072 }
2073 groups[ root ].ids.push( id );
2074 } );
2075 const newIds = [];
2076 groupOrder.forEach( function ( root, g ) {
2077 if ( g > 0 ) {
2078 markNonPersistent( dis );
2079 } // one ⌘Z for the whole plan
2080 dis.duplicateBlocks( groups[ root ].ids );
2081 const before = groups[ root ].before;
2082 sel.getBlockOrder( root ).forEach( function ( id ) {
2083 if ( before.indexOf( id ) === -1 ) {
2084 newIds.push( id );
2085 }
2086 } );
2087 } );
2088 return { new_client_ids: newIds };
2089 }
2090 case 'selectBlock': {
2091 liveness( sel, op.clientId );
2092 dis.selectBlock( op.clientId );
2093 return {};
2094 }
2095 default:
2096 throw new Error( 'forbidden_function:' + op.function );
2097 }
2098 }
2099
2100 // [RTRACE] browser-side round-trip tracer. Always pushes structured entries
2101 // into window.__zipwpTrace (read via agent-browser eval); the console mirror is
2102 // GATED behind window.ZIPAI_CONFIG.debug so it doesn't spam every shopper's
2103 // devtools in production (PR #282 S5). Diagnostic only — wrapped so it can
2104 // never break an apply.
2105 function rtrace( hop, data ) {
2106 try {
2107 const entry = Object.assign( { hop, ts: Date.now() }, data || {} );
2108 ( window.__zipwpTrace = window.__zipwpTrace || [] ).push( entry );
2109 if ( window.ZIPAI_CONFIG && window.ZIPAI_CONFIG.debug ) {
2110 console.log( '[RTRACE] gutenberg ' + hop, entry );
2111 }
2112 } catch ( e ) { /* never break the apply on a trace failure */ }
2113 }
2114
2115 // F4 — STRUCTURAL-effect verdict for a minting op. insertBlocks /
2116 // duplicateBlocks / replaceInnerBlocks all MINT clientIds; the op only
2117 // truly LANDED if those minted blocks are actually in the tree now. A
2118 // dispatch that produced an applied entry but whose minted ids never
2119 // appeared (target detached, coalesced away, silent drop) changed nothing —
2120 // the "Added a sixth card" false success. Non-minting ops
2121 // (updateBlockAttributes / move / remove) are not this verdict's concern and
2122 // return true (they carry their own noop/unverifiable verdicts). `getBlock`
2123 // is the live tree probe, bound by the caller. Pure + exported so a unit
2124 // test locks the REAL logic against a mock sel.
2125 function structurallyLanded( op, entry, getBlock ) {
2126 const fn = op && op.function;
2127 if ( fn !== 'insertBlocks' && fn !== 'duplicateBlocks' && fn !== 'replaceInnerBlocks' ) {
2128 return true;
2129 }
2130 const ids = ( entry && entry.new_client_ids ) || [];
2131 // Every id the op DID mint must be live in the tree — a minted-but-absent
2132 // id is the silent drop we're guarding against (the "sixth card" bug).
2133 if ( ! ids.every( function ( id ) {
2134 return !! getBlock( id );
2135 } ) ) {
2136 return false;
2137 }
2138 // insertBlocks / duplicateBlocks ALWAYS add ≥1 block — zero minted means
2139 // nothing was added. replaceInnerBlocks may legitimately mint zero
2140 // (clearing a container's children is a valid structural change; a
2141 // non-empty blocks[] always mints, so empty here ⇒ intended clear), so
2142 // it is NOT required to mint.
2143 if ( ( fn === 'insertBlocks' || fn === 'duplicateBlocks' ) && ids.length === 0 ) {
2144 return false;
2145 }
2146 return true;
2147 }
2148
2149 function runPlan( args ) {
2150 if ( ! window.wp || ! window.wp.data || ! window.wp.blocks ) {
2151 return { success: false, error: 'block_editor_unavailable' };
2152 }
2153 const sel = window.wp.data.select( 'core/block-editor' );
2154 const dis = window.wp.data.dispatch( 'core/block-editor' );
2155 if ( ! sel || ! dis ) {
2156 return { success: false, error: 'block_editor_unavailable' };
2157 }
2158
2159 const livePostId = currentPostId();
2160 const expectedPostId = args && args.post_id;
2161 // SEC-2 — FAIL CLOSED. This guard is the SOLE protection against applying a
2162 // plan to the WRONG open post (a second editor tab). If the brain stamped an
2163 // expected post_id but the live post id can't be resolved, we cannot verify
2164 // we are on the right post — refuse rather than apply blind. (Previously this
2165 // fell open when livePostId was null, skipping the check entirely.)
2166 // eslint-disable-next-line eqeqeq
2167 if ( expectedPostId != null && livePostId == null ) {
2168 return {
2169 success: true,
2170 data: {
2171 post_id: null, applied: [], failed: [],
2172 refused: { reason: 'post_id_unresolvable', detail: 'expected=' + expectedPostId + ',live=null' },
2173 },
2174 };
2175 }
2176 // eslint-disable-next-line eqeqeq
2177 if ( expectedPostId != null && livePostId != null && Number( livePostId ) !== Number( expectedPostId ) ) {
2178 return {
2179 success: true,
2180 data: {
2181 post_id: livePostId, applied: [], failed: [],
2182 refused: { reason: 'post_id_mismatch', detail: 'expected=' + expectedPostId + ',live=' + livePostId },
2183 },
2184 };
2185 }
2186
2187 // Subtree scope-lock — enforce ONLY when the brain bound one AND the
2188 // locked container is still live this turn. A stale lock (selection
2189 // changed / block gone) degrades OPEN — never a false block.
2190 let scopeLock = args && typeof args.scope_lock === 'string' && args.scope_lock !== ''
2191 ? args.scope_lock
2192 : null;
2193 if ( scopeLock && ! sel.getBlock( scopeLock ) ) {
2194 scopeLock = null;
2195 }
2196
2197 const ops = ( args && args.operations ) || [];
2198 const applied = [];
2199 const failed = [];
2200
2201 // [RTRACE] EXEC #1 — the whole plan the brain handed to live Gutenberg.
2202 rtrace( 'runPlan:entry', {
2203 post_id: args && args.post_id, version: args && args.version,
2204 livePostId, op_count: ops.length,
2205 functions: ops.map( function ( o ) {
2206 return o && o.function;
2207 } ),
2208 } );
2209
2210 for ( let i = 0; i < ops.length; i++ ) {
2211 try {
2212 const op = ops[ i ] || {};
2213 // Subtree confinement: refuse a mutating op that reaches OUTSIDE
2214 // the selected container (→ failed[]); in-scope ops still apply.
2215 if ( scopeLock ) {
2216 assertWithinScope( sel, op, scopeLock );
2217 }
2218 // [RTRACE] EXEC #2 — the exact wp.data.dispatch('core/block-editor') call about
2219 // to fire, with its resolved args + the LIVE target block(s) it will hit.
2220 // eslint-disable-next-line eqeqeq
2221 const __tids = op.clientIds || ( op.clientId != null ? [ op.clientId ] : ( op.rootClientId != null ? [ op.rootClientId ] : [] ) );
2222 rtrace( 'runOp:dispatch', {
2223 index: i, fn: op.function,
2224 dispatch: "wp.data.dispatch('core/block-editor')." + op.function + '(...)',
2225 clientIds: op.clientIds, clientId: op.clientId,
2226 rootClientId: op.rootClientId, toRootClientId: op.toRootClientId,
2227 index_arg: op.index, order: op.order, before: op.before, after: op.after,
2228 blocks: ( op.blocks || [] ).map( function ( s ) {
2229 return s && s.name;
2230 } ),
2231 attr_keys: op.attributes ? Object.keys( op.attributes ) : undefined,
2232 className: op.attributes && op.attributes.className,
2233 targets: __tids.map( function ( id ) {
2234 const blk = sel.getBlock( id );
2235 return { clientId: id, live: !! blk, name: blk && blk.name };
2236 } ),
2237 } );
2238 if ( applied.length > 0 ) {
2239 markNonPersistent( dis );
2240 } // undo coalescing → one ⌘Z
2241 // SSOT streaming: snapshot operand text BEFORE the op, then stream
2242 // whatever text CHANGED or APPEARED after — a pure diff, no check on
2243 // op.function. Works for any operation, present or future.
2244 const _before = {};
2245 snapshotContent( sel, opOperandIds( op ), _before );
2246 const res = runOp( sel, dis, op );
2247 streamChangedText(
2248 dis, sel,
2249 opOperandIds( op ).concat( ( res && res.new_client_ids ) || [] ),
2250 _before
2251 );
2252 const entry = { index: i };
2253 if ( res && res.new_client_ids && res.new_client_ids.length ) {
2254 entry.new_client_ids = res.new_client_ids;
2255 }
2256 if ( res && res.result_text ) {
2257 entry.result_text = res.result_text;
2258 }
2259 if ( res && res.unknown_attrs && res.unknown_attrs.length ) {
2260 entry.unknown_attrs = res.unknown_attrs;
2261 }
2262 if ( res && res.unknown_icons && res.unknown_icons.length ) {
2263 entry.unknown_icons = res.unknown_icons;
2264 }
2265 if ( res && res.impact ) {
2266 entry.impact = res.impact;
2267 }
2268 applied.push( entry );
2269 // [RTRACE] EXEC #3 — what the dispatch returned (minted clientIds / rendered text / dropped attrs).
2270 rtrace( 'runOp:result', {
2271 index: i, fn: op.function,
2272 new_client_ids: res && res.new_client_ids, result_text: res && res.result_text,
2273 unknown_attrs: res && res.unknown_attrs, unknown_icons: res && res.unknown_icons,
2274 } );
2275 } catch ( e ) {
2276 failed.push( { index: i, error: ( e && e.message ) || 'apply_failed' } );
2277 // [RTRACE] EXEC #3b — typed failure (stale_client_id / empty_content / forbidden_function).
2278 rtrace( 'runOp:failed', { index: i, fn: ( ops[ i ] || {} ).function, error: ( e && e.message ) || 'apply_failed' } );
2279 }
2280 }
2281
2282 // LIVE-JIT: compile + inject CSS for utility tokens this plan introduced
2283 // (inserted blocks included — the className collector below only covers
2284 // updateBlockAttributes). Kicked off NOW so the network round-trip runs
2285 // in parallel with the 120ms paint settle; the settle body awaits it so
2286 // the no-op check reads computed styles WITH the new rules present.
2287 const utilCssReady = ensureLiveUtilityCss( collectPlanClassNames( ops ) );
2288
2289 // Auto-focus: honour an explicit trailing selectBlock (model-driven), else leave selection.
2290 // eslint-disable-next-line eqeqeq
2291 const postId = livePostId != null ? livePostId : expectedPostId;
2292 // Fold the generated-section GBS persist into the readiness promise so the
2293 // paint settle below waits for the class bodies to land before it reads
2294 // computed styles. Only when the brain attached section styles AND a valid
2295 // post id resolved (a real post id is a positive int → truthy).
2296 const sectionStyles = args && args.styles;
2297 const gbsReady = sectionStyles && postId
2298 ? persistSectionStyles( sectionStyles, postId )
2299 : Promise.resolve();
2300 const liveCssReady = Promise.all( [ utilCssReady, gbsReady ] );
2301 return new Promise( function ( resolve ) {
2302 // M5 — TWO-PHASE settle. The 120ms snapshot is a heuristic: a slow
2303 // re-render (large page, busy main thread) can leave the computed
2304 // style untouched at 120ms and repaint later — a FALSE noop that
2305 // nudges the brain into a pointless restyle retry. So a noop is only
2306 // CONFIRMED by a second unchanged reading 160ms later; ops whose
2307 // style already moved at phase 1 (the common case) pay nothing extra
2308 // and the reply leaves at 120ms exactly as before.
2309 function finishReply( classNames ) {
2310 // F4 — stamp landed:false on any minting op whose blocks aren't in
2311 // the tree now (see structurallyLanded). Runs in BOTH settle paths
2312 // (fast + phase-2) because it lives here. Only sets the flag when
2313 // NOT landed — an absent flag means landed (brain degrades-open).
2314 for ( let s = 0; s < applied.length; s++ ) {
2315 const landed = structurallyLanded(
2316 ops[ applied[ s ].index ],
2317 applied[ s ],
2318 function ( id ) {
2319 return sel.getBlock( id );
2320 }
2321 );
2322 if ( ! landed ) {
2323 applied[ s ].landed = false;
2324 }
2325 // Phantom motion: a className op whose block resolves an
2326 // `animation-name` with NO `@keyframes` (e.g. `animate-bounce`
2327 // with no `bounce` keyframes). The class applied but paints no
2328 // motion — surface it so the brain never reports the animation
2329 // as working (it stays applied; this is advisory, not a failure).
2330 // Gate on the op actually INTRODUCING an `animate-*` token so a
2331 // plain edit (e.g. `border-red-500`) on a block already carrying
2332 // a theme/AOS animation can't mis-attribute that pre-existing
2333 // animation as this op's phantom.
2334 const op = ops[ applied[ s ].index ];
2335 if ( op && op.function === 'updateBlockAttributes' &&
2336 op.attributes && typeof op.attributes.className === 'string' &&
2337 /(^|\s)animate-/.test( op.attributes.className ) &&
2338 Array.isArray( op.clientIds ) ) {
2339 const phantoms = [];
2340 for ( let t = 0; t < op.clientIds.length; t++ ) {
2341 phantomAnimationsOnBlock( op.clientIds[ t ] ).forEach( function ( name ) {
2342 if ( phantoms.indexOf( name ) === -1 ) {
2343 phantoms.push( name );
2344 }
2345 } );
2346 }
2347 if ( phantoms.length ) {
2348 applied[ s ].phantom_animations = phantoms;
2349 }
2350 }
2351 }
2352 // [RTRACE] EXEC #4 — the reply Gutenberg sends back (after the
2353 // paint-settle no-op check — 120ms, +160ms confirm when needed).
2354 // This is what the bridge POSTs to /agent/rpc-reply → brain folds it.
2355 rtrace( 'runPlan:reply', {
2356 post_id: postId, applied_count: applied.length, failed_count: failed.length,
2357 noop_indices: applied.filter( function ( a ) {
2358 return a.noop;
2359 } ).map( function ( a ) {
2360 return a.index;
2361 } ),
2362 boosted_classNames: classNames,
2363 applied: JSON.parse( JSON.stringify( applied ) ),
2364 failed: JSON.parse( JSON.stringify( failed ) ),
2365 } );
2366 resolve( { success: true, data: { post_id: postId, applied, failed } } );
2367 }
2368 setTimeout( function () {
2369 liveCssReady.then( function () {
2370 const classNames = [];
2371 for ( let a = 0; a < applied.length; a++ ) {
2372 const o = ops[ applied[ a ].index ];
2373 if ( o && o.function === 'updateBlockAttributes' && o.attributes && typeof o.attributes.className === 'string' ) {
2374 classNames.push( o.attributes.className );
2375 }
2376 }
2377 // (COMPUTED-EFFECT CHECK DELETED 2026-08-21 — the verdict layer.)
2378 //
2379 // Phase 1 snapshotted every touched block's full computed style,
2380 // phase 2 re-read it 160ms later, and an unchanged string was
2381 // stamped `noop`. That is a CHANGE detector answering a
2382 // SATISFACTION question, and the two only diverge when the answer
2383 // was already correct — the case where it did the most damage.
2384 //
2385 // 50 scenarios through this handler (docs/checker-dryrun-2026-08-21.md
2386 // in the saas repo): "already bold at 700", "blocked by !important",
2387 // "blocked by inline style" and "genuinely changed" all produced the
2388 // SAME verdict — the only thing it truly discriminated was a real
2389 // token from a typo, which is a spelling check. It also needed four
2390 // correction layers (classNameHasLiveUtility for the wrong surface,
2391 // the two-phase settle for timing, renders_on for variants,
2392 // unverifiable for unreadable nodes) — the signal that the instrument
2393 // measured the wrong quantity.
2394 //
2395 // The reply still carries the FACTS the model cannot infer: `landed`
2396 // (structurallyLanded, in finishReply), `unknown_attrs`, `failed[]`.
2397 // Facts go back; the model holds the intent and decides.
2398 //
2399 // The 120ms wait STAYS: ensureLiveUtilityCss injects server-parity
2400 // CSS and the tree must settle before structurallyLanded reads it.
2401 finishReply( classNames );
2402 } );
2403 }, 120 );
2404 } );
2405 }
2406
2407 function handleApplyChange( args ) {
2408 // Vibe Editing v2: every apply_change is a serialized execution plan
2409 // ({ post_id?, operations[] }). runPlan validates the live editor, enforces
2410 // the post_id two-tab guard, applies each operation in order, and returns
2411 // per-index applied[] / failed[] with computed-effect no-op detection. There
2412 // is no other shape — the brain zod emits operations[] only.
2413 return runPlan( args );
2414 }
2415
2416 // Register once the bridge is ready (same retry pattern as get-context).
2417 function initHandler() {
2418 if ( window.zipwpMcp && window.zipwpMcp.registerTool ) {
2419 window.zipwpMcp.registerTool(
2420 'editor/apply-change',
2421 async function ( args ) {
2422 return handleApplyChange( args );
2423 },
2424 { previewMode: 'client' }
2425 );
2426 } else {
2427 setTimeout( initHandler, 100 );
2428 }
2429 }
2430
2431 initHandler();
2432
2433 // Test-only surface (Node/CommonJS). The browser bundle has no `module`, so
2434 // this block is inert there and the IIFE registration path above is untouched.
2435 // Exposes the PURE functions so unit tests exercise the REAL logic (no
2436 // reimplementation) against a mocked window.wp.data / window.wp.blocks.
2437 if ( typeof module !== 'undefined' && module.exports ) {
2438 module.exports = {
2439 handleApplyChange,
2440 runPlan,
2441 runOp,
2442 normalizePlanAttrs,
2443 resolveIconName,
2444 resolveIconAttrs,
2445 coerceGridLayoutLengthAttrs,
2446 placeContent,
2447 renderedTextOf,
2448 assertEffectiveContent,
2449 // Fail-closed content-flatten invariant — a plain-text write must not
2450 // silently strip a block's inline formatting. Exported PURE so a unit
2451 // test locks the predicate, not a reimplementation.
2452 contentWouldFlatten,
2453 deepMerge,
2454 // gs-* identity is immutable on a className write (anti-clobber).
2455 // Exported PURE so a unit test locks the reconcile rule.
2456 reconcileClassName,
2457 toBlock,
2458 // Phantom-animation detection (`animate-bounce` with no keyframes).
2459 // Exported PURE so unit tests lock the resolved-name → missing-keyframes
2460 // logic without a live canvas.
2461 hasKeyframesRule,
2462 missingKeyframeNames,
2463 stripBannedAttrs,
2464 // Registry-driven attr validation — getBlockType(name).attributes is
2465 // the SSOT for attr validity (mirrors what Gutenberg accepts). Exported
2466 // PURE so unit tests lock the partition, not a reimplementation.
2467 partitionAttrs,
2468 registeredAttrKeysOf,
2469 // Image-attribute routing — maps the agent's uniform {url,id,alt} to a
2470 // block's real image attr (incl. container background overlay). Exported
2471 // PURE so unit tests lock the routing per block type.
2472 normalizeImageAttrs,
2473 // Subtree scope-lock (selection confinement) — the tree-aware
2474 // airtight half of "stay inside the selected element". Exported PURE
2475 // so unit tests lock the in/out-of-scope decision against a mock sel.
2476 isWithinScope,
2477 scopeGoverningIds,
2478 resolvedDestinationOf,
2479 assertWithinScope,
2480 // F4 structural-effect verdict — a minting op only landed if its
2481 // minted clientIds are in the tree. Exported PURE so a unit test
2482 // locks the real "did it actually add a block?" logic.
2483 structurallyLanded,
2484 };
2485 }
2486 }() );
2487