class-avcf-abilities-divi-pro.php
4 days ago
class-avcf-abilities-divi.php
4 days ago
class-avcf-divi-detector.php
4 days ago
class-avcf-divi-helpers.php
4 days ago
class-avcf-divi-helpers.php
533 lines
| 1 | <?php |
| 2 | /** |
| 3 | * Shared helpers for the Divi 5 ability cluster. |
| 4 | * |
| 5 | * Divi 5 stores page content as `divi/*` WordPress blocks in post_content. We |
| 6 | * parse with core parse_blocks() into a nested tree of { name, attrs, children } |
| 7 | * nodes (non-Divi blocks preserved verbatim as opaque passthrough so writes |
| 8 | * never drop them), address nodes by slash-path of child indices ("0/1/0"), and |
| 9 | * serialize back with core serialize_blocks(). Built on stable WP core block |
| 10 | * functions, so the content round-trip is reliable; the module *registry* |
| 11 | * (list/schema) depends on Divi 5 internals and degrades gracefully. |
| 12 | * |
| 13 | * Divi 4 shortcodes are NOT handled. Not runtime-tested here. |
| 14 | * |
| 15 | * @package atarim-visual-collaboration |
| 16 | */ |
| 17 | |
| 18 | if ( ! defined('ABSPATH') ) { |
| 19 | exit; |
| 20 | } |
| 21 | |
| 22 | class AVCF_Divi_Helpers { |
| 23 | |
| 24 | /** Structural skeleton: section > row > column > (modules / group). */ |
| 25 | public static function structure_children() { |
| 26 | return [ |
| 27 | 'divi/section' => [ 'divi/row' ], |
| 28 | 'divi/row' => [ 'divi/column' ], |
| 29 | 'divi/row-inner' => [ 'divi/column-inner' ], |
| 30 | 'divi/column' => [ 'divi/row-inner', 'divi/group' ], |
| 31 | 'divi/column-inner' => [ 'divi/group' ], |
| 32 | 'divi/group' => [], |
| 33 | ]; |
| 34 | } |
| 35 | public static function raw_html_modules() { |
| 36 | return [ 'divi/code', 'divi/fullwidth-code' ]; |
| 37 | } |
| 38 | |
| 39 | /* ---------------------------- round-trip --------------------------- */ |
| 40 | |
| 41 | public static function parse_tree( $content ) { |
| 42 | return self::blocks_to_tree( parse_blocks( (string) $content ) ); |
| 43 | } |
| 44 | |
| 45 | private static function blocks_to_tree( $blocks ) { |
| 46 | $tree = []; |
| 47 | foreach ( (array) $blocks as $i => $block ) { |
| 48 | $name = isset( $block['blockName'] ) && is_string( $block['blockName'] ) ? $block['blockName'] : ''; |
| 49 | if ( strpos( $name, 'divi/' ) === 0 ) { |
| 50 | $inner = isset( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ? $block['innerBlocks'] : []; |
| 51 | $tree[] = [ |
| 52 | 'index' => (int) $i, |
| 53 | 'name' => $name, |
| 54 | 'attrs' => isset( $block['attrs'] ) && is_array( $block['attrs'] ) ? $block['attrs'] : [], |
| 55 | 'children' => self::blocks_to_tree( $inner ), |
| 56 | ]; |
| 57 | continue; |
| 58 | } |
| 59 | $html = isset( $block['innerHTML'] ) && is_string( $block['innerHTML'] ) ? $block['innerHTML'] : ''; |
| 60 | if ( $name === '' && trim( $html ) === '' ) { |
| 61 | continue; // insignificant whitespace |
| 62 | } |
| 63 | $tree[] = [ 'index' => (int) $i, 'name' => $name, '_raw' => $block, 'children' => [] ]; |
| 64 | } |
| 65 | return $tree; |
| 66 | } |
| 67 | |
| 68 | /** |
| 69 | * WHOLE-DOCUMENT OVERWRITE ONLY (set-content), where the caller supplies the |
| 70 | * entire tree and there is no prior markup to preserve. Never use this to |
| 71 | * write back a tree that was read from an existing post: the read projection |
| 72 | * discards each divi/* block's own inner HTML (which is where divi/code and |
| 73 | * divi/fullwidth-code keep their content) and all inter-block whitespace, so |
| 74 | * re-serialising it destroys them. Granular edits go through the raw_* engine. |
| 75 | */ |
| 76 | public static function serialize_tree( $tree ) { |
| 77 | return serialize_blocks( self::tree_to_blocks( $tree ) ); |
| 78 | } |
| 79 | |
| 80 | private static function tree_to_blocks( $tree ) { |
| 81 | $blocks = []; |
| 82 | foreach ( (array) $tree as $node ) { |
| 83 | if ( isset( $node['_raw'] ) && is_array( $node['_raw'] ) ) { |
| 84 | $blocks[] = self::passthrough_block( $node['_raw'] ); |
| 85 | continue; |
| 86 | } |
| 87 | $children = isset( $node['children'] ) && is_array( $node['children'] ) ? $node['children'] : []; |
| 88 | $inner = self::tree_to_blocks( $children ); |
| 89 | $blocks[] = [ |
| 90 | 'blockName' => isset( $node['name'] ) && is_string( $node['name'] ) ? $node['name'] : '', |
| 91 | 'attrs' => isset( $node['attrs'] ) && is_array( $node['attrs'] ) ? $node['attrs'] : [], |
| 92 | 'innerBlocks' => $inner, |
| 93 | 'innerHTML' => '', |
| 94 | 'innerContent' => $inner ? array_fill( 0, count( $inner ), null ) : [], |
| 95 | ]; |
| 96 | } |
| 97 | return $blocks; |
| 98 | } |
| 99 | |
| 100 | private static function passthrough_block( $raw ) { |
| 101 | $inner = []; |
| 102 | foreach ( ( isset( $raw['innerBlocks'] ) && is_array( $raw['innerBlocks'] ) ? $raw['innerBlocks'] : [] ) as $child ) { |
| 103 | if ( is_array( $child ) ) { $inner[] = $child; } |
| 104 | } |
| 105 | return [ |
| 106 | 'blockName' => isset( $raw['blockName'] ) && is_string( $raw['blockName'] ) ? $raw['blockName'] : null, |
| 107 | 'attrs' => isset( $raw['attrs'] ) && is_array( $raw['attrs'] ) ? $raw['attrs'] : [], |
| 108 | 'innerBlocks' => $inner, |
| 109 | 'innerHTML' => isset( $raw['innerHTML'] ) && is_string( $raw['innerHTML'] ) ? $raw['innerHTML'] : '', |
| 110 | 'innerContent' => isset( $raw['innerContent'] ) && is_array( $raw['innerContent'] ) ? $raw['innerContent'] : [], |
| 111 | ]; |
| 112 | } |
| 113 | |
| 114 | public static function read_tree( $post_id ) { |
| 115 | $post = get_post( $post_id ); |
| 116 | return $post ? self::parse_tree( $post->post_content ) : []; |
| 117 | } |
| 118 | public static function write_tree( $post_id, $tree ) { |
| 119 | $res = wp_update_post( [ 'ID' => (int) $post_id, 'post_content' => self::serialize_tree( $tree ) ], true ); |
| 120 | return ! is_wp_error( $res ); |
| 121 | } |
| 122 | |
| 123 | /* ----------------------------- address ----------------------------- */ |
| 124 | |
| 125 | public static function split_address( $address ) { |
| 126 | $segments = explode( '/', (string) $address ); |
| 127 | $index = (int) array_pop( $segments ); |
| 128 | return [ implode( '/', $segments ), $index ]; |
| 129 | } |
| 130 | |
| 131 | /** Strict: numeric, non-empty, no leading-zero aliasing. */ |
| 132 | private static function valid_segment( $segment ) { |
| 133 | if ( $segment === '' || ! ctype_digit( $segment ) ) { return false; } |
| 134 | if ( $segment !== '0' && $segment[0] === '0' ) { return false; } |
| 135 | return true; |
| 136 | } |
| 137 | |
| 138 | public static function tree_get( $tree, $address ) { |
| 139 | $nodes = $tree; $found = null; |
| 140 | foreach ( explode( '/', (string) $address ) as $segment ) { |
| 141 | if ( ! self::valid_segment( $segment ) ) { return null; } |
| 142 | $index = (int) $segment; |
| 143 | if ( ! array_key_exists( $index, $nodes ) ) { return null; } |
| 144 | $found = $nodes[ $index ]; |
| 145 | $nodes = isset( $found['children'] ) && is_array( $found['children'] ) ? $found['children'] : []; |
| 146 | } |
| 147 | return $found; |
| 148 | } |
| 149 | |
| 150 | public static function tree_update_children( $tree, $parent_address, $op ) { |
| 151 | if ( $parent_address === '' ) { return call_user_func( $op, $tree ); } |
| 152 | return self::tree_descend( $tree, explode( '/', $parent_address ), $op ); |
| 153 | } |
| 154 | private static function tree_descend( $nodes, $path, $op ) { |
| 155 | $index = (int) array_shift( $path ); |
| 156 | if ( ! array_key_exists( $index, $nodes ) ) { return null; } |
| 157 | $children = isset( $nodes[ $index ]['children'] ) && is_array( $nodes[ $index ]['children'] ) ? $nodes[ $index ]['children'] : []; |
| 158 | $updated = empty( $path ) ? call_user_func( $op, $children ) : self::tree_descend( $children, $path, $op ); |
| 159 | if ( $updated === null ) { return null; } |
| 160 | $nodes[ $index ]['children'] = $updated; |
| 161 | return $nodes; |
| 162 | } |
| 163 | |
| 164 | public static function tree_replace( $tree, $address, $node ) { |
| 165 | list( $parent, $index ) = self::split_address( $address ); |
| 166 | return self::tree_update_children( $tree, $parent, function( $children ) use ( $index, $node ) { |
| 167 | if ( ! array_key_exists( $index, $children ) ) { return null; } |
| 168 | $children[ $index ] = $node; |
| 169 | return $children; |
| 170 | } ); |
| 171 | } |
| 172 | public static function tree_remove( $tree, $address ) { |
| 173 | list( $parent, $index ) = self::split_address( $address ); |
| 174 | return self::tree_update_children( $tree, $parent, function( $children ) use ( $index ) { |
| 175 | if ( ! array_key_exists( $index, $children ) ) { return null; } |
| 176 | array_splice( $children, $index, 1 ); |
| 177 | return $children; |
| 178 | } ); |
| 179 | } |
| 180 | public static function tree_insert( $tree, $parent_address, $position, $node ) { |
| 181 | return self::tree_update_children( $tree, $parent_address, function( $children ) use ( $position, $node ) { |
| 182 | $pos = max( 0, min( (int) $position, count( $children ) ) ); |
| 183 | array_splice( $children, $pos, 0, [ $node ] ); |
| 184 | return $children; |
| 185 | } ); |
| 186 | } |
| 187 | |
| 188 | /* --------------------------- flatten / view ------------------------ */ |
| 189 | |
| 190 | /** Flatten to addressed rows for get-content. */ |
| 191 | public static function flatten( $tree, $include_attrs = false, $cap = 400 ) { |
| 192 | $out = []; $count = [ 0 ]; |
| 193 | self::flatten_level( $tree, '', '', $include_attrs, $cap, $out, $count ); |
| 194 | return $out; |
| 195 | } |
| 196 | private static function flatten_level( $nodes, $prefix, $parent, $include_attrs, $cap, &$out, &$count ) { |
| 197 | foreach ( $nodes as $node ) { |
| 198 | $i = isset( $node['index'] ) ? (int) $node['index'] : 0; |
| 199 | if ( $count[0] >= $cap ) { return; } |
| 200 | $addr = $prefix === '' ? (string) $i : $prefix . '/' . $i; |
| 201 | $children = isset( $node['children'] ) && is_array( $node['children'] ) ? $node['children'] : []; |
| 202 | $is_raw = isset( $node['_raw'] ); |
| 203 | $row = [ |
| 204 | 'address' => $addr, |
| 205 | 'name' => isset( $node['name'] ) ? (string) $node['name'] : '', |
| 206 | 'parent' => $parent, |
| 207 | 'child_count' => count( $children ), |
| 208 | 'passthrough' => $is_raw, |
| 209 | ]; |
| 210 | if ( $include_attrs && ! $is_raw ) { |
| 211 | $row['attrs'] = isset( $node['attrs'] ) && is_array( $node['attrs'] ) ? $node['attrs'] : []; |
| 212 | } |
| 213 | $out[] = $row; |
| 214 | $count[0]++; |
| 215 | if ( $children ) { self::flatten_level( $children, $addr, $addr, $include_attrs, $cap, $out, $count ); } |
| 216 | } |
| 217 | } |
| 218 | public static function tree_count( $tree ) { |
| 219 | $n = 0; |
| 220 | foreach ( (array) $tree as $node ) { |
| 221 | $n++; |
| 222 | if ( isset( $node['children'] ) && is_array( $node['children'] ) ) { $n += self::tree_count( $node['children'] ); } |
| 223 | } |
| 224 | return $n; |
| 225 | } |
| 226 | |
| 227 | /* --------------------------- module meta --------------------------- */ |
| 228 | |
| 229 | public static function normalize_module_name( $name ) { |
| 230 | $name = trim( (string) $name ); |
| 231 | if ( $name === '' ) { return ''; } |
| 232 | return strpos( $name, 'divi/' ) === 0 ? $name : 'divi/' . ltrim( $name, '/' ); |
| 233 | } |
| 234 | |
| 235 | /** Module metadata from the Divi 5 registry, or null if unavailable. */ |
| 236 | public static function module_metadata( $name ) { |
| 237 | $name = self::normalize_module_name( $name ); |
| 238 | $cls = '\ET\Builder\Packages\ModuleLibrary\ModuleRegistration'; |
| 239 | if ( $name === '' || ! class_exists( $cls ) || ! method_exists( $cls, 'get_core_module_metadata' ) ) { |
| 240 | return null; |
| 241 | } |
| 242 | try { |
| 243 | $meta = call_user_func( [ $cls, 'get_core_module_metadata' ], $name ); |
| 244 | } catch ( \Throwable $e ) { |
| 245 | return null; |
| 246 | } |
| 247 | if ( ! is_array( $meta ) || $meta === [] || ( ( $meta['name'] ?? null ) !== $name ) ) { |
| 248 | return null; |
| 249 | } |
| 250 | return $meta; |
| 251 | } |
| 252 | |
| 253 | /** Curated fallback list of common Divi 5 modules (when registry can't enumerate). */ |
| 254 | public static function fallback_modules() { |
| 255 | return [ |
| 256 | [ 'name' => 'divi/section', 'category' => 'structure' ], |
| 257 | [ 'name' => 'divi/row', 'category' => 'structure' ], |
| 258 | [ 'name' => 'divi/column', 'category' => 'structure' ], |
| 259 | [ 'name' => 'divi/text', 'category' => 'module' ], |
| 260 | [ 'name' => 'divi/heading', 'category' => 'module' ], |
| 261 | [ 'name' => 'divi/image', 'category' => 'module' ], |
| 262 | [ 'name' => 'divi/button', 'category' => 'module' ], |
| 263 | [ 'name' => 'divi/blurb', 'category' => 'module' ], |
| 264 | [ 'name' => 'divi/cta', 'category' => 'module' ], |
| 265 | [ 'name' => 'divi/divider', 'category' => 'module' ], |
| 266 | [ 'name' => 'divi/icon', 'category' => 'module' ], |
| 267 | [ 'name' => 'divi/code', 'category' => 'module' ], |
| 268 | [ 'name' => 'divi/blog', 'category' => 'module' ], |
| 269 | [ 'name' => 'divi/gallery', 'category' => 'module' ], |
| 270 | [ 'name' => 'divi/accordion', 'category' => 'module' ], |
| 271 | [ 'name' => 'divi/tabs', 'category' => 'module' ], |
| 272 | [ 'name' => 'divi/toggle', 'category' => 'module' ], |
| 273 | [ 'name' => 'divi/slider', 'category' => 'module' ], |
| 274 | [ 'name' => 'divi/testimonial', 'category' => 'module' ], |
| 275 | [ 'name' => 'divi/video', 'category' => 'module' ], |
| 276 | ]; |
| 277 | } |
| 278 | |
| 279 | /** Lite structural validation. Returns ['errors'=>[], 'warnings'=>[]]. */ |
| 280 | public static function validate_tree( $tree ) { |
| 281 | $errors = []; $warnings = []; |
| 282 | self::validate_level( $tree, null, $errors, $warnings ); |
| 283 | return [ 'errors' => array_values( array_unique( $errors ) ), 'warnings' => array_values( array_unique( $warnings ) ) ]; |
| 284 | } |
| 285 | private static function validate_level( $nodes, $parent_name, &$errors, &$warnings ) { |
| 286 | $structure = self::structure_children(); |
| 287 | foreach ( (array) $nodes as $node ) { |
| 288 | $name = isset( $node['name'] ) ? (string) $node['name'] : ''; |
| 289 | if ( isset( $node['_raw'] ) ) { continue; } // passthrough, not validated |
| 290 | if ( $parent_name === null ) { |
| 291 | if ( $name !== 'divi/section' ) { |
| 292 | $errors[] = sprintf( '"%s" is not allowed at the top level — only divi/section may sit at the root.', $name ); |
| 293 | } |
| 294 | } elseif ( array_key_exists( $parent_name, $structure ) ) { |
| 295 | $allowed = $structure[ $parent_name ]; |
| 296 | // Containers that hold leaf modules (column/column-inner/group) accept any module. |
| 297 | $is_container_for_modules = in_array( $parent_name, [ 'divi/column', 'divi/column-inner', 'divi/group' ], true ); |
| 298 | if ( ! $is_container_for_modules && ! in_array( $name, $allowed, true ) ) { |
| 299 | $errors[] = sprintf( '"%s" is not allowed inside "%s".', $name, $parent_name ); |
| 300 | } |
| 301 | } |
| 302 | if ( in_array( $name, self::raw_html_modules(), true ) ) { |
| 303 | $warnings[] = sprintf( '"%s" embeds raw HTML; prefer native Divi modules so the layout stays editable.', $name ); |
| 304 | } |
| 305 | $children = isset( $node['children'] ) && is_array( $node['children'] ) ? $node['children'] : []; |
| 306 | self::validate_level( $children, $name, $errors, $warnings ); |
| 307 | } |
| 308 | } |
| 309 | |
| 310 | /** Recursive deep-merge (associative merged; list values replaced). */ |
| 311 | public static function deep_merge( $base, $patch ) { |
| 312 | foreach ( (array) $patch as $k => $v ) { |
| 313 | if ( is_array( $v ) && isset( $base[ $k ] ) && is_array( $base[ $k ] ) && self::is_assoc( $v ) && self::is_assoc( $base[ $k ] ) ) { |
| 314 | $base[ $k ] = self::deep_merge( $base[ $k ], $v ); |
| 315 | } else { |
| 316 | $base[ $k ] = $v; |
| 317 | } |
| 318 | } |
| 319 | return $base; |
| 320 | } |
| 321 | private static function is_assoc( $arr ) { |
| 322 | if ( ! is_array( $arr ) || $arr === [] ) { return false; } |
| 323 | return array_keys( $arr ) !== range( 0, count( $arr ) - 1 ); |
| 324 | } |
| 325 | |
| 326 | /** Shallow {name, attrs, children} projection of a raw block, for validate_tree() only. */ |
| 327 | public static function raw_to_probe( $block ) { |
| 328 | $name = isset( $block['blockName'] ) && is_string( $block['blockName'] ) ? $block['blockName'] : ''; |
| 329 | $kids = []; |
| 330 | foreach ( ( isset( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ? $block['innerBlocks'] : [] ) as $c ) { |
| 331 | $kids[] = self::raw_to_probe( $c ); |
| 332 | } |
| 333 | return [ 'name' => $name, 'attrs' => isset( $block['attrs'] ) && is_array( $block['attrs'] ) ? $block['attrs'] : [], 'children' => $kids ]; |
| 334 | } |
| 335 | |
| 336 | /* ------------------------- raw block writes ------------------------- |
| 337 | * |
| 338 | * The tree above is a READ projection and is LOSSY BY DESIGN: it drops the |
| 339 | * literal HTML chunks a container wraps its children in, and the whitespace |
| 340 | * between siblings. Writes must never round-trip through it -- that is the |
| 341 | * bug this section replaced. Everything below mutates the raw parse_blocks() |
| 342 | * array and serializes that, so nothing is translated and nothing is lost. |
| 343 | * |
| 344 | * innerContent is the interleave serialize_block() walks: every string is |
| 345 | * printed literally, every null consumes the next entry of innerBlocks. |
| 346 | * Adding or removing a child without keeping the null count in step silently |
| 347 | * drops or duplicates content. raw_splice_children() and raw_unsplice_child() |
| 348 | * are the only two places allowed to touch it. |
| 349 | * |
| 350 | * Addresses are chains of RAW parse_blocks indices. The read view skips |
| 351 | * whitespace nodes, so published addresses are not contiguous. Never renumber. |
| 352 | */ |
| 353 | |
| 354 | public static function parse_raw( $content ) { return parse_blocks( (string) $content ); } |
| 355 | public static function serialize_raw( $blocks ) { return serialize_blocks( (array) $blocks ); } |
| 356 | |
| 357 | public static function read_raw( $post_id ) { |
| 358 | $post = get_post( $post_id ); |
| 359 | return $post ? self::parse_raw( $post->post_content ) : []; |
| 360 | } |
| 361 | public static function write_raw( $post_id, $blocks ) { |
| 362 | $res = wp_update_post( [ 'ID' => (int) $post_id, 'post_content' => self::serialize_raw( $blocks ) ], true ); |
| 363 | return ! is_wp_error( $res ); |
| 364 | } |
| 365 | |
| 366 | /** Strict index-path -> int list. '' / 'root' = document root. null = malformed. */ |
| 367 | public static function raw_address_parts( $address ) { |
| 368 | $address = trim( (string) $address ); |
| 369 | if ( $address === '' || $address === 'root' ) { return []; } |
| 370 | $parts = []; |
| 371 | foreach ( explode( '/', $address ) as $seg ) { |
| 372 | if ( $seg === '' || ! ctype_digit( $seg ) ) { return null; } |
| 373 | if ( $seg !== '0' && $seg[0] === '0' ) { return null; } |
| 374 | $parts[] = (int) $seg; |
| 375 | } |
| 376 | return $parts; |
| 377 | } |
| 378 | public static function raw_split_address( $address ) { |
| 379 | $parts = self::raw_address_parts( $address ); |
| 380 | if ( $parts === null || $parts === [] ) { return [ '', null ]; } |
| 381 | $index = array_pop( $parts ); |
| 382 | return [ implode( '/', $parts ), $index ]; |
| 383 | } |
| 384 | |
| 385 | public static function raw_node_at( $blocks, $address ) { |
| 386 | $parts = self::raw_address_parts( $address ); |
| 387 | if ( $parts === null ) { return null; } |
| 388 | $nodes = (array) $blocks; $found = null; |
| 389 | foreach ( $parts as $i ) { |
| 390 | if ( ! array_key_exists( $i, $nodes ) ) { return null; } |
| 391 | $found = $nodes[ $i ]; |
| 392 | $nodes = isset( $found['innerBlocks'] ) && is_array( $found['innerBlocks'] ) ? $found['innerBlocks'] : []; |
| 393 | } |
| 394 | return $found; |
| 395 | } |
| 396 | |
| 397 | /* |
| 398 | * The document root is not a block and has no innerContent. Wrapping it in a |
| 399 | * synthetic parent lets every address -- root included -- share one splice |
| 400 | * path; the synthetic innerContent is discarded on unwrap. |
| 401 | */ |
| 402 | private static function raw_wrap_root( $blocks ) { |
| 403 | $blocks = array_values( (array) $blocks ); |
| 404 | return [ 'blockName' => null, 'attrs' => [], 'innerBlocks' => $blocks, 'innerHTML' => '', 'innerContent' => array_fill( 0, count( $blocks ), null ) ]; |
| 405 | } |
| 406 | private static function raw_unwrap_root( $root ) { |
| 407 | return ( is_array( $root ) && isset( $root['innerBlocks'] ) && is_array( $root['innerBlocks'] ) ) ? array_values( $root['innerBlocks'] ) : []; |
| 408 | } |
| 409 | private static function raw_apply_at( $block, $parts, $fn ) { |
| 410 | if ( $parts === [] ) { return call_user_func( $fn, $block ); } |
| 411 | $i = array_shift( $parts ); |
| 412 | $inner = isset( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ? $block['innerBlocks'] : []; |
| 413 | if ( ! array_key_exists( $i, $inner ) ) { return null; } |
| 414 | $updated = self::raw_apply_at( $inner[ $i ], $parts, $fn ); |
| 415 | if ( $updated === null ) { return null; } |
| 416 | $inner[ $i ] = $updated; |
| 417 | $block['innerBlocks'] = $inner; |
| 418 | return $block; |
| 419 | } |
| 420 | |
| 421 | private static function raw_placeholder_offsets( $inner_content ) { |
| 422 | $pos = []; |
| 423 | foreach ( (array) $inner_content as $i => $chunk ) { if ( ! is_string( $chunk ) ) { $pos[] = $i; } } |
| 424 | return $pos; |
| 425 | } |
| 426 | |
| 427 | /** |
| 428 | * A container with no children has no placeholder to insert against and its |
| 429 | * wrapper is one opaque literal. Split it just before its final closing tag |
| 430 | * so the first child lands inside. null = cannot open safely; caller refuses. |
| 431 | */ |
| 432 | private static function raw_open_empty_container( $inner_content ) { |
| 433 | $joined = ''; |
| 434 | foreach ( (array) $inner_content as $chunk ) { if ( is_string( $chunk ) ) { $joined .= $chunk; } } |
| 435 | if ( $joined === '' ) { return [ null ]; } |
| 436 | if ( trim( $joined ) === '' ) { return [ $joined, null ]; } |
| 437 | if ( ! preg_match( '~</[A-Za-z][A-Za-z0-9:-]*>\s*$~', $joined, $m, PREG_OFFSET_CAPTURE ) ) { return null; } |
| 438 | $at = (int) $m[0][1]; |
| 439 | return [ substr( $joined, 0, $at ), null, substr( $joined, $at ) ]; |
| 440 | } |
| 441 | |
| 442 | private static function raw_splice_children( $parent, $k, $new ) { |
| 443 | $new = array_values( (array) $new ); |
| 444 | if ( $new === [] ) { return $parent; } |
| 445 | $inner = isset( $parent['innerBlocks'] ) && is_array( $parent['innerBlocks'] ) ? $parent['innerBlocks'] : []; |
| 446 | $ic = isset( $parent['innerContent'] ) && is_array( $parent['innerContent'] ) ? array_values( $parent['innerContent'] ) : []; |
| 447 | $k = max( 0, min( (int) $k, count( $inner ) ) ); |
| 448 | $slots = self::raw_placeholder_offsets( $ic ); |
| 449 | if ( $slots === [] ) { |
| 450 | $opened = self::raw_open_empty_container( $ic ); |
| 451 | if ( $opened === null ) { return null; } |
| 452 | $ic = $opened; |
| 453 | $s = self::raw_placeholder_offsets( $ic ); |
| 454 | $at = $s[0]; |
| 455 | array_splice( $ic, $at, 1 ); |
| 456 | } else { |
| 457 | $at = ( $k < count( $slots ) ) ? $slots[ $k ] : ( $slots[ count( $slots ) - 1 ] + 1 ); |
| 458 | } |
| 459 | array_splice( $inner, $k, 0, $new ); |
| 460 | array_splice( $ic, $at, 0, array_fill( 0, count( $new ), null ) ); |
| 461 | $parent['innerBlocks'] = $inner; |
| 462 | $parent['innerContent'] = $ic; |
| 463 | return $parent; |
| 464 | } |
| 465 | |
| 466 | private static function raw_unsplice_child( $parent, $k ) { |
| 467 | $inner = isset( $parent['innerBlocks'] ) && is_array( $parent['innerBlocks'] ) ? $parent['innerBlocks'] : []; |
| 468 | $ic = isset( $parent['innerContent'] ) && is_array( $parent['innerContent'] ) ? array_values( $parent['innerContent'] ) : []; |
| 469 | if ( ! array_key_exists( $k, $inner ) ) { return null; } |
| 470 | $slots = self::raw_placeholder_offsets( $ic ); |
| 471 | array_splice( $inner, $k, 1 ); |
| 472 | if ( isset( $slots[ $k ] ) ) { array_splice( $ic, $slots[ $k ], 1 ); } |
| 473 | $parent['innerBlocks'] = $inner; |
| 474 | $parent['innerContent'] = $ic; |
| 475 | return $parent; |
| 476 | } |
| 477 | |
| 478 | public static function raw_replace_at( $blocks, $address, $new_block ) { |
| 479 | list( $parent, $index ) = self::raw_split_address( $address ); |
| 480 | if ( $index === null ) { return null; } |
| 481 | $parts = self::raw_address_parts( $parent ); |
| 482 | if ( $parts === null ) { return null; } |
| 483 | $root = self::raw_apply_at( self::raw_wrap_root( $blocks ), $parts, function( $p ) use ( $index, $new_block ) { |
| 484 | $inner = isset( $p['innerBlocks'] ) && is_array( $p['innerBlocks'] ) ? $p['innerBlocks'] : []; |
| 485 | if ( ! array_key_exists( $index, $inner ) ) { return null; } |
| 486 | $inner[ $index ] = $new_block; |
| 487 | $p['innerBlocks'] = $inner; |
| 488 | return $p; |
| 489 | } ); |
| 490 | return $root === null ? null : self::raw_unwrap_root( $root ); |
| 491 | } |
| 492 | |
| 493 | public static function raw_remove_at( $blocks, $address ) { |
| 494 | list( $parent, $index ) = self::raw_split_address( $address ); |
| 495 | if ( $index === null ) { return null; } |
| 496 | $parts = self::raw_address_parts( $parent ); |
| 497 | if ( $parts === null ) { return null; } |
| 498 | $root = self::raw_apply_at( self::raw_wrap_root( $blocks ), $parts, function( $p ) use ( $index ) { |
| 499 | return self::raw_unsplice_child( $p, $index ); |
| 500 | } ); |
| 501 | return $root === null ? null : self::raw_unwrap_root( $root ); |
| 502 | } |
| 503 | |
| 504 | /** $new_blocks is a list of raw blocks. position null = append. */ |
| 505 | public static function raw_insert_at( $blocks, $parent_address, $position, $new_blocks ) { |
| 506 | $parts = self::raw_address_parts( $parent_address ); |
| 507 | if ( $parts === null ) { return null; } |
| 508 | $root = self::raw_apply_at( self::raw_wrap_root( $blocks ), $parts, function( $p ) use ( $position, $new_blocks ) { |
| 509 | $n = isset( $p['innerBlocks'] ) && is_array( $p['innerBlocks'] ) ? count( $p['innerBlocks'] ) : 0; |
| 510 | return self::raw_splice_children( $p, $position === null ? $n : (int) $position, $new_blocks ); |
| 511 | } ); |
| 512 | return $root === null ? null : self::raw_unwrap_root( $root ); |
| 513 | } |
| 514 | |
| 515 | /** Build a leaf raw block. Containers must come from markup, not from a name. */ |
| 516 | public static function raw_make_block( $name, $attrs = [], $html = '' ) { |
| 517 | $name = (string) $name; |
| 518 | $html = (string) $html; |
| 519 | $attrs = is_array( $attrs ) ? $attrs : []; |
| 520 | return [ 'blockName' => $name !== '' ? $name : null, 'attrs' => $attrs, 'innerBlocks' => [], 'innerHTML' => $html, 'innerContent' => $html !== '' ? [ $html ] : [] ]; |
| 521 | } |
| 522 | |
| 523 | /** Set a raw block's own inner HTML. Refuses when it would orphan children. */ |
| 524 | public static function raw_set_html( $block, $html ) { |
| 525 | $kids = isset( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ? count( $block['innerBlocks'] ) : 0; |
| 526 | if ( $kids > 0 ) { return null; } |
| 527 | $html = (string) $html; |
| 528 | $block['innerHTML'] = $html; |
| 529 | $block['innerContent'] = $html !== '' ? [ $html ] : []; |
| 530 | return $block; |
| 531 | } |
| 532 | |
| 533 | } |