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.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 1.1.8 All 29 releases
xspeed / includes / class-minify-filters.php

class-minify-filters.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN 1.3.3, at includes/class-minify-filters.php

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