| @@ -113,12 +113,38 @@ | ||
| 113 | 113 | |
| 114 | 114 | $original = $buffer; |
| 115 | 115 | |
| 116 | 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 | + | |
| 117 | 143 | // 1. Detect + localise. The consolidator is transactional: it only |
| 118 | 144 | // rewrites the page when every font it needs is already hosted |
| 119 | 145 | // locally; otherwise it returns the page untouched and flags a warm. |
| 120 | - $buffer = $this->pipeline->process( $buffer, $this->force ); | |
| 146 | + $buffer = $this->pipeline->process( $buffer, $this->force, $scope_out, $measured ); | |
| 121 | 147 | |
| 122 | 148 | // Did we actually commit a localization this run (consolidated sheet |
| 123 | 149 | // and/or an in-place local stylesheet rewrite)? |
| 124 | 150 | $localized = Consolidator::localized(); |
| @@ -132,9 +158,9 @@ | ||
| 132 | 158 | $buffer = $stripped['html']; |
| 133 | 159 | } |
| 134 | 160 | |
| 135 | 161 | // 3. Inject preloads → consolidated stylesheet → metric fallbacks. |
| 136 | - $head = $this->build_head(); | |
| 162 | + $head = $this->build_head( $route, $device, $fold_faces ); | |
| 137 | 163 | |
| 138 | 164 | if ( '' !== $head ) { |
| 139 | 165 | $buffer = $this->html->before_head_close( $buffer, $head ); |
| 140 | 166 | } |
| @@ -139,9 +165,9 @@ | ||
| 139 | 165 | $buffer = $this->html->before_head_close( $buffer, $head ); |
| 140 | 166 | } |
| 141 | 167 | |
| 142 | 168 | // 4. Log what we hosted this request (origin=buffer). |
| 143 | - $this->log_usage(); | |
| 169 | + $this->log_usage( $route ); | |
| 144 | 170 | } |
| 145 | 171 | |
| 146 | 172 | // Fail-safe: never hand back an empty document. |
| 147 | 173 | if ( '' === $buffer ) { |
| @@ -174,17 +200,24 @@ | ||
| 174 | 200 | * requests. No wp-cron involved. |
| 175 | 201 | */ |
| 176 | 202 | private function schedule_warm(): void { |
| 177 | 203 | $url = home_url( add_query_arg( array() ) ); |
| 178 | - $lock = 'easyfonts_warm_' . md5( $url ); | |
| 204 | + $lock = 'easyfonts_warm_' . md5( UsageTracker::route_for( $url ) ); | |
| 179 | 205 | |
| 180 | - // One in-flight warm per URL at a time. | |
| 206 | + // One in-flight warm per route at a time. | |
| 181 | 207 | if ( get_transient( $lock ) ) { |
| 182 | 208 | return; |
| 183 | 209 | } |
| 184 | 210 | |
| 185 | - set_transient( $lock, 1, 60 ); | |
| 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 ); | |
| 186 | 215 | |
| 216 | + if ( get_transient( $lock ) !== $claim ) { | |
| 217 | + return; | |
| 218 | + } | |
| 219 | + | |
| 187 | 220 | $target = add_query_arg( |
| 188 | 221 | array( |
| 189 | 222 | 'easyfonts_probe' => Settings::warm_key(), |
| 190 | 223 | 'efbust' => (string) time(), |
| @@ -207,16 +240,20 @@ | ||
| 207 | 240 | * Build the ordered head block: preloads first (so the browser fetches the |
| 208 | 241 | * hero fonts before it even parses the stylesheet), then the consolidated |
| 209 | 242 | * stylesheet, then the metric-matched fallback faces. |
| 210 | 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. | |
| 211 | 247 | * @return string |
| 212 | 248 | */ |
| 213 | - private function build_head(): string { | |
| 249 | + private function build_head( string $route = '', string $device = 'any', array $fold_faces = array() ): string { | |
| 214 | 250 | $out = ''; |
| 215 | 251 | $stylesheet = Consolidator::stylesheet_url(); |
| 216 | 252 | |
| 217 | - // Preloads — above the stylesheet, deduped inside SmartPreload. | |
| 218 | - $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 ); | |
| 219 | 256 | |
| 220 | 257 | // The single consolidated stylesheet — either inlined (non-render- |
| 221 | 258 | // blocking: no extra request) or as a normal <link>. |
| 222 | 259 | if ( '' !== $stylesheet ) { |
| @@ -225,13 +262,26 @@ | ||
| 225 | 262 | if ( Settings::get( 'inline_css', 0 ) ) { |
| 226 | 263 | $file = Consolidator::stylesheet_file(); |
| 227 | 264 | |
| 228 | 265 | if ( '' !== $file ) { |
| 229 | - $storage = new Storage(); | |
| 230 | - $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 ); | |
| 231 | 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 | + | |
| 232 | 282 | if ( '' !== $css ) { |
| 233 | - $out .= '<style id="easyfonts-fonts">' . $this->minify_css( $css ) . '</style>' . "\n"; | |
| 283 | + $out .= '<style id="easyfonts-fonts">' . $css . '</style>' . "\n"; | |
| 234 | 284 | $inlined = true; |
| 235 | 285 | } |
| 236 | 286 | } |
| 237 | 287 | } |
| @@ -258,11 +308,30 @@ | ||
| 258 | 308 | return $out; |
| 259 | 309 | } |
| 260 | 310 | |
| 261 | 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 | + /** | |
| 262 | 329 | * Persist hosted-font observations for this URL. |
| 330 | + * | |
| 331 | + * @param string $route Canonical route (path) for this page. | |
| 263 | 332 | */ |
| 264 | - private function log_usage(): void { | |
| 333 | + private function log_usage( string $route = '' ): void { | |
| 265 | 334 | $touched = Consolidator::touched(); |
| 266 | 335 | |
| 267 | 336 | if ( empty( $touched ) ) { |
| 268 | 337 | return; |
| @@ -267,8 +336,35 @@ | ||
| 267 | 336 | if ( empty( $touched ) ) { |
| 268 | 337 | return; |
| 269 | 338 | } |
| 270 | 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 | + | |
| 271 | 367 | $records = array(); |
| 272 | 368 | |
| 273 | 369 | foreach ( $touched as $t ) { |
| 274 | 370 | $records[] = array( |
| @@ -279,10 +375,9 @@ | ||
| 279 | 375 | 'rendered' => 0, |
| 280 | 376 | ); |
| 281 | 377 | } |
| 282 | 378 | |
| 283 | - $url = home_url( add_query_arg( array() ) ); | |
| 284 | - ( new UsageTracker() )->record( $url, (int) get_queried_object_id(), $records ); | |
| 379 | + ( new UsageTracker() )->record( $route, (int) get_queried_object_id(), $records ); | |
| 285 | 380 | } |
| 286 | 381 | |
| 287 | 382 | /** |
| 288 | 383 | * Minify CSS for inlining: strip comments and collapse whitespace, while |
| @@ -422,16 +517,40 @@ | ||
| 422 | 517 | if ( isset( $_GET['PageSpeed'] ) && 'off' === $_GET['PageSpeed'] ) { // phpcs:ignore WordPress.Security.NonceVerification |
| 423 | 518 | return false; |
| 424 | 519 | } |
| 425 | 520 | |
| 426 | - // 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". | |
| 427 | 528 | $excluded = (array) Settings::get( 'excluded_urls', array() ); |
| 428 | 529 | $path = (string) wp_parse_url( home_url( add_query_arg( array() ) ), PHP_URL_PATH ); |
| 530 | + $path = '' === $path ? '/' : $path; | |
| 429 | 531 | |
| 430 | 532 | foreach ( $excluded as $ex ) { |
| 431 | 533 | $ex = trim( (string) $ex ); |
| 432 | 534 | |
| 433 | - 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, '/' ) ) { | |
| 549 | + return false; | |
| 550 | + } | |
| 551 | + | |
| 552 | + if ( 0 === strpos( $path, rtrim( $norm, '/' ) . '/' ) ) { | |
| 434 | 553 | return false; |
| 435 | 554 | } |
| 436 | 555 | } |
| 437 | 556 | |