PluginProbe
EasyFonts – Host Google Fonts Locally, Fast & Auto-Optimize, GDPR Compliant / 2.0.1
EasyFonts – Host Google Fonts Locally, Fast & Auto-Optimize, GDPR Compliant v2.0.1
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.1, at src/Detect/Consolidator.php

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