PluginProbe
ZIP AI – AI Website Builder & AI Agent / 0.0.14
ZIP AI – AI Website Builder & AI Agent v0.0.14
0.0.14 0.0.13 0.0.12 0.0.11 0.0.10 0.0.9 trunk 0.0.4 0.0.5 0.0.6 0.0.7 0.0.8
zip-ai / assets / js / tools / editor / scripts / handler.js

handler.js in ZIP AI – AI Website Builder & AI Agent 0.0.14, at assets/js/tools/editor/scripts/handler.js

317 lines 15.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * editor/get-scripts + editor/set-scripts — typed read/patch over a BLOCK's
3 * `spectraCustomJS` attribute (the per-block JS store). `spectraCustomJS` in
4 * post_content is the single source of truth for page behaviour — Spectra Pro's
5 * BlockJsCompiler renders it once at wp_footer (spectra-blocks-pro #165),
6 * superseding the removed per-page `_spectra_blocks_page_scripts` meta.
7 *
8 * SESSION-SCOPED BY DESIGN (the apply_change philosophy): reads come from
9 * `core/block-editor` getBlockAttributes (the live editing session, incl.
10 * unsaved edits) and writes go through updateBlockAttributes — the block's JS
11 * updates in the session immediately, is DISCARDED if the user discards the
12 * session, and persists only on Save. A REST write here would race the open
13 * editor's copy and mutate before save — never do that.
14 *
15 * Target a block by `clientId` (default = the page root container, the first
16 * top-level block — page-wide behaviour). The stored code is raw JS; the
17 * renderer wraps it in an IIFE and resolves the `_current_block_` token to the
18 * block's scope class.
19 */
20 ( function () {
21 // Block-editor store access comes from the ONE shared source
22 // (editor/shared/editor-shared-utils.js): window in the browser, require()
23 // under jest — so the editor handlers can't drift.
24 function sharedEditorUtils() {
25 if ( typeof window !== 'undefined' && window.zipwpEditorShared ) {
26 return window.zipwpEditorShared;
27 }
28 if ( typeof require === 'function' ) {
29 try {
30 return require( '../shared/editor-shared-utils.js' );
31 } catch ( e ) {
32 return null;
33 }
34 }
35 return null;
36 }
37 function blockEditorSelect() {
38 const u = sharedEditorUtils(); return u && u.blockEditorSelect ? u.blockEditorSelect() : null;
39 }
40 function blockEditorDispatch() {
41 const u = sharedEditorUtils(); return u && u.blockEditorDispatch ? u.blockEditorDispatch() : null;
42 }
43 function rootClientId( sel ) {
44 const u = sharedEditorUtils(); return u && u.rootClientId ? u.rootClientId( sel ) : null;
45 }
46 function currentPostId() {
47 const u = sharedEditorUtils(); return u && u.currentPostId ? u.currentPostId() : null;
48 }
49 function isJsCapable( blockName ) {
50 const u = sharedEditorUtils(); return !! ( u && u.isJsCapable && u.isJsCapable( blockName ) );
51 }
52
53 // ── Anchor resolution for JS selectors ──────────────────────────────────
54 // The AI can't see the rendered DOM. The sanctioned way to target a block in
55 // JS is its HTML `anchor`, which renders as the element's frontend `id`
56 // (BlockAttributes::get_wrapper_attributes → `$wrapper_attrs['id'] = anchor`).
57 // collectAnchors gathers those; unresolvedSelectors reports the #ids a script
58 // references that no anchor provides. Those are surfaced to the brain as a
59 // FACT, not hard-blocked — the id may still resolve on the frontend (an
60 // imported header/footer part the editor's page tree omits, or an id authored
61 // inside block content: form fields, rich-text spans). Class/tag selectors are
62 // never gated — they name runtime state, utilities, and block-internal DOM.
63 function collectAnchors( sel ) {
64 const anchors = Object.create( null );
65 const ids = sel.getClientIdsWithDescendants ? sel.getClientIdsWithDescendants() : null;
66 if ( ! ids ) {
67 return anchors;
68 }
69 for ( let i = 0; i < ids.length; i++ ) {
70 const a = sel.getBlockAttributes( ids[ i ] );
71 if ( a && typeof a.anchor === 'string' && a.anchor !== '' ) {
72 anchors[ a.anchor ] = true;
73 }
74 }
75 return anchors;
76 }
77
78 // Every id the JS references (getElementById / #id) — from the shared SSOT, so
79 // the write-time gate parses JS identically to apply-change's delete-time
80 // orphan-JS surface (drift here would be a new bug class). Local fallback keeps
81 // the handler working if the shared bundle hasn't loaded yet.
82 function referencedIds( code ) {
83 const u = sharedEditorUtils();
84 if ( u && u.referencedIds ) {
85 return u.referencedIds( code );
86 }
87 const ids = [];
88 if ( typeof code !== 'string' || code === '' ) {
89 return ids;
90 }
91 let m;
92 const reGid = /getElementById\(\s*['"]([A-Za-z][\w-]*)['"]\s*\)/g;
93 while ( ( m = reGid.exec( code ) ) !== null ) {
94 ids.push( m[ 1 ] );
95 }
96 const reQs = /querySelector(?:All)?\(\s*(['"])([^'"]*)\1/g;
97 while ( ( m = reQs.exec( code ) ) !== null ) {
98 // eslint-disable-next-line no-var
99 var idm,
100 reId = /#([A-Za-z][\w-]*)/g;
101 while ( ( idm = reId.exec( m[ 2 ] ) ) !== null ) {
102 ids.push( idm[ 1 ] );
103 }
104 }
105 return ids;
106 }
107
108 // The #ids the JS references that no block ANCHOR in THIS page's editor tree
109 // provides. Surfaced to the brain as a FACT — NOT hard-blocked: the id may
110 // still resolve on the frontend (an imported header/footer part the editor
111 // tree omits, or an id authored inside block content — form fields, rich-text
112 // spans). The brain has the site context to judge; blocking here false-rejects
113 // those legit targets. Mirrors removalImpact (adapter surfaces facts, brain decides).
114 function unresolvedSelectors( sel, code ) {
115 const anchors = collectAnchors( sel );
116 const ids = referencedIds( code );
117 return ids.filter( function ( id, i ) {
118 return ids.indexOf( id ) === i && ! anchors[ id ];
119 } );
120 }
121
122 // The target block: an explicit clientId, else the page root container.
123 function resolveClientId( sel, args ) {
124 const cid = args && typeof args.clientId === 'string' && args.clientId !== '' ? args.clientId : null;
125 return cid || rootClientId( sel );
126 }
127
128 // A block's current spectraCustomJS ('' when unset or the block is gone).
129 // eslint-disable-next-line no-unused-vars
130 function customJsOf( sel, clientId ) {
131 const attrs = sel && sel.getBlockAttributes ? sel.getBlockAttributes( clientId ) : null;
132 return attrs && typeof attrs.spectraCustomJS === 'string' ? attrs.spectraCustomJS : '';
133 }
134
135 function handleGetScripts( args ) {
136 const sel = blockEditorSelect();
137 if ( ! sel || ! sel.getBlockAttributes ) {
138 return { success: false, error: 'editor_unavailable: core/block-editor store not present (is the block editor open?)' };
139 }
140 let clientId = resolveClientId( sel, args );
141 if ( ! clientId ) {
142 return { success: false, error: 'no_block: the page has no blocks to read JS from' };
143 }
144 // Mirror set-scripts' redirect: a non-JS-capable target's JS lives on the
145 // page root (where set-scripts wrote it), so READ from there — else the
146 // brain reads '' from the original block and re-issues, stacking duplicate JS.
147 if ( sel.getBlockName && ! isJsCapable( sel.getBlockName( clientId ) ) ) {
148 const root = rootClientId( sel );
149 if ( root ) {
150 clientId = root;
151 }
152 }
153 // Block-gone is NOT the same as no-JS: if the clientId no longer resolves,
154 // report it as a stale target (else the model reads code:'' as "no JS" and
155 // may author a fresh script against a dead block).
156 const attrs = sel.getBlockAttributes( clientId );
157 if ( ! attrs ) {
158 return { success: false, error: 'unknown_block: no block with clientId ' + clientId + ' in the live tree (it may have been deleted — re-read the outline / get-context first)' };
159 }
160 return {
161 success: true,
162 data: {
163 post_id: currentPostId(),
164 client_id: clientId,
165 code: typeof attrs.spectraCustomJS === 'string' ? attrs.spectraCustomJS : '',
166 },
167 };
168 }
169
170 // Throws on code the block renderer would reject — typed and loud beats a
171 // silent drop. `<script>` tags never belong in the store (it wraps the raw JS).
172 function validateCode( code ) {
173 if ( typeof code !== 'string' || code === '' ) {
174 throw new Error( 'invalid_input: code is required (the raw JS source, no <script> tags)' );
175 }
176 if ( /<\/?script/i.test( code ) ) {
177 throw new Error( 'invalid_input: code must be the raw JS source only, with no <script> tags (the store wraps it)' );
178 }
179 }
180
181 // `code` REPLACES the block's spectraCustomJS; `append: true` adds to the
182 // existing JS instead (read first with editor/get-scripts).
183 function handleSetScripts( args ) {
184 const sel = blockEditorSelect();
185 const dis = blockEditorDispatch();
186 if ( ! sel || ! sel.getBlockAttributes || ! dis || ! dis.updateBlockAttributes ) {
187 return { success: false, error: 'editor_unavailable: core/block-editor store not present (is the block editor open?)' };
188 }
189
190 try {
191 validateCode( args ? args.code : undefined );
192 } catch ( e ) {
193 return { success: false, error: String( e && e.message ? e.message : e ) };
194 }
195
196 const u = sharedEditorUtils();
197 const refusal = u && u.postIdRefusal ? u.postIdRefusal( args ? args.post_id : undefined ) : null;
198 if ( refusal ) {
199 return { success: false, error: refusal.reason + ': ' + refusal.detail + ' (the open editor is not the post this edit was planned for)' };
200 }
201
202 let clientId = resolveClientId( sel, args );
203 if ( ! clientId ) {
204 return { success: false, error: 'no_block: the page has no blocks to attach JS to' };
205 }
206 let attrs = sel.getBlockAttributes( clientId );
207 if ( ! attrs ) {
208 return { success: false, error: 'unknown_block: no block with clientId ' + clientId + ' in the live tree (read the outline / get-context first)' };
209 }
210
211 // The target must actually persist spectraCustomJS, or it paints this
212 // session and silently vanishes on Save. Redirect to the root container —
213 // the same fallback the converter uses for a script whose owner class
214 // sits on a non-capable block — instead of writing something that's lost.
215 let redirected = false;
216 if ( sel.getBlockName && ! isJsCapable( sel.getBlockName( clientId ) ) ) {
217 const root = rootClientId( sel );
218 // The redirect target must ALSO be JS-capable — a page whose first
219 // top-level block is core/group / core/cover would otherwise take the
220 // write, report success, then drop the attribute on Save.
221 if ( root && sel.getBlockName && ! isJsCapable( sel.getBlockName( root ) ) ) {
222 return {
223 success: false,
224 error: 'block_not_js_capable: this block type cannot hold JavaScript, and the page root container cannot either — wrap the target in a Spectra container that holds JS, then retry.',
225 };
226 }
227 const rootAttrs = root ? sel.getBlockAttributes( root ) : null;
228 if ( ! rootAttrs ) {
229 return {
230 success: false,
231 error: 'block_not_js_capable: this block type cannot hold JavaScript, and no root container was found to redirect to',
232 };
233 }
234 redirected = true;
235 clientId = root;
236 attrs = rootAttrs;
237 }
238
239 // Block-safety guard — the SAME invariant apply-change enforces via
240 // assertMutable (SCENARIO-003), applied here because this is the other path
241 // that mutates a block's attributes. `spectraCustomJS` is a block attribute,
242 // and Gutenberg's UPDATE_BLOCK_ATTRIBUTES reducer does NOT consult the lock
243 // selectors (they gate the UI, not a programmatic dispatch) — so without this
244 // check set_scripts could write JS into a template-locked block, or into a
245 // synced-pattern (core/block) instance's content-locked inner block, which
246 // edits content shared with every other page that uses that pattern. Checked
247 // AFTER the js-capable redirect so it validates the block actually written to.
248 // Degrades to ALLOW when the selector is absent (older Gutenberg), matching
249 // assertMutable — never a false block.
250 const aiEditable = ! ( u && u.isAiEditable ) || u.isAiEditable( sel, clientId );
251 if ( ( typeof sel.canEditBlock === 'function' && sel.canEditBlock( clientId ) === false ) || ! aiEditable ) {
252 return {
253 success: false,
254 error: 'locked_block: ' + clientId + ' is locked in the editor (template lock, content lock, or a synced-pattern instance), so its JavaScript cannot be changed here. Edit the pattern itself, or unlock the block.',
255 };
256 }
257
258 // Unresolved #id selectors are a FACT for the brain, not a hard block: the
259 // id may resolve on the frontend (imported header/footer part, block content)
260 // even when no anchor in THIS page's editor tree matches. Store the JS and
261 // surface them so the brain can re-target if they're genuinely dead.
262 const unresolved = unresolvedSelectors( sel, args.code );
263
264 const existing = typeof attrs.spectraCustomJS === 'string' ? attrs.spectraCustomJS : '';
265 const next = ( args.append === true && existing !== '' ) ? existing + '\n' + args.code : args.code;
266 dis.updateBlockAttributes( clientId, { spectraCustomJS: next } );
267
268 return {
269 success: true,
270 data: {
271 client_id: clientId,
272 code: next,
273 note: ( redirected
274 ? 'Session-scoped: the requested block can\'t hold JS (it would be dropped on Save), so it was attached to the page root container instead.'
275 : 'Session-scoped: the block\'s JS updates now; persists when the user saves the page.' ) +
276 ( unresolved.length
277 ? ' Heads up: #' + unresolved.join( ', #' ) + ' match no block anchor on this page — fine if they resolve in the imported header/footer or block content, otherwise set an anchor or re-target.'
278 : '' ),
279 ...( unresolved.length ? { unresolved_selectors: unresolved } : {} ),
280 },
281 };
282 }
283
284 // Register once the bridge is ready (same retry pattern as get-context /
285 // apply-change). The react-manager glob auto-enqueues this file.
286 function initHandler() {
287 if ( window.zipwpMcp && window.zipwpMcp.registerTool ) {
288 window.zipwpMcp.registerTool(
289 'editor/get-scripts',
290 async function ( args ) {
291 return handleGetScripts( args );
292 },
293 { previewMode: 'client' }
294 );
295 window.zipwpMcp.registerTool(
296 'editor/set-scripts',
297 async function ( args ) {
298 return handleSetScripts( args );
299 },
300 { previewMode: 'client' }
301 );
302 } else {
303 setTimeout( initHandler, 100 );
304 }
305 }
306
307 initHandler();
308
309 // Test-only surface (Node/CommonJS) — inert in the browser bundle.
310 if ( typeof module !== 'undefined' && module.exports ) {
311 module.exports = {
312 handleGetScripts,
313 handleSetScripts,
314 };
315 }
316 }() );
317