| @@ -30,16 +30,57 @@ | ||
| 30 | 30 | * |
| 31 | 31 | * So the report names what it could not fix and why. A run that changes nothing |
| 32 | 32 | * is a legitimate outcome, reported as such. |
| 33 | 33 | * |
| 34 | + * ## Why the score is measured rather than remembered | |
| 35 | + * | |
| 36 | + * The same honesty problem applies to the number itself. This read the last | |
| 37 | + * stored audit, which on a site measured a fortnight ago meant applying six | |
| 38 | + * changes and then reporting a two-week-old 77 as the outcome — a stale figure | |
| 39 | + * presented in the position a reader takes for a result. | |
| 40 | + * | |
| 41 | + * `measure_score` decides what to spend on avoiding that: | |
| 42 | + * | |
| 43 | + * - `auto` (default) — measure when the stored score is stale, and always | |
| 44 | + * after changes land, subject to the cooldown in Optimize_Diagnosis. | |
| 45 | + * - `never` — the pre-1.2.0 behaviour, for callers that must not spend a | |
| 46 | + * measurement. | |
| 47 | + * - `always` — measure even for a dry run. | |
| 48 | + * | |
| 49 | + * Every score now carries `age_seconds`, and the summary sentence names it, so | |
| 50 | + * a number that could not be refreshed is still readable as old rather than | |
| 51 | + * passing for fresh. | |
| 52 | + * | |
| 53 | + * ## One pass, and the seam for more | |
| 54 | + * | |
| 55 | + * This runs ONE pass: every step the tier allows, then stop. That is the whole | |
| 56 | + * of Free's behaviour and it is deliberate — a pass has a bounded cost, and | |
| 57 | + * chasing a target score does not. | |
| 58 | + * | |
| 59 | + * Repeating the cycle until a site reaches, say, 90 means measuring after each | |
| 60 | + * round and planning the next against whatever metric is actually weak, which | |
| 61 | + * is several measurements per site and only pays off across a fleet. That loop | |
| 62 | + * is Pro's; `xspeed_optimize_report` is where it attaches, so it never needs to | |
| 63 | + * fork this file. `rounds` in the report says how many passes produced it, and | |
| 64 | + * is 1 for everything Free does on its own. | |
| 65 | + * | |
| 34 | 66 | * @since 1.2.0 |
| 35 | 67 | */ |
| 36 | 68 | final class Optimize_Runner { |
| 37 | 69 | |
| 38 | 70 | /** |
| 71 | + * How many pages OUR OWN suggestions offer for post-run checking. | |
| 72 | + * | |
| 73 | + * Applied before `xspeed_optimize_verify_urls` runs, so a site can add | |
| 74 | + * its own risky template without ours crowding it out. Three is the point | |
| 75 | + * where a person still opens all of them. | |
| 76 | + */ | |
| 77 | + private const VERIFY_URL_LIMIT = 3; | |
| 78 | + | |
| 79 | + /** | |
| 39 | 80 | * Run the whole thing. |
| 40 | 81 | * |
| 41 | - * @param array{aggressiveness?:string,dry_run?:bool,budget_seconds?:int,url?:string} $args Options. | |
| 82 | + * @param array{aggressiveness?:string,dry_run?:bool,budget_seconds?:int,url?:string,measure_score?:string,round?:int} $args Options. | |
| 42 | 83 | * @return array<string,mixed>|\WP_Error |
| 43 | 84 | */ |
| 44 | 85 | public static function run( array $args = array() ) { |
| 45 | 86 | $aggressiveness = (string) ( $args['aggressiveness'] ?? Optimize_Plan::TIER_STANDARD ); |
| @@ -45,9 +86,58 @@ | ||
| 45 | 86 | $aggressiveness = (string) ( $args['aggressiveness'] ?? Optimize_Plan::TIER_STANDARD ); |
| 46 | 87 | $dry_run = (bool) ( $args['dry_run'] ?? false ); |
| 47 | 88 | $budget = (int) ( $args['budget_seconds'] ?? 120 ); |
| 48 | 89 | $url = (string) ( $args['url'] ?? home_url( '/' ) ); |
| 90 | + $measure = (string) ( $args['measure_score'] ?? 'auto' ); | |
| 49 | 91 | |
| 92 | + // Which round this is. A listener on xspeed_optimize_report re-enters | |
| 93 | + // run() for round 2 onward and passes this, so the filter context | |
| 94 | + // reports the true round rather than always claiming to be the first. | |
| 95 | + $round = max( 1, (int) ( $args['round'] ?? 1 ) ); | |
| 96 | + | |
| 97 | + // Arguments Free does not act on, forwarded to the seam so a listener | |
| 98 | + // can. `target_score` / `max_rounds` are the multi-round tuner's, and | |
| 99 | + // they arrive here from the MCP tool and the CLI like any other | |
| 100 | + // argument — Free's job is to carry them, not to understand them. | |
| 101 | + // | |
| 102 | + // Clamped on the way through, not passed on raw. Free does not act on | |
| 103 | + // these, but it is the only place that sees them before a listener | |
| 104 | + // does, and a tuner handed `target_score = 9000` would chase a score | |
| 105 | + // that cannot exist — burning rounds and measurements on an | |
| 106 | + // unreachable goal. A Lighthouse score is 0-100 and a round count | |
| 107 | + // below 1 is not a request. (#306 QA, minor 2) | |
| 108 | + $bounds = array( | |
| 109 | + 'target_score' => array( 0, 100 ), | |
| 110 | + 'max_rounds' => array( 1, 10 ), | |
| 111 | + ); | |
| 112 | + $passthrough = array(); | |
| 113 | + foreach ( $bounds as $key => list( $min, $max ) ) { | |
| 114 | + if ( isset( $args[ $key ] ) && is_numeric( $args[ $key ] ) ) { | |
| 115 | + $passthrough[ $key ] = max( $min, min( $max, (int) $args[ $key ] ) ); | |
| 116 | + } | |
| 117 | + } | |
| 118 | + | |
| 119 | + // Built once, passed to every seam call, so the two report paths cannot | |
| 120 | + // hand a listener different context for the same run. | |
| 121 | + $context = array_merge( | |
| 122 | + array( | |
| 123 | + 'aggressiveness' => $aggressiveness, | |
| 124 | + 'measure_score' => $measure, | |
| 125 | + 'budget_seconds' => $budget, | |
| 126 | + 'url' => $url, | |
| 127 | + 'round' => $round, | |
| 128 | + ), | |
| 129 | + $passthrough | |
| 130 | + ); | |
| 131 | + | |
| 132 | + if ( ! in_array( $measure, array( 'auto', 'never', 'always' ), true ) ) { | |
| 133 | + return new \WP_Error( | |
| 134 | + 'xspeed_optimize_measure_score', | |
| 135 | + __( 'measure_score must be auto, never or always.', 'xspeed' ), | |
| 136 | + array( 'status' => 400 ) | |
| 137 | + ); | |
| 138 | + } | |
| 139 | + | |
| 50 | 140 | if ( ! in_array( $aggressiveness, array( Optimize_Plan::TIER_SAFE, Optimize_Plan::TIER_STANDARD, Optimize_Plan::TIER_AGGRESSIVE ), true ) ) { |
| 51 | 141 | return new \WP_Error( |
| 52 | 142 | 'xspeed_optimize_aggressiveness', |
| 53 | 143 | __( 'Aggressiveness must be safe, standard or aggressive.', 'xspeed' ), |
| @@ -59,12 +149,17 @@ | ||
| 59 | 149 | $current = self::current_settings(); |
| 60 | 150 | $plan = Optimize_Plan::build( $current, $aggressiveness ); |
| 61 | 151 | |
| 62 | 152 | if ( $dry_run ) { |
| 63 | - $preview = Optimize_Diagnosis::build( $current ); | |
| 64 | - return array( | |
| 153 | + // A dry run changes nothing, so it has nothing to prove with a | |
| 154 | + // fresh number — and starting a PSI run for a preview is the | |
| 155 | + // surprise spend the cooldown exists to prevent. `always` is still | |
| 156 | + // honoured: someone explicitly asking to measure gets a | |
| 157 | + // measurement. | |
| 158 | + $preview = Optimize_Diagnosis::build( $current, self::score_for( 'always' === $measure ? 'always' : 'never' ) ); | |
| 159 | + $out = array( | |
| 65 | 160 | 'dry_run' => true, |
| 66 | - 'message' => self::summary( $preview, 0 ), | |
| 161 | + 'message' => self::summary( $preview, 0, count( $plan['steps'] ) ), | |
| 67 | 162 | 'score' => $preview['score'], |
| 68 | 163 | 'plan' => array_map( |
| 69 | 164 | static function ( $s ) { |
| 70 | 165 | return array( |
| @@ -78,8 +173,20 @@ | ||
| 78 | 173 | 'skipped' => $plan['skipped'], |
| 79 | 174 | 'next_steps' => $preview['agent_fixable'], |
| 80 | 175 | 'unfixable' => $preview['human_fixable'], |
| 81 | 176 | ); |
| 177 | + | |
| 178 | + // A preview asked to reach a target owes the same explanation a | |
| 179 | + // real run gives. The tool description tells an assistant to read | |
| 180 | + // `stopped_because` and relay the reason, so leaving it off a | |
| 181 | + // preview meant the assistant either said nothing about the target | |
| 182 | + // or invented a reason. The real-run path sets this below; a dry | |
| 183 | + // run returns before reaching it. (QA #306, issue 3) | |
| 184 | + if ( isset( $context['target_score'] ) ) { | |
| 185 | + $out['stopped_because'] = __( 'A target score was requested, but this was a preview — no changes were applied and the target was not chased.', 'xspeed' ); | |
| 186 | + } | |
| 187 | + | |
| 188 | + return $out; | |
| 82 | 189 | } |
| 83 | 190 | |
| 84 | 191 | // Nothing to do is a real answer, and a common one on a site that has |
| 85 | 192 | // already been tuned. Returning early avoids spending two benchmarks |
| @@ -87,20 +194,34 @@ | ||
| 87 | 194 | if ( array() === $plan['steps'] ) { |
| 88 | 195 | // The case that most needs a diagnosis attached. "Nothing to do" |
| 89 | 196 | // on a site scoring 50 is not an answer — it is the start of the |
| 90 | 197 | // conversation about what is actually wrong and who can fix it. |
| 91 | - $diagnosis = Optimize_Diagnosis::build( $current ); | |
| 92 | - return array( | |
| 93 | - 'before' => null, | |
| 94 | - 'applied' => array(), | |
| 95 | - 'skipped' => $plan['skipped'], | |
| 96 | - 'reverted' => array(), | |
| 97 | - 'after' => null, | |
| 98 | - 'score' => $diagnosis['score'], | |
| 99 | - 'next_steps' => $diagnosis['agent_fixable'], | |
| 100 | - 'unfixable' => $diagnosis['human_fixable'], | |
| 101 | - 'verified' => true, | |
| 102 | - 'message' => self::summary( $diagnosis, 0 ), | |
| 198 | + // The score carries this answer entirely. "Nothing to do" is only | |
| 199 | + // useful next to a number that is true NOW — beside a stale one it | |
| 200 | + // is indistinguishable from "we looked a fortnight ago". | |
| 201 | + $diagnosis = Optimize_Diagnosis::build( $current, self::score_for( $measure ) ); | |
| 202 | + | |
| 203 | + // This path fires the seam too. It is the case a multi-round tuner | |
| 204 | + // most needs to see: nothing left at THIS tier, with a score that | |
| 205 | + // says whether the target was reached. Returning early without | |
| 206 | + // filtering would hide the "already tuned and still short" state — | |
| 207 | + // the one where a listener has to decide between escalating the | |
| 208 | + // tier and stopping honestly. | |
| 209 | + return self::filter_report( | |
| 210 | + array( | |
| 211 | + 'before' => null, | |
| 212 | + 'applied' => array(), | |
| 213 | + 'skipped' => $plan['skipped'], | |
| 214 | + 'reverted' => array(), | |
| 215 | + 'after' => null, | |
| 216 | + 'score' => $diagnosis['score'], | |
| 217 | + 'next_steps' => $diagnosis['agent_fixable'], | |
| 218 | + 'unfixable' => $diagnosis['human_fixable'], | |
| 219 | + 'verified' => true, | |
| 220 | + 'message' => self::summary( $diagnosis, 0 ), | |
| 221 | + 'rounds' => $round, | |
| 222 | + ), | |
| 223 | + $context | |
| 103 | 224 | ); |
| 104 | 225 | } |
| 105 | 226 | |
| 106 | 227 | // --- 2. Measure ------------------------------------------------- |
| @@ -160,11 +281,27 @@ | ||
| 160 | 281 | |
| 161 | 282 | // Re-read settings: what is still off AFTER this run is what an |
| 162 | 283 | // aggressive run could try next, and offering a step we just applied |
| 163 | 284 | // would be nonsense. |
| 164 | - $diagnosis = Optimize_Diagnosis::build( self::current_settings() ); | |
| 285 | + // Changes landed, so the stored score now describes a site that no | |
| 286 | + // longer exists. This is the one path where staleness is guaranteed | |
| 287 | + // rather than likely, so the age check is skipped instead of asking | |
| 288 | + // whether six hours have passed. | |
| 289 | + // | |
| 290 | + // The COOLDOWN still applies, though — which is what the previous | |
| 291 | + // form got wrong. It passed `'always'`, and `'always'` means $force, | |
| 292 | + // which skips the cooldown as well: so every run that applied | |
| 293 | + // anything spent a measurement, on the default `auto`, ignoring the | |
| 294 | + // rate limit the manual promises. The nothing-to-do path was the only | |
| 295 | + // one actually held back, i.e. the case that matters least. | |
| 296 | + // A run inside the cooldown now falls back to the stored number with | |
| 297 | + // its age attached rather than billing the quota. (#306 QA issue 1) | |
| 298 | + $diagnosis = Optimize_Diagnosis::build( | |
| 299 | + self::current_settings(), | |
| 300 | + self::score_for( $measure, true ) | |
| 301 | + ); | |
| 165 | 302 | |
| 166 | - return array( | |
| 303 | + $report = array( | |
| 167 | 304 | 'before' => $before, |
| 168 | 305 | 'applied' => $result['applied'], |
| 169 | 306 | 'skipped' => array_merge( $plan['skipped'], $result['skipped'] ), |
| 170 | 307 | 'reverted' => $result['reverted'], |
| @@ -173,12 +310,272 @@ | ||
| 173 | 310 | 'next_steps' => $diagnosis['agent_fixable'], |
| 174 | 311 | 'unfixable' => $diagnosis['human_fixable'], |
| 175 | 312 | 'verified' => array() === $result['reverted'], |
| 176 | 313 | 'message' => self::summary( $diagnosis, count( $result['applied'] ) ), |
| 314 | + 'rounds' => $round, | |
| 177 | 315 | ); |
| 316 | + | |
| 317 | + // What `verified` does NOT cover, said out loud, next to the field it | |
| 318 | + // qualifies. Optimize_Verifier reads HTML in PHP: it catches a fatal, a | |
| 319 | + // truncated document, a stylesheet that vanished. It cannot execute | |
| 320 | + // JavaScript, so a page can arrive structurally perfect and still be | |
| 321 | + // broken in a browser — removing jQuery Migrate did exactly that, with | |
| 322 | + // intact HTML and `e.indexOf is not a function` at runtime. | |
| 323 | + // | |
| 324 | + // The caller usually CAN look: an AI assistant driving this has a | |
| 325 | + // browser, and a person has one by definition. Nothing was asking them | |
| 326 | + // to. These two fields are the ask, and they are only attached when a | |
| 327 | + // run actually changed something — a pass that applied nothing has | |
| 328 | + // nothing new to check. | |
| 329 | + if ( array() !== $result['applied'] ) { | |
| 330 | + $report['verify_urls'] = self::verify_urls( $url ); | |
| 331 | + $report['verify_note'] = __( 'Changes were applied and the HTML checks passed, which does not prove the page works in a browser — those checks cannot run JavaScript. Open these URLs and confirm each renders correctly with no console errors. If you cannot open them, tell the user to check them and what a problem would look like.', 'xspeed' ); | |
| 332 | + } | |
| 333 | + | |
| 334 | + return self::filter_report( $report, $context ); | |
| 178 | 335 | } |
| 179 | 336 | |
| 180 | 337 | /** |
| 338 | + * Fire the report seam. | |
| 339 | + * | |
| 340 | + * Both exit paths that produce a report go through here — the applied-run | |
| 341 | + * one and the nothing-to-do one — so a listener cannot silently miss half | |
| 342 | + * the outcomes. | |
| 343 | + * | |
| 344 | + * @param array<string,mixed> $report The finished report. | |
| 345 | + * @param array<string,mixed> $context Seam context — see the filter docblock below. | |
| 346 | + * @return array<string,mixed> | |
| 347 | + */ | |
| 348 | + private static function filter_report( array $report, array $context ): array { | |
| 349 | + /** | |
| 350 | + * Filter the finished report, allowing a listener to run further rounds. | |
| 351 | + * | |
| 352 | + * The seam exists because one pass cannot chase a target score. This | |
| 353 | + * pass applies every step the tier allows and stops; whether that got | |
| 354 | + * anywhere near 90 is unknown until it is measured, and acting on the | |
| 355 | + * answer means planning again against the metric that is actually | |
| 356 | + * weak. That loop is Pro's (FEATURES.md, Score card #9) — but it must | |
| 357 | + * not require forking this file, so it hooks here. | |
| 358 | + * | |
| 359 | + * A listener is expected to RE-ENTER this method for each additional | |
| 360 | + * round and merge the results, rather than reimplementing the apply | |
| 361 | + * loop. Everything that makes a round safe — baseline sampling, | |
| 362 | + * one-step-at-a-time application, revert-on-breakage, the budget | |
| 363 | + * check — lives in Optimizer::run() and is not worth a second | |
| 364 | + * implementation that can drift out of agreement with this one. | |
| 365 | + * | |
| 366 | + * Re-entrancy is the listener's problem to handle: unhook before | |
| 367 | + * recursing, or guard on `$context['round']`, or this filter fires | |
| 368 | + * again inside its own callback and recurses without end. | |
| 369 | + * | |
| 370 | + * Whatever a listener returns is what callers see, so it must keep the | |
| 371 | + * report's shape and its honesty — `unfixable` in particular is the | |
| 372 | + * part that stops a tuner claiming a win it did not make. | |
| 373 | + * | |
| 374 | + * @since 1.2.0 | |
| 375 | + * | |
| 376 | + * @param array<string,mixed> $report The finished report. | |
| 377 | + * @param array<string,mixed> $context { | |
| 378 | + * What a further round needs to plan and run. | |
| 379 | + * | |
| 380 | + * @type string $aggressiveness Tier this run used. | |
| 381 | + * @type string $measure_score The caller's measure_score. | |
| 382 | + * @type int $budget_seconds Per-round time budget. | |
| 383 | + * @type string $url URL sampled for verification. | |
| 384 | + * @type int $round 1-based index of the round just finished. | |
| 385 | + * @type int $target_score Optional. Score the caller asked to reach. | |
| 386 | + * Free does not act on it; it is carried | |
| 387 | + * here so a listener can. Absent unless the | |
| 388 | + * caller sent it. | |
| 389 | + * @type int $max_rounds Optional. Ceiling the caller asked for, | |
| 390 | + * same arrangement. | |
| 391 | + * } | |
| 392 | + */ | |
| 393 | + $filtered = apply_filters( | |
| 394 | + 'xspeed_optimize_report', | |
| 395 | + $report, | |
| 396 | + $context | |
| 397 | + ); | |
| 398 | + | |
| 399 | + // A listener that returns a non-array — or nothing, the easy mistake in | |
| 400 | + // a filter callback written as an action — must not turn a completed | |
| 401 | + // run into a fatal downstream. Fall back to the unfiltered report. | |
| 402 | + // | |
| 403 | + // An EMPTY array is the same mistake wearing a different hat: it is | |
| 404 | + // technically an array, so it passed this guard and blanked the whole | |
| 405 | + // report, losing what was applied on a run that genuinely changed the | |
| 406 | + // site. A listener with nothing to add returns the report it was | |
| 407 | + // given; one that returns nothing at all has failed, and the honest | |
| 408 | + // answer is our own report rather than silence. (#306 QA, minor 1) | |
| 409 | + $out = ( is_array( $filtered ) && array() !== $filtered ) ? $filtered : $report; | |
| 410 | + | |
| 411 | + // The caller asked to reach a score and nothing chased it: no listener | |
| 412 | + // is installed, so this was a single pass. Say so. The tool | |
| 413 | + // description tells an assistant to read `stopped_because` and relay | |
| 414 | + // it, and nothing ever set it — leaving the assistant to either stay | |
| 415 | + // silent about the target or invent a reason (#306 review, issue 2). | |
| 416 | + // Only filled when still absent, so a tuner's own reason always wins. | |
| 417 | + if ( isset( $context['target_score'] ) && ! isset( $out['stopped_because'] ) ) { | |
| 418 | + $out['stopped_because'] = __( 'A target score was requested, but no iterative tuner is installed, so this was a single pass and the target was not chased.', 'xspeed' ); | |
| 419 | + } | |
| 420 | + | |
| 421 | + return $out; | |
| 422 | + } | |
| 423 | + | |
| 424 | + /** | |
| 425 | + * A page exercising a third template — the shop where WooCommerce is | |
| 426 | + * active, otherwise the blog/posts archive, otherwise a category archive. | |
| 427 | + * | |
| 428 | + * Returns '' when none resolves; verify_urls() drops empties. | |
| 429 | + */ | |
| 430 | + private static function third_template_url(): string { | |
| 431 | + if ( function_exists( 'wc_get_page_id' ) ) { | |
| 432 | + $shop = wc_get_page_id( 'shop' ); | |
| 433 | + if ( $shop > 0 ) { | |
| 434 | + $link = get_permalink( (int) $shop ); | |
| 435 | + if ( is_string( $link ) && '' !== $link ) { | |
| 436 | + return $link; | |
| 437 | + } | |
| 438 | + } | |
| 439 | + } | |
| 440 | + | |
| 441 | + $posts_page = (int) get_option( 'page_for_posts' ); | |
| 442 | + if ( $posts_page > 0 ) { | |
| 443 | + $link = get_permalink( $posts_page ); | |
| 444 | + if ( is_string( $link ) && '' !== $link ) { | |
| 445 | + return $link; | |
| 446 | + } | |
| 447 | + } | |
| 448 | + | |
| 449 | + $terms = get_terms( | |
| 450 | + array( | |
| 451 | + 'taxonomy' => 'category', | |
| 452 | + 'number' => 1, | |
| 453 | + 'orderby' => 'count', | |
| 454 | + 'order' => 'DESC', | |
| 455 | + 'hide_empty' => true, | |
| 456 | + ) | |
| 457 | + ); | |
| 458 | + if ( is_array( $terms ) && ! empty( $terms[0] ) && ! is_wp_error( $terms[0] ) ) { | |
| 459 | + $link = get_term_link( $terms[0] ); | |
| 460 | + if ( is_string( $link ) && '' !== $link ) { | |
| 461 | + return $link; | |
| 462 | + } | |
| 463 | + } | |
| 464 | + | |
| 465 | + return ''; | |
| 466 | + } | |
| 467 | + | |
| 468 | + /** | |
| 469 | + * A short list of pages worth looking at after changes land. | |
| 470 | + * | |
| 471 | + * Verification sampled ONE url — the home page — because that is enough to | |
| 472 | + * catch a fatal. It is not enough to catch a broken template: combining CSS | |
| 473 | + * or deferring JS can leave the front page perfect and wreck a single post, | |
| 474 | + * an archive, or a shop page, because those load handles the home page | |
| 475 | + * never enqueued. | |
| 476 | + * | |
| 477 | + * So this returns the sampled page plus a couple of pages that exercise | |
| 478 | + * DIFFERENT templates. Kept to three: a list long enough to feel like | |
| 479 | + * homework gets skipped, and the point is that someone actually looks. | |
| 480 | + * | |
| 481 | + * @param string $sampled The URL verification already sampled. | |
| 482 | + * @return array<int,string> | |
| 483 | + */ | |
| 484 | + private static function verify_urls( string $sampled ): array { | |
| 485 | + $urls = array( $sampled ); | |
| 486 | + | |
| 487 | + // A single post exercises the post template and its assets, which is | |
| 488 | + // where combine/defer breakage usually shows first. | |
| 489 | + $posts = get_posts( | |
| 490 | + array( | |
| 491 | + 'numberposts' => 1, | |
| 492 | + 'post_status' => 'publish', | |
| 493 | + 'suppress_filters' => false, | |
| 494 | + 'fields' => 'ids', | |
| 495 | + ) | |
| 496 | + ); | |
| 497 | + if ( ! empty( $posts ) ) { | |
| 498 | + $link = get_permalink( (int) $posts[0] ); | |
| 499 | + if ( is_string( $link ) && '' !== $link ) { | |
| 500 | + $urls[] = $link; | |
| 501 | + } | |
| 502 | + } | |
| 503 | + | |
| 504 | + // A third template, and the one most likely to break differently: a | |
| 505 | + // shop page loads WooCommerce's own handles, an archive loads the | |
| 506 | + // theme's list template. Without this the list was always exactly two | |
| 507 | + // — the docblock above promised a spread and named shop pages | |
| 508 | + // specifically, and a WooCommerce site never saw one (#306 review, | |
| 509 | + // issue 4). | |
| 510 | + $urls[] = self::third_template_url(); | |
| 511 | + | |
| 512 | + // Cap OUR OWN suggestions before filtering, not the filtered result. | |
| 513 | + // The cap ran last, so on a site where three defaults already resolve | |
| 514 | + // — any WooCommerce site — everything a filter added landed past the | |
| 515 | + // limit and was silently dropped. The documented example is a shop's | |
| 516 | + // checkout page: the template most likely to break when scripts are | |
| 517 | + // combined, and the one the cap threw away. (#306 QA issue 4) | |
| 518 | + // | |
| 519 | + // Cleaned BEFORE capping, so the limit counts real, distinct pages. | |
| 520 | + // third_template_url() returns '' when nothing resolves, and the | |
| 521 | + // sampled URL can equal the shop or posts page — capping the raw list | |
| 522 | + // let a placeholder or a duplicate burn a slot that nothing refills. | |
| 523 | + $urls = array_slice( array_values( array_unique( array_filter( $urls ) ) ), 0, self::VERIFY_URL_LIMIT ); | |
| 524 | + | |
| 525 | + /** | |
| 526 | + * Filter the pages an optimize run asks the caller to check. | |
| 527 | + * | |
| 528 | + * A site whose risky template is a checkout, a login, or a builder | |
| 529 | + * landing page knows that better than this does. | |
| 530 | + * | |
| 531 | + * Receives our suggestions already capped, and whatever it returns is | |
| 532 | + * what the caller is asked to check — the filter is the site owner's | |
| 533 | + * final say, so it is not re-capped afterwards. Return a short list: | |
| 534 | + * the point is pages someone will actually open. | |
| 535 | + * | |
| 536 | + * @since 1.2.0 | |
| 537 | + * | |
| 538 | + * @param array<int,string> $urls Suggested URLs, already capped. | |
| 539 | + * @param string $sampled The URL verification sampled. | |
| 540 | + */ | |
| 541 | + $urls = apply_filters( 'xspeed_optimize_verify_urls', $urls, $sampled ); | |
| 542 | + | |
| 543 | + $clean = array(); | |
| 544 | + foreach ( (array) $urls as $u ) { | |
| 545 | + $u = esc_url_raw( (string) $u ); | |
| 546 | + if ( '' !== $u && ! in_array( $u, $clean, true ) ) { | |
| 547 | + $clean[] = $u; | |
| 548 | + } | |
| 549 | + } | |
| 550 | + | |
| 551 | + return $clean; | |
| 552 | + } | |
| 553 | + | |
| 554 | + /** | |
| 555 | + * The score to diagnose against, honouring the caller's measure_score. | |
| 556 | + * | |
| 557 | + * `never` is the pre-1.2.0 behaviour and stays available for callers that | |
| 558 | + * genuinely must not spend a measurement. `auto` measures only when the | |
| 559 | + * stored score is stale AND the cooldown allows it, which is what makes a | |
| 560 | + * post-run score mean what a reader assumes it means. | |
| 561 | + * | |
| 562 | + * @param string $measure One of auto|never|always. | |
| 563 | + * @param bool $assume_stale Treat the stored score as stale regardless | |
| 564 | + * of its age — the post-apply path, where | |
| 565 | + * changes just landed. Does NOT spend the | |
| 566 | + * cooldown: only an explicit `always` does | |
| 567 | + * that. (#306 QA issue 1) | |
| 568 | + * @return array<string,mixed>|null | |
| 569 | + */ | |
| 570 | + private static function score_for( string $measure, bool $assume_stale = false ): ?array { | |
| 571 | + if ( 'never' === $measure ) { | |
| 572 | + return Optimize_Diagnosis::latest_score(); | |
| 573 | + } | |
| 574 | + return Optimize_Diagnosis::measure_fresh( 'always' === $measure, $assume_stale ); | |
| 575 | + } | |
| 576 | + | |
| 577 | + /** | |
| 181 | 578 | * One sentence the assistant can lead with. |
| 182 | 579 | * |
| 183 | 580 | * Written so the honest outcomes read as outcomes rather than failures. A |
| 184 | 581 | * site where nothing was left to do is not a disappointing result, but |
| @@ -186,10 +583,13 @@ | ||
| 186 | 583 | * that alone will either apologise or invent a win. |
| 187 | 584 | * |
| 188 | 585 | * @param array<string,mixed> $diagnosis From Optimize_Diagnosis::build(). |
| 189 | 586 | * @param int $applied How many changes landed. |
| 587 | + * @param int $planned How many changes are WAITING — | |
| 588 | + * non-zero only on a preview, where | |
| 589 | + * nothing is applied by definition. | |
| 190 | 590 | */ |
| 191 | - private static function summary( array $diagnosis, int $applied ): string { | |
| 591 | + private static function summary( array $diagnosis, int $applied, int $planned = 0 ): string { | |
| 192 | 592 | $score = $diagnosis['score']['score'] ?? null; |
| 193 | 593 | $next = count( $diagnosis['agent_fixable'] ); |
| 194 | 594 | $human = count( $diagnosis['human_fixable'] ); |
| 195 | 595 | |
| @@ -200,17 +600,54 @@ | ||
| 200 | 600 | /* translators: %d: number of settings changed */ |
| 201 | 601 | _n( 'Applied %d change.', 'Applied %d changes.', $applied, 'xspeed' ), |
| 202 | 602 | $applied |
| 203 | 603 | ); |
| 604 | + } elseif ( $planned > 0 ) { | |
| 605 | + // A preview applies nothing BY DEFINITION, so "nothing was | |
| 606 | + // applied" must not be read as "nothing needed applying". This | |
| 607 | + // sentence is what an assistant relays to the owner, and it | |
| 608 | + // previously opened with "everything is already on" directly above | |
| 609 | + // a list of ten changes it would make — so the assistant reported | |
| 610 | + // a fully optimised site while the work sat waiting. | |
| 611 | + // (#306 QA issue 3) | |
| 612 | + $parts[] = sprintf( | |
| 613 | + /* translators: %d: number of changes a real run would make */ | |
| 614 | + _n( | |
| 615 | + 'Preview only — %d change would be applied.', | |
| 616 | + 'Preview only — %d changes would be applied.', | |
| 617 | + $planned, | |
| 618 | + 'xspeed' | |
| 619 | + ), | |
| 620 | + $planned | |
| 621 | + ); | |
| 204 | 622 | } else { |
| 205 | 623 | $parts[] = __( 'Everything that can be turned on safely is already on.', 'xspeed' ); |
| 206 | 624 | } |
| 207 | 625 | |
| 208 | 626 | if ( null !== $score ) { |
| 627 | + // The age is not a footnote. This sentence is what an assistant | |
| 628 | + // reads back to the user, and "score: 77" after a run that just | |
| 629 | + // finished says the run produced it. Naming when it was measured | |
| 630 | + // is the difference between a report and a claim. | |
| 631 | + $age = $diagnosis['score']['age_seconds'] ?? null; | |
| 632 | + | |
| 633 | + if ( null === $age ) { | |
| 634 | + $when = __( 'date unknown', 'xspeed' ); | |
| 635 | + } elseif ( $age < 5 * MINUTE_IN_SECONDS ) { | |
| 636 | + $when = __( 'measured just now', 'xspeed' ); | |
| 637 | + } else { | |
| 638 | + $when = sprintf( | |
| 639 | + /* translators: %s: human-readable duration, e.g. "2 hours" */ | |
| 640 | + __( 'measured %s ago', 'xspeed' ), | |
| 641 | + human_time_diff( time() - $age, time() ) | |
| 642 | + ); | |
| 643 | + } | |
| 644 | + | |
| 209 | 645 | $parts[] = sprintf( |
| 210 | - /* translators: %d: last recorded performance score */ | |
| 211 | - __( 'Last recorded score: %d.', 'xspeed' ), | |
| 212 | - (int) $score | |
| 646 | + /* translators: 1: performance score, 2: when it was measured */ | |
| 647 | + __( 'Score: %1$d (%2$s).', 'xspeed' ), | |
| 648 | + (int) $score, | |
| 649 | + $when | |
| 213 | 650 | ); |
| 214 | 651 | } |
| 215 | 652 | |
| 216 | 653 | if ( $next > 0 ) { |