| @@ -99,341 +99,8 @@ | ||
| 99 | 99 | return (bool) preg_match( '#\sdata-no-(?:optimize|minify)\b#i', $tag ); |
| 100 | 100 | } |
| 101 | 101 | |
| 102 | 102 | /** |
| 103 | - * Pristine tags as they looked before any of our transforms, keyed by | |
| 104 | - * handle. See snapshot_tag() / revert_late_marked_tag(). | |
| 105 | - * | |
| 106 | - * @var array<string,string> | |
| 107 | - */ | |
| 108 | - private static $pristine_tag = array(); | |
| 109 | - | |
| 110 | - /** | |
| 111 | - * Priority for the late opt-out re-check. Past Borlabs' ScriptBlocker | |
| 112 | - * at 999 — the highest stamper we have seen in the wild — so the | |
| 113 | - * marker has certainly landed by the time we look. (#469) | |
| 114 | - */ | |
| 115 | - private const LATE_OPT_OUT_PRIORITY = 1000; | |
| 116 | - | |
| 117 | - /** | |
| 118 | - * The priority the late opt-out re-check runs at. | |
| 119 | - * | |
| 120 | - * A site whose stamper hooks even later can move ours past it. | |
| 121 | - */ | |
| 122 | - public static function late_opt_out_priority(): int { | |
| 123 | - /** | |
| 124 | - * Filter the priority of xSpeed's late data-no-optimize re-check. | |
| 125 | - * | |
| 126 | - * @param int $priority Default 1000. | |
| 127 | - */ | |
| 128 | - return (int) apply_filters( 'xspeed_late_opt_out_priority', self::LATE_OPT_OUT_PRIORITY ); | |
| 129 | - } | |
| 130 | - | |
| 131 | - /** | |
| 132 | - * Filter: `script_loader_tag`, priority 9 — remember the tag before we | |
| 133 | - * touch it, so a marker stamped later can still be honored. | |
| 134 | - * | |
| 135 | - * Our three opt-out-aware transforms run at 15/20/30. A plugin that | |
| 136 | - * stamps `data-no-optimize` AFTER them is invisible to all three: | |
| 137 | - * Borlabs Cookie stamps at priority 100, so its consent config was | |
| 138 | - * still minified into a hashed cache file AND delayed — the script | |
| 139 | - * that has to run before anything else on the page ran only on first | |
| 140 | - * interaction. Snapshotting here is what lets the late pass put the | |
| 141 | - * original back verbatim, rather than trying to unpick each transform | |
| 142 | - * in reverse. (#469) | |
| 143 | - * | |
| 144 | - * @param string $tag | |
| 145 | - * @param string $handle | |
| 146 | - * @param string $src | |
| 147 | - */ | |
| 148 | - public static function snapshot_tag( $tag, $handle, $src ): string { | |
| 149 | - if ( is_string( $tag ) && '' !== $tag && '' !== (string) $handle ) { | |
| 150 | - self::$pristine_tag[ (string) $handle ] = $tag; | |
| 151 | - } | |
| 152 | - return (string) $tag; | |
| 153 | - } | |
| 154 | - | |
| 155 | - /** | |
| 156 | - * Filter: `script_loader_tag`, priority `LATE_OPT_OUT_PRIORITY` — hand | |
| 157 | - * back the untouched tag when a late filter stamped an opt-out marker | |
| 158 | - * after our transforms had already run. | |
| 159 | - * | |
| 160 | - * The priority has to clear the stamper, not merely the transforms: | |
| 161 | - * Borlabs stamps at 100 and Borlabs' own script blocker at 999, so an | |
| 162 | - * earlier hook reads a tag whose marker has not landed yet. PHP_INT_MAX | |
| 163 | - * would be unfriendly to a site that legitimately wants the last word, | |
| 164 | - * so this sits just past the highest stamper we know of and is | |
| 165 | - * filterable. Reverting to the snapshot is deliberate: undoing | |
| 166 | - * a delay rewrite in place would mean re-deriving `src` from | |
| 167 | - * `data-xs-src` and stripping markers, and #273 is a standing reminder | |
| 168 | - * that regex-editing these attributes in reverse goes wrong quietly. | |
| 169 | - * | |
| 170 | - * The pristine tag still carries whatever priority-10 filters did to | |
| 171 | - * it, so only OUR changes are dropped. (#469) | |
| 172 | - * | |
| 173 | - * @param string $tag | |
| 174 | - * @param string $handle | |
| 175 | - * @param string $src | |
| 176 | - */ | |
| 177 | - public static function revert_late_marked_tag( $tag, $handle, $src ): string { | |
| 178 | - if ( ! is_string( $tag ) || '' === $tag || ! self::tag_opts_out( $tag ) ) { | |
| 179 | - return (string) $tag; | |
| 180 | - } | |
| 181 | - $handle = (string) $handle; | |
| 182 | - $pristine = isset( self::$pristine_tag[ $handle ] ) ? self::$pristine_tag[ $handle ] : ''; | |
| 183 | - if ( '' !== $pristine && $pristine !== $tag ) { | |
| 184 | - // The marker is on the tag we were handed, not on the snapshot, | |
| 185 | - // so carry it — and everything else the late filter set in the | |
| 186 | - // same pass — over. A consumer reading the rendered HTML (or | |
| 187 | - // our own buffer passes) must still see the opt-out it asked | |
| 188 | - // for. | |
| 189 | - $tag = self::copy_late_attributes( $tag, $pristine ); | |
| 190 | - } | |
| 191 | - // The snapshot was taken on `script_loader_tag`, by which point | |
| 192 | - // `script_loader_src` (priority 10) had ALREADY swapped in the | |
| 193 | - // hashed cache URL — so reverting the tag alone still leaves the | |
| 194 | - // minified src behind, which is the half the client actually | |
| 195 | - // reported. Undo that here too, using the URL rewrite_script() | |
| 196 | - // recorded. (#469) | |
| 197 | - return self::restore_marked_script_src( $tag, $handle, self::current_src( $tag, (string) $src ) ); | |
| 198 | - } | |
| 199 | - | |
| 200 | - /** | |
| 201 | - * The src currently on a tag, falling back to the one WordPress passed. | |
| 202 | - * | |
| 203 | - * After a revert the tag carries the snapshot's src, which is not | |
| 204 | - * necessarily the `$src` argument this late in the chain. | |
| 205 | - * | |
| 206 | - * @param string $tag Tag to read. | |
| 207 | - * @param string $fallback Value to use when the tag has no src. | |
| 208 | - */ | |
| 209 | - private static function current_src( string $tag, string $fallback ): string { | |
| 210 | - $open = self::open_tag_offsets( $tag ); | |
| 211 | - if ( null !== $open | |
| 212 | - && preg_match( '#(?<![-\w])src\s*=\s*["\']([^"\']*)["\']#i', $open['attrs'], $m ) ) { | |
| 213 | - return $m[1]; | |
| 214 | - } | |
| 215 | - return $fallback; | |
| 216 | - } | |
| 217 | - | |
| 218 | - /** | |
| 219 | - * Carry the attributes a late filter added onto the snapshot tag. | |
| 220 | - * | |
| 221 | - * Copying only `data-no-*` would silently drop the rest of what the | |
| 222 | - * stamper set in the same pass. Borlabs adds `data-cfasync="false"` | |
| 223 | - * alongside its markers — the attribute that keeps Cloudflare Rocket | |
| 224 | - * Loader off the consent config, i.e. the same class of breakage this | |
| 225 | - * fix exists to prevent, reintroduced by the fix itself. So diff the | |
| 226 | - * attribute names and bring over every one the snapshot lacks. | |
| 227 | - * | |
| 228 | - * Our own transform markers are excluded: they are what we are | |
| 229 | - * reverting, and re-adding `data-xs-delay` would re-delay the script. | |
| 230 | - * | |
| 231 | - * @param string $from Tag as the late filter left it. | |
| 232 | - * @param string $to Snapshot tag to stamp onto. | |
| 233 | - */ | |
| 234 | - private static function copy_late_attributes( string $from, string $to ): string { | |
| 235 | - $late_tags = self::open_tags( $from ); | |
| 236 | - $to_tags = self::open_tags( $to ); | |
| 237 | - if ( empty( $late_tags ) || empty( $to_tags ) || count( $late_tags ) !== count( $to_tags ) ) { | |
| 238 | - // Counts differ when a stamper/blocker injected or replaced a | |
| 239 | - // tag inside the concatenated string, or a transform dropped an | |
| 240 | - // inline block. Positional pairing is meaningless then — but | |
| 241 | - // returning the bare snapshot would silently strip the opt-out, | |
| 242 | - // and the buffer passes would re-optimize an unmarked tag: #469 | |
| 243 | - // again, on the mismatch path. Over-marking merely leaves a tag | |
| 244 | - // unoptimized, so stamp the protective attributes onto every | |
| 245 | - // snapshot tag instead. (#470) | |
| 246 | - return self::stamp_protective_attributes( $from, $to, $to_tags ); | |
| 247 | - } | |
| 248 | - // Pair the tags positionally and stamp each one from its own | |
| 249 | - // counterpart. A stamper runs over the whole concatenated string | |
| 250 | - // and may mark several of the tags in it; collapsing that onto one | |
| 251 | - // tag would strip the opt-out from the others, and the buffer | |
| 252 | - // passes re-test `tag_opts_out()` per tag, so an unmarked sibling | |
| 253 | - // is free to be re-optimized downstream — #469 again, one pass | |
| 254 | - // later. (#469) | |
| 255 | - $out = $to; | |
| 256 | - // Right to left: an earlier splice would shift every later offset. | |
| 257 | - for ( $i = count( $to_tags ) - 1; $i >= 0; $i-- ) { | |
| 258 | - $add = self::late_attribute_delta( $late_tags[ $i ]['attrs'], $to_tags[ $i ]['attrs'] ); | |
| 259 | - if ( '' !== $add ) { | |
| 260 | - $out = substr_replace( $out, $add, $to_tags[ $i ]['attrs_end'], 0 ); | |
| 261 | - } | |
| 262 | - } | |
| 263 | - return $out; | |
| 264 | - } | |
| 265 | - | |
| 266 | - /** | |
| 267 | - * Fallback when the late tag and the snapshot cannot be paired | |
| 268 | - * positionally: copy only the attributes that protect the script from | |
| 269 | - * optimizers — the opt-out markers plus `data-cfasync` — onto every | |
| 270 | - * snapshot tag missing them. Values are taken as the stamper wrote | |
| 271 | - * them on the late tag. (#470) | |
| 272 | - * | |
| 273 | - * @param string $from Tag as the late filter left it. | |
| 274 | - * @param string $to Snapshot tag to stamp onto. | |
| 275 | - * @param array $to_tags open_tags() result for $to. | |
| 276 | - */ | |
| 277 | - private static function stamp_protective_attributes( string $from, string $to, array $to_tags ): string { | |
| 278 | - $protect = array(); | |
| 279 | - foreach ( array( 'data-no-optimize', 'data-no-minify', 'data-cfasync' ) as $name ) { | |
| 280 | - if ( preg_match( | |
| 281 | - '#\s(' . preg_quote( $name, '#' ) . ')(\s*=\s*(?:"[^"]*"|\'[^\']*\'|[^\s>]*))?#i', | |
| 282 | - $from, | |
| 283 | - $m | |
| 284 | - ) ) { | |
| 285 | - $protect[ $name ] = ' ' . $name . ( isset( $m[2] ) ? $m[2] : '' ); | |
| 286 | - } | |
| 287 | - } | |
| 288 | - if ( empty( $protect ) ) { | |
| 289 | - return $to; | |
| 290 | - } | |
| 291 | - $out = $to; | |
| 292 | - // Right to left: an earlier splice would shift every later offset. | |
| 293 | - for ( $i = count( $to_tags ) - 1; $i >= 0; $i-- ) { | |
| 294 | - $add = ''; | |
| 295 | - foreach ( $protect as $name => $attr ) { | |
| 296 | - if ( ! preg_match( '#\s' . preg_quote( $name, '#' ) . '\b#i', $to_tags[ $i ]['attrs'] ) ) { | |
| 297 | - $add .= $attr; | |
| 298 | - } | |
| 299 | - } | |
| 300 | - if ( '' !== $add ) { | |
| 301 | - $out = substr_replace( $out, $add, $to_tags[ $i ]['attrs_end'], 0 ); | |
| 302 | - } | |
| 303 | - } | |
| 304 | - return $out; | |
| 305 | - } | |
| 306 | - | |
| 307 | - /** | |
| 308 | - * The attributes present on the late tag but not the snapshot, minus | |
| 309 | - * the ones our own transforms add. | |
| 310 | - * | |
| 311 | - * @param string $late_attrs Attribute string from the transformed tag. | |
| 312 | - * @param string $to_attrs Attribute string from the snapshot tag. | |
| 313 | - */ | |
| 314 | - private static function late_attribute_delta( string $late_attrs, string $to_attrs ): string { | |
| 315 | - $pattern = '#\s([-\w:]+)(?:\s*=\s*(?:"[^"]*"|\'[^\']*\'|[^\s>]*))?#'; | |
| 316 | - if ( ! preg_match_all( $pattern, $late_attrs, $late, PREG_SET_ORDER ) ) { | |
| 317 | - return ''; | |
| 318 | - } | |
| 319 | - $have = array(); | |
| 320 | - if ( preg_match_all( $pattern, $to_attrs, $existing, PREG_SET_ORDER ) ) { | |
| 321 | - foreach ( $existing as $attr ) { | |
| 322 | - $have[ strtolower( $attr[1] ) ] = true; | |
| 323 | - } | |
| 324 | - } | |
| 325 | - $add = ''; | |
| 326 | - foreach ( $late as $attr ) { | |
| 327 | - $name = strtolower( $attr[1] ); | |
| 328 | - if ( isset( $have[ $name ] ) || in_array( $name, self::OUR_TRANSFORM_ATTRS, true ) ) { | |
| 329 | - continue; | |
| 330 | - } | |
| 331 | - if ( 0 === strpos( $name, 'data-xs-' ) ) { | |
| 332 | - continue; | |
| 333 | - } | |
| 334 | - $add .= $attr[0]; | |
| 335 | - } | |
| 336 | - return $add; | |
| 337 | - } | |
| 338 | - | |
| 339 | - /** | |
| 340 | - * Attributes our own transforms add. Copying any of these from the | |
| 341 | - * transformed tag back onto the snapshot would re-apply the very | |
| 342 | - * transform we are undoing: | |
| 343 | - * | |
| 344 | - * defer/async — defer_script_tag() | |
| 345 | - * type — delay_script_tag() parks an inline block as | |
| 346 | - * text/xspeed-delayed; a type the author set is on the | |
| 347 | - * snapshot already and matches by name before we get here | |
| 348 | - * src — belongs to the snapshot, never to the late tag | |
| 349 | - * | |
| 350 | - * `data-xs-*` is handled by prefix separately. (#469) | |
| 351 | - */ | |
| 352 | - private const OUR_TRANSFORM_ATTRS = array( 'defer', 'async', 'type', 'src' ); | |
| 353 | - | |
| 354 | - /** | |
| 355 | - * Locate the opening `<script>` that carries the src, falling back to | |
| 356 | - * the last one when none does. | |
| 357 | - * | |
| 358 | - * WP_Scripts::do_item() hands `script_loader_tag` the concatenation of | |
| 359 | - * before_inline + external + after_inline, so the FIRST `<script` is | |
| 360 | - * routinely an inline block rather than the asset — the same trap | |
| 361 | - * #234 fixed for defer and #273 for delay. Scanning attribute-wise | |
| 362 | - * also means a quoted value containing `>` (an `onerror` guard, a JSON | |
| 363 | - * payload) cannot truncate the tag the way `[^>]*` did. (#469) | |
| 364 | - * | |
| 365 | - * @param string $tag Full tag string. | |
| 366 | - * @return array{attrs:string,attrs_end:int}|null | |
| 367 | - */ | |
| 368 | - private static function open_tag_offsets( string $tag ): ?array { | |
| 369 | - $tags = self::open_tags( $tag ); | |
| 370 | - $fallback = null; | |
| 371 | - foreach ( $tags as $found ) { | |
| 372 | - if ( preg_match( '#(?<![-\w])src\s*=#i', $found['attrs'] ) ) { | |
| 373 | - return $found; | |
| 374 | - } | |
| 375 | - $fallback = $found; | |
| 376 | - } | |
| 377 | - return $fallback; | |
| 378 | - } | |
| 379 | - | |
| 380 | - /** | |
| 381 | - * Every well-formed opening `<script>` in the string, in order. | |
| 382 | - * | |
| 383 | - * @param string $tag Full tag string. | |
| 384 | - * @return array<int,array{attrs:string,attrs_end:int}> | |
| 385 | - */ | |
| 386 | - private static function open_tags( string $tag ): array { | |
| 387 | - if ( ! preg_match_all( '#<script\b#i', $tag, $m, PREG_OFFSET_CAPTURE ) ) { | |
| 388 | - return array(); | |
| 389 | - } | |
| 390 | - $found = array(); | |
| 391 | - foreach ( $m[0] as $hit ) { | |
| 392 | - $start = (int) $hit[1] + strlen( $hit[0] ); | |
| 393 | - $end = self::scan_open_tag_end( $tag, $start ); | |
| 394 | - if ( null === $end ) { | |
| 395 | - continue; | |
| 396 | - } | |
| 397 | - $found[] = array( | |
| 398 | - 'attrs' => substr( $tag, $start, $end - $start ), | |
| 399 | - 'attrs_end' => $end, | |
| 400 | - ); | |
| 401 | - } | |
| 402 | - return $found; | |
| 403 | - } | |
| 404 | - | |
| 405 | - /** | |
| 406 | - * Offset of the `>` closing an opening tag, skipping any that sit | |
| 407 | - * inside a quoted attribute value. Null when the tag is unterminated. | |
| 408 | - * | |
| 409 | - * @param string $tag Full tag string. | |
| 410 | - * @param int $offset Index just past `<script`. | |
| 411 | - */ | |
| 412 | - private static function scan_open_tag_end( string $tag, int $offset ): ?int { | |
| 413 | - $len = strlen( $tag ); | |
| 414 | - $quote = ''; | |
| 415 | - for ( $i = $offset; $i < $len; $i++ ) { | |
| 416 | - $char = $tag[ $i ]; | |
| 417 | - if ( '' !== $quote ) { | |
| 418 | - if ( $char === $quote ) { | |
| 419 | - $quote = ''; | |
| 420 | - } | |
| 421 | - continue; | |
| 422 | - } | |
| 423 | - if ( '"' === $char || "'" === $char ) { | |
| 424 | - $quote = $char; | |
| 425 | - continue; | |
| 426 | - } | |
| 427 | - if ( '>' === $char ) { | |
| 428 | - // A self-closing `/>` keeps the slash out of the attributes. | |
| 429 | - return ( $i > $offset && '/' === $tag[ $i - 1 ] ) ? $i - 1 : $i; | |
| 430 | - } | |
| 431 | - } | |
| 432 | - return null; | |
| 433 | - } | |
| 434 | - | |
| 435 | - /** | |
| 436 | 103 | * Filter: `script_loader_tag`, priority 15 — undo the minify-cache |
| 437 | 104 | * rewrite for a script whose printed tag opts out. |
| 438 | 105 | * |
| 439 | 106 | * The src rewrite happens on `script_loader_src` (priority 10), long |
| @@ -438,12 +105,12 @@ | ||
| 438 | 105 | * |
| 439 | 106 | * The src rewrite happens on `script_loader_src` (priority 10), long |
| 440 | 107 | * before any plugin's own `script_loader_tag` filter can stamp |
| 441 | 108 | * `data-no-minify` onto the tag — so the marker arrived too late to |
| 442 | - * prevent the rewrite. This runs after the filters that stamp at the | |
| 443 | - * default priority 10 and swaps the hashed cache URL back to the | |
| 444 | - * recorded original. A marker stamped later than our transforms is | |
| 445 | - * caught by revert_late_marked_tag() in the late pass instead. (#469) | |
| 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. | |
| 446 | 113 | * |
| 447 | 114 | * @param string $tag |
| 448 | 115 | * @param string $handle |
| 449 | 116 | * @param string $src |
| @@ -509,12 +176,8 @@ | ||
| 509 | 176 | // bundle's <script> tag intact so the dashboard mounts. |
| 510 | 177 | if ( self::skip_in_non_frontend_context() ) { |
| 511 | 178 | return $tag; |
| 512 | 179 | } |
| 513 | - // The author asked every optimizer to leave this tag alone. | |
| 514 | - if ( self::carries_optimizer_opt_out( $tag ) ) { | |
| 515 | - return $tag; | |
| 516 | - } | |
| 517 | 180 | if ( '' === (string) $src ) { |
| 518 | 181 | return $tag; |
| 519 | 182 | } |
| 520 | 183 | if ( self::is_excluded_script( (string) $handle, (string) $src ) ) { |
| @@ -569,15 +232,11 @@ | ||
| 569 | 232 | } |
| 570 | 233 | if ( self::skip_in_non_frontend_context() ) { |
| 571 | 234 | return $tag; |
| 572 | 235 | } |
| 573 | - // The author asked every optimizer to leave this tag alone. | |
| 574 | - if ( self::carries_optimizer_opt_out( $tag ) ) { | |
| 236 | + if ( self::is_excluded_script( (string) $handle, (string) $src ) ) { | |
| 575 | 237 | return $tag; |
| 576 | 238 | } |
| 577 | - if ( self::is_excluded_script( (string) $handle, (string) $src, true ) ) { | |
| 578 | - return $tag; | |
| 579 | - } | |
| 580 | 239 | // The tag itself asked to be left alone. (#456) |
| 581 | 240 | if ( self::tag_opts_out( $tag ) ) { |
| 582 | 241 | return $tag; |
| 583 | 242 | } |
| @@ -655,104 +314,8 @@ | ||
| 655 | 314 | * consumers, importmaps must resolve before any module runs, and our |
| 656 | 315 | * own delayed-inline marker is already handled by the bootstrap. |
| 657 | 316 | * Rewriting any of these breaks the page or its metadata. |
| 658 | 317 | */ |
| 659 | - /** | |
| 660 | - * Attributes by which a script's own author tells optimizers to stand down. | |
| 661 | - * | |
| 662 | - * The report behind #275 also asked us to leave a script alone when its | |
| 663 | - * author "already marked it to load late". Read literally that means | |
| 664 | - * `defer`/`async`, and that reading is wrong twice over: `defer` is | |
| 665 | - * stamped onto every enqueued script by our OWN Defer JS filter at | |
| 666 | - * priority 20, before Delay JS sees it at 30 — so honouring it would | |
| 667 | - * switch Delay JS off entirely on sites running both — and `async` is the | |
| 668 | - * shape of gtag, GTM and every pixel loader, which is precisely the | |
| 669 | - * payload Delay JS exists to postpone. `defer`/`async` say WHEN TO FETCH, | |
| 670 | - * not "leave me alone". | |
| 671 | - * | |
| 672 | - * These attributes do say it. Each is an established opt-out honoured by | |
| 673 | - * another optimizer — WP Rocket, LiteSpeed, Autoptimize, NitroPack, | |
| 674 | - * Jetpack Boost — so an author who prints one has already declared that | |
| 675 | - * no optimizer should touch this tag. Consent banners are the main | |
| 676 | - * beneficiary, but the rule is general and needs neither a handle nor a | |
| 677 | - * recognised URL, so it works identically on both passes. | |
| 678 | - * | |
| 679 | - * Two deliberate omissions: | |
| 680 | - * | |
| 681 | - * - `data-cfasync="false"` is a Cloudflare Rocket Loader opt-out, and | |
| 682 | - * ad stacks (Mediavine, Ezoic, AdThrive) print it on exactly the | |
| 683 | - * heavy loaders a site turns Delay JS on for. Honouring it would | |
| 684 | - * un-delay the ads. | |
| 685 | - * - `data-no-minify` is about minification, not execution timing. | |
| 686 | - */ | |
| 687 | - private const OPT_OUT_ATTRIBUTES = array( | |
| 688 | - 'nowprocket', // WP Rocket | |
| 689 | - 'data-nowprocket', // WP Rocket | |
| 690 | - 'data-no-optimize', // LiteSpeed | |
| 691 | - 'data-noptimize', // Autoptimize | |
| 692 | - 'data-no-defer', // used by several optimizers | |
| 693 | - 'nitro-exclude', // NitroPack | |
| 694 | - 'data-jetpack-boost', // Jetpack Boost (value "ignore") | |
| 695 | - 'data-wpmeteor-nooptimize', // WP Meteor | |
| 696 | - 'data-xs-nodelay', // ours | |
| 697 | - ); | |
| 698 | - | |
| 699 | - /** | |
| 700 | - * Does this tag carry an explicit "optimizers keep out" attribute? | |
| 701 | - * | |
| 702 | - * Same `(?<![-\w])` lookbehind the rest of this file uses, so | |
| 703 | - * `data-nowprocket` does not also satisfy a bare `nowprocket` lookup, and | |
| 704 | - * a trailing `\b` so `data-no-defer` does not match `data-no-deferral`. | |
| 705 | - * Bare and valued forms both count: an author writes `nowprocket`, | |
| 706 | - * `nowprocket=""` and `data-noptimize="1"` interchangeably. | |
| 707 | - * | |
| 708 | - * @param string $tag Full opening tag. | |
| 709 | - */ | |
| 710 | - private static function carries_optimizer_opt_out( string $tag ): bool { | |
| 711 | - // Read ATTRIBUTE NAMES, not the tag as a string. A substring scan | |
| 712 | - // matched `src="https://cdn/nowprocket/loader.js"`, `?nowprocket=1`, | |
| 713 | - // `class="nowprocket"` and any inline body that merely mentioned one | |
| 714 | - // of these names -- each silently un-delaying a script that should | |
| 715 | - // have been delayed. | |
| 716 | - // | |
| 717 | - // EVERY opening tag is checked, not just the first. On the enqueue | |
| 718 | - // path WP_Scripts::do_item() hands us translations + before-inline + | |
| 719 | - // the real tag + after-inline concatenated, so the first `<script` | |
| 720 | - // is often an inline block and the author's opt-out sits on the | |
| 721 | - // external tag behind it. Reading only the first tag missed it and | |
| 722 | - // delayed the script anyway -- the wrong direction: a consent banner | |
| 723 | - // its author told optimizers to leave alone would not appear. | |
| 724 | - // | |
| 725 | - // The tag regex is quote-aware because `[^>]*>` stops at a `>` inside | |
| 726 | - // a quoted value, and consent managers routinely ship JSON in a | |
| 727 | - // data-* attribute. | |
| 728 | - if ( ! preg_match_all( '#<script\b(?:[^>"\']|"[^"]*"|\'[^\']*\')*>#is', $tag, $tags ) ) { | |
| 729 | - return false; | |
| 730 | - } | |
| 731 | - | |
| 732 | - foreach ( $tags[0] as $open ) { | |
| 733 | - // Walk name/value pairs. The name pattern is deliberately | |
| 734 | - // permissive: a name we cannot recognise (Alpine's `@load`, say) | |
| 735 | - // must still consume ITS OWN VALUE, or the value gets scanned as | |
| 736 | - // if it were more attribute names. | |
| 737 | - if ( ! preg_match_all( | |
| 738 | - '#\s+([^\s=/>]+)(?:\s*=\s*(?:"[^"]*"|\'[^\']*\'|[^\s>]*))?#s', | |
| 739 | - substr( $open, 7, -1 ), | |
| 740 | - $found | |
| 741 | - ) ) { | |
| 742 | - continue; | |
| 743 | - } | |
| 744 | - | |
| 745 | - foreach ( $found[1] as $name ) { | |
| 746 | - if ( in_array( strtolower( $name ), self::OPT_OUT_ATTRIBUTES, true ) ) { | |
| 747 | - return true; | |
| 748 | - } | |
| 749 | - } | |
| 750 | - } | |
| 751 | - | |
| 752 | - return false; | |
| 753 | - } | |
| 754 | - | |
| 755 | 318 | private const NON_EXECUTABLE_TYPES = array( |
| 756 | 319 | 'application/ld+json', |
| 757 | 320 | 'application/json', |
| 758 | 321 | 'importmap', |
| @@ -886,21 +449,10 @@ | ||
| 886 | 449 | if ( false !== stripos( $tag, 'data-xs-delay' ) || false !== stripos( $tag, 'data-xs-src' ) ) { |
| 887 | 450 | return $tag; |
| 888 | 451 | } |
| 889 | 452 | |
| 890 | - // The author asked every optimizer to leave this tag alone. | |
| 891 | - // Checked before src/type: it needs neither, so an | |
| 892 | - // un-enqueued banner printed straight into wp_head is | |
| 893 | - // covered the same as an enqueued one. | |
| 894 | - // | |
| 895 | - // Both checks, because they disagree on purpose and either | |
| 896 | - // saying "leave it" is the safe answer. tag_opts_out() is | |
| 897 | - // dev's (#456) and also drives the late re-check at #469; | |
| 898 | - // carries_optimizer_opt_out() reads attribute NAMES across | |
| 899 | - // every opening tag, so it is not fooled by a marker sitting | |
| 900 | - // inside a quoted value or an inline body, and it knows the | |
| 901 | - // other optimizers' markers. | |
| 902 | - if ( self::tag_opts_out( $tag ) || self::carries_optimizer_opt_out( $tag ) ) { | |
| 453 | + // The tag itself asked to be left alone. (#456) | |
| 454 | + if ( self::tag_opts_out( $tag ) ) { | |
| 903 | 455 | return $tag; |
| 904 | 456 | } |
| 905 | 457 | |
| 906 | 458 | // No src → inline code. The enqueue path owns those; a |
| @@ -926,48 +478,37 @@ | ||
| 926 | 478 | return $tag; |
| 927 | 479 | } |
| 928 | 480 | } |
| 929 | 481 | |
| 930 | - // An ENQUEUED script reaches this sweep too: the enqueue-path | |
| 931 | - // filter leaves an EXCLUDED tag unmarked, and unmarked is all | |
| 932 | - // this pass can see. Judging it on its URL alone re-delays the | |
| 933 | - // very script the exclusion protected — and a handle is not | |
| 934 | - // generally in its own URL, which is the shape Complianz | |
| 935 | - // (`cmplz-cookiebanner`), NotificationX (`notificationx-public`) | |
| 936 | - // and jQuery (`jquery-core`) all have. A site with jquery-core | |
| 937 | - // excluded still shipped jQuery delayed, and every inline | |
| 938 | - // `jQuery(...)` on the page threw "jQuery is not defined". | |
| 939 | - // WordPress prints `id="<handle>-js"` on every enqueued | |
| 940 | - // script, so the handle is right there in the tag. (#275) | |
| 482 | + // Recover the handle from the tag's id before deciding. | |
| 941 | 483 | // |
| 942 | - // The lookbehind matters: `data-id="cmplz-cookiebanner-js"` is | |
| 943 | - // somebody's own attribute, not the handle, and reading it as | |
| 944 | - // one would shield a script nobody excluded. | |
| 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. | |
| 945 | 494 | // |
| 946 | - // Merge note for #374: if a URL->handle map built from | |
| 947 | - // wp_scripts() lands first, resolve through that and keep | |
| 948 | - // this as the FALLBACK rather than replacing it. The map is | |
| 949 | - // keyed on the REGISTERED src, and Minifier::rewrite_script() | |
| 950 | - // rewrites a local script's URL to a hashed /cache/xspeed/min/ | |
| 951 | - // path at output time -- which is why remember_original_src() | |
| 952 | - // exists. So with minify_js on, the map misses every minified | |
| 953 | - // script and an excluded one would be re-delayed here. The | |
| 954 | - // `id` survives that rewrite. | |
| 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. | |
| 955 | 501 | $tag_handle = ''; |
| 956 | - if ( preg_match( '#(?<![-\w])id\s*=\s*(["\'])(.*?)\1#is', $tag, $id_m ) ) { | |
| 502 | + if ( preg_match( '#\sid\s*=\s*(["\'])(.*?)\1#i', $tag, $id_m ) ) { | |
| 957 | 503 | // WP appends `-js`; anything else is somebody's own id and |
| 958 | 504 | // is still worth matching literally. |
| 959 | - $tag_handle = (string) preg_replace( '/-js$/', '', trim( $id_m[2] ) ); | |
| 505 | + $tag_handle = (string) preg_replace( '/-js$/', '', $id_m[2] ); | |
| 960 | 506 | } |
| 961 | 507 | |
| 962 | - if ( self::is_excluded_script( $tag_handle, $src, true ) ) { | |
| 508 | + if ( self::is_excluded_script( $tag_handle, $src ) ) { | |
| 963 | 509 | return $tag; |
| 964 | 510 | } |
| 965 | - // The handle is passed to the target test as well, so naming a | |
| 966 | - // handle in the delay list behaves the same here as it does on | |
| 967 | - // the enqueue path. The two layers disagreeing on what a target | |
| 968 | - // means is what made this look like a matching quirk rather | |
| 969 | - // than a whole layer ignoring the list. | |
| 970 | 511 | if ( ! self::is_delay_target( $tag_handle, $src ) ) { |
| 971 | 512 | return $tag; |
| 972 | 513 | } |
| 973 | 514 | // Mirror of the enqueue-path guard: a handle that inline code |
| @@ -1072,17 +613,8 @@ | ||
| 1072 | 613 | if ( false !== stripos( $body, 'document.write' ) ) { |
| 1073 | 614 | return $whole; |
| 1074 | 615 | } |
| 1075 | 616 | |
| 1076 | - // Our own inline scripts, by the id they are printed with. | |
| 1077 | - // The src passes get this for free from the handle prefix, | |
| 1078 | - // but here the handle is '' — and the facade observer's body | |
| 1079 | - // names youtube/vimeo, so a user target like "youtube" would | |
| 1080 | - // park the very script that makes those embeds cheap. | |
| 1081 | - if ( preg_match( '#(?<![-\w])id\s*=\s*(["\'])xspeed-#i', $attrs ) ) { | |
| 1082 | - return $whole; | |
| 1083 | - } | |
| 1084 | - | |
| 1085 | 617 | // The body stands in for the URL in the lists the src passes |
| 1086 | 618 | // consult — but NOT via is_delay_target(), whose empty-list |
| 1087 | 619 | // default is "delay everything". That default is right for a |
| 1088 | 620 | // tag with a URL and catastrophic here: it would park every |
| @@ -1805,146 +1337,9 @@ | ||
| 1805 | 1337 | 'wp-url', |
| 1806 | 1338 | 'wp-api-fetch', |
| 1807 | 1339 | ); |
| 1808 | 1340 | |
| 1809 | - /** | |
| 1810 | - * Consent managers are never deferred, and never delayed by a broad | |
| 1811 | - * setting — only an EXPLICIT delay_js_targets entry naming one lifts | |
| 1812 | - * the floor (see is_excluded_script()); the `xspeed_js_exclusion_floor` | |
| 1813 | - * filter remains the code-level override. | |
| 1814 | - * | |
| 1815 | - * A consent banner is drawn by JavaScript, and it is the one thing on | |
| 1816 | - * the page that has to appear before anything else happens. Delay it | |
| 1817 | - * and a visitor who lands, reads and leaves without touching the page | |
| 1818 | - * is never asked — on an opt-in configuration the site then ran | |
| 1819 | - * without ever offering the choice. | |
| 1820 | - * | |
| 1821 | - * The editable list cannot carry this. A stored value replaces the | |
| 1822 | - * schema default outright (Settings_Manager::get()), so widening that | |
| 1823 | - * default would reach fresh installs only, and clearing the textarea | |
| 1824 | - * would drop the protection again. Same floor pattern as | |
| 1825 | - * Server_Rules::COOKIE_FLOOR. Trim or extend it through | |
| 1826 | - * `xspeed_js_exclusion_floor`. | |
| 1827 | - * | |
| 1828 | - * A URL token cannot survive a rewrite of that URL: Minify JS rewrites a | |
| 1829 | - * local script to a hashed /cache/xspeed/min/ path, and Combine JS folds | |
| 1830 | - * it into a bundle. On the enqueue path original_src() gives the pre-minify | |
| 1831 | - * URL back, but the buffer sweep has only the tag -- so with Minify JS on | |
| 1832 | - * and the `id` stripped, a banner shipped un-minified is not recognised. | |
| 1833 | - * Documented in docs/user/minification.md rather than papered over. | |
| 1834 | - * | |
| 1835 | - * Each entry goes through target_matches(): an exact handle OR a | |
| 1836 | - * case-insensitive URL substring. Both passes can match either — the | |
| 1837 | - * enqueue path is handed the handle, and the buffer sweep reads it back | |
| 1838 | - * out of the tag's `id`. A URL token additionally covers a banner that | |
| 1839 | - * was never enqueued at all, which is how Cookiebot prints itself. (#275) | |
| 1840 | - */ | |
| 1841 | - private const CONSENT_MANAGER_FLOOR = array( | |
| 1842 | - // Prefer a plugin-directory or vendor-host URL token over a handle. | |
| 1843 | - // A handle is only readable on the enqueue path and, on the buffer | |
| 1844 | - // sweep, only if the tag still carries the `id` WordPress prints — | |
| 1845 | - // which another plugin can strip. A URL token matches on both passes | |
| 1846 | - // and covers a banner that was never enqueued at all. (#275 QA) | |
| 1847 | - | |
| 1848 | - // CookieYes / GDPR Cookie Consent. Handle and plugin directory are | |
| 1849 | - // the same string, so this covers both paths. | |
| 1850 | - 'cookie-law-info', | |
| 1851 | - // Complianz: the plugin directory, covering -gdpr and -gdpr-premium. | |
| 1852 | - // Was the `cmplz-cookiebanner` handle, which needed the `id` tag. | |
| 1853 | - 'complianz', | |
| 1854 | - // NotificationX runs its GDPR cookie notice off the same handle as | |
| 1855 | - // every other notification, so excluding it excludes them all. That | |
| 1856 | - // is what the plugin's own team asked for. Directory token covers | |
| 1857 | - // the Pro build too; was the `notificationx-public` handle. | |
| 1858 | - 'notificationx', | |
| 1859 | - // Cookiebot prints its loader straight into wp_head, so only the | |
| 1860 | - // buffer sweep ever sees it. This is the token Cookiebot's own WP | |
| 1861 | - // Rocket and LiteSpeed integrations exclude. | |
| 1862 | - 'consent.cookiebot.com', | |
| 1863 | - // Cookie Notice — named in the original report and one of the most | |
| 1864 | - // installed consent plugins. Its banner is enqueued from | |
| 1865 | - // /plugins/cookie-notice/js/front.min.js. | |
| 1866 | - 'cookie-notice', | |
| 1867 | - // Cookie Notice in Cookie Compliance mode prints a different loader, | |
| 1868 | - // whose host is overridable via CN_APP_WIDGET_URL — so key on the | |
| 1869 | - // filename, not the CDN host. | |
| 1870 | - 'hu-banner', | |
| 1871 | - // Moove GDPR Cookie Compliance. Directory token: its handle | |
| 1872 | - // (`moove_gdpr_frontend`) does not appear in its own URL. | |
| 1873 | - 'gdpr-cookie-compliance', | |
| 1874 | - // Termly's resource blocker, which also covers the legacy embed. | |
| 1875 | - 'app.termly.io', | |
| 1876 | - // Usercentrics, reached three ways: Cookiebot's UC mode | |
| 1877 | - // (web.cmp.usercentrics.eu), Termageddon (app.usercentrics.eu) and | |
| 1878 | - // the privacy proxy. | |
| 1879 | - 'usercentrics.eu', | |
| 1880 | - // Iubenda: both the consent solution and the consent database SDK. | |
| 1881 | - 'cdn.iubenda.com', | |
| 1882 | - // OneTrust. Pasted snippet rather than a wordpress.org plugin, so | |
| 1883 | - // this is the SDK host rather than a verified plugin path. | |
| 1884 | - 'cdn.cookielaw.org', | |
| 1885 | - // Borlabs is commercial and renames its files per release; the | |
| 1886 | - // vendor's own guidance is that this string stays in every path. | |
| 1887 | - 'borlabs-cookie', | |
| 1888 | - // Real Cookie Banner, free and pro. Its anti-adblock mode serves the | |
| 1889 | - // banner from an anonymised path that no URL token can match — use | |
| 1890 | - // `xspeed_js_exclusion_floor` to add the handle on such a site. | |
| 1891 | - 'real-cookie-banner', | |
| 1892 | - // SureCookie. | |
| 1893 | - 'surecookie', | |
| 1894 | - ); | |
| 1895 | - | |
| 1896 | - /** | |
| 1897 | - * Per-request memo for exclusion_floor(). Null = not resolved. | |
| 1898 | - * | |
| 1899 | - * @var string[]|null | |
| 1900 | - */ | |
| 1901 | - private static $exclusion_floor = null; | |
| 1902 | - | |
| 1903 | - /** | |
| 1904 | - * The built-in exclusion floor, after the site has had its say. | |
| 1905 | - * | |
| 1906 | - * @return string[] | |
| 1907 | - */ | |
| 1908 | - private static function exclusion_floor(): array { | |
| 1909 | - if ( null === self::$exclusion_floor ) { | |
| 1910 | - /** | |
| 1911 | - * Scripts that are never deferred or delayed, whatever the | |
| 1912 | - * user's exclusion list holds. Each entry is an exact script | |
| 1913 | - * handle or a case-insensitive URL substring. | |
| 1914 | - * | |
| 1915 | - * Return the array minus a token to let Delay JS postpone that | |
| 1916 | - * consent manager on purpose; add one to protect another script. | |
| 1917 | - * | |
| 1918 | - * @param string[] $floor Built-in floor. | |
| 1919 | - */ | |
| 1920 | - $floor = apply_filters( 'xspeed_js_exclusion_floor', self::CONSENT_MANAGER_FLOOR ); | |
| 1921 | - self::$exclusion_floor = array_values( | |
| 1922 | - array_filter( array_map( 'strval', (array) $floor ), static fn( $t ) => '' !== $t ) | |
| 1923 | - ); | |
| 1924 | - } | |
| 1925 | - return self::$exclusion_floor; | |
| 1926 | - } | |
| 1927 | - | |
| 1928 | - /** | |
| 1929 | - * @param string $handle Script handle ('' on the buffer sweep | |
| 1930 | - * when no id survived). | |
| 1931 | - * @param string $src Script URL. | |
| 1932 | - * @param bool $named_lifts_floor Delay paths only: a handle/URL the user | |
| 1933 | - * EXPLICITLY typed into delay_js_targets | |
| 1934 | - * passes the consent floor. Typing a | |
| 1935 | - * consent manager's name into an | |
| 1936 | - * allow-list is the site owner taking the | |
| 1937 | - * consent-timing decision back — GDPR is | |
| 1938 | - * theirs to weigh, not ours; the floor | |
| 1939 | - * only exists so Delay JS can't hide a | |
| 1940 | - * banner NOBODY pointed at. Their own | |
| 1941 | - * exclusion list, ALWAYS_EXCLUDED_HANDLES | |
| 1942 | - * and our beacons still win: on a | |
| 1943 | - * conflict between the user's two lists, | |
| 1944 | - * protection beats postponement. | |
| 1945 | - */ | |
| 1946 | - private static function is_excluded_script( string $handle, string $src, bool $named_lifts_floor = false ): bool { | |
| 1341 | + private static function is_excluded_script( string $handle, string $src ): bool { | |
| 1947 | 1342 | if ( in_array( $handle, self::ALWAYS_EXCLUDED_HANDLES, true ) ) { |
| 1948 | 1343 | return true; |
| 1949 | 1344 | } |
| 1950 | 1345 | // Never defer or delay our own scripts. The fold and RUM beacons |
| @@ -1957,21 +1352,8 @@ | ||
| 1957 | 1352 | // beacon added later cannot re-open the hole. |
| 1958 | 1353 | if ( 0 === strpos( $handle, 'xspeed-' ) ) { |
| 1959 | 1354 | return true; |
| 1960 | 1355 | } |
| 1961 | - // Ahead of the user list, and ahead of the empty-list early return | |
| 1962 | - // below: an install that saved the Minify panel before this shipped | |
| 1963 | - // has a stored list that knows nothing about consent managers, and | |
| 1964 | - // one that cleared the textarea has no list at all. Neither may | |
| 1965 | - // hide the banner. (#275) | |
| 1966 | - foreach ( self::exclusion_floor() as $needle ) { | |
| 1967 | - if ( self::target_matches( $needle, $handle, $src ) ) { | |
| 1968 | - if ( $named_lifts_floor && self::is_user_named_target( $handle, $src ) ) { | |
| 1969 | - break; | |
| 1970 | - } | |
| 1971 | - return true; | |
| 1972 | - } | |
| 1973 | - } | |
| 1974 | 1356 | $opts = self::opts(); |
| 1975 | 1357 | $excluded = is_array( $opts['defer_js_excluded'] ?? null ) ? $opts['defer_js_excluded'] : array(); |
| 1976 | 1358 | if ( empty( $excluded ) ) { |
| 1977 | 1359 | return false; |
| @@ -2176,11 +1558,9 @@ | ||
| 2176 | 1558 | self::$opts = null; |
| 2177 | 1559 | self::$uploads_base = null; |
| 2178 | 1560 | self::$delay_bootstrap_printed = false; |
| 2179 | 1561 | self::$js_measured_layout = null; |
| 2180 | - self::$exclusion_floor = null; | |
| 2181 | 1562 | self::$inline_bound_handles = null; |
| 2182 | - self::$pristine_tag = array(); | |
| 2183 | 1563 | } |
| 2184 | 1564 | |
| 2185 | 1565 | /** |
| 2186 | 1566 | * Per-request memo for inline_bound_handles(). Null = not resolved. |