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 / Detect / Consolidator.php

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

1,545 lines 47.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Consolidator — the aggregate-and-replace engine.
4 *
5 * Instead of rewriting font URLs in place (which left protocol-relative URLs
6 * behind, created @import chains, and produced malformed CSS), this runs ONE
7 * pass that:
8 *
9 * 1. COLLECT — gather every Google/Bunny font source from <link> tags,
10 * inline <style> (@import + remote @font-face), same-origin and
11 * external CSS files, and JS web-font loaders.
12 * 2. AGGREGATE— parse all sources into normalized variants, dedupe, and filter
13 * by subset (inferred from unicode-range, comment-independent).
14 * 3. HOST — download each unique woff2 once, register it, and build ONE
15 * clean local stylesheet (correct font-display placement).
16 * 4. APPLY — strip every original source and inject a single <link>.
17 *
18 * @package EasyFonts
19 */
20
21 namespace EasyFonts\Detect;
22
23 use EasyFonts\Fonts\Downloader;
24 use EasyFonts\Fonts\Registry;
25 use EasyFonts\Fonts\Storage;
26 use EasyFonts\Frontend\HtmlProcessor;
27 use EasyFonts\Parser\FontFaceParser;
28 use EasyFonts\Settings;
29
30 defined( 'ABSPATH' ) || exit;
31
32 /**
33 * Detects, consolidates, and locally hosts web fonts.
34 */
35 class Consolidator {
36
37 /**
38 * @var FontFaceParser
39 */
40 private FontFaceParser $parser;
41
42 /**
43 * @var Downloader
44 */
45 private Downloader $downloader;
46
47 /**
48 * @var Storage
49 */
50 private Storage $storage;
51
52 /**
53 * @var Registry
54 */
55 private Registry $registry;
56
57 /**
58 * @var StylesheetFetcher
59 */
60 private StylesheetFetcher $fetcher;
61
62 /**
63 * @var HtmlProcessor
64 */
65 private HtmlProcessor $html;
66
67 /**
68 * Provider CSS URLs to fetch: url => provider.
69 *
70 * @var array<string,string>
71 */
72 private array $css_urls = array();
73
74 /**
75 * Raw remote @font-face CSS snippets found inline / in files: each [css, provider].
76 *
77 * @var array<int,array{css:string,provider:string}>
78 */
79 private array $raw_blocks = array();
80
81 /**
82 * Exact <link> hrefs to physically remove.
83 *
84 * @var string[]
85 */
86 private array $remove_links = array();
87
88 /**
89 * Detector ids that contributed at least one source (for Auto-Config).
90 *
91 * @var array<string,bool>
92 */
93 private array $contributed = array();
94
95 /**
96 * Set true when an enabled variant the page needs is NOT yet cached and we
97 * couldn't download it on this request (visitor render = downloads disabled,
98 * or warm-budget exhausted). When true, run() leaves the page untouched and
99 * signals a background warm so the cache fills off the render path.
100 *
101 * @var bool
102 */
103 private bool $missing = false;
104
105 /**
106 * Number of local/external stylesheets rewritten in place to cleaned copies
107 * this request (independent of the consolidated provider stylesheet).
108 *
109 * @var int
110 */
111 private int $local_rewrites = 0;
112
113 /**
114 * Font binaries downloaded so far this request (warm-budget counter).
115 *
116 * @var int
117 */
118 private int $dl_count = 0;
119
120 /**
121 * Monotonic start time of the first download this request (warm budget).
122 *
123 * @var float
124 */
125 private float $dl_started = 0.0;
126
127 /**
128 * Lowercased families the beacon has CONFIRMED as loaded-but-never-rendered
129 * on the current route — the only families page scoping drops. A font that
130 * hasn't been measured yet (newly declared, just added/replaced) is NOT in
131 * here, so it always loads until a beacon has judged it. See set_page_scope().
132 *
133 * @var array<string,bool>
134 */
135 private array $scope_out = array();
136
137 /**
138 * Whether the current route has been measured by a beacon. When false we
139 * never scope (the scope-out set is empty anyway); kept so the page-scope
140 * feature can be filtered off wholesale.
141 *
142 * @var bool
143 */
144 private bool $route_measured = false;
145
146 /**
147 * Provide the per-route scope-out set before run(). A declared font whose
148 * family is listed here is left out of THIS page's stylesheet (the beacon
149 * measured it loaded but it never rendered here). The font stays registered
150 * and is still hosted for the routes that use it.
151 *
152 * @param array<string,bool> $scope_out Lowercased family => true.
153 * @param bool $measured Route has beacon data.
154 * @return void
155 */
156 public function set_page_scope( array $scope_out, bool $measured ): void {
157 $this->scope_out = $scope_out;
158 $this->route_measured = $measured;
159 }
160
161 /**
162 * True when the most recent run found enabled fonts that aren't cached yet
163 * and downloads were disabled (visitor render) — i.e. a background warm is
164 * needed. Read by the output buffer to spawn a non-blocking loopback.
165 *
166 * @var bool
167 */
168 private static bool $needs_warm = false;
169
170 /**
171 * True when the most recent run actually committed a localization (a
172 * consolidated stylesheet and/or an in-place local stylesheet rewrite). The
173 * output buffer uses this to decide whether to inject head markup / strip
174 * resource hints.
175 *
176 * @var bool
177 */
178 private static bool $localized = false;
179
180 /**
181 * Variants hosted this request (for fallbacks + usage logging).
182 *
183 * @var array<int,array{family:string,weight:string,style:string}>
184 */
185 private static array $touched = array();
186
187 /**
188 * Consolidated stylesheet URL produced by the most recent run ('' if none).
189 *
190 * @var string
191 */
192 private static string $stylesheet_url = '';
193
194 /**
195 * Cache-relative path of the consolidated stylesheet from the most recent
196 * run ('' if none). Used by the output buffer to inline the CSS.
197 *
198 * @var string
199 */
200 private static string $stylesheet_file = '';
201
202 /**
203 * Fonts touched this request.
204 *
205 * @return array<int,array{family:string,weight:string,style:string}>
206 */
207 public static function touched(): array {
208 return self::$touched;
209 }
210
211 /**
212 * Does the current page need a background cache-warm? True when an enabled
213 * font isn't cached yet and downloads were disabled for this (visitor)
214 * render.
215 *
216 * @return bool
217 */
218 public static function needs_warm(): bool {
219 return self::$needs_warm;
220 }
221
222 /**
223 * Did the most recent run commit a localization (so the output buffer should
224 * inject preloads/fallbacks and strip provider hints)?
225 *
226 * @return bool
227 */
228 public static function localized(): bool {
229 return self::$localized;
230 }
231
232 /**
233 * Consolidated stylesheet URL from the most recent run.
234 *
235 * @return string
236 */
237 public static function stylesheet_url(): string {
238 return self::$stylesheet_url;
239 }
240
241 /**
242 * Cache-relative path of the consolidated stylesheet from the most recent
243 * run ('' if none).
244 *
245 * @return string
246 */
247 public static function stylesheet_file(): string {
248 return self::$stylesheet_file;
249 }
250
251 /**
252 * Constructor.
253 */
254 public function __construct() {
255 $this->parser = new FontFaceParser();
256 $this->downloader = new Downloader();
257 $this->storage = new Storage();
258 $this->registry = new Registry();
259 $this->fetcher = new StylesheetFetcher();
260 $this->html = new HtmlProcessor();
261 }
262
263 /**
264 * Run the full consolidation pass over a document.
265 *
266 * Transactional + fail-safe: detection/rewriting happens on a working copy.
267 * The rewritten page is only returned ("committed") when EVERY enabled font
268 * it needs is already served locally. If any needed font isn't cached yet and
269 * downloads are disabled for this request (a normal visitor render never
270 * downloads — that's what used to stall and white-screen the site), the
271 * ORIGINAL page is returned unchanged (so the original Google Fonts keep
272 * working — no FOUC, no missing text) and a background warm is flagged.
273 *
274 * @param string $html Full HTML.
275 * @param bool $force Reserved (probe mode); detection is identical.
276 * @return string
277 */
278 public function run( string $html, bool $force = false ): string {
279 unset( $force );
280
281 $original = $html;
282
283 self::$stylesheet_url = '';
284 self::$stylesheet_file = '';
285 self::$needs_warm = false;
286 self::$localized = false;
287 $this->missing = false;
288 $this->local_rewrites = 0;
289
290 // 1. COLLECT (each step may rewrite the working HTML — e.g. strip inline
291 // blocks, or rewrite a theme stylesheet to a cleaned local copy). We
292 // keep the untouched $original to fall back to.
293 $working = $this->collect_from_links( $html );
294 $working = $this->collect_from_inline( $working );
295 $working = $this->collect_from_webfont( $working );
296 $this->collect_async_urls();
297
298 // Backstop: nothing in collection may ever blank the document. If a step
299 // somehow emptied a non-empty page, discard the working copy entirely.
300 if ( '' === $working && '' !== $original ) {
301 return $original;
302 }
303
304 $has_provider = ! empty( $this->css_urls ) || ! empty( $this->raw_blocks );
305
306 // Nothing font-related found at all → return the page untouched.
307 if ( ! $has_provider && 0 === $this->local_rewrites ) {
308 return $original;
309 }
310
311 // 2. AGGREGATE + 3. HOST the consolidated (provider <link> / inline) set.
312 // Downloads only happen here when allowed (admin/CLI/warm); on a
313 // visitor render the missing flag is set instead.
314 $file_url = null;
315
316 if ( $has_provider ) {
317 $variants = $this->aggregate();
318
319 if ( ! empty( $variants ) ) {
320 $file_url = $this->host( $variants );
321 }
322 }
323
324 // If ANY enabled font the page needs isn't served locally yet, don't touch
325 // the page — keep the originals working and warm the cache in background.
326 if ( $this->missing ) {
327 return $this->bail( $original );
328 }
329
330 // Nothing actually localized (no consolidated sheet built and no local
331 // stylesheet rewritten) → return the page untouched.
332 if ( null === $file_url && 0 === $this->local_rewrites ) {
333 return $this->bail( $original );
334 }
335
336 // Everything needed is hosted locally. Physically remove the provider
337 // <link> tags we flagged — but only when we built a consolidated
338 // replacement for them (otherwise leave them, so we never drop fonts).
339 if ( null !== $file_url ) {
340 $working = $this->html->remove_stylesheet_links( $working, array_values( array_unique( $this->remove_links ) ) );
341 self::$stylesheet_url = $file_url;
342 }
343
344 self::$localized = true;
345
346 foreach ( array_keys( $this->contributed ) as $id ) {
347 Settings::learn_detector( $id );
348 }
349
350 return $working;
351 }
352
353 /**
354 * Abort the rewrite: clear any partial output state and return the page
355 * exactly as it came in. If fonts are missing only because downloads were
356 * disabled (visitor render), flag a background warm.
357 *
358 * @param string $original Untouched HTML.
359 * @return string
360 */
361 private function bail( string $original ): string {
362 self::$stylesheet_url = '';
363 self::$stylesheet_file = '';
364 self::$touched = array();
365
366 if ( $this->missing && ! Downloader::fonts_allowed() ) {
367 self::$needs_warm = true;
368 }
369
370 return $original;
371 }
372
373 /**
374 * Warm-download budget: bounds how much a single warm (probe) request will
375 * fetch, so it can't run long enough to hit max_execution_time. Anything left
376 * over is fetched by the next warm. Always true when downloads are disabled
377 * (the caller handles that case via the missing flag).
378 *
379 * @return bool
380 */
381 private function within_budget(): bool {
382 if ( 0.0 === $this->dl_started ) {
383 $this->dl_started = microtime( true );
384 }
385
386 /**
387 * Max font binaries to download in a single warm request.
388 *
389 * @param int $max
390 */
391 $max_n = (int) apply_filters( 'easyfonts_warm_max_downloads', 25 );
392
393 /**
394 * Max wall-clock seconds to spend downloading in a single warm request.
395 *
396 * @param float $max
397 */
398 $max_s = (float) apply_filters( 'easyfonts_warm_max_seconds', 25.0 );
399
400 if ( $this->dl_count >= $max_n ) {
401 return false;
402 }
403
404 return ( microtime( true ) - $this->dl_started ) < $max_s;
405 }
406
407 /* --------------------------------------------------------------------- *
408 * 1. COLLECT
409 * --------------------------------------------------------------------- */
410
411 /**
412 * Walk <link> tags: flag provider stylesheets for removal, and rewrite
413 * same-origin/external CSS files that hide provider fonts to cleaned copies.
414 *
415 * @param string $html HTML.
416 * @return string
417 */
418 private function collect_from_links( string $html ): string {
419 $result = $this->html->rewrite_stylesheet_links(
420 $html,
421 function ( $href ) {
422 $url = Providers::normalize_url( $href );
423
424 // Direct provider stylesheet → consolidated pipeline (removed +
425 // re-emitted as one clean local sheet).
426 if ( Providers::is_css_url( $url ) ) {
427 $this->add_css_url( $url, 'link' );
428 $this->remove_links[] = $href;
429 return null;
430 }
431
432 // A real CSS file (theme/plugin) that may hide provider fonts in
433 // @import or @font-face rules.
434 if ( $this->fetcher->is_skippable( $url ) ) {
435 return null;
436 }
437
438 $is_local = $this->fetcher->is_local( $url );
439 $css = $is_local
440 ? $this->fetcher->read_local( $url )
441 : $this->fetcher->read_external( $url, $this->downloader );
442
443 if ( null === $css || ! $this->mentions_provider( $css ) ) {
444 return null;
445 }
446
447 $detector = $is_local ? 'local_css' : 'external_css';
448
449 // Localise IN PLACE: download the provider fonts, rewrite their
450 // @font-face src to local URLs, inline any provider @import (also
451 // localised). The rest of the file is preserved, so the sheet stays
452 // in its original cascade position — just self-hosted now.
453 $localized = $this->localize_stylesheet( $css, $url );
454
455 if ( null === $localized ) {
456 return null;
457 }
458
459 // Save the localised copy in a clean css/ folder (no doubled-hash
460 // folder, which security scanners flag).
461 $filename = 'css/ext-' . substr( hash( 'sha256', $url ), 0, 18 ) . '.css';
462
463 if ( ! $this->storage->write( $filename, $localized ) ) {
464 return null;
465 }
466
467 $this->mark( $detector );
468 $this->local_rewrites++;
469
470 return add_query_arg( 'ver', Settings::buster(), $this->storage->url( $filename ) );
471 }
472 );
473
474 return $result['html'];
475 }
476
477 /**
478 * Walk inline <style> blocks: pull provider @import URLs and remote
479 * @font-face blocks out, leaving the rest of the CSS (e.g. Font Awesome)
480 * untouched.
481 *
482 * @param string $html HTML.
483 * @return string
484 */
485 private function collect_from_inline( string $html ): string {
486 $result = $this->html->rewrite_style_blocks(
487 $html,
488 function ( $css ) {
489 $original = $css;
490
491 // Provider @import → collect + remove the statement.
492 $replaced = preg_replace_callback(
493 '/@import\s+(?:url\(\s*)?[\'"]?([^\'")\s]+)[\'"]?\s*\)?\s*;?/i',
494 function ( $m ) {
495 $url = Providers::normalize_url( $m[1] );
496
497 if ( Providers::is_css_url( $url ) ) {
498 $this->add_css_url( $url, 'inline' );
499 return '';
500 }
501
502 return $m[0];
503 },
504 $css
505 );
506 $css = is_string( $replaced ) ? $replaced : $original;
507
508 // Remote @font-face → collect raw block + remove it.
509 $blocks = $this->parser->blocks( $css );
510
511 foreach ( $blocks as $block ) {
512 if ( $this->parser->is_remote_origin( $block['full'] ) ) {
513 $this->raw_blocks[] = array(
514 'css' => $block['full'],
515 'provider' => Providers::id_for( $block['full'] ),
516 );
517 $css = str_replace( $block['full'], '', $css );
518 $this->mark( 'inline' );
519 }
520 }
521
522 return $css === $original ? null : $css;
523 }
524 );
525
526 return $result['html'];
527 }
528
529 /**
530 * Detect the JS Web Font Loader, convert its config to a provider CSS URL,
531 * and neutralise the loader scripts.
532 *
533 * @param string $html HTML.
534 * @return string
535 */
536 private function collect_from_webfont( string $html ): string {
537 if ( false === stripos( $html, 'WebFont' ) && false === stripos( $html, 'webfont.js' ) ) {
538 return $html;
539 }
540
541 // Match the Google config object in either authoring style:
542 // WebFontConfig['google'] = { families: [...] } (bracket assignment)
543 // ... google: { families: [...] } ... (object literal)
544 // The object body has no nested braces (families uses [ ]), so [^{}] is safe.
545 if ( ! preg_match(
546 '/(?:WebFontConfig\s*\[\s*[\'"]google[\'"]\s*\]\s*=\s*|[\'"]?google[\'"]?\s*:\s*)(\{[^{}]*\})/is',
547 $html,
548 $m
549 ) ) {
550 return $html;
551 }
552
553 // HTML-decode the captured object (&amp;subset=latin → &subset=latin)
554 // BEFORE parsing, or the entity breaks family/subset extraction.
555 $block = html_entity_decode( $m[1], ENT_QUOTES | ENT_HTML5 );
556
557 if ( ! preg_match( '/families\s*:\s*\[(.*?)\]/is', $block, $fm ) ) {
558 return $html;
559 }
560
561 $families = array();
562 $subsets = array();
563
564 if ( preg_match_all( '/[\'"]([^\'"]+)[\'"]/', $fm[1], $items ) ) {
565 foreach ( $items[1] as $item ) {
566 $parsed = $this->webfont_family_to_spec( $item );
567
568 if ( ! empty( $parsed['family'] ) ) {
569 $families[] = $parsed;
570
571 if ( '' !== $parsed['subset'] ) {
572 $subsets = array_merge( $subsets, explode( ',', $parsed['subset'] ) );
573 }
574 }
575 }
576 }
577
578 if ( empty( $families ) ) {
579 return $html;
580 }
581
582 // Build a modern Google CSS2 request. CSS2 is the endpoint the rest of the
583 // pipeline is proven against (subset comments + unicode-ranges parse
584 // cleanly), unlike the legacy v1 /css endpoint. Each family becomes a
585 // `family=Name:ital,wght@…` parameter. Subsets from the config travel as a
586 // global &subset= param; aggregate() merges in user-selected subsets too.
587 $url = $this->build_css2_url( $families );
588
589 if ( '' === $url ) {
590 return $html;
591 }
592
593 $subsets = array_values( array_unique( array_filter( $subsets ) ) );
594
595 if ( ! empty( $subsets ) ) {
596 $url .= '&subset=' . implode( ',', $subsets );
597 }
598
599 $this->add_css_url( $url, 'webfont' );
600 $this->mark( 'webfont' );
601
602 // Remove webfont.js loader scripts + the WebFontConfig block. The src
603 // pattern uses bounded character classes (safe); null-guard it anyway so a
604 // PCRE failure can never blank the page. The WebFontConfig block is removed
605 // by a linear scan instead of a tempered-dot regex — that regex hit the
606 // PCRE2 JIT stack limit on ordinary inline scripts and blanked the page.
607 $stripped = preg_replace( '#<script\b[^>]*\bsrc=[\'"][^\'"]*webfont(?:\.min)?\.js[\'"][^>]*>\s*</script>#i', '', $html );
608 $html = is_string( $stripped ) ? $stripped : $html;
609 $html = $this->remove_script_block_containing( $html, 'WebFontConfig' );
610
611 return $html;
612 }
613
614 /**
615 * Remove any <script>…</script> block whose contents contain $needle, using a
616 * linear strpos scan (no regex → no catastrophic backtracking / JIT crash).
617 *
618 * @param string $html HTML.
619 * @param string $needle Marker that must appear inside the script.
620 * @return string
621 */
622 private function remove_script_block_containing( string $html, string $needle ): string {
623 $offset = 0;
624
625 while ( true ) {
626 $pos = stripos( $html, $needle, $offset );
627
628 if ( false === $pos ) {
629 break;
630 }
631
632 $start = strripos( substr( $html, 0, $pos ), '<script' );
633 $gt = false === $start ? false : strpos( $html, '>', $start );
634 $end = stripos( $html, '</script>', $pos );
635
636 // Marker isn't inside a clean <script>…</script> wrapper → skip past it.
637 if ( false === $start || false === $gt || false === $end || $gt > $pos ) {
638 $offset = $pos + strlen( $needle );
639 continue;
640 }
641
642 $html = substr( $html, 0, $start ) . substr( $html, $end + 9 );
643 $offset = $start;
644 }
645
646 return $html;
647 }
648
649 /**
650 * Convert one Web Font Loader families[] entry into a structured spec:
651 * { family: 'Open Sans', weights: ['400','700'], italics: ['400'], subset: '' }
652 *
653 * Handles plain names, weight lists, FVD notation ("n4"/"i7"), a trailing
654 * "&subset=" / ":subset" segment, URL-encoded names ("%2C"), and skips font
655 * stacks / generic families (e.g. "Tahoma,Geneva, sans-serif").
656 *
657 * @param string $entry Raw families[] entry.
658 * @return array{family:string,weights:string[],italics:string[],subset:string}
659 */
660 private function webfont_family_to_spec( string $entry ): array {
661 $none = array( 'family' => '', 'weights' => array(), 'italics' => array(), 'subset' => '' );
662 $entry = html_entity_decode( trim( $entry ), ENT_QUOTES | ENT_HTML5 );
663
664 if ( '' === $entry ) {
665 return $none;
666 }
667
668 // Pull off a &subset= / &subsets= query suffix and capture it.
669 $subset = '';
670
671 if ( preg_match( '/[&?]subsets?=([a-z0-9,\-]+)/i', $entry, $sm ) ) {
672 $subset = strtolower( $sm[1] );
673 }
674
675 $entry = (string) preg_replace( '/[&?].*$/', '', $entry ); // drop the query part.
676
677 $parts = explode( ':', $entry );
678 $family_raw = rawurldecode( trim( $parts[0] ) ); // %2C → ",".
679
680 // Skip font stacks / generic families — not Google fonts.
681 if ( false !== strpos( $family_raw, ',' ) ) {
682 return $none;
683 }
684
685 $lc = strtolower( trim( $family_raw ) );
686
687 if ( in_array( $lc, array( 'sans-serif', 'serif', 'monospace', 'cursive', 'fantasy', 'system-ui', '-apple-system', 'blinkmacsystemfont', 'inherit', 'initial' ), true ) ) {
688 return $none;
689 }
690
691 $family = trim( $family_raw );
692
693 if ( '' === $family ) {
694 return $none;
695 }
696
697 // A third colon segment is a subset list in WebFont loader syntax.
698 if ( isset( $parts[2] ) && '' !== trim( $parts[2] ) ) {
699 $third = strtolower( trim( $parts[2] ) );
700 $subset = '' !== $subset ? $subset . ',' . $third : $third;
701 }
702
703 // Weights (second segment) → split into upright + italic numeric lists.
704 $weights = array();
705 $italics = array();
706
707 if ( isset( $parts[1] ) && '' !== trim( $parts[1] ) ) {
708 foreach ( explode( ',', $parts[1] ) as $token ) {
709 $token = trim( $token );
710
711 if ( preg_match( '/^([nio])([1-9])$/i', $token, $fvd ) ) {
712 $w = $fvd[2] . '00';
713 'i' === strtolower( $fvd[1] ) ? $italics[] = $w : $weights[] = $w;
714 } elseif ( preg_match( '/^(\d{3})italic$/i', $token, $wm ) ) {
715 $italics[] = $wm[1];
716 } elseif ( preg_match( '/^\d{3}$/', $token ) ) {
717 $weights[] = $token;
718 } elseif ( 'bold' === strtolower( $token ) ) {
719 $weights[] = '700';
720 } elseif ( in_array( strtolower( $token ), array( 'regular', 'normal' ), true ) ) {
721 $weights[] = '400';
722 } elseif ( 'italic' === strtolower( $token ) ) {
723 $italics[] = '400';
724 }
725 }
726 }
727
728 // Default to a single upright 400 if no weight was specified.
729 if ( empty( $weights ) && empty( $italics ) ) {
730 $weights[] = '400';
731 }
732
733 return array(
734 'family' => $family,
735 'weights' => array_values( array_unique( $weights ) ),
736 'italics' => array_values( array_unique( $italics ) ),
737 'subset' => $subset,
738 );
739 }
740
741 /**
742 * Build a single Google CSS2 URL from structured family specs.
743 *
744 * Produces e.g.
745 * https://fonts.googleapis.com/css2?family=Open+Sans:ital,wght@0,400;0,700;1,400&family=Roboto:wght@400&display=swap
746 *
747 * @param array<int,array{family:string,weights:string[],italics:string[],subset:string}> $families Specs.
748 * @return string CSS2 URL, or '' if no families.
749 */
750 private function build_css2_url( array $families ): string {
751 $params = array();
752
753 foreach ( $families as $spec ) {
754 $name = str_replace( ' ', '+', $spec['family'] );
755
756 if ( '' === $name ) {
757 continue;
758 }
759
760 $uprights = $spec['weights'];
761 $italics = $spec['italics'];
762
763 if ( ! empty( $italics ) ) {
764 // Mixed axis: must list every tuple as ital,wght and sort ascending.
765 $tuples = array();
766
767 foreach ( $uprights as $w ) {
768 $tuples[] = '0,' . $w;
769 }
770
771 foreach ( $italics as $w ) {
772 $tuples[] = '1,' . $w;
773 }
774
775 sort( $tuples ); // CSS2 requires ascending tuple order.
776 $params[] = $name . ':ital,wght@' . implode( ';', $tuples );
777 } else {
778 // Upright only.
779 $ws = $uprights;
780 sort( $ws, SORT_STRING );
781 $params[] = $name . ':wght@' . implode( ';', $ws );
782 }
783 }
784
785 if ( empty( $params ) ) {
786 return '';
787 }
788
789 $display = (string) Settings::get( 'font_display', 'swap' );
790
791 return 'https://fonts.googleapis.com/css2?family=' . implode( '&family=', $params ) . '&display=' . $display;
792 }
793
794 /**
795 * Fold in Google/Bunny CSS URLs that were injected by JavaScript at runtime
796 * and reported by async-blocker.js (stored via REST /async-fonts). These
797 * never appear in the server-rendered HTML, so this is how arbitrary
798 * JS-loaded fonts get self-hosted — closing the one detection gap versus a
799 * pure output-buffer scanner.
800 *
801 * @return void
802 */
803 private function collect_async_urls(): void {
804 if ( ! Settings::get( 'async_blocker', 0 ) ) {
805 return;
806 }
807
808 $urls = (array) get_option( 'easyfonts_async_urls', array() );
809
810 foreach ( $urls as $url ) {
811 $url = Providers::normalize_url( (string) $url );
812
813 if ( Providers::is_css_url( $url ) ) {
814 $this->add_css_url( $url, 'async' );
815 }
816 }
817 }
818
819 /**
820 * Parse every collected source into normalized, deduped, subset-filtered
821 * variants.
822 *
823 * @return array<string,array<string,mixed>> Keyed by variant_key.
824 */
825 private function aggregate(): array {
826 $variants = array();
827 $subsets = $this->effective_subsets();
828
829 // Provider CSS URLs → fetch + parse. Google v1 URLs are augmented so the
830 // API actually returns the non-latin subsets the user enabled.
831 foreach ( $this->css_urls as $url => $provider ) {
832 $css = $this->downloader->fetch_css( $this->augment_v1_subsets( $url, $subsets ) );
833
834 if ( null === $css ) {
835 continue;
836 }
837
838 $this->collect_variants( $css, $provider, $subsets, $variants, $url );
839 }
840
841 // Raw inline @font-face snippets → parse directly.
842 foreach ( $this->raw_blocks as $block ) {
843 $this->collect_variants( $block['css'], $block['provider'], $subsets, $variants, '' );
844 }
845
846 return $variants;
847 }
848
849 /**
850 * Parse CSS into variants and merge them into the accumulator.
851 *
852 * @param string $css CSS.
853 * @param string $provider Provider id.
854 * @param string[] $subsets Allowed subsets.
855 * @param array<string,array<string,mixed>> $variants Accumulator (by ref).
856 * @param string $source Source URL (for provenance).
857 */
858 private function collect_variants( string $css, string $provider, array $subsets, array &$variants, string $source ): void {
859 $blocks = $this->parser->blocks( $css );
860 $variable_map = $this->parser->variable_families( $blocks );
861
862 foreach ( $blocks as $block ) {
863 $props = $this->parser->properties( $block['body'] );
864 $family = $props['family'];
865 $src = $props['src'];
866
867 if ( '' === $family || '' === $src ) {
868 continue;
869 }
870
871 $src = Providers::normalize_url( $src, $source );
872
873 // Subset: prefer the Google /* comment */, else infer from range.
874 $subset = isset( $block['lead'] ) ? $this->clean_subset( $block['lead'] ) : '';
875
876 if ( '' === $subset ) {
877 $subset = FontFaceParser::subset_from_unicode_range( $props['unicode_range'] );
878 }
879
880 // Filter by the EFFECTIVE subset set (user selection + latin baseline,
881 // computed by the caller). latin/latin-ext are always kept, so enabling
882 // an extra script (e.g. cyrillic) never drops a latin-only font. Unknown
883 // subset ('') is always kept (we can't tell what it is).
884 if ( '' !== $subset && ! in_array( $subset, $subsets, true ) ) {
885 continue;
886 }
887
888 $is_variable = isset( $variable_map[ strtolower( $family ) ] )
889 || (bool) preg_match( '/\d+\s+\d+/', $props['weight'] );
890
891 $subset_key = '' !== $subset ? $subset : 'latin';
892 $key = Registry::variant_key( $family, $props['weight'], $props['style'], $subset_key );
893
894 // First write wins; identical variants from multiple sources collapse.
895 if ( isset( $variants[ $key ] ) ) {
896 continue;
897 }
898
899 $variants[ $key ] = array(
900 'family' => $family,
901 'weight' => $props['weight'],
902 'style' => $props['style'],
903 'subset' => $subset_key,
904 'src' => $src,
905 'unicode_range' => $props['unicode_range'],
906 'is_variable' => $is_variable,
907 'provider' => $provider,
908 'source' => $source,
909 );
910 }
911 }
912
913 /* --------------------------------------------------------------------- *
914 * 3. HOST
915 * --------------------------------------------------------------------- */
916
917 /**
918 * Register every variant and write one consolidated stylesheet. The file is
919 * keyed by the variant set + display + subsets, so pages sharing fonts share
920 * the file.
921 *
922 * Registration runs for EVERY variant on every request (idempotent upsert),
923 * even when the consolidated file already exists — so the hosted-fonts list
924 * can't drift out of sync with what's actually served. Font binaries are
925 * downloaded only when missing. Metric extraction is deliberately NOT done
926 * here; it runs off the page-render path (see MetricsBackfill).
927 *
928 * @param array<string,array<string,mixed>> $variants Variants by key.
929 * @return string|null Local stylesheet URL (cache-busted), or null.
930 */
931 private function host( array $variants ): ?string {
932 $display = (string) Settings::get( 'font_display', 'swap' );
933 $subsets = implode( ',', (array) Settings::get( 'subsets', array() ) );
934
935 $disabled = $this->registry->disabled_keys();
936
937 // Page scoping: when the route has been measured, drop only the fonts a
938 // beacon CONFIRMED are loaded-but-unused here (e.g. a theme enqueues a
939 // global Google-fonts set, but a font only renders on one template).
940 // Matched at the family level and beacon-confirmed, so a newly declared
941 // font is never dropped before it's been judged, and the decision can't
942 // oscillate (it's based on the page's authorial font-family, which holds
943 // even after the font is scoped out).
944 // - family is a confirmed scope-out -> skip on this page
945 // - family in the safelist -> always include
946 // - everything else (used, or new/unjudged) -> include
947 $scope = $this->route_measured
948 && (bool) apply_filters( 'easyfonts_page_scope_fonts', true );
949
950 $force = array();
951
952 foreach ( (array) apply_filters( 'easyfonts_force_families', array() ) as $f ) {
953 $force[ strtolower( (string) $f ) ] = true;
954 }
955
956 // Decide, per detected variant, whether it is EMITTED on this page.
957 // Skipped variants are still registered below so they stay visible and
958 // re-enableable, and are still hosted by the routes that use them.
959 $emit = array();
960
961 foreach ( $variants as $key => $v ) {
962 if ( isset( $disabled[ $key ] ) ) {
963 continue;
964 }
965
966 if ( $scope ) {
967 $fam = strtolower( (string) $v['family'] );
968
969 if ( isset( $this->scope_out[ $fam ] ) && ! isset( $force[ $fam ] ) ) {
970 continue;
971 }
972 }
973
974 $emit[ $key ] = true;
975 }
976
977 // Cache key = the EMITTED set (+ display + subsets). Two pages that emit
978 // the same faces share one file; a page whose enabled set differs gets
979 // its own. If the keyed file already exists we never rewrite it —
980 // identical sets are deduped on disk, so we don't burn IO/CPU
981 // regenerating the same stylesheet.
982 $emit_keys = array_keys( $emit );
983 sort( $emit_keys );
984 $hash = substr( hash( 'sha256', implode( '|', $emit_keys ) . '|' . $display . '|' . $subsets ), 0, 20 );
985
986 // Consolidated stylesheet lives in a readable `css/` folder. Font
987 // binaries live under `fonts/{family}/…` so identical variants are
988 // shared across stylesheets.
989 $file = 'css/' . $hash . '.css';
990 $file_exists = $this->storage->exists( $file );
991
992 // M-3: one batched read of the rows we already have for every detected
993 // variant, so registration costs a single SELECT instead of one per
994 // variant. We then only write rows that are new or whose font file
995 // changed — on a warm-cache render that means zero writes.
996 $known = $this->registry->existing_map( array_keys( $variants ) );
997
998 $css = "/**\n * Auto Generated by EasyFonts\n * @url: https://fluxpress.io\n */\n";
999 $hosted = 0;
1000
1001 foreach ( $variants as $key => $v ) {
1002 $is_emitted = isset( $emit[ $key ] );
1003
1004 $basename = $this->basename_for( $v );
1005 $font_file = $this->locate_font( $basename );
1006 $size = $font_file ? $this->storage->size( $font_file ) : 0;
1007
1008 // Only EMITTED variants are required to be hosted on this page. A
1009 // disabled or unloaded variant is never downloaded and never blocks
1010 // the page (it isn't needed here); it may still be downloaded later
1011 // by a route that actually renders it (font binaries are shared).
1012 if ( $is_emitted && null === $font_file ) {
1013 if ( ! Downloader::fonts_allowed() || ! $this->within_budget() ) {
1014 $this->missing = true;
1015 continue;
1016 }
1017
1018 $download = $this->downloader->fetch_font( $v['src'], $basename, $this->storage );
1019
1020 if ( null === $download ) {
1021 // Genuine fetch failure — treat as missing so we don't strip
1022 // the original source and leave the page without this font.
1023 $this->missing = true;
1024 continue;
1025 }
1026
1027 $this->dl_count++;
1028 $font_file = $download['file'];
1029 $size = $download['size'];
1030 }
1031
1032 // Register the variant, but skip the write when we already have it
1033 // with the same font file (the common warm-cache case) — keeps the
1034 // hosted-fonts list in sync without N queries on the hot path.
1035 $current = $known[ $key ] ?? null;
1036
1037 if ( null === $current || (string) $current !== (string) $font_file ) {
1038 $this->registry->upsert(
1039 array(
1040 'family' => $v['family'],
1041 'weight' => $v['weight'],
1042 'style' => $v['style'],
1043 'subset' => $v['subset'],
1044 'is_variable' => $v['is_variable'],
1045 'provider' => $v['provider'],
1046 'css_file' => $file,
1047 'font_file' => (string) $font_file,
1048 'file_size' => $size,
1049 'source_url' => $v['source'],
1050 )
1051 );
1052 }
1053
1054 if ( ! $is_emitted || null === $font_file ) {
1055 continue;
1056 }
1057
1058 $local_url = add_query_arg( 'ver', Settings::buster(), $this->storage->url( $font_file ) );
1059 $css .= $this->build_face_rule( $v, $local_url, $display );
1060
1061 self::$touched[] = array(
1062 'family' => $v['family'],
1063 'weight' => $v['weight'],
1064 'style' => $v['style'],
1065 );
1066
1067 $hosted++;
1068 }
1069
1070 if ( 0 === $hosted ) {
1071 return null;
1072 }
1073
1074 // Deterministic content for a given emit set: only write when the keyed
1075 // file doesn't already exist.
1076 if ( ! $file_exists ) {
1077 if ( ! $this->storage->write( $file, $css ) ) {
1078 return null;
1079 }
1080 }
1081
1082 self::$stylesheet_file = $file;
1083
1084 return add_query_arg( 'ver', Settings::buster(), $this->storage->url( $file ) );
1085 }
1086
1087 /**
1088 * Find an already-downloaded font file for a basename (any known extension).
1089 *
1090 * @param string $basename Cache-relative basename (no extension).
1091 * @return string|null Cache-relative filename, or null if none.
1092 */
1093 private function locate_font( string $basename ): ?string {
1094 foreach ( array( 'woff2', 'woff', 'ttf', 'otf' ) as $ext ) {
1095 $candidate = $basename . '.' . $ext;
1096
1097 if ( $this->storage->exists( $candidate ) ) {
1098 return $candidate;
1099 }
1100 }
1101
1102 return null;
1103 }
1104
1105 /**
1106 * Build one clean @font-face rule (correct property order + placement).
1107 *
1108 * @param array<string,mixed> $v Variant.
1109 * @param string $local_url Local font URL.
1110 * @param string $display font-display value.
1111 * @return string
1112 */
1113 private function build_face_rule( array $v, string $local_url, string $display ): string {
1114 $ext = strtolower( (string) pathinfo( (string) wp_parse_url( $local_url, PHP_URL_PATH ), PATHINFO_EXTENSION ) );
1115 $format = 'woff' === $ext ? 'woff' : ( 'ttf' === $ext ? 'truetype' : ( 'otf' === $ext ? 'opentype' : 'woff2' ) );
1116
1117 // Harden every interpolated value against CSS-context breakout. Family /
1118 // weight / style / unicode-range originate from parsed @font-face bodies
1119 // and font URLs, which an author could craft; stripping CSS structural
1120 // characters stops a malicious value from escaping its declaration (and,
1121 // when the sheet is inlined, from breaking out of <style>). Legitimate
1122 // values — "Open Sans", "100 900", "oblique 10deg", "U+0000-00FF" — are
1123 // unaffected, so nothing is lost.
1124 $family = self::css_token( (string) $v['family'] );
1125 $style = self::css_token( (string) $v['style'] );
1126 $weight = self::css_token( (string) $v['weight'] );
1127 $urange = self::css_token( (string) $v['unicode_range'] );
1128
1129 $lines = array();
1130 $lines[] = '@font-face {';
1131 $lines[] = " font-family: '" . $family . "';";
1132 $lines[] = ' font-style: ' . ( '' !== $style ? $style : 'normal' ) . ';';
1133 $lines[] = ' font-weight: ' . ( '' !== $weight ? $weight : '400' ) . ';';
1134
1135 if ( '' !== $display && 'auto' !== $display ) {
1136 $lines[] = ' font-display: ' . self::css_token( $display ) . ';';
1137 }
1138
1139 $lines[] = " src: url('" . esc_url_raw( $local_url ) . "') format('" . $format . "');";
1140
1141 if ( '' !== $urange ) {
1142 $lines[] = ' unicode-range: ' . $urange . ';';
1143 }
1144
1145 $lines[] = "}\n";
1146
1147 return implode( "\n", $lines );
1148 }
1149
1150 /**
1151 * Strip characters that could break out of a CSS string or declaration (or,
1152 * for an inlined sheet, out of the <style> element). Removes quotes, braces,
1153 * semicolons, angle brackets, backslashes, comment delimiters and newlines.
1154 * Real font-family / weight / style / unicode-range values never contain
1155 * these, so this is purely defensive and lossless for legitimate input.
1156 *
1157 * @param string $value Raw value.
1158 * @return string
1159 */
1160 private static function css_token( string $value ): string {
1161 return trim(
1162 str_replace(
1163 array( "'", '"', ';', '{', '}', '<', '>', '\\', '/*', '*/', "\r", "\n", "\t" ),
1164 '',
1165 $value
1166 )
1167 );
1168 }
1169
1170 /**
1171 * Deterministic, human-readable font path for a variant, grouped by family.
1172 * Variable fonts drop the weight. Returns a cache-relative basename (no ext).
1173 *
1174 * e.g. fonts/open-sans/open-sans-normal-latin-400
1175 *
1176 * @param array<string,mixed> $v Variant.
1177 * @return string
1178 */
1179 private function basename_for( array $v ): string {
1180 $slug = $this->slug( $v['family'] );
1181 $parts = array( $slug, $v['style'], $v['subset'] );
1182
1183 if ( empty( $v['is_variable'] ) ) {
1184 $parts[] = $this->slug( $v['weight'] );
1185 }
1186
1187 return 'fonts/' . $slug . '/' . implode( '-', array_filter( $parts ) );
1188 }
1189
1190 /**
1191 * Filesystem-safe slug (lowercase alphanumerics + hyphens).
1192 *
1193 * @param string $text Text.
1194 * @return string
1195 */
1196 private function slug( string $text ): string {
1197 $text = strtolower( trim( $text ) );
1198 $text = preg_replace( '/[^a-z0-9]+/', '-', $text );
1199
1200 return trim( (string) $text, '-' );
1201 }
1202
1203 /* --------------------------------------------------------------------- *
1204 * Helpers
1205 * --------------------------------------------------------------------- */
1206
1207 /**
1208 * Localise a theme/plugin stylesheet IN PLACE: download provider fonts and
1209 * rewrite their @font-face src to local URLs, and inline any provider
1210 * @import (also localised). Everything else is preserved so the sheet keeps
1211 * its cascade position. Returns the localised CSS, or null if it contained
1212 * no provider fonts.
1213 *
1214 * @param string $css CSS contents.
1215 * @param string $base_url The file's own URL (for absolutising).
1216 * @return string|null
1217 */
1218 private function localize_stylesheet( string $css, string $base_url ): ?string {
1219 $found = false;
1220 $effective = $this->effective_subsets();
1221 $out = $this->absolutize_urls( $css, $base_url );
1222
1223 // 1. Provider @import → fetch that CSS, localise its faces, inline it.
1224 $imported_out = preg_replace_callback(
1225 '/@import\s+(?:url\(\s*)?[\'"]?([^\'")\s]+)[\'"]?\s*\)?\s*;?/i',
1226 function ( $m ) use ( &$found, $effective ) {
1227 $url = Providers::normalize_url( $m[1] );
1228
1229 if ( ! Providers::is_css_url( $url ) ) {
1230 return $m[0];
1231 }
1232
1233 $imported = $this->downloader->fetch_css( $this->augment_v1_subsets( $url, $effective ) );
1234
1235 if ( null === $imported ) {
1236 return ''; // Drop a dead/blocked provider import.
1237 }
1238
1239 $found = true;
1240
1241 return $this->localize_faces_in_css( $imported, $url );
1242 },
1243 $out
1244 );
1245 $out = is_string( $imported_out ) ? $imported_out : $out;
1246
1247 // 2. Provider @font-face already in the file → localise src in place.
1248 $out = $this->localize_faces_in_css( $out, $base_url, $found );
1249
1250 return $found ? $out : null;
1251 }
1252
1253 /**
1254 * Rewrite every remote provider @font-face in a CSS string to a locally
1255 * hosted copy: download the binary, register the variant (honouring the
1256 * user's enable/disable + subset choices), and replace the block with a
1257 * clean local @font-face. Non-provider faces are left untouched.
1258 *
1259 * @param string $css CSS string.
1260 * @param string $source Source URL (for provenance + absolutising).
1261 * @param bool $found Set true if any provider face was localised (by ref).
1262 * @return string
1263 */
1264 private function localize_faces_in_css( string $css, string $source, bool &$found = false ): string {
1265 $blocks = $this->parser->blocks( $css );
1266
1267 if ( empty( $blocks ) ) {
1268 return $css;
1269 }
1270
1271 $variable_map = $this->parser->variable_families( $blocks );
1272 $disabled = $this->registry->disabled_keys();
1273 $display = (string) Settings::get( 'font_display', 'swap' );
1274 $effective = $this->effective_subsets();
1275
1276 foreach ( $blocks as $block ) {
1277 if ( ! $this->parser->is_remote_origin( $block['full'] ) ) {
1278 continue;
1279 }
1280
1281 $props = $this->parser->properties( $block['body'] );
1282 $family = $props['family'];
1283 $src = $props['src'];
1284
1285 if ( '' === $family || '' === $src ) {
1286 continue;
1287 }
1288
1289 $src = Providers::normalize_url( $src, $source );
1290 $subset = isset( $block['lead'] ) ? $this->clean_subset( $block['lead'] ) : '';
1291
1292 if ( '' === $subset ) {
1293 $subset = FontFaceParser::subset_from_unicode_range( $props['unicode_range'] );
1294 }
1295
1296 // Subset filter (baseline-inclusive). Drop an unselected, non-baseline
1297 // subset face from the sheet entirely.
1298 if ( '' !== $subset && ! in_array( $subset, $effective, true ) ) {
1299 $css = $this->str_replace_once( $block['full'], '', $css );
1300 continue;
1301 }
1302
1303 $subset_key = '' !== $subset ? $subset : 'latin';
1304 $is_variable = isset( $variable_map[ strtolower( $family ) ] )
1305 || (bool) preg_match( '/\d+\s+\d+/', $props['weight'] );
1306
1307 $variant = array(
1308 'family' => $family,
1309 'weight' => $props['weight'],
1310 'style' => $props['style'],
1311 'subset' => $subset_key,
1312 'unicode_range' => $props['unicode_range'],
1313 'is_variable' => $is_variable,
1314 'provider' => Providers::id_for( $src ),
1315 'source' => $source,
1316 );
1317
1318 $key = Registry::variant_key( $family, $props['weight'], $props['style'], $subset_key );
1319
1320 // Disabled variant → register (so it shows in the admin to re-enable)
1321 // but remove the face so it doesn't load.
1322 if ( isset( $disabled[ $key ] ) ) {
1323 $this->register_variant( $variant, '', 0 );
1324 $css = $this->str_replace_once( $block['full'], '', $css );
1325 $found = true;
1326 continue;
1327 }
1328
1329 $basename = $this->basename_for( $variant );
1330 $font_file = $this->locate_font( $basename );
1331 $size = $font_file ? $this->storage->size( $font_file ) : 0;
1332
1333 if ( null === $font_file ) {
1334 // As in host(): never download on a visitor render, and honour the
1335 // warm budget. Mark missing and leave the original @font-face src
1336 // in place so the font keeps loading from the provider until warm.
1337 if ( ! Downloader::fonts_allowed() || ! $this->within_budget() ) {
1338 $this->missing = true;
1339 continue;
1340 }
1341
1342 $dl = $this->downloader->fetch_font( $src, $basename, $this->storage );
1343
1344 if ( null === $dl ) {
1345 $this->missing = true;
1346 continue; // Leave the original src if the download fails.
1347 }
1348
1349 $this->dl_count++;
1350 $font_file = $dl['file'];
1351 $size = $dl['size'];
1352 }
1353
1354 $local_url = add_query_arg( 'ver', Settings::buster(), $this->storage->url( $font_file ) );
1355
1356 $this->register_variant( $variant, $font_file, $size );
1357
1358 $css = $this->str_replace_once( $block['full'], rtrim( $this->build_face_rule( $variant, $local_url, $display ) ), $css );
1359
1360 self::$touched[] = array(
1361 'family' => $family,
1362 'weight' => $props['weight'],
1363 'style' => $props['style'],
1364 );
1365
1366 $found = true;
1367 }
1368
1369 return $css;
1370 }
1371
1372 /**
1373 * Register (idempotent upsert) one variant.
1374 *
1375 * @param array<string,mixed> $v Variant.
1376 * @param string $font_file Cache-relative font file ('' if disabled/none).
1377 * @param int $size File size in bytes.
1378 * @return void
1379 */
1380 private function register_variant( array $v, string $font_file, int $size = 0 ): void {
1381 $this->registry->upsert(
1382 array(
1383 'family' => $v['family'],
1384 'weight' => $v['weight'],
1385 'style' => $v['style'],
1386 'subset' => $v['subset'],
1387 'is_variable' => $v['is_variable'],
1388 'provider' => $v['provider'],
1389 'css_file' => '',
1390 'font_file' => $font_file,
1391 'file_size' => $size,
1392 'source_url' => $v['source'] ?? '',
1393 )
1394 );
1395 }
1396
1397 /**
1398 * Replace only the first occurrence of a substring.
1399 *
1400 * @param string $needle Needle.
1401 * @param string $replacement Replacement.
1402 * @param string $haystack Haystack.
1403 * @return string
1404 */
1405 private function str_replace_once( string $needle, string $replacement, string $haystack ): string {
1406 $pos = strpos( $haystack, $needle );
1407
1408 if ( false === $pos ) {
1409 return $haystack;
1410 }
1411
1412 return substr_replace( $haystack, $replacement, $pos, strlen( $needle ) );
1413 }
1414
1415 /**
1416 * Effective subset set: exactly what the user selected. Latin is no longer
1417 * force-included — users may deselect it. As a safety net, if the selection
1418 * is completely empty we fall back to latin so a site is never left without
1419 * any matching faces (which would drop every identifiable-subset font).
1420 *
1421 * @return string[]
1422 */
1423 private function effective_subsets(): array {
1424 $selected = array_values( array_filter( array_map( 'strval', (array) Settings::get( 'subsets', array( 'latin' ) ) ) ) );
1425
1426 if ( empty( $selected ) ) {
1427 return array( 'latin' );
1428 }
1429
1430 return array_values( array_unique( $selected ) );
1431 }
1432
1433 /**
1434 * Append/merge a global &subset= param onto a Google CSS v1 request so the
1435 * API returns the requested scripts. Non-v1 URLs are returned unchanged.
1436 *
1437 * @param string $url URL.
1438 * @param string[] $subsets Subsets to ensure are requested.
1439 * @return string
1440 */
1441 private function augment_v1_subsets( string $url, array $subsets ): string {
1442 // Only Google v1 (/css?) honours the global subset param; v2 (/css2) and
1443 // other providers control subsets differently.
1444 if ( false === strpos( $url, 'fonts.googleapis.com/css' ) || false !== strpos( $url, '/css2' ) ) {
1445 return $url;
1446 }
1447
1448 $existing = array();
1449
1450 if ( preg_match( '/[?&]subset=([^&]+)/i', $url, $m ) ) {
1451 $existing = explode( ',', strtolower( $m[1] ) );
1452 }
1453
1454 $merged = array_values( array_unique( array_filter( array_merge( $existing, $subsets ) ) ) );
1455
1456 // Strip any existing subset param, then re-append the merged list.
1457 $url = (string) preg_replace( '/([?&])subset=[^&]*/i', '$1', $url );
1458 $url = rtrim( $url, '?&' );
1459 $sep = false === strpos( $url, '?' ) ? '?' : '&';
1460
1461 return $url . $sep . 'subset=' . implode( ',', $merged );
1462 }
1463
1464 /**
1465 * Absolutize relative url() references against a base URL so a cleaned copy
1466 * served from /uploads/easyfonts/ still resolves theme assets correctly.
1467 *
1468 * @param string $css CSS.
1469 * @param string $base_url Base URL of the original file.
1470 * @return string
1471 */
1472 private function absolutize_urls( string $css, string $base_url ): string {
1473 $out = preg_replace_callback(
1474 '/url\(\s*([\'"]?)([^\'")]+)\1\s*\)/i',
1475 function ( $m ) use ( $base_url ) {
1476 $url = trim( $m[2] );
1477
1478 if ( '' === $url
1479 || 0 === strpos( $url, 'data:' )
1480 || 0 === strpos( $url, '//' )
1481 || 0 === strpos( $url, '/' )
1482 || preg_match( '#^https?://#i', $url ) ) {
1483 return $m[0];
1484 }
1485
1486 return "url('" . Providers::normalize_url( $url, $base_url ) . "')";
1487 },
1488 $css
1489 );
1490
1491 return is_string( $out ) ? $out : $css;
1492 }
1493
1494 /**
1495 * Normalize a subset comment token (e.g. "latin", "[0]" → '').
1496 *
1497 * @param string $lead Raw comment text.
1498 * @return string
1499 */
1500 private function clean_subset( string $lead ): string {
1501 $lead = strtolower( trim( $lead ) );
1502
1503 return preg_match( '/^[a-z]+(?:-[a-z]+)?$/', $lead ) ? $lead : '';
1504 }
1505
1506 /**
1507 * Does this CSS reference any provider host? (cheap pre-filter)
1508 *
1509 * @param string $css CSS.
1510 * @return bool
1511 */
1512 private function mentions_provider( string $css ): bool {
1513 foreach ( Providers::hint_hosts() as $host ) {
1514 if ( false !== strpos( $css, $host ) ) {
1515 return true;
1516 }
1517 }
1518
1519 return false;
1520 }
1521
1522 /**
1523 * Register a provider CSS URL to fetch.
1524 *
1525 * @param string $url Normalized URL.
1526 * @param string $detector Detector id ('' if from a nested source).
1527 */
1528 private function add_css_url( string $url, string $detector ): void {
1529 $this->css_urls[ $url ] = Providers::id_for( $url );
1530
1531 if ( '' !== $detector ) {
1532 $this->mark( $detector );
1533 }
1534 }
1535
1536 /**
1537 * Flag a detector as having contributed.
1538 *
1539 * @param string $detector Detector id.
1540 */
1541 private function mark( string $detector ): void {
1542 $this->contributed[ $detector ] = true;
1543 }
1544 }
1545