| @@ -622,9 +622,17 @@ | ||
| 622 | 622 | // A non-executable type means this tag is data, or is being held by |
| 623 | 623 | // somebody else on purpose. The buffer pass has always checked this; |
| 624 | 624 | // the enqueue path did not, so a consent-blocked or JSON-carrying |
| 625 | 625 | // handle could still be rewritten here. (#274) |
| 626 | - if ( in_array( self::extract_type( $tag ), self::NON_EXECUTABLE_TYPES, true ) ) { | |
| 626 | + // | |
| 627 | + // Read the type from the tag that carries the src. $tag is before + | |
| 628 | + // external + after, and Smart Delay parks the before/after snippets | |
| 629 | + // as text/xspeed-delayed before this filter runs. Reading the first | |
| 630 | + // `type=` in the whole string found the parked snippet, so every | |
| 631 | + // handle with its own snippets kept a live src behind parked | |
| 632 | + // snippets. | |
| 633 | + $type_open = '' !== (string) $src ? self::open_tag_offsets( $tag ) : null; | |
| 634 | + if ( in_array( self::extract_type( null !== $type_open ? $type_open['attrs'] : $tag ), self::NON_EXECUTABLE_TYPES, true ) ) { | |
| 627 | 635 | return $tag; |
| 628 | 636 | } |
| 629 | 637 | // src= variant: swap src → data-xs-src and add data-xs-delay marker. |
| 630 | 638 | if ( '' !== (string) $src ) { |
| @@ -1201,8 +1209,239 @@ | ||
| 1201 | 1209 | return $out; |
| 1202 | 1210 | } |
| 1203 | 1211 | |
| 1204 | 1212 | /** |
| 1213 | + * What a held async sheet runs once it has settled: mark itself and let | |
| 1214 | + * the flip script check whether every held sheet has. | |
| 1215 | + */ | |
| 1216 | + private const HOLD_SETTLED_JS = "this.setAttribute('data-xs-ready','1');window.xsCssFlip&&xsCssFlip()"; | |
| 1217 | + | |
| 1218 | + /** | |
| 1219 | + * A held sheet that fails (404, blocked, network error) never fires | |
| 1220 | + * `load`, so with only an onload the flip waited for it and every other | |
| 1221 | + * sheet stayed off until the 5-second fallback: an unstyled page. An | |
| 1222 | + * error settles it as well. An onerror the tag already carries keeps | |
| 1223 | + * running, after ours, so its throwing cannot hold the flip back. | |
| 1224 | + * | |
| 1225 | + * @param string $tag A held `<link>` tag, already carrying our onload. | |
| 1226 | + */ | |
| 1227 | + private static function settle_held_sheet_on_error( string $tag ): string { | |
| 1228 | + $merged = (string) preg_replace_callback( | |
| 1229 | + '#\sonerror\s*=\s*(?:"([^"]*)"|\'([^\']*)\'|([^\s"\'>]+))#i', | |
| 1230 | + static function ( array $e ): string { | |
| 1231 | + $own = (string) ( $e[1] ?? '' ) . (string) ( $e[2] ?? '' ) . (string) ( $e[3] ?? '' ); | |
| 1232 | + $own = html_entity_decode( $own, ENT_QUOTES | ENT_HTML5, 'UTF-8' ); | |
| 1233 | + return ' onerror="' . self::HOLD_SETTLED_JS . ';' . esc_attr( $own ) . '"'; | |
| 1234 | + }, | |
| 1235 | + $tag, | |
| 1236 | + 1, | |
| 1237 | + $count | |
| 1238 | + ); | |
| 1239 | + if ( 1 === $count ) { | |
| 1240 | + return $merged; | |
| 1241 | + } | |
| 1242 | + return (string) preg_replace( | |
| 1243 | + '#\sonload="' . preg_quote( self::HOLD_SETTLED_JS, '#' ) . '"#', | |
| 1244 | + '$0 onerror="' . self::HOLD_SETTLED_JS . '"', | |
| 1245 | + $tag, | |
| 1246 | + 1 | |
| 1247 | + ); | |
| 1248 | + } | |
| 1249 | + | |
| 1250 | + /** | |
| 1251 | + * Hosts whose stylesheets are the site's own for the transition hold: | |
| 1252 | + * the site's host and, when the CDN rewrite is on, its CDN host. A sheet | |
| 1253 | + * from any other host applies on its own load. | |
| 1254 | + * | |
| 1255 | + * @return string[] Lowercase hosts. | |
| 1256 | + */ | |
| 1257 | + private static function first_party_hosts(): array { | |
| 1258 | + $hosts = array( strtolower( (string) wp_parse_url( home_url( '/' ), PHP_URL_HOST ) ) ); | |
| 1259 | + $cdn = get_option( 'xspeed_module_cdn', array() ); | |
| 1260 | + if ( is_array( $cdn ) && ! empty( $cdn['enabled'] ) && ! empty( $cdn['cdn_url'] ) ) { | |
| 1261 | + $hosts[] = (string) preg_replace( '#[/:].*$#', '', Cdn_Rewriter::normalize_host( (string) $cdn['cdn_url'] ) ); | |
| 1262 | + } | |
| 1263 | + /** | |
| 1264 | + * Filter: xspeed_async_css_first_party_hosts. Hosts whose async | |
| 1265 | + * stylesheets wait for the transition hold's single flip. Add a | |
| 1266 | + * static-asset host that serves the site's own CSS. | |
| 1267 | + * | |
| 1268 | + * @param string[] $hosts Lowercase hosts. | |
| 1269 | + */ | |
| 1270 | + $hosts = (array) apply_filters( 'xspeed_async_css_first_party_hosts', $hosts ); | |
| 1271 | + return array_values( array_filter( array_map( static fn( $h ) => strtolower( trim( (string) $h ) ), $hosts ) ) ); | |
| 1272 | + } | |
| 1273 | + | |
| 1274 | + /** | |
| 1275 | + * Hold CSS transitions while async stylesheets load. | |
| 1276 | + * | |
| 1277 | + * Critical CSS styles only the first screen. When the deferred | |
| 1278 | + * stylesheets arrive, every element that has a `transition` and a value | |
| 1279 | + * that changes animates at once: below the fold from the browser's | |
| 1280 | + * defaults, above it wherever the critical CSS differs. On an Elementor | |
| 1281 | + * page that was about 100 elements and seconds of main-thread work on a | |
| 1282 | + * throttled phone. Transitions stay off until the last async sheet has | |
| 1283 | + * applied, then come back two frames later. | |
| 1284 | + * | |
| 1285 | + * @param string $html Complete page HTML. | |
| 1286 | + */ | |
| 1287 | + public static function hold_transitions_for_async_css( $html ): string { | |
| 1288 | + if ( ! is_string( $html ) || '' === $html ) { | |
| 1289 | + return (string) $html; | |
| 1290 | + } | |
| 1291 | + if ( self::skip_in_non_frontend_context() ) { | |
| 1292 | + return $html; | |
| 1293 | + } | |
| 1294 | + /** | |
| 1295 | + * Filter: xspeed_async_css_hold_transitions. Return false to let | |
| 1296 | + * transitions run while async stylesheets load. | |
| 1297 | + * | |
| 1298 | + * @param bool $hold Whether to hold them. Default true. | |
| 1299 | + */ | |
| 1300 | + if ( ! apply_filters( 'xspeed_async_css_hold_transitions', true ) ) { | |
| 1301 | + return $html; | |
| 1302 | + } | |
| 1303 | + if ( false !== strpos( $html, 'id="xspeed-transition-hold-js"' ) ) { | |
| 1304 | + return $html; | |
| 1305 | + } | |
| 1306 | + if ( ! preg_match( '#<link\b[^>]*\bdata-(?:xs|xspeed)-async\b#i', $html ) ) { | |
| 1307 | + return $html; | |
| 1308 | + } | |
| 1309 | + // Async sheets used to switch themselves on as each finished, in | |
| 1310 | + // download order. Between the first and the last the page ran on a | |
| 1311 | + // partial cascade that matched neither the critical CSS nor the | |
| 1312 | + // final layout, and the hero jumped (CLS 0.3 on a phone). Each sheet | |
| 1313 | + // now only marks itself loaded; the script below switches them all | |
| 1314 | + // on together, in document order, once every one has loaded. | |
| 1315 | + // A cross-origin sheet (Google Fonts, a CDN font kit) keeps switching | |
| 1316 | + // itself on: waiting for it let a slow third party hold back the | |
| 1317 | + // site's own CSS. The flip script skips it too. | |
| 1318 | + $own_hosts = self::first_party_hosts(); | |
| 1319 | + $rewritten = (string) preg_replace_callback( | |
| 1320 | + '#<link\b[^>]*\bdata-(?:xs|xspeed)-async\b[^>]*>#i', | |
| 1321 | + static function ( array $m ) use ( $own_hosts ): string { | |
| 1322 | + if ( preg_match( '#\shref\s*=\s*["\']?(?:https?:)?//([^/"\'\s>:]+)#i', $m[0], $h ) && ! in_array( strtolower( $h[1] ), $own_hosts, true ) ) { | |
| 1323 | + return $m[0]; | |
| 1324 | + } | |
| 1325 | + $held = (string) preg_replace( | |
| 1326 | + '#\sonload\s*=\s*"this\.media=\'[^\'"]*\'"#i', | |
| 1327 | + ' data-xs-hold="1" onload="' . self::HOLD_SETTLED_JS . '"', | |
| 1328 | + $m[0], | |
| 1329 | + 1, | |
| 1330 | + $count | |
| 1331 | + ); | |
| 1332 | + if ( 1 !== $count ) { | |
| 1333 | + return $m[0]; | |
| 1334 | + } | |
| 1335 | + return self::settle_held_sheet_on_error( $held ); | |
| 1336 | + }, | |
| 1337 | + $html | |
| 1338 | + ); | |
| 1339 | + | |
| 1340 | + // The hold style is added by a script, so a visitor without | |
| 1341 | + // JavaScript, whom the flip script never reaches, keeps transitions: | |
| 1342 | + // a style in the markup stayed for good there. The no-JS page is the | |
| 1343 | + // page without this pass, apart from the inert script tags. | |
| 1344 | + $style = '<script id="xspeed-transition-hold-css">(function(){var s=document.createElement("style");s.id="xspeed-transition-hold";' | |
| 1345 | + . 's.textContent="*,*::before,*::after{transition:none!important}";(document.head||document.documentElement).appendChild(s)})();</script>'; | |
| 1346 | + $script = '<script id="xspeed-transition-hold-js">(function(){var s=document.getElementById("xspeed-transition-hold"),d=0,dcl=0;' | |
| 1347 | + . 'function links(){return document.querySelectorAll("link[data-xs-hold]")}' | |
| 1348 | + . 'function flip(force){if(d)return;var l=links(),i;if(!force){if(!dcl)return;for(i=0;i<l.length;i++)if(l[i].media==="print"&&!l[i].hasAttribute("data-xs-ready"))return}' | |
| 1349 | + . 'd=1;for(i=0;i<l.length;i++)if(l[i].media==="print")l[i].media=l[i].getAttribute("data-xs-async")||l[i].getAttribute("data-xspeed-async")||"all";' | |
| 1350 | + . 'requestAnimationFrame(function(){requestAnimationFrame(function(){s&&s.parentNode&&s.parentNode.removeChild(s)})})}' | |
| 1351 | + . 'window.xsCssFlip=function(){flip(0)};' | |
| 1352 | + . 'document.addEventListener("DOMContentLoaded",function(){dcl=1;flip(0)});setTimeout(function(){flip(1)},5000)})();</script>'; | |
| 1353 | + | |
| 1354 | + $out = preg_replace( '#<head\b[^>]*>#i', '$0' . $style, $rewritten, 1, $count ); | |
| 1355 | + if ( null === $out || 1 !== $count ) { | |
| 1356 | + return $html; | |
| 1357 | + } | |
| 1358 | + $with_script = preg_replace( '#</body>#i', $script . '$0', $out, 1, $count ); | |
| 1359 | + return ( null === $with_script || 1 !== $count ) ? $html : $with_script; | |
| 1360 | + } | |
| 1361 | + | |
| 1362 | + /** | |
| 1363 | + * Action: `wp_head` (priority 1). Show Elementor's entrance-animation | |
| 1364 | + * elements when Delay JS holds back the script that would show them. | |
| 1365 | + * | |
| 1366 | + * Elementor prints a widget with an entrance animation as | |
| 1367 | + * `elementor-invisible` (visibility:hidden), and its frontend script | |
| 1368 | + * removes the class as the widget scrolls into view. Delayed, that script | |
| 1369 | + * runs on the first interaction, so a hero with an entrance animation | |
| 1370 | + * stayed blank until then. Lighthouse never interacts: it measured an | |
| 1371 | + * empty first screen, and its LCP and CLS described a page nobody sees. | |
| 1372 | + * | |
| 1373 | + * Printed from the head rather than the cached-page buffer, which never | |
| 1374 | + * runs with the page cache off or on a bypassed page, where the script | |
| 1375 | + * is delayed all the same. The decision is the one delay_script_tag() | |
| 1376 | + * makes later in the request: Smart Delay delays the handle unless it is | |
| 1377 | + * excluded; plain delay keeps it live (its config snippet reads it) | |
| 1378 | + * unless the user named it. A rule left on a page with no such element | |
| 1379 | + * matches nothing. | |
| 1380 | + */ | |
| 1381 | + public static function print_entrance_animation_reveal(): void { | |
| 1382 | + if ( self::skip_in_non_frontend_context() || ! self::delays_elementor_frontend() ) { | |
| 1383 | + return; | |
| 1384 | + } | |
| 1385 | + /** | |
| 1386 | + * Filter: xspeed_delay_js_reveal_entrance_animations. Return false to | |
| 1387 | + * keep Elementor's entrance-animation elements hidden until the | |
| 1388 | + * delayed frontend script runs. | |
| 1389 | + * | |
| 1390 | + * @param bool $reveal Whether to show them at load. Default true. | |
| 1391 | + */ | |
| 1392 | + if ( ! apply_filters( 'xspeed_delay_js_reveal_entrance_animations', true ) ) { | |
| 1393 | + return; | |
| 1394 | + } | |
| 1395 | + echo self::entrance_reveal_markup() . "\n"; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- fixed markup, no input. | |
| 1396 | + } | |
| 1397 | + | |
| 1398 | + /** | |
| 1399 | + * The reveal style, and the script that settles what the visitor has | |
| 1400 | + * already seen. | |
| 1401 | + * | |
| 1402 | + * Shown by the style, an element still carries its entrance animation, | |
| 1403 | + * and when the delayed frontend script runs Elementor plays it: the | |
| 1404 | + * element drops to opacity 0 and fades back in. For the first screen that | |
| 1405 | + * was the hero heading vanishing on the first mouse move. So when the | |
| 1406 | + * replay starts (`xspeed:replaying`, before any delayed script is back), | |
| 1407 | + * every animated element in the viewport has its animation set to | |
| 1408 | + * `none` in `data-settings`, which Elementor reads as "show it, do not | |
| 1409 | + * animate". Elements further down keep theirs and animate as they | |
| 1410 | + * scroll in, as on a page without Delay JS. | |
| 1411 | + */ | |
| 1412 | + public static function entrance_reveal_markup(): string { | |
| 1413 | + return '<style id="xspeed-reveal-entrance">.elementor-invisible{visibility:visible!important}</style>' | |
| 1414 | + . '<script id="xspeed-reveal-entrance-js" data-xs-nodelay>document.addEventListener("xspeed:replaying",function(){' | |
| 1415 | + . 'var h=window.innerHeight,w=window.innerWidth,j=window.jQuery;' | |
| 1416 | + . 'Array.prototype.forEach.call(document.querySelectorAll(".elementor-invisible[data-settings]"),function(e){' | |
| 1417 | + . 'var r=e.getBoundingClientRect(),s,k;' | |
| 1418 | + . 'if(!(r.width||r.height)||r.bottom<=0||r.top>=h||r.right<=0||r.left>=w)return;' | |
| 1419 | + . 'try{s=JSON.parse(e.getAttribute("data-settings"))}catch(x){return}' | |
| 1420 | + . 'if(!s||typeof s!=="object")return;' | |
| 1421 | + . 'for(k in s)if(/^_?animation(_|$)/.test(k)&&!/(delay|duration)/.test(k))s[k]="none";' | |
| 1422 | + . 'e.setAttribute("data-settings",JSON.stringify(s));e.classList.remove("elementor-invisible");' | |
| 1423 | + . 'j&&j.removeData&&j.removeData(e,"settings")})},{once:true})</script>'; | |
| 1424 | + } | |
| 1425 | + | |
| 1426 | + /** Will Delay JS hold back Elementor's frontend script on this request? */ | |
| 1427 | + private static function delays_elementor_frontend(): bool { | |
| 1428 | + $opts = self::opts(); | |
| 1429 | + if ( empty( $opts['delay_js'] ) ) { | |
| 1430 | + return false; | |
| 1431 | + } | |
| 1432 | + $handle = 'elementor-frontend'; | |
| 1433 | + $src = self::registered_src( $handle ); | |
| 1434 | + if ( '' === $src ) { | |
| 1435 | + return false; // Elementor is not loaded. | |
| 1436 | + } | |
| 1437 | + if ( self::smart_delay_enabled() ) { | |
| 1438 | + return self::smart_delays_handle( $handle ); | |
| 1439 | + } | |
| 1440 | + return ! self::is_excluded_script( $handle, $src, true ) && self::is_user_named_target( $handle, $src ); | |
| 1441 | + } | |
| 1442 | + | |
| 1443 | + /** | |
| 1205 | 1444 | * Inline bootstrap that flips delayed scripts on the first user |
| 1206 | 1445 | * interaction. Printed once on wp_footer priority 1000. |
| 1207 | 1446 | */ |
| 1208 | 1447 | public static function print_delay_bootstrap(): void { |
| @@ -2128,9 +2367,9 @@ | ||
| 2128 | 2367 | // has a stored list that knows nothing about consent managers, and |
| 2129 | 2368 | // one that cleared the textarea has no list at all. Neither may |
| 2130 | 2369 | // hide the banner. (#275) |
| 2131 | 2370 | foreach ( self::exclusion_floor() as $needle ) { |
| 2132 | - if ( self::target_matches( $needle, $handle, $src ) ) { | |
| 2371 | + if ( self::floor_matches( $needle, $handle, $src ) ) { | |
| 2133 | 2372 | // `continue`, not `break`: a second floor token matching the |
| 2134 | 2373 | // same tag has to be named too, or a filter-added token |
| 2135 | 2374 | // would be lifted by an entry that names only the first. |
| 2136 | 2375 | if ( $named_lifts_floor && self::user_named_consent_manager( $needle, $handle, $src ) ) { |
| @@ -2155,8 +2394,56 @@ | ||
| 2155 | 2394 | return false; |
| 2156 | 2395 | } |
| 2157 | 2396 | |
| 2158 | 2397 | /** |
| 2398 | + * target_matches() for a floor token, with the site's own host removed | |
| 2399 | + * from the URL first. | |
| 2400 | + * | |
| 2401 | + * Floor tokens are plugin names, and a plugin's own website is often | |
| 2402 | + * named after the plugin. On notificationx.com the `notificationx` token | |
| 2403 | + * matched every same-origin script URL, so Defer JS and Delay JS skipped | |
| 2404 | + * every script on the site. The path still matches, so a script under | |
| 2405 | + * /plugins/notificationx/ keeps its protection, and a third-party host | |
| 2406 | + * such as consent.cookiebot.com still matches in full. | |
| 2407 | + * | |
| 2408 | + * @param string $needle Floor token. | |
| 2409 | + * @param string $handle Script handle. | |
| 2410 | + * @param string $src Script URL. | |
| 2411 | + */ | |
| 2412 | + private static function floor_matches( string $needle, string $handle, string $src ): bool { | |
| 2413 | + if ( '' === $needle ) { | |
| 2414 | + return false; | |
| 2415 | + } | |
| 2416 | + if ( $handle === $needle ) { | |
| 2417 | + return true; | |
| 2418 | + } | |
| 2419 | + foreach ( array( $src, self::original_src( $handle ) ) as $url ) { | |
| 2420 | + $url = self::without_own_host( $url ); | |
| 2421 | + if ( '' !== $url && false !== stripos( $url, $needle ) ) { | |
| 2422 | + return true; | |
| 2423 | + } | |
| 2424 | + } | |
| 2425 | + return false; | |
| 2426 | + } | |
| 2427 | + | |
| 2428 | + /** | |
| 2429 | + * The URL without its scheme and host when the host is the site's own. | |
| 2430 | + * Any other URL comes back unchanged. | |
| 2431 | + * | |
| 2432 | + * @param string $url Script URL. | |
| 2433 | + */ | |
| 2434 | + private static function without_own_host( string $url ): string { | |
| 2435 | + if ( '' === $url || ! function_exists( 'home_url' ) ) { | |
| 2436 | + return $url; | |
| 2437 | + } | |
| 2438 | + $host = (string) wp_parse_url( home_url(), PHP_URL_HOST ); | |
| 2439 | + if ( '' === $host ) { | |
| 2440 | + return $url; | |
| 2441 | + } | |
| 2442 | + return (string) preg_replace( '#^(?:https?:)?//' . preg_quote( $host, '#' ) . '(?::\d+)?(?=[/?\#]|$)#i', '', $url ); | |
| 2443 | + } | |
| 2444 | + | |
| 2445 | + /** | |
| 2159 | 2446 | * Include-list targeting for delay (issue #36): when delay_js_targets |
| 2160 | 2447 | * is non-empty, ONLY matching scripts are delayed — a heavy |
| 2161 | 2448 | * third-party embed can be postponed without delaying the whole |
| 2162 | 2449 | * page's JS. Empty targets = historical behavior (delay everything |
| @@ -2277,13 +2564,85 @@ | ||
| 2277 | 2564 | private static function smart_delays_handle( string $handle ): bool { |
| 2278 | 2565 | if ( '' === $handle ) { |
| 2279 | 2566 | return false; |
| 2280 | 2567 | } |
| 2568 | + // Check the URL delay_script_tag() checks. original_src() is set | |
| 2569 | + // only when Minify JS rewrote the URL, so with Minify JS off it was | |
| 2570 | + // '' here. A URL exclusion then passed here and failed there, and | |
| 2571 | + // the snippets were parked while the script stayed live. | |
| 2281 | 2572 | $src = self::original_src( $handle ); |
| 2573 | + if ( '' === $src ) { | |
| 2574 | + $src = self::registered_src( $handle ); | |
| 2575 | + } | |
| 2282 | 2576 | if ( self::is_excluded_script( $handle, $src, true ) ) { |
| 2283 | 2577 | return false; |
| 2284 | 2578 | } |
| 2285 | 2579 | return self::is_delay_target( $handle, $src ); |
| 2580 | + } | |
| 2581 | + | |
| 2582 | + /** | |
| 2583 | + * A handle's registered URL, made absolute the way WP_Scripts prints it, | |
| 2584 | + * without the version query. '' when the handle has no file. | |
| 2585 | + * | |
| 2586 | + * @param string $handle Script handle. | |
| 2587 | + */ | |
| 2588 | + private static function registered_src( string $handle ): string { | |
| 2589 | + if ( ! function_exists( 'wp_scripts' ) ) { | |
| 2590 | + return ''; | |
| 2591 | + } | |
| 2592 | + $scripts = wp_scripts(); | |
| 2593 | + if ( ! $scripts instanceof \WP_Scripts ) { | |
| 2594 | + return ''; | |
| 2595 | + } | |
| 2596 | + $reg = $scripts->registered[ $handle ] ?? null; | |
| 2597 | + $src = ( is_object( $reg ) && is_string( $reg->src ) ) ? $reg->src : ''; | |
| 2598 | + if ( '' !== $src && ! preg_match( '#^(?:https?:)?//#i', $src ) ) { | |
| 2599 | + $src = (string) ( $scripts->base_url ?? '' ) . $src; | |
| 2600 | + } | |
| 2601 | + return $src; | |
| 2602 | + } | |
| 2603 | + | |
| 2604 | + /** | |
| 2605 | + * Filter: `script_loader_tag`, after every other xSpeed pass. Un-park a | |
| 2606 | + * handle's before/after snippets when its external tag was not delayed. | |
| 2607 | + * | |
| 2608 | + * park_smart_inline() decides before the tag exists, so a later rule | |
| 2609 | + * that keeps the tag live (an opt-out attribute, a non-executable type, | |
| 2610 | + * the late opt-out revert, another plugin's filter) left the snippets | |
| 2611 | + * parked and the script live. The script then ran without the config | |
| 2612 | + * its `before` snippet sets, which is how Elementor's frontend lost | |
| 2613 | + * elementorFrontendConfig. This filter makes that state impossible. | |
| 2614 | + * | |
| 2615 | + * @param string $tag | |
| 2616 | + * @param string $handle | |
| 2617 | + * @param string $src | |
| 2618 | + */ | |
| 2619 | + public static function unpark_orphaned_smart_inline( $tag, $handle, $src ): string { | |
| 2620 | + if ( ! is_string( $tag ) || '' === $tag || '' === (string) $handle || false === stripos( $tag, 'text/xspeed-delayed' ) ) { | |
| 2621 | + return (string) $tag; | |
| 2622 | + } | |
| 2623 | + // Only the external tag in this string can carry data-xs-src. Its | |
| 2624 | + // `src` is gone once delayed, so open_tag_offsets() cannot find it. | |
| 2625 | + if ( preg_match( '#<script\b[^>]*(?<![-\w])data-xs-src\s*=#i', $tag ) ) { | |
| 2626 | + return $tag; | |
| 2627 | + } | |
| 2628 | + return (string) preg_replace_callback( | |
| 2629 | + '#<script\b([^>]*\sid\s*=\s*(["\'])' . preg_quote( (string) $handle, '#' ) . '-js-(?:before|after)\2[^>]*)>#i', | |
| 2630 | + static function ( array $m ): string { | |
| 2631 | + $attrs = $m[1]; | |
| 2632 | + if ( 'text/xspeed-delayed' !== self::extract_type( $attrs ) ) { | |
| 2633 | + return $m[0]; | |
| 2634 | + } | |
| 2635 | + $orig = preg_match( '#\sdata-xs-type\s*=\s*(["\'])([^"\']*)\1#i', $attrs, $t ) ? $t[2] : ''; | |
| 2636 | + $attrs = (string) preg_replace( self::TYPE_ATTR_RE, '', $attrs ); | |
| 2637 | + $attrs = (string) preg_replace( '#\sdata-xs-(?:delay|type)\s*=\s*(["\'])[^"\']*\1#i', '', $attrs ); | |
| 2638 | + if ( '' !== $orig ) { | |
| 2639 | + $attrs .= ' type="' . esc_attr( $orig ) . '"'; | |
| 2640 | + } | |
| 2641 | + return '<script' . $attrs . '>'; | |
| 2642 | + }, | |
| 2643 | + $tag | |
| 2644 | + ); | |
| 2286 | 2645 | } |
| 2287 | 2646 | |
| 2288 | 2647 | /** |
| 2289 | 2648 | * Park a delayed handle's own before/after snippet, in Smart Delay mode. |