PluginProbe
EasyFonts – Host Google Fonts Locally, Fast & Auto-Optimize, GDPR Compliant / 2.0.0
EasyFonts – Host Google Fonts Locally, Fast & Auto-Optimize, GDPR Compliant v2.0.0
2.0.4 2.0.3 2.0.2 2.0.1 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.2 1.3 2.0.0
easyfonts / src / Detect / Consolidator.php

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

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