PluginProbe
Atarim – AI Agency for WordPress: Edit Pages, Fix Code, Update Plugins, SEO & Client Feedback / 5.1.2
Atarim – AI Agency for WordPress: Edit Pages, Fix Code, Update Plugins, SEO & Client Feedback v5.1.2
5.1.3 5.1.2 5.1.1 5.1 5.0 trunk 3.10 3.11 3.12 3.13 3.14 3.15 3.16 3.17 3.18 3.19 3.2.0 3.2.1 3.22 3.22.1 3.22.2 3.22.3 3.22.4 3.22.5 3.22.6 All 75 releases
atarim-visual-collaboration / third-party / page-builder / etch / class-avcf-etch-helpers.php
class-avcf-etch-helpers.php
457 lines 21.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Shared helpers for the Etch ability cluster.
4 *
5 * Etch stores its document as Gutenberg blocks in post_content. We parse with
6 * core parse_blocks() into a tree of { block, attrs?, children?, html? } nodes
7 * (block = Gutenberg block name like "etch/element"/"etch/text"; html = inner
8 * HTML for leaf/freeform; block "" + html = freeform HTML), address nodes by
9 * index-path ("0/1/2"), and serialize back with core serialize_blocks().
10 * Built on stable WP core block functions; not runtime-tested here.
11 *
12 * @package atarim-visual-collaboration
13 */
14
15 if ( ! defined('ABSPATH') ) {
16 exit;
17 }
18
19 class AVCF_Etch_Helpers {
20
21 /* ---------------------------- round-trip --------------------------- */
22
23 public static function parse_tree( $content ) {
24 return self::blocks_to_tree( parse_blocks( (string) $content ) );
25 }
26
27 private static function blocks_to_tree( $blocks ) {
28 $tree = [];
29 foreach ( (array) $blocks as $i => $block ) {
30 $name = isset( $block['blockName'] ) && is_string( $block['blockName'] ) ? $block['blockName'] : '';
31 $inner_blocks = isset( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ? $block['innerBlocks'] : [];
32 $inner_html = isset( $block['innerHTML'] ) && is_string( $block['innerHTML'] ) ? $block['innerHTML'] : '';
33
34 if ( $name === '' && trim( $inner_html ) === '' ) { continue; } // whitespace
35
36 $node = [ 'index' => (int) $i, 'block' => $name ];
37 if ( isset( $block['attrs'] ) && is_array( $block['attrs'] ) && $block['attrs'] !== [] ) { $node['attrs'] = $block['attrs']; }
38 if ( $inner_blocks ) { $node['children'] = self::blocks_to_tree( $inner_blocks ); }
39 elseif ( trim( $inner_html ) !== '' ) { $node['html'] = $inner_html; }
40 $tree[] = $node;
41 }
42 return $tree;
43 }
44
45 /**
46 * WHOLE-DOCUMENT OVERWRITE ONLY (set-content), where the caller supplies the
47 * entire tree and there is no prior markup to preserve. Never use this to
48 * write back a tree that was read from an existing post: the read projection
49 * has already discarded that post's wrapper markup, so re-serialising it
50 * destroys every container in the document. Granular edits go through the
51 * raw_* engine below.
52 */
53 public static function serialize_tree( $tree ) {
54 return serialize_blocks( self::tree_to_blocks( $tree ) );
55 }
56
57 private static function tree_to_blocks( $tree ) {
58 $blocks = [];
59 foreach ( (array) $tree as $node ) {
60 if ( ! is_array( $node ) ) { continue; }
61 $name = isset( $node['block'] ) ? (string) $node['block'] : '';
62 $attrs = isset( $node['attrs'] ) && is_array( $node['attrs'] ) ? $node['attrs'] : [];
63 $children = isset( $node['children'] ) && is_array( $node['children'] ) ? $node['children'] : [];
64 $html = isset( $node['html'] ) ? (string) $node['html'] : '';
65
66 if ( $name === '' ) {
67 // Freeform HTML node.
68 $blocks[] = [ 'blockName' => null, 'attrs' => [], 'innerBlocks' => [], 'innerHTML' => $html, 'innerContent' => [ $html ] ];
69 continue;
70 }
71 if ( $children ) {
72 $inner = self::tree_to_blocks( $children );
73 $blocks[] = [ 'blockName' => $name, 'attrs' => $attrs, 'innerBlocks' => $inner, 'innerHTML' => '', 'innerContent' => $inner ? array_fill( 0, count( $inner ), null ) : [] ];
74 } else {
75 $blocks[] = [ 'blockName' => $name, 'attrs' => $attrs, 'innerBlocks' => [], 'innerHTML' => $html, 'innerContent' => $html !== '' ? [ $html ] : [] ];
76 }
77 }
78 return $blocks;
79 }
80
81 public static function read_tree( $post_id ) {
82 $post = get_post( $post_id );
83 return $post ? self::parse_tree( $post->post_content ) : [];
84 }
85 public static function write_tree( $post_id, $tree ) {
86 $res = wp_update_post( [ 'ID' => (int) $post_id, 'post_content' => self::serialize_tree( $tree ) ], true );
87 return ! is_wp_error( $res );
88 }
89
90 /**
91 * Lossy = a whole-tree overwrite would destroy something the tree can't carry:
92 * freeform HTML nodes with real content, or any container holding literal
93 * wrapper markup around its children (<ul class="wp-block-list">, <div
94 * class="wp-block-group">, ...). Granular raw_* edits are never lossy; this
95 * guard is for set-content.
96 */
97 public static function is_lossy( $content ) {
98 return self::scan_lossy( parse_blocks( (string) $content ) );
99 }
100 private static function scan_lossy( $blocks ) {
101 foreach ( (array) $blocks as $b ) {
102 $name = isset( $b['blockName'] ) ? $b['blockName'] : null;
103 $html = isset( $b['innerHTML'] ) ? (string) $b['innerHTML'] : '';
104 if ( ( $name === null || $name === '' ) && trim( $html ) !== '' ) { return true; }
105 $kids = isset( $b['innerBlocks'] ) && is_array( $b['innerBlocks'] ) ? $b['innerBlocks'] : [];
106 if ( $kids ) {
107 foreach ( ( isset( $b['innerContent'] ) && is_array( $b['innerContent'] ) ? $b['innerContent'] : [] ) as $chunk ) {
108 if ( is_string( $chunk ) && trim( $chunk ) !== '' ) { return true; } // wrapper markup
109 }
110 if ( self::scan_lossy( $kids ) ) { return true; }
111 }
112 }
113 return false;
114 }
115
116 /* ----------------------------- address ----------------------------- */
117
118 public static function address_parts( $address ) {
119 $address = trim( (string) $address );
120 if ( $address === '' || $address === 'root' ) { return []; }
121 $parts = [];
122 foreach ( explode( '/', $address ) as $seg ) {
123 if ( $seg === '' || ! ctype_digit( $seg ) ) { return null; }
124 if ( $seg !== '0' && $seg[0] === '0' ) { return null; }
125 $parts[] = (int) $seg;
126 }
127 return $parts;
128 }
129
130 public static function node_at( $tree, $address ) {
131 $parts = self::address_parts( $address );
132 if ( $parts === null ) { return null; }
133 $nodes = $tree; $found = null;
134 foreach ( $parts as $i ) {
135 if ( ! array_key_exists( $i, $nodes ) ) { return null; }
136 $found = $nodes[ $i ];
137 $nodes = isset( $found['children'] ) && is_array( $found['children'] ) ? $found['children'] : [];
138 }
139 return $found;
140 }
141
142 /** Apply $op to the children list of the node at $parent_address (""=root). */
143 public static function update_children( $tree, $parent_address, $op ) {
144 $parts = self::address_parts( $parent_address );
145 if ( $parts === null ) { return null; }
146 if ( $parts === [] ) { return call_user_func( $op, $tree ); }
147 return self::descend( $tree, $parts, $op );
148 }
149 private static function descend( $nodes, $parts, $op ) {
150 $i = array_shift( $parts );
151 if ( ! array_key_exists( $i, $nodes ) ) { return null; }
152 $children = isset( $nodes[ $i ]['children'] ) && is_array( $nodes[ $i ]['children'] ) ? $nodes[ $i ]['children'] : [];
153 $updated = empty( $parts ) ? call_user_func( $op, $children ) : self::descend( $children, $parts, $op );
154 if ( $updated === null ) { return null; }
155 $nodes[ $i ]['children'] = $updated;
156 return $nodes;
157 }
158
159 public static function split_address( $address ) {
160 $parts = self::address_parts( $address );
161 if ( $parts === null || $parts === [] ) { return [ '', null ]; }
162 $index = array_pop( $parts );
163 return [ implode( '/', $parts ), $index ];
164 }
165
166 public static function replace_at( $tree, $address, $node ) {
167 list( $parent, $index ) = self::split_address( $address );
168 if ( $index === null ) { return null; }
169 return self::update_children( $tree, $parent, function( $children ) use ( $index, $node ) {
170 if ( ! array_key_exists( $index, $children ) ) { return null; }
171 $children[ $index ] = $node;
172 return $children;
173 } );
174 }
175 public static function remove_at( $tree, $address ) {
176 list( $parent, $index ) = self::split_address( $address );
177 if ( $index === null ) { return null; }
178 return self::update_children( $tree, $parent, function( $children ) use ( $index ) {
179 if ( ! array_key_exists( $index, $children ) ) { return null; }
180 array_splice( $children, $index, 1 );
181 return $children;
182 } );
183 }
184 public static function insert_at( $tree, $parent_address, $position, $node ) {
185 return self::update_children( $tree, $parent_address, function( $children ) use ( $position, $node ) {
186 $pos = $position === null ? count( $children ) : max( 0, min( (int) $position, count( $children ) ) );
187 array_splice( $children, $pos, 0, [ $node ] );
188 return $children;
189 } );
190 }
191
192 /* ---------------------------- flatten/view ------------------------- */
193
194 public static function flatten( $tree, $include_attrs = false, $cap = 400 ) {
195 $out = []; $count = [ 0 ];
196 self::flatten_level( $tree, '', '', $include_attrs, $cap, $out, $count );
197 return $out;
198 }
199 private static function flatten_level( $nodes, $prefix, $parent, $include_attrs, $cap, &$out, &$count ) {
200 foreach ( $nodes as $node ) {
201 if ( $count[0] >= $cap ) { return; }
202 $i = isset( $node['index'] ) ? (int) $node['index'] : 0;
203 $addr = $prefix === '' ? (string) $i : $prefix . '/' . $i;
204 $children = isset( $node['children'] ) && is_array( $node['children'] ) ? $node['children'] : [];
205 $row = [ 'address' => $addr, 'block' => isset( $node['block'] ) ? (string) $node['block'] : '', 'parent' => $parent, 'child_count' => count( $children ), 'has_html' => isset( $node['html'] ) ];
206 if ( $include_attrs ) {
207 if ( isset( $node['attrs'] ) ) { $row['attrs'] = $node['attrs']; }
208 if ( isset( $node['html'] ) ) { $row['html'] = (string) $node['html']; }
209 }
210 $out[] = $row;
211 $count[0]++;
212 if ( $children ) { self::flatten_level( $children, $addr, $addr, $include_attrs, $cap, $out, $count ); }
213 }
214 }
215 public static function tree_count( $tree ) {
216 $n = 0;
217 foreach ( (array) $tree as $node ) {
218 $n++;
219 if ( isset( $node['children'] ) && is_array( $node['children'] ) ) { $n += self::tree_count( $node['children'] ); }
220 }
221 return $n;
222 }
223
224 /** Sanitize an incoming tree to clean {block, attrs?, children?, html?} nodes. */
225 public static function sanitize_tree( $nodes ) {
226 $out = [];
227 foreach ( (array) $nodes as $n ) {
228 if ( ! is_array( $n ) || ! isset( $n['block'] ) ) { continue; }
229 $node = [ 'block' => (string) $n['block'] ];
230 if ( isset( $n['attrs'] ) && is_array( $n['attrs'] ) ) { $node['attrs'] = $n['attrs']; }
231 if ( isset( $n['children'] ) && is_array( $n['children'] ) ) { $node['children'] = self::sanitize_tree( $n['children'] ); }
232 elseif ( isset( $n['html'] ) ) { $node['html'] = (string) $n['html']; }
233 $out[] = $node;
234 }
235 return $out;
236 }
237
238 /* --------------------------- vocabulary ---------------------------- */
239
240 public static function list_elements() {
241 $out = [];
242 if ( class_exists( '\WP_Block_Type_Registry' ) ) {
243 $reg = \WP_Block_Type_Registry::get_instance();
244 if ( $reg && method_exists( $reg, 'get_all_registered' ) ) {
245 foreach ( $reg->get_all_registered() as $name => $type ) {
246 if ( strpos( (string) $name, 'etch/' ) === 0 ) {
247 $out[] = [ 'block' => (string) $name, 'title' => isset( $type->title ) ? (string) $type->title : '' ];
248 }
249 }
250 }
251 }
252 if ( empty( $out ) ) {
253 foreach ( [ 'etch/element', 'etch/text', 'etch/component', 'etch/loop', 'etch/template-part', 'etch/fragment' ] as $b ) {
254 $out[] = [ 'block' => $b, 'title' => '' ];
255 }
256 }
257 return $out;
258 }
259
260 /* ------------------------- raw block writes -------------------------
261 *
262 * The tree above is a READ projection and is LOSSY BY DESIGN: it drops the
263 * literal HTML chunks a container wraps its children in, and the whitespace
264 * between siblings. Writes must never round-trip through it -- that is the
265 * bug this section replaced. Everything below mutates the raw parse_blocks()
266 * array and serializes that, so nothing is translated and nothing is lost.
267 *
268 * innerContent is the interleave serialize_block() walks: every string is
269 * printed literally, every null consumes the next entry of innerBlocks.
270 * Adding or removing a child without keeping the null count in step silently
271 * drops or duplicates content. raw_splice_children() and raw_unsplice_child()
272 * are the only two places allowed to touch it.
273 *
274 * Addresses are chains of RAW parse_blocks indices. The read view skips
275 * whitespace nodes, so published addresses are not contiguous. Never renumber.
276 */
277
278 public static function parse_raw( $content ) { return parse_blocks( (string) $content ); }
279 public static function serialize_raw( $blocks ) { return serialize_blocks( (array) $blocks ); }
280
281 public static function read_raw( $post_id ) {
282 $post = get_post( $post_id );
283 return $post ? self::parse_raw( $post->post_content ) : [];
284 }
285 public static function write_raw( $post_id, $blocks ) {
286 $res = wp_update_post( [ 'ID' => (int) $post_id, 'post_content' => self::serialize_raw( $blocks ) ], true );
287 return ! is_wp_error( $res );
288 }
289
290 /** Strict index-path -> int list. '' / 'root' = document root. null = malformed. */
291 public static function raw_address_parts( $address ) {
292 $address = trim( (string) $address );
293 if ( $address === '' || $address === 'root' ) { return []; }
294 $parts = [];
295 foreach ( explode( '/', $address ) as $seg ) {
296 if ( $seg === '' || ! ctype_digit( $seg ) ) { return null; }
297 if ( $seg !== '0' && $seg[0] === '0' ) { return null; }
298 $parts[] = (int) $seg;
299 }
300 return $parts;
301 }
302 public static function raw_split_address( $address ) {
303 $parts = self::raw_address_parts( $address );
304 if ( $parts === null || $parts === [] ) { return [ '', null ]; }
305 $index = array_pop( $parts );
306 return [ implode( '/', $parts ), $index ];
307 }
308
309 public static function raw_node_at( $blocks, $address ) {
310 $parts = self::raw_address_parts( $address );
311 if ( $parts === null ) { return null; }
312 $nodes = (array) $blocks; $found = null;
313 foreach ( $parts as $i ) {
314 if ( ! array_key_exists( $i, $nodes ) ) { return null; }
315 $found = $nodes[ $i ];
316 $nodes = isset( $found['innerBlocks'] ) && is_array( $found['innerBlocks'] ) ? $found['innerBlocks'] : [];
317 }
318 return $found;
319 }
320
321 /*
322 * The document root is not a block and has no innerContent. Wrapping it in a
323 * synthetic parent lets every address -- root included -- share one splice
324 * path; the synthetic innerContent is discarded on unwrap.
325 */
326 private static function raw_wrap_root( $blocks ) {
327 $blocks = array_values( (array) $blocks );
328 return [ 'blockName' => null, 'attrs' => [], 'innerBlocks' => $blocks, 'innerHTML' => '', 'innerContent' => array_fill( 0, count( $blocks ), null ) ];
329 }
330 private static function raw_unwrap_root( $root ) {
331 return ( is_array( $root ) && isset( $root['innerBlocks'] ) && is_array( $root['innerBlocks'] ) ) ? array_values( $root['innerBlocks'] ) : [];
332 }
333 private static function raw_apply_at( $block, $parts, $fn ) {
334 if ( $parts === [] ) { return call_user_func( $fn, $block ); }
335 $i = array_shift( $parts );
336 $inner = isset( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ? $block['innerBlocks'] : [];
337 if ( ! array_key_exists( $i, $inner ) ) { return null; }
338 $updated = self::raw_apply_at( $inner[ $i ], $parts, $fn );
339 if ( $updated === null ) { return null; }
340 $inner[ $i ] = $updated;
341 $block['innerBlocks'] = $inner;
342 return $block;
343 }
344
345 private static function raw_placeholder_offsets( $inner_content ) {
346 $pos = [];
347 foreach ( (array) $inner_content as $i => $chunk ) { if ( ! is_string( $chunk ) ) { $pos[] = $i; } }
348 return $pos;
349 }
350
351 /**
352 * A container with no children has no placeholder to insert against and its
353 * wrapper is one opaque literal. Split it just before its final closing tag
354 * so the first child lands inside. null = cannot open safely; caller refuses.
355 */
356 private static function raw_open_empty_container( $inner_content ) {
357 $joined = '';
358 foreach ( (array) $inner_content as $chunk ) { if ( is_string( $chunk ) ) { $joined .= $chunk; } }
359 if ( $joined === '' ) { return [ null ]; }
360 if ( trim( $joined ) === '' ) { return [ $joined, null ]; }
361 if ( ! preg_match( '~</[A-Za-z][A-Za-z0-9:-]*>\s*$~', $joined, $m, PREG_OFFSET_CAPTURE ) ) { return null; }
362 $at = (int) $m[0][1];
363 return [ substr( $joined, 0, $at ), null, substr( $joined, $at ) ];
364 }
365
366 private static function raw_splice_children( $parent, $k, $new ) {
367 $new = array_values( (array) $new );
368 if ( $new === [] ) { return $parent; }
369 $inner = isset( $parent['innerBlocks'] ) && is_array( $parent['innerBlocks'] ) ? $parent['innerBlocks'] : [];
370 $ic = isset( $parent['innerContent'] ) && is_array( $parent['innerContent'] ) ? array_values( $parent['innerContent'] ) : [];
371 $k = max( 0, min( (int) $k, count( $inner ) ) );
372 $slots = self::raw_placeholder_offsets( $ic );
373 if ( $slots === [] ) {
374 $opened = self::raw_open_empty_container( $ic );
375 if ( $opened === null ) { return null; }
376 $ic = $opened;
377 $s = self::raw_placeholder_offsets( $ic );
378 $at = $s[0];
379 array_splice( $ic, $at, 1 );
380 } else {
381 $at = ( $k < count( $slots ) ) ? $slots[ $k ] : ( $slots[ count( $slots ) - 1 ] + 1 );
382 }
383 array_splice( $inner, $k, 0, $new );
384 array_splice( $ic, $at, 0, array_fill( 0, count( $new ), null ) );
385 $parent['innerBlocks'] = $inner;
386 $parent['innerContent'] = $ic;
387 return $parent;
388 }
389
390 private static function raw_unsplice_child( $parent, $k ) {
391 $inner = isset( $parent['innerBlocks'] ) && is_array( $parent['innerBlocks'] ) ? $parent['innerBlocks'] : [];
392 $ic = isset( $parent['innerContent'] ) && is_array( $parent['innerContent'] ) ? array_values( $parent['innerContent'] ) : [];
393 if ( ! array_key_exists( $k, $inner ) ) { return null; }
394 $slots = self::raw_placeholder_offsets( $ic );
395 array_splice( $inner, $k, 1 );
396 if ( isset( $slots[ $k ] ) ) { array_splice( $ic, $slots[ $k ], 1 ); }
397 $parent['innerBlocks'] = $inner;
398 $parent['innerContent'] = $ic;
399 return $parent;
400 }
401
402 public static function raw_replace_at( $blocks, $address, $new_block ) {
403 list( $parent, $index ) = self::raw_split_address( $address );
404 if ( $index === null ) { return null; }
405 $parts = self::raw_address_parts( $parent );
406 if ( $parts === null ) { return null; }
407 $root = self::raw_apply_at( self::raw_wrap_root( $blocks ), $parts, function( $p ) use ( $index, $new_block ) {
408 $inner = isset( $p['innerBlocks'] ) && is_array( $p['innerBlocks'] ) ? $p['innerBlocks'] : [];
409 if ( ! array_key_exists( $index, $inner ) ) { return null; }
410 $inner[ $index ] = $new_block;
411 $p['innerBlocks'] = $inner;
412 return $p;
413 } );
414 return $root === null ? null : self::raw_unwrap_root( $root );
415 }
416
417 public static function raw_remove_at( $blocks, $address ) {
418 list( $parent, $index ) = self::raw_split_address( $address );
419 if ( $index === null ) { return null; }
420 $parts = self::raw_address_parts( $parent );
421 if ( $parts === null ) { return null; }
422 $root = self::raw_apply_at( self::raw_wrap_root( $blocks ), $parts, function( $p ) use ( $index ) {
423 return self::raw_unsplice_child( $p, $index );
424 } );
425 return $root === null ? null : self::raw_unwrap_root( $root );
426 }
427
428 /** $new_blocks is a list of raw blocks. position null = append. */
429 public static function raw_insert_at( $blocks, $parent_address, $position, $new_blocks ) {
430 $parts = self::raw_address_parts( $parent_address );
431 if ( $parts === null ) { return null; }
432 $root = self::raw_apply_at( self::raw_wrap_root( $blocks ), $parts, function( $p ) use ( $position, $new_blocks ) {
433 $n = isset( $p['innerBlocks'] ) && is_array( $p['innerBlocks'] ) ? count( $p['innerBlocks'] ) : 0;
434 return self::raw_splice_children( $p, $position === null ? $n : (int) $position, $new_blocks );
435 } );
436 return $root === null ? null : self::raw_unwrap_root( $root );
437 }
438
439 /** Build a leaf raw block. Containers must come from markup, not from a name. */
440 public static function raw_make_block( $name, $attrs = [], $html = '' ) {
441 $name = (string) $name;
442 $html = (string) $html;
443 $attrs = is_array( $attrs ) ? $attrs : [];
444 return [ 'blockName' => $name !== '' ? $name : null, 'attrs' => $attrs, 'innerBlocks' => [], 'innerHTML' => $html, 'innerContent' => $html !== '' ? [ $html ] : [] ];
445 }
446
447 /** Set a raw block's own inner HTML. Refuses when it would orphan children. */
448 public static function raw_set_html( $block, $html ) {
449 $kids = isset( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ? count( $block['innerBlocks'] ) : 0;
450 if ( $kids > 0 ) { return null; }
451 $html = (string) $html;
452 $block['innerHTML'] = $html;
453 $block['innerContent'] = $html !== '' ? [ $html ] : [];
454 return $block;
455 }
456
457 }