# xspeed/1.1.2/includes/class-migration.php

xSpeed Cache: AI-Powered Performance Hub with MCP, Caching &amp; CDN, version 1.1.2. 727 lines.

- Page: https://pluginprobe.com/plugins/xspeed/1.1.2/code/includes/class-migration.php
- Raw: https://pluginprobe.com/plugins/xspeed/1.1.2/raw/includes/class-migration.php
- Modified: 2026-07-14T10:10:50+00:00

Line numbers below start at 1. Link to a line or a range by appending a fragment to the
page URL, for example `https://pluginprobe.com/plugins/xspeed/1.1.2/code/includes/class-migration.php#L10-L20`.

```php
<?php
/**
 * Migration — read settings from other popular caching plugins and
 * translate them into xSpeed equivalents.
 *
 * Each source plugin has its own importer that returns a single
 * normalized patch shape:
 *
 *   array<string,array> // module-slug → settings patch
 *
 * which feeds straight into update_option('xspeed_module_<slug>')
 * via the same write path the Recommendations module uses.
 *
 * Importers are pure: detect() reads the options table, returns
 * what it found (or null if the source plugin's options aren't
 * present). plan() turns that raw read into the patch. apply()
 * writes it. preview() returns plan() without writing — used by
 * the React panel for the "what would import" diff view.
 *
 * Adding a new source = adding a private detect_*() + plan_*()
 * pair, then wiring them in `sources()`.
 *
 * @package XSpeed
 */

declare(strict_types=1);

namespace XSpeed;

defined( 'ABSPATH' ) || exit;

final class Migration {

	/**
	 * Public list of source plugins with metadata for the UI:
	 *   [ id => [ label, detect_cb, plan_cb ] ]
	 */
	public static function sources(): array {
		return array(
			'wp-rocket' => array(
				'label'  => 'WP Rocket',
				'detect' => array( __CLASS__, 'detect_wp_rocket' ),
				'plan'   => array( __CLASS__, 'plan_wp_rocket' ),
			),
			'w3-total-cache' => array(
				'label'  => 'W3 Total Cache',
				'detect' => array( __CLASS__, 'detect_w3tc' ),
				'plan'   => array( __CLASS__, 'plan_w3tc' ),
			),
			'wp-super-cache' => array(
				'label'  => 'WP Super Cache',
				'detect' => array( __CLASS__, 'detect_wpsc' ),
				'plan'   => array( __CLASS__, 'plan_wpsc' ),
			),
			'litespeed-cache' => array(
				'label'  => 'LiteSpeed Cache',
				'detect' => array( __CLASS__, 'detect_litespeed' ),
				'plan'   => array( __CLASS__, 'plan_litespeed' ),
			),
		);
	}

	/** Site option holding the list of source ids already imported. */
	private const IMPORTED_OPTION = 'xspeed_migration_imported';

	/** Source id → plugin file, for the active-state check. */
	private const PLUGIN_FILE = array(
		'wp-rocket'       => 'wp-rocket/wp-rocket.php',
		'w3-total-cache'  => 'w3-total-cache/w3-total-cache.php',
		'wp-super-cache'  => 'wp-super-cache/wp-cache.php',
		'litespeed-cache' => 'litespeed-cache/litespeed-cache.php',
	);

	/** Record a source as imported (idempotent). */
	public static function mark_imported( string $id ): void {
		$done = (array) get_option( self::IMPORTED_OPTION, array() );
		if ( ! in_array( $id, $done, true ) ) {
			$done[] = $id;
			update_option( self::IMPORTED_OPTION, array_values( $done ) );
		}
	}

	/**
	 * For each source, return { id, label, detected, value_count, mapped_count,
	 * imported, active }.
	 *   - `detected`     true when the source plugin's settings are present.
	 *   - `value_count`  raw number of keys in the source's own config — NOT
	 *                    how many we import; kept for diagnostics only.
	 *   - `mapped_count` how many settings the importer ACTUALLY writes into
	 *                    xSpeed (the honest number to show users).
	 *   - `imported`     true once this source has been imported (so the panel
	 *                    shows it as done, not as a fresh Import target).
	 *   - `active`       whether the source plugin is still active.
	 */
	public static function status(): array {
		$imported = (array) get_option( self::IMPORTED_OPTION, array() );
		if ( ! function_exists( 'is_plugin_active' ) ) {
			require_once ABSPATH . 'wp-admin/includes/plugin.php';
		}
		$out = array();
		foreach ( self::sources() as $id => $spec ) {
			$raw      = call_user_func( $spec['detect'] );
			$detected = is_array( $raw );
			$mapped   = 0;
			if ( $detected ) {
				$patch = call_user_func( $spec['plan'], $raw );
				if ( is_array( $patch ) ) {
					$mapped = self::count_meaningful( $patch );
				}
			}
			$file = self::PLUGIN_FILE[ $id ] ?? '';
			$out[] = array(
				'id'           => $id,
				'label'        => $spec['label'],
				'detected'     => $detected,
				'value_count'  => $detected ? count( $raw ) : 0,
				'mapped_count' => $mapped,
				'imported'     => in_array( $id, $imported, true ),
				'active'       => '' !== $file && is_plugin_active( $file ),
			);
		}
		return $out;
	}

	/**
	 * Count the settings in a plan patch that will MEANINGFULLY change the
	 * config — i.e. the ones actually enabled / non-empty in the source.
	 *
	 * A `plan_*` patch always emits every mapped field, including the ones the
	 * source has turned OFF (`false`) or left empty. Counting those inflated
	 * the "settings available to migrate" number — a source with 2 settings on
	 * still reported ~12 because the importer listed every false mapping.
	 * Disabled (`false`) booleans and empty arrays/strings/zeros contribute
	 * nothing on import, so they're excluded from the count. (FBS-82449)
	 *
	 * @param array<string,mixed> $patch Plan patch (module slug => values).
	 * @return int Number of enabled / non-empty settings.
	 */
	private static function count_meaningful( array $patch ): int {
		$count = 0;
		foreach ( $patch as $vals ) {
			if ( is_array( $vals ) ) {
				$count += count( self::meaningful_values( $vals ) );
			}
		}
		return $count;
	}

	/**
	 * Filter one module's plan values down to the ones that meaningfully change
	 * the config: enabled (`true`) booleans, non-empty arrays, and non-empty
	 * scalars. Disabled toggles, empty lists, and zero/empty scalars are
	 * dropped — they represent "nothing to import" for that setting. Shared by
	 * the count (status) and the write (apply) so both agree. (FBS-82449)
	 *
	 * @param array<string,mixed> $values One module's mapped values.
	 * @return array<string,mixed> Only the meaningful entries.
	 */
	private static function meaningful_values( array $values ): array {
		$out = array();
		foreach ( $values as $key => $value ) {
			if ( is_bool( $value ) ) {
				if ( $value ) {
					$out[ $key ] = $value; // Only an enabled toggle imports.
				}
			} elseif ( is_array( $value ) ) {
				if ( ! empty( $value ) ) {
					$out[ $key ] = $value; // Non-empty list (e.g. excluded_urls).
				}
			} elseif ( '' !== $value && null !== $value && 0 !== $value && '0' !== $value ) {
				$out[ $key ] = $value; // Non-empty scalar (e.g. cache_expiry).
			}
		}
		return $out;
	}

	/**
	 * Map a source plugin's "separate mobile cache" flag onto the cache patch
	 * WITHOUT enabling xSpeed's mobile_separate. The xSpeed static-file fast
	 * path is device-blind, so mobile_separate=ON disables it — and a source
	 * site that had the flag on very often serves identical HTML to every
	 * device (it was on by habit). Rather than silently kill the fast path on
	 * import, we keep mobile_separate off and, when the source had it on, set
	 * `mobile_separate_review` so the dashboard can prompt the user to turn it
	 * back on only if their site really differs per device.
	 * (FBS-83144 / FBS-83145)
	 *
	 * @param array $patch     The plan patch (modified by reference).
	 * @param bool  $source_on Whether the source plugin had mobile-separate on.
	 */
	private static function map_mobile_separate( array &$patch, bool $source_on ): void {
		if ( ! isset( $patch['cache'] ) || ! is_array( $patch['cache'] ) ) {
			$patch['cache'] = array();
		}
		// Never import as ON; leave the fast path intact.
		$patch['cache']['mobile_separate'] = false;
		if ( $source_on ) {
			$patch['cache']['mobile_separate_review'] = true;
		}
	}

	/**
	 * Return the patch that `apply()` would write, without writing.
	 */
	public static function preview( string $source_id ): ?array {
		$src = self::sources()[ $source_id ] ?? null;
		if ( null === $src ) {
			return null;
		}
		$raw = call_user_func( $src['detect'] );
		if ( ! is_array( $raw ) ) {
			return null;
		}
		return call_user_func( $src['plan'], $raw );
	}

	/**
	 * Read source settings + write the translated patch. Returns the
	 * per-module write results, same shape as Recommendations::apply.
	 */
	public static function apply( string $source_id ): array {
		$patch = self::preview( $source_id );
		if ( null === $patch ) {
			return array();
		}
		$results = array();
		foreach ( $patch as $slug => $values ) {
			if ( ! is_string( $slug ) || ! is_array( $values ) ) {
				continue;
			}
			// Import only the settings that are actually enabled / non-empty in
			// the source. A plan patch emits every mapped field including the
			// ones the source turned OFF; merging those `false`/empty values
			// would silently DISABLE settings the user already had on in xSpeed.
			// Migration is additive — it never clobbers existing config with a
			// source's disabled value. (FBS-82449)
			$meaningful = self::meaningful_values( $values );
			if ( empty( $meaningful ) ) {
				continue;
			}
			$option = 'xspeed_module_' . $slug;
			$cur    = (array) get_option( $option, array() );
			$next   = array_merge( $cur, $meaningful );
			$ok     = update_option( $option, $next );
			$results[ $slug ] = array(
				'ok'      => (bool) $ok,
				'applied' => array_keys( $meaningful ),
			);
		}
		if ( ! empty( $results ) ) {
			self::mark_imported( $source_id );
		}
		return $results;
	}

	// ─────────────────────────── WP Rocket ───────────────────────────

	public static function detect_wp_rocket(): ?array {
		$opt = get_option( 'wp_rocket_settings', null );
		return is_array( $opt ) ? $opt : null;
	}

	/**
	 * Translate WP Rocket's `wp_rocket_settings` array into our module
	 * settings. Only safe-to-port booleans + counts; behaviorally
	 * different toggles (Critical CSS, RUCSS) skip — Pro handles those.
	 *
	 * @param array $r raw wp_rocket_settings.
	 */
	public static function plan_wp_rocket( array $r ): array {
		$patch = array();
		// Page caching.
		$patch['cache'] = array(
			'enabled' => ! empty( $r['cache_logged_user'] ) || ! isset( $r['cache_logged_user'] ),
			// The Cache module's TTL setting is `cache_expiry` (hours), NOT
			// `expiry_hours` — the latter is a dead key nothing reads, so the
			// imported lifetime was silently dropped. Clamp to the same 1–720h
			// range the Cache schema + LiteSpeed importer use. (FBS-83144)
			'cache_expiry' => isset( $r['purge_cron_interval'] ) ? max( 1, min( 720, (int) ( (int) $r['purge_cron_interval'] / 3600 ) ) ) : 24,
		);
		// Excluded URLs / cookies — both are arrays of strings in WP Rocket.
		if ( ! empty( $r['cache_reject_uri'] ) && is_array( $r['cache_reject_uri'] ) ) {
			$patch['cache']['excluded_urls'] = array_values( array_filter( array_map( 'strval', $r['cache_reject_uri'] ) ) );
		}
		if ( ! empty( $r['cache_reject_cookies'] ) && is_array( $r['cache_reject_cookies'] ) ) {
			$patch['cache']['excluded_cookies'] = array_values( array_filter( array_map( 'strval', $r['cache_reject_cookies'] ) ) );
		}

		// Minify.
		$patch['minify'] = array(
			'minify_html' => ! empty( $r['minify_html'] ),
			'minify_css'  => ! empty( $r['minify_css'] ),
			'minify_js'   => ! empty( $r['minify_js'] ),
			'combine_css' => ! empty( $r['minify_concatenate_css'] ),
			'combine_js'  => ! empty( $r['minify_concatenate_js'] ),
			'defer_js'    => ! empty( $r['defer_all_js'] ),
		);

		// Lazy load.
		$patch['lazy'] = array(
			'lazy_images'  => ! empty( $r['lazyload'] ),
			'lazy_iframes' => ! empty( $r['lazyload_iframes'] ),
			'lazy_videos'  => ! empty( $r['lazyload_youtube'] ),
		);

		// Separate Mobile Cache — do NOT import this as ON. WP Rocket's
		// "separate cache files for mobile" is frequently left on by habit even
		// when the site serves identical HTML to every device, and xSpeed's
		// static-file fast path is device-blind — enabling mobile_separate
		// DISABLES it, silently dropping the site from HIT (nginx) to HIT (php).
		// Instead, keep the fast path (mobile_separate stays false) and flag it
		// for review so the dashboard can prompt the user to re-enable it only
		// if their site really differs per device. (FBS-83144 / FBS-83145)
		self::map_mobile_separate( $patch, ! empty( $r['do_caching_mobile_files'] ) );

		// Preloader.
		if ( ! empty( $r['manual_preload'] ) || ! empty( $r['sitemap_preload'] ) ) {
			$patch['preloader'] = array(
				'enabled'  => true,
				'schedule' => 'daily',
			);
			if ( ! empty( $r['sitemap_preload_url'] ) && is_array( $r['sitemap_preload_url'] ) ) {
				$patch['preloader']['sitemap_urls'] = array_values( array_filter( array_map( 'strval', $r['sitemap_preload_url'] ) ) );
			}
		}

		// CDN — WP Rocket stores CDN hosts in cdn_cnames (array).
		if ( ! empty( $r['cdn'] ) && ! empty( $r['cdn_cnames'] ) && is_array( $r['cdn_cnames'] ) ) {
			$first = (string) ( $r['cdn_cnames'][0] ?? '' );
			if ( '' !== $first ) {
				$patch['cdn'] = array(
					'enabled' => true,
					'cdn_url' => $first,
				);
			}
		}

		return $patch;
	}

	// ─────────────────────────── W3 Total Cache ──────────────────────

	public static function detect_w3tc(): ?array {
		// W3 Total Cache does NOT store its config in the options table — it
		// writes a PHP file at wp-content/w3tc-config/master.php whose body
		// is a short PHP guard followed by a JSON blob of dotted-key settings
		// (pgcache.enabled, minify.html.enable, …). Reading w3tc_config /
		// w3tc_master_settings options always returned null, so detection
		// failed on every install. Read + parse the config file instead.
		$cfg = self::read_w3tc_config_file();
		if ( is_array( $cfg ) && ! empty( $cfg ) ) {
			return $cfg;
		}
		// Defensive fallback for any build that did persist an options blob.
		$opt = get_option( 'w3tc_config', null );
		if ( ! is_array( $opt ) ) {
			$opt = get_option( 'w3tc_master_settings', null );
		}
		return is_array( $opt ) ? $opt : null;
	}

	/**
	 * Parse W3TC's master config file into a flat dotted-key array.
	 * Format: a short PHP guard (a php-open, exit, php-close) immediately
	 * followed by a JSON object. We strip everything up to and including the
	 * PHP closing tag, then JSON-decode the remainder.
	 *
	 * @return array|null parsed config, or null if the file is missing/unreadable.
	 */
	private static function read_w3tc_config_file(): ?array {
		if ( ! defined( 'WP_CONTENT_DIR' ) ) {
			return null;
		}
		$path = WP_CONTENT_DIR . '/w3tc-config/master.php';
		if ( ! is_readable( $path ) ) {
			return null;
		}
		$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.
		if ( false === $raw || '' === $raw ) {
			return null;
		}
		// Drop the leading PHP guard and decode the JSON tail. The pattern
		// matches up to the first PHP closing tag; built from a char-code so
		// no literal close tag appears in this source file.
		$close_tag = '?' . '>';
		$json      = preg_replace( '/^.*?' . preg_quote( $close_tag, '/' ) . '/s', '', $raw );
		$cfg  = json_decode( trim( (string) $json ), true );
		return is_array( $cfg ) ? $cfg : null;
	}

	public static function plan_w3tc( array $r ): array {
		$patch = array();
		$patch['cache'] = array(
			'enabled' => ! empty( $r['pgcache.enabled'] ),
			// Cache module reads `cache_expiry` (hours), not the dead
			// `expiry_hours` key — see plan_wp_rocket. (FBS-83144)
			'cache_expiry' => isset( $r['pgcache.lifetime'] ) ? max( 1, min( 720, (int) ( (int) $r['pgcache.lifetime'] / 3600 ) ) ) : 24,
		);
		if ( ! empty( $r['pgcache.reject.uri'] ) && is_array( $r['pgcache.reject.uri'] ) ) {
			$patch['cache']['excluded_urls'] = array_values( array_filter( array_map( 'strval', $r['pgcache.reject.uri'] ) ) );
		}

		$patch['minify'] = array(
			'minify_html' => ! empty( $r['minify.html.enable'] ),
			'minify_css'  => ! empty( $r['minify.css.enable'] ),
			'minify_js'   => ! empty( $r['minify.js.enable'] ),
		);

		if ( ! empty( $r['objectcache.enabled'] ) && ! empty( $r['objectcache.engine'] ) ) {
			$patch['object-cache'] = array(
				'backend' => 'memcached' === $r['objectcache.engine'] ? 'memcached' : 'redis',
			);
			if ( ! empty( $r['objectcache.servers'] ) && is_array( $r['objectcache.servers'] ) ) {
				$first = (string) ( $r['objectcache.servers'][0] ?? '' );
				if ( false !== strpos( $first, ':' ) ) {
					[ $host, $port ] = explode( ':', $first, 2 );
					if ( 'redis' === ( $patch['object-cache']['backend'] ?? '' ) ) {
						$patch['object-cache']['redis_host'] = $host;
						$patch['object-cache']['redis_port'] = (int) $port;
					} else {
						$patch['object-cache']['memcached_host'] = $host;
						$patch['object-cache']['memcached_port'] = (int) $port;
					}
				}
			}
		}

		if ( ! empty( $r['browsercache.enabled'] ) ) {
			$patch['browser-cache'] = array(
				'enabled' => true,
			);
		}

		return $patch;
	}

	// ─────────────────────────── WP Super Cache ──────────────────────

	public static function detect_wpsc(): ?array {
		// WP Super Cache stores its settings as PHP globals in
		// wp-content/wp-cache-config.php (NOT the options table — the old
		// get_option('wp_cache_enabled') reads always returned null). Parse
		// the config file for the globals plan_wpsc() needs. If the file
		// doesn't exist yet (plugin active but never configured), fall back
		// to a minimal "active" marker so the source still appears in the UI
		// and a default import is possible.
		$cfg = self::read_wpsc_config_file();
		if ( is_array( $cfg ) && ! empty( $cfg ) ) {
			return $cfg;
		}
		if ( self::plugin_active( 'wp-super-cache/wp-cache.php' ) ) {
			// Active but unconfigured — expose the on/off intent only.
			return array( 'wp_cache_enabled' => defined( 'WPCACHEHOME' ) );
		}
		return null;
	}

	/**
	 * Parse the WP Super Cache config file for the globals we map. The file
	 * is plain PHP assigning `$wp_cache_* = …;` lines; we extract them with a
	 * regex rather than including the file (including it would define
	 * constants / run code in our request).
	 *
	 * @return array|null name => value for the recognised globals, or null.
	 */
	private static function read_wpsc_config_file(): ?array {
		if ( ! defined( 'WP_CONTENT_DIR' ) ) {
			return null;
		}
		$path = WP_CONTENT_DIR . '/wp-cache-config.php';
		if ( ! is_readable( $path ) ) {
			return null;
		}
		$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.
		if ( false === $raw || '' === $raw ) {
			return null;
		}
		$out  = array();
		$keys = array( 'wp_cache_enabled', 'wp_cache_mod_rewrite', 'wp_cache_mobile_enabled', 'wp_cache_not_logged_in', 'wp_cache_make_known_anon' );
		foreach ( $keys as $key ) {
			// Match `$key = 1;` / `$key = '1';` / `$key = true;` etc.
			if ( preg_match( '/\$' . preg_quote( $key, '/' ) . '\s*=\s*([^;]+);/', $raw, $m ) ) {
				$val          = trim( $m[1], " \t'\"" );
				$out[ $key ] = in_array( strtolower( $val ), array( '1', 'true' ), true );
			}
		}
		return ! empty( $out ) ? $out : null;
	}

	/** Thin wrapper so detection works before admin plugin.php is loaded. */
	private static function plugin_active( string $plugin ): bool {
		$active = (array) get_option( 'active_plugins', array() );
		if ( in_array( $plugin, $active, true ) ) {
			return true;
		}
		// Network-activated (multisite).
		$network = (array) get_site_option( 'active_sitewide_plugins', array() );
		return isset( $network[ $plugin ] );
	}

	public static function plan_wpsc( array $r ): array {
		$plan = array(
			'cache' => array(
				'enabled' => ! empty( $r['wp_cache_enabled'] ),
			),
		);
		// See plan_wp_rocket: never import Separate Mobile Cache as ON — it
		// disables the device-blind static fast path. Flag for review instead.
		self::map_mobile_separate( $plan, ! empty( $r['wp_cache_mobile_enabled'] ) );
		return $plan;
	}

	// ─────────────────────────── LiteSpeed Cache ─────────────────────

	/**
	 * Read LiteSpeed Cache settings into a flat `name => value` array keyed
	 * by LiteSpeed's dotted setting names (cache, cache-mobile, optm-*,
	 * media-*, object-*, cdn-*, …) — the shape plan_litespeed() expects.
	 *
	 * Storage has changed across LiteSpeed versions:
	 *   - v4+ (current): ONE option PER setting, named `litespeed.conf.<name>`
	 *     (e.g. litespeed.conf.cache, litespeed.conf.cache-mobile). There is
	 *     NO single `litespeed.conf` blob — reading that key returns null,
	 *     which is why detection used to fail on every modern install.
	 *   - v3 and earlier: a single serialized array under `litespeed.conf`
	 *     (or the legacy `litespeed-cache-conf`).
	 * We handle all three: try the per-option family first (the common case
	 * today), then fall back to the legacy single-blob options.
	 *
	 * @return array|null raw conf (name => value), or null when absent.
	 */
	public static function detect_litespeed(): ?array {
		global $wpdb;

		// v4+: individual `litespeed.conf.<name>` options. Pull them all and
		// strip the prefix so keys match what plan_litespeed() reads.
		// 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.
		$rows = $wpdb->get_results(
			"SELECT option_name, option_value FROM {$wpdb->options} WHERE option_name LIKE 'litespeed.conf.%'",
			ARRAY_A
		);
		if ( ! empty( $rows ) ) {
			$conf = array();
			foreach ( $rows as $row ) {
				$name = substr( (string) $row['option_name'], strlen( 'litespeed.conf.' ) );
				if ( '' === $name || '_version' === $name ) {
					continue;
				}
				// option_value is stored serialized by WP; maybe_unserialize
				// gives back arrays (list settings) or scalars as-is.
				$conf[ $name ] = maybe_unserialize( $row['option_value'] );
			}
			if ( ! empty( $conf ) ) {
				return $conf;
			}
		}

		// v3 / legacy: a single serialized array.
		$opt = get_option( 'litespeed.conf', null );
		if ( ! is_array( $opt ) ) {
			$opt = get_option( 'litespeed-cache-conf', null );
		}
		return is_array( $opt ) && ! empty( $opt ) ? $opt : null;
	}

	/**
	 * Translate LiteSpeed's `litespeed.conf` into xSpeed module patches.
	 * LiteSpeed uses dotted keys; values are mostly bool-ish (1/0/'1').
	 * We map only the settings that have a clean xSpeed equivalent and
	 * leave the rest untouched so nothing is silently mis-imported.
	 *
	 * @param array $r raw litespeed.conf.
	 */
	public static function plan_litespeed( array $r ): array {
		$on = static function ( $key ) use ( $r ): bool {
			return isset( $r[ $key ] ) && ! empty( $r[ $key ] );
		};
		// LiteSpeed list fields are stored as either a newline-delimited
		// string or an array. Normalize to a clean string[] either way.
		$list = static function ( $key ) use ( $r ): array {
			$v = $r[ $key ] ?? null;
			if ( is_string( $v ) ) {
				$v = preg_split( '/\r\n|\r|\n/', $v );
			}
			if ( ! is_array( $v ) ) {
				return array();
			}
			return array_values( array_filter( array_map( 'trim', array_map( 'strval', $v ) ) ) );
		};
		$set_list = static function ( array &$dest, string $dest_key, array $vals ): void {
			if ( $vals ) {
				$dest[ $dest_key ] = $vals;
			}
		};

		$patch = array();

		// ── Page cache ────────────────────────────────────────────────
		$patch['cache'] = array(
			'enabled' => $on( 'cache' ) || $on( 'cache-priv' ),
		);
		// See plan_wp_rocket: never import Separate Mobile Cache as ON — it
		// disables the device-blind static fast path. Flag for review instead.
		self::map_mobile_separate( $patch, $on( 'cache-mobile' ) );
		// TTL: LiteSpeed stores cache-ttl_pub in seconds → xSpeed wants hours.
		if ( isset( $r['cache-ttl_pub'] ) && (int) $r['cache-ttl_pub'] > 0 ) {
			$patch['cache']['cache_expiry'] = max( 1, min( 720, (int) ( (int) $r['cache-ttl_pub'] / 3600 ) ) );
		}
		// Excluded URIs / cookies / user-agents / dropped query strings.
		$set_list( $patch['cache'], 'excluded_urls', $list( 'cache-exc' ) );
		$set_list( $patch['cache'], 'excluded_cookies', $list( 'cache-exc_cookies' ) );
		$set_list( $patch['cache'], 'bypass_user_agents', $list( 'cache-exc_useragent' ) );
		// LiteSpeed "drop query string" list ≈ xSpeed ignored_query_params.
		$set_list( $patch['cache'], 'ignored_query_params', $list( 'cache-drop_qs' ) );

		// ── Minify / optimization ─────────────────────────────────────
		$patch['minify'] = array(
			'minify_html'          => $on( 'optm-html_min' ),
			'minify_css'           => $on( 'optm-css_min' ),
			'minify_js'            => $on( 'optm-js_min' ),
			'combine_css'          => $on( 'optm-css_comb' ),
			'combine_js'           => $on( 'optm-js_comb' ),
			'defer_js'             => $on( 'optm-js_defer' ),
			// LiteSpeed "Delay JS" (optm-js_defer === 2 in some versions, or
			// the dedicated optm-js_delay flag) → xSpeed delay_js.
			'delay_js'             => $on( 'optm-js_delay' ) || ( isset( $r['optm-js_defer'] ) && (int) $r['optm-js_defer'] === 2 ),
			// Async/“load CSS asynchronously” — LiteSpeed CCSS async.
			'async_css'            => $on( 'optm-css_async' ),
			// Remove query strings from static resources.
			'remove_query_strings' => $on( 'optm-qs_rm' ),
		);
		// Defer/delay exclusion list — merge LiteSpeed's JS defer + delay
		// exclude lists into xSpeed's single defer_js_excluded.
		$defer_exc = array_values( array_unique( array_merge( $list( 'optm-js_defer_exc' ), $list( 'optm-js_delay_exc' ) ) ) );
		$set_list( $patch['minify'], 'defer_js_excluded', $defer_exc );

		// ── Lazy load (media) ─────────────────────────────────────────
		$patch['lazy'] = array(
			'lazy_images'            => $on( 'media-lazy' ),
			'lazy_iframes'           => $on( 'media-iframe_lazy' ),
			// LiteSpeed has no separate HTML5-video lazy toggle; mirror the
			// image setting so video preload follows the same intent.
			'lazy_videos'            => $on( 'media-lazy' ),
			// "Add Missing Sizes" → add_missing_dimensions (anti-CLS).
			'add_missing_dimensions' => $on( 'media-add_missing_sizes' ),
		);
		$set_list( $patch['lazy'], 'excluded_images', $list( 'media-lazy_exc' ) );

		// ── Fonts ─────────────────────────────────────────────────────
		// LiteSpeed "Font Display Optimization" (optm-localize_style /
		// optm-css_font_display) → xSpeed font-display: swap.
		if ( $on( 'optm-css_font_display' ) || $on( 'optm-localize' ) ) {
			$patch['fonts'] = array( 'font_display_swap' => true );
		}

		// ── Disable bloat ─────────────────────────────────────────────
		// Only map the one LiteSpeed "remove" toggle with a clean xSpeed
		// equivalent: removing the emoji + oEmbed scripts ≈ disable_oembed.
		// (LiteSpeed's optm-emoji_rm strips the wp-emoji + wp-embed pair.)
		// jQuery-migrate / dashicons / XML-RPC / RSS / REST aren't
		// LiteSpeed-managed, so we don't guess at them.
		if ( $on( 'optm-emoji_rm' ) ) {
			$patch['bloat'] = array( 'disable_oembed' => true );
		}

		// ── Browser cache (LiteSpeed: cache-browser) ──────────────────
		if ( $on( 'cache-browser' ) ) {
			$patch['browser-cache'] = array( 'enabled' => true );
			if ( isset( $r['cache-ttl_browser'] ) && (int) $r['cache-ttl_browser'] > 0 ) {
				$patch['browser-cache']['asset_ttl'] = (int) $r['cache-ttl_browser'];
			}
		}

		// ── Object cache ──────────────────────────────────────────────
		if ( $on( 'object' ) ) {
			$kind = isset( $r['object-kind'] ) && (int) $r['object-kind'] === 1 ? 'redis' : 'memcached';
			$patch['object-cache'] = array( 'backend' => $kind );
			if ( ! empty( $r['object-host'] ) ) {
				$host_key = 'redis' === $kind ? 'redis_host' : 'memcached_host';
				$patch['object-cache'][ $host_key ] = (string) $r['object-host'];
			}
			if ( ! empty( $r['object-port'] ) ) {
				$port_key = 'redis' === $kind ? 'redis_port' : 'memcached_port';
				$patch['object-cache'][ $port_key ] = (int) $r['object-port'];
			}
			if ( 'redis' === $kind && isset( $r['object-db_id'] ) ) {
				$patch['object-cache']['redis_database'] = max( 0, min( 15, (int) $r['object-db_id'] ) );
			}
			if ( ! empty( $r['object-pswd'] ) && 'redis' === $kind ) {
				$patch['object-cache']['redis_password'] = (string) $r['object-pswd'];
			}
			if ( ! empty( $r['object-global_groups'] ) || ! empty( $r['object-persistent'] ) ) {
				$patch['object-cache']['persistent'] = $on( 'object-persistent' );
			}
		}

		// ── Image conversion (Pro Images module) ──────────────────────
		// LiteSpeed media-webp / next-gen image generation → xSpeed Images.
		if ( $on( 'img_optm-webp' ) || $on( 'media-webp' ) || $on( 'img_optm-auto' ) ) {
			$patch['images'] = array(
				'webp' => $on( 'img_optm-webp' ) || $on( 'media-webp' ),
				'avif' => $on( 'img_optm-avif' ),
			);
		}

		// ── CDN ───────────────────────────────────────────────────────
		if ( $on( 'cdn' ) ) {
			$cdn_url = '';
			if ( ! empty( $r['cdn-mapping'] ) && is_array( $r['cdn-mapping'] ) ) {
				$first   = $r['cdn-mapping'][0] ?? array();
				// LiteSpeed cdn-mapping rows use the 'url' sub-key (array form)
				// or a bare URL string (legacy).
				$cdn_url = is_array( $first ) ? (string) ( $first['url'] ?? ( $first['cdn_url'] ?? '' ) ) : (string) $first;
			}
			if ( '' !== $cdn_url ) {
				$patch['cdn'] = array(
					'enabled' => true,
					'cdn_url' => $cdn_url,
				);
				$set_list( $patch['cdn'], 'excluded_patterns', $list( 'cdn-exclude' ) );
			}
		}

		return $patch;
	}
}

```
