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 +250 -32 2.0.0 → 2.0.4 View file →
@@ -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).