PluginProbe ʕ •ᴥ•ʔ
Superb Addons: Blocks, Patterns, Pre-built Pages, Sliders, Popups, Free Forms, Animations & More / 4.0.8
Superb Addons: Blocks, Patterns, Pre-built Pages, Sliders, Popups, Free Forms, Animations & More v4.0.8
4.1.0 4.0.9 4.0.8 4.0.7 4.0.6 4.0.5 4.0.4 4.0.3 4.0.2 4.0.1 4.0.0 trunk 1.0.0 2.0.0 2.0.1 2.0.2 2.0.3 3.0 3.0.1 3.0.2 3.0.5 3.0.6 3.0.7 3.0.8 3.0.9 3.1.0 3.1.2 3.1.3 3.2.0 3.2.1 3.2.2 3.2.4 3.2.5 3.2.7 3.2.8 3.2.9 3.3.0 3.3.1 3.3.2 3.4.0 3.4.1 3.4.2 3.4.5 3.4.6 3.5.0 3.5.1 3.5.2 3.5.3 3.5.4 3.5.6 3.5.7 3.5.8 3.5.9 3.6.0 3.6.1 3.6.2 3.7.0 3.7.1
superb-blocks / src / gutenberg / import / class-gutenberg-block-id-regenerator.php
superb-blocks / src / gutenberg / import Last commit date
class-gutenberg-block-id-regenerator.php 1 month ago
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