| 1 |
<?php |
| 2 |
/** |
| 3 |
* Minify_Filters — frontend HTML rewriters for the "smarter minifier" |
| 4 |
* sub-features (Phase 4.1a): defer JS, delay JS, async CSS, remove |
| 5 |
* query strings. |
| 6 |
* |
| 7 |
* Each method is a WordPress filter callback. None of them touch the |
| 8 |
* file system — they're pure tag rewrites or src-string rewrites |
| 9 |
* applied to enqueued asset URLs / tags. |
| 10 |
* |
| 11 |
* The heavier combine-CSS / combine-JS engine lands in Phase 4.1b |
| 12 |
* with its own class; keeping the filter-only logic isolated here |
| 13 |
* makes that future split clean. |
| 14 |
* |
| 15 |
* @package XSpeed |
| 16 |
*/ |
| 17 |
|
| 18 |
declare(strict_types=1); |
| 19 |
|
| 20 |
namespace XSpeed; |
| 21 |
|
| 22 |
defined( 'ABSPATH' ) || exit; |
| 23 |
|
| 24 |
final class Minify_Filters { |
| 25 |
|
| 26 |
/** |
| 27 |
* Settings cache (one read per request). |
| 28 |
* |
| 29 |
* @var array|null |
| 30 |
*/ |
| 31 |
private static $opts = null; |
| 32 |
|
| 33 |
/** |
| 34 |
* Has the delay-JS bootstrap snippet been printed? Guards against |
| 35 |
* duplicate emission in pages that hit wp_footer multiple times. |
| 36 |
*/ |
| 37 |
private static $delay_bootstrap_printed = false; |
| 38 |
|
| 39 |
/** |
| 40 |
* Pre-minify script URLs, keyed by handle. |
| 41 |
* |
| 42 |
* `script_loader_src` (priority 10) rewrites a local script's URL to a |
| 43 |
* hashed /cache/xspeed/min/<key>.js path long before |
| 44 |
* `script_loader_tag` (priority 20/30) runs, so the delay + exclusion |
| 45 |
* checks only ever see the hashed URL. A user targeting a script by |
| 46 |
* URL substring — the obvious thing to do, and what the UI invites — |
| 47 |
* would silently stop matching the moment minification was enabled. |
| 48 |
* Minifier::rewrite_script() records the original here so those |
| 49 |
* checks can test both. (FBS field report against 1.1.2) |
| 50 |
* |
| 51 |
* @var array<string,string> |
| 52 |
*/ |
| 53 |
private static $original_src = array(); |
| 54 |
|
| 55 |
/** |
| 56 |
* Record a script's URL as it was BEFORE minification rewrote it. |
| 57 |
* Called from Minifier::rewrite_script(). |
| 58 |
* |
| 59 |
* @param string $handle Script handle. |
| 60 |
* @param string $src Original (pre-minify) URL. |
| 61 |
*/ |
| 62 |
public static function remember_original_src( string $handle, string $src ): void { |
| 63 |
if ( '' !== $handle && '' !== $src ) { |
| 64 |
self::$original_src[ $handle ] = $src; |
| 65 |
} |
| 66 |
} |
| 67 |
|
| 68 |
/** |
| 69 |
* The pre-minify URL for a handle, or '' when we never rewrote it |
| 70 |
* (external script, minification off, or a handle we didn't touch). |
| 71 |
* |
| 72 |
* @param string $handle Script handle. |
| 73 |
*/ |
| 74 |
public static function original_src( string $handle ): string { |
| 75 |
return isset( self::$original_src[ $handle ] ) ? self::$original_src[ $handle ] : ''; |
| 76 |
} |
| 77 |
|
| 78 |
/** |
| 79 |
* Reset the remembered URLs. Test-only seam. |
| 80 |
*/ |
| 81 |
public static function reset_original_src(): void { |
| 82 |
self::$original_src = array(); |
| 83 |
} |
| 84 |
|
| 85 |
/** |
| 86 |
* Does this tag (or attribute string) opt out of optimization? |
| 87 |
* |
| 88 |
* `data-no-optimize` / `data-no-minify` are the de-facto convention |
| 89 |
* consent managers and other plugins print so optimizers keep hands |
| 90 |
* off (Borlabs Cookie stamps both on its config script). The CSS |
| 91 |
* combine buffer has honored `data-no-optimize` from the start; the |
| 92 |
* JS paths did not, so a marked consent script was still minified |
| 93 |
* into a hashed cache file — and a stale copy of a legally relevant |
| 94 |
* consent config is a correctness problem, not a cosmetic one. (#456) |
| 95 |
* |
| 96 |
* @param string $tag A full tag, or just its attribute string. |
| 97 |
*/ |
| 98 |
public static function tag_opts_out( string $tag ): bool { |
| 99 |
return (bool) preg_match( '#\sdata-no-(?:optimize|minify)\b#i', $tag ); |
| 100 |
} |
| 101 |
|
| 102 |
/** |
| 103 |
* Filter: `script_loader_tag`, priority 15 — undo the minify-cache |
| 104 |
* rewrite for a script whose printed tag opts out. |
| 105 |
* |
| 106 |
* The src rewrite happens on `script_loader_src` (priority 10), long |
| 107 |
* before any plugin's own `script_loader_tag` filter can stamp |
| 108 |
* `data-no-minify` onto the tag — so the marker arrived too late to |
| 109 |
* prevent the rewrite. This runs after those filters had their say |
| 110 |
* (they typically hook at default priority 10; we're at 15, before |
| 111 |
* defer at 20 and delay at 30) and swaps the hashed cache URL back to |
| 112 |
* the recorded original. |
| 113 |
* |
| 114 |
* @param string $tag |
| 115 |
* @param string $handle |
| 116 |
* @param string $src |
| 117 |
*/ |
| 118 |
public static function restore_marked_script_src( $tag, $handle, $src ): string { |
| 119 |
if ( ! is_string( $tag ) || '' === $tag || ! self::tag_opts_out( $tag ) ) { |
| 120 |
return (string) $tag; |
| 121 |
} |
| 122 |
$original = self::original_src( (string) $handle ); |
| 123 |
if ( '' === $original || '' === (string) $src || false === strpos( $tag, (string) $src ) ) { |
| 124 |
return $tag; |
| 125 |
} |
| 126 |
return str_replace( (string) $src, $original, $tag ); |
| 127 |
} |
| 128 |
|
| 129 |
/** |
| 130 |
* Does a user-supplied target match this script? |
| 131 |
* |
| 132 |
* A target is either a script handle (exact) or a URL substring. The |
| 133 |
* URL is checked against BOTH the current src and the pre-minify src, |
| 134 |
* so a target written against the real asset path keeps working once |
| 135 |
* minification starts rewriting URLs to hashed cache paths. |
| 136 |
* |
| 137 |
* @param string $needle Target from the user's list. |
| 138 |
* @param string $handle Script handle. |
| 139 |
* @param string $src Current (possibly rewritten) src. |
| 140 |
*/ |
| 141 |
private static function target_matches( string $needle, string $handle, string $src ): bool { |
| 142 |
if ( '' === $needle ) { |
| 143 |
return false; |
| 144 |
} |
| 145 |
if ( $handle === $needle ) { |
| 146 |
return true; |
| 147 |
} |
| 148 |
if ( '' !== $src && false !== stripos( $src, $needle ) ) { |
| 149 |
return true; |
| 150 |
} |
| 151 |
$original = self::original_src( $handle ); |
| 152 |
return '' !== $original && false !== stripos( $original, $needle ); |
| 153 |
} |
| 154 |
|
| 155 |
/** |
| 156 |
* Filter: `script_loader_tag` — add defer="defer" to non-excluded |
| 157 |
* scripts. WordPress passes the full <script> tag string, the |
| 158 |
* handle, and the src. We bail when: |
| 159 |
* - the user excluded this handle / src substring, |
| 160 |
* - the tag already has defer or async (don't double-set), |
| 161 |
* - the tag has no src (inline scripts can't be deferred — would |
| 162 |
* execute synchronously regardless). |
| 163 |
* |
| 164 |
* @param string $tag |
| 165 |
* @param string $handle |
| 166 |
* @param string $src |
| 167 |
*/ |
| 168 |
public static function defer_script_tag( $tag, $handle, $src ): string { |
| 169 |
if ( ! is_string( $tag ) || '' === $tag ) { |
| 170 |
return (string) $tag; |
| 171 |
} |
| 172 |
// Self-guard: even though Minifier::__construct() bails on admin/ |
| 173 |
// AJAX/REST/cron at registration, a late context switch (e.g. a |
| 174 |
// custom wp_print_scripts() call inside an admin page render) can |
| 175 |
// leave the filter attached. Skipping here keeps the React admin |
| 176 |
// bundle's <script> tag intact so the dashboard mounts. |
| 177 |
if ( self::skip_in_non_frontend_context() ) { |
| 178 |
return $tag; |
| 179 |
} |
| 180 |
if ( '' === (string) $src ) { |
| 181 |
return $tag; |
| 182 |
} |
| 183 |
if ( self::is_excluded_script( (string) $handle, (string) $src ) ) { |
| 184 |
return $tag; |
| 185 |
} |
| 186 |
// The tag itself asked to be left alone. (#456) |
| 187 |
if ( self::tag_opts_out( $tag ) ) { |
| 188 |
return $tag; |
| 189 |
} |
| 190 |
// Inline code elsewhere on the page reads this handle (or something |
| 191 |
// it depends on). Inline blocks never defer, so deferring this one |
| 192 |
// would run the consumer first. Defer only — delay is an opt-in |
| 193 |
// target list, where the user has named the script deliberately. |
| 194 |
if ( isset( self::inline_bound_handles()[ (string) $handle ] ) ) { |
| 195 |
return $tag; |
| 196 |
} |
| 197 |
// NB: is_protected_from_bundling() is the same two rules in one call |
| 198 |
// for the combiner; the split here is deliberate, since the |
| 199 |
// exclusion check above already ran and short-circuits earlier. |
| 200 |
if ( false !== stripos( $tag, ' defer' ) || false !== stripos( $tag, ' async' ) ) { |
| 201 |
return $tag; |
| 202 |
} |
| 203 |
// Target the <script> that actually carries a src, NOT simply the |
| 204 |
// first one in the string. WP_Scripts::do_item() hands this filter |
| 205 |
// the CONCATENATION of before_inline + external + after_inline, so |
| 206 |
// for any handle carrying a `before` inline script the first |
| 207 |
// `<script` is the inline block. Deferring that is a no-op (the HTML |
| 208 |
// spec ignores defer on inline scripts) AND leaves the external |
| 209 |
// script undeferred while its dependencies get deferred — which |
| 210 |
// inverts WordPress's guaranteed execution order and throws in any |
| 211 |
// dependent that touches a global its dependency defines. (#234) |
| 212 |
// |
| 213 |
// The lookahead scans only within the tag (`[^>]*`) for ` src=`, so |
| 214 |
// an inline `<script id="…-js-before">` can never match. |
| 215 |
return (string) preg_replace( '#<script\b(?=[^>]*\ssrc\s*=)#i', '<script defer="defer"', $tag, 1 ); |
| 216 |
} |
| 217 |
|
| 218 |
/** |
| 219 |
* Filter: `script_loader_tag` — rewrite src= to data-xs-src= so the |
| 220 |
* browser ignores it until the bootstrap (printed once on |
| 221 |
* wp_footer) swaps it back on first user interaction. Same |
| 222 |
* exclusion rules as defer. Inline scripts (no src) are also |
| 223 |
* deferred until the first interaction. |
| 224 |
* |
| 225 |
* @param string $tag |
| 226 |
* @param string $handle |
| 227 |
* @param string $src |
| 228 |
*/ |
| 229 |
public static function delay_script_tag( $tag, $handle, $src ): string { |
| 230 |
if ( ! is_string( $tag ) || '' === $tag ) { |
| 231 |
return (string) $tag; |
| 232 |
} |
| 233 |
if ( self::skip_in_non_frontend_context() ) { |
| 234 |
return $tag; |
| 235 |
} |
| 236 |
if ( self::is_excluded_script( (string) $handle, (string) $src ) ) { |
| 237 |
return $tag; |
| 238 |
} |
| 239 |
// The tag itself asked to be left alone. (#456) |
| 240 |
if ( self::tag_opts_out( $tag ) ) { |
| 241 |
return $tag; |
| 242 |
} |
| 243 |
if ( ! self::is_delay_target( (string) $handle, (string) $src ) ) { |
| 244 |
return $tag; |
| 245 |
} |
| 246 |
// Inline code elsewhere on the page reads this handle (or something |
| 247 |
// it depends on) — same registry walk defer uses. Delaying it runs |
| 248 |
// the consumer at parse time against a global that arrives on first |
| 249 |
// interaction: `wp_add_inline_script( 'jquery-ui-core', |
| 250 |
// 'jQuery.uiBackCompat…', 'before' )` throws "jQuery is not defined" |
| 251 |
// the moment jquery-core is delayed. A handle the user NAMED in |
| 252 |
// delay_js_targets is still delayed — an explicit entry is the user |
| 253 |
// saying they know the inline consumer is safe to break or absent. |
| 254 |
if ( isset( self::inline_bound_handles()[ (string) $handle ] ) |
| 255 |
&& ! self::is_user_named_target( (string) $handle, (string) $src ) ) { |
| 256 |
return $tag; |
| 257 |
} |
| 258 |
// A non-executable type means this tag is data, or is being held by |
| 259 |
// somebody else on purpose. The buffer pass has always checked this; |
| 260 |
// the enqueue path did not, so a consent-blocked or JSON-carrying |
| 261 |
// handle could still be rewritten here. (#274) |
| 262 |
if ( in_array( self::extract_type( $tag ), self::NON_EXECUTABLE_TYPES, true ) ) { |
| 263 |
return $tag; |
| 264 |
} |
| 265 |
// src= variant: swap src → data-xs-src and add data-xs-delay marker. |
| 266 |
if ( '' !== (string) $src ) { |
| 267 |
// Anchor on the opening <script …> tag that carries the src. |
| 268 |
// Matching a bare `src=` across the whole string would rewrite |
| 269 |
// the first occurrence anywhere — including inside a `before` |
| 270 |
// inline block, where JS like `el.src = "…"` becomes the |
| 271 |
// syntax error `el.data-xs-src="…" data-xs-delay="1"` and the |
| 272 |
// real external script is left undelayed. $tag is the |
| 273 |
// concatenation of before_inline + external + after_inline, |
| 274 |
// so that is a routine shape, not a corner case. (#234) |
| 275 |
// `(?<![-\w])` where `\b` used to be. A hyphen is a non-word |
| 276 |
// character, so `\bsrc=` also matches the TAIL of any |
| 277 |
// `data-…-src=` attribute — and consent managers and other |
| 278 |
// optimizers park a blocked script's real URL in exactly that |
| 279 |
// shape. Complianz's `data-cmplz-src` became |
| 280 |
// `data-cmplz-data-xs-src`, so after the visitor clicked Accept |
| 281 |
// the plugin looked for an attribute that no longer existed and |
| 282 |
// the script never loaded: analytics and pixels silently dead, |
| 283 |
// no console error, nothing in the UI. Same class of bug as the |
| 284 |
// image-dimension resolver in #328. (#273) |
| 285 |
return (string) preg_replace( |
| 286 |
'#(<script\b[^>]*?)(?<![-\w])src\s*=\s*(["\'][^"\']*["\'])#i', |
| 287 |
'$1data-xs-src=$2 data-xs-delay="1"', |
| 288 |
$tag, |
| 289 |
1 |
| 290 |
); |
| 291 |
} |
| 292 |
// Inline script: change type to text/xspeed-delayed so the browser |
| 293 |
// doesn't execute, mark for bootstrap rewriter. Any existing type |
| 294 |
// is REPLACED, not appended-after: HTML keeps an attribute's first |
| 295 |
// occurrence, so a snippet carrying its own `type="text/javascript"` |
| 296 |
// would win over a marker appended behind it and keep executing. |
| 297 |
// A non-default original type is stashed in data-xs-type so the |
| 298 |
// bootstrap can restore it on replay (#274 — type is what a script |
| 299 |
// IS; a parked `type="module"` must come back as a module). |
| 300 |
$tag = (string) preg_replace_callback( |
| 301 |
'#<script\b([^>]*)>#i', |
| 302 |
static function ( array $m ): string { |
| 303 |
return '<script' . self::park_type_attrs( $m[1] ) . '>'; |
| 304 |
}, |
| 305 |
$tag, |
| 306 |
1 |
| 307 |
); |
| 308 |
return $tag; |
| 309 |
} |
| 310 |
|
| 311 |
/** |
| 312 |
* Script types the buffer pass must never touch. `<script>` carries |
| 313 |
* data as often as it carries code: JSON-LD feeds structured-data |
| 314 |
* consumers, importmaps must resolve before any module runs, and our |
| 315 |
* own delayed-inline marker is already handled by the bootstrap. |
| 316 |
* Rewriting any of these breaks the page or its metadata. |
| 317 |
*/ |
| 318 |
private const NON_EXECUTABLE_TYPES = array( |
| 319 |
'application/ld+json', |
| 320 |
'application/json', |
| 321 |
'importmap', |
| 322 |
'speculationrules', |
| 323 |
'text/template', |
| 324 |
'text/x-template', |
| 325 |
'text/xspeed-delayed', |
| 326 |
// A consent manager parks a blocked third-party script here and |
| 327 |
// swaps the type back only once the visitor has agreed. Whatever we |
| 328 |
// do to such a tag we do on behalf of a decision the visitor has not |
| 329 |
// made yet, so the only correct move is to leave it alone. (#274) |
| 330 |
'text/plain', |
| 331 |
); |
| 332 |
|
| 333 |
/** |
| 334 |
* The `type` attribute, quoted OR unquoted, anchored to attribute |
| 335 |
* position — a required leading whitespace, never a bare `\b`. |
| 336 |
* |
| 337 |
* The anchoring matters twice over. `\btype` also matches the tail of |
| 338 |
* any hyphenated `data-…-type` attribute (a `-` is a non-word char, so |
| 339 |
* the boundary sits inside the name — the same #273 class as `src`), |
| 340 |
* and it matches a `type=` sitting INSIDE another attribute's value |
| 341 |
* (`onload="this.type='done'"`). Requiring whitespace before the name |
| 342 |
* rules both out: attributes are whitespace-separated, while `.type` |
| 343 |
* and `-type` never are. The unquoted branch exists because |
| 344 |
* `type=text/javascript` is valid HTML: a quoted-only pattern left it |
| 345 |
* standing, the parking type appended after it lost the |
| 346 |
* first-occurrence race, and the snippet executed immediately AND |
| 347 |
* replayed on interaction — every vendor event fired twice. |
| 348 |
*/ |
| 349 |
private const TYPE_ATTR_RE = '#\stype\s*=\s*(?:(["\'])(.*?)\1|([^\s>]+))#is'; |
| 350 |
|
| 351 |
/** |
| 352 |
* `type` values a parked tag need not remember: the replay default is |
| 353 |
* already JavaScript, so stashing these would only fatten the markup. |
| 354 |
*/ |
| 355 |
private const DEFAULT_JS_TYPES = array( |
| 356 |
'text/javascript', |
| 357 |
'application/javascript', |
| 358 |
); |
| 359 |
|
| 360 |
/** |
| 361 |
* Read a tag's `type` attribute value, lowercased and trimmed. |
| 362 |
* |
| 363 |
* @param string $haystack Full tag or its attribute string. |
| 364 |
* @return string '' when no type attribute is present. |
| 365 |
*/ |
| 366 |
private static function extract_type( string $haystack ): string { |
| 367 |
if ( ! preg_match( self::TYPE_ATTR_RE, $haystack, $m ) ) { |
| 368 |
return ''; |
| 369 |
} |
| 370 |
$value = ( isset( $m[3] ) && '' !== $m[3] ) ? $m[3] : $m[2]; |
| 371 |
return strtolower( trim( $value ) ); |
| 372 |
} |
| 373 |
|
| 374 |
/** |
| 375 |
* Rewrite an inline tag's attribute string for parking: strip its own |
| 376 |
* `type`, stash a non-default one in `data-xs-type` (the bootstrap |
| 377 |
* restores it on replay, so a parked `type="module"` comes back as a |
| 378 |
* module rather than a classic script — #274), and append the parking |
| 379 |
* marker pair. |
| 380 |
* |
| 381 |
* @param string $attrs Raw attribute string (everything between |
| 382 |
* `<script` and `>`). |
| 383 |
*/ |
| 384 |
private static function park_type_attrs( string $attrs ): string { |
| 385 |
$orig = self::extract_type( $attrs ); |
| 386 |
$attrs = (string) preg_replace( self::TYPE_ATTR_RE, '', $attrs ); |
| 387 |
$stash = ''; |
| 388 |
if ( '' !== $orig && ! in_array( $orig, self::DEFAULT_JS_TYPES, true ) ) { |
| 389 |
// MIME-ish charset only — a type value is never markup, and this |
| 390 |
// string is re-emitted inside a double-quoted attribute. |
| 391 |
$orig = (string) preg_replace( '#[^a-z0-9/+.\-]#', '', $orig ); |
| 392 |
if ( '' !== $orig ) { |
| 393 |
$stash = ' data-xs-type="' . $orig . '"'; |
| 394 |
} |
| 395 |
} |
| 396 |
return $attrs . $stash . ' type="text/xspeed-delayed" data-xs-delay="1"'; |
| 397 |
} |
| 398 |
|
| 399 |
/** |
| 400 |
* URL fragments that must keep a live src no matter what. The enqueue |
| 401 |
* path guards these by handle (ALWAYS_EXCLUDED_HANDLES), but a buffer |
| 402 |
* pass only ever sees a URL, so the same protection is re-expressed |
| 403 |
* here. Without this the admin bundle could be delayed on a frontend |
| 404 |
* render and the dashboard would not mount. |
| 405 |
*/ |
| 406 |
private const ALWAYS_EXCLUDED_SRC = array( |
| 407 |
'/plugins/xspeed/assets/', |
| 408 |
'/wp-includes/js/dist/hooks', |
| 409 |
'/wp-includes/js/dist/i18n', |
| 410 |
); |
| 411 |
|
| 412 |
/** |
| 413 |
* Delay `<script src>` tags that never passed through wp_enqueue_script. |
| 414 |
* |
| 415 |
* `delay_script_tag()` hooks `script_loader_tag`, so it only ever sees |
| 416 |
* enqueued scripts. Analytics, pixels, chat widgets and most third-party |
| 417 |
* embeds are printed straight into `wp_head` / `wp_footer` as literal |
| 418 |
* markup, bypassing that filter entirely — and those are exactly the |
| 419 |
* scripts most worth delaying. On the site that surfaced this, 39 |
| 420 |
* enqueued scripts were correctly delayed while one un-enqueued |
| 421 |
* analytics tag still downloaded 441 KB: 98% of the page's JS payload. |
| 422 |
* |
| 423 |
* Runs on the finished page buffer via `xspeed_cache_final_html`, so the |
| 424 |
* rewrite is baked into the cached HTML and replays on every static hit |
| 425 |
* (where PHP never boots). Deliberately conservative — it rewrites only |
| 426 |
* `src`, leaves inline code to the enqueue path, and skips any tag whose |
| 427 |
* `type` marks it as data rather than code. |
| 428 |
* |
| 429 |
* @param string $html Complete page HTML. |
| 430 |
*/ |
| 431 |
public static function delay_raw_script_tags( $html ): string { |
| 432 |
if ( ! is_string( $html ) || '' === $html ) { |
| 433 |
return (string) $html; |
| 434 |
} |
| 435 |
if ( self::skip_in_non_frontend_context() ) { |
| 436 |
return $html; |
| 437 |
} |
| 438 |
$opts = self::opts(); |
| 439 |
if ( empty( $opts['delay_js'] ) ) { |
| 440 |
return $html; |
| 441 |
} |
| 442 |
|
| 443 |
return (string) preg_replace_callback( |
| 444 |
'#<script\b[^>]*>#i', |
| 445 |
static function ( array $m ): string { |
| 446 |
$tag = $m[0]; |
| 447 |
|
| 448 |
// Already handled by the enqueue-path filter. |
| 449 |
if ( false !== stripos( $tag, 'data-xs-delay' ) || false !== stripos( $tag, 'data-xs-src' ) ) { |
| 450 |
return $tag; |
| 451 |
} |
| 452 |
|
| 453 |
// The tag itself asked to be left alone. (#456) |
| 454 |
if ( self::tag_opts_out( $tag ) ) { |
| 455 |
return $tag; |
| 456 |
} |
| 457 |
|
| 458 |
// No src → inline code. The enqueue path owns those; a |
| 459 |
// buffer rewrite here would have to reason about execution |
| 460 |
// order it cannot see. |
| 461 |
// `(?<![-\w])` not `\b` — see the note on the enqueue-path |
| 462 |
// rewrite above. With `\b`, a tag whose ONLY url lives in |
| 463 |
// `data-cmplz-src` (a consent-blocked script, no real src at |
| 464 |
// all) read as an external script here, and the rewrite |
| 465 |
// below then mangled that attribute. (#273) |
| 466 |
if ( ! preg_match( '#(?<![-\w])src\s*=\s*(["\'])(.*?)\1#is', $tag, $src_m ) ) { |
| 467 |
return $tag; |
| 468 |
} |
| 469 |
$src = $src_m[2]; |
| 470 |
|
| 471 |
// Data, not code. |
| 472 |
if ( in_array( self::extract_type( $tag ), self::NON_EXECUTABLE_TYPES, true ) ) { |
| 473 |
return $tag; |
| 474 |
} |
| 475 |
|
| 476 |
foreach ( self::ALWAYS_EXCLUDED_SRC as $needle ) { |
| 477 |
if ( false !== stripos( $src, $needle ) ) { |
| 478 |
return $tag; |
| 479 |
} |
| 480 |
} |
| 481 |
|
| 482 |
// Recover the handle from the tag's id before deciding. |
| 483 |
// |
| 484 |
// This pass used to pass '' as the handle, on the reasoning |
| 485 |
// that a tag reaching the buffer was never enqueued and so has |
| 486 |
// none. That holds for the third-party snippets this pass |
| 487 |
// exists for — but NOT for enqueued scripts, which also travel |
| 488 |
// through here, and which WordPress prints with |
| 489 |
// `id="<handle>-js"`. Passing '' meant every handle-based |
| 490 |
// exclusion was silently inert at this layer: the user writes |
| 491 |
// `jquery-core`, the enqueue path honours it, and then the |
| 492 |
// buffer pass — which only ever compared URLs — delayed the |
| 493 |
// very script the list was protecting. |
| 494 |
// |
| 495 |
// That is how a site with jquery-core AND jquery-migrate |
| 496 |
// excluded still shipped jQuery delayed while migrate loaded |
| 497 |
// normally, and every inline `jQuery(...)` on the page threw |
| 498 |
// "jQuery is not defined". The two behaved differently for no |
| 499 |
// reason a user could see, which is what made it look like a |
| 500 |
// matching quirk rather than a whole layer ignoring the list. |
| 501 |
$tag_handle = ''; |
| 502 |
if ( preg_match( '#\sid\s*=\s*(["\'])(.*?)\1#i', $tag, $id_m ) ) { |
| 503 |
// WP appends `-js`; anything else is somebody's own id and |
| 504 |
// is still worth matching literally. |
| 505 |
$tag_handle = (string) preg_replace( '/-js$/', '', $id_m[2] ); |
| 506 |
} |
| 507 |
|
| 508 |
if ( self::is_excluded_script( $tag_handle, $src ) ) { |
| 509 |
return $tag; |
| 510 |
} |
| 511 |
if ( ! self::is_delay_target( $tag_handle, $src ) ) { |
| 512 |
return $tag; |
| 513 |
} |
| 514 |
// Mirror of the enqueue-path guard: a handle that inline code |
| 515 |
// reads stays eager unless the user named it. wp_scripts() |
| 516 |
// is still populated at xspeed_cache_final_html time on a |
| 517 |
// MISS, so the registry walk is consultable here too; an |
| 518 |
// unrecoverable handle ('') simply never matches the set. |
| 519 |
if ( '' !== $tag_handle |
| 520 |
&& isset( self::inline_bound_handles()[ $tag_handle ] ) |
| 521 |
&& ! self::is_user_named_target( $tag_handle, $src ) ) { |
| 522 |
return $tag; |
| 523 |
} |
| 524 |
|
| 525 |
return (string) preg_replace( |
| 526 |
'#(?<![-\w])src\s*=\s*(["\'][^"\']*["\'])#i', |
| 527 |
'data-xs-src=$1 data-xs-delay="1"', |
| 528 |
$tag, |
| 529 |
1 |
| 530 |
); |
| 531 |
}, |
| 532 |
$html |
| 533 |
); |
| 534 |
} |
| 535 |
|
| 536 |
/** |
| 537 |
* Delay inline vendor snippets that reference a known third-party host. |
| 538 |
* |
| 539 |
* The pass above rewrites `src` and deliberately leaves inline code |
| 540 |
* alone — but the OFFICIAL install for Clarity, GA, GTM and the Meta |
| 541 |
* pixel is an inline loader (`(function(c,l,a,r,i,t,y){…t.src=…})`) |
| 542 |
* with no `src` attribute at all. That snippet executes on every page |
| 543 |
* load, fetches the vendor bundle inside the measurement window, and |
| 544 |
* puts the one host whose Cache-Control the site cannot set straight |
| 545 |
* into the cache-policy and TBT audits. Delaying the enqueue path and |
| 546 |
* the raw-src path while this runs untouched is delaying everything |
| 547 |
* except the tag the feature exists for. |
| 548 |
* |
| 549 |
* The judgment call is the same one KNOWN_THIRD_PARTY_SRC already |
| 550 |
* makes: an inline body that names one of those hosts is that vendor's |
| 551 |
* loader or its config — never something first-party code holds a |
| 552 |
* synchronous reference to. The body is the haystack for the user's |
| 553 |
* exclusion and target lists too, so the same fragment that protects a |
| 554 |
* `src` tag protects its inline install. |
| 555 |
* |
| 556 |
* `document.write` bodies are skipped outright: replayed after the |
| 557 |
* parser has closed the document, a delayed write would replace the |
| 558 |
* page rather than add to it. |
| 559 |
* |
| 560 |
* @param string $html Complete page HTML. |
| 561 |
*/ |
| 562 |
public static function delay_inline_snippets( $html ): string { |
| 563 |
if ( ! is_string( $html ) || '' === $html ) { |
| 564 |
return (string) $html; |
| 565 |
} |
| 566 |
if ( self::skip_in_non_frontend_context() ) { |
| 567 |
return $html; |
| 568 |
} |
| 569 |
$opts = self::opts(); |
| 570 |
if ( empty( $opts['delay_js'] ) ) { |
| 571 |
return $html; |
| 572 |
} |
| 573 |
|
| 574 |
$out = preg_replace_callback( |
| 575 |
'#<script\b([^>]*)>(.*?)</script>#is', |
| 576 |
static function ( array $m ): string { |
| 577 |
list( $whole, $attrs, $body ) = $m; |
| 578 |
|
| 579 |
if ( '' === trim( $body ) ) { |
| 580 |
return $whole; |
| 581 |
} |
| 582 |
|
| 583 |
// The tag itself asked to be left alone. (#456) |
| 584 |
if ( self::tag_opts_out( $attrs ) ) { |
| 585 |
return $whole; |
| 586 |
} |
| 587 |
|
| 588 |
// Our own replay bootstrap. Its body quotes the delay |
| 589 |
// machinery's own strings, so a pathological user target |
| 590 |
// fragment could match it — and a parked bootstrap means |
| 591 |
// nothing on the page ever replays. |
| 592 |
if ( false !== stripos( $attrs, 'xspeed-delay-bootstrap' ) ) { |
| 593 |
return $whole; |
| 594 |
} |
| 595 |
|
| 596 |
// Already marked, or a real src= — the src passes own those. |
| 597 |
// `(?<![-\w])` for the same reason as above: `data-cmplz-src` |
| 598 |
// must not read as a src. (#273) |
| 599 |
if ( false !== stripos( $attrs, 'data-xs-delay' ) || false !== stripos( $attrs, 'data-xs-src' ) ) { |
| 600 |
return $whole; |
| 601 |
} |
| 602 |
if ( preg_match( '#(?<![-\w])src\s*=\s*(["\']).*?\1#is', $attrs ) ) { |
| 603 |
return $whole; |
| 604 |
} |
| 605 |
|
| 606 |
// Data, a module map, or a consent manager's parked tag. |
| 607 |
if ( in_array( self::extract_type( $attrs ), self::NON_EXECUTABLE_TYPES, true ) ) { |
| 608 |
return $whole; |
| 609 |
} |
| 610 |
|
| 611 |
// A delayed document.write replays after the document has |
| 612 |
// closed and replaces the page. Never delay one. |
| 613 |
if ( false !== stripos( $body, 'document.write' ) ) { |
| 614 |
return $whole; |
| 615 |
} |
| 616 |
|
| 617 |
// The body stands in for the URL in the lists the src passes |
| 618 |
// consult — but NOT via is_delay_target(), whose empty-list |
| 619 |
// default is "delay everything". That default is right for a |
| 620 |
// tag with a URL and catastrophic here: it would park every |
| 621 |
// inline script on the page. Inline code is delayed only on a |
| 622 |
// positive identification — the body names a known vendor |
| 623 |
// host, or a fragment the user targeted — and the exclusion |
| 624 |
// list still wins first. |
| 625 |
if ( self::is_excluded_script( '', $body ) ) { |
| 626 |
return $whole; |
| 627 |
} |
| 628 |
if ( ! self::matches_known_third_party( $body ) && ! self::matches_user_targets( $body ) ) { |
| 629 |
return $whole; |
| 630 |
} |
| 631 |
|
| 632 |
// Replace — not append — any existing type. Attributes keep |
| 633 |
// their FIRST occurrence in HTML, so appending the parking |
| 634 |
// type after the snippet's own `type="text/javascript"` |
| 635 |
// would leave the original executable. A non-default type is |
| 636 |
// stashed in data-xs-type for the bootstrap to restore. |
| 637 |
return '<script' . self::park_type_attrs( $attrs ) . '>' . $body . '</script>'; |
| 638 |
}, |
| 639 |
$html |
| 640 |
); |
| 641 |
// A PCRE failure (backtrack limit on a huge inline body) returns |
| 642 |
// null — and casting that to '' would serve AND cache a blank page. |
| 643 |
// The unrewritten original is always the safe fallback. |
| 644 |
return null === $out ? $html : $out; |
| 645 |
} |
| 646 |
|
| 647 |
/** |
| 648 |
* Inline bootstrap that flips delayed scripts on the first user |
| 649 |
* interaction. Printed once on wp_footer priority 1000. |
| 650 |
*/ |
| 651 |
public static function print_delay_bootstrap(): void { |
| 652 |
if ( self::skip_in_non_frontend_context() ) { |
| 653 |
return; |
| 654 |
} |
| 655 |
if ( self::$delay_bootstrap_printed ) { |
| 656 |
return; |
| 657 |
} |
| 658 |
self::$delay_bootstrap_printed = true; |
| 659 |
|
| 660 |
// Failsafe timer for visitors who never interact. 0 disables it |
| 661 |
// entirely (interaction-only), which is what lab tools measure |
| 662 |
// best: a timer that fires inside Lighthouse's / GTmetrix's |
| 663 |
// measurement window loads the "delayed" scripts anyway and |
| 664 |
// inflates the reported TTI, so the delay looks ineffective. |
| 665 |
$opts = self::opts(); |
| 666 |
$timeout = isset( $opts['delay_js_timeout'] ) ? (int) $opts['delay_js_timeout'] : 8000; |
| 667 |
$timeout = max( 0, min( 60000, $timeout ) ); |
| 668 |
|
| 669 |
// Tiny vanilla bootstrap; keep it self-contained so the page |
| 670 |
// has no JS dependencies before the first interaction. |
| 671 |
?> |
| 672 |
<script id="xspeed-delay-bootstrap"> |
| 673 |
(function(){ |
| 674 |
var events=['mousemove','keydown','touchstart','scroll','wheel']; |
| 675 |
var fired=false; |
| 676 |
function load(){ |
| 677 |
if(fired)return;fired=true; |
| 678 |
events.forEach(function(e){window.removeEventListener(e,load,{passive:true,capture:true});}); |
| 679 |
var delayed=document.querySelectorAll('script[data-xs-delay]'); |
| 680 |
delayed.forEach(function(s){ |
| 681 |
var n=document.createElement('script'); |
| 682 |
// A dynamically-created script is async by default, so replayed |
| 683 |
// EXTERNALS would race each other; async=false restores document |
| 684 |
// order among the externals. Narrower guarantee, stated plainly: |
| 685 |
// a replayed INLINE script still executes synchronously at its |
| 686 |
// replaceChild, i.e. possibly before an earlier external has |
| 687 |
// finished LOADING — so an inline consumer of a delayed external |
| 688 |
// is only safe when both were delayed by explicit user targeting |
| 689 |
// (the inline-bound guard keeps the implicit case eager). |
| 690 |
n.async=false; |
| 691 |
// Nonce hiding: a connected element's nonce CONTENT attribute reads |
| 692 |
// as "", so copying it via the attribute loop would hand the clone |
| 693 |
// an empty nonce and a nonce-based CSP would block the replay. The |
| 694 |
// IDL property still carries the real value. |
| 695 |
if(s.nonce){n.nonce=s.nonce;} |
| 696 |
Array.prototype.slice.call(s.attributes).forEach(function(a){ |
| 697 |
if(a.name==='data-xs-src'){n.setAttribute('src',a.value);return;} |
| 698 |
if(a.name==='data-xs-delay')return; |
| 699 |
if(a.name==='nonce')return; |
| 700 |
// A parked inline tag's ORIGINAL type (module, mostly) rides in |
| 701 |
// data-xs-type — restore it, or the replay runs a module as a |
| 702 |
// classic script and its imports throw. (#274) |
| 703 |
if(a.name==='data-xs-type'){n.setAttribute('type',a.value);return;} |
| 704 |
// `type` is what a script IS, not decoration, so it is carried over |
| 705 |
// — with ONE exception: our own inline parking marker, which exists |
| 706 |
// only to stop the browser executing the original and must not be |
| 707 |
// copied onto the replacement. Dropping type wholesale broke two |
| 708 |
// things: `type="module"` became a classic script (core's Script |
| 709 |
// Modules — Navigation, lightbox, Query Loop — threw "Cannot use |
| 710 |
// import statement outside a module" on the default theme), and |
| 711 |
// `type="text/plain"`, which is precisely how a consent manager |
| 712 |
// parks a blocked third-party script, became executable again. The |
| 713 |
// second is a privacy failure, not a broken feature. (#274) |
| 714 |
if(a.name==='type'&&a.value==='text/xspeed-delayed')return; |
| 715 |
n.setAttribute(a.name,a.value); |
| 716 |
}); |
| 717 |
if(!s.hasAttribute('data-xs-src')){n.text=s.text;} |
| 718 |
s.parentNode.replaceChild(n,s); |
| 719 |
}); |
| 720 |
} |
| 721 |
events.forEach(function(e){window.addEventListener(e,load,{passive:true,capture:true});}); |
| 722 |
<?php if ( $timeout > 0 ) : ?> |
| 723 |
setTimeout(load,<?php echo (int) $timeout; ?>); |
| 724 |
<?php endif; ?> |
| 725 |
})(); |
| 726 |
</script> |
| 727 |
<?php |
| 728 |
} |
| 729 |
|
| 730 |
/** |
| 731 |
* Filter: `style_loader_tag` — wrap stylesheets in the |
| 732 |
* print → onload="all" pattern so they download non-blocking. |
| 733 |
* Pairs with critical CSS workflows. Adds a <noscript> fallback so |
| 734 |
* users with JS disabled still get styles applied (via media="all"). |
| 735 |
* |
| 736 |
* @param string $tag |
| 737 |
* @param string $handle |
| 738 |
*/ |
| 739 |
public static function async_style_tag( $tag, $handle ): string { |
| 740 |
if ( ! is_string( $tag ) || '' === $tag ) { |
| 741 |
return (string) $tag; |
| 742 |
} |
| 743 |
if ( self::skip_in_non_frontend_context() ) { |
| 744 |
return $tag; |
| 745 |
} |
| 746 |
// Only operate on <link rel=stylesheet> with a media attribute |
| 747 |
// we can swap. Skip anything custom (preload, etc.) — we don't |
| 748 |
// want to fight with explicit author intent. |
| 749 |
if ( false === stripos( $tag, 'rel=\'stylesheet\'' ) && false === stripos( $tag, 'rel="stylesheet"' ) ) { |
| 750 |
return $tag; |
| 751 |
} |
| 752 |
// The stylesheets that lay the page out stay render-blocking. |
| 753 |
// |
| 754 |
// This transform moves a sheet to AFTER first paint. That is the |
| 755 |
// point of it — but a sheet the layout depends on is then missing |
| 756 |
// from the only paint the visitor sees, and the page renders as |
| 757 |
// unstyled HTML (bulleted nav, underlined links) until the swap |
| 758 |
// runs. The pattern is only safe when something already styles the |
| 759 |
// above-the-fold area, i.e. critical CSS — which Free does not |
| 760 |
// generate. Deferring EVERY sheet on a site without it guarantees |
| 761 |
// the flash rather than risking it: on the reported Kadence site |
| 762 |
// all 17 stylesheets were deferred and none was render-blocking, |
| 763 |
// so there was nothing left to paint the page with. (#269) |
| 764 |
if ( self::is_layout_critical_style( $handle ) ) { |
| 765 |
return $tag; |
| 766 |
} |
| 767 |
// A JS-measured layout on this page makes deferral unsafe for EVERY |
| 768 |
// sheet, not just the theme's. |
| 769 |
// |
| 770 |
// Masonry, isotope, packery and the slider libraries lay elements out |
| 771 |
// by MEASURING them and then writing absolute positions. Deferring the |
| 772 |
// stylesheet that sizes those elements means the script measures them |
| 773 |
// unstyled — zero or full-width — computes positions from those wrong |
| 774 |
// numbers, and commits them. The CSS arriving a moment later cannot |
| 775 |
// undo it: the script has already run and does not re-measure. The |
| 776 |
// result is a permanently broken grid (items overlapping, or stranded |
| 777 |
// with a large gap), which is worse than the flash this feature's |
| 778 |
// other guard prevents, because it never resolves itself. |
| 779 |
// |
| 780 |
// This is checked per PAGE rather than per handle deliberately. The |
| 781 |
// script that measures is rarely the one whose handle matches the |
| 782 |
// sheet — Kadence's gallery is styled by |
| 783 |
// `kadence-blocks-advancedgallery` but laid out by core's `masonry` — |
| 784 |
// so pairing handles misses it. Whether a measuring library is present |
| 785 |
// at all is the signal that generalises. (#269) |
| 786 |
if ( self::page_has_js_measured_layout() ) { |
| 787 |
return $tag; |
| 788 |
} |
| 789 |
// Avoid double-wrapping. |
| 790 |
if ( false !== stripos( $tag, 'data-xs-async' ) ) { |
| 791 |
return $tag; |
| 792 |
} |
| 793 |
// Someone else already made this sheet non-render-blocking. |
| 794 |
// |
| 795 |
// Plugins that ship their own async-CSS handling apply the same |
| 796 |
// media="print" + onload swap we do, and they run on the SAME |
| 797 |
// filter — SureCookie's consent banner does it at style_loader_tag |
| 798 |
// priority 10, ours is priority 20, so its finished tag arrives |
| 799 |
// here looking like a plain stylesheet with no marker of ours. |
| 800 |
// |
| 801 |
// Transforming it again breaks the sheet two ways: the media we'd |
| 802 |
// capture as "the original to restore" is already `print`, so we |
| 803 |
// emit onload="this.media='print'" — a swap to itself that never |
| 804 |
// activates the stylesheet — and we append a SECOND onload |
| 805 |
// attribute, of which the parser honours only the first (ours), |
| 806 |
// discarding the plugin's correct this.media='all'. The banner |
| 807 |
// then mounts unstyled, in both logged-in and logged-out states. |
| 808 |
// |
| 809 |
// An onload handler or a print media on a stylesheet link is only |
| 810 |
// ever this pattern; a genuinely print-only sheet is already off |
| 811 |
// the critical path and gains nothing from us. Either way the |
| 812 |
// right move is to leave the tag alone — the same "don't fight |
| 813 |
// explicit author intent" rule the rel= check above applies. (#216) |
| 814 |
if ( preg_match( '#\bonload\s*=#i', $tag ) ) { |
| 815 |
return $tag; |
| 816 |
} |
| 817 |
if ( preg_match( '#\bmedia\s*=\s*(["\'])\s*print\s*\1#i', $tag ) ) { |
| 818 |
return $tag; |
| 819 |
} |
| 820 |
return self::async_link_markup( $tag ); |
| 821 |
} |
| 822 |
|
| 823 |
/** |
| 824 |
* The one place the async-CSS output shape lives: swap the link's media |
| 825 |
* to `print`, restore the original media onload, record it in |
| 826 |
* `data-xs-async`, and re-emit the untouched tag inside `<noscript>` for |
| 827 |
* clients that never run the onload handler. |
| 828 |
* |
| 829 |
* Shared by the enqueue-path filter above and the raw-tag buffer pass |
| 830 |
* below so the two can never drift — Pro's Critical CSS recognises this |
| 831 |
* exact marker to avoid double-wrapping, and a second copy of the |
| 832 |
* pattern is how that kind of contract quietly breaks. |
| 833 |
* |
| 834 |
* Callers own every skip decision (markers, onload, non-screen media); |
| 835 |
* this helper only produces the markup. |
| 836 |
* |
| 837 |
* @param string $tag A `<link rel="stylesheet">` tag deemed safe to defer. |
| 838 |
*/ |
| 839 |
private static function async_link_markup( string $tag ): string { |
| 840 |
$async = (string) preg_replace_callback( |
| 841 |
'#\bmedia\s*=\s*(["\'])([^"\']*)\1#i', |
| 842 |
static function ( $m ) { |
| 843 |
$orig = $m[2]; |
| 844 |
return 'media="print" onload="this.media=\'' . esc_attr( $orig ) . '\'" data-xs-async="' . esc_attr( $orig ) . '"'; |
| 845 |
}, |
| 846 |
$tag, |
| 847 |
1 |
| 848 |
); |
| 849 |
// If no media= was present (rare), inject one. |
| 850 |
if ( $async === $tag ) { |
| 851 |
$async = (string) preg_replace( |
| 852 |
'#<link\b#i', |
| 853 |
'<link media="print" onload="this.media=\'all\'" data-xs-async="all"', |
| 854 |
$tag, |
| 855 |
1 |
| 856 |
); |
| 857 |
} |
| 858 |
// Fallback for noscript users — re-emit the original tag inside <noscript>. |
| 859 |
return $async . '<noscript>' . $tag . '</noscript>'; |
| 860 |
} |
| 861 |
|
| 862 |
/** |
| 863 |
* Stylesheet hosts that serve FONT CSS — small, render-blocking sheets of |
| 864 |
* `@font-face` rules. The buffer pass below defers only these: a raw |
| 865 |
* cross-origin `<link>` could carry anything, and blindly deferring an |
| 866 |
* unknown vendor's layout CSS from the buffer would reintroduce the |
| 867 |
* unstyled-flash failure async_style_tag()'s guards exist to prevent. |
| 868 |
* Font CSS is the safe subset — text renders in a fallback face and swaps, |
| 869 |
* which is exactly what `font-display: swap` does on purpose. |
| 870 |
*/ |
| 871 |
private const FONT_CSS_HOSTS = array( |
| 872 |
'fonts.googleapis.com', |
| 873 |
'fonts.bunny.net', |
| 874 |
'use.typekit.net', |
| 875 |
'p.typekit.net', |
| 876 |
'fonts.cdnfonts.com', |
| 877 |
); |
| 878 |
|
| 879 |
/** |
| 880 |
* The font-CSS host allowlist, filtered and normalised. |
| 881 |
* |
| 882 |
* @return string[] Lowercase hostnames. |
| 883 |
*/ |
| 884 |
private static function font_css_hosts(): array { |
| 885 |
/** |
| 886 |
* Hosts whose stylesheet links the async-CSS buffer pass rewrites to |
| 887 |
* the non-blocking print → onload pattern. Only font-CSS providers |
| 888 |
* belong here: every listed host's sheets are safe to load late |
| 889 |
* because they only add `@font-face` rules. |
| 890 |
* |
| 891 |
* @param string[] $hosts Hostnames (exact match, case-insensitive). |
| 892 |
*/ |
| 893 |
$hosts = (array) apply_filters( 'xspeed_async_css_font_hosts', self::FONT_CSS_HOSTS ); |
| 894 |
|
| 895 |
return array_map( 'strtolower', array_map( 'strval', $hosts ) ); |
| 896 |
} |
| 897 |
|
| 898 |
/** |
| 899 |
* Media values that never apply to a screen paint. A sheet restricted to |
| 900 |
* one of these is not render-blocking for screen, so deferring it saves |
| 901 |
* nothing — and `print` in particular is either a genuine print sheet or |
| 902 |
* somebody's finished async pattern, both of which must be left alone. |
| 903 |
*/ |
| 904 |
private const NON_SCREEN_MEDIA = array( |
| 905 |
'print', |
| 906 |
'speech', |
| 907 |
'aural', |
| 908 |
'braille', |
| 909 |
'embossed', |
| 910 |
'handheld', |
| 911 |
'projection', |
| 912 |
'tty', |
| 913 |
'tv', |
| 914 |
); |
| 915 |
|
| 916 |
/** |
| 917 |
* Filter: `xspeed_cache_final_html` — defer RAW font-CSS stylesheet links |
| 918 |
* that never passed through wp_enqueue_style. |
| 919 |
* |
| 920 |
* `async_style_tag()` hooks `style_loader_tag`, so it only ever sees |
| 921 |
* enqueued stylesheets. Themes and font plugins print Google Fonts (and |
| 922 |
* Bunny, Typekit, CDNFonts) as literal |
| 923 |
* `<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=…">` |
| 924 |
* markup in the head — on the site that surfaced this, four such tags — |
| 925 |
* and each one stays render-blocking with no plugin lever. Unused CSS |
| 926 |
* skips cross-origin hrefs by design, so nothing else picks them up. |
| 927 |
* |
| 928 |
* Runs on the finished page buffer, so the rewrite is baked into the |
| 929 |
* cached HTML and replays on every static hit. Deliberately narrow: only |
| 930 |
* links whose host is on the font-CSS allowlist are touched — see |
| 931 |
* FONT_CSS_HOSTS. Same-origin links (no host, or the site's own) never |
| 932 |
* match the allowlist and are untouched. |
| 933 |
* |
| 934 |
* @param string $html Complete page HTML. |
| 935 |
*/ |
| 936 |
public static function async_raw_font_css_links( $html ): string { |
| 937 |
if ( ! is_string( $html ) || '' === $html ) { |
| 938 |
return (string) $html; |
| 939 |
} |
| 940 |
if ( self::skip_in_non_frontend_context() ) { |
| 941 |
return $html; |
| 942 |
} |
| 943 |
$opts = self::opts(); |
| 944 |
if ( empty( $opts['async_css'] ) ) { |
| 945 |
return $html; |
| 946 |
} |
| 947 |
|
| 948 |
// Never rewrite inside a <noscript>. That block IS the no-JS |
| 949 |
// fallback — its <link> is a plain blocking stylesheet on purpose, |
| 950 |
// and async_style_tag() itself emits one for every sheet it defers. |
| 951 |
// Rewriting it would nest <noscript> (invalid; the parser closes the |
| 952 |
// outer block at the first </noscript>) and hand no-JS visitors a |
| 953 |
// media="print" sheet whose onload never runs: no stylesheet at all. |
| 954 |
// Splitting the buffer on <noscript> spans and rewriting only the |
| 955 |
// slices between them also makes the pass idempotent against |
| 956 |
// whatever an earlier pass emitted. |
| 957 |
$parts = preg_split( |
| 958 |
'#(<noscript\b[^>]*>.*?</noscript\s*>)#is', |
| 959 |
$html, |
| 960 |
-1, |
| 961 |
PREG_SPLIT_DELIM_CAPTURE |
| 962 |
); |
| 963 |
|
| 964 |
// preg_split failed (pathological buffer / backtrack limit). Without |
| 965 |
// the split we cannot tell a fallback link from a live one, so leave |
| 966 |
// the page untouched — a few blocking font sheets beat a broken |
| 967 |
// no-JS fallback. |
| 968 |
if ( ! is_array( $parts ) ) { |
| 969 |
return $html; |
| 970 |
} |
| 971 |
|
| 972 |
foreach ( $parts as $i => $part ) { |
| 973 |
// Odd indices are the captured <noscript> blocks. |
| 974 |
if ( 1 === $i % 2 || '' === $part ) { |
| 975 |
continue; |
| 976 |
} |
| 977 |
$parts[ $i ] = self::async_font_links_in_slice( $part ); |
| 978 |
} |
| 979 |
|
| 980 |
return implode( '', $parts ); |
| 981 |
} |
| 982 |
|
| 983 |
/** |
| 984 |
* Rewrite the font-CSS links in one <noscript>-free slice of the buffer. |
| 985 |
* |
| 986 |
* @param string $html Slice of page HTML with no <noscript> spans. |
| 987 |
*/ |
| 988 |
private static function async_font_links_in_slice( string $html ): string { |
| 989 |
$hosts = self::font_css_hosts(); |
| 990 |
|
| 991 |
$out = preg_replace_callback( |
| 992 |
'#<link\b[^>]*>#i', |
| 993 |
static function ( array $m ) use ( $hosts ): string { |
| 994 |
$tag = $m[0]; |
| 995 |
|
| 996 |
// Only plain stylesheets — never preload/alternate/anything |
| 997 |
// carrying explicit author intent. `(?<![-\w])` not `\b`, so |
| 998 |
// a `data-rel=` attribute can never read as the rel — same |
| 999 |
// reason the delay passes spell src that way. (#273) |
| 1000 |
if ( ! preg_match( '#(?<![-\w])rel\s*=\s*(["\']?)\s*stylesheet\s*\1#i', $tag ) ) { |
| 1001 |
return $tag; |
| 1002 |
} |
| 1003 |
|
| 1004 |
// Already deferred (either marker spelling — ours and Pro's), |
| 1005 |
// or explicitly opted out by the theme. |
| 1006 |
foreach ( array( 'data-xs-async', 'data-xspeed-async', 'data-xspeed-keep' ) as $marker ) { |
| 1007 |
if ( false !== stripos( $tag, $marker ) ) { |
| 1008 |
return $tag; |
| 1009 |
} |
| 1010 |
} |
| 1011 |
|
| 1012 |
// An onload handler on a stylesheet link is only ever |
| 1013 |
// somebody's finished async pattern — same rule as |
| 1014 |
// async_style_tag(). (#216) |
| 1015 |
if ( preg_match( '#(?<![-\w])onload\s*=#i', $tag ) ) { |
| 1016 |
return $tag; |
| 1017 |
} |
| 1018 |
|
| 1019 |
// A sheet that never applies on screen is not blocking paint. |
| 1020 |
if ( preg_match( '#(?<![-\w])media\s*=\s*(["\'])([^"\']*)\1#i', $tag, $mm ) |
| 1021 |
&& in_array( strtolower( trim( $mm[2] ) ), self::NON_SCREEN_MEDIA, true ) ) { |
| 1022 |
return $tag; |
| 1023 |
} |
| 1024 |
|
| 1025 |
if ( ! preg_match( '#(?<![-\w])href\s*=\s*(["\'])([^"\']+)\1#i', $tag, $hm ) ) { |
| 1026 |
return $tag; |
| 1027 |
} |
| 1028 |
// No host means a relative URL — same-origin, and the enqueue |
| 1029 |
// path's business if it is anybody's. |
| 1030 |
$host = strtolower( (string) wp_parse_url( $hm[2], PHP_URL_HOST ) ); |
| 1031 |
if ( '' === $host || ! in_array( $host, $hosts, true ) ) { |
| 1032 |
return $tag; |
| 1033 |
} |
| 1034 |
|
| 1035 |
return self::async_link_markup( $tag ); |
| 1036 |
}, |
| 1037 |
$html |
| 1038 |
); |
| 1039 |
|
| 1040 |
// A PCRE failure returns null — the unrewritten slice is the safe |
| 1041 |
// fallback, never an empty page. |
| 1042 |
return null === $out ? $html : $out; |
| 1043 |
} |
| 1044 |
|
| 1045 |
/** |
| 1046 |
* Whether a stylesheet handle carries the page's layout, and so must |
| 1047 |
* keep blocking the first paint. |
| 1048 |
* |
| 1049 |
* Two families qualify: |
| 1050 |
* |
| 1051 |
* - The ACTIVE THEME's own sheets. A theme stylesheet is the page's |
| 1052 |
* layout by definition; without it the document paints as unstyled |
| 1053 |
* HTML. Resolved from the live theme's stem (`kadence` → |
| 1054 |
* `kadence-global`, `kadence-header`, …) plus the handles WordPress |
| 1055 |
* itself registers for a theme, so this holds for any theme rather |
| 1056 |
* than a hard-coded list. |
| 1057 |
* - WordPress' own BLOCK and layout sheets (`wp-block-library`, |
| 1058 |
* `global-styles`, `classic-theme-styles`). These style block |
| 1059 |
* content on the front end and are as structural as the theme's. |
| 1060 |
* |
| 1061 |
* Everything else — plugin sheets, icon fonts, widget and page-builder |
| 1062 |
* add-ons, the long tail that makes async CSS worth having — is still |
| 1063 |
* deferred, so the optimization keeps most of its benefit. |
| 1064 |
* |
| 1065 |
* A site WITH critical CSS can defer these too; that is what the |
| 1066 |
* `xspeed_async_css_layout_critical` filter is for. |
| 1067 |
* |
| 1068 |
* Pure aside from the theme lookup — unit-tested via the filter. |
| 1069 |
* |
| 1070 |
* @param string $handle Stylesheet handle from `style_loader_tag`. |
| 1071 |
*/ |
| 1072 |
public static function is_layout_critical_style( string $handle ): bool { |
| 1073 |
$handle = strtolower( $handle ); |
| 1074 |
|
| 1075 |
// Core's front-end block + global styles. |
| 1076 |
$core = array( |
| 1077 |
'wp-block-library', |
| 1078 |
'wp-block-library-theme', |
| 1079 |
'global-styles', |
| 1080 |
'classic-theme-styles', |
| 1081 |
); |
| 1082 |
$critical = in_array( $handle, $core, true ); |
| 1083 |
|
| 1084 |
// The active theme's own sheets. |
| 1085 |
// |
| 1086 |
// Matched on the theme stem, but NOT as a bare prefix: a plugin from |
| 1087 |
// the same vendor shares it (the Kadence theme is `kadence`, while |
| 1088 |
// `kadence-blocks-rowlayout` and `kadence-fonts-gfonts` come from the |
| 1089 |
// Kadence Blocks PLUGIN and a webfont loader). Treating those as |
| 1090 |
// layout-critical would leave almost nothing deferred and quietly |
| 1091 |
// undo the feature. So the stem must be followed by a recognised |
| 1092 |
// theme-area segment, which is how themes name their split sheets. |
| 1093 |
if ( ! $critical && function_exists( 'get_template' ) ) { |
| 1094 |
$areas = array( |
| 1095 |
'style', |
| 1096 |
'global', |
| 1097 |
'header', |
| 1098 |
'content', |
| 1099 |
'footer', |
| 1100 |
'main', |
| 1101 |
'layout', |
| 1102 |
'base', |
| 1103 |
'core', |
| 1104 |
'theme', |
| 1105 |
'woocommerce', |
| 1106 |
); |
| 1107 |
foreach ( array( get_template(), get_stylesheet() ) as $stem ) { |
| 1108 |
$stem = strtolower( (string) $stem ); |
| 1109 |
if ( '' === $stem ) { |
| 1110 |
continue; |
| 1111 |
} |
| 1112 |
if ( $handle === $stem ) { |
| 1113 |
$critical = true; |
| 1114 |
break; |
| 1115 |
} |
| 1116 |
foreach ( $areas as $area ) { |
| 1117 |
if ( $handle === $stem . '-' . $area ) { |
| 1118 |
$critical = true; |
| 1119 |
break 2; |
| 1120 |
} |
| 1121 |
} |
| 1122 |
} |
| 1123 |
} |
| 1124 |
|
| 1125 |
/** |
| 1126 |
* Whether this stylesheet must keep blocking the first paint. |
| 1127 |
* |
| 1128 |
* Return false for a handle to let async CSS defer it anyway — the |
| 1129 |
* right call on a site that ships critical CSS. Return true to |
| 1130 |
* protect an additional sheet the layout depends on. |
| 1131 |
* |
| 1132 |
* @param bool $critical Whether the sheet is treated as layout-critical. |
| 1133 |
* @param string $handle The stylesheet handle. |
| 1134 |
*/ |
| 1135 |
return (bool) apply_filters( 'xspeed_async_css_layout_critical', $critical, $handle ); |
| 1136 |
} |
| 1137 |
|
| 1138 |
/** |
| 1139 |
* Filter: `style_loader_src` + `script_loader_src` — strip the |
| 1140 |
* ?ver=X.Y query string that WP appends for cache busting. Some |
| 1141 |
* CDNs / reverse proxies cache better when the URL has no query. |
| 1142 |
* |
| 1143 |
* Skip URLs whose query carries non-ver params — those might be |
| 1144 |
* intentional (e.g. a CDN providing per-image transforms). |
| 1145 |
* |
| 1146 |
* `ver` is load-bearing on one class of asset: a file a plugin |
| 1147 |
* REGENERATES IN PLACE. Complianz rewrites |
| 1148 |
* uploads/complianz/css/banner-1-optin.css whenever the banner is |
| 1149 |
* edited, Beaver Builder rewrites uploads/bb-plugin/cache/<post>-layout.css |
| 1150 |
* on every layout save, Elementor uploads/elementor/css/post-<id>.css on |
| 1151 |
* publish. The path never changes, so `?ver=<timestamp|hash>` is the only |
| 1152 |
* thing telling a browser — or our own Browser Cache `immutable` rule — to |
| 1153 |
* refetch. Strip it and the old styling is served until the browser cache |
| 1154 |
* gives up, which for us is a year. So anything under the uploads root |
| 1155 |
* keeps its version. |
| 1156 |
* |
| 1157 |
* Release assets under plugins/, themes/ and core are still stripped, but |
| 1158 |
* not because they are safe: an update overwrites the same path there too, |
| 1159 |
* and only `?ver=` changed. The difference is frequency, not mechanism — a |
| 1160 |
* plugin update lands rarely and is expected to, a banner edit is a setting |
| 1161 |
* the user just changed and expects to see. Stripping is the feature the |
| 1162 |
* toggle is for; with Browser Cache on it is what the user is buying, and |
| 1163 |
* `docs/user/minification.md` states the cost. (#276) |
| 1164 |
* |
| 1165 |
* @param string $src |
| 1166 |
*/ |
| 1167 |
public static function strip_version_query( $src ): string { |
| 1168 |
if ( ! is_string( $src ) || '' === $src ) { |
| 1169 |
return (string) $src; |
| 1170 |
} |
| 1171 |
if ( self::skip_in_non_frontend_context() ) { |
| 1172 |
return $src; |
| 1173 |
} |
| 1174 |
$parts = wp_parse_url( $src ); |
| 1175 |
if ( ! is_array( $parts ) || empty( $parts['query'] ) ) { |
| 1176 |
return $src; |
| 1177 |
} |
| 1178 |
parse_str( $parts['query'], $query ); |
| 1179 |
if ( ! is_array( $query ) || ! array_key_exists( 'ver', $query ) ) { |
| 1180 |
return $src; |
| 1181 |
} |
| 1182 |
|
| 1183 |
$strip = ! self::is_regenerated_asset( $parts ); |
| 1184 |
|
| 1185 |
/** |
| 1186 |
* Whether Remove Query Strings drops `?ver` from this asset URL. |
| 1187 |
* |
| 1188 |
* False by default under the uploads root, where page builders and |
| 1189 |
* consent plugins rewrite generated CSS/JS in place and `ver` is its |
| 1190 |
* only cache-buster. Return false to protect a generator that writes |
| 1191 |
* somewhere else, true to force stripping. |
| 1192 |
* |
| 1193 |
* @param bool $strip Whether `ver` will be removed. |
| 1194 |
* @param string $src The asset URL as enqueued. |
| 1195 |
*/ |
| 1196 |
if ( ! apply_filters( 'xspeed_strip_asset_version', $strip, $src ) ) { |
| 1197 |
return $src; |
| 1198 |
} |
| 1199 |
|
| 1200 |
// Only strip 'ver' — keep anything else the asset URL needs. |
| 1201 |
unset( $query['ver'] ); |
| 1202 |
$new_query = http_build_query( $query ); |
| 1203 |
|
| 1204 |
// Rebuild the authority only when the source had one. An enqueued |
| 1205 |
// src is not always absolute: `//cdn.example/x.css` says "the |
| 1206 |
// page's own scheme", and defaulting that to http:// is mixed |
| 1207 |
// content an https page blocks outright; `/wp-includes/x.js` has no |
| 1208 |
// host at all, and pasting one in produced `http:///wp-includes/…`, |
| 1209 |
// which resolves nowhere. |
| 1210 |
$new_url = ''; |
| 1211 |
if ( isset( $parts['host'] ) && '' !== $parts['host'] ) { |
| 1212 |
$new_url = isset( $parts['scheme'] ) ? $parts['scheme'] . '://' : '//'; |
| 1213 |
$new_url .= $parts['host']; |
| 1214 |
if ( isset( $parts['port'] ) ) { |
| 1215 |
$new_url .= ':' . $parts['port']; |
| 1216 |
} |
| 1217 |
} |
| 1218 |
$new_url .= $parts['path'] ?? ''; |
| 1219 |
if ( '' !== $new_query ) { |
| 1220 |
$new_url .= '?' . $new_query; |
| 1221 |
} |
| 1222 |
if ( ! empty( $parts['fragment'] ) ) { |
| 1223 |
$new_url .= '#' . $parts['fragment']; |
| 1224 |
} |
| 1225 |
return $new_url; |
| 1226 |
} |
| 1227 |
|
| 1228 |
/** |
| 1229 |
* Memoised uploads root, see uploads_base(). Cleared by reset_state(). |
| 1230 |
* |
| 1231 |
* @var array{host:string,path:string}|null |
| 1232 |
*/ |
| 1233 |
private static $uploads_base = null; |
| 1234 |
|
| 1235 |
/** |
| 1236 |
* The uploads root as a URL host + PATH, read from wp_get_upload_dir() |
| 1237 |
* rather than hardcoded so a moved uploads dir, the `UPLOADS` constant and |
| 1238 |
* the legacy multisite `/files/` layout all work. |
| 1239 |
* |
| 1240 |
* On multisite wp_get_upload_dir() answers with the per-site |
| 1241 |
* `…/uploads/sites/<id>`. Generated assets live under the network root |
| 1242 |
* too, so the suffix comes off and the whole tree matches. |
| 1243 |
* |
| 1244 |
* @return array{host:string,path:string} |
| 1245 |
*/ |
| 1246 |
private static function uploads_base(): array { |
| 1247 |
if ( null !== self::$uploads_base ) { |
| 1248 |
return self::$uploads_base; |
| 1249 |
} |
| 1250 |
$base = ''; |
| 1251 |
if ( function_exists( 'wp_get_upload_dir' ) ) { |
| 1252 |
$dir = wp_get_upload_dir(); |
| 1253 |
$base = is_array( $dir ) && isset( $dir['baseurl'] ) ? (string) $dir['baseurl'] : ''; |
| 1254 |
} |
| 1255 |
$host = ''; |
| 1256 |
$path = ''; |
| 1257 |
if ( '' !== $base ) { |
| 1258 |
$host = strtolower( (string) wp_parse_url( $base, PHP_URL_HOST ) ); |
| 1259 |
$path = (string) wp_parse_url( $base, PHP_URL_PATH ); |
| 1260 |
} |
| 1261 |
$path = (string) preg_replace( '#/sites/\d+/?$#', '', rtrim( $path, '/' ) ); |
| 1262 |
if ( '' === $path && '' === $host ) { |
| 1263 |
// Unreadable. An empty prefix would match every asset on the |
| 1264 |
// site, so fall back to where uploads normally is. |
| 1265 |
$path = '/wp-content/uploads'; |
| 1266 |
} |
| 1267 |
self::$uploads_base = array( |
| 1268 |
'host' => $host, |
| 1269 |
'path' => $path, |
| 1270 |
); |
| 1271 |
return self::$uploads_base; |
| 1272 |
} |
| 1273 |
|
| 1274 |
/** |
| 1275 |
* Does this URL sit under the uploads root — i.e. is it a file some plugin |
| 1276 |
* generates at runtime and rewrites in place? |
| 1277 |
* |
| 1278 |
* @param array<string,mixed> $parts wp_parse_url() output for the asset. |
| 1279 |
*/ |
| 1280 |
private static function is_regenerated_asset( array $parts ): bool { |
| 1281 |
$base = self::uploads_base(); |
| 1282 |
|
| 1283 |
if ( '' !== $base['path'] ) { |
| 1284 |
// Path only, never host: a pull-zone CDN, a protocol-relative URL |
| 1285 |
// and an http/https flip all leave the path alone. |
| 1286 |
$path = (string) ( $parts['path'] ?? '' ); |
| 1287 |
return '' !== $path && 0 === strpos( $path, $base['path'] . '/' ); |
| 1288 |
} |
| 1289 |
|
| 1290 |
// Uploads AT the root of their own domain — an offload plugin |
| 1291 |
// pointing `upload_url_path` at https://cdn.example.com. There is no |
| 1292 |
// prefix left to test, and testing the path anyway would have read |
| 1293 |
// every generated file on that CDN as an ordinary release asset and |
| 1294 |
// stripped the one thing telling a browser it had changed. The host |
| 1295 |
// is the whole answer here: everything served from it is an upload. |
| 1296 |
$host = strtolower( (string) ( $parts['host'] ?? '' ) ); |
| 1297 |
return '' !== $host && $host === $base['host']; |
| 1298 |
} |
| 1299 |
|
| 1300 |
/** |
| 1301 |
* Defensive context guard for filter callbacks. Mirrors the registration- |
| 1302 |
* time bail in Minifier::__construct() so a late context flip (admin page |
| 1303 |
* render kicked off mid-request, REST_REQUEST set after plugins_loaded, |
| 1304 |
* etc.) doesn't let frontend tag rewrites leak into wp-admin / AJAX / |
| 1305 |
* REST / cron responses. |
| 1306 |
* |
| 1307 |
* Specifically prevents the React admin bundle's <script> tag from being |
| 1308 |
* deferred or src-swapped to data-xs-src — which would stop the dashboard |
| 1309 |
* from booting and make toggles appear unchecked until first interaction. |
| 1310 |
*/ |
| 1311 |
private static function skip_in_non_frontend_context(): bool { |
| 1312 |
if ( is_admin() ) { |
| 1313 |
return true; |
| 1314 |
} |
| 1315 |
if ( defined( 'DOING_AJAX' ) && DOING_AJAX ) { |
| 1316 |
return true; |
| 1317 |
} |
| 1318 |
if ( defined( 'DOING_CRON' ) && DOING_CRON ) { |
| 1319 |
return true; |
| 1320 |
} |
| 1321 |
if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) { |
| 1322 |
return true; |
| 1323 |
} |
| 1324 |
return false; |
| 1325 |
} |
| 1326 |
|
| 1327 |
/** |
| 1328 |
* Built-in exclusion list — always skipped regardless of user settings. |
| 1329 |
* Covers our own admin bundle and the WP script-modules it depends on, |
| 1330 |
* so that even if the registration-time admin guard is somehow bypassed, |
| 1331 |
* the dashboard's React app can still boot. |
| 1332 |
*/ |
| 1333 |
private const ALWAYS_EXCLUDED_HANDLES = array( |
| 1334 |
'xspeed-admin', |
| 1335 |
'wp-hooks', |
| 1336 |
'wp-i18n', |
| 1337 |
'wp-url', |
| 1338 |
'wp-api-fetch', |
| 1339 |
); |
| 1340 |
|
| 1341 |
private static function is_excluded_script( string $handle, string $src ): bool { |
| 1342 |
if ( in_array( $handle, self::ALWAYS_EXCLUDED_HANDLES, true ) ) { |
| 1343 |
return true; |
| 1344 |
} |
| 1345 |
// Never defer or delay our own scripts. The fold and RUM beacons |
| 1346 |
// measure the FIRST paint — delayed to first interaction they |
| 1347 |
// measure a scrolled page or nothing, so fold quorum never fills |
| 1348 |
// and full CSS deferral never licenses. Found live: delay_js with |
| 1349 |
// empty targets delayed the fold beacon itself, and the site sat |
| 1350 |
// at zero fold reports for hours while its stylesheets stayed |
| 1351 |
// render-blocking. Prefix, not a handle list, so a Pro module's |
| 1352 |
// beacon added later cannot re-open the hole. |
| 1353 |
if ( 0 === strpos( $handle, 'xspeed-' ) ) { |
| 1354 |
return true; |
| 1355 |
} |
| 1356 |
$opts = self::opts(); |
| 1357 |
$excluded = is_array( $opts['defer_js_excluded'] ?? null ) ? $opts['defer_js_excluded'] : array(); |
| 1358 |
if ( empty( $excluded ) ) { |
| 1359 |
return false; |
| 1360 |
} |
| 1361 |
foreach ( $excluded as $needle ) { |
| 1362 |
// Matched against the pre-minify URL too: an exclusion that |
| 1363 |
// stops matching is worse than a delay target that does — the |
| 1364 |
// script the user explicitly protected gets deferred anyway. |
| 1365 |
if ( self::target_matches( (string) $needle, $handle, $src ) ) { |
| 1366 |
return true; |
| 1367 |
} |
| 1368 |
} |
| 1369 |
return false; |
| 1370 |
} |
| 1371 |
|
| 1372 |
/** |
| 1373 |
* Include-list targeting for delay (issue #36): when delay_js_targets |
| 1374 |
* is non-empty, ONLY matching scripts are delayed — a heavy |
| 1375 |
* third-party embed can be postponed without delaying the whole |
| 1376 |
* page's JS. Empty targets = historical behavior (delay everything |
| 1377 |
* minus exclusions). Same matching semantics as the exclusion list: |
| 1378 |
* exact handle match OR case-insensitive URL substring. |
| 1379 |
*/ |
| 1380 |
/** |
| 1381 |
* Whether the user's delay_js_targets list matches this haystack. |
| 1382 |
* |
| 1383 |
* The inline-snippet pass needs the target list WITHOUT |
| 1384 |
* is_delay_target()'s empty-list-means-everything default — an inline |
| 1385 |
* body is only ever delayed on a positive match. |
| 1386 |
* |
| 1387 |
* @param string $haystack Script body (or URL) to match fragments against. |
| 1388 |
*/ |
| 1389 |
private static function matches_user_targets( string $haystack ): bool { |
| 1390 |
$opts = self::opts(); |
| 1391 |
$targets = is_array( $opts['delay_js_targets'] ?? null ) ? $opts['delay_js_targets'] : array(); |
| 1392 |
foreach ( $targets as $needle ) { |
| 1393 |
$needle = (string) $needle; |
| 1394 |
if ( '' !== $needle && false !== stripos( $haystack, $needle ) ) { |
| 1395 |
return true; |
| 1396 |
} |
| 1397 |
} |
| 1398 |
return false; |
| 1399 |
} |
| 1400 |
|
| 1401 |
/** |
| 1402 |
* Whether the user EXPLICITLY named this script in delay_js_targets. |
| 1403 |
* |
| 1404 |
* Unlike is_delay_target() this never treats an empty list as |
| 1405 |
* everything and never falls back to the vendor list — it answers |
| 1406 |
* only "did the user deliberately point at this handle/URL?", which |
| 1407 |
* is what lets an explicit entry override the inline-bound guard. |
| 1408 |
* |
| 1409 |
* @param string $handle Script handle. |
| 1410 |
* @param string $src Script URL. |
| 1411 |
*/ |
| 1412 |
private static function is_user_named_target( string $handle, string $src ): bool { |
| 1413 |
$opts = self::opts(); |
| 1414 |
$targets = is_array( $opts['delay_js_targets'] ?? null ) ? $opts['delay_js_targets'] : array(); |
| 1415 |
foreach ( $targets as $needle ) { |
| 1416 |
$needle = (string) $needle; |
| 1417 |
if ( '' !== $needle && self::target_matches( $needle, $handle, $src ) ) { |
| 1418 |
return true; |
| 1419 |
} |
| 1420 |
} |
| 1421 |
return false; |
| 1422 |
} |
| 1423 |
|
| 1424 |
private static function is_delay_target( string $handle, string $src ): bool { |
| 1425 |
$opts = self::opts(); |
| 1426 |
$targets = is_array( $opts['delay_js_targets'] ?? null ) ? $opts['delay_js_targets'] : array(); |
| 1427 |
$targets = array_filter( array_map( 'strval', $targets ), static fn( $t ) => '' !== $t ); |
| 1428 |
if ( empty( $targets ) ) { |
| 1429 |
return true; |
| 1430 |
} |
| 1431 |
foreach ( $targets as $needle ) { |
| 1432 |
if ( self::target_matches( $needle, $handle, $src ) ) { |
| 1433 |
return true; |
| 1434 |
} |
| 1435 |
} |
| 1436 |
// The user's list is an ALLOW-list, so a target they never thought to |
| 1437 |
// add is not delayed — and the scripts worth delaying are third-party |
| 1438 |
// tags nobody enumerates by hand. Falling back to the built-in vendor |
| 1439 |
// list means a site that lists one heavy embed still gets the obvious |
| 1440 |
// analytics and widget tags postponed, instead of silently keeping |
| 1441 |
// them on the main thread. (A user who wants one of these to run |
| 1442 |
// early excludes it; the exclusion list is checked before this.) |
| 1443 |
return self::matches_known_third_party( $src ); |
| 1444 |
} |
| 1445 |
|
| 1446 |
/** |
| 1447 |
* Whether a URL belongs to a third-party tag that is safe to postpone. |
| 1448 |
* |
| 1449 |
* These are analytics, tag managers, chat widgets, review embeds, session |
| 1450 |
* recorders and error trackers: scripts that never paint anything above |
| 1451 |
* the fold and that no first-party code holds a synchronous reference to. |
| 1452 |
* They are also the scripts that dominate a real page's blocking time — |
| 1453 |
* on embedpress.com one chat widget alone accounted for ~450ms of TBT and |
| 1454 |
* a 22-point score swing between runs, purely on whether it happened to |
| 1455 |
* arrive inside the measurement window. |
| 1456 |
* |
| 1457 |
* Matched on URL only, never on handle: these tags are printed straight |
| 1458 |
* into wp_head / wp_footer by their vendors' snippets and usually have no |
| 1459 |
* WordPress handle at all. Host fragments rather than whole domains, so a |
| 1460 |
* regional or versioned CDN path still matches. |
| 1461 |
* |
| 1462 |
* Deliberately NOT here: anything from the site's own origin, jQuery, or |
| 1463 |
* any wp-* core script. Those carry inline consumers, and delaying them |
| 1464 |
* is what breaks pages — see inline_bound_handles(). |
| 1465 |
*/ |
| 1466 |
private const KNOWN_THIRD_PARTY_SRC = array( |
| 1467 |
// Tag managers and analytics. |
| 1468 |
'googletagmanager.com', |
| 1469 |
'google-analytics.com', |
| 1470 |
'analytics.google.com', |
| 1471 |
'/gtag/js', |
| 1472 |
'gtm4wp', |
| 1473 |
'plausible.io', |
| 1474 |
'matomo', |
| 1475 |
'segment.com/analytics.js', |
| 1476 |
'stats.wp.com', |
| 1477 |
// Advertising and conversion pixels. |
| 1478 |
'connect.facebook.net', |
| 1479 |
'fbevents.js', |
| 1480 |
'ads-twitter.com', |
| 1481 |
'snap.licdn.com', |
| 1482 |
'analytics.tiktok.com', |
| 1483 |
'googleadservices.com', |
| 1484 |
'doubleclick.net', |
| 1485 |
// Session recording and heatmaps. |
| 1486 |
'hotjar.com', |
| 1487 |
'clarity.ms', |
| 1488 |
'mouseflow.com', |
| 1489 |
'fullstory.com', |
| 1490 |
'luckyorange', |
| 1491 |
// Chat and support widgets. |
| 1492 |
'client.crisp.chat', |
| 1493 |
'widget.intercom.io', |
| 1494 |
'js.driftt.com', |
| 1495 |
'tawk.to', |
| 1496 |
'livechatinc.com', |
| 1497 |
'zdassets.com', |
| 1498 |
'helpscout.net', |
| 1499 |
// Reviews, social proof and marketing. |
| 1500 |
'tp.widget.bootstrap', |
| 1501 |
'trustpilot.com', |
| 1502 |
'static.klaviyo.com', |
| 1503 |
'js.hs-scripts.com', |
| 1504 |
'list-manage.com', |
| 1505 |
'sumo.com', |
| 1506 |
// Error and performance monitoring. |
| 1507 |
'sentry-cdn.com', |
| 1508 |
'browser.sentry', |
| 1509 |
'bugsnag.com', |
| 1510 |
'newrelic.com', |
| 1511 |
); |
| 1512 |
|
| 1513 |
/** |
| 1514 |
* Match a script URL against the built-in third-party list. |
| 1515 |
* |
| 1516 |
* @param string $src Script source URL. |
| 1517 |
*/ |
| 1518 |
private static function matches_known_third_party( string $src ): bool { |
| 1519 |
if ( '' === $src ) { |
| 1520 |
return false; |
| 1521 |
} |
| 1522 |
|
| 1523 |
$known = self::KNOWN_THIRD_PARTY_SRC; |
| 1524 |
|
| 1525 |
/** |
| 1526 |
* URL fragments the delay pass treats as safe-to-postpone third-party |
| 1527 |
* tags when the user's target list does not match. |
| 1528 |
* |
| 1529 |
* Append a vendor this list does not know yet, or remove one the site |
| 1530 |
* genuinely needs early. Entries are case-insensitive substrings of |
| 1531 |
* the script URL. |
| 1532 |
* |
| 1533 |
* @param string[] $known Built-in fragments. |
| 1534 |
* @param string $src The script URL being tested. |
| 1535 |
*/ |
| 1536 |
$known = (array) apply_filters( 'xspeed_delay_known_third_party', $known, $src ); |
| 1537 |
|
| 1538 |
foreach ( $known as $needle ) { |
| 1539 |
$needle = (string) $needle; |
| 1540 |
if ( '' !== $needle && false !== stripos( $src, $needle ) ) { |
| 1541 |
return true; |
| 1542 |
} |
| 1543 |
} |
| 1544 |
return false; |
| 1545 |
} |
| 1546 |
|
| 1547 |
private static function opts(): array { |
| 1548 |
if ( null === self::$opts ) { |
| 1549 |
self::$opts = Settings_Manager::get( 'minify' ); |
| 1550 |
} |
| 1551 |
return self::$opts; |
| 1552 |
} |
| 1553 |
|
| 1554 |
/** |
| 1555 |
* Test-only — clear cached opts + bootstrap-printed flag. |
| 1556 |
*/ |
| 1557 |
public static function reset_state(): void { |
| 1558 |
self::$opts = null; |
| 1559 |
self::$uploads_base = null; |
| 1560 |
self::$delay_bootstrap_printed = false; |
| 1561 |
self::$js_measured_layout = null; |
| 1562 |
self::$inline_bound_handles = null; |
| 1563 |
} |
| 1564 |
|
| 1565 |
/** |
| 1566 |
* Per-request memo for inline_bound_handles(). Null = not resolved. |
| 1567 |
* |
| 1568 |
* @var array<string,true>|null |
| 1569 |
*/ |
| 1570 |
private static $inline_bound_handles = null; |
| 1571 |
|
| 1572 |
/** |
| 1573 |
* Handles that cannot be deferred because inline code depends on them. |
| 1574 |
* |
| 1575 |
* #234 fixed the case where a handle carries its OWN inline block: the |
| 1576 |
* tag WordPress hands the filter is `before_inline + external + |
| 1577 |
* after_inline`, so defer goes on the external <script> and order holds. |
| 1578 |
* That leaves the cross-handle case, which is the one that actually |
| 1579 |
* breaks sites: `wp_add_inline_script( 'foo', … )` prints a bare inline |
| 1580 |
* block that runs at parse time and calls into whatever `foo` — or any |
| 1581 |
* of foo's DEPENDENCIES — defined. Inline scripts can never be deferred |
| 1582 |
* (the HTML spec ignores the attribute), so deferring anything they read |
| 1583 |
* from inverts the order WordPress guarantees and throws on a global |
| 1584 |
* that is not there yet. |
| 1585 |
* |
| 1586 |
* jQuery is the canonical victim: one `wp_add_inline_script( 'jquery', |
| 1587 |
* 'jQuery(function($){…})' )` anywhere on the page makes `jquery-core` |
| 1588 |
* undeferrable, and every hand-maintained exclusion list in the wild |
| 1589 |
* exists to say so. The registry already knows it, so read it instead of |
| 1590 |
* asking the user. |
| 1591 |
* |
| 1592 |
* Walks each handle carrying `after`/`before` inline data and marks the |
| 1593 |
* handle plus its transitive dependency chain. Cycles are guarded by the |
| 1594 |
* seen-map, so a self- or mutually-referential deps array terminates. |
| 1595 |
* |
| 1596 |
* Pure aside from the global registry read; memoised per request and |
| 1597 |
* cleared by reset_state(). |
| 1598 |
* |
| 1599 |
* @return array<string,true> Handle => true, for O(1) lookup. |
| 1600 |
*/ |
| 1601 |
public static function inline_bound_handles(): array { |
| 1602 |
if ( null !== self::$inline_bound_handles ) { |
| 1603 |
return self::$inline_bound_handles; |
| 1604 |
} |
| 1605 |
|
| 1606 |
$bound = array(); |
| 1607 |
if ( function_exists( 'wp_scripts' ) ) { |
| 1608 |
$scripts = wp_scripts(); |
| 1609 |
if ( $scripts instanceof \WP_Scripts ) { |
| 1610 |
foreach ( array_keys( (array) $scripts->registered ) as $handle ) { |
| 1611 |
$handle = (string) $handle; |
| 1612 |
if ( ! self::handle_carries_inline( $scripts, $handle ) ) { |
| 1613 |
continue; |
| 1614 |
} |
| 1615 |
self::mark_with_deps( $scripts, $handle, $bound ); |
| 1616 |
} |
| 1617 |
} |
| 1618 |
} |
| 1619 |
|
| 1620 |
/** |
| 1621 |
* Handles auto-excluded from defer because inline code reads them. |
| 1622 |
* |
| 1623 |
* Return a handle => true map. Add an entry to protect a script whose |
| 1624 |
* inline consumer this cannot see (one printed directly by a theme |
| 1625 |
* rather than through wp_add_inline_script), or remove one to defer a |
| 1626 |
* handle whose inline block is known not to touch it. |
| 1627 |
* |
| 1628 |
* @param array<string,true> $bound Detected handles. |
| 1629 |
*/ |
| 1630 |
$bound = (array) apply_filters( 'xspeed_defer_inline_bound_handles', $bound ); |
| 1631 |
|
| 1632 |
self::$inline_bound_handles = $bound; |
| 1633 |
|
| 1634 |
return self::$inline_bound_handles; |
| 1635 |
} |
| 1636 |
|
| 1637 |
/** |
| 1638 |
* Whether a handle must be kept out of a combined bundle. |
| 1639 |
* |
| 1640 |
* Combining re-homes a script's code under a different handle, so every |
| 1641 |
* protection keyed to the ORIGINAL handle or URL stops matching: the |
| 1642 |
* user's `defer_js_excluded` entry, and the inline-bound set above. The |
| 1643 |
* combiner already refuses a handle carrying its own inline data, which |
| 1644 |
* is why the gap is invisible until you look for it — a DEPENDENCY of an |
| 1645 |
* inline consumer carries none of its own, so `jquery-core` lands in the |
| 1646 |
* bundle while the exclusion list still reads as though it were honoured. |
| 1647 |
* |
| 1648 |
* Returning true here is enough on its own: the combiner drops any |
| 1649 |
* dependent of an uncombinable handle transitively, so the whole chain |
| 1650 |
* stays in the queue where WordPress prints it in the right order. |
| 1651 |
* |
| 1652 |
* @param string $handle Script handle. |
| 1653 |
* @param string $src Registered source URL. |
| 1654 |
*/ |
| 1655 |
public static function is_protected_from_bundling( string $handle, string $src ): bool { |
| 1656 |
if ( self::is_excluded_script( $handle, $src ) ) { |
| 1657 |
return true; |
| 1658 |
} |
| 1659 |
return isset( self::inline_bound_handles()[ $handle ] ); |
| 1660 |
} |
| 1661 |
|
| 1662 |
/** |
| 1663 |
* Whether a handle has inline JS attached in either position. |
| 1664 |
* |
| 1665 |
* `get_data()` returns the raw value, which is an array of code chunks |
| 1666 |
* for `after` and a string for `before`; both are falsy when absent, and |
| 1667 |
* an empty chunk array must not count as inline code. |
| 1668 |
* |
| 1669 |
* @param \WP_Scripts $scripts Registry. |
| 1670 |
* @param string $handle Handle to inspect. |
| 1671 |
*/ |
| 1672 |
private static function handle_carries_inline( \WP_Scripts $scripts, string $handle ): bool { |
| 1673 |
foreach ( array( 'after', 'before' ) as $position ) { |
| 1674 |
$data = $scripts->get_data( $handle, $position ); |
| 1675 |
if ( is_array( $data ) ) { |
| 1676 |
foreach ( $data as $chunk ) { |
| 1677 |
if ( '' !== trim( (string) $chunk ) ) { |
| 1678 |
return true; |
| 1679 |
} |
| 1680 |
} |
| 1681 |
continue; |
| 1682 |
} |
| 1683 |
if ( '' !== trim( (string) $data ) ) { |
| 1684 |
return true; |
| 1685 |
} |
| 1686 |
} |
| 1687 |
return false; |
| 1688 |
} |
| 1689 |
|
| 1690 |
/** |
| 1691 |
* Mark a handle and everything it depends on, transitively. |
| 1692 |
* |
| 1693 |
* @param \WP_Scripts $scripts Registry. |
| 1694 |
* @param string $handle Handle to mark. |
| 1695 |
* @param array<string,true> $seen Accumulator, by reference. |
| 1696 |
*/ |
| 1697 |
private static function mark_with_deps( \WP_Scripts $scripts, string $handle, array &$seen ): void { |
| 1698 |
if ( isset( $seen[ $handle ] ) ) { |
| 1699 |
return; |
| 1700 |
} |
| 1701 |
$seen[ $handle ] = true; |
| 1702 |
if ( ! isset( $scripts->registered[ $handle ]->deps ) ) { |
| 1703 |
return; |
| 1704 |
} |
| 1705 |
foreach ( (array) $scripts->registered[ $handle ]->deps as $dep ) { |
| 1706 |
self::mark_with_deps( $scripts, (string) $dep, $seen ); |
| 1707 |
} |
| 1708 |
} |
| 1709 |
|
| 1710 |
/** |
| 1711 |
* Per-request memo for page_has_js_measured_layout(). Null = not resolved. |
| 1712 |
* |
| 1713 |
* @var bool|null |
| 1714 |
*/ |
| 1715 |
private static $js_measured_layout = null; |
| 1716 |
|
| 1717 |
/** |
| 1718 |
* Scripts that lay out the page by measuring the DOM. |
| 1719 |
* |
| 1720 |
* Each of these reads element sizes and then writes positions. If the CSS |
| 1721 |
* that sizes those elements has not applied when the script runs, it |
| 1722 |
* measures the wrong values and commits a broken layout that no later |
| 1723 |
* stylesheet can correct. |
| 1724 |
* |
| 1725 |
* Matched as a substring of the registered handle, so a plugin shipping |
| 1726 |
* `acme-masonry` or `masonry-init` is covered without naming it here. |
| 1727 |
* |
| 1728 |
* @return string[] |
| 1729 |
*/ |
| 1730 |
private static function js_layout_script_markers(): array { |
| 1731 |
return array( |
| 1732 |
'masonry', |
| 1733 |
'isotope', |
| 1734 |
'packery', |
| 1735 |
'salvattore', |
| 1736 |
'justified-gallery', |
| 1737 |
'slick', |
| 1738 |
'splide', |
| 1739 |
'swiper', |
| 1740 |
'flickity', |
| 1741 |
'owl-carousel', |
| 1742 |
'matchheight', |
| 1743 |
); |
| 1744 |
} |
| 1745 |
|
| 1746 |
/** |
| 1747 |
* True when a script that measures the DOM to build a layout is enqueued |
| 1748 |
* for this request. |
| 1749 |
* |
| 1750 |
* Reads the enqueue registry rather than the finished HTML, because this |
| 1751 |
* runs on `style_loader_tag` — while the head is being printed, before any |
| 1752 |
* body markup exists to scan. Both the queue and each queued handle's |
| 1753 |
* dependencies are checked: core registers `masonry` as a DEPENDENCY of a |
| 1754 |
* plugin's init script, so it is frequently absent from the queue itself. |
| 1755 |
* |
| 1756 |
* Pure aside from the global registry read; the result is memoised per |
| 1757 |
* request and cleared by reset_state(). |
| 1758 |
*/ |
| 1759 |
public static function page_has_js_measured_layout(): bool { |
| 1760 |
if ( null !== self::$js_measured_layout ) { |
| 1761 |
return self::$js_measured_layout; |
| 1762 |
} |
| 1763 |
|
| 1764 |
$found = false; |
| 1765 |
if ( function_exists( 'wp_scripts' ) ) { |
| 1766 |
$scripts = wp_scripts(); |
| 1767 |
if ( $scripts instanceof \WP_Scripts ) { |
| 1768 |
$handles = (array) $scripts->queue; |
| 1769 |
// Pull in dependencies — `masonry` usually arrives that way. |
| 1770 |
foreach ( (array) $scripts->queue as $queued ) { |
| 1771 |
if ( isset( $scripts->registered[ $queued ]->deps ) ) { |
| 1772 |
$handles = array_merge( $handles, (array) $scripts->registered[ $queued ]->deps ); |
| 1773 |
} |
| 1774 |
} |
| 1775 |
$markers = self::js_layout_script_markers(); |
| 1776 |
foreach ( $handles as $handle ) { |
| 1777 |
$handle = strtolower( (string) $handle ); |
| 1778 |
foreach ( $markers as $marker ) { |
| 1779 |
if ( false !== strpos( $handle, $marker ) ) { |
| 1780 |
$found = true; |
| 1781 |
break 2; |
| 1782 |
} |
| 1783 |
} |
| 1784 |
} |
| 1785 |
} |
| 1786 |
} |
| 1787 |
|
| 1788 |
/** |
| 1789 |
* Whether this request renders a JS-measured layout, making async CSS |
| 1790 |
* unsafe for the whole page. |
| 1791 |
* |
| 1792 |
* Return false to defer anyway (a site that ships critical CSS, or one |
| 1793 |
* whose grid is pure CSS), or true to protect a library not detected |
| 1794 |
* by handle. |
| 1795 |
* |
| 1796 |
* @param bool $found Whether a measuring script was detected. |
| 1797 |
*/ |
| 1798 |
self::$js_measured_layout = (bool) apply_filters( 'xspeed_async_css_js_measured_layout', $found ); |
| 1799 |
|
| 1800 |
return self::$js_measured_layout; |
| 1801 |
} |
| 1802 |
} |
| 1803 |
|