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

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

332 lines 9.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Smart preload.
4 *
5 * Emits <link rel="preload"> for the font faces that render ABOVE THE FOLD on
6 * the current page, so the browser fetches them as early as possible. Preload
7 * is a scarce resource — preloading a font that isn't critical (or isn't even
8 * on the page) competes with the hero content — so the set is built narrowly:
9 *
10 * 1. USER-FORCED variants — toggled to "always preload". First, so an
11 * explicit choice never loses its slot to an automatic one.
12 * 2. PER-ROUTE above-the-fold faces — measured by the beacon for THIS route.
13 * A font above the fold only on one page is preloaded only there.
14 * 3. GLOBAL above-the-fold faces — for families this route hasn't measured:
15 * the faces of families that are above the fold on every route where they
16 * render (e.g. the body/heading font), preloaded wherever present without
17 * any per-route lookup (the cheap path the site-wide fonts take).
18 *
19 * (2.0.4) Faces, not families: a family with seven weights used to fill every
20 * slot with weights the page never shows, in declaration order, while the one
21 * it did show came last and was cut by the cap. At most 3 per page by default
22 * (was 6) — fonts compete with the hero image — shared round-robin, so every
23 * above-the-fold family gets a slot before any family gets a second.
24 *
25 * Every candidate is intersected with the families actually emitted on this
26 * page (Consolidator::touched), and resolved to the file that is genuinely
27 * hosted, in the subset of the page language — so a font is never preloaded
28 * on a page that doesn't use it, and a face the beacon reported at a weight we
29 * don't host still resolves to the right file.
30 *
31 * @package EasyFonts
32 */
33
34 namespace EasyFonts\Frontend;
35
36 use EasyFonts\Detect\Consolidator;
37 use EasyFonts\Fonts\Registry;
38 use EasyFonts\Fonts\Storage;
39 use EasyFonts\Fonts\UsageTracker;
40 use EasyFonts\Settings;
41
42 defined( 'ABSPATH' ) || exit;
43
44 /**
45 * Builds deduped, page-scoped preload link markup.
46 */
47 class SmartPreload {
48
49 /**
50 * @var Registry
51 */
52 private Registry $registry;
53
54 /**
55 * @var Storage
56 */
57 private Storage $storage;
58
59 /**
60 * Constructor.
61 */
62 public function __construct() {
63 $this->registry = new Registry();
64 $this->storage = new Storage();
65 }
66
67 /**
68 * Build preload markup for the current request.
69 *
70 * @param string $route Canonical route (path) of the current page.
71 * @param string $device 'mobile'|'desktop'|'any' (reserved).
72 * @param array<string,bool> $present_families Lowercased families emitted on this page (from Consolidator::touched).
73 * @param array<string,array<string,bool>> $fold_faces Per family rendered on THIS route: its above-the-fold faces ('weight|style').
74 * @return string
75 */
76 public function build( string $route = '', string $device = 'any', array $present_families = array(), array $fold_faces = array() ): string {
77 if ( ! Settings::get( 'smart_preload', 1 ) ) {
78 return '';
79 }
80
81 $cap = max( 1, (int) apply_filters( 'easyfonts_preload_cap', 3 ) );
82
83 $links = '';
84 $seen = array();
85 $count = 0;
86
87 // 1. User-forced "always preload" variants, intersected with the page.
88 $gate = ! empty( $present_families );
89
90 foreach ( $this->registry->preload_targets() as $u ) {
91 if ( $count >= $cap ) {
92 break;
93 }
94
95 if ( $gate && empty( $present_families[ strtolower( (string) $u['family'] ) ] ) ) {
96 continue; // forced font isn't on this page — don't preload here.
97 }
98
99 if ( $this->emit_link( $u['file'], $seen, $links ) ) {
100 $count++;
101 }
102 }
103
104 // 2 + 3. Above-the-fold faces (per-route, else global) — resolved to the
105 // hosted variant, so a faux-bold/weight-mismatch report still lands
106 // on a real file.
107 $subset = self::page_subset();
108
109 foreach ( $this->candidates( $fold_faces ) as $c ) {
110 if ( $count >= $cap ) {
111 break;
112 }
113
114 $file = $this->registry->resolve_preload_file( $c['family'], $c['weight'], $c['style'], $subset );
115
116 if ( $this->emit_link( $file, $seen, $links ) ) {
117 $count++;
118 }
119 }
120
121 return $links;
122 }
123
124 /**
125 * Above-the-fold faces to preload on this page, best first.
126 *
127 * Per family emitted here: its faces measured above the fold on this route
128 * (none, when it renders only further down); for a family this route hasn't
129 * measured, its global faces. Within a family the most body-like face comes
130 * first (normal style, weight nearest 400); families then take turns, so a
131 * small cap covers every above-the-fold family before a second weight of
132 * any one of them.
133 *
134 * @param array<string,array<string,bool>> $fold_faces Per-route faces by lowercased family.
135 * @return array<int,array{family:string,weight:string,style:string}>
136 */
137 private function candidates( array $fold_faces ): array {
138 // Families in page order, with the emitted spelling (registry lookups
139 // match on it) and their emitted faces (for the 2.0.3 fallback).
140 $names = array();
141 $emitted = array();
142
143 foreach ( Consolidator::touched() as $t ) {
144 $fam = strtolower( (string) $t['family'] );
145
146 if ( ! isset( $names[ $fam ] ) ) {
147 $names[ $fam ] = (string) $t['family'];
148 }
149
150 $emitted[ $fam ][ $t['weight'] . '|' . $t['style'] ] = true;
151 }
152
153 $global = ( new UsageTracker() )->global_preload_faces();
154 $lists = array();
155
156 foreach ( $names as $fam => $family ) {
157 if ( isset( $fold_faces[ $fam ] ) ) {
158 $faces = $fold_faces[ $fam ];
159 } elseif ( isset( $global[ $fam ] ) ) {
160 // `true` = stored by 2.0.3 without faces: the whole family, as then.
161 $faces = true === $global[ $fam ] ? $emitted[ $fam ] : $global[ $fam ];
162 } else {
163 continue;
164 }
165
166 $list = array();
167
168 foreach ( array_keys( $faces ) as $face ) {
169 $parts = explode( '|', (string) $face, 2 );
170 $list[] = array(
171 'family' => $family,
172 'weight' => $parts[0],
173 'style' => $parts[1] ?? 'normal',
174 );
175 }
176
177 usort(
178 $list,
179 function ( $a, $b ) {
180 return self::rank( $a ) <=> self::rank( $b );
181 }
182 );
183
184 $lists[] = $list;
185 }
186
187 $out = array();
188
189 for ( $depth = 0; ; $depth++ ) {
190 $added = false;
191
192 foreach ( $lists as $list ) {
193 if ( isset( $list[ $depth ] ) ) {
194 $out[] = $list[ $depth ];
195 $added = true;
196 }
197 }
198
199 if ( ! $added ) {
200 break;
201 }
202 }
203
204 return $out;
205 }
206
207 /**
208 * Sort key for a face within its family: normal before italic, then the
209 * weight nearest 400 (body text) first.
210 *
211 * @param array{weight:string,style:string} $face Face.
212 * @return int
213 */
214 private static function rank( array $face ): int {
215 return ( 'normal' === $face['style'] ? 0 : 1000 ) + abs( (int) $face['weight'] - 400 );
216 }
217
218 /**
219 * The subset the page's text is set in, from its locale: the one file per
220 * face worth preloading. Latin-script languages — and Chinese/Japanese,
221 * which Google slices into numbered ranges instead — keep 'latin'; a face
222 * whose family lacks the subset falls back to latin in the lookup.
223 *
224 * @return string
225 */
226 private static function page_subset(): string {
227 $locale = (string) get_locale();
228 $lang = strtolower( (string) strtok( $locale, '_-' ) );
229
230 $map = array(
231 'ru' => 'cyrillic',
232 'uk' => 'cyrillic',
233 'bg' => 'cyrillic',
234 'sr' => 'cyrillic',
235 'mk' => 'cyrillic',
236 'be' => 'cyrillic',
237 'kk' => 'cyrillic',
238 'ky' => 'cyrillic',
239 'mn' => 'cyrillic',
240 'tg' => 'cyrillic',
241 'tt' => 'cyrillic',
242 'ba' => 'cyrillic',
243 'sah' => 'cyrillic',
244 'os' => 'cyrillic',
245 'el' => 'greek',
246 'he' => 'hebrew',
247 'ar' => 'arabic',
248 'ary' => 'arabic',
249 'fa' => 'arabic',
250 'ur' => 'arabic',
251 'ps' => 'arabic',
252 'ckb' => 'arabic',
253 'ug' => 'arabic',
254 'skr' => 'arabic',
255 'hi' => 'devanagari',
256 'mr' => 'devanagari',
257 'ne' => 'devanagari',
258 'bn' => 'bengali',
259 'as' => 'bengali',
260 'gu' => 'gujarati',
261 'pa' => 'gurmukhi',
262 'ta' => 'tamil',
263 'te' => 'telugu',
264 'kn' => 'kannada',
265 'ml' => 'malayalam',
266 'si' => 'sinhala',
267 'or' => 'oriya',
268 'th' => 'thai',
269 'km' => 'khmer',
270 'my' => 'myanmar',
271 'bo' => 'tibetan',
272 'ko' => 'korean',
273 );
274
275 /**
276 * Subset to preload on this page (Google subset name, e.g. 'latin',
277 * 'cyrillic', 'greek').
278 *
279 * @param string $subset Subset derived from the locale.
280 * @param string $locale Locale.
281 */
282 return (string) apply_filters( 'easyfonts_preload_subset', $map[ $lang ] ?? 'latin', $locale );
283 }
284
285 /**
286 * Append a preload <link> for a hosted file, deduping by file and skipping
287 * anything missing on disk. Returns true when a link was added.
288 *
289 * @param string|null $file Font filename.
290 * @param array<string,bool> $seen Dedupe set (by reference).
291 * @param string $links Accumulated markup (by reference).
292 * @return bool
293 */
294 private function emit_link( ?string $file, array &$seen, string &$links ): bool {
295 $file = (string) $file;
296
297 if ( '' === $file || isset( $seen[ $file ] ) || ! $this->storage->exists( $file ) ) {
298 return false;
299 }
300
301 $seen[ $file ] = true;
302
303 $url = $this->storage->font_url( $file );
304 $links .= sprintf(
305 '<link rel="preload" href="%s" as="font" type="%s" crossorigin>' . "\n",
306 esc_url( $url ),
307 esc_attr( $this->mime_for( $file ) )
308 );
309
310 return true;
311 }
312
313 /**
314 * MIME for a font file.
315 *
316 * @param string $file Filename.
317 * @return string
318 */
319 private function mime_for( string $file ): string {
320 $ext = strtolower( (string) pathinfo( $file, PATHINFO_EXTENSION ) );
321
322 $map = array(
323 'woff2' => 'font/woff2',
324 'woff' => 'font/woff',
325 'ttf' => 'font/ttf',
326 'otf' => 'font/otf',
327 );
328
329 return $map[ $ext ] ?? 'font/woff2';
330 }
331 }
332