PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.0.7
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.0.7
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 1.2.0 All 28 releases
xspeed / includes / class-migration.php

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

721 lines 29.4 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 $mapped = self::count_meaningful( $patch );
109 }
110 }
111 $file = self::PLUGIN_FILE[ $id ] ?? '';
112 $out[] = array(
113 'id' => $id,
114 'label' => $spec['label'],
115 'detected' => $detected,
116 'value_count' => $detected ? count( $raw ) : 0,
117 'mapped_count' => $mapped,
118 'imported' => in_array( $id, $imported, true ),
119 'active' => '' !== $file && is_plugin_active( $file ),
120 );
121 }
122 return $out;
123 }
124
125 /**
126 * Count the settings in a plan patch that will MEANINGFULLY change the
127 * config — i.e. the ones actually enabled / non-empty in the source.
128 *
129 * A `plan_*` patch always emits every mapped field, including the ones the
130 * source has turned OFF (`false`) or left empty. Counting those inflated
131 * the "settings available to migrate" number — a source with 2 settings on
132 * still reported ~12 because the importer listed every false mapping.
133 * Disabled (`false`) booleans and empty arrays/strings/zeros contribute
134 * nothing on import, so they're excluded from the count. (FBS-82449)
135 *
136 * @param array<string,mixed> $patch Plan patch (module slug => values).
137 * @return int Number of enabled / non-empty settings.
138 */
139 private static function count_meaningful( array $patch ): int {
140 $count = 0;
141 foreach ( $patch as $vals ) {
142 if ( is_array( $vals ) ) {
143 $count += count( self::meaningful_values( $vals ) );
144 }
145 }
146 return $count;
147 }
148
149 /**
150 * Filter one module's plan values down to the ones that meaningfully change
151 * the config: enabled (`true`) booleans, non-empty arrays, and non-empty
152 * scalars. Disabled toggles, empty lists, and zero/empty scalars are
153 * dropped — they represent "nothing to import" for that setting. Shared by
154 * the count (status) and the write (apply) so both agree. (FBS-82449)
155 *
156 * @param array<string,mixed> $values One module's mapped values.
157 * @return array<string,mixed> Only the meaningful entries.
158 */
159 private static function meaningful_values( array $values ): array {
160 $out = array();
161 foreach ( $values as $key => $value ) {
162 if ( is_bool( $value ) ) {
163 if ( $value ) {
164 $out[ $key ] = $value; // Only an enabled toggle imports.
165 }
166 } elseif ( is_array( $value ) ) {
167 if ( ! empty( $value ) ) {
168 $out[ $key ] = $value; // Non-empty list (e.g. excluded_urls).
169 }
170 } elseif ( '' !== $value && null !== $value && 0 !== $value && '0' !== $value ) {
171 $out[ $key ] = $value; // Non-empty scalar (e.g. cache_expiry).
172 }
173 }
174 return $out;
175 }
176
177 /**
178 * Map a source plugin's "separate mobile cache" flag onto the cache patch
179 * WITHOUT enabling xSpeed's mobile_separate. The xSpeed static-file fast
180 * path is device-blind, so mobile_separate=ON disables it — and a source
181 * site that had the flag on very often serves identical HTML to every
182 * device (it was on by habit). Rather than silently kill the fast path on
183 * import, we keep mobile_separate off and, when the source had it on, set
184 * `mobile_separate_review` so the dashboard can prompt the user to turn it
185 * back on only if their site really differs per device.
186 * (FBS-83144 / FBS-83145)
187 *
188 * @param array $patch The plan patch (modified by reference).
189 * @param bool $source_on Whether the source plugin had mobile-separate on.
190 */
191 private static function map_mobile_separate( array &$patch, bool $source_on ): void {
192 if ( ! isset( $patch['cache'] ) || ! is_array( $patch['cache'] ) ) {
193 $patch['cache'] = array();
194 }
195 // Never import as ON; leave the fast path intact.
196 $patch['cache']['mobile_separate'] = false;
197 if ( $source_on ) {
198 $patch['cache']['mobile_separate_review'] = true;
199 }
200 }
201
202 /**
203 * Return the patch that `apply()` would write, without writing.
204 */
205 public static function preview( string $source_id ): ?array {
206 $src = self::sources()[ $source_id ] ?? null;
207 if ( null === $src ) {
208 return null;
209 }
210 $raw = call_user_func( $src['detect'] );
211 if ( ! is_array( $raw ) ) {
212 return null;
213 }
214 return call_user_func( $src['plan'], $raw );
215 }
216
217 /**
218 * Read source settings + write the translated patch. Returns the
219 * per-module write results, same shape as Recommendations::apply.
220 */
221 public static function apply( string $source_id ): array {
222 $patch = self::preview( $source_id );
223 if ( null === $patch ) {
224 return array();
225 }
226 $results = array();
227 foreach ( $patch as $slug => $values ) {
228 if ( ! is_string( $slug ) || ! is_array( $values ) ) {
229 continue;
230 }
231 // Import only the settings that are actually enabled / non-empty in
232 // the source. A plan patch emits every mapped field including the
233 // ones the source turned OFF; merging those `false`/empty values
234 // would silently DISABLE settings the user already had on in xSpeed.
235 // Migration is additive — it never clobbers existing config with a
236 // source's disabled value. (FBS-82449)
237 $meaningful = self::meaningful_values( $values );
238 if ( empty( $meaningful ) ) {
239 continue;
240 }
241 $option = 'xspeed_module_' . $slug;
242 $cur = (array) get_option( $option, array() );
243 $next = array_merge( $cur, $meaningful );
244 $ok = update_option( $option, $next );
245 $results[ $slug ] = array(
246 'ok' => (bool) $ok,
247 'applied' => array_keys( $meaningful ),
248 );
249 }
250 if ( ! empty( $results ) ) {
251 self::mark_imported( $source_id );
252 }
253 return $results;
254 }
255
256 // ─────────────────────────── WP Rocket ───────────────────────────
257
258 public static function detect_wp_rocket(): ?array {
259 $opt = get_option( 'wp_rocket_settings', null );
260 return is_array( $opt ) ? $opt : null;
261 }
262
263 /**
264 * Translate WP Rocket's `wp_rocket_settings` array into our module
265 * settings. Only safe-to-port booleans + counts; behaviorally
266 * different toggles (Critical CSS, RUCSS) skip — Pro handles those.
267 *
268 * @param array $r raw wp_rocket_settings.
269 */
270 public static function plan_wp_rocket( array $r ): array {
271 $patch = array();
272 // Page caching.
273 $patch['cache'] = array(
274 'enabled' => ! empty( $r['cache_logged_user'] ) || ! isset( $r['cache_logged_user'] ),
275 'expiry_hours' => isset( $r['purge_cron_interval'] ) ? max( 1, (int) ( $r['purge_cron_interval'] / 3600 ) ) : 24,
276 );
277 // Excluded URLs / cookies — both are arrays of strings in WP Rocket.
278 if ( ! empty( $r['cache_reject_uri'] ) && is_array( $r['cache_reject_uri'] ) ) {
279 $patch['cache']['excluded_urls'] = array_values( array_filter( array_map( 'strval', $r['cache_reject_uri'] ) ) );
280 }
281 if ( ! empty( $r['cache_reject_cookies'] ) && is_array( $r['cache_reject_cookies'] ) ) {
282 $patch['cache']['excluded_cookies'] = array_values( array_filter( array_map( 'strval', $r['cache_reject_cookies'] ) ) );
283 }
284
285 // Minify.
286 $patch['minify'] = array(
287 'minify_html' => ! empty( $r['minify_html'] ),
288 'minify_css' => ! empty( $r['minify_css'] ),
289 'minify_js' => ! empty( $r['minify_js'] ),
290 'combine_css' => ! empty( $r['minify_concatenate_css'] ),
291 'combine_js' => ! empty( $r['minify_concatenate_js'] ),
292 'defer_js' => ! empty( $r['defer_all_js'] ),
293 );
294
295 // Lazy load.
296 $patch['lazy'] = array(
297 'lazy_images' => ! empty( $r['lazyload'] ),
298 'lazy_iframes' => ! empty( $r['lazyload_iframes'] ),
299 'lazy_videos' => ! empty( $r['lazyload_youtube'] ),
300 );
301
302 // Separate Mobile Cache — do NOT import this as ON. WP Rocket's
303 // "separate cache files for mobile" is frequently left on by habit even
304 // when the site serves identical HTML to every device, and xSpeed's
305 // static-file fast path is device-blind — enabling mobile_separate
306 // DISABLES it, silently dropping the site from HIT (nginx) to HIT (php).
307 // Instead, keep the fast path (mobile_separate stays false) and flag it
308 // for review so the dashboard can prompt the user to re-enable it only
309 // if their site really differs per device. (FBS-83144 / FBS-83145)
310 self::map_mobile_separate( $patch, ! empty( $r['do_caching_mobile_files'] ) );
311
312 // Preloader.
313 if ( ! empty( $r['manual_preload'] ) || ! empty( $r['sitemap_preload'] ) ) {
314 $patch['preloader'] = array(
315 'enabled' => true,
316 'schedule' => 'daily',
317 );
318 if ( ! empty( $r['sitemap_preload_url'] ) && is_array( $r['sitemap_preload_url'] ) ) {
319 $patch['preloader']['sitemap_urls'] = array_values( array_filter( array_map( 'strval', $r['sitemap_preload_url'] ) ) );
320 }
321 }
322
323 // CDN — WP Rocket stores CDN hosts in cdn_cnames (array).
324 if ( ! empty( $r['cdn'] ) && ! empty( $r['cdn_cnames'] ) && is_array( $r['cdn_cnames'] ) ) {
325 $first = (string) ( $r['cdn_cnames'][0] ?? '' );
326 if ( '' !== $first ) {
327 $patch['cdn'] = array(
328 'enabled' => true,
329 'cdn_url' => $first,
330 );
331 }
332 }
333
334 return $patch;
335 }
336
337 // ─────────────────────────── W3 Total Cache ──────────────────────
338
339 public static function detect_w3tc(): ?array {
340 // W3 Total Cache does NOT store its config in the options table — it
341 // writes a PHP file at wp-content/w3tc-config/master.php whose body
342 // is a short PHP guard followed by a JSON blob of dotted-key settings
343 // (pgcache.enabled, minify.html.enable, …). Reading w3tc_config /
344 // w3tc_master_settings options always returned null, so detection
345 // failed on every install. Read + parse the config file instead.
346 $cfg = self::read_w3tc_config_file();
347 if ( is_array( $cfg ) && ! empty( $cfg ) ) {
348 return $cfg;
349 }
350 // Defensive fallback for any build that did persist an options blob.
351 $opt = get_option( 'w3tc_config', null );
352 if ( ! is_array( $opt ) ) {
353 $opt = get_option( 'w3tc_master_settings', null );
354 }
355 return is_array( $opt ) ? $opt : null;
356 }
357
358 /**
359 * Parse W3TC's master config file into a flat dotted-key array.
360 * Format: a short PHP guard (a php-open, exit, php-close) immediately
361 * followed by a JSON object. We strip everything up to and including the
362 * PHP closing tag, then JSON-decode the remainder.
363 *
364 * @return array|null parsed config, or null if the file is missing/unreadable.
365 */
366 private static function read_w3tc_config_file(): ?array {
367 if ( ! defined( 'WP_CONTENT_DIR' ) ) {
368 return null;
369 }
370 $path = WP_CONTENT_DIR . '/w3tc-config/master.php';
371 if ( ! is_readable( $path ) ) {
372 return null;
373 }
374 $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.
375 if ( false === $raw || '' === $raw ) {
376 return null;
377 }
378 // Drop the leading PHP guard and decode the JSON tail. The pattern
379 // matches up to the first PHP closing tag; built from a char-code so
380 // no literal close tag appears in this source file.
381 $close_tag = '?' . '>';
382 $json = preg_replace( '/^.*?' . preg_quote( $close_tag, '/' ) . '/s', '', $raw );
383 $cfg = json_decode( trim( (string) $json ), true );
384 return is_array( $cfg ) ? $cfg : null;
385 }
386
387 public static function plan_w3tc( array $r ): array {
388 $patch = array();
389 $patch['cache'] = array(
390 'enabled' => ! empty( $r['pgcache.enabled'] ),
391 'expiry_hours' => isset( $r['pgcache.lifetime'] ) ? max( 1, (int) ( (int) $r['pgcache.lifetime'] / 3600 ) ) : 24,
392 );
393 if ( ! empty( $r['pgcache.reject.uri'] ) && is_array( $r['pgcache.reject.uri'] ) ) {
394 $patch['cache']['excluded_urls'] = array_values( array_filter( array_map( 'strval', $r['pgcache.reject.uri'] ) ) );
395 }
396
397 $patch['minify'] = array(
398 'minify_html' => ! empty( $r['minify.html.enable'] ),
399 'minify_css' => ! empty( $r['minify.css.enable'] ),
400 'minify_js' => ! empty( $r['minify.js.enable'] ),
401 );
402
403 if ( ! empty( $r['objectcache.enabled'] ) && ! empty( $r['objectcache.engine'] ) ) {
404 $patch['object-cache'] = array(
405 'backend' => 'memcached' === $r['objectcache.engine'] ? 'memcached' : 'redis',
406 );
407 if ( ! empty( $r['objectcache.servers'] ) && is_array( $r['objectcache.servers'] ) ) {
408 $first = (string) ( $r['objectcache.servers'][0] ?? '' );
409 if ( false !== strpos( $first, ':' ) ) {
410 [ $host, $port ] = explode( ':', $first, 2 );
411 if ( 'redis' === ( $patch['object-cache']['backend'] ?? '' ) ) {
412 $patch['object-cache']['redis_host'] = $host;
413 $patch['object-cache']['redis_port'] = (int) $port;
414 } else {
415 $patch['object-cache']['memcached_host'] = $host;
416 $patch['object-cache']['memcached_port'] = (int) $port;
417 }
418 }
419 }
420 }
421
422 if ( ! empty( $r['browsercache.enabled'] ) ) {
423 $patch['browser-cache'] = array(
424 'enabled' => true,
425 );
426 }
427
428 return $patch;
429 }
430
431 // ─────────────────────────── WP Super Cache ──────────────────────
432
433 public static function detect_wpsc(): ?array {
434 // WP Super Cache stores its settings as PHP globals in
435 // wp-content/wp-cache-config.php (NOT the options table — the old
436 // get_option('wp_cache_enabled') reads always returned null). Parse
437 // the config file for the globals plan_wpsc() needs. If the file
438 // doesn't exist yet (plugin active but never configured), fall back
439 // to a minimal "active" marker so the source still appears in the UI
440 // and a default import is possible.
441 $cfg = self::read_wpsc_config_file();
442 if ( is_array( $cfg ) && ! empty( $cfg ) ) {
443 return $cfg;
444 }
445 if ( self::plugin_active( 'wp-super-cache/wp-cache.php' ) ) {
446 // Active but unconfigured — expose the on/off intent only.
447 return array( 'wp_cache_enabled' => defined( 'WPCACHEHOME' ) );
448 }
449 return null;
450 }
451
452 /**
453 * Parse the WP Super Cache config file for the globals we map. The file
454 * is plain PHP assigning `$wp_cache_* = …;` lines; we extract them with a
455 * regex rather than including the file (including it would define
456 * constants / run code in our request).
457 *
458 * @return array|null name => value for the recognised globals, or null.
459 */
460 private static function read_wpsc_config_file(): ?array {
461 if ( ! defined( 'WP_CONTENT_DIR' ) ) {
462 return null;
463 }
464 $path = WP_CONTENT_DIR . '/wp-cache-config.php';
465 if ( ! is_readable( $path ) ) {
466 return null;
467 }
468 $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.
469 if ( false === $raw || '' === $raw ) {
470 return null;
471 }
472 $out = array();
473 $keys = array( 'wp_cache_enabled', 'wp_cache_mod_rewrite', 'wp_cache_mobile_enabled', 'wp_cache_not_logged_in', 'wp_cache_make_known_anon' );
474 foreach ( $keys as $key ) {
475 // Match `$key = 1;` / `$key = '1';` / `$key = true;` etc.
476 if ( preg_match( '/\$' . preg_quote( $key, '/' ) . '\s*=\s*([^;]+);/', $raw, $m ) ) {
477 $val = trim( $m[1], " \t'\"" );
478 $out[ $key ] = in_array( strtolower( $val ), array( '1', 'true' ), true );
479 }
480 }
481 return ! empty( $out ) ? $out : null;
482 }
483
484 /** Thin wrapper so detection works before admin plugin.php is loaded. */
485 private static function plugin_active( string $plugin ): bool {
486 $active = (array) get_option( 'active_plugins', array() );
487 if ( in_array( $plugin, $active, true ) ) {
488 return true;
489 }
490 // Network-activated (multisite).
491 $network = (array) get_site_option( 'active_sitewide_plugins', array() );
492 return isset( $network[ $plugin ] );
493 }
494
495 public static function plan_wpsc( array $r ): array {
496 $plan = array(
497 'cache' => array(
498 'enabled' => ! empty( $r['wp_cache_enabled'] ),
499 ),
500 );
501 // See plan_wp_rocket: never import Separate Mobile Cache as ON — it
502 // disables the device-blind static fast path. Flag for review instead.
503 self::map_mobile_separate( $plan, ! empty( $r['wp_cache_mobile_enabled'] ) );
504 return $plan;
505 }
506
507 // ─────────────────────────── LiteSpeed Cache ─────────────────────
508
509 /**
510 * Read LiteSpeed Cache settings into a flat `name => value` array keyed
511 * by LiteSpeed's dotted setting names (cache, cache-mobile, optm-*,
512 * media-*, object-*, cdn-*, …) — the shape plan_litespeed() expects.
513 *
514 * Storage has changed across LiteSpeed versions:
515 * - v4+ (current): ONE option PER setting, named `litespeed.conf.<name>`
516 * (e.g. litespeed.conf.cache, litespeed.conf.cache-mobile). There is
517 * NO single `litespeed.conf` blob — reading that key returns null,
518 * which is why detection used to fail on every modern install.
519 * - v3 and earlier: a single serialized array under `litespeed.conf`
520 * (or the legacy `litespeed-cache-conf`).
521 * We handle all three: try the per-option family first (the common case
522 * today), then fall back to the legacy single-blob options.
523 *
524 * @return array|null raw conf (name => value), or null when absent.
525 */
526 public static function detect_litespeed(): ?array {
527 global $wpdb;
528
529 // v4+: individual `litespeed.conf.<name>` options. Pull them all and
530 // strip the prefix so keys match what plan_litespeed() reads.
531 // 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.
532 $rows = $wpdb->get_results(
533 "SELECT option_name, option_value FROM {$wpdb->options} WHERE option_name LIKE 'litespeed.conf.%'",
534 ARRAY_A
535 );
536 if ( ! empty( $rows ) ) {
537 $conf = array();
538 foreach ( $rows as $row ) {
539 $name = substr( (string) $row['option_name'], strlen( 'litespeed.conf.' ) );
540 if ( '' === $name || '_version' === $name ) {
541 continue;
542 }
543 // option_value is stored serialized by WP; maybe_unserialize
544 // gives back arrays (list settings) or scalars as-is.
545 $conf[ $name ] = maybe_unserialize( $row['option_value'] );
546 }
547 if ( ! empty( $conf ) ) {
548 return $conf;
549 }
550 }
551
552 // v3 / legacy: a single serialized array.
553 $opt = get_option( 'litespeed.conf', null );
554 if ( ! is_array( $opt ) ) {
555 $opt = get_option( 'litespeed-cache-conf', null );
556 }
557 return is_array( $opt ) && ! empty( $opt ) ? $opt : null;
558 }
559
560 /**
561 * Translate LiteSpeed's `litespeed.conf` into xSpeed module patches.
562 * LiteSpeed uses dotted keys; values are mostly bool-ish (1/0/'1').
563 * We map only the settings that have a clean xSpeed equivalent and
564 * leave the rest untouched so nothing is silently mis-imported.
565 *
566 * @param array $r raw litespeed.conf.
567 */
568 public static function plan_litespeed( array $r ): array {
569 $on = static function ( $key ) use ( $r ): bool {
570 return isset( $r[ $key ] ) && ! empty( $r[ $key ] );
571 };
572 // LiteSpeed list fields are stored as either a newline-delimited
573 // string or an array. Normalize to a clean string[] either way.
574 $list = static function ( $key ) use ( $r ): array {
575 $v = $r[ $key ] ?? null;
576 if ( is_string( $v ) ) {
577 $v = preg_split( '/\r\n|\r|\n/', $v );
578 }
579 if ( ! is_array( $v ) ) {
580 return array();
581 }
582 return array_values( array_filter( array_map( 'trim', array_map( 'strval', $v ) ) ) );
583 };
584 $set_list = static function ( array &$dest, string $dest_key, array $vals ): void {
585 if ( $vals ) {
586 $dest[ $dest_key ] = $vals;
587 }
588 };
589
590 $patch = array();
591
592 // ── Page cache ────────────────────────────────────────────────
593 $patch['cache'] = array(
594 'enabled' => $on( 'cache' ) || $on( 'cache-priv' ),
595 );
596 // See plan_wp_rocket: never import Separate Mobile Cache as ON — it
597 // disables the device-blind static fast path. Flag for review instead.
598 self::map_mobile_separate( $patch, $on( 'cache-mobile' ) );
599 // TTL: LiteSpeed stores cache-ttl_pub in seconds → xSpeed wants hours.
600 if ( isset( $r['cache-ttl_pub'] ) && (int) $r['cache-ttl_pub'] > 0 ) {
601 $patch['cache']['cache_expiry'] = max( 1, min( 720, (int) ( (int) $r['cache-ttl_pub'] / 3600 ) ) );
602 }
603 // Excluded URIs / cookies / user-agents / dropped query strings.
604 $set_list( $patch['cache'], 'excluded_urls', $list( 'cache-exc' ) );
605 $set_list( $patch['cache'], 'excluded_cookies', $list( 'cache-exc_cookies' ) );
606 $set_list( $patch['cache'], 'bypass_user_agents', $list( 'cache-exc_useragent' ) );
607 // LiteSpeed "drop query string" list ≈ xSpeed ignored_query_params.
608 $set_list( $patch['cache'], 'ignored_query_params', $list( 'cache-drop_qs' ) );
609
610 // ── Minify / optimization ─────────────────────────────────────
611 $patch['minify'] = array(
612 'minify_html' => $on( 'optm-html_min' ),
613 'minify_css' => $on( 'optm-css_min' ),
614 'minify_js' => $on( 'optm-js_min' ),
615 'combine_css' => $on( 'optm-css_comb' ),
616 'combine_js' => $on( 'optm-js_comb' ),
617 'defer_js' => $on( 'optm-js_defer' ),
618 // LiteSpeed "Delay JS" (optm-js_defer === 2 in some versions, or
619 // the dedicated optm-js_delay flag) → xSpeed delay_js.
620 'delay_js' => $on( 'optm-js_delay' ) || ( isset( $r['optm-js_defer'] ) && (int) $r['optm-js_defer'] === 2 ),
621 // Async/“load CSS asynchronously” — LiteSpeed CCSS async.
622 'async_css' => $on( 'optm-css_async' ),
623 // Remove query strings from static resources.
624 'remove_query_strings' => $on( 'optm-qs_rm' ),
625 );
626 // Defer/delay exclusion list — merge LiteSpeed's JS defer + delay
627 // exclude lists into xSpeed's single defer_js_excluded.
628 $defer_exc = array_values( array_unique( array_merge( $list( 'optm-js_defer_exc' ), $list( 'optm-js_delay_exc' ) ) ) );
629 $set_list( $patch['minify'], 'defer_js_excluded', $defer_exc );
630
631 // ── Lazy load (media) ─────────────────────────────────────────
632 $patch['lazy'] = array(
633 'lazy_images' => $on( 'media-lazy' ),
634 'lazy_iframes' => $on( 'media-iframe_lazy' ),
635 // LiteSpeed has no separate HTML5-video lazy toggle; mirror the
636 // image setting so video preload follows the same intent.
637 'lazy_videos' => $on( 'media-lazy' ),
638 // "Add Missing Sizes" → add_missing_dimensions (anti-CLS).
639 'add_missing_dimensions' => $on( 'media-add_missing_sizes' ),
640 );
641 $set_list( $patch['lazy'], 'excluded_images', $list( 'media-lazy_exc' ) );
642
643 // ── Fonts ─────────────────────────────────────────────────────
644 // LiteSpeed "Font Display Optimization" (optm-localize_style /
645 // optm-css_font_display) → xSpeed font-display: swap.
646 if ( $on( 'optm-css_font_display' ) || $on( 'optm-localize' ) ) {
647 $patch['fonts'] = array( 'font_display_swap' => true );
648 }
649
650 // ── Disable bloat ─────────────────────────────────────────────
651 // Only map the one LiteSpeed "remove" toggle with a clean xSpeed
652 // equivalent: removing the emoji + oEmbed scripts ≈ disable_oembed.
653 // (LiteSpeed's optm-emoji_rm strips the wp-emoji + wp-embed pair.)
654 // jQuery-migrate / dashicons / XML-RPC / RSS / REST aren't
655 // LiteSpeed-managed, so we don't guess at them.
656 if ( $on( 'optm-emoji_rm' ) ) {
657 $patch['bloat'] = array( 'disable_oembed' => true );
658 }
659
660 // ── Browser cache (LiteSpeed: cache-browser) ──────────────────
661 if ( $on( 'cache-browser' ) ) {
662 $patch['browser-cache'] = array( 'enabled' => true );
663 if ( isset( $r['cache-ttl_browser'] ) && (int) $r['cache-ttl_browser'] > 0 ) {
664 $patch['browser-cache']['asset_ttl'] = (int) $r['cache-ttl_browser'];
665 }
666 }
667
668 // ── Object cache ──────────────────────────────────────────────
669 if ( $on( 'object' ) ) {
670 $kind = isset( $r['object-kind'] ) && (int) $r['object-kind'] === 1 ? 'redis' : 'memcached';
671 $patch['object-cache'] = array( 'backend' => $kind );
672 if ( ! empty( $r['object-host'] ) ) {
673 $host_key = 'redis' === $kind ? 'redis_host' : 'memcached_host';
674 $patch['object-cache'][ $host_key ] = (string) $r['object-host'];
675 }
676 if ( ! empty( $r['object-port'] ) ) {
677 $port_key = 'redis' === $kind ? 'redis_port' : 'memcached_port';
678 $patch['object-cache'][ $port_key ] = (int) $r['object-port'];
679 }
680 if ( 'redis' === $kind && isset( $r['object-db_id'] ) ) {
681 $patch['object-cache']['redis_database'] = max( 0, min( 15, (int) $r['object-db_id'] ) );
682 }
683 if ( ! empty( $r['object-pswd'] ) && 'redis' === $kind ) {
684 $patch['object-cache']['redis_password'] = (string) $r['object-pswd'];
685 }
686 if ( ! empty( $r['object-global_groups'] ) || ! empty( $r['object-persistent'] ) ) {
687 $patch['object-cache']['persistent'] = $on( 'object-persistent' );
688 }
689 }
690
691 // ── Image conversion (Pro Images module) ──────────────────────
692 // LiteSpeed media-webp / next-gen image generation → xSpeed Images.
693 if ( $on( 'img_optm-webp' ) || $on( 'media-webp' ) || $on( 'img_optm-auto' ) ) {
694 $patch['images'] = array(
695 'webp' => $on( 'img_optm-webp' ) || $on( 'media-webp' ),
696 'avif' => $on( 'img_optm-avif' ),
697 );
698 }
699
700 // ── CDN ───────────────────────────────────────────────────────
701 if ( $on( 'cdn' ) ) {
702 $cdn_url = '';
703 if ( ! empty( $r['cdn-mapping'] ) && is_array( $r['cdn-mapping'] ) ) {
704 $first = $r['cdn-mapping'][0] ?? array();
705 // LiteSpeed cdn-mapping rows use the 'url' sub-key (array form)
706 // or a bare URL string (legacy).
707 $cdn_url = is_array( $first ) ? (string) ( $first['url'] ?? ( $first['cdn_url'] ?? '' ) ) : (string) $first;
708 }
709 if ( '' !== $cdn_url ) {
710 $patch['cdn'] = array(
711 'enabled' => true,
712 'cdn_url' => $cdn_url,
713 );
714 $set_list( $patch['cdn'], 'excluded_patterns', $list( 'cdn-exclude' ) );
715 }
716 }
717
718 return $patch;
719 }
720 }
721