| 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 |
|