| @@ -92,8 +92,93 @@ | ||
| 92 | 92 | */ |
| 93 | 93 | private array $contributed = array(); |
| 94 | 94 | |
| 95 | 95 | /** |
| 96 | + * Set true when an enabled variant the page needs is NOT yet cached and we | |
| 97 | + * couldn't download it on this request (visitor render = downloads disabled, | |
| 98 | + * or warm-budget exhausted). When true, run() leaves the page untouched and | |
| 99 | + * signals a background warm so the cache fills off the render path. | |
| 100 | + * | |
| 101 | + * @var bool | |
| 102 | + */ | |
| 103 | + private bool $missing = false; | |
| 104 | + | |
| 105 | + /** | |
| 106 | + * Number of local/external stylesheets rewritten in place to cleaned copies | |
| 107 | + * this request (independent of the consolidated provider stylesheet). | |
| 108 | + * | |
| 109 | + * @var int | |
| 110 | + */ | |
| 111 | + private int $local_rewrites = 0; | |
| 112 | + | |
| 113 | + /** | |
| 114 | + * Font binaries downloaded so far this request (warm-budget counter). | |
| 115 | + * | |
| 116 | + * @var int | |
| 117 | + */ | |
| 118 | + private int $dl_count = 0; | |
| 119 | + | |
| 120 | + /** | |
| 121 | + * Monotonic start time of the first download this request (warm budget). | |
| 122 | + * | |
| 123 | + * @var float | |
| 124 | + */ | |
| 125 | + private float $dl_started = 0.0; | |
| 126 | + | |
| 127 | + /** | |
| 128 | + * Lowercased families the beacon has CONFIRMED as loaded-but-never-rendered | |
| 129 | + * on the current route — the only families page scoping drops. A font that | |
| 130 | + * hasn't been measured yet (newly declared, just added/replaced) is NOT in | |
| 131 | + * here, so it always loads until a beacon has judged it. See set_page_scope(). | |
| 132 | + * | |
| 133 | + * @var array<string,bool> | |
| 134 | + */ | |
| 135 | + private array $scope_out = array(); | |
| 136 | + | |
| 137 | + /** | |
| 138 | + * Whether the current route has been measured by a beacon. When false we | |
| 139 | + * never scope (the scope-out set is empty anyway); kept so the page-scope | |
| 140 | + * feature can be filtered off wholesale. | |
| 141 | + * | |
| 142 | + * @var bool | |
| 143 | + */ | |
| 144 | + private bool $route_measured = false; | |
| 145 | + | |
| 146 | + /** | |
| 147 | + * Provide the per-route scope-out set before run(). A declared font whose | |
| 148 | + * family is listed here is left out of THIS page's stylesheet (the beacon | |
| 149 | + * measured it loaded but it never rendered here). The font stays registered | |
| 150 | + * and is still hosted for the routes that use it. | |
| 151 | + * | |
| 152 | + * @param array<string,bool> $scope_out Lowercased family => true. | |
| 153 | + * @param bool $measured Route has beacon data. | |
| 154 | + * @return void | |
| 155 | + */ | |
| 156 | + public function set_page_scope( array $scope_out, bool $measured ): void { | |
| 157 | + $this->scope_out = $scope_out; | |
| 158 | + $this->route_measured = $measured; | |
| 159 | + } | |
| 160 | + | |
| 161 | + /** | |
| 162 | + * True when the most recent run found enabled fonts that aren't cached yet | |
| 163 | + * and downloads were disabled (visitor render) — i.e. a background warm is | |
| 164 | + * needed. Read by the output buffer to spawn a non-blocking loopback. | |
| 165 | + * | |
| 166 | + * @var bool | |
| 167 | + */ | |
| 168 | + private static bool $needs_warm = false; | |
| 169 | + | |
| 170 | + /** | |
| 171 | + * True when the most recent run actually committed a localization (a | |
| 172 | + * consolidated stylesheet and/or an in-place local stylesheet rewrite). The | |
| 173 | + * output buffer uses this to decide whether to inject head markup / strip | |
| 174 | + * resource hints. | |
| 175 | + * | |
| 176 | + * @var bool | |
| 177 | + */ | |
| 178 | + private static bool $localized = false; | |
| 179 | + | |
| 180 | + /** | |
| 96 | 181 | * Variants hosted this request (for fallbacks + usage logging). |
| 97 | 182 | * |
| 98 | 183 | * @var array<int,array{family:string,weight:string,style:string}> |
| 99 | 184 | */ |
| @@ -123,8 +208,29 @@ | ||
| 123 | 208 | return self::$touched; |
| 124 | 209 | } |
| 125 | 210 | |
| 126 | 211 | /** |
| 212 | + * Does the current page need a background cache-warm? True when an enabled | |
| 213 | + * font isn't cached yet and downloads were disabled for this (visitor) | |
| 214 | + * render. | |
| 215 | + * | |
| 216 | + * @return bool | |
| 217 | + */ | |
| 218 | + public static function needs_warm(): bool { | |
| 219 | + return self::$needs_warm; | |
| 220 | + } | |
| 221 | + | |
| 222 | + /** | |
| 223 | + * Did the most recent run commit a localization (so the output buffer should | |
| 224 | + * inject preloads/fallbacks and strip provider hints)? | |
| 225 | + * | |
| 226 | + * @return bool | |
| 227 | + */ | |
| 228 | + public static function localized(): bool { | |
| 229 | + return self::$localized; | |
| 230 | + } | |
| 231 | + | |
| 232 | + /** | |
| 127 | 233 | * Consolidated stylesheet URL from the most recent run. |
| 128 | 234 | * |
| 129 | 235 | * @return string |
| 130 | 236 | */ |
| @@ -156,8 +262,16 @@ | ||
| 156 | 262 | |
| 157 | 263 | /** |
| 158 | 264 | * Run the full consolidation pass over a document. |
| 159 | 265 | * |
| 266 | + * Transactional + fail-safe: detection/rewriting happens on a working copy. | |
| 267 | + * The rewritten page is only returned ("committed") when EVERY enabled font | |
| 268 | + * it needs is already served locally. If any needed font isn't cached yet and | |
| 269 | + * downloads are disabled for this request (a normal visitor render never | |
| 270 | + * downloads — that's what used to stall and white-screen the site), the | |
| 271 | + * ORIGINAL page is returned unchanged (so the original Google Fonts keep | |
| 272 | + * working — no FOUC, no missing text) and a background warm is flagged. | |
| 273 | + * | |
| 160 | 274 | * @param string $html Full HTML. |
| 161 | 275 | * @param bool $force Reserved (probe mode); detection is identical. |
| 162 | 276 | * @return string |
| 163 | 277 | */ |
| @@ -163,50 +277,134 @@ | ||
| 163 | 277 | */ |
| 164 | 278 | public function run( string $html, bool $force = false ): string { |
| 165 | 279 | unset( $force ); |
| 166 | 280 | |
| 281 | + $original = $html; | |
| 282 | + | |
| 167 | 283 | self::$stylesheet_url = ''; |
| 168 | 284 | self::$stylesheet_file = ''; |
| 285 | + self::$needs_warm = false; | |
| 286 | + self::$localized = false; | |
| 287 | + $this->missing = false; | |
| 288 | + $this->local_rewrites = 0; | |
| 169 | 289 | |
| 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 ); | |
| 290 | + // 1. COLLECT (each step may rewrite the working HTML — e.g. strip inline | |
| 291 | + // blocks, or rewrite a theme stylesheet to a cleaned local copy). We | |
| 292 | + // keep the untouched $original to fall back to. | |
| 293 | + $working = $this->collect_from_links( $html ); | |
| 294 | + $working = $this->collect_from_inline( $working ); | |
| 295 | + $working = $this->collect_from_webfont( $working ); | |
| 174 | 296 | $this->collect_async_urls(); |
| 175 | 297 | |
| 176 | - // Physically remove the provider <link> tags we flagged. | |
| 177 | - $html = $this->html->remove_stylesheet_links( $html, array_values( array_unique( $this->remove_links ) ) ); | |
| 298 | + // Backstop: nothing in collection may ever blank the document. If a step | |
| 299 | + // somehow emptied a non-empty page, discard the working copy entirely. | |
| 300 | + if ( '' === $working && '' !== $original ) { | |
| 301 | + return $original; | |
| 302 | + } | |
| 178 | 303 | |
| 179 | - if ( empty( $this->css_urls ) && empty( $this->raw_blocks ) ) { | |
| 180 | - return $html; | |
| 304 | + $has_provider = ! empty( $this->css_urls ) || ! empty( $this->raw_blocks ); | |
| 305 | + | |
| 306 | + // Nothing font-related found at all → return the page untouched. | |
| 307 | + if ( ! $has_provider && 0 === $this->local_rewrites ) { | |
| 308 | + return $original; | |
| 181 | 309 | } |
| 182 | 310 | |
| 183 | - // 2. AGGREGATE. | |
| 184 | - $variants = $this->aggregate(); | |
| 311 | + // 2. AGGREGATE + 3. HOST the consolidated (provider <link> / inline) set. | |
| 312 | + // Downloads only happen here when allowed (admin/CLI/warm); on a | |
| 313 | + // visitor render the missing flag is set instead. | |
| 314 | + $file_url = null; | |
| 185 | 315 | |
| 186 | - if ( empty( $variants ) ) { | |
| 187 | - return $html; | |
| 316 | + if ( $has_provider ) { | |
| 317 | + $variants = $this->aggregate(); | |
| 318 | + | |
| 319 | + if ( ! empty( $variants ) ) { | |
| 320 | + $file_url = $this->host( $variants ); | |
| 321 | + } | |
| 188 | 322 | } |
| 189 | 323 | |
| 190 | - // 3. HOST (register + build the consolidated stylesheet). | |
| 191 | - $file_url = $this->host( $variants ); | |
| 324 | + // If ANY enabled font the page needs isn't served locally yet, don't touch | |
| 325 | + // the page — keep the originals working and warm the cache in background. | |
| 326 | + if ( $this->missing ) { | |
| 327 | + return $this->bail( $original ); | |
| 328 | + } | |
| 192 | 329 | |
| 193 | - if ( null === $file_url ) { | |
| 194 | - return $html; | |
| 330 | + // Nothing actually localized (no consolidated sheet built and no local | |
| 331 | + // stylesheet rewritten) → return the page untouched. | |
| 332 | + if ( null === $file_url && 0 === $this->local_rewrites ) { | |
| 333 | + return $this->bail( $original ); | |
| 195 | 334 | } |
| 196 | 335 | |
| 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; | |
| 336 | + // Everything needed is hosted locally. Physically remove the provider | |
| 337 | + // <link> tags we flagged — but only when we built a consolidated | |
| 338 | + // replacement for them (otherwise leave them, so we never drop fonts). | |
| 339 | + if ( null !== $file_url ) { | |
| 340 | + $working = $this->html->remove_stylesheet_links( $working, array_values( array_unique( $this->remove_links ) ) ); | |
| 341 | + self::$stylesheet_url = $file_url; | |
| 342 | + } | |
| 201 | 343 | |
| 344 | + self::$localized = true; | |
| 345 | + | |
| 202 | 346 | foreach ( array_keys( $this->contributed ) as $id ) { |
| 203 | 347 | Settings::learn_detector( $id ); |
| 204 | 348 | } |
| 205 | 349 | |
| 206 | - return $html; | |
| 350 | + return $working; | |
| 207 | 351 | } |
| 208 | 352 | |
| 353 | + /** | |
| 354 | + * Abort the rewrite: clear any partial output state and return the page | |
| 355 | + * exactly as it came in. If fonts are missing only because downloads were | |
| 356 | + * disabled (visitor render), flag a background warm. | |
| 357 | + * | |
| 358 | + * @param string $original Untouched HTML. | |
| 359 | + * @return string | |
| 360 | + */ | |
| 361 | + private function bail( string $original ): string { | |
| 362 | + self::$stylesheet_url = ''; | |
| 363 | + self::$stylesheet_file = ''; | |
| 364 | + self::$touched = array(); | |
| 365 | + | |
| 366 | + if ( $this->missing && ! Downloader::fonts_allowed() ) { | |
| 367 | + self::$needs_warm = true; | |
| 368 | + } | |
| 369 | + | |
| 370 | + return $original; | |
| 371 | + } | |
| 372 | + | |
| 373 | + /** | |
| 374 | + * Warm-download budget: bounds how much a single warm (probe) request will | |
| 375 | + * fetch, so it can't run long enough to hit max_execution_time. Anything left | |
| 376 | + * over is fetched by the next warm. Always true when downloads are disabled | |
| 377 | + * (the caller handles that case via the missing flag). | |
| 378 | + * | |
| 379 | + * @return bool | |
| 380 | + */ | |
| 381 | + private function within_budget(): bool { | |
| 382 | + if ( 0.0 === $this->dl_started ) { | |
| 383 | + $this->dl_started = microtime( true ); | |
| 384 | + } | |
| 385 | + | |
| 386 | + /** | |
| 387 | + * Max font binaries to download in a single warm request. | |
| 388 | + * | |
| 389 | + * @param int $max | |
| 390 | + */ | |
| 391 | + $max_n = (int) apply_filters( 'easyfonts_warm_max_downloads', 25 ); | |
| 392 | + | |
| 393 | + /** | |
| 394 | + * Max wall-clock seconds to spend downloading in a single warm request. | |
| 395 | + * | |
| 396 | + * @param float $max | |
| 397 | + */ | |
| 398 | + $max_s = (float) apply_filters( 'easyfonts_warm_max_seconds', 25.0 ); | |
| 399 | + | |
| 400 | + if ( $this->dl_count >= $max_n ) { | |
| 401 | + return false; | |
| 402 | + } | |
| 403 | + | |
| 404 | + return ( microtime( true ) - $this->dl_started ) < $max_s; | |
| 405 | + } | |
| 406 | + | |
| 209 | 407 | /* --------------------------------------------------------------------- * |
| 210 | 408 | * 1. COLLECT |
| 211 | 409 | * --------------------------------------------------------------------- */ |
| 212 | 410 | |
| @@ -266,8 +464,9 @@ | ||
| 266 | 464 | return null; |
| 267 | 465 | } |
| 268 | 466 | |
| 269 | 467 | $this->mark( $detector ); |
| 468 | + $this->local_rewrites++; | |
| 270 | 469 | |
| 271 | 470 | return add_query_arg( 'ver', Settings::buster(), $this->storage->url( $filename ) ); |
| 272 | 471 | } |
| 273 | 472 | ); |
| @@ -289,9 +488,9 @@ | ||
| 289 | 488 | function ( $css ) { |
| 290 | 489 | $original = $css; |
| 291 | 490 | |
| 292 | 491 | // Provider @import → collect + remove the statement. |
| 293 | - $css = (string) preg_replace_callback( | |
| 492 | + $replaced = preg_replace_callback( | |
| 294 | 493 | '/@import\s+(?:url\(\s*)?[\'"]?([^\'")\s]+)[\'"]?\s*\)?\s*;?/i', |
| 295 | 494 | function ( $m ) { |
| 296 | 495 | $url = Providers::normalize_url( $m[1] ); |
| 297 | 496 | |
| @@ -303,8 +502,9 @@ | ||
| 303 | 502 | return $m[0]; |
| 304 | 503 | }, |
| 305 | 504 | $css |
| 306 | 505 | ); |
| 506 | + $css = is_string( $replaced ) ? $replaced : $original; | |
| 307 | 507 | |
| 308 | 508 | // Remote @font-face → collect raw block + remove it. |
| 309 | 509 | $blocks = $this->parser->blocks( $css ); |
| 310 | 510 | |
| @@ -398,16 +598,56 @@ | ||
| 398 | 598 | |
| 399 | 599 | $this->add_css_url( $url, 'webfont' ); |
| 400 | 600 | $this->mark( 'webfont' ); |
| 401 | 601 | |
| 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 ); | |
| 602 | + // Remove webfont.js loader scripts + the WebFontConfig block. The src | |
| 603 | + // pattern uses bounded character classes (safe); null-guard it anyway so a | |
| 604 | + // PCRE failure can never blank the page. The WebFontConfig block is removed | |
| 605 | + // by a linear scan instead of a tempered-dot regex — that regex hit the | |
| 606 | + // PCRE2 JIT stack limit on ordinary inline scripts and blanked the page. | |
| 607 | + $stripped = preg_replace( '#<script\b[^>]*\bsrc=[\'"][^\'"]*webfont(?:\.min)?\.js[\'"][^>]*>\s*</script>#i', '', $html ); | |
| 608 | + $html = is_string( $stripped ) ? $stripped : $html; | |
| 609 | + $html = $this->remove_script_block_containing( $html, 'WebFontConfig' ); | |
| 405 | 610 | |
| 406 | 611 | return $html; |
| 407 | 612 | } |
| 408 | 613 | |
| 409 | 614 | /** |
| 615 | + * Remove any <script>…</script> block whose contents contain $needle, using a | |
| 616 | + * linear strpos scan (no regex → no catastrophic backtracking / JIT crash). | |
| 617 | + * | |
| 618 | + * @param string $html HTML. | |
| 619 | + * @param string $needle Marker that must appear inside the script. | |
| 620 | + * @return string | |
| 621 | + */ | |
| 622 | + private function remove_script_block_containing( string $html, string $needle ): string { | |
| 623 | + $offset = 0; | |
| 624 | + | |
| 625 | + while ( true ) { | |
| 626 | + $pos = stripos( $html, $needle, $offset ); | |
| 627 | + | |
| 628 | + if ( false === $pos ) { | |
| 629 | + break; | |
| 630 | + } | |
| 631 | + | |
| 632 | + $start = strripos( substr( $html, 0, $pos ), '<script' ); | |
| 633 | + $gt = false === $start ? false : strpos( $html, '>', $start ); | |
| 634 | + $end = stripos( $html, '</script>', $pos ); | |
| 635 | + | |
| 636 | + // Marker isn't inside a clean <script>…</script> wrapper → skip past it. | |
| 637 | + if ( false === $start || false === $gt || false === $end || $gt > $pos ) { | |
| 638 | + $offset = $pos + strlen( $needle ); | |
| 639 | + continue; | |
| 640 | + } | |
| 641 | + | |
| 642 | + $html = substr( $html, 0, $start ) . substr( $html, $end + 9 ); | |
| 643 | + $offset = $start; | |
| 644 | + } | |
| 645 | + | |
| 646 | + return $html; | |
| 647 | + } | |
| 648 | + | |
| 649 | + /** | |
| 410 | 650 | * Convert one Web Font Loader families[] entry into a structured spec: |
| 411 | 651 | * { family: 'Open Sans', weights: ['400','700'], italics: ['400'], subset: '' } |
| 412 | 652 | * |
| 413 | 653 | * Handles plain names, weight lists, FVD notation ("n4"/"i7"), a trailing |
| @@ -675,10 +915,9 @@ | ||
| 675 | 915 | * --------------------------------------------------------------------- */ |
| 676 | 916 | |
| 677 | 917 | /** |
| 678 | 918 | * 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. | |
| 919 | + * named by its content, so pages sharing fonts share the file. | |
| 681 | 920 | * |
| 682 | 921 | * Registration runs for EVERY variant on every request (idempotent upsert), |
| 683 | 922 | * even when the consolidated file already exists — so the hosted-fonts list |
| 684 | 923 | * can't drift out of sync with what's actually served. Font binaries are |
| @@ -688,71 +927,127 @@ | ||
| 688 | 927 | * @param array<string,array<string,mixed>> $variants Variants by key. |
| 689 | 928 | * @return string|null Local stylesheet URL (cache-busted), or null. |
| 690 | 929 | */ |
| 691 | 930 | private function host( array $variants ): ?string { |
| 692 | - $keys = array_keys( $variants ); | |
| 693 | - sort( $keys ); | |
| 931 | + $display = (string) Settings::get( 'font_display', 'swap' ); | |
| 932 | + $subsets = implode( ',', (array) Settings::get( 'subsets', array() ) ); | |
| 694 | 933 | |
| 695 | - $display = (string) Settings::get( 'font_display', 'swap' ); | |
| 696 | - $subsets = implode( ',', (array) Settings::get( 'subsets', array() ) ); | |
| 934 | + $disabled = $this->registry->disabled_keys(); | |
| 697 | 935 | |
| 698 | - $disabled = $this->registry->disabled_keys(); | |
| 936 | + // Page scoping: when the route has been measured, drop only the fonts a | |
| 937 | + // beacon CONFIRMED are loaded-but-unused here (e.g. a theme enqueues a | |
| 938 | + // global Google-fonts set, but a font only renders on one template). | |
| 939 | + // Matched at the family level and beacon-confirmed, so a newly declared | |
| 940 | + // font is never dropped before it's been judged, and the decision can't | |
| 941 | + // oscillate (it's based on the page's authorial font-family, which holds | |
| 942 | + // even after the font is scoped out). | |
| 943 | + // - family is a confirmed scope-out -> skip on this page | |
| 944 | + // - family in the safelist -> always include | |
| 945 | + // - everything else (used, or new/unjudged) -> include | |
| 946 | + $scope = $this->route_measured | |
| 947 | + && (bool) apply_filters( 'easyfonts_page_scope_fonts', true ); | |
| 699 | 948 | |
| 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 ); | |
| 949 | + $force = array(); | |
| 704 | 950 | |
| 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 ); | |
| 951 | + foreach ( (array) apply_filters( 'easyfonts_force_families', array() ) as $f ) { | |
| 952 | + $force[ strtolower( (string) $f ) ] = true; | |
| 953 | + } | |
| 710 | 954 | |
| 955 | + // Decide, per detected variant, whether it is EMITTED on this page. | |
| 956 | + // Skipped variants are still registered below so they stay visible and | |
| 957 | + // re-enableable, and are still hosted by the routes that use them. | |
| 958 | + $emit = array(); | |
| 959 | + | |
| 960 | + foreach ( $variants as $key => $v ) { | |
| 961 | + if ( isset( $disabled[ $key ] ) ) { | |
| 962 | + continue; | |
| 963 | + } | |
| 964 | + | |
| 965 | + if ( $scope ) { | |
| 966 | + $fam = strtolower( (string) $v['family'] ); | |
| 967 | + | |
| 968 | + if ( isset( $this->scope_out[ $fam ] ) && ! isset( $force[ $fam ] ) ) { | |
| 969 | + continue; | |
| 970 | + } | |
| 971 | + } | |
| 972 | + | |
| 973 | + $emit[ $key ] = true; | |
| 974 | + } | |
| 975 | + | |
| 976 | + // The emitted set (+ display + subsets), recorded on each variant row as | |
| 977 | + // its css_file. The stylesheet itself is named by its CONTENT (below). | |
| 978 | + $emit_keys = array_keys( $emit ); | |
| 979 | + sort( $emit_keys ); | |
| 980 | + $hash = substr( hash( 'sha256', implode( '|', $emit_keys ) . '|' . $display . '|' . $subsets ), 0, 20 ); | |
| 981 | + $file = 'css/' . $hash . '.css'; | |
| 982 | + | |
| 983 | + // M-3: one batched read of the rows we already have for every detected | |
| 984 | + // variant, so registration costs a single SELECT instead of one per | |
| 985 | + // variant. We then only write rows that are new or whose font file | |
| 986 | + // changed — on a warm-cache render that means zero writes. | |
| 987 | + $known = $this->registry->existing_map( array_keys( $variants ) ); | |
| 988 | + | |
| 711 | 989 | $css = "/**\n * Auto Generated by EasyFonts\n * @url: https://fluxpress.io\n */\n"; |
| 712 | 990 | $hosted = 0; |
| 713 | 991 | |
| 714 | 992 | 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 ] ); | |
| 993 | + $is_emitted = isset( $emit[ $key ] ); | |
| 719 | 994 | |
| 720 | 995 | $basename = $this->basename_for( $v ); |
| 721 | 996 | $font_file = $this->locate_font( $basename ); |
| 722 | 997 | $size = $font_file ? $this->storage->size( $font_file ) : 0; |
| 723 | 998 | |
| 724 | - if ( ! $is_disabled && null === $font_file ) { | |
| 999 | + // Only EMITTED variants are required to be hosted on this page. A | |
| 1000 | + // disabled or unloaded variant is never downloaded and never blocks | |
| 1001 | + // the page (it isn't needed here); it may still be downloaded later | |
| 1002 | + // by a route that actually renders it (font binaries are shared). | |
| 1003 | + if ( $is_emitted && null === $font_file ) { | |
| 1004 | + if ( ! Downloader::fonts_allowed() || ! $this->within_budget() ) { | |
| 1005 | + $this->missing = true; | |
| 1006 | + continue; | |
| 1007 | + } | |
| 1008 | + | |
| 725 | 1009 | $download = $this->downloader->fetch_font( $v['src'], $basename, $this->storage ); |
| 726 | 1010 | |
| 727 | 1011 | if ( null === $download ) { |
| 1012 | + // Genuine fetch failure — treat as missing so we don't strip | |
| 1013 | + // the original source and leave the page without this font. | |
| 1014 | + $this->missing = true; | |
| 728 | 1015 | continue; |
| 729 | 1016 | } |
| 730 | 1017 | |
| 1018 | + $this->dl_count++; | |
| 731 | 1019 | $font_file = $download['file']; |
| 732 | 1020 | $size = $download['size']; |
| 733 | 1021 | } |
| 734 | 1022 | |
| 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 | - ); | |
| 1023 | + // Register the variant, but skip the write when we already have it | |
| 1024 | + // with the same font file (the common warm-cache case) — keeps the | |
| 1025 | + // hosted-fonts list in sync without N queries on the hot path. | |
| 1026 | + $current = $known[ $key ] ?? null; | |
| 749 | 1027 | |
| 750 | - if ( $is_disabled || null === $font_file ) { | |
| 1028 | + if ( null === $current || (string) $current !== (string) $font_file ) { | |
| 1029 | + $this->registry->upsert( | |
| 1030 | + array( | |
| 1031 | + 'family' => $v['family'], | |
| 1032 | + 'weight' => $v['weight'], | |
| 1033 | + 'style' => $v['style'], | |
| 1034 | + 'subset' => $v['subset'], | |
| 1035 | + 'is_variable' => $v['is_variable'], | |
| 1036 | + 'provider' => $v['provider'], | |
| 1037 | + 'css_file' => $file, | |
| 1038 | + 'font_file' => (string) $font_file, | |
| 1039 | + 'file_size' => $size, | |
| 1040 | + 'source_url' => $v['source'], | |
| 1041 | + ) | |
| 1042 | + ); | |
| 1043 | + } | |
| 1044 | + | |
| 1045 | + if ( ! $is_emitted || null === $font_file ) { | |
| 751 | 1046 | continue; |
| 752 | 1047 | } |
| 753 | 1048 | |
| 754 | - $local_url = add_query_arg( 'ver', Settings::buster(), $this->storage->url( $font_file ) ); | |
| 1049 | + $local_url = $this->storage->font_url( $font_file ); | |
| 755 | 1050 | $css .= $this->build_face_rule( $v, $local_url, $display ); |
| 756 | 1051 | |
| 757 | 1052 | self::$touched[] = array( |
| 758 | 1053 | 'family' => $v['family'], |
| @@ -766,19 +1061,25 @@ | ||
| 766 | 1061 | if ( 0 === $hosted ) { |
| 767 | 1062 | return null; |
| 768 | 1063 | } |
| 769 | 1064 | |
| 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 ) ) { | |
| 1065 | + // (2.0.4) Named by CONTENT. Keyed by the emitted set alone, a file written | |
| 1066 | + // once outlived any later change to what it should contain (a font | |
| 1067 | + // re-downloaded after Purge, a CDN switch) and kept serving stale URLs, | |
| 1068 | + // as did the inlined copy cached under its name. Identical content still | |
| 1069 | + // shares one file and is never rewritten; older files are left for | |
| 1070 | + // page-cached HTML that may still link them (Purge removes them). | |
| 1071 | + $sheet = 'css/' . substr( hash( 'sha256', $css ), 0, 20 ) . '.css'; | |
| 1072 | + | |
| 1073 | + if ( ! $this->storage->exists( $sheet ) ) { | |
| 1074 | + if ( ! $this->storage->write( $sheet, $css ) ) { | |
| 774 | 1075 | return null; |
| 775 | 1076 | } |
| 776 | 1077 | } |
| 777 | 1078 | |
| 778 | - self::$stylesheet_file = $file; | |
| 1079 | + self::$stylesheet_file = $sheet; | |
| 779 | 1080 | |
| 780 | - return add_query_arg( 'ver', Settings::buster(), $this->storage->url( $file ) ); | |
| 1081 | + return add_query_arg( 'ver', Settings::buster(), $this->storage->url( $sheet ) ); | |
| 781 | 1082 | } |
| 782 | 1083 | |
| 783 | 1084 | /** |
| 784 | 1085 | * Find an already-downloaded font file for a basename (any known extension). |
| @@ -809,22 +1110,34 @@ | ||
| 809 | 1110 | private function build_face_rule( array $v, string $local_url, string $display ): string { |
| 810 | 1111 | $ext = strtolower( (string) pathinfo( (string) wp_parse_url( $local_url, PHP_URL_PATH ), PATHINFO_EXTENSION ) ); |
| 811 | 1112 | $format = 'woff' === $ext ? 'woff' : ( 'ttf' === $ext ? 'truetype' : ( 'otf' === $ext ? 'opentype' : 'woff2' ) ); |
| 812 | 1113 | |
| 1114 | + // Harden every interpolated value against CSS-context breakout. Family / | |
| 1115 | + // weight / style / unicode-range originate from parsed @font-face bodies | |
| 1116 | + // and font URLs, which an author could craft; stripping CSS structural | |
| 1117 | + // characters stops a malicious value from escaping its declaration (and, | |
| 1118 | + // when the sheet is inlined, from breaking out of <style>). Legitimate | |
| 1119 | + // values — "Open Sans", "100 900", "oblique 10deg", "U+0000-00FF" — are | |
| 1120 | + // unaffected, so nothing is lost. | |
| 1121 | + $family = self::css_token( (string) $v['family'] ); | |
| 1122 | + $style = self::css_token( (string) $v['style'] ); | |
| 1123 | + $weight = self::css_token( (string) $v['weight'] ); | |
| 1124 | + $urange = self::css_token( (string) $v['unicode_range'] ); | |
| 1125 | + | |
| 813 | 1126 | $lines = array(); |
| 814 | 1127 | $lines[] = '@font-face {'; |
| 815 | - $lines[] = " font-family: '" . str_replace( "'", '', $v['family'] ) . "';"; | |
| 816 | - $lines[] = ' font-style: ' . $v['style'] . ';'; | |
| 817 | - $lines[] = ' font-weight: ' . $v['weight'] . ';'; | |
| 1128 | + $lines[] = " font-family: '" . $family . "';"; | |
| 1129 | + $lines[] = ' font-style: ' . ( '' !== $style ? $style : 'normal' ) . ';'; | |
| 1130 | + $lines[] = ' font-weight: ' . ( '' !== $weight ? $weight : '400' ) . ';'; | |
| 818 | 1131 | |
| 819 | 1132 | if ( '' !== $display && 'auto' !== $display ) { |
| 820 | - $lines[] = ' font-display: ' . $display . ';'; | |
| 1133 | + $lines[] = ' font-display: ' . self::css_token( $display ) . ';'; | |
| 821 | 1134 | } |
| 822 | 1135 | |
| 823 | 1136 | $lines[] = " src: url('" . esc_url_raw( $local_url ) . "') format('" . $format . "');"; |
| 824 | 1137 | |
| 825 | - if ( '' !== $v['unicode_range'] ) { | |
| 826 | - $lines[] = ' unicode-range: ' . $v['unicode_range'] . ';'; | |
| 1138 | + if ( '' !== $urange ) { | |
| 1139 | + $lines[] = ' unicode-range: ' . $urange . ';'; | |
| 827 | 1140 | } |
| 828 | 1141 | |
| 829 | 1142 | $lines[] = "}\n"; |
| 830 | 1143 | |
| @@ -831,8 +1144,28 @@ | ||
| 831 | 1144 | return implode( "\n", $lines ); |
| 832 | 1145 | } |
| 833 | 1146 | |
| 834 | 1147 | /** |
| 1148 | + * Strip characters that could break out of a CSS string or declaration (or, | |
| 1149 | + * for an inlined sheet, out of the <style> element). Removes quotes, braces, | |
| 1150 | + * semicolons, angle brackets, backslashes, comment delimiters and newlines. | |
| 1151 | + * Real font-family / weight / style / unicode-range values never contain | |
| 1152 | + * these, so this is purely defensive and lossless for legitimate input. | |
| 1153 | + * | |
| 1154 | + * @param string $value Raw value. | |
| 1155 | + * @return string | |
| 1156 | + */ | |
| 1157 | + private static function css_token( string $value ): string { | |
| 1158 | + return trim( | |
| 1159 | + str_replace( | |
| 1160 | + array( "'", '"', ';', '{', '}', '<', '>', '\\', '/*', '*/', "\r", "\n", "\t" ), | |
| 1161 | + '', | |
| 1162 | + $value | |
| 1163 | + ) | |
| 1164 | + ); | |
| 1165 | + } | |
| 1166 | + | |
| 1167 | + /** | |
| 835 | 1168 | * Deterministic, human-readable font path for a variant, grouped by family. |
| 836 | 1169 | * Variable fonts drop the weight. Returns a cache-relative basename (no ext). |
| 837 | 1170 | * |
| 838 | 1171 | * e.g. fonts/open-sans/open-sans-normal-latin-400 |
| @@ -884,9 +1217,9 @@ | ||
| 884 | 1217 | $effective = $this->effective_subsets(); |
| 885 | 1218 | $out = $this->absolutize_urls( $css, $base_url ); |
| 886 | 1219 | |
| 887 | 1220 | // 1. Provider @import → fetch that CSS, localise its faces, inline it. |
| 888 | - $out = (string) preg_replace_callback( | |
| 1221 | + $imported_out = preg_replace_callback( | |
| 889 | 1222 | '/@import\s+(?:url\(\s*)?[\'"]?([^\'")\s]+)[\'"]?\s*\)?\s*;?/i', |
| 890 | 1223 | function ( $m ) use ( &$found, $effective ) { |
| 891 | 1224 | $url = Providers::normalize_url( $m[1] ); |
| 892 | 1225 | |
| @@ -905,8 +1238,9 @@ | ||
| 905 | 1238 | return $this->localize_faces_in_css( $imported, $url ); |
| 906 | 1239 | }, |
| 907 | 1240 | $out |
| 908 | 1241 | ); |
| 1242 | + $out = is_string( $imported_out ) ? $imported_out : $out; | |
| 909 | 1243 | |
| 910 | 1244 | // 2. Provider @font-face already in the file → localise src in place. |
| 911 | 1245 | $out = $this->localize_faces_in_css( $out, $base_url, $found ); |
| 912 | 1246 | |
| @@ -993,19 +1327,29 @@ | ||
| 993 | 1327 | $font_file = $this->locate_font( $basename ); |
| 994 | 1328 | $size = $font_file ? $this->storage->size( $font_file ) : 0; |
| 995 | 1329 | |
| 996 | 1330 | if ( null === $font_file ) { |
| 1331 | + // As in host(): never download on a visitor render, and honour the | |
| 1332 | + // warm budget. Mark missing and leave the original @font-face src | |
| 1333 | + // in place so the font keeps loading from the provider until warm. | |
| 1334 | + if ( ! Downloader::fonts_allowed() || ! $this->within_budget() ) { | |
| 1335 | + $this->missing = true; | |
| 1336 | + continue; | |
| 1337 | + } | |
| 1338 | + | |
| 997 | 1339 | $dl = $this->downloader->fetch_font( $src, $basename, $this->storage ); |
| 998 | 1340 | |
| 999 | 1341 | if ( null === $dl ) { |
| 1342 | + $this->missing = true; | |
| 1000 | 1343 | continue; // Leave the original src if the download fails. |
| 1001 | 1344 | } |
| 1002 | 1345 | |
| 1346 | + $this->dl_count++; | |
| 1003 | 1347 | $font_file = $dl['file']; |
| 1004 | 1348 | $size = $dl['size']; |
| 1005 | 1349 | } |
| 1006 | 1350 | |
| 1007 | - $local_url = add_query_arg( 'ver', Settings::buster(), $this->storage->url( $font_file ) ); | |
| 1351 | + $local_url = $this->storage->font_url( $font_file ); | |
| 1008 | 1352 | |
| 1009 | 1353 | $this->register_variant( $variant, $font_file, $size ); |
| 1010 | 1354 | |
| 1011 | 1355 | $css = $this->str_replace_once( $block['full'], rtrim( $this->build_face_rule( $variant, $local_url, $display ) ), $css ); |
| @@ -1122,9 +1466,9 @@ | ||
| 1122 | 1466 | * @param string $base_url Base URL of the original file. |
| 1123 | 1467 | * @return string |
| 1124 | 1468 | */ |
| 1125 | 1469 | private function absolutize_urls( string $css, string $base_url ): string { |
| 1126 | - return (string) preg_replace_callback( | |
| 1470 | + $out = preg_replace_callback( | |
| 1127 | 1471 | '/url\(\s*([\'"]?)([^\'")]+)\1\s*\)/i', |
| 1128 | 1472 | function ( $m ) use ( $base_url ) { |
| 1129 | 1473 | $url = trim( $m[2] ); |
| 1130 | 1474 | |
| @@ -1139,8 +1483,10 @@ | ||
| 1139 | 1483 | return "url('" . Providers::normalize_url( $url, $base_url ) . "')"; |
| 1140 | 1484 | }, |
| 1141 | 1485 | $css |
| 1142 | 1486 | ); |
| 1487 | + | |
| 1488 | + return is_string( $out ) ? $out : $css; | |
| 1143 | 1489 | } |
| 1144 | 1490 | |
| 1145 | 1491 | /** |
| 1146 | 1492 | * Normalize a subset comment token (e.g. "latin", "[0]" → ''). |