PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.4
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.4
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 1.1.7 All 30 releases
← All changes | includes/class-minify-filters.php +338 -4 1.3.31.3.4 View file →
@@ -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.