| @@ -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 ) { |
| @@ -2128,9 +2136,9 @@ | ||
| 2128 | 2136 | // has a stored list that knows nothing about consent managers, and |
| 2129 | 2137 | // one that cleared the textarea has no list at all. Neither may |
| 2130 | 2138 | // hide the banner. (#275) |
| 2131 | 2139 | foreach ( self::exclusion_floor() as $needle ) { |
| 2132 | - if ( self::target_matches( $needle, $handle, $src ) ) { | |
| 2140 | + if ( self::floor_matches( $needle, $handle, $src ) ) { | |
| 2133 | 2141 | // `continue`, not `break`: a second floor token matching the |
| 2134 | 2142 | // same tag has to be named too, or a filter-added token |
| 2135 | 2143 | // would be lifted by an entry that names only the first. |
| 2136 | 2144 | if ( $named_lifts_floor && self::user_named_consent_manager( $needle, $handle, $src ) ) { |
| @@ -2155,8 +2163,56 @@ | ||
| 2155 | 2163 | return false; |
| 2156 | 2164 | } |
| 2157 | 2165 | |
| 2158 | 2166 | /** |
| 2167 | + * target_matches() for a floor token, with the site's own host removed | |
| 2168 | + * from the URL first. | |
| 2169 | + * | |
| 2170 | + * Floor tokens are plugin names, and a plugin's own website is often | |
| 2171 | + * named after the plugin. On notificationx.com the `notificationx` token | |
| 2172 | + * matched every same-origin script URL, so Defer JS and Delay JS skipped | |
| 2173 | + * every script on the site. The path still matches, so a script under | |
| 2174 | + * /plugins/notificationx/ keeps its protection, and a third-party host | |
| 2175 | + * such as consent.cookiebot.com still matches in full. | |
| 2176 | + * | |
| 2177 | + * @param string $needle Floor token. | |
| 2178 | + * @param string $handle Script handle. | |
| 2179 | + * @param string $src Script URL. | |
| 2180 | + */ | |
| 2181 | + private static function floor_matches( string $needle, string $handle, string $src ): bool { | |
| 2182 | + if ( '' === $needle ) { | |
| 2183 | + return false; | |
| 2184 | + } | |
| 2185 | + if ( $handle === $needle ) { | |
| 2186 | + return true; | |
| 2187 | + } | |
| 2188 | + foreach ( array( $src, self::original_src( $handle ) ) as $url ) { | |
| 2189 | + $url = self::without_own_host( $url ); | |
| 2190 | + if ( '' !== $url && false !== stripos( $url, $needle ) ) { | |
| 2191 | + return true; | |
| 2192 | + } | |
| 2193 | + } | |
| 2194 | + return false; | |
| 2195 | + } | |
| 2196 | + | |
| 2197 | + /** | |
| 2198 | + * The URL without its scheme and host when the host is the site's own. | |
| 2199 | + * Any other URL comes back unchanged. | |
| 2200 | + * | |
| 2201 | + * @param string $url Script URL. | |
| 2202 | + */ | |
| 2203 | + private static function without_own_host( string $url ): string { | |
| 2204 | + if ( '' === $url || ! function_exists( 'home_url' ) ) { | |
| 2205 | + return $url; | |
| 2206 | + } | |
| 2207 | + $host = (string) wp_parse_url( home_url(), PHP_URL_HOST ); | |
| 2208 | + if ( '' === $host ) { | |
| 2209 | + return $url; | |
| 2210 | + } | |
| 2211 | + return (string) preg_replace( '#^(?:https?:)?//' . preg_quote( $host, '#' ) . '(?::\d+)?(?=[/?\#]|$)#i', '', $url ); | |
| 2212 | + } | |
| 2213 | + | |
| 2214 | + /** | |
| 2159 | 2215 | * Include-list targeting for delay (issue #36): when delay_js_targets |
| 2160 | 2216 | * is non-empty, ONLY matching scripts are delayed — a heavy |
| 2161 | 2217 | * third-party embed can be postponed without delaying the whole |
| 2162 | 2218 | * page's JS. Empty targets = historical behavior (delay everything |
| @@ -2277,13 +2333,85 @@ | ||
| 2277 | 2333 | private static function smart_delays_handle( string $handle ): bool { |
| 2278 | 2334 | if ( '' === $handle ) { |
| 2279 | 2335 | return false; |
| 2280 | 2336 | } |
| 2337 | + // Check the URL delay_script_tag() checks. original_src() is set | |
| 2338 | + // only when Minify JS rewrote the URL, so with Minify JS off it was | |
| 2339 | + // '' here. A URL exclusion then passed here and failed there, and | |
| 2340 | + // the snippets were parked while the script stayed live. | |
| 2281 | 2341 | $src = self::original_src( $handle ); |
| 2342 | + if ( '' === $src ) { | |
| 2343 | + $src = self::registered_src( $handle ); | |
| 2344 | + } | |
| 2282 | 2345 | if ( self::is_excluded_script( $handle, $src, true ) ) { |
| 2283 | 2346 | return false; |
| 2284 | 2347 | } |
| 2285 | 2348 | return self::is_delay_target( $handle, $src ); |
| 2349 | + } | |
| 2350 | + | |
| 2351 | + /** | |
| 2352 | + * A handle's registered URL, made absolute the way WP_Scripts prints it, | |
| 2353 | + * without the version query. '' when the handle has no file. | |
| 2354 | + * | |
| 2355 | + * @param string $handle Script handle. | |
| 2356 | + */ | |
| 2357 | + private static function registered_src( string $handle ): string { | |
| 2358 | + if ( ! function_exists( 'wp_scripts' ) ) { | |
| 2359 | + return ''; | |
| 2360 | + } | |
| 2361 | + $scripts = wp_scripts(); | |
| 2362 | + if ( ! $scripts instanceof \WP_Scripts ) { | |
| 2363 | + return ''; | |
| 2364 | + } | |
| 2365 | + $reg = $scripts->registered[ $handle ] ?? null; | |
| 2366 | + $src = ( is_object( $reg ) && is_string( $reg->src ) ) ? $reg->src : ''; | |
| 2367 | + if ( '' !== $src && ! preg_match( '#^(?:https?:)?//#i', $src ) ) { | |
| 2368 | + $src = (string) ( $scripts->base_url ?? '' ) . $src; | |
| 2369 | + } | |
| 2370 | + return $src; | |
| 2371 | + } | |
| 2372 | + | |
| 2373 | + /** | |
| 2374 | + * Filter: `script_loader_tag`, after every other xSpeed pass. Un-park a | |
| 2375 | + * handle's before/after snippets when its external tag was not delayed. | |
| 2376 | + * | |
| 2377 | + * park_smart_inline() decides before the tag exists, so a later rule | |
| 2378 | + * that keeps the tag live (an opt-out attribute, a non-executable type, | |
| 2379 | + * the late opt-out revert, another plugin's filter) left the snippets | |
| 2380 | + * parked and the script live. The script then ran without the config | |
| 2381 | + * its `before` snippet sets, which is how Elementor's frontend lost | |
| 2382 | + * elementorFrontendConfig. This filter makes that state impossible. | |
| 2383 | + * | |
| 2384 | + * @param string $tag | |
| 2385 | + * @param string $handle | |
| 2386 | + * @param string $src | |
| 2387 | + */ | |
| 2388 | + public static function unpark_orphaned_smart_inline( $tag, $handle, $src ): string { | |
| 2389 | + if ( ! is_string( $tag ) || '' === $tag || '' === (string) $handle || false === stripos( $tag, 'text/xspeed-delayed' ) ) { | |
| 2390 | + return (string) $tag; | |
| 2391 | + } | |
| 2392 | + // Only the external tag in this string can carry data-xs-src. Its | |
| 2393 | + // `src` is gone once delayed, so open_tag_offsets() cannot find it. | |
| 2394 | + if ( preg_match( '#<script\b[^>]*(?<![-\w])data-xs-src\s*=#i', $tag ) ) { | |
| 2395 | + return $tag; | |
| 2396 | + } | |
| 2397 | + return (string) preg_replace_callback( | |
| 2398 | + '#<script\b([^>]*\sid\s*=\s*(["\'])' . preg_quote( (string) $handle, '#' ) . '-js-(?:before|after)\2[^>]*)>#i', | |
| 2399 | + static function ( array $m ): string { | |
| 2400 | + $attrs = $m[1]; | |
| 2401 | + if ( 'text/xspeed-delayed' !== self::extract_type( $attrs ) ) { | |
| 2402 | + return $m[0]; | |
| 2403 | + } | |
| 2404 | + $orig = preg_match( '#\sdata-xs-type\s*=\s*(["\'])([^"\']*)\1#i', $attrs, $t ) ? $t[2] : ''; | |
| 2405 | + $attrs = (string) preg_replace( self::TYPE_ATTR_RE, '', $attrs ); | |
| 2406 | + $attrs = (string) preg_replace( '#\sdata-xs-(?:delay|type)\s*=\s*(["\'])[^"\']*\1#i', '', $attrs ); | |
| 2407 | + if ( '' !== $orig ) { | |
| 2408 | + $attrs .= ' type="' . esc_attr( $orig ) . '"'; | |
| 2409 | + } | |
| 2410 | + return '<script' . $attrs . '>'; | |
| 2411 | + }, | |
| 2412 | + $tag | |
| 2413 | + ); | |
| 2286 | 2414 | } |
| 2287 | 2415 | |
| 2288 | 2416 | /** |
| 2289 | 2417 | * Park a delayed handle's own before/after snippet, in Smart Delay mode. |