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
← All changes | src/Frontend/SmartPreload.php +201 -45 2.0.2 → 2.0.4 View file →
@@ -6,21 +6,29 @@
6 6 * the current page, so the browser fetches them as early as possible. Preload
7 7 * is a scarce resource — preloading a font that isn't critical (or isn't even
8 8 * on the page) competes with the hero content — so the set is built narrowly:
9 9 *
10 - * 1. PER-ROUTE above-the-fold families — measured by the beacon for THIS
11 - * route. A font above the fold only on one page is preloaded only there.
12 - * 2. GLOBAL above-the-fold families — families that are above the fold on
13 - * every route where they render (e.g. the body/heading font). Their answer
14 - * is the same everywhere, so they're preloaded wherever present without any
15 - * per-route lookup (the cheap path the site-wide fonts take).
16 - * 3. USER-FORCED families — variants the user toggled to "always preload".
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).
17 18 *
18 - * Every candidate is intersected with the faces actually emitted on this page
19 - * (Consolidator::touched), and resolved to the file that is genuinely hosted —
20 - * so a font is never preloaded on a page that doesn't use it, and a face the
21 - * beacon reported at a weight we don't host still resolves to the right file.
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.
22 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 + *
23 31 * @package EasyFonts
24 32 */
25 33
26 34 namespace EasyFonts\Frontend;
@@ -58,76 +66,224 @@
58 66
59 67 /**
60 68 * Build preload markup for the current request.
61 69 *
62 - * @param string $route Canonical route (path) of the current page.
63 - * @param string $device 'mobile'|'desktop'|'any' (reserved; matching is family-level).
64 - * @param array<string,bool> $present_families Lowercased families emitted on this page (from Consolidator::touched).
65 - * @param array<string,bool> $above_fold Lowercased families measured above the fold on THIS route.
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').
66 74 * @return string
67 75 */
68 - public function build( string $route = '', string $device = 'any', array $present_families = array(), array $above_fold = array() ): string {
76 + public function build( string $route = '', string $device = 'any', array $present_families = array(), array $fold_faces = array() ): string {
69 77 if ( ! Settings::get( 'smart_preload', 1 ) ) {
70 78 return '';
71 79 }
72 80
73 - // Families that should preload here: this route's above-the-fold set,
74 - // plus the uniformly-above-the-fold (global) set. The global set is the
75 - // "skip per-route checking" path — its members preload wherever present.
76 - $want = $above_fold;
81 + $cap = max( 1, (int) apply_filters( 'easyfonts_preload_cap', 3 ) );
77 82
78 - foreach ( ( new UsageTracker() )->global_preload_families() as $fam => $_ ) {
79 - $want[ $fam ] = true;
80 - }
81 -
82 - $cap = max( 1, (int) apply_filters( 'easyfonts_preload_cap', 6 ) );
83 -
84 83 $links = '';
85 84 $seen = array();
86 85 $count = 0;
87 86
88 - // 1. Above-the-fold (per-route + global): preload the faces this page
89 - // actually emits for those families — using the hosted variant, so a
90 - // faux-bold/weight-mismatch report still resolves to a real file.
91 - foreach ( Consolidator::touched() as $t ) {
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 ) {
92 91 if ( $count >= $cap ) {
93 92 break;
94 93 }
95 94
96 - $fam = strtolower( (string) $t['family'] );
95 + if ( $gate && empty( $present_families[ strtolower( (string) $u['family'] ) ] ) ) {
96 + continue; // forced font isn't on this page — don't preload here.
97 + }
97 98
98 - if ( empty( $want[ $fam ] ) ) {
99 - continue;
99 + if ( $this->emit_link( $u['file'], $seen, $links ) ) {
100 + $count++;
100 101 }
102 + }
101 103
102 - $file = $this->registry->resolve_preload_file( (string) $t['family'], (string) $t['weight'], (string) $t['style'] );
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();
103 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 +
104 116 if ( $this->emit_link( $file, $seen, $links ) ) {
105 117 $count++;
106 118 }
107 119 }
108 120
109 - // 2. User-forced "always preload" variants, intersected with the page.
110 - $gate = ! empty( $present_families );
121 + return $links;
122 + }
111 123
112 - foreach ( $this->registry->preload_targets() as $u ) {
113 - if ( $count >= $cap ) {
114 - break;
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'];
115 148 }
116 149
117 - if ( $gate && empty( $present_families[ strtolower( (string) $u['family'] ) ] ) ) {
118 - continue; // forced font isn't on this page — don't preload here.
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;
119 164 }
120 165
121 - if ( $this->emit_link( $u['file'], $seen, $links ) ) {
122 - $count++;
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 + );
123 175 }
176 +
177 + usort(
178 + $list,
179 + function ( $a, $b ) {
180 + return self::rank( $a ) <=> self::rank( $b );
181 + }
182 + );
183 +
184 + $lists[] = $list;
124 185 }
125 186
126 - return $links;
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;
127 205 }
128 206
129 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 + /**
130 286 * Append a preload <link> for a hosted file, deduping by file and skipping
131 287 * anything missing on disk. Returns true when a link was added.
132 288 *
133 289 * @param string|null $file Font filename.
@@ -143,9 +299,9 @@
143 299 }
144 300
145 301 $seen[ $file ] = true;
146 302
147 - $url = add_query_arg( 'ver', Settings::buster(), $this->storage->url( $file ) );
303 + $url = $this->storage->font_url( $file );
148 304 $links .= sprintf(
149 305 '<link rel="preload" href="%s" as="font" type="%s" crossorigin>' . "\n",
150 306 esc_url( $url ),
151 307 esc_attr( $this->mime_for( $file ) )