PluginProbe
ZIP AI – AI Website Builder & AI Agent (Beta) / 0.0.8
ZIP AI – AI Website Builder & AI Agent (Beta) v0.0.8
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 (Beta) 0.0.8, at assets/js/tools/editor/scripts/handler.js

310 lines 14.6 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 let clientId = resolveClientId( sel, args );
197 if ( ! clientId ) {
198 return { success: false, error: 'no_block: the page has no blocks to attach JS to' };
199 }
200 let attrs = sel.getBlockAttributes( clientId );
201 if ( ! attrs ) {
202 return { success: false, error: 'unknown_block: no block with clientId ' + clientId + ' in the live tree (read the outline / get-context first)' };
203 }
204
205 // The target must actually persist spectraCustomJS, or it paints this
206 // session and silently vanishes on Save. Redirect to the root container —
207 // the same fallback the converter uses for a script whose owner class
208 // sits on a non-capable block — instead of writing something that's lost.
209 let redirected = false;
210 if ( sel.getBlockName && ! isJsCapable( sel.getBlockName( clientId ) ) ) {
211 const root = rootClientId( sel );
212 // The redirect target must ALSO be JS-capable — a page whose first
213 // top-level block is core/group / core/cover would otherwise take the
214 // write, report success, then drop the attribute on Save.
215 if ( root && sel.getBlockName && ! isJsCapable( sel.getBlockName( root ) ) ) {
216 return {
217 success: false,
218 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.',
219 };
220 }
221 const rootAttrs = root ? sel.getBlockAttributes( root ) : null;
222 if ( ! rootAttrs ) {
223 return {
224 success: false,
225 error: 'block_not_js_capable: this block type cannot hold JavaScript, and no root container was found to redirect to',
226 };
227 }
228 redirected = true;
229 clientId = root;
230 attrs = rootAttrs;
231 }
232
233 // Block-safety guard — the SAME invariant apply-change enforces via
234 // assertMutable (SCENARIO-003), applied here because this is the other path
235 // that mutates a block's attributes. `spectraCustomJS` is a block attribute,
236 // and Gutenberg's UPDATE_BLOCK_ATTRIBUTES reducer does NOT consult the lock
237 // selectors (they gate the UI, not a programmatic dispatch) — so without this
238 // check set_scripts could write JS into a template-locked block, or into a
239 // synced-pattern (core/block) instance's content-locked inner block, which
240 // edits content shared with every other page that uses that pattern. Checked
241 // AFTER the js-capable redirect so it validates the block actually written to.
242 // Degrades to ALLOW when the selector is absent (older Gutenberg), matching
243 // assertMutable — never a false block.
244 if ( typeof sel.canEditBlock === 'function' && sel.canEditBlock( clientId ) === false ) {
245 return {
246 success: false,
247 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.',
248 };
249 }
250
251 // Unresolved #id selectors are a FACT for the brain, not a hard block: the
252 // id may resolve on the frontend (imported header/footer part, block content)
253 // even when no anchor in THIS page's editor tree matches. Store the JS and
254 // surface them so the brain can re-target if they're genuinely dead.
255 const unresolved = unresolvedSelectors( sel, args.code );
256
257 const existing = typeof attrs.spectraCustomJS === 'string' ? attrs.spectraCustomJS : '';
258 const next = ( args.append === true && existing !== '' ) ? existing + '\n' + args.code : args.code;
259 dis.updateBlockAttributes( clientId, { spectraCustomJS: next } );
260
261 return {
262 success: true,
263 data: {
264 client_id: clientId,
265 code: next,
266 note: ( redirected
267 ? '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.'
268 : 'Session-scoped: the block\'s JS updates now; persists when the user saves the page.' ) +
269 ( unresolved.length
270 ? ' 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.'
271 : '' ),
272 ...( unresolved.length ? { unresolved_selectors: unresolved } : {} ),
273 },
274 };
275 }
276
277 // Register once the bridge is ready (same retry pattern as get-context /
278 // apply-change). The react-manager glob auto-enqueues this file.
279 function initHandler() {
280 if ( window.zipwpMcp && window.zipwpMcp.registerTool ) {
281 window.zipwpMcp.registerTool(
282 'editor/get-scripts',
283 async function ( args ) {
284 return handleGetScripts( args );
285 },
286 { previewMode: 'client' }
287 );
288 window.zipwpMcp.registerTool(
289 'editor/set-scripts',
290 async function ( args ) {
291 return handleSetScripts( args );
292 },
293 { previewMode: 'client' }
294 );
295 } else {
296 setTimeout( initHandler, 100 );
297 }
298 }
299
300 initHandler();
301
302 // Test-only surface (Node/CommonJS) — inert in the browser bundle.
303 if ( typeof module !== 'undefined' && module.exports ) {
304 module.exports = {
305 handleGetScripts,
306 handleSetScripts,
307 };
308 }
309 }() );
310