class-gutenberg-block-id-regenerator.php
332 lines
| 1 | <?php |
| 2 | |
| 3 | namespace SuperbAddons\Gutenberg\Import; |
| 4 | |
| 5 | defined('ABSPATH') || exit(); |
| 6 | |
| 7 | /** |
| 8 | * Rewrites unique block IDs (and the attributes that reference them) inside |
| 9 | * library-imported block content so that every import produces fresh IDs. |
| 10 | * |
| 11 | * Context: the theme designer wizard and the editor library browser insert |
| 12 | * prebuilt library content via pure PHP (wp_insert_post). The client-side |
| 13 | * duplicate-detection hooks in the block edit components never run on that |
| 14 | * content, so importing the same template twice would otherwise leave the |
| 15 | * site with colliding popupId / formId values, breaking FormRegistry, |
| 16 | * PopupRegistry, form submission storage, and button->popup references. |
| 17 | */ |
| 18 | class GutenbergBlockIdRegenerator |
| 19 | { |
| 20 | /** |
| 21 | * Blocks that own a unique ID attribute. |
| 22 | * |
| 23 | * Each entry: |
| 24 | * - attr: the attribute name holding the unique ID |
| 25 | * - prefix: prefix used when generating a new ID (mirrors JS generateBlockId) |
| 26 | * - namespace: logical grouping used for reference rewrites |
| 27 | * - extras: optional map of additional attributes that should also be |
| 28 | * regenerated whenever the owner ID is regenerated |
| 29 | * (e.g. form honeypot key). Keyed by attribute name, value |
| 30 | * is the prefix for the extra. |
| 31 | */ |
| 32 | private static $owners = array( |
| 33 | 'superb-addons/popup' => array( |
| 34 | 'attr' => 'popupId', |
| 35 | 'prefix' => 'superb-popup-', |
| 36 | 'namespace' => 'popup', |
| 37 | ), |
| 38 | 'superb-addons/form' => array( |
| 39 | 'attr' => 'formId', |
| 40 | 'prefix' => 'form_', |
| 41 | 'namespace' => 'form', |
| 42 | 'extras' => array( |
| 43 | 'honeypotKey' => 'field_', |
| 44 | ), |
| 45 | ), |
| 46 | 'superb-addons/multistep-form' => array( |
| 47 | 'attr' => 'formId', |
| 48 | 'prefix' => 'form_', |
| 49 | 'namespace' => 'form', |
| 50 | 'extras' => array( |
| 51 | 'honeypotKey' => 'field_', |
| 52 | ), |
| 53 | ), |
| 54 | 'superb-addons/accordion-block' => array( |
| 55 | 'attr' => 'accordionId', |
| 56 | 'prefix' => 'superb-accordion-', |
| 57 | 'namespace' => 'accordion', |
| 58 | ), |
| 59 | 'superb-addons/countdown' => array( |
| 60 | 'attr' => 'countdownId', |
| 61 | 'prefix' => 'superb-countdown-', |
| 62 | 'namespace' => 'countdown', |
| 63 | ), |
| 64 | ); |
| 65 | |
| 66 | /** |
| 67 | * Blocks that reference an owner ID via an attribute. Each block can |
| 68 | * have multiple reference attributes. |
| 69 | * |
| 70 | * Structure: blockName => array of array('attr' => ..., 'namespace' => ...) |
| 71 | */ |
| 72 | private static $references = array( |
| 73 | 'core/button' => array( |
| 74 | array( |
| 75 | 'attr' => 'spbaddPopupTarget', |
| 76 | 'namespace' => 'popup', |
| 77 | ), |
| 78 | ), |
| 79 | ); |
| 80 | |
| 81 | /** |
| 82 | * Live registries to avoid collisions with IDs that already exist on |
| 83 | * other posts. Loaded lazily once per call. |
| 84 | */ |
| 85 | private static $live_taken = null; |
| 86 | |
| 87 | /** |
| 88 | * Entry point. Takes serialized block markup, regenerates owner IDs, |
| 89 | * rewrites matching references, and returns the rewritten markup. |
| 90 | * |
| 91 | * @param string $content |
| 92 | * @return string |
| 93 | */ |
| 94 | public static function RegenerateIds($content) |
| 95 | { |
| 96 | if (!is_string($content) || $content === '') { |
| 97 | return $content; |
| 98 | } |
| 99 | |
| 100 | // Early exit: if content contains none of the managed owner blocks, nothing to do. |
| 101 | $has_any_owner = false; |
| 102 | foreach (self::$owners as $block_name => $config) { |
| 103 | if (has_block($block_name, $content)) { |
| 104 | $has_any_owner = true; |
| 105 | break; |
| 106 | } |
| 107 | } |
| 108 | if (!$has_any_owner) { |
| 109 | return $content; |
| 110 | } |
| 111 | |
| 112 | $parsed_blocks = parse_blocks($content); |
| 113 | |
| 114 | // Namespace => array(old_id => new_id) |
| 115 | $id_map = array(); |
| 116 | // Namespace => array(id => true) of all IDs already used (live registries + current batch). |
| 117 | $taken = self::LoadLiveTaken(); |
| 118 | |
| 119 | // Pass 1: regenerate owner IDs, populate id_map, and rewrite the IDs |
| 120 | // baked into each owner's own saved markup. |
| 121 | self::WalkBlocks($parsed_blocks, function (&$block) use (&$id_map, &$taken) { |
| 122 | if (empty($block['blockName']) || !isset(self::$owners[$block['blockName']])) { |
| 123 | return; |
| 124 | } |
| 125 | $config = self::$owners[$block['blockName']]; |
| 126 | $attr_name = $config['attr']; |
| 127 | $namespace = $config['namespace']; |
| 128 | |
| 129 | if (!isset($block['attrs']) || !is_array($block['attrs'])) { |
| 130 | $block['attrs'] = array(); |
| 131 | } |
| 132 | |
| 133 | // old ID => new ID substitutions to apply to this block's markup. |
| 134 | $markup_swaps = array(); |
| 135 | |
| 136 | $old_id = isset($block['attrs'][$attr_name]) ? (string) $block['attrs'][$attr_name] : ''; |
| 137 | |
| 138 | $new_id = self::GenerateUniqueId($config['prefix'], $namespace, $taken); |
| 139 | $block['attrs'][$attr_name] = $new_id; |
| 140 | |
| 141 | if ($old_id !== '') { |
| 142 | if (!isset($id_map[$namespace])) { |
| 143 | $id_map[$namespace] = array(); |
| 144 | } |
| 145 | $id_map[$namespace][$old_id] = $new_id; |
| 146 | if ($old_id !== $new_id) { |
| 147 | $markup_swaps[$old_id] = $new_id; |
| 148 | } |
| 149 | } |
| 150 | |
| 151 | // Regenerate "extras" (e.g. form honeypotKey). These are not cross-referenced, |
| 152 | // so we do not track them in the id_map. |
| 153 | if (!empty($config['extras']) && is_array($config['extras'])) { |
| 154 | foreach ($config['extras'] as $extra_attr => $extra_prefix) { |
| 155 | $old_extra = isset($block['attrs'][$extra_attr]) ? (string) $block['attrs'][$extra_attr] : ''; |
| 156 | // Use a synthetic "extras" namespace for collision tracking so the |
| 157 | // same-call taken-set catches intra-batch dupes. This is cheap. |
| 158 | $new_extra = self::GenerateUniqueId($extra_prefix, '__extras__', $taken); |
| 159 | $block['attrs'][$extra_attr] = $new_extra; |
| 160 | if ($old_extra !== '' && $old_extra !== $new_extra) { |
| 161 | $markup_swaps[$old_extra] = $new_extra; |
| 162 | } |
| 163 | } |
| 164 | } |
| 165 | |
| 166 | // The owner's IDs are also baked into its saved HTML (data-popup-id, |
| 167 | // data-form-id, the honeypot field's id/name/for attributes, ...). |
| 168 | // serialize_blocks re-emits innerContent verbatim, so unless those |
| 169 | // occurrences are rewritten too the editor's block validator sees a |
| 170 | // mismatch between the parsed attributes and the stored markup. |
| 171 | self::RewriteBlockMarkup($block, $markup_swaps); |
| 172 | }); |
| 173 | |
| 174 | // Pass 2: rewrite references using the id_map. |
| 175 | if (!empty($id_map)) { |
| 176 | self::WalkBlocks($parsed_blocks, function (&$block) use ($id_map) { |
| 177 | if (empty($block['blockName']) || !isset(self::$references[$block['blockName']])) { |
| 178 | return; |
| 179 | } |
| 180 | if (!isset($block['attrs']) || !is_array($block['attrs'])) { |
| 181 | return; |
| 182 | } |
| 183 | foreach (self::$references[$block['blockName']] as $ref_config) { |
| 184 | $attr_name = $ref_config['attr']; |
| 185 | $namespace = $ref_config['namespace']; |
| 186 | if (!isset($block['attrs'][$attr_name])) { |
| 187 | continue; |
| 188 | } |
| 189 | $current = (string) $block['attrs'][$attr_name]; |
| 190 | if ($current === '') { |
| 191 | continue; |
| 192 | } |
| 193 | if (isset($id_map[$namespace]) && isset($id_map[$namespace][$current])) { |
| 194 | $block['attrs'][$attr_name] = $id_map[$namespace][$current]; |
| 195 | } |
| 196 | } |
| 197 | }); |
| 198 | } |
| 199 | |
| 200 | return serialize_blocks($parsed_blocks); |
| 201 | } |
| 202 | |
| 203 | /** |
| 204 | * Recursively walk a block tree (by reference) and invoke $callback on |
| 205 | * each block. Descent into innerBlocks is skipped for core/block so that |
| 206 | * synced pattern references stay byte-identical with their shared source. |
| 207 | * |
| 208 | * @param array $blocks |
| 209 | * @param callable $callback |
| 210 | */ |
| 211 | private static function WalkBlocks(&$blocks, $callback) |
| 212 | { |
| 213 | if (!is_array($blocks)) { |
| 214 | return; |
| 215 | } |
| 216 | foreach ($blocks as &$block) { |
| 217 | if (!is_array($block)) { |
| 218 | continue; |
| 219 | } |
| 220 | call_user_func_array($callback, array(&$block)); |
| 221 | |
| 222 | // Skip descent into synced patterns: editing IDs inside a core/block |
| 223 | // wrapper would desync it from the shared wp_block source. |
| 224 | $block_name = isset($block['blockName']) ? $block['blockName'] : ''; |
| 225 | if ($block_name === 'core/block') { |
| 226 | continue; |
| 227 | } |
| 228 | |
| 229 | if (isset($block['innerBlocks']) && is_array($block['innerBlocks']) && !empty($block['innerBlocks'])) { |
| 230 | self::WalkBlocks($block['innerBlocks'], $callback); |
| 231 | } |
| 232 | } |
| 233 | unset($block); |
| 234 | } |
| 235 | |
| 236 | /** |
| 237 | * Replace every occurrence of an old ID with its new ID inside a single |
| 238 | * block's own saved markup. Only the block's own innerContent string |
| 239 | * chunks are touched; child blocks occupy null placeholders there and are |
| 240 | * rewritten when the walk reaches them, so their markup is left alone. |
| 241 | * |
| 242 | * @param array $block Block node, by reference. |
| 243 | * @param array $swaps Map of old string => new string. |
| 244 | */ |
| 245 | private static function RewriteBlockMarkup(&$block, $swaps) |
| 246 | { |
| 247 | if (empty($swaps)) { |
| 248 | return; |
| 249 | } |
| 250 | |
| 251 | $search = array_keys($swaps); |
| 252 | $replace = array_values($swaps); |
| 253 | |
| 254 | if (isset($block['innerHTML']) && is_string($block['innerHTML'])) { |
| 255 | $block['innerHTML'] = str_replace($search, $replace, $block['innerHTML']); |
| 256 | } |
| 257 | |
| 258 | if (isset($block['innerContent']) && is_array($block['innerContent'])) { |
| 259 | foreach ($block['innerContent'] as &$chunk) { |
| 260 | if (is_string($chunk)) { |
| 261 | $chunk = str_replace($search, $replace, $chunk); |
| 262 | } |
| 263 | } |
| 264 | unset($chunk); |
| 265 | } |
| 266 | } |
| 267 | |
| 268 | /** |
| 269 | * Generate a fresh ID with the given prefix, ensuring it does not collide |
| 270 | * with any ID already recorded in $taken (for the given namespace) or in |
| 271 | * the cross-namespace "*" bucket that holds live registry entries. |
| 272 | * |
| 273 | * @param string $prefix |
| 274 | * @param string $namespace |
| 275 | * @param array $taken Keyed by namespace => array(id => true). |
| 276 | * @return string |
| 277 | */ |
| 278 | private static function GenerateUniqueId($prefix, $namespace, &$taken) |
| 279 | { |
| 280 | if (!isset($taken[$namespace])) { |
| 281 | $taken[$namespace] = array(); |
| 282 | } |
| 283 | |
| 284 | // Retry loop. wp_generate_password(8, false, false) yields 8 lowercase |
| 285 | // alphanumeric chars (~2.8e12 space), so collisions are essentially |
| 286 | // impossible; loop is a defensive safety net. |
| 287 | for ($i = 0; $i < 10; $i++) { |
| 288 | $candidate = $prefix . wp_generate_password(8, false, false); |
| 289 | if (isset($taken[$namespace][$candidate])) { |
| 290 | continue; |
| 291 | } |
| 292 | $taken[$namespace][$candidate] = true; |
| 293 | return $candidate; |
| 294 | } |
| 295 | |
| 296 | // Fallback: uniqid() is always distinct within a process. |
| 297 | $candidate = $prefix . substr(str_replace('.', '', uniqid('', true)), 0, 8); |
| 298 | $taken[$namespace][$candidate] = true; |
| 299 | return $candidate; |
| 300 | } |
| 301 | |
| 302 | /** |
| 303 | * Build a per-namespace "taken" map seeded from the live form and popup |
| 304 | * registries so that generated IDs never collide with IDs already |
| 305 | * assigned to forms/popups on other posts. |
| 306 | * |
| 307 | * @return array |
| 308 | */ |
| 309 | private static function LoadLiveTaken() |
| 310 | { |
| 311 | $taken = array(); |
| 312 | |
| 313 | $form_registry = get_option('spb_form_registry', array()); |
| 314 | if (is_array($form_registry) && !empty($form_registry)) { |
| 315 | $taken['form'] = array(); |
| 316 | foreach ($form_registry as $fid => $_entry) { |
| 317 | $taken['form'][(string) $fid] = true; |
| 318 | } |
| 319 | } |
| 320 | |
| 321 | $popup_registry = get_option('spb_popup_registry', array()); |
| 322 | if (is_array($popup_registry) && !empty($popup_registry)) { |
| 323 | $taken['popup'] = array(); |
| 324 | foreach ($popup_registry as $pid => $_entry) { |
| 325 | $taken['popup'][(string) $pid] = true; |
| 326 | } |
| 327 | } |
| 328 | |
| 329 | return $taken; |
| 330 | } |
| 331 | } |
| 332 |