PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.0.5
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.0.5
1.3.3 1.3.2 1.3.1 1.3.0 1.2.4 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.1.5 1.1.6 1.1.7 1.1.8 All 29 releases
xspeed / includes / class-migration.php

class-migration.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN 1.0.5, at includes/class-migration.php

628 lines 25.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Migration — read settings from other popular caching plugins and
4 * translate them into xSpeed equivalents.
5 *
6 * Each source plugin has its own importer that returns a single
7 * normalized patch shape:
8 *
9 * array<string,array> // module-slug → settings patch
10 *
11 * which feeds straight into update_option('xspeed_module_<slug>')
12 * via the same write path the Recommendations module uses.
13 *
14 * Importers are pure: detect() reads the options table, returns
15 * what it found (or null if the source plugin's options aren't
16 * present). plan() turns that raw read into the patch. apply()
17 * writes it. preview() returns plan() without writing — used by
18 * the React panel for the "what would import" diff view.
19 *
20 * Adding a new source = adding a private detect_*() + plan_*()
21 * pair, then wiring them in `sources()`.
22 *
23 * @package XSpeed
24 */
25
26 declare(strict_types=1);
27
28 namespace XSpeed;
29
30 defined( 'ABSPATH' ) || exit;
31
32 final class Migration {
33
34 /**
35 * Public list of source plugins with metadata for the UI:
36 * [ id => [ label, detect_cb, plan_cb ] ]
37 */
38 public static function sources(): array {
39 return array(
40 'wp-rocket' => array(
41 'label' => 'WP Rocket',
42 'detect' => array( __CLASS__, 'detect_wp_rocket' ),
43 'plan' => array( __CLASS__, 'plan_wp_rocket' ),
44 ),
45 'w3-total-cache' => array(
46 'label' => 'W3 Total Cache',
47 'detect' => array( __CLASS__, 'detect_w3tc' ),
48 'plan' => array( __CLASS__, 'plan_w3tc' ),
49 ),
50 'wp-super-cache' => array(
51 'label' => 'WP Super Cache',
52 'detect' => array( __CLASS__, 'detect_wpsc' ),
53 'plan' => array( __CLASS__, 'plan_wpsc' ),
54 ),
55 'litespeed-cache' => array(
56 'label' => 'LiteSpeed Cache',
57 'detect' => array( __CLASS__, 'detect_litespeed' ),
58 'plan' => array( __CLASS__, 'plan_litespeed' ),
59 ),
60 );
61 }
62
63 /** Site option holding the list of source ids already imported. */
64 private const IMPORTED_OPTION = 'xspeed_migration_imported';
65
66 /** Source id → plugin file, for the active-state check. */
67 private const PLUGIN_FILE = array(
68 'wp-rocket' => 'wp-rocket/wp-rocket.php',
69 'w3-total-cache' => 'w3-total-cache/w3-total-cache.php',
70 'wp-super-cache' => 'wp-super-cache/wp-cache.php',
71 'litespeed-cache' => 'litespeed-cache/litespeed-cache.php',
72 );
73
74 /** Record a source as imported (idempotent). */
75 public static function mark_imported( string $id ): void {
76 $done = (array) get_option( self::IMPORTED_OPTION, array() );
77 if ( ! in_array( $id, $done, true ) ) {
78 $done[] = $id;
79 update_option( self::IMPORTED_OPTION, array_values( $done ) );
80 }
81 }
82
83 /**
84 * For each source, return { id, label, detected, value_count, mapped_count,
85 * imported, active }.
86 * - `detected` true when the source plugin's settings are present.
87 * - `value_count` raw number of keys in the source's own config — NOT
88 * how many we import; kept for diagnostics only.
89 * - `mapped_count` how many settings the importer ACTUALLY writes into
90 * xSpeed (the honest number to show users).
91 * - `imported` true once this source has been imported (so the panel
92 * shows it as done, not as a fresh Import target).
93 * - `active` whether the source plugin is still active.
94 */
95 public static function status(): array {
96 $imported = (array) get_option( self::IMPORTED_OPTION, array() );
97 if ( ! function_exists( 'is_plugin_active' ) ) {
98 require_once ABSPATH . 'wp-admin/includes/plugin.php';
99 }
100 $out = array();
101 foreach ( self::sources() as $id => $spec ) {
102 $raw = call_user_func( $spec['detect'] );
103 $detected = is_array( $raw );
104 $mapped = 0;
105 if ( $detected ) {
106 $patch = call_user_func( $spec['plan'], $raw );
107 if ( is_array( $patch ) ) {
108 foreach ( $patch as $vals ) {
109 if ( is_array( $vals ) ) {
110 $mapped += count( $vals );
111 }
112 }
113 }
114 }
115 $file = self::PLUGIN_FILE[ $id ] ?? '';
116 $out[] = array(
117 'id' => $id,
118 'label' => $spec['label'],
119 'detected' => $detected,
120 'value_count' => $detected ? count( $raw ) : 0,
121 'mapped_count' => $mapped,
122 'imported' => in_array( $id, $imported, true ),
123 'active' => '' !== $file && is_plugin_active( $file ),
124 );
125 }
126 return $out;
127 }
128
129 /**
130 * Return the patch that `apply()` would write, without writing.
131 */
132 public static function preview( string $source_id ): ?array {
133 $src = self::sources()[ $source_id ] ?? null;
134 if ( null === $src ) {
135 return null;
136 }
137 $raw = call_user_func( $src['detect'] );
138 if ( ! is_array( $raw ) ) {
139 return null;
140 }
141 return call_user_func( $src['plan'], $raw );
142 }
143
144 /**
145 * Read source settings + write the translated patch. Returns the
146 * per-module write results, same shape as Recommendations::apply.
147 */
148 public static function apply( string $source_id ): array {
149 $patch = self::preview( $source_id );
150 if ( null === $patch ) {
151 return array();
152 }
153 $results = array();
154 foreach ( $patch as $slug => $values ) {
155 if ( ! is_string( $slug ) || ! is_array( $values ) ) {
156 continue;
157 }
158 $option = 'xspeed_module_' . $slug;
159 $cur = (array) get_option( $option, array() );
160 $next = array_merge( $cur, $values );
161 $ok = update_option( $option, $next );
162 $results[ $slug ] = array(
163 'ok' => (bool) $ok,
164 'applied' => array_keys( $values ),
165 );
166 }
167 if ( ! empty( $results ) ) {
168 self::mark_imported( $source_id );
169 }
170 return $results;
171 }
172
173 // ─────────────────────────── WP Rocket ───────────────────────────
174
175 public static function detect_wp_rocket(): ?array {
176 $opt = get_option( 'wp_rocket_settings', null );
177 return is_array( $opt ) ? $opt : null;
178 }
179
180 /**
181 * Translate WP Rocket's `wp_rocket_settings` array into our module
182 * settings. Only safe-to-port booleans + counts; behaviorally
183 * different toggles (Critical CSS, RUCSS) skip — Pro handles those.
184 *
185 * @param array $r raw wp_rocket_settings.
186 */
187 public static function plan_wp_rocket( array $r ): array {
188 $patch = array();
189 // Page caching.
190 $patch['cache'] = array(
191 'enabled' => ! empty( $r['cache_logged_user'] ) || ! isset( $r['cache_logged_user'] ),
192 'expiry_hours' => isset( $r['purge_cron_interval'] ) ? max( 1, (int) ( $r['purge_cron_interval'] / 3600 ) ) : 24,
193 );
194 // Excluded URLs / cookies — both are arrays of strings in WP Rocket.
195 if ( ! empty( $r['cache_reject_uri'] ) && is_array( $r['cache_reject_uri'] ) ) {
196 $patch['cache']['excluded_urls'] = array_values( array_filter( array_map( 'strval', $r['cache_reject_uri'] ) ) );
197 }
198 if ( ! empty( $r['cache_reject_cookies'] ) && is_array( $r['cache_reject_cookies'] ) ) {
199 $patch['cache']['excluded_cookies'] = array_values( array_filter( array_map( 'strval', $r['cache_reject_cookies'] ) ) );
200 }
201
202 // Minify.
203 $patch['minify'] = array(
204 'minify_html' => ! empty( $r['minify_html'] ),
205 'minify_css' => ! empty( $r['minify_css'] ),
206 'minify_js' => ! empty( $r['minify_js'] ),
207 'combine_css' => ! empty( $r['minify_concatenate_css'] ),
208 'combine_js' => ! empty( $r['minify_concatenate_js'] ),
209 'defer_js' => ! empty( $r['defer_all_js'] ),
210 );
211
212 // Lazy load.
213 $patch['lazy'] = array(
214 'lazy_images' => ! empty( $r['lazyload'] ),
215 'lazy_iframes' => ! empty( $r['lazyload_iframes'] ),
216 'lazy_videos' => ! empty( $r['lazyload_youtube'] ),
217 );
218
219 // GZIP — WP Rocket writes its own .htaccess; we infer the toggle from "do_cloudflare" etc.
220 if ( isset( $r['do_caching_mobile_files'] ) ) {
221 $patch['cache']['mobile_separate'] = (bool) $r['do_caching_mobile_files'];
222 }
223
224 // Preloader.
225 if ( ! empty( $r['manual_preload'] ) || ! empty( $r['sitemap_preload'] ) ) {
226 $patch['preloader'] = array(
227 'enabled' => true,
228 'schedule' => 'daily',
229 );
230 if ( ! empty( $r['sitemap_preload_url'] ) && is_array( $r['sitemap_preload_url'] ) ) {
231 $patch['preloader']['sitemap_urls'] = array_values( array_filter( array_map( 'strval', $r['sitemap_preload_url'] ) ) );
232 }
233 }
234
235 // CDN — WP Rocket stores CDN hosts in cdn_cnames (array).
236 if ( ! empty( $r['cdn'] ) && ! empty( $r['cdn_cnames'] ) && is_array( $r['cdn_cnames'] ) ) {
237 $first = (string) ( $r['cdn_cnames'][0] ?? '' );
238 if ( '' !== $first ) {
239 $patch['cdn'] = array(
240 'enabled' => true,
241 'cdn_url' => $first,
242 );
243 }
244 }
245
246 return $patch;
247 }
248
249 // ─────────────────────────── W3 Total Cache ──────────────────────
250
251 public static function detect_w3tc(): ?array {
252 // W3 Total Cache does NOT store its config in the options table — it
253 // writes a PHP file at wp-content/w3tc-config/master.php whose body
254 // is a short PHP guard followed by a JSON blob of dotted-key settings
255 // (pgcache.enabled, minify.html.enable, …). Reading w3tc_config /
256 // w3tc_master_settings options always returned null, so detection
257 // failed on every install. Read + parse the config file instead.
258 $cfg = self::read_w3tc_config_file();
259 if ( is_array( $cfg ) && ! empty( $cfg ) ) {
260 return $cfg;
261 }
262 // Defensive fallback for any build that did persist an options blob.
263 $opt = get_option( 'w3tc_config', null );
264 if ( ! is_array( $opt ) ) {
265 $opt = get_option( 'w3tc_master_settings', null );
266 }
267 return is_array( $opt ) ? $opt : null;
268 }
269
270 /**
271 * Parse W3TC's master config file into a flat dotted-key array.
272 * Format: a short PHP guard (a php-open, exit, php-close) immediately
273 * followed by a JSON object. We strip everything up to and including the
274 * PHP closing tag, then JSON-decode the remainder.
275 *
276 * @return array|null parsed config, or null if the file is missing/unreadable.
277 */
278 private static function read_w3tc_config_file(): ?array {
279 if ( ! defined( 'WP_CONTENT_DIR' ) ) {
280 return null;
281 }
282 $path = WP_CONTENT_DIR . '/w3tc-config/master.php';
283 if ( ! is_readable( $path ) ) {
284 return null;
285 }
286 $raw = file_get_contents( $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- reading another plugin's local config file; WP_Filesystem is overkill for a one-shot read.
287 if ( false === $raw || '' === $raw ) {
288 return null;
289 }
290 // Drop the leading PHP guard and decode the JSON tail. The pattern
291 // matches up to the first PHP closing tag; built from a char-code so
292 // no literal close tag appears in this source file.
293 $close_tag = '?' . '>';
294 $json = preg_replace( '/^.*?' . preg_quote( $close_tag, '/' ) . '/s', '', $raw );
295 $cfg = json_decode( trim( (string) $json ), true );
296 return is_array( $cfg ) ? $cfg : null;
297 }
298
299 public static function plan_w3tc( array $r ): array {
300 $patch = array();
301 $patch['cache'] = array(
302 'enabled' => ! empty( $r['pgcache.enabled'] ),
303 'expiry_hours' => isset( $r['pgcache.lifetime'] ) ? max( 1, (int) ( (int) $r['pgcache.lifetime'] / 3600 ) ) : 24,
304 );
305 if ( ! empty( $r['pgcache.reject.uri'] ) && is_array( $r['pgcache.reject.uri'] ) ) {
306 $patch['cache']['excluded_urls'] = array_values( array_filter( array_map( 'strval', $r['pgcache.reject.uri'] ) ) );
307 }
308
309 $patch['minify'] = array(
310 'minify_html' => ! empty( $r['minify.html.enable'] ),
311 'minify_css' => ! empty( $r['minify.css.enable'] ),
312 'minify_js' => ! empty( $r['minify.js.enable'] ),
313 );
314
315 if ( ! empty( $r['objectcache.enabled'] ) && ! empty( $r['objectcache.engine'] ) ) {
316 $patch['object-cache'] = array(
317 'backend' => 'memcached' === $r['objectcache.engine'] ? 'memcached' : 'redis',
318 );
319 if ( ! empty( $r['objectcache.servers'] ) && is_array( $r['objectcache.servers'] ) ) {
320 $first = (string) ( $r['objectcache.servers'][0] ?? '' );
321 if ( false !== strpos( $first, ':' ) ) {
322 [ $host, $port ] = explode( ':', $first, 2 );
323 if ( 'redis' === ( $patch['object-cache']['backend'] ?? '' ) ) {
324 $patch['object-cache']['redis_host'] = $host;
325 $patch['object-cache']['redis_port'] = (int) $port;
326 } else {
327 $patch['object-cache']['memcached_host'] = $host;
328 $patch['object-cache']['memcached_port'] = (int) $port;
329 }
330 }
331 }
332 }
333
334 if ( ! empty( $r['browsercache.enabled'] ) ) {
335 $patch['browser-cache'] = array(
336 'enabled' => true,
337 );
338 }
339
340 return $patch;
341 }
342
343 // ─────────────────────────── WP Super Cache ──────────────────────
344
345 public static function detect_wpsc(): ?array {
346 // WP Super Cache stores its settings as PHP globals in
347 // wp-content/wp-cache-config.php (NOT the options table — the old
348 // get_option('wp_cache_enabled') reads always returned null). Parse
349 // the config file for the globals plan_wpsc() needs. If the file
350 // doesn't exist yet (plugin active but never configured), fall back
351 // to a minimal "active" marker so the source still appears in the UI
352 // and a default import is possible.
353 $cfg = self::read_wpsc_config_file();
354 if ( is_array( $cfg ) && ! empty( $cfg ) ) {
355 return $cfg;
356 }
357 if ( self::plugin_active( 'wp-super-cache/wp-cache.php' ) ) {
358 // Active but unconfigured — expose the on/off intent only.
359 return array( 'wp_cache_enabled' => defined( 'WPCACHEHOME' ) );
360 }
361 return null;
362 }
363
364 /**
365 * Parse the WP Super Cache config file for the globals we map. The file
366 * is plain PHP assigning `$wp_cache_* = …;` lines; we extract them with a
367 * regex rather than including the file (including it would define
368 * constants / run code in our request).
369 *
370 * @return array|null name => value for the recognised globals, or null.
371 */
372 private static function read_wpsc_config_file(): ?array {
373 if ( ! defined( 'WP_CONTENT_DIR' ) ) {
374 return null;
375 }
376 $path = WP_CONTENT_DIR . '/wp-cache-config.php';
377 if ( ! is_readable( $path ) ) {
378 return null;
379 }
380 $raw = file_get_contents( $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- one-shot read of another plugin's config file.
381 if ( false === $raw || '' === $raw ) {
382 return null;
383 }
384 $out = array();
385 $keys = array( 'wp_cache_enabled', 'wp_cache_mod_rewrite', 'wp_cache_mobile_enabled', 'wp_cache_not_logged_in', 'wp_cache_make_known_anon' );
386 foreach ( $keys as $key ) {
387 // Match `$key = 1;` / `$key = '1';` / `$key = true;` etc.
388 if ( preg_match( '/\$' . preg_quote( $key, '/' ) . '\s*=\s*([^;]+);/', $raw, $m ) ) {
389 $val = trim( $m[1], " \t'\"" );
390 $out[ $key ] = in_array( strtolower( $val ), array( '1', 'true' ), true );
391 }
392 }
393 return ! empty( $out ) ? $out : null;
394 }
395
396 /** Thin wrapper so detection works before admin plugin.php is loaded. */
397 private static function plugin_active( string $plugin ): bool {
398 $active = (array) get_option( 'active_plugins', array() );
399 if ( in_array( $plugin, $active, true ) ) {
400 return true;
401 }
402 // Network-activated (multisite).
403 $network = (array) get_site_option( 'active_sitewide_plugins', array() );
404 return isset( $network[ $plugin ] );
405 }
406
407 public static function plan_wpsc( array $r ): array {
408 return array(
409 'cache' => array(
410 'enabled' => ! empty( $r['wp_cache_enabled'] ),
411 'mobile_separate' => ! empty( $r['wp_cache_mobile_enabled'] ),
412 ),
413 );
414 }
415
416 // ─────────────────────────── LiteSpeed Cache ─────────────────────
417
418 /**
419 * Read LiteSpeed Cache settings into a flat `name => value` array keyed
420 * by LiteSpeed's dotted setting names (cache, cache-mobile, optm-*,
421 * media-*, object-*, cdn-*, …) — the shape plan_litespeed() expects.
422 *
423 * Storage has changed across LiteSpeed versions:
424 * - v4+ (current): ONE option PER setting, named `litespeed.conf.<name>`
425 * (e.g. litespeed.conf.cache, litespeed.conf.cache-mobile). There is
426 * NO single `litespeed.conf` blob — reading that key returns null,
427 * which is why detection used to fail on every modern install.
428 * - v3 and earlier: a single serialized array under `litespeed.conf`
429 * (or the legacy `litespeed-cache-conf`).
430 * We handle all three: try the per-option family first (the common case
431 * today), then fall back to the legacy single-blob options.
432 *
433 * @return array|null raw conf (name => value), or null when absent.
434 */
435 public static function detect_litespeed(): ?array {
436 global $wpdb;
437
438 // v4+: individual `litespeed.conf.<name>` options. Pull them all and
439 // strip the prefix so keys match what plan_litespeed() reads.
440 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- one-time settings-import scan of another plugin's option rows by name prefix; no WP API bulk-reads by option_name LIKE, and caching a single migration-time read is pointless.
441 $rows = $wpdb->get_results(
442 "SELECT option_name, option_value FROM {$wpdb->options} WHERE option_name LIKE 'litespeed.conf.%'",
443 ARRAY_A
444 );
445 if ( ! empty( $rows ) ) {
446 $conf = array();
447 foreach ( $rows as $row ) {
448 $name = substr( (string) $row['option_name'], strlen( 'litespeed.conf.' ) );
449 if ( '' === $name || '_version' === $name ) {
450 continue;
451 }
452 // option_value is stored serialized by WP; maybe_unserialize
453 // gives back arrays (list settings) or scalars as-is.
454 $conf[ $name ] = maybe_unserialize( $row['option_value'] );
455 }
456 if ( ! empty( $conf ) ) {
457 return $conf;
458 }
459 }
460
461 // v3 / legacy: a single serialized array.
462 $opt = get_option( 'litespeed.conf', null );
463 if ( ! is_array( $opt ) ) {
464 $opt = get_option( 'litespeed-cache-conf', null );
465 }
466 return is_array( $opt ) && ! empty( $opt ) ? $opt : null;
467 }
468
469 /**
470 * Translate LiteSpeed's `litespeed.conf` into xSpeed module patches.
471 * LiteSpeed uses dotted keys; values are mostly bool-ish (1/0/'1').
472 * We map only the settings that have a clean xSpeed equivalent and
473 * leave the rest untouched so nothing is silently mis-imported.
474 *
475 * @param array $r raw litespeed.conf.
476 */
477 public static function plan_litespeed( array $r ): array {
478 $on = static function ( $key ) use ( $r ): bool {
479 return isset( $r[ $key ] ) && ! empty( $r[ $key ] );
480 };
481 // LiteSpeed list fields are stored as either a newline-delimited
482 // string or an array. Normalize to a clean string[] either way.
483 $list = static function ( $key ) use ( $r ): array {
484 $v = $r[ $key ] ?? null;
485 if ( is_string( $v ) ) {
486 $v = preg_split( '/\r\n|\r|\n/', $v );
487 }
488 if ( ! is_array( $v ) ) {
489 return array();
490 }
491 return array_values( array_filter( array_map( 'trim', array_map( 'strval', $v ) ) ) );
492 };
493 $set_list = static function ( array &$dest, string $dest_key, array $vals ): void {
494 if ( $vals ) {
495 $dest[ $dest_key ] = $vals;
496 }
497 };
498
499 $patch = array();
500
501 // ── Page cache ────────────────────────────────────────────────
502 $patch['cache'] = array(
503 'enabled' => $on( 'cache' ) || $on( 'cache-priv' ),
504 'mobile_separate' => $on( 'cache-mobile' ),
505 );
506 // TTL: LiteSpeed stores cache-ttl_pub in seconds → xSpeed wants hours.
507 if ( isset( $r['cache-ttl_pub'] ) && (int) $r['cache-ttl_pub'] > 0 ) {
508 $patch['cache']['cache_expiry'] = max( 1, min( 720, (int) ( (int) $r['cache-ttl_pub'] / 3600 ) ) );
509 }
510 // Excluded URIs / cookies / user-agents / dropped query strings.
511 $set_list( $patch['cache'], 'excluded_urls', $list( 'cache-exc' ) );
512 $set_list( $patch['cache'], 'excluded_cookies', $list( 'cache-exc_cookies' ) );
513 $set_list( $patch['cache'], 'bypass_user_agents', $list( 'cache-exc_useragent' ) );
514 // LiteSpeed "drop query string" list ≈ xSpeed ignored_query_params.
515 $set_list( $patch['cache'], 'ignored_query_params', $list( 'cache-drop_qs' ) );
516
517 // ── Minify / optimization ─────────────────────────────────────
518 $patch['minify'] = array(
519 'minify_html' => $on( 'optm-html_min' ),
520 'minify_css' => $on( 'optm-css_min' ),
521 'minify_js' => $on( 'optm-js_min' ),
522 'combine_css' => $on( 'optm-css_comb' ),
523 'combine_js' => $on( 'optm-js_comb' ),
524 'defer_js' => $on( 'optm-js_defer' ),
525 // LiteSpeed "Delay JS" (optm-js_defer === 2 in some versions, or
526 // the dedicated optm-js_delay flag) → xSpeed delay_js.
527 'delay_js' => $on( 'optm-js_delay' ) || ( isset( $r['optm-js_defer'] ) && (int) $r['optm-js_defer'] === 2 ),
528 // Async/“load CSS asynchronously” — LiteSpeed CCSS async.
529 'async_css' => $on( 'optm-css_async' ),
530 // Remove query strings from static resources.
531 'remove_query_strings' => $on( 'optm-qs_rm' ),
532 );
533 // Defer/delay exclusion list — merge LiteSpeed's JS defer + delay
534 // exclude lists into xSpeed's single defer_js_excluded.
535 $defer_exc = array_values( array_unique( array_merge( $list( 'optm-js_defer_exc' ), $list( 'optm-js_delay_exc' ) ) ) );
536 $set_list( $patch['minify'], 'defer_js_excluded', $defer_exc );
537
538 // ── Lazy load (media) ─────────────────────────────────────────
539 $patch['lazy'] = array(
540 'lazy_images' => $on( 'media-lazy' ),
541 'lazy_iframes' => $on( 'media-iframe_lazy' ),
542 // LiteSpeed has no separate HTML5-video lazy toggle; mirror the
543 // image setting so video preload follows the same intent.
544 'lazy_videos' => $on( 'media-lazy' ),
545 // "Add Missing Sizes" → add_missing_dimensions (anti-CLS).
546 'add_missing_dimensions' => $on( 'media-add_missing_sizes' ),
547 );
548 $set_list( $patch['lazy'], 'excluded_images', $list( 'media-lazy_exc' ) );
549
550 // ── Fonts ─────────────────────────────────────────────────────
551 // LiteSpeed "Font Display Optimization" (optm-localize_style /
552 // optm-css_font_display) → xSpeed font-display: swap.
553 if ( $on( 'optm-css_font_display' ) || $on( 'optm-localize' ) ) {
554 $patch['fonts'] = array( 'font_display_swap' => true );
555 }
556
557 // ── Disable bloat ─────────────────────────────────────────────
558 // Only map the one LiteSpeed "remove" toggle with a clean xSpeed
559 // equivalent: removing the emoji + oEmbed scripts ≈ disable_oembed.
560 // (LiteSpeed's optm-emoji_rm strips the wp-emoji + wp-embed pair.)
561 // jQuery-migrate / dashicons / XML-RPC / RSS / REST aren't
562 // LiteSpeed-managed, so we don't guess at them.
563 if ( $on( 'optm-emoji_rm' ) ) {
564 $patch['bloat'] = array( 'disable_oembed' => true );
565 }
566
567 // ── Browser cache (LiteSpeed: cache-browser) ──────────────────
568 if ( $on( 'cache-browser' ) ) {
569 $patch['browser-cache'] = array( 'enabled' => true );
570 if ( isset( $r['cache-ttl_browser'] ) && (int) $r['cache-ttl_browser'] > 0 ) {
571 $patch['browser-cache']['asset_ttl'] = (int) $r['cache-ttl_browser'];
572 }
573 }
574
575 // ── Object cache ──────────────────────────────────────────────
576 if ( $on( 'object' ) ) {
577 $kind = isset( $r['object-kind'] ) && (int) $r['object-kind'] === 1 ? 'redis' : 'memcached';
578 $patch['object-cache'] = array( 'backend' => $kind );
579 if ( ! empty( $r['object-host'] ) ) {
580 $host_key = 'redis' === $kind ? 'redis_host' : 'memcached_host';
581 $patch['object-cache'][ $host_key ] = (string) $r['object-host'];
582 }
583 if ( ! empty( $r['object-port'] ) ) {
584 $port_key = 'redis' === $kind ? 'redis_port' : 'memcached_port';
585 $patch['object-cache'][ $port_key ] = (int) $r['object-port'];
586 }
587 if ( 'redis' === $kind && isset( $r['object-db_id'] ) ) {
588 $patch['object-cache']['redis_database'] = max( 0, min( 15, (int) $r['object-db_id'] ) );
589 }
590 if ( ! empty( $r['object-pswd'] ) && 'redis' === $kind ) {
591 $patch['object-cache']['redis_password'] = (string) $r['object-pswd'];
592 }
593 if ( ! empty( $r['object-global_groups'] ) || ! empty( $r['object-persistent'] ) ) {
594 $patch['object-cache']['persistent'] = $on( 'object-persistent' );
595 }
596 }
597
598 // ── Image conversion (Pro Images module) ──────────────────────
599 // LiteSpeed media-webp / next-gen image generation → xSpeed Images.
600 if ( $on( 'img_optm-webp' ) || $on( 'media-webp' ) || $on( 'img_optm-auto' ) ) {
601 $patch['images'] = array(
602 'webp' => $on( 'img_optm-webp' ) || $on( 'media-webp' ),
603 'avif' => $on( 'img_optm-avif' ),
604 );
605 }
606
607 // ── CDN ───────────────────────────────────────────────────────
608 if ( $on( 'cdn' ) ) {
609 $cdn_url = '';
610 if ( ! empty( $r['cdn-mapping'] ) && is_array( $r['cdn-mapping'] ) ) {
611 $first = $r['cdn-mapping'][0] ?? array();
612 // LiteSpeed cdn-mapping rows use the 'url' sub-key (array form)
613 // or a bare URL string (legacy).
614 $cdn_url = is_array( $first ) ? (string) ( $first['url'] ?? ( $first['cdn_url'] ?? '' ) ) : (string) $first;
615 }
616 if ( '' !== $cdn_url ) {
617 $patch['cdn'] = array(
618 'enabled' => true,
619 'cdn_url' => $cdn_url,
620 );
621 $set_list( $patch['cdn'], 'excluded_patterns', $list( 'cdn-exclude' ) );
622 }
623 }
624
625 return $patch;
626 }
627 }
628