| @@ -39,8 +39,38 @@ | ||
| 39 | 39 | public const USER_AGENT = 'xSpeed-Preloader/1.0 (+cache warmer; admin-initiated)'; |
| 40 | 40 | public const REQUEST_TIMEOUT = 8; |
| 41 | 41 | |
| 42 | 42 | /** |
| 43 | + * Default cap on NEW remote images resolved per warmed page. | |
| 44 | + * | |
| 45 | + * A crawl warms the cache; it is not a licence to hit third-party hosts | |
| 46 | + * hundreds of times for one page. | |
| 47 | + * | |
| 48 | + * The cap counts only images whose dimensions are not already known, and | |
| 49 | + * results persist between runs — so each crawl advances through a heavily | |
| 50 | + * embedded page rather than re-picking the same first N. That is what | |
| 51 | + * makes a cap safe here: without the skip it would strand everything past | |
| 52 | + * the limit permanently, because images appear in the same DOM order | |
| 53 | + * every time. | |
| 54 | + * | |
| 55 | + * 20 is a starting point, not a measurement. Sites that embed more can | |
| 56 | + * raise it via `xspeed_preloader_remote_dimension_limit`. | |
| 57 | + */ | |
| 58 | + private const REMOTE_DIMENSION_LIMIT = 20; | |
| 59 | + | |
| 60 | + /** | |
| 61 | + * How many new remote images one warmed page may resolve. | |
| 62 | + */ | |
| 63 | + private static function remote_dimension_limit(): int { | |
| 64 | + /** | |
| 65 | + * Filter the per-page cap on remote dimension lookups. | |
| 66 | + * | |
| 67 | + * @param int $limit Default 20. Values below 1 disable the lookup. | |
| 68 | + */ | |
| 69 | + return (int) apply_filters( 'xspeed_preloader_remote_dimension_limit', self::REMOTE_DIMENSION_LIMIT ); | |
| 70 | + } | |
| 71 | + | |
| 72 | + /** | |
| 43 | 73 | * Why the top-level sitemap fetch failed on this request, or '' when it |
| 44 | 74 | * succeeded. Set by fetch_sitemap_urls(), read by resolve_queue() — the |
| 45 | 75 | * reason has to survive the return of an empty array, which is exactly |
| 46 | 76 | * what it could not do before. Request-scoped; never persisted. (#142) |
| @@ -311,8 +341,99 @@ | ||
| 311 | 341 | 'error' => sprintf( 'HTTP %d', $code ), |
| 312 | 342 | 'ts' => time(), |
| 313 | 343 | ); |
| 314 | 344 | $state['errors'] = array_slice( $state['errors'], -20 ); |
| 345 | + return; | |
| 346 | + } | |
| 347 | + | |
| 348 | + self::warm_remote_dimensions( (string) wp_remote_retrieve_body( $response ) ); | |
| 349 | + } | |
| 350 | + | |
| 351 | + /** | |
| 352 | + * Resolve dimensions for externally hosted images found on a warmed page. | |
| 353 | + * | |
| 354 | + * The crawl already has the HTML in hand, so harvesting image URLs from it | |
| 355 | + * costs nothing extra — and this is the one place where paying for a | |
| 356 | + * remote lookup is free of consequence, because no visitor is waiting. | |
| 357 | + * | |
| 358 | + * An image on another domain has no local file to measure, so the front | |
| 359 | + * end skips it and the page ships without width/height — which is layout | |
| 360 | + * shift, on precisely the sites least able to fix it by hand (a CDN, a | |
| 361 | + * sister site, a shared asset host). Warming here means the NEXT render | |
| 362 | + * finds the dimensions in cache and stamps them, with the visitor paying | |
| 363 | + * nothing. | |
| 364 | + * | |
| 365 | + * Deliberately bounded per page: a crawl should not turn into a scraper | |
| 366 | + * for a page embedding hundreds of third-party images. | |
| 367 | + * | |
| 368 | + * @param string $html The warmed page's HTML. | |
| 369 | + */ | |
| 370 | + private static function warm_remote_dimensions( string $html ): void { | |
| 371 | + if ( '' === $html || ! class_exists( '\XSpeed\Lazy_Loader' ) ) { | |
| 372 | + return; | |
| 373 | + } | |
| 374 | + | |
| 375 | + $opts = Settings_Manager::get( 'lazy' ); | |
| 376 | + if ( empty( $opts['add_missing_dimensions'] ) ) { | |
| 377 | + return; | |
| 378 | + } | |
| 379 | + | |
| 380 | + // Match any <img>, not only one carrying `src`. The URL worth warming | |
| 381 | + // may live in a lazy attribute instead — which is the whole point of | |
| 382 | + // #328 — and resolvable_image_url() below is what knows where to look. | |
| 383 | + if ( ! preg_match_all( '#<img\b[^>]*>#i', $html, $m, PREG_SET_ORDER ) ) { | |
| 384 | + return; | |
| 385 | + } | |
| 386 | + | |
| 387 | + $home = wp_parse_url( home_url(), PHP_URL_HOST ); | |
| 388 | + $targets = array(); | |
| 389 | + foreach ( $m as $tag ) { | |
| 390 | + // Only tags MISSING a dimension are worth resolving — one that | |
| 391 | + // already declares both needs nothing. | |
| 392 | + // Same lookbehind as Lazy_Loader::ensure_dimensions(): a bare | |
| 393 | + // `\bwidth=` also matches `data-width=`, so a slider carrying its | |
| 394 | + // own metadata looked already-sized and was skipped from warming. | |
| 395 | + // The two must agree, or the collector skips exactly the tags the | |
| 396 | + // renderer still needs measured. (#333 review round 3, issue 2) | |
| 397 | + if ( preg_match( '#(?<![-\w])width\s*=#i', $tag[0] ) && preg_match( '#(?<![-\w])height\s*=#i', $tag[0] ) ) { | |
| 398 | + continue; | |
| 399 | + } | |
| 400 | + // Ask the same resolver the render path uses, rather than reading | |
| 401 | + // `src` directly. A slider parks a spacer in `src` and the real | |
| 402 | + // URL in `data-lazy`/`data-src`/`data-original`, so a collector | |
| 403 | + // looking only at `src` warmed the SPACER and never the image — | |
| 404 | + // leaving remotely-hosted slider images unresolvable at render | |
| 405 | + // time, the exact markup #328 is about. (#333 review round 2, | |
| 406 | + // issue 3) | |
| 407 | + // `false`: do not let the resolver settle a name-refused URL by | |
| 408 | + // MEASURING it. That is circular here — remote measurement is | |
| 409 | + // gated until warm_dimensions() sets $warming, and this collector | |
| 410 | + // is what feeds warm_dimensions(). Take the URL the tag offers and | |
| 411 | + // let the warm pass decide. (#333 review round 3, issue 3) | |
| 412 | + $src = Lazy_Loader::resolvable_image_url( $tag[0], false ); | |
| 413 | + if ( '' === $src || ! preg_match( '#^https?://#i', $src ) ) { | |
| 414 | + continue; | |
| 415 | + } | |
| 416 | + $host = wp_parse_url( $src, PHP_URL_HOST ); | |
| 417 | + if ( ! $host || $host === $home ) { | |
| 418 | + continue; // Local images already resolve from disk. | |
| 419 | + } | |
| 420 | + // Already resolved (or already known unresolvable) — looking it up | |
| 421 | + // again costs a request and teaches us nothing. Skipping it is | |
| 422 | + // also what makes the cap below advance: images appear in the same | |
| 423 | + // DOM order every crawl, so a collector that did not skip would | |
| 424 | + // re-pick the same first N for ever and never reach the rest. | |
| 425 | + if ( Lazy_Loader::dimensions_known( $src ) ) { | |
| 426 | + continue; | |
| 427 | + } | |
| 428 | + $targets[ $src ] = true; | |
| 429 | + if ( count( $targets ) >= self::remote_dimension_limit() ) { | |
| 430 | + break; | |
| 431 | + } | |
| 432 | + } | |
| 433 | + | |
| 434 | + if ( $targets ) { | |
| 435 | + Lazy_Loader::warm_dimensions( array_keys( $targets ) ); | |
| 315 | 436 | } |
| 316 | 437 | } |
| 317 | 438 | |
| 318 | 439 | private static function mark_complete( array $state ): void { |