PluginProbe
EasyFonts – Host Google Fonts Locally, Fast & Auto-Optimize, GDPR Compliant / 2.0.4
EasyFonts – Host Google Fonts Locally, Fast & Auto-Optimize, GDPR Compliant v2.0.4
2.0.4 2.0.3 2.0.2 2.0.1 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.2 1.3 2.0.0
← All changes | src/Frontend/OutputBuffer.php +136 -17 2.0.1 → 2.0.4 View file →
@@ -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