PluginProbe
WDesignKit – AI Templates, Widget Builder & MCP Workflow / 2.7.0
WDesignKit – AI Templates, Widget Builder & MCP Workflow v2.7.0
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 1.0.16 All 129 releases
wdesignkit / includes / admin / import / class-wdkit-import-css.php

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

1,016 lines 35.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Block CSS generation, server-side.
4 *
5 * ── What this replaces ──────────────────────────────────────────────────────
6 *
7 * The Plus Addons stores each post's generated CSS in `_tpgb_css` and builds it when the post
8 * is saved from the block editor. The wizard has therefore always regenerated it by opening
9 * every imported page in a hidden editor iframe and letting it autosave, one page at a time:
10 * `update_setting()` -> `Delete_post()` -> `start_tpgb_resync()` -> `resync_next_page()`.
11 *
12 * That is roughly 8 seconds per page. On a thirteen-template kit it is the single longest part
13 * of the import - longer than fetching and importing all the content - and it happens after the
14 * progress screen has already ticked everything off, so it reads as a hang.
15 *
16 * The plugin exposes `make_block_css_by_post_id()`, which does the same job in PHP. Measured on
17 * this kit:
18 *
19 * hidden-iframe pass ~8s per page ~100s for 13
20 * make_block_css... ~0.015s per page ~0.2s for 13
21 *
22 * ── Why it is safe to swap ──────────────────────────────────────────────────
23 *
24 * The two outputs are not byte-identical - the PHP generator groups responsive rules into
25 * @media blocks (72 of them on one page, where the editor emitted 2 and inlined the rest), so
26 * a selector-level diff looks alarming. What matters is what the browser computes, and that was
27 * compared element by element on the same template generated both ways:
28 *
29 * matched 12/12 blocks on font-size, color, padding, margin, background-color, text-align
30 * identical set of var(--tpgb-*) references
31 *
32 * The iframe pass is kept as the fallback: if no generator class is present this returns
33 * `available => false` and the browser runs the old chain exactly as before.
34 *
35 * One difference worth recording: the editor autosave also renumbers a block's `block_id`
36 * suffix to the new post id. This does not. That turns out not to matter - the generated CSS is
37 * keyed to whatever suffix the content actually carries, so the two agree either way - but it
38 * is the reason not to assume the two passes are interchangeable for anything beyond CSS.
39 *
40 * @package Wdesignkit
41 * @since 2.6.5
42 */
43
44 if ( ! defined( 'ABSPATH' ) ) {
45 exit;
46 }
47
48 if ( ! class_exists( 'Wdkit_Import_Css' ) ) {
49
50 /**
51 * Generates `_tpgb_css` without a browser.
52 */
53 class Wdkit_Import_Css {
54
55 /**
56 * The Plus Addons' generator instance, or null.
57 *
58 * @var object|null
59 */
60 private static $generator = false;
61
62 /**
63 * Build the block CSS for a list of posts.
64 *
65 * @param int[] $post_ids Imported post ids.
66 * @return array{available:bool,generated:int,skipped:int,ms:int}
67 */
68 public static function generate( $post_ids ) {
69 $started = microtime( true );
70
71 $result = array(
72 'available' => false,
73 'generated' => 0,
74 'skipped' => 0,
75 'ms' => 0,
76 );
77
78 $generator = self::generator();
79
80 if ( null === $generator ) {
81 return $result;
82 }
83
84 $result['available'] = true;
85
86 /* One cache-bust version for everything this import built - see stamp_version(). */
87 $version = time();
88
89 foreach ( array_unique( array_map( 'intval', (array) $post_ids ) ) as $post_id ) {
90 if ( self::rebuild( $post_id, $version ) ) {
91 ++$result['generated'];
92 } else {
93 ++$result['skipped'];
94 }
95 }
96
97 $result['ms'] = (int) round( ( microtime( true ) - $started ) * 1000 );
98
99 if ( class_exists( 'Wdkit_Import_Log' ) ) {
100 Wdkit_Import_Log::add( 'css', $result );
101 }
102
103 return $result;
104 }
105
106 /**
107 * Build one post's block CSS and record its version where the front end reads it.
108 *
109 * The single entry point for this - the runner's generate() above and every importer
110 * path in class-api.php (wdkit_rebuild_block_css()) go through here, so none of them
111 * leaves a page with CSS but no version.
112 *
113 * make_block_css_by_post_id() records its version through update_posts_metadata(), which
114 * writes TERM meta whenever the request is not a singular front-end view and is not
115 * flagged as the editor - i.e. in every admin-ajax, cron or CLI import. That write is
116 * undone here: a post id is not a term id, and left alone the row either sits orphaned or,
117 * on a site with that many terms, overwrites a real term's own `_block_css`.
118 *
119 * @since 2.7.2
120 *
121 * @param int $post_id Post id.
122 * @param int|null $version Version to record; now when omitted.
123 * @return bool Whether the CSS was built.
124 */
125 public static function rebuild( $post_id, $version = null ) {
126 $post_id = (int) $post_id;
127
128 if ( $post_id <= 0 || ! get_post( $post_id ) ) {
129 return false;
130 }
131
132 $generator = self::generator();
133
134 if ( null === $generator ) {
135 return false;
136 }
137
138 self::register_late_blocks();
139
140 $term_before = get_term_meta( $post_id, '_block_css', true );
141 $built = false;
142
143 try {
144 $generator->make_block_css_by_post_id( $post_id );
145
146 self::stamp_version( $generator, $post_id, null === $version ? time() : (int) $version );
147
148 $built = true;
149 } catch ( Throwable $e ) {
150 /* One page failing to build its CSS is a styling problem on that page, not a
151 * reason to fail an import that has already written everything. */
152 $built = false;
153 }
154
155 if ( '' === $term_before || false === $term_before ) {
156 delete_term_meta( $post_id, '_block_css' );
157 } else {
158 update_term_meta( $post_id, '_block_css', $term_before );
159 }
160
161 return $built;
162 }
163
164 /**
165 * Record the CSS version on the POST, where the front end reads it.
166 *
167 * Nexter Blocks versions every block stylesheet from `_block_css['version']` - the page's
168 * own plus-css-{id}.css and, through the page being viewed, plus-global.css. With that
169 * meta empty (see the term-meta note in generate()) it falls back to the WordPress core
170 * version, so the first view of every imported page asked for
171 * `plus-global.css?ver=7.1`: the same URL on every import, which a browser that had
172 * seen any earlier import of this site served from cache - the previous kit's palette
173 * and fonts. That render wrote the version, the next request carried a timestamp, and a
174 * refresh "fixed" the styling. The editor save the browser path relies on writes the same
175 * key through the same helper with its `$is_editor` flag set (plus_save_block_css());
176 * this does exactly that.
177 *
178 * @param object $generator Nexter Blocks' core instance.
179 * @param int $post_id Post id.
180 * @param int $version Version to record.
181 * @return void
182 */
183 private static function stamp_version( $generator, $post_id, $version ) {
184 $upload = wp_get_upload_dir();
185
186 if ( ! file_exists( trailingslashit( $upload['basedir'] ) . "theplus_gutenberg/plus-css-{$post_id}.css" ) ) {
187 return;
188 }
189
190 if ( method_exists( $generator, 'update_posts_metadata' ) ) {
191 $generator->update_posts_metadata( $post_id, '_block_css', 'version', $version, true );
192
193 return;
194 }
195
196 $meta = get_post_meta( $post_id, '_block_css', true );
197 $meta = is_array( $meta ) ? $meta : array();
198 $meta['version'] = $version;
199
200 update_post_meta( $post_id, '_block_css', $meta );
201 }
202
203 /**
204 * Render the front page once, server-side, so the visitor's first view is not the one that
205 * builds the caches.
206 *
207 * `_tpgb_css` is not the only thing a page needs. `_block_css` carries the cache-bust
208 * version AND the list of blocks whose assets to enqueue, and it is written during a
209 * render, not by make_block_css_by_post_id(). Measured straight after an import:
210 *
211 * front page _block_css = {"version":…,"blocks":[…]}
212 * Header _block_css = '' <- 6.5KB of CSS, but nothing telling the page to load it
213 * Footer _block_css = '' <- 42KB, same
214 *
215 * So clicking "Preview site" produced an unstyled page, that request filled in the missing
216 * bookkeeping, and a manual refresh looked correct. Doing one request here moves that cost
217 * off the user: their click is already the second view.
218 *
219 * Deliberately not reverse-engineered. Whatever a first render initialises - this meta, the
220 * template map, anything added later - a real render initialises correctly, which is not
221 * true of anything reimplemented here.
222 *
223 * Best effort throughout: a site that cannot reach itself over HTTP (a host without loopback,
224 * basic-auth staging) is common enough that this must never fail an import.
225 *
226 * @since 2.6.5
227 *
228 * @param string[] $urls Extra URLs to warm, front page first.
229 * @return array{warmed:int,failed:int,ms:int}
230 */
231 /**
232 * Write plus-global.css, the file that defines every global variable the page CSS uses.
233 *
234 * Each page's generated CSS refers to the palette and typography by variable —
235 * `var(--tpgb-C12)`, `var(--tpgb-T15-font-size)` — and those variables are declared in
236 * one shared file. Until Nexter Blocks 5.0.5 that file could only be produced by its
237 * block-editor JavaScript, which is why an import driven from outside WordPress came out
238 * with correct pages whose every colour and font resolved to nothing.
239 *
240 * Nexter Blocks now ships the PHP mirror of that generator, so this just asks for it.
241 * Absent — an older Nexter Blocks — it reports unavailable and the browser flow's editor
242 * pass still produces the file exactly as before; nothing regresses, but a headless
243 * import on that version is still unstyled.
244 *
245 * @return array{available:bool,written:bool}
246 */
247 public static function globals() {
248 /* `Tpgb_Global_Style_Vars` does not exist in any shipped Nexter Blocks build —
249 * grepping the whole plugin finds the name only in this file. So this branch is the
250 * one that always runs, and it used to return silently, which made the
251 * `global_css` step look like a step that had run and found nothing to do.
252 *
253 * It is not nothing. `uploads/theplus_gutenberg/plus-global.css` holds every design
254 * token the kit's blocks reference — `:root{--tpgb-C1..;--tpgb-T1-*..;
255 * --tpgb-container-md..}` — and it is written ONLY by TPGB's editor JS, from a
256 * wp.data subscription that fires when a post is saved inside a real editor session
257 * (see src/helper/editor_autosave.js). The wizard gets it because a browser performs
258 * that pass on the success screen. A headless run — the website-driven import, WP-CLI,
259 * cron — has no browser, so the file is never created and every block emits
260 * var(--tpgb-C1) with nothing defining it.
261 *
262 * Reported rather than hidden: the caller puts `reason` in the run's result so a
263 * headless import can say its styling is still pending instead of reporting a clean
264 * success for a site that will render with no palette at all. */
265 if ( ! class_exists( 'Tpgb_Global_Style_Vars' ) ) {
266 /* Build it ourselves. Verified byte-identical to what TPGB's editor pass writes
267 * for the same `tpgb_global_options` (6527 bytes on the Zion kit), and harmless
268 * if it ever drifts: the next real editor save overwrites this file with
269 * Nexter's own output. What it removes is the state where a headless import
270 * leaves a site with no design tokens at all. */
271 $written = self::write_global_css();
272
273 if ( class_exists( 'Wdkit_Import_Log' ) ) {
274 Wdkit_Import_Log::add(
275 'css',
276 array(
277 'what' => 'plus-global.css',
278 'written' => $written ? 1 : 0,
279 'via' => 'php',
280 'reason' => $written ? '' : 'needs_editor_pass',
281 )
282 );
283 }
284
285 return array(
286 'available' => $written,
287 'written' => $written,
288 'via' => 'php',
289
290 /* Only still pending when even the fallback could not produce it — no preset
291 * to read, or an unwritable uploads dir. */
292 'reason' => ( $written || 'gutenberg' !== self::builder_of_record() ) ? '' : 'needs_editor_pass',
293 );
294 }
295
296 $written = (bool) Tpgb_Global_Style_Vars::save();
297
298 Wdkit_Import_Log::add(
299 'css',
300 array(
301 'what' => 'plus-global.css',
302 'written' => $written,
303 )
304 );
305
306 return array(
307 'available' => true,
308 'written' => $written,
309 );
310 }
311
312 /**
313 * Is the token file this site needs a Gutenberg one?
314 *
315 * Only a Gutenberg import depends on plus-global.css, so an Elementor run must not be
316 * told its styling is pending. Read off the file rather than the run because globals()
317 * takes no arguments and every caller is inside a Gutenberg-gated branch already; the
318 * file check keeps it true even if that changes.
319 *
320 * @since 2.7.2
321 *
322 * @return string 'gutenberg' when the token file is absent or empty, '' otherwise.
323 */
324 private static function builder_of_record() {
325 $uploads = wp_upload_dir();
326
327 if ( empty( $uploads['basedir'] ) ) {
328 return 'gutenberg';
329 }
330
331 $file = $uploads['basedir'] . '/theplus_gutenberg/plus-global.css';
332
333 return ( ! file_exists( $file ) || filesize( $file ) < 1 ) ? 'gutenberg' : '';
334 }
335
336 public static function warm( $urls = array() ) {
337 $started = microtime( true );
338
339 $result = array(
340 'warmed' => 0,
341 'failed' => 0,
342 'ms' => 0,
343 );
344
345 $targets = array_values( array_unique( array_filter( array_merge( array( home_url( '/' ) ), (array) $urls ), 'is_string' ) ) );
346
347 foreach ( $targets as $url ) {
348 if ( self::request( $url ) ) {
349 ++$result['warmed'];
350
351 continue;
352 }
353
354 /* The site's own URL is not always reachable from the site. A container that
355 * publishes on a mapped port, a host without loopback, split-horizon DNS - all
356 * common, and all the reason WP's own cron has a loopback health check. Retry
357 * against the loopback address with the site's Host header, which reaches the
358 * local web server without needing the public name to resolve. */
359 $loopback = self::loopback_url( $url );
360
361 if ( '' !== $loopback && self::request( $loopback, self::host_header( $url ) ) ) {
362 ++$result['warmed'];
363
364 continue;
365 }
366
367 ++$result['failed'];
368 }
369
370 $result['ms'] = (int) round( ( microtime( true ) - $started ) * 1000 );
371
372 if ( class_exists( 'Wdkit_Import_Log' ) ) {
373 Wdkit_Import_Log::add( 'warm', $result );
374 }
375
376 return $result;
377 }
378
379 /**
380 * One warming request.
381 *
382 * @param string $url URL to fetch.
383 * @param string $host Host header to send, when different from the URL's.
384 * @return bool
385 */
386 private static function request( $url, $host = '' ) {
387 $args = array(
388 /* Short, because this is best-effort: warming saves the first visitor a slow
389 * page load and nothing depends on it succeeding. Fifteen seconds turned a
390 * nicety into 30 seconds of an import when the loopback could not connect. */
391 'timeout' => 5,
392 'redirection' => 2,
393 'sslverify' => false,
394 'blocking' => true,
395 /* Marks the request as ours, so it is recognisable in an access log. */
396 'user-agent' => 'WDesignKit-Import-Warmup',
397 );
398
399 if ( '' !== $host ) {
400 $args['headers'] = array( 'Host' => $host );
401
402 /* On the loopback attempt a redirect is a symptom, not something to follow: it
403 * means WordPress did not recognise the Host and is sending us to the public
404 * address, which is the one that already failed. */
405 $args['redirection'] = 0;
406 }
407
408 $response = wp_remote_get( $url, $args );
409
410 $ok = ! is_wp_error( $response ) && (int) wp_remote_retrieve_response_code( $response ) < 400;
411
412 if ( ! $ok && class_exists( 'Wdkit_Import_Log' ) ) {
413 Wdkit_Import_Log::add(
414 'warm_fail',
415 array(
416 'url' => $url,
417 'host' => $host,
418 'error' => is_wp_error( $response ) ? $response->get_error_message() : '',
419 'status' => is_wp_error( $response ) ? 0 : (int) wp_remote_retrieve_response_code( $response ),
420 )
421 );
422 }
423
424 return $ok;
425 }
426
427 /**
428 * Host header for a URL, INCLUDING the port.
429 *
430 * wp_parse_url( $url, PHP_URL_HOST ) drops the port, and that one omission defeated the
431 * whole fallback: sending `Host: localhost` for a site at `localhost:8188` made WordPress
432 * issue a canonical redirect to its real address, which is the address that was unreachable
433 * in the first place, so following it failed. With the port present the loopback request is
434 * served directly - 200, no redirect.
435 *
436 * @param string $url Site URL.
437 * @return string
438 */
439 private static function host_header( $url ) {
440 $parts = wp_parse_url( $url );
441
442 if ( empty( $parts['host'] ) ) {
443 return '';
444 }
445
446 return $parts['host'] . ( ! empty( $parts['port'] ) ? ':' . (int) $parts['port'] : '' );
447 }
448
449 /**
450 * The same URL addressed at the loopback interface.
451 *
452 * @param string $url Site URL.
453 * @return string Loopback URL, or '' when one cannot be formed.
454 */
455 private static function loopback_url( $url ) {
456 $parts = wp_parse_url( $url );
457
458 if ( empty( $parts['host'] ) ) {
459 return '';
460 }
461
462 $scheme = ! empty( $parts['scheme'] ) ? $parts['scheme'] : 'http';
463 $path = ! empty( $parts['path'] ) ? $parts['path'] : '/';
464
465 /* No port: the public port is often a mapping that does not exist internally, and the
466 * local server is on the scheme's default. */
467 return $scheme . '://127.0.0.1' . $path;
468 }
469
470 /**
471 * Find whichever loaded class owns make_block_css_by_post_id().
472 *
473 * Located by capability rather than by name - the same lookup
474 * Wdkit_Api_Call::wdkit_rebuild_block_css() uses - so a rename in The Plus Addons does
475 * not silently disable this.
476 *
477 * @return object|null
478 */
479 /**
480 * Whether register_late_blocks() already ran in this process.
481 *
482 * @var bool
483 */
484 private static $late_blocks_done = false;
485
486 /**
487 * Register Plus blocks that were enabled after `init` in this request.
488 *
489 * The Plus registers only the blocks on its enabled list when the request starts, each
490 * through its own `init` callback. An import enables the kit's blocks part-way through
491 * (Wdkit_Import_Dependencies::enable_blocks), and a website-queued import then builds
492 * the CSS in that same request - where those blocks are still unregistered, so the
493 * generator (it needs WP_Block_Type) silently wrote no CSS for them. On a Zion import
494 * that left the header menu without its desktop rules (the mobile toggle showed) and
495 * the heading-title blocks unstyled.
496 *
497 * Loads each such block's file and runs the `init` callbacks it added. A file already
498 * loaded is never loaded again - its functions would be redeclared.
499 *
500 * @return int Blocks registered here.
501 */
502 private static function register_late_blocks() {
503 if ( self::$late_blocks_done ) {
504 return 0;
505 }
506
507 self::$late_blocks_done = true;
508
509 if ( ! did_action( 'init' ) || ! class_exists( 'Tp_Blocks_Helper' ) || ! method_exists( 'Tp_Blocks_Helper', 'get_instance' ) ) {
510 return 0;
511 }
512
513 $opts = get_option( 'tpgb_normal_blocks_opts' );
514 $enabled = ( is_array( $opts ) && ! empty( $opts['enable_normal_blocks'] ) && is_array( $opts['enable_normal_blocks'] ) ) ? $opts['enable_normal_blocks'] : array();
515
516 if ( empty( $enabled ) ) {
517 return 0;
518 }
519
520 /* Child blocks the helper loads alongside their parent. */
521 $children = array(
522 'tp-container' => 'tp-container-inner',
523 'tp-accordion' => 'tp-accordion-inner',
524 'tp-tabs-tours' => 'tp-tab-item',
525 'tp-anything-carousel' => 'tp-anything-slide',
526 );
527
528 foreach ( $enabled as $block_id ) {
529 if ( isset( $children[ $block_id ] ) ) {
530 $enabled[] = $children[ $block_id ];
531 }
532 }
533
534 $roots = array();
535
536 foreach ( array( 'TPGBP_PATH', 'TPGB_PATH' ) as $const ) {
537 if ( defined( $const ) ) {
538 $roots[] = constant( $const );
539 }
540 }
541
542 $included = array_flip( array_map( 'wp_normalize_path', get_included_files() ) );
543 $helper = Tp_Blocks_Helper::get_instance();
544 $count = 0;
545
546 foreach ( array_unique( $enabled ) as $block_id ) {
547 $block_id = (string) $block_id;
548
549 if ( '' === $block_id || ! preg_match( '/^[a-z0-9\/_-]+$/', $block_id ) ) {
550 continue;
551 }
552
553 $already = false;
554
555 foreach ( $roots as $root ) {
556 if ( isset( $included[ wp_normalize_path( $root . 'classes/blocks/' . $block_id . '/index.php' ) ] ) ) {
557 $already = true;
558 break;
559 }
560 }
561
562 if ( $already ) {
563 continue;
564 }
565
566 $before = self::init_callbacks();
567
568 try {
569 if ( ! $helper->include_block( $block_id ) ) {
570 continue;
571 }
572
573 foreach ( array_diff_key( self::init_callbacks(), $before ) as $callback ) {
574 if ( is_callable( $callback ) ) {
575 call_user_func( $callback );
576 }
577 }
578
579 ++$count;
580 } catch ( Throwable $e ) {
581 continue;
582 }
583 }
584
585 if ( $count > 0 && class_exists( 'Wdkit_Import_Log' ) ) {
586 Wdkit_Import_Log::add( 'css', array( 'what' => 'late_blocks', 'registered' => $count ) );
587 }
588
589 return $count;
590 }
591
592 /**
593 * The `init` hook's callbacks, keyed by WordPress's unique id for each.
594 *
595 * @return array<string,callable>
596 */
597 private static function init_callbacks() {
598 global $wp_filter;
599
600 $out = array();
601
602 if ( empty( $wp_filter['init'] ) || ! is_object( $wp_filter['init'] ) ) {
603 return $out;
604 }
605
606 foreach ( (array) $wp_filter['init']->callbacks as $priority => $callbacks ) {
607 foreach ( (array) $callbacks as $id => $callback ) {
608 $out[ $priority . '|' . $id ] = isset( $callback['function'] ) ? $callback['function'] : null;
609 }
610 }
611
612 return $out;
613 }
614
615 private static function generator() {
616 if ( false !== self::$generator && null !== self::$generator ) {
617 return self::$generator;
618 }
619
620 /* A miss is not cached: Nexter Blocks can be installed and activated partway
621 * through the same request, and every call after that must still find it. */
622 self::$generator = null;
623
624 foreach ( get_declared_classes() as $class ) {
625 if ( ! method_exists( $class, 'make_block_css_by_post_id' ) ) {
626 continue;
627 }
628
629 try {
630 if ( method_exists( $class, 'instance' ) ) {
631 self::$generator = $class::instance();
632 } elseif ( method_exists( $class, 'get_instance' ) ) {
633 self::$generator = $class::get_instance();
634 } else {
635 self::$generator = new $class();
636 }
637 } catch ( Throwable $e ) {
638 self::$generator = null;
639 }
640
641 break;
642 }
643
644 return self::$generator;
645 }
646
647 /**
648 * Build `plus-global.css` from `tpgb_global_options`, in PHP.
649 *
650 * ── Why this exists ───────────────────────────────────────────────────────────────
651 *
652 * That file holds every design token a Nexter kit's blocks reference — the palette
653 * (`--tpgb-C1..Cn`), the type scale (`--tpgb-T1-*`), the gradients (`--tpgb-GC1..n`) and
654 * the container widths. Nothing in Nexter writes it from PHP: its editor JS assembles the
655 * CSS from `tpgb_global_options` and POSTs it to the `plus_save_block_css` REST route,
656 * from a wp.data subscription that only fires inside a real editor session. The wizard
657 * gets the file because a browser performs that pass on the success screen. A headless
658 * run — the website-driven import, WP-CLI, cron — has no browser, so the file was never
659 * created and every block emitted `var(--tpgb-C1)` with nothing defining it. The site had
660 * the right content and no design system at all.
661 *
662 * This is the fallback for exactly that case. It is NOT a replacement for Nexter's
663 * generator: the moment a real editor pass runs, TPGB overwrites this file with its own
664 * output, which is the desired outcome. What this guarantees is that the site is never
665 * left token-less in the meantime.
666 *
667 * The shape below was derived from Nexter's own output and is verified by byte-comparing
668 * against a file TPGB wrote (6527 bytes for the Zion kit). Re-run that comparison after a
669 * Nexter upgrade; a drift here is cosmetic-until-overwritten, not fatal.
670 *
671 * @since 2.7.2
672 *
673 * @return array{css:string,fonts:array,font_link:string}|array Empty when there is nothing to build.
674 */
675 public static function build_global_css() {
676 $raw = get_option( 'tpgb_global_options' );
677 $opt = is_string( $raw ) ? json_decode( $raw, true ) : $raw;
678
679 if ( ! is_array( $opt ) || empty( $opt['active'] ) || empty( $opt['presets'][ $opt['active'] ] ) ) {
680 return array();
681 }
682
683 $preset = $opt['presets'][ $opt['active'] ];
684 $typo = ( ! empty( $preset['typography'] ) && is_array( $preset['typography'] ) ) ? array_values( $preset['typography'] ) : array();
685 $colors = ( ! empty( $preset['colors'] ) && is_array( $preset['colors'] ) ) ? array_values( $preset['colors'] ) : array();
686 $grads = ( ! empty( $preset['gradient'] ) && is_array( $preset['gradient'] ) ) ? array_values( $preset['gradient'] ) : array();
687
688 if ( empty( $typo ) && empty( $colors ) ) {
689 return array();
690 }
691
692 $root = '';
693 $tablet = '';
694 $mobile = '';
695 $fonts = array();
696
697 foreach ( $typo as $i => $entry ) {
698 $value = isset( $entry['value'] ) && is_array( $entry['value'] ) ? $entry['value'] : array();
699 $n = $i + 1;
700
701 $size = isset( $value['size'] ) && is_array( $value['size'] ) ? $value['size'] : array();
702 $height = isset( $value['height'] ) && is_array( $value['height'] ) ? $value['height'] : array();
703 $spacing = isset( $value['spacing'] ) && is_array( $value['spacing'] ) ? $value['spacing'] : array();
704 $family = isset( $value['fontFamily'] ) && is_array( $value['fontFamily'] ) ? $value['fontFamily'] : array();
705
706 $root .= '--tpgb-T' . $n . '-font-size:' . self::css_length( $size, 'md', 'px' ) . ';';
707 $root .= '--tpgb-T' . $n . '-line-height:' . self::css_length( $height, 'md', 'px' ) . ';';
708
709 /* Emitted only when the value is JS-truthy, which is what decides it in Nexter's
710 * generator — and the two languages disagree here. An entry carrying the NUMBER 0
711 * is skipped; one carrying the STRING "0" is emitted as `0`. PHP would treat both
712 * as falsy, so the test is written out rather than left to `empty()`. */
713 if ( isset( $spacing['md'] ) && self::js_truthy( $spacing['md'] ) ) {
714 $root .= '--tpgb-T' . $n . '-letter-spacing:' . self::css_length( $spacing, 'md', '' ) . ';';
715 }
716
717 $name = self::sanitize_font_family( isset( $family['family'] ) ? $family['family'] : '' );
718 $type = isset( $family['type'] ) && in_array( $family['type'], array( 'serif', 'sans-serif', 'monospace', 'cursive', 'fantasy', 'system-ui' ), true )
719 ? $family['type']
720 : 'sans-serif';
721
722 if ( '' !== $name ) {
723 $root .= '--tpgb-T' . $n . "-font-family:'" . $name . "'," . $type . ';';
724
725 if ( ! isset( $fonts[ $name ] ) ) {
726 $fonts[ $name ] = array();
727 }
728 }
729
730 $weight = self::sanitize_font_weight( isset( $family['fontWeight'] ) ? $family['fontWeight'] : '' );
731
732 if ( '' !== $weight ) {
733 $root .= '--tpgb-T' . $n . '-font-weight:' . $weight . ';';
734
735 if ( '' !== $name && ! in_array( $weight, $fonts[ $name ], true ) ) {
736 $fonts[ $name ][] = $weight;
737 }
738 }
739
740 $root .= '--tpgb-T' . $n . '-font-style:' . self::sanitize_font_style( isset( $value['fontStyle'] ) ? $value['fontStyle'] : '' ) . ';';
741
742 /* Responsive overrides carry only the two metrics that change per breakpoint. */
743 foreach ( array( 'sm' => &$tablet, 'xs' => &$mobile ) as $key => &$bucket ) {
744 if ( isset( $size[ $key ] ) && self::js_truthy( $size[ $key ] ) ) {
745 $bucket .= '--tpgb-T' . $n . '-font-size:' . self::css_length( $size, $key, 'px' ) . ';';
746 }
747
748 if ( isset( $height[ $key ] ) && self::js_truthy( $height[ $key ] ) ) {
749 $bucket .= '--tpgb-T' . $n . '-line-height:' . self::css_length( $height, $key, 'px' ) . ';';
750 }
751 }
752
753 unset( $bucket );
754 }
755
756 foreach ( $colors as $i => $entry ) {
757 $color = self::sanitize_css_color( isset( $entry['value'] ) ? $entry['value'] : '' );
758
759 if ( '' !== $color ) {
760 $root .= '--tpgb-C' . ( $i + 1 ) . ':' . $color . ';';
761 }
762 }
763
764 foreach ( $grads as $i => $entry ) {
765 $gradient = self::sanitize_css_color( isset( $entry['value'] ) ? $entry['value'] : '' );
766
767 if ( '' !== $gradient ) {
768 $root .= '--tpgb-GC' . ( $i + 1 ) . ':' . $gradient . ';';
769 }
770 }
771
772 $container = ( ! empty( $opt['globalContainer'] ) && is_array( $opt['globalContainer'] ) ) ? $opt['globalContainer'] : array();
773 $container_unit = isset( $container['unit'] ) ? (string) $container['unit'] : '';
774 $allowed_units = array( 'px', 'em', 'rem', '%', 'vh', 'vw', 'vmin', 'vmax', 'pt', 'ch', 'ex' );
775 $unit = in_array( $container_unit, $allowed_units, true ) ? $container_unit : 'px';
776 $widths = array();
777
778 foreach ( array( 'md', 'xs' ) as $key ) {
779 if ( isset( $container[ $key ] ) && self::js_truthy( $container[ $key ] ) && is_numeric( $container[ $key ] ) ) {
780 $widths[ $key ] = $container[ $key ] . $unit;
781 $root .= '--tpgb-container-' . $key . ':' . $widths[ $key ] . '!important;';
782 }
783 }
784
785 $css = ':root{' . rtrim( $root, ';' ) . '}';
786
787 if ( '' !== $tablet ) {
788 $css .= '@media (max-width:1199px){:root{' . rtrim( $tablet, ';' ) . '}}';
789 }
790
791 if ( '' !== $mobile ) {
792 $css .= '@media (max-width:767px){:root{' . rtrim( $mobile, ';' ) . '}}';
793 }
794
795 $css .= self::container_rules( $widths );
796
797 $link = array();
798
799 foreach ( $fonts as $name => $weights ) {
800 $fonts[ $name ] = implode( ',', $weights );
801 $link[] = str_replace( ' ', '+', $name ) . ( $weights ? ':' . $fonts[ $name ] : '' );
802 }
803
804 return array(
805 'css' => $css,
806 'fonts' => $fonts,
807 'font_link' => $link ? 'https://fonts.googleapis.com/css?family=' . implode( '|', $link ) : '',
808 );
809 }
810
811 /**
812 * The two container media blocks, verbatim in Nexter's selector shape.
813 *
814 * @since 2.7.2
815 *
816 * @param array $widths {md?:string, xs?:string} already carrying their unit.
817 * @return string
818 */
819 private static function container_rules( $widths ) {
820 $out = '';
821 $map = array(
822 'md' => 'min-width:1200px',
823 'xs' => 'min-width:768px',
824 );
825
826 foreach ( $map as $key => $query ) {
827 if ( empty( $widths[ $key ] ) ) {
828 continue;
829 }
830
831 $wide = '#nxt-footer .tpgb-container-row.tpgb-container-wide.alignwide.tpgb-nxtcont-type,#nxt-header .tpgb-container-row.tpgb-container-wide.alignwide.tpgb-nxtcont-type,.tpgb-container-row.tpgb-container-wide.alignwide.tpgb-nxtcont-type';
832 $full = '#nxt-footer .tpgb-container-row.tpgb-container-wide.alignfull.tpgb-nxtcont-type,#nxt-header .tpgb-container-row.tpgb-container-wide.alignfull.tpgb-nxtcont-type,.tpgb-container-row.tpgb-container-wide.alignfull.tpgb-nxtcont-type';
833
834 $out .= '@media (' . $query . '){'
835 . $wide . '{max-width:' . $widths[ $key ] . ';--tpgb-container-' . $key . ':' . $widths[ $key ] . '}'
836 . $full . '{--tpgb-container-' . $key . ':' . $widths[ $key ] . '}'
837 . '}';
838 }
839
840 return $out;
841 }
842
843 /**
844 * `<value><unit>`, with the unit defaulted the way Nexter's generator defaults it.
845 *
846 * $box is kit/cloud-supplied data written verbatim into a publicly-served .css file
847 * (build_global_css() below) — see ClickUp 14ynqxywnce. A non-numeric value or an
848 * unrecognised unit is dropped rather than concatenated, so a malicious or compromised
849 * kit source cannot break out of a declaration's value position.
850 *
851 * @since 2.7.2
852 *
853 * @param array $box Metric array.
854 * @param string $key Breakpoint key.
855 * @param string $default Unit to use when the array carries none.
856 * @return string
857 */
858 private static function css_length( $box, $key, $default ) {
859 $value = isset( $box[ $key ] ) ? $box[ $key ] : '';
860 $unit = isset( $box['unit'] ) && '' !== $box['unit'] ? (string) $box['unit'] : $default;
861
862 if ( '' === $value ) {
863 return '';
864 }
865
866 if ( ! is_numeric( $value ) ) {
867 return '';
868 }
869
870 $allowed_units = array( 'px', 'em', 'rem', '%', 'vh', 'vw', 'vmin', 'vmax', 'pt', 'ch', 'ex', '' );
871
872 if ( ! in_array( $unit, $allowed_units, true ) ) {
873 return '';
874 }
875
876 return (string) $value . $unit;
877 }
878
879 /**
880 * Would JavaScript treat this value as true?
881 *
882 * The string "0" is truthy in JS and falsy in PHP, and Nexter's generator is JS — so a
883 * PHP `empty()` here would silently drop declarations Nexter emits.
884 *
885 * @since 2.7.2
886 *
887 * @param mixed $value Value.
888 * @return bool
889 */
890 private static function js_truthy( $value ) {
891 if ( is_string( $value ) ) {
892 return '' !== $value;
893 }
894
895 return ! empty( $value );
896 }
897
898 /**
899 * Validate a color or gradient value before it is concatenated into a publicly-served
900 * .css file.
901 *
902 * $value is cloud/kit-supplied - a preset color or gradient string from
903 * tpgb_global_options - not something this site's own admin typed, so it is validated
904 * here rather than trusted. Real values look like `#8072FC`,
905 * `rgba(0,0,0,0.15)` or `linear-gradient(135deg,rgb(8,148,229) 0%,rgb(155,81,224) 100%)`,
906 * so this allows the character set those need (hex digits, function calls, percentages,
907 * degrees, commas) and rejects anything that could close the custom-property declaration
908 * early or introduce a new rule - `;`, `{`, `}`, `<`, `>`, quotes, backslashes - and the
909 * specific sequences an injected value would use to do real damage even without those
910 * characters (`url(`, `@import`, `expression(`, `javascript:`). See ClickUp 14ynqxywnce.
911 *
912 * @since 2.7.3
913 *
914 * @param mixed $value Raw value.
915 * @return string Empty when the value fails validation.
916 */
917 private static function sanitize_css_color( $value ) {
918 if ( ! is_string( $value ) || '' === $value ) {
919 return '';
920 }
921
922 if ( ! preg_match( '/^[A-Za-z0-9#(),.%\s-]+$/', $value ) ) {
923 return '';
924 }
925
926 if ( preg_match( '/url\s*\(|@import|expression\s*\(|javascript:/i', $value ) ) {
927 return '';
928 }
929
930 return $value;
931 }
932
933 /**
934 * Validate a font-family name from kit data before it is concatenated into CSS.
935 *
936 * @since 2.7.3
937 *
938 * @param mixed $value Raw value.
939 * @return string Empty when the value fails validation.
940 */
941 private static function sanitize_font_family( $value ) {
942 if ( ! is_string( $value ) || '' === $value ) {
943 return '';
944 }
945
946 return preg_match( '/^[A-Za-z0-9 \-]+$/', $value ) ? $value : '';
947 }
948
949 /**
950 * Validate a CSS font-weight from kit data.
951 *
952 * @since 2.7.3
953 *
954 * @param mixed $value Raw value.
955 * @return string Empty when the value fails validation.
956 */
957 private static function sanitize_font_weight( $value ) {
958 $value = (string) $value;
959
960 return preg_match( '/^(?:100|200|300|400|500|600|700|800|900|normal|bold|bolder|lighter)$/', $value ) ? $value : '';
961 }
962
963 /**
964 * Validate a CSS font-style from kit data.
965 *
966 * @since 2.7.3
967 *
968 * @param mixed $value Raw value.
969 * @return string Falls back to 'normal', matching the caller's own prior default.
970 */
971 private static function sanitize_font_style( $value ) {
972 $value = (string) $value;
973
974 return in_array( $value, array( 'normal', 'italic', 'oblique' ), true ) ? $value : 'normal';
975 }
976
977 /**
978 * Write the token file and the option TPGB reads it back from.
979 *
980 * Mirrors what `plus_save_block_css` does for a real editor save — the same option and the
981 * same path — so TPGB's own enqueue picks it up with no further involvement from us.
982 *
983 * @since 2.7.2
984 *
985 * @return bool True when a file was written.
986 */
987 public static function write_global_css() {
988 $built = self::build_global_css();
989
990 if ( empty( $built['css'] ) ) {
991 return false;
992 }
993
994 $uploads = wp_upload_dir();
995
996 if ( empty( $uploads['basedir'] ) ) {
997 return false;
998 }
999
1000 $dir = trailingslashit( $uploads['basedir'] ) . 'theplus_gutenberg/';
1001
1002 if ( ! wp_mkdir_p( $dir ) ) {
1003 return false;
1004 }
1005
1006 $written = ( false !== file_put_contents( $dir . 'plus-global.css', $built['css'] ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- WP_Filesystem is not initialised on a cron/CLI request and this is the same path plus_save_block_css writes.
1007
1008 if ( $written ) {
1009 update_option( '_tpgb_global_css', $built );
1010 }
1011
1012 return $written;
1013 }
1014 }
1015 }
1016