PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.3
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.3
1.3.5 1.3.4 1.3.3 1.3.2 1.3.1 1.3.0 1.2.4 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.1.5 1.1.6 All 31 releases
← All changes | includes/class-minify-filters.php +28 -648 trunk1.3.3 View file →
@@ -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.