PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.0
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.0
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-optimize-plan.php

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

392 lines 12.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Optimize plan — what an autopilot run is allowed to change, and in what order.
4 *
5 * @package XSpeed
6 */
7
8 namespace XSpeed;
9
10 defined( 'ABSPATH' ) || exit;
11
12 /**
13 * The settings an automated optimization run may touch, classified by risk.
14 *
15 * This file is deliberately pure: no I/O, no WordPress calls beyond the array
16 * it returns. Every judgement an autopilot makes about "is this safe to turn
17 * on" is encoded here, in one table, so the answer does not depend on which AI
18 * is driving or what it happened to infer from a settings screen.
19 *
20 * Two of the classifications below are evidence, not taste:
21 *
22 * - `combine_css` sits in STANDARD rather than SAFE because it has unstyled a
23 * production frontend before. WordPress can inline a stylesheet it considers
24 * small, which dropped the combined file every other sheet had been folded
25 * into; the cascade and inline-preservation contract exists because of it.
26 * It stays in the plan — the win is real — but only behind a verification
27 * that can put it back.
28 *
29 * - `strip_jquery_migrate` sits in AGGRESSIVE because turning it on broke a
30 * live page with `jQuery.Deferred exception: e.indexOf is not a function`.
31 * The document was complete and the right size; only a rendered console
32 * caught it. Anything whose failure is invisible to HTML-level checks
33 * belongs behind an explicit opt-in.
34 *
35 * @since 1.2.0
36 */
37 final class Optimize_Plan {
38
39 /**
40 * Pseudo-module for settings that are not module settings.
41 *
42 * Page caching is the case: it installs a drop-in and writes a global
43 * option, so it cannot go through Settings_Manager::update() like the
44 * rest. Marking it here keeps the catalog uniform and lets the runner
45 * dispatch it correctly instead of writing a key that will be dropped.
46 */
47 public const MODULE_GLOBAL = '__global';
48
49 /** Always applied — removals and server-side wins with no render risk. */
50 public const TIER_SAFE = 'safe';
51
52 /** Applied by default. Real wins that can change how a page renders. */
53 public const TIER_STANDARD = 'standard';
54
55 /** Opt-in only. Known to break real sites in ways HTML checks miss. */
56 public const TIER_AGGRESSIVE = 'aggressive';
57
58 /**
59 * Never applied by an automated run, at any aggressiveness.
60 *
61 * Not a risk rating — a boundary. These edit content, disable other
62 * people's plugins, or restrict endpoints a theme may depend on. An
63 * autopilot that can do them is not an optimizer, it is a site editor,
64 * and nobody asked for one.
65 */
66 public const TIER_NEVER = 'never';
67
68 /**
69 * One step: the module to write, the values, and why.
70 *
71 * `id` matches the recommendation vocabulary where one exists, so a step
72 * and a recommendation for the same problem are recognisably the same
73 * thing in a report.
74 *
75 * @return array<int,array{id:string,tier:string,module:string,values:array<string,mixed>,label:string}>
76 */
77 private static function catalog(): array {
78 return array(
79 // --- SAFE -------------------------------------------------------
80 // The single biggest win available, and the reason the plugin
81 // exists. Nothing renders differently; requests stop reaching PHP.
82 array(
83 'id' => 'cache_disabled',
84 'tier' => self::TIER_SAFE,
85 // Not a module setting. Page caching is a drop-in install plus
86 // a global option, so it goes through Cache::toggle() rather
87 // than Settings_Manager — writing `cache.enabled` looks right,
88 // is silently dropped as out-of-schema (#206), and reports
89 // success having changed nothing.
90 'module' => self::MODULE_GLOBAL,
91 'values' => array( 'cache_enabled' => true ),
92 'label' => 'Turn on page caching',
93 ),
94 // Bytes over the wire. Inert where the server cannot do it — the
95 // module writes .htaccess only where supported and otherwise just
96 // shows a snippet, so enabling it can never half-configure a host.
97 array(
98 'id' => 'gzip_off',
99 'tier' => self::TIER_SAFE,
100 'module' => 'gzip',
101 'values' => array( 'gzip_enabled' => true ),
102 'label' => 'Turn on GZIP compression',
103 ),
104 // Far-future headers for static assets only; HTML keeps its own
105 // short TTL, so a content change is still picked up immediately.
106 array(
107 'id' => 'browser_cache_off',
108 'tier' => self::TIER_SAFE,
109 'module' => 'browser-cache',
110 'values' => array( 'enabled' => true ),
111 'label' => 'Add browser cache headers',
112 ),
113 // Whitespace and comments only. Neither reorders nor combines
114 // anything, so none of the cascade risk that combine_css carries.
115 array(
116 'id' => 'minify_html_off',
117 'tier' => self::TIER_SAFE,
118 'module' => 'minify',
119 'values' => array( 'minify_html' => true ),
120 'label' => 'Minify HTML',
121 ),
122 array(
123 'id' => 'minify_css_off',
124 'tier' => self::TIER_SAFE,
125 'module' => 'minify',
126 'values' => array( 'minify_css' => true ),
127 'label' => 'Minify CSS',
128 ),
129 // Admin icon font on a public page. Removed only for logged-out
130 // visitors, who have no admin bar to render it in.
131 array(
132 'id' => 'dashicons_frontend',
133 'tier' => self::TIER_SAFE,
134 'module' => 'bloat',
135 'values' => array( 'disable_dashicons_frontend' => true ),
136 'label' => 'Stop loading admin icons for visitors',
137 ),
138 array(
139 'id' => 'xmlrpc_on',
140 'tier' => self::TIER_SAFE,
141 'module' => 'bloat',
142 'values' => array( 'disable_xmlrpc' => true ),
143 'label' => 'Disable XML-RPC',
144 ),
145
146 // --- STANDARD ---------------------------------------------------
147 array(
148 'id' => 'minify_js_off',
149 'tier' => self::TIER_STANDARD,
150 'module' => 'minify',
151 'values' => array( 'minify_js' => true ),
152 'label' => 'Minify JavaScript',
153 ),
154 array(
155 'id' => 'defer_js_off',
156 'tier' => self::TIER_STANDARD,
157 'module' => 'minify',
158 'values' => array( 'defer_js' => true ),
159 'label' => 'Defer JavaScript',
160 ),
161 array(
162 'id' => 'lazy_images_off',
163 'tier' => self::TIER_STANDARD,
164 'module' => 'lazy',
165 'values' => array( 'lazy_images' => true ),
166 'label' => 'Lazy-load images',
167 ),
168 // Fills width/height where it can resolve them locally. Never
169 // fetches a remote image to measure it, so an externally hosted
170 // image is left alone rather than guessed at.
171 array(
172 'id' => 'missing_dimensions_off',
173 'tier' => self::TIER_STANDARD,
174 'module' => 'lazy',
175 'values' => array( 'add_missing_dimensions' => true ),
176 'label' => 'Add missing image dimensions',
177 ),
178 // Last in the order on purpose — see sort_order().
179 array(
180 'id' => 'combine_css_off',
181 'tier' => self::TIER_STANDARD,
182 'module' => 'minify',
183 'values' => array( 'combine_css' => true ),
184 'label' => 'Combine CSS files',
185 ),
186 array(
187 'id' => 'combine_js_off',
188 'tier' => self::TIER_STANDARD,
189 'module' => 'minify',
190 'values' => array( 'combine_js' => true ),
191 'label' => 'Combine JavaScript files',
192 ),
193
194 // --- AGGRESSIVE -------------------------------------------------
195 array(
196 'id' => 'delay_js_off',
197 'tier' => self::TIER_AGGRESSIVE,
198 'module' => 'minify',
199 'values' => array( 'delay_js' => true ),
200 'label' => 'Delay JavaScript until interaction',
201 ),
202 // Flash of unstyled content when the theme has no critical CSS.
203 array(
204 'id' => 'async_css_off',
205 'tier' => self::TIER_AGGRESSIVE,
206 'module' => 'minify',
207 'values' => array( 'async_css' => true ),
208 'label' => 'Load CSS asynchronously',
209 ),
210 // Broke a live page; the failure was invisible to HTML checks.
211 array(
212 'id' => 'jquery_migrate_on',
213 'tier' => self::TIER_AGGRESSIVE,
214 'module' => 'bloat',
215 'values' => array( 'strip_jquery_migrate' => true ),
216 'label' => 'Remove jQuery Migrate',
217 ),
218 );
219 }
220
221 /**
222 * Order to attempt steps in: cheapest and safest first.
223 *
224 * The point is not speed, it is diagnosis. By the time the riskiest change
225 * is attempted, every earlier one has been applied AND verified — so if
226 * the page breaks, the change that broke it is the one just made, and
227 * reverting one setting is enough to recover.
228 *
229 * Ids not listed sort last, in catalog order, so adding a step without
230 * touching this list degrades to "attempt it late" rather than "attempt it
231 * first".
232 *
233 * @return string[]
234 */
235 private static function sort_order(): array {
236 return array(
237 'cache_disabled',
238 'gzip_off',
239 'browser_cache_off',
240 'minify_html_off',
241 'minify_css_off',
242 'dashicons_frontend',
243 'xmlrpc_on',
244 'minify_js_off',
245 'defer_js_off',
246 'lazy_images_off',
247 'missing_dimensions_off',
248 'delay_js_off',
249 'async_css_off',
250 'jquery_migrate_on',
251 // Combining runs LAST. It is the change most likely to alter how
252 // the page renders, so it is attempted against a page every other
253 // step has already been verified against.
254 'combine_css_off',
255 'combine_js_off',
256 );
257 }
258
259 /**
260 * Which tiers a run at this aggressiveness may apply.
261 *
262 * @param string $aggressiveness safe|standard|aggressive.
263 * @return string[]
264 */
265 public static function tiers_for( string $aggressiveness ): array {
266 switch ( $aggressiveness ) {
267 case self::TIER_SAFE:
268 return array( self::TIER_SAFE );
269 case self::TIER_AGGRESSIVE:
270 return array( self::TIER_SAFE, self::TIER_STANDARD, self::TIER_AGGRESSIVE );
271 case self::TIER_STANDARD:
272 default:
273 return array( self::TIER_SAFE, self::TIER_STANDARD );
274 }
275 }
276
277 /**
278 * The tier of one step id, or TIER_NEVER when it is not in the catalog.
279 *
280 * Unknown ids resolve to `never` rather than to a default tier: a setting
281 * nobody has classified must not become applyable by being forgotten.
282 *
283 * @param string $id Step id.
284 */
285 public static function tier( string $id ): string {
286 foreach ( self::catalog() as $step ) {
287 if ( $step['id'] === $id ) {
288 return $step['tier'];
289 }
290 }
291 return self::TIER_NEVER;
292 }
293
294 /**
295 * Every step in the catalog, ordered. Exposed for tests and `--dry-run`.
296 *
297 * @return array<int,array<string,mixed>>
298 */
299 public static function all_steps(): array {
300 $order = array_flip( self::sort_order() );
301 $steps = self::catalog();
302 $fallback = count( $order );
303 usort(
304 $steps,
305 static function ( $a, $b ) use ( $order, $fallback ) {
306 $ra = $order[ $a['id'] ] ?? $fallback;
307 $rb = $order[ $b['id'] ] ?? $fallback;
308 return $ra <=> $rb;
309 }
310 );
311 return $steps;
312 }
313
314 /**
315 * Build the ordered plan for a site.
316 *
317 * A setting already at its target value is NOT a step — it belongs in the
318 * report's `skipped[]`, because "we turned this on" about something that
319 * was already on is a false claim of work, and it inflates any before/after
320 * story built from the plan.
321 *
322 * @param array<string,array<string,mixed>> $current Current settings, keyed by module slug.
323 * @param string $aggressiveness safe|standard|aggressive.
324 * @return array{steps:array<int,array<string,mixed>>,skipped:array<int,array<string,string>>}
325 */
326 public static function build( array $current, string $aggressiveness = self::TIER_STANDARD ): array {
327 $allowed = self::tiers_for( $aggressiveness );
328 $steps = array();
329 $skipped = array();
330
331 foreach ( self::all_steps() as $step ) {
332 if ( ! in_array( $step['tier'], $allowed, true ) ) {
333 $skipped[] = array(
334 'id' => $step['id'],
335 'why' => sprintf(
336 /* translators: 1: tier name, 2: current aggressiveness */
337 __( 'Needs %1$s aggressiveness (run is %2$s).', 'xspeed' ),
338 $step['tier'],
339 $aggressiveness
340 ),
341 );
342 continue;
343 }
344
345 $module = $current[ $step['module'] ] ?? array();
346 if ( self::already_satisfied( $module, $step['values'] ) ) {
347 $skipped[] = array(
348 'id' => $step['id'],
349 'why' => __( 'Already enabled.', 'xspeed' ),
350 );
351 continue;
352 }
353
354 $steps[] = $step;
355 }
356
357 return array(
358 'steps' => $steps,
359 'skipped' => $skipped,
360 );
361 }
362
363 /**
364 * True when every target value is already the current value.
365 *
366 * Compared loosely on purpose: settings arrive from the options table where
367 * a stored `1` and a schema `true` mean the same thing, and a strict
368 * comparison would re-apply half the catalog on every run.
369 *
370 * @param array<string,mixed> $module Current module settings.
371 * @param array<string,mixed> $values Target values.
372 */
373 private static function already_satisfied( array $module, array $values ): bool {
374 foreach ( $values as $key => $want ) {
375 if ( ! array_key_exists( $key, $module ) ) {
376 return false;
377 }
378 if ( is_bool( $want ) ) {
379 if ( (bool) $module[ $key ] !== $want ) {
380 return false;
381 }
382 continue;
383 }
384 // phpcs:ignore WordPress.PHP.StrictComparisons.LooseComparison -- stored options are strings; 1 == true is the intent.
385 if ( $module[ $key ] != $want ) {
386 return false;
387 }
388 }
389 return true;
390 }
391 }
392