| @@ -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 ) ) |