| @@ -14,8 +14,9 @@ | ||
| 14 | 14 | |
| 15 | 15 | use EasyFonts\Detect\Consolidator; |
| 16 | 16 | use EasyFonts\Detect\Pipeline; |
| 17 | 17 | use EasyFonts\Detect\Providers; |
| 18 | +use EasyFonts\Fonts\Downloader; | |
| 18 | 19 | use EasyFonts\Fonts\Storage; |
| 19 | 20 | use EasyFonts\Fonts\UsageTracker; |
| 20 | 21 | use EasyFonts\Settings; |
| 21 | 22 | |
| @@ -90,8 +91,13 @@ | ||
| 90 | 91 | $this->html = new HtmlProcessor(); |
| 91 | 92 | $this->pipeline = new Pipeline(); |
| 92 | 93 | $this->force = $this->is_probe(); |
| 93 | 94 | |
| 95 | + // Only warm (probe) requests may download font binaries. A normal visitor | |
| 96 | + // render serves cached fonts only and never blocks on remote downloads — | |
| 97 | + // this is the core fix for the output-buffer stall / white screen. | |
| 98 | + Downloader::set_allow_fonts( $this->force ); | |
| 99 | + | |
| 94 | 100 | ob_start( array( $this, 'process' ) ); |
| 95 | 101 | } |
| 96 | 102 | |
| 97 | 103 | /** |
| @@ -104,31 +110,131 @@ | ||
| 104 | 110 | if ( '' === trim( $buffer ) || ! $this->looks_like_html( $buffer ) ) { |
| 105 | 111 | return $buffer; |
| 106 | 112 | } |
| 107 | 113 | |
| 108 | - // 1. Detect + localise (pipeline; force-all in probe mode). The | |
| 109 | - // consolidated stylesheet is recorded but NOT injected yet, so we can | |
| 110 | - // place preloads above it. | |
| 111 | - $buffer = $this->pipeline->process( $buffer, $this->force ); | |
| 114 | + $original = $buffer; | |
| 112 | 115 | |
| 113 | - // 2. Strip now-useless Google resource hints. | |
| 114 | - if ( Settings::get( 'strip_hints', 1 ) ) { | |
| 115 | - $stripped = $this->html->strip_font_hints( $buffer, Providers::hint_hosts() ); | |
| 116 | - $buffer = $stripped['html']; | |
| 116 | + try { | |
| 117 | + // Resolve the page identity once (canonical route + device). | |
| 118 | + $route = UsageTracker::current_route(); | |
| 119 | + $device = wp_is_mobile() ? 'mobile' : 'desktop'; | |
| 120 | + | |
| 121 | + // Page scope: the families a beacon has seen actually used on this | |
| 122 | + // route. A normal render is scoped to those (so a globally-declared | |
| 123 | + // font that isn't used here is left off the page); a warm/probe is | |
| 124 | + // NOT scoped, so it downloads every declared font and keeps the | |
| 125 | + // shared cache complete for the routes that do use them. With no | |
| 126 | + // beacon data yet, scoping is off and every declared font loads — | |
| 127 | + // so the first visit still self-hosts everything, and later visits | |
| 128 | + // trim down once the page has been measured. | |
| 129 | + // Page scope + preload, from one per-route query. We scope out only | |
| 130 | + // fonts a beacon CONFIRMED are loaded-but-unused here — never a font | |
| 131 | + // that simply hasn't been measured yet (so a just-added/replaced | |
| 132 | + // Customizer font loads immediately, then gets judged by the beacon). | |
| 133 | + // A warm/probe is never scoped, so it downloads every declared font | |
| 134 | + // and keeps the shared cache complete. | |
| 135 | + $tracker = new UsageTracker(); | |
| 136 | + $profile = $this->force | |
| 137 | + ? array( 'scope_out' => array(), 'fold_faces' => array(), 'measured' => false ) | |
| 138 | + : $tracker->route_render_profile( $route ); | |
| 139 | + $scope_out = $profile['scope_out']; | |
| 140 | + $fold_faces = $profile['fold_faces']; | |
| 141 | + $measured = ! empty( $profile['measured'] ); | |
| 142 | + | |
| 143 | + // 1. Detect + localise. The consolidator is transactional: it only | |
| 144 | + // rewrites the page when every font it needs is already hosted | |
| 145 | + // locally; otherwise it returns the page untouched and flags a warm. | |
| 146 | + $buffer = $this->pipeline->process( $buffer, $this->force, $scope_out, $measured ); | |
| 147 | + | |
| 148 | + // Did we actually commit a localization this run (consolidated sheet | |
| 149 | + // and/or an in-place local stylesheet rewrite)? | |
| 150 | + $localized = Consolidator::localized(); | |
| 151 | + | |
| 152 | + if ( $localized ) { | |
| 153 | + // 2. Strip now-useless Google resource hints (only when we've | |
| 154 | + // actually localised — otherwise the page still uses the | |
| 155 | + // provider and needs its preconnect/dns-prefetch hints). | |
| 156 | + if ( Settings::get( 'strip_hints', 1 ) ) { | |
| 157 | + $stripped = $this->html->strip_font_hints( $buffer, Providers::hint_hosts() ); | |
| 158 | + $buffer = $stripped['html']; | |
| 159 | + } | |
| 160 | + | |
| 161 | + // 3. Inject preloads → consolidated stylesheet → metric fallbacks. | |
| 162 | + $head = $this->build_head( $route, $device, $fold_faces ); | |
| 163 | + | |
| 164 | + if ( '' !== $head ) { | |
| 165 | + $buffer = $this->html->before_head_close( $buffer, $head ); | |
| 166 | + } | |
| 167 | + | |
| 168 | + // 4. Log what we hosted this request (origin=buffer). | |
| 169 | + $this->log_usage( $route ); | |
| 170 | + } | |
| 171 | + | |
| 172 | + // Fail-safe: never hand back an empty document. | |
| 173 | + if ( '' === $buffer ) { | |
| 174 | + $buffer = $original; | |
| 175 | + } | |
| 176 | + } catch ( \Throwable $e ) { | |
| 177 | + // Whatever went wrong, never white-screen the site: return the page | |
| 178 | + // exactly as it came in. | |
| 179 | + if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { | |
| 180 | + error_log( 'EasyFonts: output buffer failed — ' . $e->getMessage() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions | |
| 181 | + } | |
| 182 | + | |
| 183 | + $buffer = $original; | |
| 117 | 184 | } |
| 118 | 185 | |
| 119 | - // 3. Inject the head block in the right order: | |
| 120 | - // preloads → consolidated stylesheet → metric fallbacks. | |
| 121 | - $head = $this->build_head(); | |
| 186 | + // If the page needs fonts that aren't cached yet, warm the cache in the | |
| 187 | + // background via a non-blocking loopback (no wp-cron). Never from a warm | |
| 188 | + // request itself (avoids loops). | |
| 189 | + if ( ! $this->force && Consolidator::needs_warm() ) { | |
| 190 | + $this->schedule_warm(); | |
| 191 | + } | |
| 122 | 192 | |
| 123 | - if ( '' !== $head ) { | |
| 124 | - $buffer = $this->html->before_head_close( $buffer, $head ); | |
| 193 | + return $buffer; | |
| 194 | + } | |
| 195 | + | |
| 196 | + /** | |
| 197 | + * Spawn a single non-blocking loopback request that warms the cache for the | |
| 198 | + * current URL (downloads the missing fonts off the visitor's render path). | |
| 199 | + * Rate-limited per URL so a burst of traffic can't fan out into many self | |
| 200 | + * requests. No wp-cron involved. | |
| 201 | + */ | |
| 202 | + private function schedule_warm(): void { | |
| 203 | + $url = home_url( add_query_arg( array() ) ); | |
| 204 | + $lock = 'easyfonts_warm_' . md5( UsageTracker::route_for( $url ) ); | |
| 205 | + | |
| 206 | + // One in-flight warm per route at a time. | |
| 207 | + if ( get_transient( $lock ) ) { | |
| 208 | + return; | |
| 125 | 209 | } |
| 126 | 210 | |
| 127 | - // 4. Log what we hosted this request (origin=buffer). | |
| 128 | - $this->log_usage(); | |
| 211 | + // Claim with a per-request token and re-read: if two requests raced | |
| 212 | + // through the check above, only the one whose token survived proceeds. | |
| 213 | + $claim = wp_generate_password( 8, false, false ); | |
| 214 | + set_transient( $lock, $claim, 60 ); | |
| 129 | 215 | |
| 130 | - return $buffer; | |
| 216 | + if ( get_transient( $lock ) !== $claim ) { | |
| 217 | + return; | |
| 218 | + } | |
| 219 | + | |
| 220 | + $target = add_query_arg( | |
| 221 | + array( | |
| 222 | + 'easyfonts_probe' => Settings::warm_key(), | |
| 223 | + 'efbust' => (string) time(), | |
| 224 | + ), | |
| 225 | + $url | |
| 226 | + ); | |
| 227 | + | |
| 228 | + wp_remote_get( | |
| 229 | + $target, | |
| 230 | + array( | |
| 231 | + 'blocking' => false, | |
| 232 | + 'timeout' => 0.01, | |
| 233 | + 'sslverify' => (bool) apply_filters( 'easyfonts_loopback_sslverify', true ), | |
| 234 | + 'headers' => array( 'X-EasyFonts-Warm' => '1' ), | |
| 235 | + ) | |
| 236 | + ); | |
| 131 | 237 | } |
| 132 | 238 | |
| 133 | 239 | /** |
| 134 | 240 | * Build the ordered head block: preloads first (so the browser fetches the |
| @@ -134,16 +240,20 @@ | ||
| 134 | 240 | * Build the ordered head block: preloads first (so the browser fetches the |
| 135 | 241 | * hero fonts before it even parses the stylesheet), then the consolidated |
| 136 | 242 | * stylesheet, then the metric-matched fallback faces. |
| 137 | 243 | * |
| 244 | + * @param string $route Canonical route for this page. | |
| 245 | + * @param string $device Device ('mobile'|'desktop'). | |
| 246 | + * @param array<string,array<string,bool>> $fold_faces Above-the-fold faces per family rendered on this route. | |
| 138 | 247 | * @return string |
| 139 | 248 | */ |
| 140 | - private function build_head(): string { | |
| 249 | + private function build_head( string $route = '', string $device = 'any', array $fold_faces = array() ): string { | |
| 141 | 250 | $out = ''; |
| 142 | 251 | $stylesheet = Consolidator::stylesheet_url(); |
| 143 | 252 | |
| 144 | - // Preloads — above the stylesheet, deduped inside SmartPreload. | |
| 145 | - $out .= ( new SmartPreload() )->build(); | |
| 253 | + // Preloads — above the stylesheet, deduped inside SmartPreload, scoped | |
| 254 | + // to the fonts actually emitted on this page (Consolidator::touched). | |
| 255 | + $out .= ( new SmartPreload() )->build( $route, $device, $this->present_families(), $fold_faces ); | |
| 146 | 256 | |
| 147 | 257 | // The single consolidated stylesheet — either inlined (non-render- |
| 148 | 258 | // blocking: no extra request) or as a normal <link>. |
| 149 | 259 | if ( '' !== $stylesheet ) { |
| @@ -152,13 +262,26 @@ | ||
| 152 | 262 | if ( Settings::get( 'inline_css', 0 ) ) { |
| 153 | 263 | $file = Consolidator::stylesheet_file(); |
| 154 | 264 | |
| 155 | 265 | if ( '' !== $file ) { |
| 156 | - $storage = new Storage(); | |
| 157 | - $css = $storage->exists( $file ) ? (string) @file_get_contents( $storage->path( $file ) ) : ''; // phpcs:ignore WordPress.WP.AlternativeFunctions | |
| 266 | + // The stylesheet content is deterministic for its filename | |
| 267 | + // (content-hash keyed), so cache the minified result instead | |
| 268 | + // of re-reading + re-minifying the file on every request. | |
| 269 | + $cache_key = 'easyfonts_inline_' . md5( $file ); | |
| 270 | + $css = get_transient( $cache_key ); | |
| 158 | 271 | |
| 272 | + if ( ! is_string( $css ) || '' === $css ) { | |
| 273 | + $storage = new Storage(); | |
| 274 | + $raw = $storage->exists( $file ) ? (string) @file_get_contents( $storage->path( $file ) ) : ''; // phpcs:ignore WordPress.WP.AlternativeFunctions | |
| 275 | + $css = '' !== $raw ? $this->minify_css( $raw ) : ''; | |
| 276 | + | |
| 277 | + if ( '' !== $css ) { | |
| 278 | + set_transient( $cache_key, $css, DAY_IN_SECONDS ); | |
| 279 | + } | |
| 280 | + } | |
| 281 | + | |
| 159 | 282 | if ( '' !== $css ) { |
| 160 | - $out .= '<style id="easyfonts-fonts">' . $this->minify_css( $css ) . '</style>' . "\n"; | |
| 283 | + $out .= '<style id="easyfonts-fonts">' . $css . '</style>' . "\n"; | |
| 161 | 284 | $inlined = true; |
| 162 | 285 | } |
| 163 | 286 | } |
| 164 | 287 | } |
| @@ -185,11 +308,30 @@ | ||
| 185 | 308 | return $out; |
| 186 | 309 | } |
| 187 | 310 | |
| 188 | 311 | /** |
| 312 | + * Lowercased set of families actually emitted on this page (from the | |
| 313 | + * consolidator's "touched" list), used to gate preload so a font is never | |
| 314 | + * preloaded on a page that doesn't use it. | |
| 315 | + * | |
| 316 | + * @return array<string,bool> | |
| 317 | + */ | |
| 318 | + private function present_families(): array { | |
| 319 | + $out = array(); | |
| 320 | + | |
| 321 | + foreach ( Consolidator::touched() as $t ) { | |
| 322 | + $out[ strtolower( (string) $t['family'] ) ] = true; | |
| 323 | + } | |
| 324 | + | |
| 325 | + return $out; | |
| 326 | + } | |
| 327 | + | |
| 328 | + /** | |
| 189 | 329 | * Persist hosted-font observations for this URL. |
| 330 | + * | |
| 331 | + * @param string $route Canonical route (path) for this page. | |
| 190 | 332 | */ |
| 191 | - private function log_usage(): void { | |
| 333 | + private function log_usage( string $route = '' ): void { | |
| 192 | 334 | $touched = Consolidator::touched(); |
| 193 | 335 | |
| 194 | 336 | if ( empty( $touched ) ) { |
| 195 | 337 | return; |
| @@ -194,8 +336,35 @@ | ||
| 194 | 336 | if ( empty( $touched ) ) { |
| 195 | 337 | return; |
| 196 | 338 | } |
| 197 | 339 | |
| 340 | + // Key usage by the canonical route (path only). This keeps the buffer | |
| 341 | + // and the browser beacon on one identity per page, and prevents volatile | |
| 342 | + // query params (the warm cache-buster efbust, the easyfonts_probe key, | |
| 343 | + // builder/tracking flags) from minting duplicate rows. | |
| 344 | + if ( '' === $route ) { | |
| 345 | + $route = UsageTracker::current_route(); | |
| 346 | + } | |
| 347 | + | |
| 348 | + // Throttle this bookkeeping write to at most once per route per window. | |
| 349 | + // These rows only record "the plugin hosted these fonts here" (for the | |
| 350 | + // usage dashboard and GC freshness); the beacon — which drives page | |
| 351 | + // scoping and preload — records separately and is NOT throttled here. A | |
| 352 | + // warm/probe render always logs so "Optimize" reflects immediately. | |
| 353 | + if ( ! $this->force ) { | |
| 354 | + $lock = 'easyfonts_logged_' . md5( $route ); | |
| 355 | + | |
| 356 | + if ( get_transient( $lock ) ) { | |
| 357 | + return; | |
| 358 | + } | |
| 359 | + | |
| 360 | + $ttl = (int) apply_filters( 'easyfonts_usage_log_throttle', HOUR_IN_SECONDS ); | |
| 361 | + | |
| 362 | + if ( $ttl > 0 ) { | |
| 363 | + set_transient( $lock, 1, $ttl ); | |
| 364 | + } | |
| 365 | + } | |
| 366 | + | |
| 198 | 367 | $records = array(); |
| 199 | 368 | |
| 200 | 369 | foreach ( $touched as $t ) { |
| 201 | 370 | $records[] = array( |
| @@ -206,10 +375,9 @@ | ||
| 206 | 375 | 'rendered' => 0, |
| 207 | 376 | ); |
| 208 | 377 | } |
| 209 | 378 | |
| 210 | - $url = home_url( add_query_arg( array() ) ); | |
| 211 | - ( new UsageTracker() )->record( $url, (int) get_queried_object_id(), $records ); | |
| 379 | + ( new UsageTracker() )->record( $route, (int) get_queried_object_id(), $records ); | |
| 212 | 380 | } |
| 213 | 381 | |
| 214 | 382 | /** |
| 215 | 383 | * Minify CSS for inlining: strip comments and collapse whitespace, while |
| @@ -219,16 +387,27 @@ | ||
| 219 | 387 | * @param string $css CSS. |
| 220 | 388 | * @return string |
| 221 | 389 | */ |
| 222 | 390 | private function minify_css( string $css ): string { |
| 391 | + $input = $css; | |
| 392 | + | |
| 223 | 393 | // Remove /* ... */ comments (incl. the generated banner). |
| 224 | - $css = (string) preg_replace( '#/\*.*?\*/#s', '', $css ); | |
| 394 | + $css = preg_replace( '#/\*.*?\*/#s', '', $css ); | |
| 395 | + if ( ! is_string( $css ) ) { | |
| 396 | + return $input; | |
| 397 | + } | |
| 225 | 398 | |
| 226 | 399 | // Collapse all runs of whitespace to a single space. |
| 227 | - $css = (string) preg_replace( '/\s+/', ' ', $css ); | |
| 400 | + $css = preg_replace( '/\s+/', ' ', $css ); | |
| 401 | + if ( ! is_string( $css ) ) { | |
| 402 | + return $input; | |
| 403 | + } | |
| 228 | 404 | |
| 229 | 405 | // Drop spaces around structural punctuation. |
| 230 | - $css = (string) preg_replace( '/\s*([{};:,>])\s*/', '$1', $css ); | |
| 406 | + $css = preg_replace( '/\s*([{};:,>])\s*/', '$1', $css ); | |
| 407 | + if ( ! is_string( $css ) ) { | |
| 408 | + return $input; | |
| 409 | + } | |
| 231 | 410 | |
| 232 | 411 | // Remove the last semicolon before a closing brace, and any leftover gaps. |
| 233 | 412 | $css = str_replace( ';}', '}', $css ); |
| 234 | 413 | |
| @@ -338,18 +517,42 @@ | ||
| 338 | 517 | if ( isset( $_GET['PageSpeed'] ) && 'off' === $_GET['PageSpeed'] ) { // phpcs:ignore WordPress.Security.NonceVerification |
| 339 | 518 | return false; |
| 340 | 519 | } |
| 341 | 520 | |
| 342 | - // Excluded URLs. | |
| 521 | + // Excluded URLs. Patterns are matched against the request PATH with | |
| 522 | + // explicit, anchored semantics (no loose substring matching, which made | |
| 523 | + // "/cart" also exclude "/cart-guide"): | |
| 524 | + // - a pattern containing "*" is a glob (fnmatch), e.g. "/shop/*" | |
| 525 | + // - otherwise it matches the exact path, or the path as a path-segment | |
| 526 | + // prefix, e.g. "/cart" matches "/cart" and "/cart/checkout" but NOT | |
| 527 | + // "/cart-guide". | |
| 343 | 528 | $excluded = (array) Settings::get( 'excluded_urls', array() ); |
| 344 | 529 | $path = (string) wp_parse_url( home_url( add_query_arg( array() ) ), PHP_URL_PATH ); |
| 530 | + $path = '' === $path ? '/' : $path; | |
| 345 | 531 | |
| 346 | 532 | foreach ( $excluded as $ex ) { |
| 347 | 533 | $ex = trim( (string) $ex ); |
| 348 | 534 | |
| 349 | - if ( '' !== $ex && false !== strpos( $path, $ex ) ) { | |
| 535 | + if ( '' === $ex ) { | |
| 536 | + continue; | |
| 537 | + } | |
| 538 | + | |
| 539 | + if ( false !== strpos( $ex, '*' ) ) { | |
| 540 | + if ( fnmatch( $ex, $path ) ) { | |
| 541 | + return false; | |
| 542 | + } | |
| 543 | + continue; | |
| 544 | + } | |
| 545 | + | |
| 546 | + $norm = '/' . ltrim( $ex, '/' ); | |
| 547 | + | |
| 548 | + if ( $path === $norm || $path === rtrim( $norm, '/' ) ) { | |
| 350 | 549 | return false; |
| 351 | 550 | } |
| 551 | + | |
| 552 | + if ( 0 === strpos( $path, rtrim( $norm, '/' ) . '/' ) ) { | |
| 553 | + return false; | |
| 554 | + } | |
| 352 | 555 | } |
| 353 | 556 | |
| 354 | 557 | return true; |
| 355 | 558 | } |
| @@ -354,14 +557,29 @@ | ||
| 354 | 557 | return true; |
| 355 | 558 | } |
| 356 | 559 | |
| 357 | 560 | /** |
| 358 | - * Admin-triggered probe: ?easyfonts_probe=1 (force a full uncached pass). | |
| 561 | + * Is this a "warm" (probe) request that may download fonts and force a full | |
| 562 | + * uncached pass? Authorised either by an admin (?easyfonts_probe=1 in the | |
| 563 | + * browser) or by the secret warm key (used by the background loopback, the | |
| 564 | + * admin "Optimize" action and WP-CLI). The key path lets an unauthenticated | |
| 565 | + * self-request be trusted without wp-cron. | |
| 359 | 566 | * |
| 360 | 567 | * @return bool |
| 361 | 568 | */ |
| 362 | 569 | private function is_probe(): bool { |
| 363 | - return isset( $_GET['easyfonts_probe'] ) && current_user_can( 'manage_options' ); // phpcs:ignore WordPress.Security.NonceVerification | |
| 570 | + if ( ! isset( $_GET['easyfonts_probe'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification | |
| 571 | + return false; | |
| 572 | + } | |
| 573 | + | |
| 574 | + if ( current_user_can( 'manage_options' ) ) { | |
| 575 | + return true; | |
| 576 | + } | |
| 577 | + | |
| 578 | + $provided = (string) wp_unslash( $_GET['easyfonts_probe'] ); // phpcs:ignore WordPress.Security.NonceVerification | |
| 579 | + $key = (string) get_option( 'easyfonts_warm_key', '' ); | |
| 580 | + | |
| 581 | + return '' !== $key && hash_equals( $key, $provided ); | |
| 364 | 582 | } |
| 365 | 583 | |
| 366 | 584 | /** |
| 367 | 585 | * Quick HTML sniff (skip JSON/feeds/fragments). |