| @@ -1,30 +1,49 @@ | ||
| 1 | 1 | <?php |
| 2 | 2 | /** |
| 3 | 3 | * Smart preload. |
| 4 | 4 | * |
| 5 | - * Emits <link rel="preload"> for the font variants the user has marked to | |
| 6 | - * preload (Fonts screen → per-variant Preload toggle). Results are deduped by | |
| 7 | - * file and capped, and the output buffer positions them ABOVE the consolidated | |
| 8 | - * stylesheet so the browser starts fetching them as early as possible. | |
| 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 | 9 | * |
| 10 | - * When "smart preload" is enabled, the beacon auto-marks above-the-fold variants | |
| 11 | - * as preloaded after a real visit (see RestApi::beacon), so this populates with | |
| 12 | - * sensible defaults that the user can then refine. | |
| 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). | |
| 13 | 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 | + * | |
| 14 | 31 | * @package EasyFonts |
| 15 | 32 | */ |
| 16 | 33 | |
| 17 | 34 | namespace EasyFonts\Frontend; |
| 18 | 35 | |
| 36 | +use EasyFonts\Detect\Consolidator; | |
| 19 | 37 | use EasyFonts\Fonts\Registry; |
| 20 | 38 | use EasyFonts\Fonts\Storage; |
| 39 | +use EasyFonts\Fonts\UsageTracker; | |
| 21 | 40 | use EasyFonts\Settings; |
| 22 | 41 | |
| 23 | 42 | defined( 'ABSPATH' ) || exit; |
| 24 | 43 | |
| 25 | 44 | /** |
| 26 | - * Builds deduped preload link markup. | |
| 45 | + * Builds deduped, page-scoped preload link markup. | |
| 27 | 46 | */ |
| 28 | 47 | class SmartPreload { |
| 29 | 48 | |
| 30 | 49 | /** |
| @@ -47,48 +66,249 @@ | ||
| 47 | 66 | |
| 48 | 67 | /** |
| 49 | 68 | * Build preload markup for the current request. |
| 50 | 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'). | |
| 51 | 74 | * @return string |
| 52 | 75 | */ |
| 53 | - public function build(): string { | |
| 76 | + public function build( string $route = '', string $device = 'any', array $present_families = array(), array $fold_faces = array() ): string { | |
| 54 | 77 | if ( ! Settings::get( 'smart_preload', 1 ) ) { |
| 55 | 78 | return ''; |
| 56 | 79 | } |
| 57 | 80 | |
| 58 | - $targets = $this->registry->preload_targets(); | |
| 81 | + $cap = max( 1, (int) apply_filters( 'easyfonts_preload_cap', 3 ) ); | |
| 59 | 82 | |
| 60 | - if ( empty( $targets ) ) { | |
| 61 | - return ''; | |
| 62 | - } | |
| 63 | - | |
| 64 | 83 | $links = ''; |
| 65 | 84 | $seen = array(); |
| 66 | 85 | $count = 0; |
| 67 | 86 | |
| 68 | - foreach ( $targets as $t ) { | |
| 69 | - if ( $count >= 6 ) { | |
| 70 | - break; // Hard cap — preloading too much hurts more than it helps. | |
| 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; | |
| 71 | 93 | } |
| 72 | 94 | |
| 73 | - $file = $t['file']; | |
| 95 | + if ( $gate && empty( $present_families[ strtolower( (string) $u['family'] ) ] ) ) { | |
| 96 | + continue; // forced font isn't on this page — don't preload here. | |
| 97 | + } | |
| 74 | 98 | |
| 75 | - if ( '' === $file || isset( $seen[ $file ] ) || ! $this->storage->exists( $file ) ) { | |
| 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 { | |
| 76 | 163 | continue; |
| 77 | 164 | } |
| 78 | 165 | |
| 79 | - $seen[ $file ] = true; | |
| 166 | + $list = array(); | |
| 80 | 167 | |
| 81 | - $url = add_query_arg( 'ver', Settings::buster(), $this->storage->url( $file ) ); | |
| 82 | - $links .= sprintf( | |
| 83 | - '<link rel="preload" href="%s" as="font" type="%s" crossorigin>' . "\n", | |
| 84 | - esc_url( $url ), | |
| 85 | - esc_attr( $this->mime_for( $file ) ) | |
| 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 | + } | |
| 86 | 182 | ); |
| 87 | - $count++; | |
| 183 | + | |
| 184 | + $lists[] = $list; | |
| 88 | 185 | } |
| 89 | 186 | |
| 90 | - 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; | |
| 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; | |
| 91 | 311 | } |
| 92 | 312 | |
| 93 | 313 | /** |
| 94 | 314 | * MIME for a font file. |