PluginProbe
Extendify / 3.2.2
Extendify v3.2.2
3.2.2 3.2.1 3.2.0 3.1.6 3.1.5 3.1.4 3.1.3 3.1.2 3.1.1 3.1.0 3.0.6 3.0.5 3.0.4 trunk 0.1.0 0.10.0 0.10.1 0.10.2 0.11.0 0.11.1 0.2.0 0.3.0 0.3.1 0.4.0 0.5.0 All 128 releases
extendify / app / Agent / Controllers / UpdateBlocksController.php

UpdateBlocksController.php in Extendify 3.2.2, at app/Agent/Controllers/UpdateBlocksController.php

670 lines 26.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace Extendify\Agent\Controllers;
4
5 defined('ABSPATH') || die('No direct access.');
6
7 use Extendify\Agent\PostBlockFinder;
8 use Extendify\Agent\TemplatePartBlockFinder;
9
10 // Ops splice in request order against one stamped parse, so earlier ops never
11 // invalidate later ids; untouched blocks round-trip byte-for-byte.
12 class UpdateBlocksController
13 {
14 // Labels match the words the apply helpers use for a missing id.
15 // phpcs:ignore PSR12.Properties.ConstantVisibility.NotFound
16 const ID_FIELDS = [
17 'blockId' => 'block id',
18 'anchorId' => 'anchor block',
19 'targetId' => 'target block',
20 ];
21
22 // Keyed by the model-facing container word; the placeholder paragraph
23 // marks the wrapped block's slot.
24 // phpcs:ignore PSR12.Properties.ConstantVisibility.NotFound
25 const WRAP_TEMPLATES = [
26 'core/column' => '<!-- wp:columns --><div class="wp-block-columns">'
27 . '<!-- wp:column --><div class="wp-block-column">'
28 . '<!-- wp:paragraph --><p></p><!-- /wp:paragraph -->'
29 . '</div><!-- /wp:column -->'
30 . '</div><!-- /wp:columns -->',
31 'core/group' => '<!-- wp:group {"layout":{"type":"constrained"}} -->'
32 . '<div class="wp-block-group">'
33 . '<!-- wp:paragraph --><p></p><!-- /wp:paragraph -->'
34 . '</div><!-- /wp:group -->',
35 ];
36
37 /**
38 * Apply a batch of block operations to a post.
39 *
40 * @param \WP_REST_Request $request The REST API request object.
41 * @return \WP_REST_Response
42 */
43 public static function updateBlocks(\WP_REST_Request $request)
44 {
45 $params = $request->get_json_params();
46 $params = is_array($params) ? $params : [];
47
48 $partSlug = (string) ($params['partSlug'] ?? '');
49 $inTemplatePart = $partSlug !== '';
50
51 if ($inTemplatePart) {
52 $post = self::resolveTemplatePart($partSlug);
53 if (\is_wp_error($post)) {
54 return new \WP_REST_Response(['error' => $post->get_error_message()], 404);
55 }
56 if (!\current_user_can('edit_theme_options')) {
57 return new \WP_REST_Response(['error' => 'Forbidden for this template part'], 403);
58 }
59 $blocks = TemplatePartBlockFinder::stamp(\parse_blocks($post->post_content));
60 } else {
61 $postId = (int) ($params['postId'] ?? 0);
62 $post = $postId ? \get_post($postId) : null;
63 if (!$post) {
64 return new \WP_REST_Response(['error' => 'Post not found'], 404);
65 }
66 if (!\current_user_can('edit_post', $post->ID)) {
67 return new \WP_REST_Response(['error' => 'Forbidden for this post'], 403);
68 }
69 $blocks = PostBlockFinder::stamp(\parse_blocks($post->post_content));
70 }
71
72 $operations = isset($params['operations']) && is_array($params['operations'])
73 ? $params['operations']
74 : [];
75 if (!$operations) {
76 return new \WP_REST_Response(['error' => 'operations required'], 400);
77 }
78
79 $trees = [$post->ID => self::newTree($post, $blocks)];
80
81 $applied = [];
82 $refused = [];
83 foreach ($operations as $operation) {
84 $operation = is_array($operation) ? $operation : [];
85 $op = (string) ($operation['op'] ?? '');
86 $reportKey = $op === 'add' ? 'anchorId' : 'blockId';
87 $reportId = $operation[$reportKey] ?? null;
88
89 $routed = self::route($operation, $trees, $post);
90 if (\is_wp_error($routed)) {
91 $refused[] = [$reportKey => $reportId, 'reason' => $routed->get_error_message()];
92 continue;
93 }
94 $owner = $routed['owner'];
95 $operation = $routed['operation'];
96 $blockId = (int) ($operation['blockId'] ?? 0);
97
98 if ($op === 'add') {
99 $reason = self::applyAdd(
100 $trees[$owner]['blocks'],
101 (int) ($operation['anchorId'] ?? 0),
102 $operation,
103 $trees[$owner]['wrappers']
104 );
105 } elseif ($op === 'wrap') {
106 $reason = self::applyWrap(
107 $trees[$owner]['blocks'],
108 $blockId,
109 $operation,
110 $trees[$owner]['wrappers']
111 );
112 } elseif ($op === 'duplicate') {
113 $reason = self::applyDuplicate($trees[$owner]['blocks'], $blockId, $operation);
114 } elseif (in_array($op, ['edit', 'delete', 'move'], true)) {
115 $reason = self::applyOperation($trees[$owner]['blocks'], $op, $blockId, $operation);
116 } else {
117 $reason = 'unknown op';
118 }
119
120 if ($reason !== null) {
121 $refused[] = [$reportKey => $reportId, 'reason' => $reason];
122 continue;
123 }
124 $trees[$owner]['dirty'] = true;
125 $applied[] = ['op' => $op, $reportKey => $reportId, 'owner' => $owner];
126 }
127
128 $failed = [];
129 foreach ($trees as $tree) {
130 if (!$tree['dirty']) {
131 continue;
132 }
133 $update = \wp_update_post([
134 'ID' => $tree['post']->ID,
135 'post_content' => \wp_slash(\serialize_blocks($tree['blocks'])),
136 ], true);
137 if (\is_wp_error($update)) {
138 $failed[$tree['post']->ID] = $update->get_error_message();
139 }
140 }
141
142 // An earlier post is already written, so a failed save reports itself.
143 foreach ($applied as $index => $entry) {
144 if (!isset($failed[$entry['owner']])) {
145 continue;
146 }
147 $refused[] = [
148 'blockId' => $entry['blockId'] ?? ($entry['anchorId'] ?? null),
149 'reason' => $failed[$entry['owner']],
150 ];
151 unset($applied[$index]);
152 }
153
154 return new \WP_REST_Response([
155 'applied' => array_values(array_map(function ($entry) {
156 unset($entry['owner']);
157 return $entry;
158 }, $applied)),
159 'refused' => $refused,
160 ], 200);
161 }
162
163 // A composite id names another post, and a splice can't reach across two of
164 // them — so an operation whose ids disagree on the owner has nowhere to land.
165 private static function route(array $operation, array &$trees, \WP_Post $container)
166 {
167 $owner = null;
168 foreach (self::ID_FIELDS as $field => $label) {
169 if (!isset($operation[$field])) {
170 continue;
171 }
172 $resolved = TemplatePartBlockFinder::owningPost($operation[$field], $container);
173 if ($resolved === null) {
174 return new \WP_Error('not_found', "{$label} not found in this post");
175 }
176 $postId = $resolved['post']->ID;
177 if ($owner !== null && $owner !== $postId) {
178 return new \WP_Error('spans_posts', 'one operation cannot span two posts');
179 }
180 if (!isset($trees[$postId])) {
181 if (!\current_user_can('edit_post', $postId)) {
182 return new \WP_Error('forbidden', 'Forbidden for the post that owns this block');
183 }
184 $trees[$postId] = self::newTree(
185 $resolved['post'],
186 TemplatePartBlockFinder::stamp(\parse_blocks($resolved['post']->post_content))
187 );
188 }
189 $owner = $postId;
190 $operation[$field] = $resolved['blockId'];
191 }
192
193 return ['owner' => $owner ?? $container->ID, 'operation' => $operation];
194 }
195
196 // `wrappers` is per-post: an add can only join a container this same batch
197 // created in the same post.
198 private static function newTree(\WP_Post $post, array $blocks): array
199 {
200 return ['post' => $post, 'blocks' => $blocks, 'wrappers' => [], 'dirty' => false];
201 }
202
203 // Resolve via WP's own resolver so the save lands on the post WP renders
204 // from; no wp_id means an uncustomized theme-file part with nothing to
205 // save to. Mirrors QuickEdit's SaveController::resolveSourcePost.
206 private static function resolveTemplatePart(string $slug)
207 {
208 $stylesheet = \wp_get_theme()->get_stylesheet();
209 $template = \get_block_template("{$stylesheet}//{$slug}", 'wp_template_part');
210 if (!$template) {
211 return new \WP_Error('not_found', 'Template part not found');
212 }
213 $post = empty($template->wp_id) ? null : \get_post($template->wp_id);
214 if ($post) {
215 return $post;
216 }
217 return self::forkThemeTemplatePart($template, $stylesheet, $slug);
218 }
219
220 // An untouched part has no post, so the first edit has to mint one.
221 private static function forkThemeTemplatePart($template, string $stylesheet, string $slug)
222 {
223 $postId = \wp_insert_post([
224 'post_type' => 'wp_template_part',
225 'post_name' => $slug,
226 'post_title' => empty($template->title) ? $slug : $template->title,
227 'post_content' => $template->content,
228 'post_status' => 'publish',
229 ], true);
230 if (\is_wp_error($postId)) {
231 return $postId;
232 }
233
234 // Absent the theme term, get_block_template never resolves the fork again.
235 \wp_set_object_terms($postId, $stylesheet, 'wp_theme');
236 if (!empty($template->area)) {
237 \wp_set_object_terms($postId, $template->area, 'wp_template_part_area');
238 }
239
240 $post = \get_post($postId);
241 return $post ? $post : new \WP_Error('not_found', 'Template part not found');
242 }
243
244 // Returns null when the op spliced in, or the refusal reason.
245 private static function applyOperation(array &$blocks, string $op, int $blockId, array $operation)
246 {
247 if ($op === 'edit') {
248 $newBlock = self::parseSingleBlock((string) ($operation['block'] ?? ''));
249 if (!$newBlock) {
250 return 'block must parse to exactly one block';
251 }
252 $path = PostBlockFinder::pathByRef($blocks, $blockId);
253 if ($path === null) {
254 return 'block id not found in this post';
255 }
256 $newBlock = self::carryStamps(self::blockAtPath($blocks, $path), $newBlock);
257 $blocks = self::spliceAtPath($blocks, $path, $newBlock);
258 return null;
259 }
260
261 if ($op === 'delete') {
262 $path = PostBlockFinder::pathByRef($blocks, $blockId);
263 if ($path === null) {
264 return 'block id not found in this post';
265 }
266 $blocks = self::spliceAtPath($blocks, $path, null);
267 return null;
268 }
269
270 $position = (string) ($operation['position'] ?? '');
271 if (!in_array($position, ['before', 'after'], true)) {
272 return "position must be 'before' or 'after'";
273 }
274 $targetId = (int) ($operation['targetId'] ?? 0);
275 if ($targetId === $blockId) {
276 return 'cannot move a block relative to itself';
277 }
278 $sourcePath = PostBlockFinder::pathByRef($blocks, $blockId);
279 if ($sourcePath === null) {
280 return 'block id not found in this post';
281 }
282 $targetPath = PostBlockFinder::pathByRef($blocks, $targetId);
283 if ($targetPath === null) {
284 return 'target block not found in this post';
285 }
286 if (array_slice($targetPath, 0, count($sourcePath)) === $sourcePath) {
287 return 'target is inside the moved block';
288 }
289
290 $moved = self::blockAtPath($blocks, $sourcePath);
291 $blocks = self::spliceAtPath($blocks, $sourcePath, null);
292 // Extraction shifted indexes; the stamp still finds the target.
293 $targetPath = PostBlockFinder::pathByRef($blocks, $targetId);
294 $blocks = self::insertAtPath($blocks, $targetPath, $moved, $position);
295 return null;
296 }
297
298 // Returns null when the block spliced in next to its anchor, or the refusal reason.
299 private static function applyAdd(array &$blocks, int $anchorId, array $operation, array &$sharedWrappers)
300 {
301 $position = (string) ($operation['position'] ?? '');
302 if (!in_array($position, ['before', 'after', 'inside'], true)) {
303 return "position must be 'before', 'after' or 'inside'";
304 }
305 $newBlock = self::parseSingleBlock((string) ($operation['block'] ?? ''));
306 if (!$newBlock) {
307 return 'block must parse to exactly one block';
308 }
309 $anchorPath = PostBlockFinder::pathByRef($blocks, $anchorId);
310 if ($anchorPath === null) {
311 return 'anchor block not found in this post';
312 }
313 if ($position === 'inside') {
314 return self::nestInMenuItem($blocks, $anchorPath, $newBlock);
315 }
316 // A bare column is only valid as a core/columns child, so its anchor
317 // decides the splice here — the code owns the wrapper, never the model.
318 if (($newBlock['blockName'] ?? '') === 'core/column') {
319 return self::spliceColumn($blocks, $anchorPath, $newBlock, $position, $anchorId, $sharedWrappers);
320 }
321 $blocks = self::insertAtPath($blocks, $anchorPath, $newBlock, $position);
322 return null;
323 }
324
325 // The editor's "Add submenu": the link becomes a submenu keeping its label and URL.
326 private static function nestInMenuItem(array &$blocks, array $anchorPath, array $newBlock)
327 {
328 $anchor = self::blockAtPath($blocks, $anchorPath);
329 $anchorName = $anchor['blockName'] ?? '';
330 if (!in_array($anchorName, ['core/navigation-link', 'core/navigation-submenu'], true)) {
331 return "position 'inside' needs a menu item as the anchor";
332 }
333 if (($newBlock['blockName'] ?? '') !== 'core/navigation-link') {
334 return 'only a menu link can go inside a menu item';
335 }
336 // A child claiming top level renders as if it sat beside its parent.
337 unset($newBlock['attrs']['isTopLevelLink']);
338 if ($anchorName === 'core/navigation-link') {
339 $anchor['blockName'] = 'core/navigation-submenu';
340 $attrs = [];
341 foreach (($anchor['attrs'] ?? []) as $key => $value) {
342 $attrs[$key === 'isTopLevelLink' ? 'isTopLevelItem' : $key] = $value;
343 }
344 $anchor['attrs'] = $attrs;
345 }
346 $anchor['innerBlocks'][] = $newBlock;
347 $anchor['innerContent'][] = null;
348 $blocks = self::spliceAtPath($blocks, $anchorPath, $anchor);
349 return null;
350 }
351
352 // A pre-existing section never absorbs an add — the only wrapper an add
353 // joins is one this same batch created.
354 private static function spliceColumn(
355 array &$blocks,
356 array $anchorPath,
357 array $newBlock,
358 string $position,
359 int $anchorId,
360 array &$sharedWrappers
361 ) {
362 if ((self::blockAtPath($blocks, $anchorPath)['blockName'] ?? '') === 'core/column') {
363 $blocks = self::insertAtPath($blocks, $anchorPath, $newBlock, $position);
364 return null;
365 }
366
367 $key = $anchorId . ':' . $position;
368 if (isset($sharedWrappers[$key])) {
369 $wrapperPath = PostBlockFinder::pathByRef($blocks, $sharedWrappers[$key]);
370 $wrapper = self::blockAtPath($blocks, $wrapperPath);
371 $lastChild = array_merge($wrapperPath, ['innerBlocks', count($wrapper['innerBlocks']) - 1]);
372 $blocks = self::insertAtPath($blocks, $lastChild, $newBlock, 'after');
373 return null;
374 }
375
376 $wrapper = self::parseSingleBlock(
377 '<!-- wp:columns --><div class="wp-block-columns">'
378 . '<!-- wp:paragraph --><p></p><!-- /wp:paragraph -->'
379 . '</div><!-- /wp:columns -->'
380 );
381 $wrapper['innerBlocks'][0] = $newBlock;
382 // Render stamps are positive, so a negative ref can't collide.
383 $wrapper[PostBlockFinder::REF_KEY] = -(count($sharedWrappers) + 1);
384 $sharedWrappers[$key] = $wrapper[PostBlockFinder::REF_KEY];
385 $blocks = self::insertAtPath($blocks, $anchorPath, $wrapper, $position);
386 return null;
387 }
388
389 // Returns null when the container spliced in around the block, or the
390 // refusal reason. The wrapped subtree is reused verbatim — bytes and id
391 // stamps survive, so later ops in the batch can still anchor to it.
392 private static function applyWrap(array &$blocks, int $blockId, array $operation, array &$sharedWrappers)
393 {
394 $container = (string) ($operation['container'] ?? '');
395 if (!isset(self::WRAP_TEMPLATES[$container])) {
396 return 'unknown container';
397 }
398 $path = PostBlockFinder::pathByRef($blocks, $blockId);
399 if ($path === null) {
400 return 'block id not found in this post';
401 }
402 $block = self::blockAtPath($blocks, $path);
403 // Pulling a column out of core/columns leaves the parent unserializable.
404 if (($block['blockName'] ?? '') === 'core/column') {
405 return 'a column cannot be wrapped';
406 }
407 $isColumn = $container === 'core/column';
408 if ($isColumn && self::joinWrapShell($blocks, $path, $block, $blockId, $sharedWrappers)) {
409 return null;
410 }
411 $wrapper = self::parseSingleBlock(self::WRAP_TEMPLATES[$container]);
412 if ($container === 'core/column') {
413 $wrapper['innerBlocks'][0]['innerBlocks'][0] = $block;
414 // "Wrap this in a column and add another beside it": a later
415 // column add anchored to the wrapped block joins this wrapper.
416 $wrapper[PostBlockFinder::REF_KEY] = -(count($sharedWrappers) + 1);
417 $sharedWrappers[$blockId . ':after'] = $wrapper[PostBlockFinder::REF_KEY];
418 $sharedWrappers[$blockId . ':before'] = $wrapper[PostBlockFinder::REF_KEY];
419 $sharedWrappers['wrap-shell'] = $wrapper[PostBlockFinder::REF_KEY];
420 } else {
421 $wrapper['innerBlocks'][0] = $block;
422 }
423 $blocks = self::spliceAtPath($blocks, $path, $wrapper);
424 return null;
425 }
426
427 // Two column wraps in one batch mean side by side — join the first shell.
428 private static function joinWrapShell(
429 array &$blocks,
430 array $path,
431 array $block,
432 int $blockId,
433 array &$sharedWrappers
434 ) {
435 if (!isset($sharedWrappers['wrap-shell'])) {
436 return false;
437 }
438 $shellRef = $sharedWrappers['wrap-shell'];
439 $shellPath = PostBlockFinder::pathByRef($blocks, $shellRef);
440 // A shell inside the wrapped block would leave with it — start fresh.
441 if ($shellPath === null || array_slice($shellPath, 0, count($path)) === $path) {
442 return false;
443 }
444 $blocks = self::spliceAtPath($blocks, $path, null);
445 $shellPath = PostBlockFinder::pathByRef($blocks, $shellRef);
446 $shell = self::blockAtPath($blocks, $shellPath);
447 $column = self::parseSingleBlock(
448 '<!-- wp:column --><div class="wp-block-column">'
449 . '<!-- wp:paragraph --><p></p><!-- /wp:paragraph -->'
450 . '</div><!-- /wp:column -->'
451 );
452 $column['innerBlocks'][0] = $block;
453 $lastChild = array_merge($shellPath, ['innerBlocks', count($shell['innerBlocks']) - 1]);
454 $blocks = self::insertAtPath($blocks, $lastChild, $column, 'after');
455 $sharedWrappers[$blockId . ':after'] = $shellRef;
456 $sharedWrappers[$blockId . ':before'] = $shellRef;
457 return true;
458 }
459
460 // Returns null when the copy spliced in, or the refusal reason.
461 private static function applyDuplicate(array &$blocks, int $blockId, array $operation)
462 {
463 $path = PostBlockFinder::pathByRef($blocks, $blockId);
464 if ($path === null) {
465 return 'block id not found in this post';
466 }
467 $original = self::blockAtPath($blocks, $path);
468 $copy = self::withoutStamps($original);
469 if (isset($operation['block'])) {
470 $copy = self::parseSingleBlock((string) $operation['block']);
471 if (!$copy) {
472 return 'block must parse to exactly one block';
473 }
474 if (($copy['blockName'] ?? '') !== ($original['blockName'] ?? '')) {
475 return 'a copy must keep the original block type';
476 }
477 }
478 $anchorPath = $path;
479 $position = 'after';
480 if (isset($operation['targetId'])) {
481 $anchorPath = PostBlockFinder::pathByRef($blocks, (int) $operation['targetId']);
482 if ($anchorPath === null) {
483 return 'target block not found in this post';
484 }
485 if (array_slice($anchorPath, 0, count($path)) === $path && $anchorPath !== $path) {
486 return 'target is inside the copied block';
487 }
488 $position = (string) ($operation['position'] ?? 'after');
489 if (!in_array($position, ['before', 'after'], true)) {
490 return "position must be 'before' or 'after'";
491 }
492 }
493 $blocks = self::insertAtPath($blocks, $anchorPath, $copy, $position);
494 return null;
495 }
496
497 // A stamped copy would catch later ops aimed at the original's ids.
498 private static function withoutStamps(array $block): array
499 {
500 unset($block[PostBlockFinder::REF_KEY]);
501 $block['innerBlocks'] = array_map([self::class, 'withoutStamps'], $block['innerBlocks'] ?? []);
502 return $block;
503 }
504
505 private static function blockAtPath(array $blocks, array $path)
506 {
507 $node = $blocks;
508 foreach ($path as $key) {
509 $node = $node[$key];
510 }
511 return $node;
512 }
513
514 // Position can't tell an untouched child from a swapped one.
515 private static function carryStamps(array $old, array $new): array
516 {
517 if (isset($old[PostBlockFinder::REF_KEY])) {
518 $new[PostBlockFinder::REF_KEY] = $old[PostBlockFinder::REF_KEY];
519 }
520 $oldInner = isset($old['innerBlocks']) && is_array($old['innerBlocks'])
521 ? $old['innerBlocks']
522 : [];
523 $newInner = isset($new['innerBlocks']) && is_array($new['innerBlocks'])
524 ? $new['innerBlocks']
525 : [];
526 foreach ($newInner as $i => $child) {
527 if (!isset($oldInner[$i]) || !is_array($oldInner[$i]) || !is_array($child)) {
528 continue;
529 }
530 if (\serialize_blocks([$oldInner[$i]]) !== \serialize_blocks([$child])) {
531 continue;
532 }
533 $newInner[$i] = self::carryStamps($oldInner[$i], $child);
534 }
535 if ($newInner) {
536 $new['innerBlocks'] = $newInner;
537 }
538 return $new;
539 }
540
541 private static function parseSingleBlock(string $markup)
542 {
543 $parsed = array_values(array_filter(
544 \parse_blocks($markup),
545 static function ($block) {
546 return is_array($block) && !empty($block['blockName']);
547 }
548 ));
549 return count($parsed) === 1 ? $parsed[0] : null;
550 }
551
552 // Path elements alternate index / 'innerBlocks' / …; null deletes.
553 private static function spliceAtPath(array $list, array $path, $newBlock): array
554 {
555 $head = ($path[0] ?? null);
556 if (!is_int($head) || !isset($list[$head])) {
557 return $list;
558 }
559
560 $rest = array_slice($path, 1);
561 if (!$rest) {
562 if ($newBlock === null) {
563 array_splice($list, $head, 1);
564 } else {
565 $list[$head] = $newBlock;
566 }
567 return $list;
568 }
569
570 if ($rest[0] !== 'innerBlocks') {
571 return $list;
572 }
573
574 $childPath = array_slice($rest, 1);
575 $inner = isset($list[$head]['innerBlocks']) && is_array($list[$head]['innerBlocks'])
576 ? $list[$head]['innerBlocks']
577 : [];
578
579 // Drop the child's innerContent placeholder too, or serialize_blocks
580 // misaligns the remaining children.
581 if ($newBlock === null && count($childPath) === 1) {
582 $innerContent = isset($list[$head]['innerContent']) && is_array($list[$head]['innerContent'])
583 ? $list[$head]['innerContent']
584 : [];
585 $list[$head]['innerContent'] = self::withoutNthPlaceholder($innerContent, $childPath[0]);
586 }
587
588 $list[$head]['innerBlocks'] = self::spliceAtPath($inner, $childPath, $newBlock);
589 return $list;
590 }
591
592 private static function withoutNthPlaceholder(array $innerContent, int $n): array
593 {
594 $seen = -1;
595 foreach ($innerContent as $i => $chunk) {
596 if (is_string($chunk)) {
597 continue;
598 }
599 $seen++;
600 if ($seen === $n) {
601 array_splice($innerContent, $i, 1);
602 break;
603 }
604 }
605 return $innerContent;
606 }
607
608 private static function insertAtPath(array $list, array $path, array $block, string $position): array
609 {
610 $head = ($path[0] ?? null);
611 if (!is_int($head) || !isset($list[$head])) {
612 return $list;
613 }
614
615 $rest = array_slice($path, 1);
616 if (!$rest) {
617 array_splice($list, $head + ($position === 'after' ? 1 : 0), 0, [$block]);
618 return $list;
619 }
620
621 if ($rest[0] !== 'innerBlocks') {
622 return $list;
623 }
624
625 $childPath = array_slice($rest, 1);
626 // Mirror the delete case: a new child needs its innerContent
627 // placeholder too, or serialize_blocks misaligns the children.
628 if (count($childPath) === 1) {
629 $innerContent = isset($list[$head]['innerContent']) && is_array($list[$head]['innerContent'])
630 ? $list[$head]['innerContent']
631 : [];
632 $list[$head]['innerContent'] = self::withPlaceholderAt(
633 $innerContent,
634 $childPath[0] + ($position === 'after' ? 1 : 0)
635 );
636 }
637
638 $inner = isset($list[$head]['innerBlocks']) && is_array($list[$head]['innerBlocks'])
639 ? $list[$head]['innerBlocks']
640 : [];
641 $list[$head]['innerBlocks'] = self::insertAtPath($inner, $childPath, $block, $position);
642 return $list;
643 }
644
645 // Inserts a null so it becomes the nth placeholder; past the last one it
646 // lands right after it, before any trailing closing markup.
647 private static function withPlaceholderAt(array $innerContent, int $n): array
648 {
649 $seen = -1;
650 foreach ($innerContent as $i => $chunk) {
651 if (is_string($chunk)) {
652 continue;
653 }
654 $seen++;
655 if ($seen === $n) {
656 array_splice($innerContent, $i, 0, [null]);
657 return $innerContent;
658 }
659 }
660 for ($i = count($innerContent) - 1; $i >= 0; $i--) {
661 if (!is_string($innerContent[$i])) {
662 array_splice($innerContent, $i + 1, 0, [null]);
663 return $innerContent;
664 }
665 }
666 $innerContent[] = null;
667 return $innerContent;
668 }
669 }
670