PluginProbe
WDesignKit – AI Templates, Widget Builder & MCP Workflow / 2.7.1
WDesignKit – AI Templates, Widget Builder & MCP Workflow v2.7.1
2.7.1 2.7.0 2.6.6 2.6.5 2.6.4 2.6.3 2.6.2 2.6.1 2.6.0 2.5.5 2.5.4 2.5.3 2.5.2 2.5.1 2.5.0 2.4.0 2.3.3 2.3.2 2.3.1 1.0.10 1.0.11 1.0.12 1.0.13 1.0.14 1.0.15 All 130 releases
wdesignkit / includes / admin / import / class-wdkit-import-globals.php

class-wdkit-import-globals.php in WDesignKit – AI Templates, Widget Builder & MCP Workflow 2.7.1, at includes/admin/import/class-wdkit-import-globals.php

1,228 lines 44.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Global colours, typography and site-level globals.
4 *
5 * ── How the browser does it ─────────────────────────────────────────────────
6 *
7 * import_temp_main.js#get_global_data() walks every template in the kit and collects each
8 * template's `global_data.color` and `global_data.typography`, de-duplicating by `_id`
9 * (Elementor) or `id` (Gutenberg), and builds `font_family` from the typography titles.
10 * The user can then recolour them on the preview screen; the result lands on
11 * `site_obj.kit_global` / `site_obj.font_family`.
12 *
13 * import_loader.js then calls two separate handlers:
14 *
15 * update_global_val → global colours + typography
16 * wdkit_update_site_setting → container width, body background, `__globals__`
17 *
18 * Both are now reachable as services on Wdkit_Api_Call, so this class contains no copy of
19 * their WordPress writes — it only decides *what* to send.
20 *
21 * ── Idempotency ────────────────────────────────────────────────────────────
22 *
23 * The Elementor colour/typography write merges with `array_merge( $new, $existing )` over
24 * numeric-keyed lists, which appends. Run the browser import twice and the kit accumulates
25 * duplicate palette entries. That behaviour is preserved in the AJAX path, but this class
26 * de-duplicates by `_id` against what the kit already holds before calling in, so a PHP run
27 * is idempotent and never grows the palette on retry.
28 *
29 * Unrelated existing globals are preserved: entries the kit does not define are left alone,
30 * because the underlying write merges rather than replaces.
31 *
32 * @package Wdesignkit
33 * @since 2.6.5
34 */
35
36 if ( ! defined( 'ABSPATH' ) ) {
37 exit;
38 }
39
40 if ( ! class_exists( 'Wdkit_Import_Globals' ) ) {
41
42 /**
43 * Global style application.
44 */
45 class Wdkit_Import_Globals {
46
47 /**
48 * Collect global colours and typography from a kit's decoded templates.
49 *
50 * Port of get_global_data() in import_temp_main.js. De-duplicates on `_id`
51 * (Elementor) / `id` (Gutenberg), keeping the first definition seen — the same rule
52 * the browser applies via findIndex().
53 *
54 * @param array[] $decoded_templates Decoded template payloads.
55 * @param string $builder 'elementor'|'gutenberg'.
56 * @return array{color:array,typo:array,font_family:array}
57 */
58 /**
59 * The preset an import writes. Recognised when reading, so a later import never treats
60 * an earlier one's output as the site's own palette.
61 *
62 * @var string
63 */
64 const PRESET_KEY = 'wdk_preset';
65
66 public static function collect( $decoded_templates, $builder = 'elementor' ) {
67 $id_key = ( 'gutenberg' === $builder ) ? 'id' : '_id';
68
69 $colors = array();
70 $typo = array();
71 $fonts = array();
72
73 foreach ( (array) $decoded_templates as $decoded ) {
74 if ( ! is_array( $decoded ) || empty( $decoded['global_data'] ) || ! is_array( $decoded['global_data'] ) ) {
75 continue;
76 }
77
78 $global = $decoded['global_data'];
79
80 foreach ( ( ! empty( $global['color'] ) && is_array( $global['color'] ) ? $global['color'] : array() ) as $entry ) {
81 if ( ! is_array( $entry ) || ! self::has_id( $entry, $id_key ) ) {
82 continue;
83 }
84
85 $key = (string) $entry[ $id_key ];
86
87 if ( ! isset( $colors[ $key ] ) ) {
88 $colors[ $key ] = $entry;
89 }
90 }
91
92 foreach ( ( ! empty( $global['typography'] ) && is_array( $global['typography'] ) ? $global['typography'] : array() ) as $entry ) {
93 if ( ! is_array( $entry ) || ! self::has_id( $entry, $id_key ) ) {
94 continue;
95 }
96
97 $key = (string) $entry[ $id_key ];
98
99 if ( ! isset( $typo[ $key ] ) ) {
100 $typo[ $key ] = $entry;
101 }
102
103 $family = self::font_family_of( $entry, $builder );
104
105 if ( '' !== $family && ! isset( $fonts[ $family ] ) ) {
106 $fonts[ $family ] = array(
107 'original' => $family,
108 'replace' => $family,
109 );
110 }
111 }
112 }
113
114 /* Gutenberg ids are positions in the source preset, so ordering by id makes the
115 * imported palette read in the kit's own order rather than in the order the walk
116 * happened to meet each colour. Nothing depends on the old order: from_kit() and
117 * rewrite_kit_references() both index this same array, so they stay in step. */
118 if ( 'id' === $id_key ) {
119 ksort( $colors, SORT_NUMERIC );
120 ksort( $typo, SORT_NUMERIC );
121 }
122
123 return array(
124 'color' => array_values( $colors ),
125 'typo' => array_values( $typo ),
126 'font_family' => array_values( $fonts ),
127 );
128 }
129
130 /**
131 * Whether a global entry carries a usable id.
132 *
133 * `empty()` is wrong here and cost the kit its primary colour. A gutenberg kit's
134 * `global_data` numbers entries by POSITION IN THE SOURCE PRESET, zero-based - id 0 is
135 * C1, id 8 is C9 - so `empty( $entry['id'] )` threw away the first slot of every kit.
136 * Downstream the effect was silent: rewrite_kit_references() built no map entry for
137 * C1, so every `var(--tpgb-C1)` in the imported blocks was left pointing at the site's
138 * own stock C1 and a kit whose brand colour was a dark green rendered in the plugin's
139 * default purple.
140 *
141 * Elementor's `_id` is a hash string, where the only unusable value is the empty one.
142 *
143 * @since 2.6.5
144 *
145 * @param array $entry Global entry.
146 * @param string $id_key 'id' for gutenberg, '_id' for elementor.
147 * @return bool
148 */
149 private static function has_id( $entry, $id_key ) {
150 if ( ! isset( $entry[ $id_key ] ) || is_array( $entry[ $id_key ] ) ) {
151 return false;
152 }
153
154 return '' !== (string) $entry[ $id_key ];
155 }
156
157 /**
158 * The font family named by one typography entry.
159 *
160 * Elementor stores it flat as `typography_font_family`; Gutenberg nests it under
161 * `value.fontFamily.family`. Mirrors the two branches in get_global_data().
162 *
163 * @param array $entry Typography entry.
164 * @param string $builder Builder.
165 * @return string
166 */
167 private static function font_family_of( $entry, $builder ) {
168 if ( 'gutenberg' === $builder ) {
169 return isset( $entry['value']['fontFamily']['family'] ) && is_string( $entry['value']['fontFamily']['family'] )
170 ? $entry['value']['fontFamily']['family']
171 : '';
172 }
173
174 return isset( $entry['typography_font_family'] ) && is_string( $entry['typography_font_family'] )
175 ? $entry['typography_font_family']
176 : '';
177 }
178
179 /**
180 * Apply collected globals, skipping anything the kit already defines.
181 *
182 * @param array $globals Output of collect().
183 * @param string $builder Builder.
184 * @param array $site_data Optional site-level globals (container width, body bg).
185 * @return array{colors:int,typography:int,site:bool,skipped:bool}
186 */
187 /**
188 * Normalise the kit's own global palette into the shape apply() consumes.
189 *
190 * The wizard carries it as `site_obj.kit_global` = { color: [...], typo: [...] }, and
191 * each colour holds its intended value in `new_color` — collect() cannot read that,
192 * because collect() reads `global_data` off decoded TEMPLATES, where the keys are
193 * `color`/`typography` and the value is already in place.
194 *
195 * Missing this is why an imported site came out unstyled: the kit's pages reference
196 * `--tpgb-C12`, `--tpgb-T15-*` and friends, but plus-global.css only ever contained the
197 * plugin's own defaults (C1-C5, T1-T7), so every colour and font fell back.
198 *
199 * ── Why the ids are preserved verbatim ─────────────────────────────
200 *
201 * The block markup references globals BY ID (`--tpgb-C12`). The browser path reassigns
202 * gutenberg ids sequentially against the existing site palette, which only works because
203 * it also rewrites every reference as it goes. Nothing here rewrites references, so the
204 * kit's ids are kept exactly as the kit shipped them — that is what makes `C12` resolve.
205 *
206 * @param mixed $kit_global site_obj.kit_global.
207 * @param string $builder 'elementor'|'gutenberg'.
208 * @param mixed $font_pairs site_obj.font_family — [{original, replace}].
209 * @return array{color:array,typo:array}
210 */
211 public static function from_kit( $kit_global, $builder = 'elementor', $font_pairs = array(), $site_global = array(), $session_id = '' ) {
212 $out = array(
213 'color' => array(),
214 'typo' => array(),
215 'preset' => array(),
216 );
217
218 if ( ! is_array( $kit_global ) ) {
219 return $out;
220 }
221
222 $builder = ( 'gutenberg' === $builder ) ? 'gutenberg' : 'elementor';
223
224 /* Font swaps chosen on the fonts screen: [{original, replace}]. */
225 $pairs = array();
226
227 foreach ( (array) $font_pairs as $pair ) {
228 if ( is_array( $pair ) && ! empty( $pair['original'] ) && ! empty( $pair['replace'] ) ) {
229 $pairs[ (string) $pair['original'] ] = (string) $pair['replace'];
230 }
231 }
232
233 $colors = ( ! empty( $kit_global['color'] ) && is_array( $kit_global['color'] ) ) ? $kit_global['color'] : array();
234 $typos = ( ! empty( $kit_global['typo'] ) && is_array( $kit_global['typo'] ) ) ? $kit_global['typo'] : array();
235
236 if ( 'gutenberg' === $builder ) {
237 /* ── Faithful port of the gutenberg branch of import_globla_data() ──
238 *
239 * The palette is NOT stored as a flat list. tpgb keeps it inside
240 * `tpgb_global_options.presets[]`, and the browser builds one preset —
241 * key `wdk_preset` — whose `colors` and `typography` arrays are POSITIONAL:
242 * the ids are stripped, so the first entry is C1, the twelfth is C12. That
243 * positional list is what generates the `--tpgb-C*` / `--tpgb-T*` custom
244 * properties in plus-global.css.
245 *
246 * Building no preset at all is why the imported site was unstyled: the colours
247 * were accepted, `presets` kept only the plugin's stock typography, `active`
248 * stayed empty, and every `var(--tpgb-C12)` in the page CSS resolved to nothing.
249 */
250 /* The site's existing palette stays, and the kit's entries are appended after it -
251 * exactly what import_globla_data() does with site_g_color / site_g_typo. */
252 $baseline = self::baseline( $session_id );
253
254 $seq = count( $baseline['colors'] );
255
256 foreach ( $colors as $entry ) {
257 if ( ! is_array( $entry ) ) {
258 continue;
259 }
260
261 if ( isset( $entry['new_color'] ) && '' !== $entry['new_color'] ) {
262 $entry['value'] = $entry['new_color'];
263 }
264
265 unset( $entry['new_color'] );
266
267 $entry['id'] = ++$seq;
268 $out['color'][] = $entry;
269 }
270
271 $seq = count( $baseline['typography'] );
272
273 foreach ( $typos as $entry ) {
274 if ( ! is_array( $entry ) ) {
275 continue;
276 }
277
278 /* The family sits at value.fontFamily.family, and a swapped font also has
279 * the "| Original" suffix trimmed off its label — both as the browser does. */
280 $family = $entry['value']['fontFamily']['family'] ?? '';
281
282 if ( '' !== $family && isset( $pairs[ $family ] ) ) {
283 $entry['value']['fontFamily']['family'] = $pairs[ $family ];
284
285 if ( ! empty( $entry['label'] ) && is_string( $entry['label'] ) ) {
286 $parts = explode( '|', $entry['label'] );
287 $entry['label'] = trim( $parts[0] );
288 }
289 }
290
291 $entry['id'] = ++$seq;
292 $out['typo'][] = $entry;
293 }
294
295 /* Positional lists for the preset: same entries, ids removed. */
296 $preset_colors = $baseline['colors'];
297 $preset_typo = $baseline['typography'];
298
299 foreach ( $out['color'] as $entry ) {
300 unset( $entry['id'] );
301 $preset_colors[] = $entry;
302 }
303
304 foreach ( $out['typo'] as $entry ) {
305 unset( $entry['id'] );
306 $preset_typo[] = $entry;
307 }
308
309 /* Keep the rest of the active preset - gradients, spacing, box shadows,
310 * globalContainer - rather than starting from an empty object, which is what
311 * `Object.assign({}, site_obj?.site_global, {...})` preserves in the browser. */
312 $preset = ( ! empty( $site_global ) && is_array( $site_global ) )
313 ? $site_global
314 : ( isset( $baseline['preset'] ) && is_array( $baseline['preset'] ) ? $baseline['preset'] : array() );
315
316 unset( $preset['color'] );
317
318 $preset['colors'] = $preset_colors;
319 $preset['typography'] = $preset_typo;
320 $preset['key'] = self::PRESET_KEY;
321 $preset['name'] = 'WDK Preset';
322
323 $out['preset'] = $preset;
324
325 return $out;
326 }
327
328 /* Elementor: the value lives in `color`, and ids stay as the kit shipped them —
329 * Elementor references globals by id, not by position — EXCEPT where the kit's id
330 * already belongs to a global on this site. See elementor_id_map(). */
331 $id_map = self::elementor_id_map( $kit_global, $session_id );
332
333 foreach ( $colors as $entry ) {
334 if ( ! is_array( $entry ) ) {
335 continue;
336 }
337
338 if ( isset( $entry['new_color'] ) && '' !== $entry['new_color'] ) {
339 $entry['color'] = $entry['new_color'];
340 }
341
342 unset( $entry['new_color'] );
343
344 if ( ! empty( $entry['_id'] ) && isset( $id_map['color'][ (string) $entry['_id'] ] ) ) {
345 $entry['_id'] = $id_map['color'][ (string) $entry['_id'] ];
346 }
347
348 $out['color'][] = $entry;
349 }
350
351 foreach ( $typos as $entry ) {
352 if ( ! is_array( $entry ) ) {
353 continue;
354 }
355
356 if ( ! empty( $entry['typography_font_family'] ) && isset( $pairs[ $entry['typography_font_family'] ] ) ) {
357 $entry['typography_font_family'] = $pairs[ $entry['typography_font_family'] ];
358 }
359
360 if ( ! empty( $entry['_id'] ) && isset( $id_map['typo'][ (string) $entry['_id'] ] ) ) {
361 $entry['_id'] = $id_map['typo'][ (string) $entry['_id'] ];
362 }
363
364 $out['typo'][] = $entry;
365 }
366
367 return $out;
368 }
369
370 /**
371 * Replacement `_id`s for kit globals whose id is already taken on this site.
372 *
373 * ── The problem ────────────────────────────────────────────────────────────
374 *
375 * Elementor resolves a global by id: page content carries
376 * `__globals__: { text_color: "globals/colors?id=72e09b4" }`. A kit ships its own ids,
377 * and on a fresh site they are free, so the references resolve to the kit's own palette
378 * and nothing needs remapping — which is why this never bit on a first import.
379 *
380 * On a site that ALREADY has Elementor globals, an id can collide. `apply()` then hands
381 * the colliding entry to without_known(), which drops it as "already present" — so the
382 * kit's colour is never added, and every page that references that id renders in the
383 * SITE's colour instead. The page looks imported and is quietly wrong.
384 *
385 * import_globla_data() in the browser handles this by minting a fresh `_id` for the
386 * colliding entry and recording `{temp_id, site_id}`, which Extract_elementor_global()
387 * then swaps through the page content. This is that behaviour, server-side.
388 *
389 * ── Why the id is derived, not random ──────────────────────────────────────
390 *
391 * keyUniqueID() is random, which is fine in a browser that computes the map once and
392 * holds it in memory. The runner has neither luxury: the map is needed in
393 * stage_content (to rewrite each page as it is written) and again in stage_setup (to
394 * write the globals themselves), across separate requests and up to RUNNER_LANES
395 * concurrent processes. A random id would differ between them and the references would
396 * point at nothing.
397 *
398 * Hashing the session id with the old id instead makes the map a pure function of
399 * inputs every lane already has: same answer everywhere, no persistence, no locking, and
400 * stable across a resume. Shape matches keyUniqueID() — six lowercase base-36 characters
401 * plus the two-digit year.
402 *
403 * @since 2.7.2
404 *
405 * @param array $kit_global The kit's palette, `{color:[], typo:[]}`.
406 * @param string $session_id Session id, the per-run salt.
407 * @return array{color:array<string,string>,typo:array<string,string>} old id => new id.
408 */
409 public static function elementor_id_map( $kit_global, $session_id = '' ) {
410 $map = array(
411 'color' => array(),
412 'typo' => array(),
413 );
414
415 if ( ! is_array( $kit_global ) ) {
416 return $map;
417 }
418
419 /* Memoized per request: the page importer asks once per template, and the answer
420 * cannot change within a request — the site's globals are not written until
421 * stage_setup, which is a later one. Keyed so a second kit in the same process (the
422 * remote job executor runs several) cannot read the first one's map. */
423 static $memo = array();
424
425 $memo_key = md5( $session_id . '|' . (string) wp_json_encode( $kit_global ) );
426
427 if ( isset( $memo[ $memo_key ] ) ) {
428 return $memo[ $memo_key ];
429 }
430
431 $existing = self::existing_elementor_globals();
432
433 /* `taken` is keyed by id and holds the entry, not just `true`, because whether an id
434 * is a COLLISION depends on what is sitting on it. See is_same_global(). */
435 $taken = array();
436
437 foreach ( array( 'custom_colors', 'custom_typography' ) as $group ) {
438 foreach ( (array) $existing[ $group ] as $entry ) {
439 if ( is_array( $entry ) && ! empty( $entry['_id'] ) ) {
440 $taken[ (string) $entry['_id'] ] = $entry;
441 }
442 }
443 }
444
445 if ( empty( $taken ) ) {
446 $memo[ $memo_key ] = $map;
447
448 return $map;
449 }
450
451 $groups = array(
452 'color' => ( ! empty( $kit_global['color'] ) && is_array( $kit_global['color'] ) ) ? $kit_global['color'] : array(),
453 'typo' => ( ! empty( $kit_global['typo'] ) && is_array( $kit_global['typo'] ) ) ? $kit_global['typo'] : array(),
454 );
455
456 $year = gmdate( 'y' );
457
458 $candidate = function ( $key, $old, $attempt ) use ( $session_id, $year ) {
459 return substr( md5( $session_id . '|' . $key . '|' . $old . '|' . $attempt ), 0, 6 ) . $year;
460 };
461
462 /* Every id this map could ever mint, before deciding anything.
463 *
464 * The re-hash loop below has to skip an id that is already taken — but by stage_setup
465 * this run has WRITTEN its own replacements into the site kit, so on a retry that
466 * re-imports a page afterwards they come back as "taken" and the loop would move on
467 * to attempt 1, producing a different id than the pages already reference. Knowing our
468 * own outputs up front lets the loop treat them as reserved-by-us rather than
469 * occupied, which is what makes this map a pure function of (session, kit) and
470 * identical no matter when it is asked. */
471 $ours = array();
472
473 foreach ( $groups as $key => $entries ) {
474 foreach ( $entries as $entry ) {
475 if ( is_array( $entry ) && ! empty( $entry['_id'] ) ) {
476 for ( $attempt = 0; $attempt < 8; $attempt++ ) {
477 $ours[ $candidate( $key, (string) $entry['_id'], $attempt ) ] = true;
478 }
479 }
480 }
481 }
482
483 foreach ( $groups as $key => $entries ) {
484 foreach ( $entries as $entry ) {
485 if ( ! is_array( $entry ) || empty( $entry['_id'] ) ) {
486 continue;
487 }
488
489 $old = (string) $entry['_id'];
490
491 if ( ! isset( $taken[ $old ] ) || isset( $map[ $key ][ $old ] ) ) {
492 continue;
493 }
494
495 /* The id is taken by THIS SAME global — almost always because an earlier
496 * import of this kit put it there. Not a collision: remapping it would mint a
497 * second copy of a global the site already has, `without_known()` would no
498 * longer recognise it as known, and every re-import would grow the palette by
499 * the size of the kit (observed: 15 colours -> 29 on a second run). Leave the
500 * id alone and let without_known() do its job. */
501 if ( self::is_same_global( $entry, $taken[ $old ], $key ) ) {
502 continue;
503 }
504
505 /* Re-hash on the (vanishingly unlikely) chance the derived id belongs to a
506 * global that was on this site BEFORE the import. Bounded so a pathological
507 * site cannot spin here. */
508 $new = '';
509
510 for ( $attempt = 0; $attempt < 8; $attempt++ ) {
511 $try = $candidate( $key, $old, $attempt );
512
513 if ( ! isset( $taken[ $try ] ) || isset( $ours[ $try ] ) ) {
514 $new = $try;
515 break;
516 }
517 }
518
519 if ( '' === $new ) {
520 continue;
521 }
522
523 $map[ $key ][ $old ] = $new;
524 }
525 }
526
527 $memo[ $memo_key ] = $map;
528
529 return $map;
530 }
531
532 /**
533 * Are these two global entries the same global, rather than two different ones that happen
534 * to share an `_id`?
535 *
536 * Only the fields that decide what the global RENDERS AS are compared — a colour's value, a
537 * typography entry's family — plus the title, because two entries with the same value and
538 * different names are still meaningfully different to whoever named them. The kit entry is
539 * read through both spellings: `new_color` is where the preview screen puts a recoloured
540 * swatch, `color` is where it ends up after from_kit().
541 *
542 * Used only to tell a re-import (same kit, globals already applied) apart from a genuine id
543 * clash with an unrelated global. Erring toward "these are different" costs one duplicated
544 * palette entry; erring the other way costs a page rendering in the wrong colour — so when
545 * this cannot tell, it says different.
546 *
547 * @since 2.7.2
548 *
549 * @param array $kit_entry Entry from the kit's palette.
550 * @param array $existing_entry Entry already on the site under the same `_id`.
551 * @param string $group 'color' or 'typo'.
552 * @return bool
553 */
554 private static function is_same_global( $kit_entry, $existing_entry, $group ) {
555 if ( ! is_array( $kit_entry ) || ! is_array( $existing_entry ) ) {
556 return false;
557 }
558
559 $title_kit = isset( $kit_entry['title'] ) ? (string) $kit_entry['title'] : '';
560 $title_existing = isset( $existing_entry['title'] ) ? (string) $existing_entry['title'] : '';
561
562 if ( $title_kit !== $title_existing ) {
563 return false;
564 }
565
566 if ( 'color' === $group ) {
567 $kit_color = '';
568
569 foreach ( array( 'new_color', 'color' ) as $field ) {
570 if ( ! empty( $kit_entry[ $field ] ) ) {
571 $kit_color = (string) $kit_entry[ $field ];
572 break;
573 }
574 }
575
576 $existing_color = isset( $existing_entry['color'] ) ? (string) $existing_entry['color'] : '';
577
578 return '' !== $kit_color && 0 === strcasecmp( $kit_color, $existing_color );
579 }
580
581 $kit_family = isset( $kit_entry['typography_font_family'] ) ? (string) $kit_entry['typography_font_family'] : '';
582 $existing_family = isset( $existing_entry['typography_font_family'] ) ? (string) $existing_entry['typography_font_family'] : '';
583
584 /* A typography entry with no family on either side is still the same entry when the
585 * titles match — the kit ships plenty that only set size and weight. */
586 return $kit_family === $existing_family;
587 }
588
589 /**
590 * Swap remapped global ids through one page's Elementor content.
591 *
592 * The PHP counterpart of Extract_elementor_global()'s `status == 'update'` branch, and
593 * deliberately only that branch: elementor_id_map() produces nothing else. The browser's
594 * other branch (`remove`, which inlines a global's literal values and deletes the
595 * reference) belongs to a case import_globla_data() reaches and the runner does not.
596 *
597 * Operates on the raw JSON string rather than the decoded tree on purpose. The reference
598 * is a URL inside a `__globals__` value — `globals/colors?id=<id>` — so an anchored
599 * string swap touches exactly the references and nothing that merely happens to contain
600 * the same seven characters. Decoding, walking and re-encoding a 200KB tree to change a
601 * handful of ids would cost far more and risk re-encoding differences.
602 *
603 * @since 2.7.2
604 *
605 * @param string $content Elementor content, JSON-encoded.
606 * @param array $map As elementor_id_map() returns it.
607 * @return string Content with the references remapped; unchanged when there is nothing to do.
608 */
609 public static function rewrite_elementor_global_ids( $content, $map ) {
610 if ( ! is_string( $content ) || '' === $content || ! is_array( $map ) ) {
611 return $content;
612 }
613
614 $prefixes = array(
615 'color' => 'colors?id=',
616 'typo' => 'typography?id=',
617 );
618
619 $search = array();
620 $replace = array();
621
622 foreach ( $prefixes as $key => $prefix ) {
623 if ( empty( $map[ $key ] ) || ! is_array( $map[ $key ] ) ) {
624 continue;
625 }
626
627 foreach ( $map[ $key ] as $old => $new ) {
628 /* Anchored on `colors?id=` / `typography?id=` rather than on the full
629 * `globals/colors?id=`, because the slash in front of it is not literal in
630 * this string. `_elementor_data` is JSON, and wp_json_encode() escapes
631 * forward slashes — the reference is stored as `globals\/colors?id=<id>`, so
632 * a needle containing a bare `/` matches nothing at all. Rather than carry
633 * both spellings and hope no third one exists, the needle starts after the
634 * slash. `colors?id=` plus a specific 7-8 character id is still far too
635 * distinctive to collide with anything else in a page. */
636 $search[] = $prefix . $old;
637 $replace[] = $prefix . $new;
638 }
639 }
640
641 if ( empty( $search ) ) {
642 return $content;
643 }
644
645 return str_replace( $search, $replace, $content );
646 }
647
648 /**
649 * Merge two {color, typo} sets, first occurrence of an id winning.
650 *
651 * @param array $primary Takes precedence.
652 * @param array $fallback Fills gaps.
653 * @param string $builder Builder, which decides the id key.
654 * @return array
655 */
656 public static function merge_globals( $primary, $fallback, $builder = 'elementor' ) {
657 $id_key = ( 'gutenberg' === $builder ) ? 'id' : '_id';
658 $out = array(
659 'color' => array(),
660 'typo' => array(),
661 );
662
663 /* The preset is positional and built as a whole; it cannot be merged entry by
664 * entry, so the primary's preset wins outright. */
665 if ( ! empty( $primary['preset'] ) ) {
666 $out['preset'] = $primary['preset'];
667 } elseif ( ! empty( $fallback['preset'] ) ) {
668 $out['preset'] = $fallback['preset'];
669 }
670
671 foreach ( array( 'color', 'typo' ) as $bucket ) {
672 $seen = array();
673
674 foreach ( array( $primary, $fallback ) as $set ) {
675 foreach ( ( ! empty( $set[ $bucket ] ) && is_array( $set[ $bucket ] ) ? $set[ $bucket ] : array() ) as $entry ) {
676 if ( ! is_array( $entry ) ) {
677 continue;
678 }
679
680 $key = isset( $entry[ $id_key ] ) ? (string) $entry[ $id_key ] : wp_json_encode( $entry );
681
682 if ( isset( $seen[ $key ] ) ) {
683 continue;
684 }
685
686 $seen[ $key ] = true;
687 $out[ $bucket ][] = $entry;
688 }
689 }
690 }
691
692 return $out;
693 }
694
695 public static function apply( $globals, $builder = 'elementor', $site_data = array() ) {
696 $result = array(
697 'colors' => 0,
698 'typography' => 0,
699 'site' => false,
700 'skipped' => false,
701 );
702
703 if ( ! class_exists( 'Wdkit_Api_Call' ) ) {
704 $result['skipped'] = true;
705
706 return $result;
707 }
708
709 $api = Wdkit_Api_Call::get_instance();
710 $builder = ( 'gutenberg' === $builder ) ? 'gutenberg' : 'elementor';
711
712 $colors = ! empty( $globals['color'] ) && is_array( $globals['color'] ) ? $globals['color'] : array();
713 $typo = ! empty( $globals['typo'] ) && is_array( $globals['typo'] ) ? $globals['typo'] : array();
714
715 if ( 'elementor' === $builder ) {
716 /* Drop anything already in the kit so a repeat run does not append duplicates
717 * through the underlying array_merge. */
718 $existing = self::existing_elementor_globals();
719
720 $colors = self::without_known( $colors, $existing['custom_colors'], '_id' );
721 $typo = self::without_known( $typo, $existing['custom_typography'], '_id' );
722 }
723
724 if ( ! empty( $colors ) || ! empty( $typo ) ) {
725 $response = $api->wdkit_apply_global_values_data( $builder, $colors, $typo, self::gutenberg_preset( $globals, $builder ) );
726
727 if ( ! empty( $response['success'] ) ) {
728 $result['colors'] = count( $colors );
729 $result['typography'] = count( $typo );
730 }
731 }
732
733 if ( ! empty( $site_data ) && is_array( $site_data ) ) {
734 $clean = self::sanitize_site_data( $site_data, $builder );
735
736 /* Nothing recognised survived the whitelist. Writing it anyway would replace a
737 * real container width with an empty value, which is exactly the regression this
738 * guard exists to stop — leaving the site's current setting alone is the safe
739 * outcome, and it is logged so a kit shipping an unexpected shape is visible. */
740 if ( empty( $clean ) ) {
741 if ( class_exists( 'Wdkit_Import_Log' ) ) {
742 Wdkit_Import_Log::add(
743 'globals',
744 array(
745 'what' => 'site_data',
746 'skipped' => 1,
747 'builder' => $builder,
748 'keys' => implode( ',', array_slice( array_keys( $site_data ), 0, 8 ) ),
749 )
750 );
751 }
752 } else {
753 $site_response = $api->wdkit_apply_site_globals_data( $builder, $clean );
754 $result['site'] = ! empty( $site_response['success'] );
755 }
756 }
757
758 return $result;
759 }
760
761 /**
762 * What the active Elementor kit already holds.
763 *
764 * @return array{custom_colors:array,custom_typography:array}
765 */
766 private static function existing_elementor_globals() {
767 $empty = array(
768 'custom_colors' => array(),
769 'custom_typography' => array(),
770 );
771
772 $kit_id = get_option( 'elementor_active_kit' );
773
774 if ( ! $kit_id ) {
775 return $empty;
776 }
777
778 $meta = get_post_meta( $kit_id, '_elementor_page_settings', true );
779
780 if ( ! is_array( $meta ) ) {
781 return $empty;
782 }
783
784 return array(
785 'custom_colors' => ! empty( $meta['custom_colors'] ) && is_array( $meta['custom_colors'] ) ? $meta['custom_colors'] : array(),
786 'custom_typography' => ! empty( $meta['custom_typography'] ) && is_array( $meta['custom_typography'] ) ? $meta['custom_typography'] : array(),
787 );
788 }
789
790 /**
791 * Remove entries whose id already exists in the target.
792 *
793 * @param array $entries Candidate entries.
794 * @param array $existing Entries already stored.
795 * @param string $id_key Identity key.
796 * @return array
797 */
798 private static function without_known( $entries, $existing, $id_key ) {
799 if ( empty( $existing ) ) {
800 return $entries;
801 }
802
803 $known = array();
804
805 foreach ( $existing as $entry ) {
806 if ( is_array( $entry ) && ! empty( $entry[ $id_key ] ) ) {
807 $known[ (string) $entry[ $id_key ] ] = true;
808 }
809 }
810
811 $out = array();
812
813 foreach ( $entries as $entry ) {
814 if ( ! is_array( $entry ) || empty( $entry[ $id_key ] ) ) {
815 continue;
816 }
817
818 if ( isset( $known[ (string) $entry[ $id_key ] ] ) ) {
819 continue;
820 }
821
822 $out[] = $entry;
823 }
824
825 return $out;
826 }
827
828 /**
829 * The Gutenberg preset envelope, which is keyed and therefore already idempotent.
830 *
831 * @param array $globals Collected globals.
832 * @param string $builder Builder.
833 * @return array
834 */
835 private static function gutenberg_preset( $globals, $builder ) {
836 if ( 'gutenberg' !== $builder ) {
837 return array();
838 }
839
840 if ( ! empty( $globals['preset'] ) && is_array( $globals['preset'] ) && ! empty( $globals['preset']['key'] ) ) {
841 return $globals['preset'];
842 }
843
844 return array();
845 }
846
847 /**
848 * Keep only the site-level keys the underlying write recognises, per builder.
849 *
850 * Anything else is dropped rather than forwarded, so a future payload cannot smuggle
851 * extra keys into `_elementor_page_settings` or `tpgb_global_options`.
852 *
853 * The two builders ship DIFFERENT shapes in the same `site_settings` block, and the
854 * whitelist used to describe only Elementor's:
855 *
856 * elementor {container_width:{unit,size,sizes}, globals:{...}, body_background_color:[]}
857 * gutenberg {md:"1240", xs:"1240", unit:"px"}
858 *
859 * So for a Gutenberg kit every key was dropped and the caller wrote an EMPTY array over
860 * `tpgb_global_options.globalContainer` — the site then rendered at Nexter's default
861 * 1140px instead of the kit's 1240px. Gutenberg's values are scalars, not arrays, which
862 * is why the `is_array()` test alone could never have passed them through.
863 *
864 * @param array $site_data Raw site data.
865 * @param string $builder 'elementor'|'gutenberg'.
866 * @return array
867 */
868 private static function sanitize_site_data( $site_data, $builder = 'elementor' ) {
869 $clean = array();
870
871 if ( ! is_array( $site_data ) ) {
872 return $clean;
873 }
874
875 if ( 'gutenberg' === $builder ) {
876 /* Breakpoint widths plus the unit they are expressed in. Scalars only — a
877 * nested structure here is not something Nexter reads, so it is dropped. */
878 foreach ( array( 'xs', 'sm', 'md', 'lg', 'unit' ) as $key ) {
879 if ( isset( $site_data[ $key ] ) && is_scalar( $site_data[ $key ] ) ) {
880 $clean[ $key ] = sanitize_text_field( (string) $site_data[ $key ] );
881 }
882 }
883
884 return $clean;
885 }
886
887 foreach ( array( 'container_width', 'globals', 'body_background_color' ) as $key ) {
888 if ( isset( $site_data[ $key ] ) && is_array( $site_data[ $key ] ) ) {
889 $clean[ $key ] = $site_data[ $key ];
890 }
891 }
892
893 return $clean;
894 }
895
896 /**
897 * Point a kit's global references at the ids this site assigned.
898 *
899 * ── Why this is here and not in the browser ────────────────────────────────
900 *
901 * The kit's blocks reference globals by the KIT's own ids, which are sparse
902 * (`typo: [14,7,5,17,16,13,12,9,8,11,6,15]`). from_kit() writes the palette
903 * positionally, so the same entries land at 1..n. Every reference in the content has to
904 * move with them or it points at a palette slot that holds something else - or, for an id
905 * past the end, at nothing at all, which is what left T13..T18 undefined and the headings
906 * unstyled.
907 *
908 * The browser does this with Extract_gutenberg_global(). Handing the page back for it to
909 * transform and re-save cost one extra round trip per page and, more to the point, could
910 * not be tested without running a full import: it regex-matches block comments and
911 * JSON.parses the capture, so its behaviour depends on the exact serialisation it is
912 * handed. Doing it here works on the parsed block array the runner already holds, before
913 * it is ever serialised, and is checkable from the CLI.
914 *
915 * The RULES are the browser's, kept deliberately narrow:
916 *
917 * colour a string `var(--tpgb-C{n})` becomes `var(--tpgb-C{site})`.
918 * typography a `globalTypo: n` object is REPLACED by the kit global's own typography
919 * value with `globalTypo` set to the site id - the browser assigns
920 * `new_data = typo_data`, so the block ends up carrying the resolved
921 * typography, not just a renumbered pointer. The matching
922 * `var(--tpgb-{n}-font-size)` style references are renumbered too.
923 * GC/S/BS left exactly as they are. The shipped call sites pass neither gradient,
924 * spacing nor boxshadow, so the browser leaves these alone as well; inventing
925 * a mapping here would be new behaviour, not a port.
926 *
927 * A reference the kit's own global data does not describe is left untouched rather than
928 * guessed at - same as the browser, where the lookup simply misses.
929 *
930 * @since 2.6.5
931 *
932 * @param array $blocks Parsed blocks, by reference.
933 * @param mixed $kit_global site_obj.kit_global - {color, typo}.
934 * @return array oldId => newId for the typography ids that moved, for the string pass.
935 */
936 /**
937 * The site's own global palette, as it stood before this import touched anything.
938 *
939 * The browser reads this with the `get_global_val` action at the top of
940 * import_globla_data() and then APPENDS the kit's entries to it:
941 *
942 * let new_color = Object.assign({}, color, { id: (site_g_color.length + 1) })
943 * site_g_color.push(new_color)
944 *
945 * So a site whose active preset already holds five colours gets the kit's first colour
946 * at C6, and the finished preset is [site's five] + [kit's thirteen]. Numbering the kit
947 * from 1 instead - which is what this class did - silently replaces the site's own
948 * palette, so any content already on the site that referenced C1..C5 changes colour.
949 *
950 * Captured ONCE per session and kept there. Pages are imported before the globals step
951 * runs, and after it runs the active preset is the new one, so re-reading the option
952 * later would give a different, larger baseline and the second half of a resumed import
953 * would be numbered against it.
954 *
955 * @since 2.6.5
956 *
957 * @param string $session_id Session id. Empty reads live without caching.
958 * @return array{colors:array,typography:array}
959 */
960 public static function baseline( $session_id = '' ) {
961 if ( '' !== $session_id ) {
962 $session = Wdkit_Import_Session::get( $session_id );
963
964 if ( is_array( $session ) && isset( $session['global_baseline'] ) && is_array( $session['global_baseline'] ) ) {
965 return $session['global_baseline'];
966 }
967 }
968
969 $baseline = self::read_active_preset();
970
971 if ( '' !== $session_id ) {
972 Wdkit_Import_Session::update( $session_id, array( 'global_baseline' => $baseline ) );
973 }
974
975 return $baseline;
976 }
977
978 /**
979 * Read the active tpgb preset's colour and typography lists.
980 *
981 * Same option and same selection rule `get_global_val` uses - `presets[ active ]` - but
982 * without the emit-and-die, so the runner can call it.
983 *
984 * @return array{colors:array,typography:array}
985 */
986 private static function read_active_preset() {
987 $empty = array(
988 'colors' => array(),
989 'typography' => array(),
990 'preset' => array(),
991 );
992
993 $raw = get_option( 'tpgb_global_options' );
994
995 /* Nothing has written the option yet, which on a fresh site is the normal state.
996 *
997 * Returning empty here is not harmless: the kit's palette is APPENDED to the baseline,
998 * so with no baseline the site ends up with only the kit's own entries — a headless
999 * import produced 13 colours instead of 18, 12 type entries instead of 19, and no
1000 * gradients, spacing or box shadows at all, while the same kit through the wizard came
1001 * out complete. The wizard differs only by accident: its globals screen calls
1002 * wdkit_get_global_val() first, and that seeds the option as a side effect.
1003 *
1004 * Seed it here with the same record, so both paths build on the same baseline. */
1005 if ( empty( $raw ) && class_exists( 'Wdkit_Api_Call' ) && method_exists( 'Wdkit_Api_Call', 'wdkit_default_gutenberg_globals' ) ) {
1006 $defaults = Wdkit_Api_Call::wdkit_default_gutenberg_globals();
1007
1008 if ( ! empty( $defaults ) ) {
1009 update_option( 'tpgb_global_options', wp_json_encode( $defaults ) );
1010
1011 $raw = get_option( 'tpgb_global_options' );
1012 }
1013 }
1014
1015 if ( empty( $raw ) ) {
1016 return $empty;
1017 }
1018
1019 $settings = is_string( $raw ) ? json_decode( $raw, true ) : $raw;
1020
1021 if ( ! is_array( $settings ) || empty( $settings['presets'] ) || ! is_array( $settings['presets'] ) ) {
1022 return $empty;
1023 }
1024
1025 $active = isset( $settings['active'] ) ? (string) $settings['active'] : '';
1026
1027 /* Never build on top of a previous import's own preset.
1028 *
1029 * The kit's palette is appended after whatever the site already had, and the block
1030 * CSS refers to colours BY POSITION — C1, C2, C12. Taking `wdk_preset` as the
1031 * baseline meant every import stacked on the last one: 5 stock colours, then 12,
1032 * then 24, then 37, with the kit's real palette pushed further out each time while
1033 * its content still asked for the low positions. The result on a site imported
1034 * three times was dark text on a dark background, because C1-C5 were no longer the
1035 * kit's colours at all.
1036 *
1037 * So a previous import's preset is skipped in favour of a stock one, which makes
1038 * the palette the same size and the same shape however many times a site is
1039 * imported to. */
1040 if ( self::PRESET_KEY === $active ) {
1041 foreach ( array( 'preset1', 'preset2' ) as $stock ) {
1042 if ( isset( $settings['presets'][ $stock ] ) && is_array( $settings['presets'][ $stock ] ) ) {
1043 $active = $stock;
1044
1045 break;
1046 }
1047 }
1048
1049 /* Nothing stock left to fall back on — start clean rather than compounding. */
1050 if ( self::PRESET_KEY === $active ) {
1051 return $empty;
1052 }
1053 }
1054
1055 if ( '' === $active || ! isset( $settings['presets'][ $active ] ) || ! is_array( $settings['presets'][ $active ] ) ) {
1056 return $empty;
1057 }
1058
1059 $preset = $settings['presets'][ $active ];
1060
1061 return array(
1062 'colors' => ( ! empty( $preset['colors'] ) && is_array( $preset['colors'] ) ) ? array_values( $preset['colors'] ) : array(),
1063 'typography' => ( ! empty( $preset['typography'] ) && is_array( $preset['typography'] ) ) ? array_values( $preset['typography'] ) : array(),
1064 'preset' => $preset,
1065 );
1066 }
1067
1068 public static function rewrite_kit_references( &$blocks, $kit_global, $session_id = '' ) {
1069 if ( ! is_array( $blocks ) || ! is_array( $kit_global ) ) {
1070 return array();
1071 }
1072
1073 $colors = ( ! empty( $kit_global['color'] ) && is_array( $kit_global['color'] ) ) ? $kit_global['color'] : array();
1074 $typos = ( ! empty( $kit_global['typo'] ) && is_array( $kit_global['typo'] ) ) ? $kit_global['typo'] : array();
1075
1076 if ( empty( $colors ) && empty( $typos ) ) {
1077 return array();
1078 }
1079
1080 /* The browser matches with `Number(temp_id) + 1 == n`, so C11 means the entry whose
1081 * kit id is 10. Keyed the same way here so a kit whose ids start at 0 still lines up. */
1082 /* Same offset from_kit() uses, from the same captured baseline, so the id a block is
1083 * pointed at is the slot the palette actually puts that entry in. Reading the baseline
1084 * separately in each place would work only for as long as the two happened to agree. */
1085 $baseline = self::baseline( $session_id );
1086 $color_offset = count( $baseline['colors'] );
1087 $typo_offset = count( $baseline['typography'] );
1088
1089 $color_map = array();
1090
1091 foreach ( $colors as $i => $entry ) {
1092 if ( is_array( $entry ) && isset( $entry['id'] ) ) {
1093 $color_map[ (int) $entry['id'] + 1 ] = $color_offset + $i + 1;
1094 }
1095 }
1096
1097 $typo_map = array();
1098
1099 foreach ( $typos as $i => $entry ) {
1100 if ( is_array( $entry ) && isset( $entry['id'] ) ) {
1101 $typo_map[ (int) $entry['id'] + 1 ] = array(
1102 'site_id' => $typo_offset + $i + 1,
1103 'value' => ( isset( $entry['value'] ) && is_array( $entry['value'] ) ) ? $entry['value'] : array(),
1104 );
1105 }
1106 }
1107
1108 $moved = array();
1109
1110 self::walk_kit_references( $blocks, $color_map, $typo_map, $moved );
1111
1112 return $moved;
1113 }
1114
1115 /**
1116 * Recurse through blocks and attributes applying the reference rewrite.
1117 *
1118 * @param array $nodes Blocks or attribute values, by reference.
1119 * @param array $color_map n => site id.
1120 * @param array $typo_map n => {site_id, value}.
1121 * @param array $moved Collects oldId => newId, by reference.
1122 * @return void
1123 */
1124 private static function walk_kit_references( &$nodes, $color_map, $typo_map, &$moved ) {
1125 foreach ( $nodes as $key => &$value ) {
1126 if ( is_string( $value ) ) {
1127 $nodes[ $key ] = self::swap_colour_refs( $value, $color_map );
1128 continue;
1129 }
1130
1131 if ( ! is_array( $value ) ) {
1132 continue;
1133 }
1134
1135 /* A typography object: swap the whole thing for the global it points at, exactly as
1136 * Extract_gutenberg_global()'s `new_data = typo_data` does. */
1137 if ( isset( $value['globalTypo'] ) && ! is_array( $value['globalTypo'] ) ) {
1138 $old = (int) $value['globalTypo'];
1139
1140 if ( isset( $typo_map[ $old ] ) ) {
1141 $resolved = $typo_map[ $old ]['value'];
1142
1143 $resolved['globalTypo'] = $typo_map[ $old ]['site_id'];
1144
1145 $moved[ $old ] = $typo_map[ $old ]['site_id'];
1146
1147 $nodes[ $key ] = $resolved;
1148
1149 continue;
1150 }
1151 }
1152
1153 self::walk_kit_references( $value, $color_map, $typo_map, $moved );
1154 }
1155
1156 unset( $value );
1157 }
1158
1159 /**
1160 * Rewrite `var(--tpgb-C{n})` inside one string.
1161 *
1162 * @param string $subject Attribute value.
1163 * @param array $color_map n => site id.
1164 * @return string
1165 */
1166 private static function swap_colour_refs( $subject, $color_map ) {
1167 if ( empty( $color_map ) || false === strpos( $subject, 'tpgb-C' ) ) {
1168 return $subject;
1169 }
1170
1171 return preg_replace_callback(
1172 '/var\\(\\s*--tpgb-C(\\d+)\\s*\\)/',
1173 function ( $m ) use ( $color_map ) {
1174 $n = (int) $m[1];
1175
1176 return isset( $color_map[ $n ] ) ? 'var(--tpgb-C' . $color_map[ $n ] . ')' : $m[0];
1177 },
1178 $subject
1179 );
1180 }
1181
1182 /**
1183 * Renumber the per-property typography variables in serialised markup.
1184 *
1185 * These read `var(--tpgb-15-font-size)` - the kit's typography id, no `T` prefix - and
1186 * they turn up in innerHTML as well as in attributes, which is why this runs over the
1187 * serialised string rather than the block array.
1188 *
1189 * Two passes through a sentinel, because the id spaces overlap: renumbering 15 to 1
1190 * directly and then 1 to something else would move the same reference twice.
1191 *
1192 * @param string $content Serialised block markup.
1193 * @param array $moved oldId => newId.
1194 * @return string
1195 */
1196 public static function renumber_typo_vars( $content, $moved ) {
1197 if ( ! is_string( $content ) || empty( $moved ) ) {
1198 return (string) $content;
1199 }
1200
1201 $props = array(
1202 'font-family',
1203 'font-weight',
1204 'font-style',
1205 'font-size',
1206 'line-height',
1207 'letter-spacing',
1208 'text-transform',
1209 'text-decoration',
1210 );
1211
1212 $search = array();
1213 $replace = array();
1214
1215 foreach ( $moved as $old => $new ) {
1216 foreach ( $props as $prop ) {
1217 $search[] = '--tpgb-' . $old . '-' . $prop;
1218 $replace[] = '--tpgb-@@' . $new . '@@-' . $prop;
1219 }
1220 }
1221
1222 $content = str_replace( $search, $replace, $content );
1223
1224 return str_replace( array( '--tpgb-@@', '@@-' ), array( '--tpgb-', '-' ), $content );
1225 }
1226 }
1227 }
1228