| @@ -10,22 +10,153 @@ | ||
| 10 | 10 | // Shared by SaveController (write) and WPController::getBlockCode (read) so the |
| 11 | 11 | // two halves can never resolve a different block for the same id. |
| 12 | 12 | class TemplatePartBlockFinder |
| 13 | 13 | { |
| 14 | - // Preorder walk matching TagTemplateParts numbering: nested | |
| 15 | - // core/template-part blocks open a separate seq space (skipped), and the | |
| 16 | - // shared $ignored dynamic blocks (mini-cart, loops, …) count as one leaf | |
| 17 | - // each but are not descended into — the tagger skips their rendered subtree. | |
| 18 | - // | |
| 19 | - // Ref-based navigation blocks (`core/navigation` with a `ref` attr) resolve | |
| 20 | - // their items at render time from a separate wp_navigation post. | |
| 21 | - // TagTemplateParts fires for those rendered items inside the outer | |
| 22 | - // template-part's frame, so each one increments the outer seq. parse_blocks | |
| 23 | - // of the post we're walking only sees the navigation block (empty | |
| 24 | - // innerBlocks for refs), so a naïve walk would under-count. Resolve the nav | |
| 25 | - // ref here and add its block count to the running counter so blockIds | |
| 26 | - // *after* the navigation (e.g. social-link in a sibling social-links block) | |
| 27 | - // still line up. | |
| 14 | + // These resolve by ref; template parts by slug. | |
| 15 | + // phpcs:ignore PSR12.Properties.ConstantVisibility.NotFound | |
| 16 | + const REF_POST_TYPES = [ | |
| 17 | + 'block' => 'wp_block', | |
| 18 | + 'navigation' => 'wp_navigation', | |
| 19 | + ]; | |
| 20 | + | |
| 21 | + // phpcs:ignore PSR12.Properties.ConstantVisibility.NotFound | |
| 22 | + const COMPOSITE_ID_PATTERN = '/^(block|navigation):(\d+):(\d+)$/'; | |
| 23 | + | |
| 24 | + // phpcs:ignore PSR12.Properties.ConstantVisibility.NotFound | |
| 25 | + const REF_BLOCKS = [ | |
| 26 | + 'core/block' => 'block', | |
| 27 | + 'core/navigation' => 'navigation', | |
| 28 | + ]; | |
| 29 | + | |
| 30 | + // Both taggers emit this; this class parses it back. | |
| 31 | + public static function refPrefix(string $type, array $block): string | |
| 32 | + { | |
| 33 | + return $type . ':' . (int) ($block['attrs']['ref'] ?? 0) . ':'; | |
| 34 | + } | |
| 35 | + | |
| 36 | + // An inline navigation keeps its items in the containing post. | |
| 37 | + public static function isRefNav(array $block): bool | |
| 38 | + { | |
| 39 | + return ($block['blockName'] ?? '') === 'core/navigation' | |
| 40 | + && !empty($block['attrs']['ref']); | |
| 41 | + } | |
| 42 | + | |
| 43 | + // These live in their own post, so the id has to name that post. | |
| 44 | + public static function parseCompositeId($blockId) | |
| 45 | + { | |
| 46 | + if (!preg_match(self::COMPOSITE_ID_PATTERN, (string) $blockId, $matches)) { | |
| 47 | + return null; | |
| 48 | + } | |
| 49 | + return [ | |
| 50 | + 'postType' => self::REF_POST_TYPES[$matches[1]], | |
| 51 | + 'ref' => (int) $matches[2], | |
| 52 | + 'seq' => (int) $matches[3], | |
| 53 | + ]; | |
| 54 | + } | |
| 55 | + | |
| 56 | + public static function owningPost($blockId, \WP_Post $container) | |
| 57 | + { | |
| 58 | + $composite = self::parseCompositeId($blockId); | |
| 59 | + if ($composite === null) { | |
| 60 | + return preg_match('/^\d+$/', (string) $blockId) && (int) $blockId > 0 | |
| 61 | + ? ['post' => $container, 'blockId' => (int) $blockId] | |
| 62 | + : null; | |
| 63 | + } | |
| 64 | + $post = \get_post($composite['ref']); | |
| 65 | + if (!$post || $post->post_type !== $composite['postType']) { | |
| 66 | + return null; | |
| 67 | + } | |
| 68 | + if (!self::rendersRef($container, $composite['ref'])) { | |
| 69 | + return null; | |
| 70 | + } | |
| 71 | + return ['post' => $post, 'blockId' => $composite['seq']]; | |
| 72 | + } | |
| 73 | + | |
| 74 | + // Without this, any wp_block on the site is writable from any page. | |
| 75 | + public static function rendersRef(\WP_Post $container, int $ref): bool | |
| 76 | + { | |
| 77 | + return self::contentRendersRef($container->post_content, $ref); | |
| 78 | + } | |
| 79 | + | |
| 80 | + // An untouched theme-file part has no post, so containment reads its markup. | |
| 81 | + public static function contentRendersRef(string $content, int $ref): bool | |
| 82 | + { | |
| 83 | + $seen = []; | |
| 84 | + return self::reachesRef(\parse_blocks($content), $ref, $seen); | |
| 85 | + } | |
| 86 | + | |
| 87 | + private static function reachesRef(array $blocks, int $ref, array &$seen): bool | |
| 88 | + { | |
| 89 | + foreach ($blocks as $block) { | |
| 90 | + if (!is_array($block)) { | |
| 91 | + continue; | |
| 92 | + } | |
| 93 | + $name = $block['blockName'] ?? ''; | |
| 94 | + if (isset(self::REF_BLOCKS[$name])) { | |
| 95 | + $blockRef = (int) ($block['attrs']['ref'] ?? 0); | |
| 96 | + if ($blockRef === $ref) { | |
| 97 | + return true; | |
| 98 | + } | |
| 99 | + if ($blockRef && self::reachesInside("ref:{$blockRef}", $ref, $seen)) { | |
| 100 | + return true; | |
| 101 | + } | |
| 102 | + } | |
| 103 | + if ($name === 'core/template-part' && !empty($block['attrs']['slug'])) { | |
| 104 | + $slug = (string) $block['attrs']['slug']; | |
| 105 | + if (self::reachesInside("part:{$slug}", $ref, $seen)) { | |
| 106 | + return true; | |
| 107 | + } | |
| 108 | + } | |
| 109 | + if (!empty($block['innerBlocks']) && self::reachesRef($block['innerBlocks'], $ref, $seen)) { | |
| 110 | + return true; | |
| 111 | + } | |
| 112 | + } | |
| 113 | + return false; | |
| 114 | + } | |
| 115 | + | |
| 116 | + // A pattern can reference itself, directly or through a part. | |
| 117 | + private static function reachesInside(string $key, int $ref, array &$seen): bool | |
| 118 | + { | |
| 119 | + if (isset($seen[$key])) { | |
| 120 | + return false; | |
| 121 | + } | |
| 122 | + $seen[$key] = true; | |
| 123 | + $content = self::contentFor($key); | |
| 124 | + return $content === null | |
| 125 | + ? false | |
| 126 | + : self::reachesRef(\parse_blocks($content), $ref, $seen); | |
| 127 | + } | |
| 128 | + | |
| 129 | + private static function contentFor(string $key) | |
| 130 | + { | |
| 131 | + list($kind, $value) = explode(':', $key, 2); | |
| 132 | + if ($kind === 'ref') { | |
| 133 | + $post = \get_post((int) $value); | |
| 134 | + return $post ? $post->post_content : null; | |
| 135 | + } | |
| 136 | + $template = \get_block_template( | |
| 137 | + \wp_get_theme()->get_stylesheet() . '//' . $value, | |
| 138 | + 'wp_template_part' | |
| 139 | + ); | |
| 140 | + return $template ? $template->content : null; | |
| 141 | + } | |
| 142 | + | |
| 143 | + // Lets the render-time tagger number a ref'd post the way find() walks it. | |
| 144 | + public static function outline(array $blocks): array | |
| 145 | + { | |
| 146 | + $max = 0; | |
| 147 | + $visited = []; | |
| 148 | + self::find($blocks, 0, $max, $visited); | |
| 149 | + return $visited; | |
| 150 | + } | |
| 151 | + | |
| 152 | + // Their content belongs to another post's id space. | |
| 153 | + private static function opensOwnScope(string $name): bool | |
| 154 | + { | |
| 155 | + return in_array($name, ['core/template-part', 'core/block'], true); | |
| 156 | + } | |
| 157 | + | |
| 158 | + // Diverging from TagTemplateParts' numbering here drifts every later id. | |
| 28 | 159 | public static function find( |
| 29 | 160 | array $blocks, |
| 30 | 161 | int $targetId, |
| 31 | 162 | int &$maxCounter = 0, |
| @@ -46,9 +177,9 @@ | ||
| 46 | 177 | $walk($block['innerBlocks'], array_merge($pathSoFar, [$i, 'innerBlocks'])); |
| 47 | 178 | } |
| 48 | 179 | continue; |
| 49 | 180 | } |
| 50 | - if ($name === 'core/template-part') { | |
| 181 | + if (self::opensOwnScope($name)) { | |
| 51 | 182 | continue; |
| 52 | 183 | } |
| 53 | 184 | $counter++; |
| 54 | 185 | $visited[] = ['c' => $counter, 'n' => $name]; |
| @@ -55,30 +186,12 @@ | ||
| 55 | 186 | if ($counter === $targetId) { |
| 56 | 187 | $found = ['block' => $block, 'path' => array_merge($pathSoFar, [$i])]; |
| 57 | 188 | return; |
| 58 | 189 | } |
| 59 | - // Dynamic self-rendering blocks (loops, woocommerce/mini-cart, …) | |
| 60 | - // count as one leaf but are not descended into: TagTemplateParts | |
| 61 | - // skips their render-injected subtree, so descending here would | |
| 62 | - // drift every later id. Shares the one $ignored list. | |
| 190 | + // The tagger skips their render-injected subtree. | |
| 63 | 191 | if (in_array($name, TagTemplateParts::$ignored, true)) { |
| 64 | 192 | continue; |
| 65 | 193 | } |
| 66 | - if ($name === 'core/navigation' && !empty($block['attrs']['ref'])) { | |
| 67 | - $navPost = get_post((int) $block['attrs']['ref']); | |
| 68 | - if ($navPost) { | |
| 69 | - $navBlocks = parse_blocks($navPost->post_content); | |
| 70 | - $before = $counter; | |
| 71 | - self::countBlocksPreorder($navBlocks, $counter); | |
| 72 | - for ($j = $before + 1; $j <= $counter; $j++) { | |
| 73 | - $visited[] = ['c' => $j, 'n' => '(nav-ref-item)']; | |
| 74 | - } | |
| 75 | - } | |
| 76 | - // Ref navs have no innerBlocks in the parsed tree — items | |
| 77 | - // live in the wp_navigation post and can't be replaced from | |
| 78 | - // here anyway (WPNavigationController owns that). | |
| 79 | - continue; | |
| 80 | - } | |
| 81 | 194 | if (!empty($block['innerBlocks'])) { |
| 82 | 195 | $walk($block['innerBlocks'], array_merge($pathSoFar, [$i, 'innerBlocks'])); |
| 83 | 196 | } |
| 84 | 197 | } |
| @@ -89,27 +202,36 @@ | ||
| 89 | 202 | |
| 90 | 203 | return $found; |
| 91 | 204 | } |
| 92 | 205 | |
| 93 | - private static function countBlocksPreorder(array $blocks, int &$counter) | |
| 206 | + // Stamps in find()'s order so a splice resolves the id the client read. | |
| 207 | + public static function stamp(array $blocks): array | |
| 94 | 208 | { |
| 95 | - foreach ($blocks as $block) { | |
| 209 | + $seq = 0; | |
| 210 | + return self::stampWalk($blocks, $seq); | |
| 211 | + } | |
| 212 | + | |
| 213 | + private static function stampWalk(array $list, int &$seq): array | |
| 214 | + { | |
| 215 | + foreach ($list as $i => $block) { | |
| 96 | 216 | $name = $block['blockName'] ?? ''; |
| 97 | 217 | if ($name === '') { |
| 98 | 218 | if (!empty($block['innerBlocks'])) { |
| 99 | - self::countBlocksPreorder($block['innerBlocks'], $counter); | |
| 219 | + $list[$i]['innerBlocks'] = self::stampWalk($block['innerBlocks'], $seq); | |
| 100 | 220 | } |
| 101 | 221 | continue; |
| 102 | 222 | } |
| 103 | - if ($name === 'core/template-part') { | |
| 223 | + if (self::opensOwnScope($name)) { | |
| 104 | 224 | continue; |
| 105 | 225 | } |
| 106 | - $counter++; | |
| 226 | + $seq++; | |
| 227 | + $list[$i][PostBlockFinder::REF_KEY] = $seq; | |
| 107 | 228 | if (in_array($name, TagTemplateParts::$ignored, true)) { |
| 108 | 229 | continue; |
| 109 | 230 | } |
| 110 | 231 | if (!empty($block['innerBlocks'])) { |
| 111 | - self::countBlocksPreorder($block['innerBlocks'], $counter); | |
| 232 | + $list[$i]['innerBlocks'] = self::stampWalk($block['innerBlocks'], $seq); | |
| 112 | 233 | } |
| 113 | 234 | } |
| 235 | + return $list; | |
| 114 | 236 | } |
| 115 | 237 | } |