PluginProbe
EasyFonts – Host Google Fonts Locally, Fast & Auto-Optimize, GDPR Compliant / 2.0.2
EasyFonts – Host Google Fonts Locally, Fast & Auto-Optimize, GDPR Compliant v2.0.2
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
easyfonts / src / Frontend / OutputBuffer.php

OutputBuffer.php in EasyFonts – Host Google Fonts Locally, Fast & Auto-Optimize, GDPR Compliant 2.0.2, at src/Frontend/OutputBuffer.php

576 lines 17.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Output buffer — the authoritative font detector.
4 *
5 * Detection happens inside the real visitor request on the true rendered HTML
6 * (no loopback crawling, no cache mismatch). The buffer runs the detector
7 * pipeline, strips now-useless resource hints, injects metric fallbacks and
8 * smart preloads, and logs what it hosted.
9 *
10 * @package EasyFonts
11 */
12
13 namespace EasyFonts\Frontend;
14
15 use EasyFonts\Detect\Consolidator;
16 use EasyFonts\Detect\Pipeline;
17 use EasyFonts\Detect\Providers;
18 use EasyFonts\Fonts\Downloader;
19 use EasyFonts\Fonts\Storage;
20 use EasyFonts\Fonts\UsageTracker;
21 use EasyFonts\Settings;
22
23 defined( 'ABSPATH' ) || exit;
24
25 /**
26 * Buffers and processes frontend HTML.
27 */
28 class OutputBuffer {
29
30 /**
31 * Query params page builders set on their preview/edit screens.
32 *
33 * @var string[]
34 */
35 const BUILDER_PARAMS = array(
36 'elementor-preview',
37 'et_fb',
38 'et_pb_preview',
39 'fl_builder',
40 'fl_builder_preview',
41 'ct_builder',
42 'ct_inner',
43 'oxygen_iframe',
44 'tve',
45 'brizy-edit',
46 'brizy-edit-iframe',
47 'vc_action',
48 'vc_editable',
49 'vcv-action',
50 'siteorigin_panels_live_editor',
51 'op3editor',
52 'fb-edit',
53 'bricks',
54 'builder',
55 'wp-customize',
56 'customize_changeset_uuid',
57 'perfmatters',
58 );
59
60 /**
61 * @var HtmlProcessor
62 */
63 private HtmlProcessor $html;
64
65 /**
66 * @var Pipeline
67 */
68 private Pipeline $pipeline;
69
70 /**
71 * @var bool
72 */
73 private bool $force;
74
75 /**
76 * Register hooks.
77 */
78 public function boot(): void {
79 add_action( 'template_redirect', array( $this, 'start' ), 4 );
80 add_action( 'login_init', array( $this, 'start' ), 4 );
81 }
82
83 /**
84 * Start buffering if allowed.
85 */
86 public function start(): void {
87 if ( ! $this->should_run() ) {
88 return;
89 }
90
91 $this->html = new HtmlProcessor();
92 $this->pipeline = new Pipeline();
93 $this->force = $this->is_probe();
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
100 ob_start( array( $this, 'process' ) );
101 }
102
103 /**
104 * Buffer callback.
105 *
106 * @param string $buffer Full HTML.
107 * @return string
108 */
109 public function process( string $buffer ): string {
110 if ( '' === trim( $buffer ) || ! $this->looks_like_html( $buffer ) ) {
111 return $buffer;
112 }
113
114 $original = $buffer;
115
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(), 'above_fold' => array(), 'measured' => false )
138 : $tracker->route_render_profile( $route );
139 $scope_out = $profile['scope_out'];
140 $above_fold = $profile['above_fold'];
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, $above_fold );
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;
184 }
185
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 }
192
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;
209 }
210
211 set_transient( $lock, 1, 60 );
212
213 $target = add_query_arg(
214 array(
215 'easyfonts_probe' => Settings::warm_key(),
216 'efbust' => (string) time(),
217 ),
218 $url
219 );
220
221 wp_remote_get(
222 $target,
223 array(
224 'blocking' => false,
225 'timeout' => 0.01,
226 'sslverify' => (bool) apply_filters( 'easyfonts_loopback_sslverify', true ),
227 'headers' => array( 'X-EasyFonts-Warm' => '1' ),
228 )
229 );
230 }
231
232 /**
233 * Build the ordered head block: preloads first (so the browser fetches the
234 * hero fonts before it even parses the stylesheet), then the consolidated
235 * stylesheet, then the metric-matched fallback faces.
236 *
237 * @param string $route Canonical route for this page.
238 * @param string $device Device ('mobile'|'desktop').
239 * @param array<string,bool> $above_fold Lowercased families above the fold on this route.
240 * @return string
241 */
242 private function build_head( string $route = '', string $device = 'any', array $above_fold = array() ): string {
243 $out = '';
244 $stylesheet = Consolidator::stylesheet_url();
245
246 // Preloads — above the stylesheet, deduped inside SmartPreload, scoped
247 // to the fonts actually emitted on this page (Consolidator::touched).
248 $out .= ( new SmartPreload() )->build( $route, $device, $this->present_families(), $above_fold );
249
250 // The single consolidated stylesheet — either inlined (non-render-
251 // blocking: no extra request) or as a normal <link>.
252 if ( '' !== $stylesheet ) {
253 $inlined = false;
254
255 if ( Settings::get( 'inline_css', 0 ) ) {
256 $file = Consolidator::stylesheet_file();
257
258 if ( '' !== $file ) {
259 $storage = new Storage();
260 $css = $storage->exists( $file ) ? (string) @file_get_contents( $storage->path( $file ) ) : ''; // phpcs:ignore WordPress.WP.AlternativeFunctions
261
262 if ( '' !== $css ) {
263 $out .= '<style id="easyfonts-fonts">' . $this->minify_css( $css ) . '</style>' . "\n";
264 $inlined = true;
265 }
266 }
267 }
268
269 if ( ! $inlined ) {
270 $out .= sprintf(
271 '<link rel="stylesheet" id="easyfonts-fonts" href="%s" media="all">' . "\n",
272 esc_url( $stylesheet )
273 );
274 }
275 }
276
277 // Metric-matched fallback faces (zero-CLS).
278 $touched = Consolidator::touched();
279
280 if ( Settings::get( 'metric_fallbacks', 1 ) && ! empty( $touched ) ) {
281 $css = ( new MetricFallbacks() )->build_for( $touched );
282
283 if ( '' !== $css ) {
284 $out .= '<style id="easyfonts-fallbacks">' . $css . '</style>' . "\n";
285 }
286 }
287
288 return $out;
289 }
290
291 /**
292 * Lowercased set of families actually emitted on this page (from the
293 * consolidator's "touched" list), used to gate preload so a font is never
294 * preloaded on a page that doesn't use it.
295 *
296 * @return array<string,bool>
297 */
298 private function present_families(): array {
299 $out = array();
300
301 foreach ( Consolidator::touched() as $t ) {
302 $out[ strtolower( (string) $t['family'] ) ] = true;
303 }
304
305 return $out;
306 }
307
308 /**
309 * Persist hosted-font observations for this URL.
310 *
311 * @param string $route Canonical route (path) for this page.
312 */
313 private function log_usage( string $route = '' ): void {
314 $touched = Consolidator::touched();
315
316 if ( empty( $touched ) ) {
317 return;
318 }
319
320 // Key usage by the canonical route (path only). This keeps the buffer
321 // and the browser beacon on one identity per page, and prevents volatile
322 // query params (the warm cache-buster efbust, the easyfonts_probe key,
323 // builder/tracking flags) from minting duplicate rows.
324 if ( '' === $route ) {
325 $route = UsageTracker::current_route();
326 }
327
328 // Throttle this bookkeeping write to at most once per route per window.
329 // These rows only record "the plugin hosted these fonts here" (for the
330 // usage dashboard and GC freshness); the beacon — which drives page
331 // scoping and preload — records separately and is NOT throttled here. A
332 // warm/probe render always logs so "Optimize" reflects immediately.
333 if ( ! $this->force ) {
334 $lock = 'easyfonts_logged_' . md5( $route );
335
336 if ( get_transient( $lock ) ) {
337 return;
338 }
339
340 $ttl = (int) apply_filters( 'easyfonts_usage_log_throttle', HOUR_IN_SECONDS );
341
342 if ( $ttl > 0 ) {
343 set_transient( $lock, 1, $ttl );
344 }
345 }
346
347 $records = array();
348
349 foreach ( $touched as $t ) {
350 $records[] = array(
351 'family' => $t['family'],
352 'weight' => $t['weight'],
353 'style' => $t['style'],
354 'origin' => 'buffer',
355 'rendered' => 0,
356 );
357 }
358
359 ( new UsageTracker() )->record( $route, (int) get_queried_object_id(), $records );
360 }
361
362 /**
363 * Minify CSS for inlining: strip comments and collapse whitespace, while
364 * preserving values that matter (unicode-range, url(), font names). Safe and
365 * conservative — it never touches the inside of strings/urls.
366 *
367 * @param string $css CSS.
368 * @return string
369 */
370 private function minify_css( string $css ): string {
371 $input = $css;
372
373 // Remove /* ... */ comments (incl. the generated banner).
374 $css = preg_replace( '#/\*.*?\*/#s', '', $css );
375 if ( ! is_string( $css ) ) {
376 return $input;
377 }
378
379 // Collapse all runs of whitespace to a single space.
380 $css = preg_replace( '/\s+/', ' ', $css );
381 if ( ! is_string( $css ) ) {
382 return $input;
383 }
384
385 // Drop spaces around structural punctuation.
386 $css = preg_replace( '/\s*([{};:,>])\s*/', '$1', $css );
387 if ( ! is_string( $css ) ) {
388 return $input;
389 }
390
391 // Remove the last semicolon before a closing brace, and any leftover gaps.
392 $css = str_replace( ';}', '}', $css );
393
394 return trim( $css );
395 }
396
397 /**
398 * Is this request a front-end page-builder EDITOR (not the live site)?
399 * Covers the major builders' edit modes via their own API where available,
400 * plus a query-string fallback. Conservative: any hit means "don't process".
401 *
402 * @return bool
403 */
404 private static function is_builder_editor(): bool {
405 // Elementor edit/preview mode.
406 if ( did_action( 'elementor/loaded' ) && class_exists( '\Elementor\Plugin' ) ) {
407 $el = \Elementor\Plugin::instance();
408 if ( isset( $el->editor ) && method_exists( $el->editor, 'is_edit_mode' ) && $el->editor->is_edit_mode() ) {
409 return true;
410 }
411 if ( isset( $el->preview ) && method_exists( $el->preview, 'is_preview_mode' ) && $el->preview->is_preview_mode() ) {
412 return true;
413 }
414 }
415
416 // Beaver Builder active edit session.
417 if ( class_exists( '\FLBuilderModel' ) && method_exists( '\FLBuilderModel', 'is_builder_active' ) && \FLBuilderModel::is_builder_active() ) {
418 return true;
419 }
420
421 // Divi front-end builder.
422 if ( function_exists( 'et_core_is_fb_enabled' ) && et_core_is_fb_enabled() ) {
423 return true;
424 }
425
426 // Query-string fallback for builders without a stable API check here.
427 // phpcs:disable WordPress.Security.NonceVerification.Recommended
428 $flags = array( 'fl_builder', 'elementor-preview', 'et_fb', 'ct_builder', 'oxygen_iframe', 'brizy-edit', 'vcv-action', 'tve', 'bricks' );
429 foreach ( $flags as $f ) {
430 if ( isset( $_GET[ $f ] ) ) {
431 return true;
432 }
433 }
434 // phpcs:enable WordPress.Security.NonceVerification.Recommended
435
436 return false;
437 }
438
439 /**
440 * Should the buffer run for this request?
441 *
442 * @return bool
443 */
444 private function should_run(): bool {
445 if ( ! Settings::get( 'enabled', 1 ) ) {
446 return false;
447 }
448
449 // Hard environment bail-outs.
450 if ( is_admin() || wp_doing_ajax() || wp_doing_cron() || is_feed() ) {
451 return false;
452 }
453
454 if ( ( defined( 'REST_REQUEST' ) && REST_REQUEST ) || ( defined( 'XMLRPC_REQUEST' ) && XMLRPC_REQUEST ) ) {
455 return false;
456 }
457
458 // Explicit kill switch.
459 if ( isset( $_GET['easyfonts'] ) && 'off' === $_GET['easyfonts'] ) { // phpcs:ignore WordPress.Security.NonceVerification
460 return false;
461 }
462
463 // Probe mode always runs (admin only).
464 if ( $this->is_probe() ) {
465 return true;
466 }
467
468 // Builder previews / customizer.
469 foreach ( self::BUILDER_PARAMS as $param ) {
470 if ( isset( $_GET[ $param ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification
471 return false;
472 }
473 }
474
475 if ( function_exists( 'is_customize_preview' ) && is_customize_preview() ) {
476 return false;
477 }
478
479 // Front-end page-builder EDIT modes (not just previews). When a builder
480 // loads its editor on a front-end URL, we must not strip/replace fonts
481 // or the user can't see real font styling while editing.
482 if ( self::is_builder_editor() ) {
483 return false;
484 }
485
486 /**
487 * Allow themes/plugins to force-skip Easy Fonts for the current request
488 * (e.g. bespoke editors). Return true to skip.
489 *
490 * @param bool $skip
491 */
492 if ( apply_filters( 'easyfonts_skip_request', false ) ) {
493 return false;
494 }
495
496 // PageSpeed=off convention.
497 if ( isset( $_GET['PageSpeed'] ) && 'off' === $_GET['PageSpeed'] ) { // phpcs:ignore WordPress.Security.NonceVerification
498 return false;
499 }
500
501 // Excluded URLs. Patterns are matched against the request PATH with
502 // explicit, anchored semantics (no loose substring matching, which made
503 // "/cart" also exclude "/cart-guide"):
504 // - a pattern containing "*" is a glob (fnmatch), e.g. "/shop/*"
505 // - otherwise it matches the exact path, or the path as a path-segment
506 // prefix, e.g. "/cart" matches "/cart" and "/cart/checkout" but NOT
507 // "/cart-guide".
508 $excluded = (array) Settings::get( 'excluded_urls', array() );
509 $path = (string) wp_parse_url( home_url( add_query_arg( array() ) ), PHP_URL_PATH );
510 $path = '' === $path ? '/' : $path;
511
512 foreach ( $excluded as $ex ) {
513 $ex = trim( (string) $ex );
514
515 if ( '' === $ex ) {
516 continue;
517 }
518
519 if ( false !== strpos( $ex, '*' ) ) {
520 if ( fnmatch( $ex, $path ) ) {
521 return false;
522 }
523 continue;
524 }
525
526 $norm = '/' . ltrim( $ex, '/' );
527
528 if ( $path === $norm || $path === rtrim( $norm, '/' ) ) {
529 return false;
530 }
531
532 if ( 0 === strpos( $path, rtrim( $norm, '/' ) . '/' ) ) {
533 return false;
534 }
535 }
536
537 return true;
538 }
539
540 /**
541 * Is this a "warm" (probe) request that may download fonts and force a full
542 * uncached pass? Authorised either by an admin (?easyfonts_probe=1 in the
543 * browser) or by the secret warm key (used by the background loopback, the
544 * admin "Optimize" action and WP-CLI). The key path lets an unauthenticated
545 * self-request be trusted without wp-cron.
546 *
547 * @return bool
548 */
549 private function is_probe(): bool {
550 if ( ! isset( $_GET['easyfonts_probe'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification
551 return false;
552 }
553
554 if ( current_user_can( 'manage_options' ) ) {
555 return true;
556 }
557
558 $provided = (string) wp_unslash( $_GET['easyfonts_probe'] ); // phpcs:ignore WordPress.Security.NonceVerification
559 $key = (string) get_option( 'easyfonts_warm_key', '' );
560
561 return '' !== $key && hash_equals( $key, $provided );
562 }
563
564 /**
565 * Quick HTML sniff (skip JSON/feeds/fragments).
566 *
567 * @param string $buffer Buffer.
568 * @return bool
569 */
570 private function looks_like_html( string $buffer ): bool {
571 $head = ltrim( substr( $buffer, 0, 200 ) );
572
573 return ( false !== stripos( $head, '<!doctype html' ) || false !== stripos( $head, '<html' ) );
574 }
575 }
576