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

378 lines 9.9 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\Storage;
19 use EasyFonts\Fonts\UsageTracker;
20 use EasyFonts\Settings;
21
22 defined( 'ABSPATH' ) || exit;
23
24 /**
25 * Buffers and processes frontend HTML.
26 */
27 class OutputBuffer {
28
29 /**
30 * Query params page builders set on their preview/edit screens.
31 *
32 * @var string[]
33 */
34 const BUILDER_PARAMS = array(
35 'elementor-preview',
36 'et_fb',
37 'et_pb_preview',
38 'fl_builder',
39 'fl_builder_preview',
40 'ct_builder',
41 'ct_inner',
42 'oxygen_iframe',
43 'tve',
44 'brizy-edit',
45 'brizy-edit-iframe',
46 'vc_action',
47 'vc_editable',
48 'vcv-action',
49 'siteorigin_panels_live_editor',
50 'op3editor',
51 'fb-edit',
52 'bricks',
53 'builder',
54 'wp-customize',
55 'customize_changeset_uuid',
56 'perfmatters',
57 );
58
59 /**
60 * @var HtmlProcessor
61 */
62 private HtmlProcessor $html;
63
64 /**
65 * @var Pipeline
66 */
67 private Pipeline $pipeline;
68
69 /**
70 * @var bool
71 */
72 private bool $force;
73
74 /**
75 * Register hooks.
76 */
77 public function boot(): void {
78 add_action( 'template_redirect', array( $this, 'start' ), 4 );
79 add_action( 'login_init', array( $this, 'start' ), 4 );
80 }
81
82 /**
83 * Start buffering if allowed.
84 */
85 public function start(): void {
86 if ( ! $this->should_run() ) {
87 return;
88 }
89
90 $this->html = new HtmlProcessor();
91 $this->pipeline = new Pipeline();
92 $this->force = $this->is_probe();
93
94 ob_start( array( $this, 'process' ) );
95 }
96
97 /**
98 * Buffer callback.
99 *
100 * @param string $buffer Full HTML.
101 * @return string
102 */
103 public function process( string $buffer ): string {
104 if ( '' === trim( $buffer ) || ! $this->looks_like_html( $buffer ) ) {
105 return $buffer;
106 }
107
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 );
112
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'];
117 }
118
119 // 3. Inject the head block in the right order:
120 // preloads → consolidated stylesheet → metric fallbacks.
121 $head = $this->build_head();
122
123 if ( '' !== $head ) {
124 $buffer = $this->html->before_head_close( $buffer, $head );
125 }
126
127 // 4. Log what we hosted this request (origin=buffer).
128 $this->log_usage();
129
130 return $buffer;
131 }
132
133 /**
134 * Build the ordered head block: preloads first (so the browser fetches the
135 * hero fonts before it even parses the stylesheet), then the consolidated
136 * stylesheet, then the metric-matched fallback faces.
137 *
138 * @return string
139 */
140 private function build_head(): string {
141 $out = '';
142 $stylesheet = Consolidator::stylesheet_url();
143
144 // Preloads — above the stylesheet, deduped inside SmartPreload.
145 $out .= ( new SmartPreload() )->build();
146
147 // The single consolidated stylesheet — either inlined (non-render-
148 // blocking: no extra request) or as a normal <link>.
149 if ( '' !== $stylesheet ) {
150 $inlined = false;
151
152 if ( Settings::get( 'inline_css', 0 ) ) {
153 $file = Consolidator::stylesheet_file();
154
155 if ( '' !== $file ) {
156 $storage = new Storage();
157 $css = $storage->exists( $file ) ? (string) @file_get_contents( $storage->path( $file ) ) : ''; // phpcs:ignore WordPress.WP.AlternativeFunctions
158
159 if ( '' !== $css ) {
160 $out .= '<style id="easyfonts-fonts">' . $this->minify_css( $css ) . '</style>' . "\n";
161 $inlined = true;
162 }
163 }
164 }
165
166 if ( ! $inlined ) {
167 $out .= sprintf(
168 '<link rel="stylesheet" id="easyfonts-fonts" href="%s" media="all">' . "\n",
169 esc_url( $stylesheet )
170 );
171 }
172 }
173
174 // Metric-matched fallback faces (zero-CLS).
175 $touched = Consolidator::touched();
176
177 if ( Settings::get( 'metric_fallbacks', 1 ) && ! empty( $touched ) ) {
178 $css = ( new MetricFallbacks() )->build_for( $touched );
179
180 if ( '' !== $css ) {
181 $out .= '<style id="easyfonts-fallbacks">' . $css . '</style>' . "\n";
182 }
183 }
184
185 return $out;
186 }
187
188 /**
189 * Persist hosted-font observations for this URL.
190 */
191 private function log_usage(): void {
192 $touched = Consolidator::touched();
193
194 if ( empty( $touched ) ) {
195 return;
196 }
197
198 $records = array();
199
200 foreach ( $touched as $t ) {
201 $records[] = array(
202 'family' => $t['family'],
203 'weight' => $t['weight'],
204 'style' => $t['style'],
205 'origin' => 'buffer',
206 'rendered' => 0,
207 );
208 }
209
210 $url = home_url( add_query_arg( array() ) );
211 ( new UsageTracker() )->record( $url, (int) get_queried_object_id(), $records );
212 }
213
214 /**
215 * Minify CSS for inlining: strip comments and collapse whitespace, while
216 * preserving values that matter (unicode-range, url(), font names). Safe and
217 * conservative — it never touches the inside of strings/urls.
218 *
219 * @param string $css CSS.
220 * @return string
221 */
222 private function minify_css( string $css ): string {
223 // Remove /* ... */ comments (incl. the generated banner).
224 $css = (string) preg_replace( '#/\*.*?\*/#s', '', $css );
225
226 // Collapse all runs of whitespace to a single space.
227 $css = (string) preg_replace( '/\s+/', ' ', $css );
228
229 // Drop spaces around structural punctuation.
230 $css = (string) preg_replace( '/\s*([{};:,>])\s*/', '$1', $css );
231
232 // Remove the last semicolon before a closing brace, and any leftover gaps.
233 $css = str_replace( ';}', '}', $css );
234
235 return trim( $css );
236 }
237
238 /**
239 * Is this request a front-end page-builder EDITOR (not the live site)?
240 * Covers the major builders' edit modes via their own API where available,
241 * plus a query-string fallback. Conservative: any hit means "don't process".
242 *
243 * @return bool
244 */
245 private static function is_builder_editor(): bool {
246 // Elementor edit/preview mode.
247 if ( did_action( 'elementor/loaded' ) && class_exists( '\Elementor\Plugin' ) ) {
248 $el = \Elementor\Plugin::instance();
249 if ( isset( $el->editor ) && method_exists( $el->editor, 'is_edit_mode' ) && $el->editor->is_edit_mode() ) {
250 return true;
251 }
252 if ( isset( $el->preview ) && method_exists( $el->preview, 'is_preview_mode' ) && $el->preview->is_preview_mode() ) {
253 return true;
254 }
255 }
256
257 // Beaver Builder active edit session.
258 if ( class_exists( '\FLBuilderModel' ) && method_exists( '\FLBuilderModel', 'is_builder_active' ) && \FLBuilderModel::is_builder_active() ) {
259 return true;
260 }
261
262 // Divi front-end builder.
263 if ( function_exists( 'et_core_is_fb_enabled' ) && et_core_is_fb_enabled() ) {
264 return true;
265 }
266
267 // Query-string fallback for builders without a stable API check here.
268 // phpcs:disable WordPress.Security.NonceVerification.Recommended
269 $flags = array( 'fl_builder', 'elementor-preview', 'et_fb', 'ct_builder', 'oxygen_iframe', 'brizy-edit', 'vcv-action', 'tve', 'bricks' );
270 foreach ( $flags as $f ) {
271 if ( isset( $_GET[ $f ] ) ) {
272 return true;
273 }
274 }
275 // phpcs:enable WordPress.Security.NonceVerification.Recommended
276
277 return false;
278 }
279
280 /**
281 * Should the buffer run for this request?
282 *
283 * @return bool
284 */
285 private function should_run(): bool {
286 if ( ! Settings::get( 'enabled', 1 ) ) {
287 return false;
288 }
289
290 // Hard environment bail-outs.
291 if ( is_admin() || wp_doing_ajax() || wp_doing_cron() || is_feed() ) {
292 return false;
293 }
294
295 if ( ( defined( 'REST_REQUEST' ) && REST_REQUEST ) || ( defined( 'XMLRPC_REQUEST' ) && XMLRPC_REQUEST ) ) {
296 return false;
297 }
298
299 // Explicit kill switch.
300 if ( isset( $_GET['easyfonts'] ) && 'off' === $_GET['easyfonts'] ) { // phpcs:ignore WordPress.Security.NonceVerification
301 return false;
302 }
303
304 // Probe mode always runs (admin only).
305 if ( $this->is_probe() ) {
306 return true;
307 }
308
309 // Builder previews / customizer.
310 foreach ( self::BUILDER_PARAMS as $param ) {
311 if ( isset( $_GET[ $param ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification
312 return false;
313 }
314 }
315
316 if ( function_exists( 'is_customize_preview' ) && is_customize_preview() ) {
317 return false;
318 }
319
320 // Front-end page-builder EDIT modes (not just previews). When a builder
321 // loads its editor on a front-end URL, we must not strip/replace fonts
322 // or the user can't see real font styling while editing.
323 if ( self::is_builder_editor() ) {
324 return false;
325 }
326
327 /**
328 * Allow themes/plugins to force-skip Easy Fonts for the current request
329 * (e.g. bespoke editors). Return true to skip.
330 *
331 * @param bool $skip
332 */
333 if ( apply_filters( 'easyfonts_skip_request', false ) ) {
334 return false;
335 }
336
337 // PageSpeed=off convention.
338 if ( isset( $_GET['PageSpeed'] ) && 'off' === $_GET['PageSpeed'] ) { // phpcs:ignore WordPress.Security.NonceVerification
339 return false;
340 }
341
342 // Excluded URLs.
343 $excluded = (array) Settings::get( 'excluded_urls', array() );
344 $path = (string) wp_parse_url( home_url( add_query_arg( array() ) ), PHP_URL_PATH );
345
346 foreach ( $excluded as $ex ) {
347 $ex = trim( (string) $ex );
348
349 if ( '' !== $ex && false !== strpos( $path, $ex ) ) {
350 return false;
351 }
352 }
353
354 return true;
355 }
356
357 /**
358 * Admin-triggered probe: ?easyfonts_probe=1 (force a full uncached pass).
359 *
360 * @return bool
361 */
362 private function is_probe(): bool {
363 return isset( $_GET['easyfonts_probe'] ) && current_user_can( 'manage_options' ); // phpcs:ignore WordPress.Security.NonceVerification
364 }
365
366 /**
367 * Quick HTML sniff (skip JSON/feeds/fragments).
368 *
369 * @param string $buffer Buffer.
370 * @return bool
371 */
372 private function looks_like_html( string $buffer ): bool {
373 $head = ltrim( substr( $buffer, 0, 200 ) );
374
375 return ( false !== stripos( $head, '<!doctype html' ) || false !== stripos( $head, '<html' ) );
376 }
377 }
378