| @@ -99,8 +99,341 @@ | ||
| 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 | + /** | |
| 103 | 436 | * Filter: `script_loader_tag`, priority 15 — undo the minify-cache |
| 104 | 437 | * rewrite for a script whose printed tag opts out. |
| 105 | 438 | * |
| 106 | 439 | * The src rewrite happens on `script_loader_src` (priority 10), long |
| @@ -105,12 +438,12 @@ | ||
| 105 | 438 | * |
| 106 | 439 | * The src rewrite happens on `script_loader_src` (priority 10), long |
| 107 | 440 | * before any plugin's own `script_loader_tag` filter can stamp |
| 108 | 441 | * `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. | |
| 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) | |
| 113 | 446 | * |
| 114 | 447 | * @param string $tag |
| 115 | 448 | * @param string $handle |
| 116 | 449 | * @param string $src |
| @@ -1559,8 +1892,9 @@ | ||
| 1559 | 1892 | self::$uploads_base = null; |
| 1560 | 1893 | self::$delay_bootstrap_printed = false; |
| 1561 | 1894 | self::$js_measured_layout = null; |
| 1562 | 1895 | self::$inline_bound_handles = null; |
| 1896 | + self::$pristine_tag = array(); | |
| 1563 | 1897 | } |
| 1564 | 1898 | |
| 1565 | 1899 | /** |
| 1566 | 1900 | * Per-request memo for inline_bound_handles(). Null = not resolved. |