main', 'main#wp--skip-link--target', 'main', '#primary', '.content-area', '[role="main"]', '#brx-content', '#main-content', ); /** * Cookie name prefixes that make a page personalised (no prefetch, no client cache). */ const PERSONALIZED_COOKIES = array( 'woocommerce_items_in_cart', 'woocommerce_cart_hash', 'wp-postpass_', 'comment_author_', 'wordpress_logged_in_', 'wp_woocommerce_session_', ); /** * Settings service. * * @var WPAJXL_Settings */ private $settings; /** * Memoised result of is_active_for_request(). * * @var bool|null */ private $active = null; /** * Memoised result of is_bypass_page(). * * @var bool|null */ private $bypass = null; /** * Memoised page state (marker, assets, inline script). * * @var array|null */ private $state = null; /** * Request-level cache of the asset lookup. * * @var array */ private static $asset_cache = array(); /** * Constructor. * * @param WPAJXL_Settings $settings Settings service. */ public function __construct( WPAJXL_Settings $settings ) { $this->settings = $settings; } /** * Registers the front-end hooks. * * @return void */ public function register(): void { if ( did_action( 'init' ) ) { $this->maybe_disable_emojis(); } else { add_action( 'init', array( $this, 'maybe_disable_emojis' ) ); } add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_assets' ) ); add_action( 'wp_head', array( $this, 'print_marker' ), 1 ); add_filter( 'wp_speculation_rules_configuration', array( $this, 'filter_speculation_rules' ), 20 ); } /** * Whether the engine may run for the current request (memoised once the main query exists). * * False before the 'wp' action, when the plugin is disabled or paused, in admin, AJAX, cron, REST and * JSON requests, feeds, embeds, robots/favicon/trackback requests, the Customizer preview, AMP pages, * login/signup screens and page-builder editors; and for non-administrators in "admins only" mode. * * @return bool */ public function is_active_for_request(): bool { if ( null !== $this->active ) { return $this->active; } if ( ! did_action( 'wp' ) ) { // Conditional tags are not reliable before the main query. return false; } $active = false; try { if ( $this->passes_hard_requirements() ) { $settings = $this->effective_settings(); $active = 'admins' !== ( $settings['visibility'] ?? 'everyone' ) || current_user_can( 'manage_options' ); /** * Filters whether AJAX navigation runs on the current front-end request. * * Not applied in contexts where the engine can never run (admin, REST, feeds, AMP, Customizer …). * * @since 2.0.0 * * @param bool $active Whether the engine runs. * @param array $settings Effective settings. */ $active = (bool) apply_filters( 'ajax_loading_enabled', $active, $settings ); } } catch ( Throwable $e ) { $active = false; } $this->active = $active; return $active; } /** * Whether the current page must load normally (marker "bypass", no assets). Memoised. * * @return bool */ public function is_bypass_page(): bool { if ( null !== $this->bypass ) { return $this->bypass; } $settings = $this->effective_settings(); $bypass = WPAJXL_Exclusions::is_bypass_request( $settings ); /** * Filters whether the current page bypasses AJAX navigation (the page and links to it load normally). * * @since 2.0.0 * * @param bool $bypass Whether the page is bypassed. * @param array $settings Effective settings. */ $bypass = (bool) apply_filters( 'ajax_loading_bypass', $bypass, $settings ); if ( did_action( 'wp' ) ) { $this->bypass = $bypass; } return $bypass; } /** * Builds the JavaScript configuration (window.ajaxLoadingConfig). * * Works in any context (front end, admin, tests); it never checks the current request. * * @param array|null $settings Settings to build from; null uses the effective plugin settings. * Missing keys fall back to defaults. * @return array */ public static function get_config( ?array $settings = null ): array { $raw = $settings; if ( null === $raw ) { $raw = array(); if ( class_exists( 'WPAJXL_Settings' ) ) { try { $effective = WPAJXL_Settings::instance()->effective(); $raw = is_array( $effective ) ? $effective : array(); } catch ( Throwable $e ) { $raw = array(); } } } $s = self::with_defaults( $raw ); $version = defined( 'WPAJXL_PLUGIN_VERSION' ) ? (string) WPAJXL_PLUGIN_VERSION : '2.0.0'; $error_logging = self::read_bool( $s, 'error_logging' ); $loading_text = __( 'Loading…', 'ajax-loading' ); $loader_text = self::read_text( $s, 'loader_text', 200 ); $config = array( 'version' => $version, 'cacheVersion' => self::read_int( $s, 'cache_version', 0, PHP_INT_MAX ), 'debug' => self::read_bool( $s, 'debug_mode' ), 'safeMode' => self::read_bool( $s, 'safe_mode' ), 'homeUrl' => home_url( '/' ), 'adminUrl' => admin_url(), 'loginUrl' => WPAJXL_Exclusions::public_login_url(), 'restLogUrl' => $error_logging ? rest_url( 'ajax-loading/v1/log' ) : null, 'isLoggedIn' => is_user_logged_in(), 'devices' => array( 'desktop' => self::read_bool( $s, 'enable_desktop' ), 'mobile' => self::read_bool( $s, 'enable_mobile' ), 'breakpoint' => self::read_int( $s, 'mobile_breakpoint', 320, 2000 ), ), 'mode' => self::read_enum( $s, 'navigation_mode' ), 'contentSelectors' => self::read_lines( $s['content_selectors'] ), 'candidateSelectors' => self::filtered_list( 'ajax_loading_content_selectors', self::CONTENT_CANDIDATES, $s ), 'headerSelector' => self::read_text( $s, 'header_selector', 500 ), 'footerSelector' => self::read_text( $s, 'footer_selector', 500 ), 'headerMode' => self::read_enum( $s, 'header_mode' ), 'footerMode' => self::read_enum( $s, 'footer_mode' ), 'exclusions' => WPAJXL_Exclusions::for_config( $s ), 'personalizedCookies' => self::PERSONALIZED_COOKIES, 'interactiveBlocks' => self::read_enum( $s, 'interactive_blocks' ), 'handle404' => self::read_bool( $s, 'handle_404' ), 'forms' => array( 'search' => self::read_bool( $s, 'forms_search' ), 'selectors' => self::read_lines( $s['form_selectors'] ), ), 'scripts' => array( 'executeContent' => self::read_bool( $s, 'execute_content_scripts' ), 'refreshLocalized' => self::read_bool( $s, 'refresh_localized_data' ), 'dispatchDomEvents' => self::read_bool( $s, 'dispatch_dom_events' ), 'removeStaleStyles' => self::read_bool( $s, 'remove_stale_styles' ), 'runNewInline' => self::read_bool( $s, 'run_new_inline_scripts' ), ), 'loader' => array( 'type' => self::read_enum( $s, 'loader_type' ), 'mobileType' => self::read_enum( $s, 'mobile_loader_type' ), 'color' => self::read_color( $s, 'loader_color' ), 'bg' => self::read_color( $s, 'loader_bg' ), 'size' => self::read_int( $s, 'loader_size', 8, 200 ), 'barHeight' => self::read_int( $s, 'bar_height', 1, 12 ), 'barPosition' => self::read_enum( $s, 'bar_position' ), 'speed' => self::read_int( $s, 'loader_speed', 200, 5000 ), 'overlayOpacity' => self::read_float( $s, 'overlay_opacity', 0.0, 1.0 ), 'delay' => self::read_int( $s, 'loader_delay', 0, 2000 ), 'minDuration' => self::read_int( $s, 'loader_min_duration', 0, 3000 ), 'position' => self::read_enum( $s, 'loader_position' ), 'zIndex' => self::read_int( $s, 'loader_zindex', 1, 2147483647 ), 'text' => '' !== $loader_text ? $loader_text : $loading_text, 'logoUrl' => self::logo_url( self::read_int( $s, 'loader_logo_id', 0, PHP_INT_MAX ) ), ), 'transition' => array( 'out' => self::read_enum( $s, 'transition_out' ), 'in' => self::read_enum( $s, 'transition_in' ), 'duration' => self::read_int( $s, 'transition_duration', 0, 2000 ), 'easing' => self::read_enum( $s, 'transition_easing' ), 'headerFooter' => self::read_bool( $s, 'transition_header_footer' ), 'mobile' => self::read_enum( $s, 'mobile_transition' ), 'reducedMotion' => self::read_bool( $s, 'respect_reduced_motion' ), ), 'scroll' => array( 'behavior' => self::read_enum( $s, 'scroll_behavior' ), 'restore' => self::read_bool( $s, 'preserve_scroll' ), ), 'a11y' => array( 'focus' => self::read_enum( $s, 'focus_target' ), 'announce' => self::read_bool( $s, 'announce_navigation' ), ), 'prefetch' => array( 'enabled' => self::read_bool( $s, 'prefetch_enabled' ), 'triggers' => self::read_multi( $s, 'prefetch_triggers' ), 'hoverDelay' => self::read_int( $s, 'prefetch_hover_delay', 0, 1000 ), 'maxConcurrent' => self::read_int( $s, 'prefetch_max_concurrent', 1, 6 ), 'viewportLimit' => self::read_int( $s, 'prefetch_viewport_limit', 1, 30 ), 'networkAware' => self::read_bool( $s, 'network_aware' ), ), 'cache' => array( 'enabled' => self::read_bool( $s, 'cache_enabled' ), 'maxPages' => self::read_int( $s, 'cache_max_pages', 1, 50 ), 'ttl' => self::read_int( $s, 'cache_ttl', 10, 3600 ), 'maxBytes' => self::read_int( $s, 'cache_max_kb', 256, 20480 ) * 1024, ), 'timeout' => self::read_int( $s, 'request_timeout', 2000, 60000 ), 'state' => array( 'forms' => self::read_bool( $s, 'preserve_form_state' ), 'menus' => self::read_bool( $s, 'sync_menu_state' ), ), 'seo' => array( 'metadata' => self::read_bool( $s, 'sync_metadata' ), 'canonical' => self::read_bool( $s, 'sync_canonical' ), 'structuredData' => self::read_bool( $s, 'sync_structured_data' ), 'headSelectors' => self::filtered_list( 'ajax_loading_head_selectors', self::head_selectors( self::read_bool( $s, 'sync_metadata' ), self::read_bool( $s, 'sync_canonical' ), self::read_bool( $s, 'sync_structured_data' ) ), $s ), ), 'analytics' => array( 'mode' => self::analytics_mode( $raw, $s ), 'gtmEvent' => self::read_pattern( $s, 'gtm_event_name', '/^[A-Za-z0-9_.-]{1,64}$/' ), 'timing' => self::read_bool( $s, 'analytics_timing' ), 'requireConsent' => self::read_bool( $s, 'analytics_require_consent' ), 'callback' => self::read_pattern( $s, 'analytics_callback', '/^[A-Za-z_$][\w$]*(\.[A-Za-z_$][\w$]*)*$/' ), ), 'integrations' => class_exists( 'WPAJXL_Compat' ) ? WPAJXL_Compat::normalize_modes( $s['integrations'] ) : ( is_array( $s['integrations'] ) ? $s['integrations'] : array() ), 'failures' => array( 'autoDisable' => self::read_bool( $s, 'auto_disable' ), 'threshold' => self::read_int( $s, 'auto_disable_threshold', 2, 20 ), 'minutes' => self::read_int( $s, 'auto_disable_minutes', 1, 1440 ), 'logging' => $error_logging, ), 'legacyCallbackDelay' => self::legacy_callback_delay( $s ), 'i18n' => array( 'loading' => $loading_text, /* translators: %s: title of the page that was loaded. */ 'loaded' => __( 'Page loaded: %s', 'ajax-loading' ), 'failed' => __( 'Loading failed, reloading the page…', 'ajax-loading' ), /* translators: %s: title of the page that was navigated to. */ 'navigated' => __( 'Navigated to %s', 'ajax-loading' ), ), ); /** * Filters the configuration passed to the front-end engine (window.ajaxLoadingConfig). * * @since 2.0.0 * * @param array $config Configuration. * @param array $settings Settings the configuration was built from (defaults applied). */ $filtered = apply_filters( 'ajax_loading_frontend_config', $config, $s ); return is_array( $filtered ) ? $filtered : $config; } /** * Locates the built front-end files (prefers unminified files when SCRIPT_DEBUG is on, minified * otherwise, and falls back to the other variant of each file). * * @return array{js_path:string,js_url:string,css_path:string,css_url:string,version:string}|null * Null when the script or the stylesheet is missing. */ public static function asset_files(): ?array { $debug = defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG; $key = $debug ? 'debug' : 'min'; if ( array_key_exists( $key, self::$asset_cache ) ) { return self::$asset_cache[ $key ]; } $dir = self::plugin_dir() . 'assets/dist/'; $url = self::plugin_url() . 'assets/dist/'; $order = $debug ? array( '', '.min' ) : array( '.min', '' ); $files = array( 'js' => '', 'css' => '', ); foreach ( array( 'js', 'css' ) as $type ) { foreach ( $order as $suffix ) { $name = 'frontend' . $suffix . '.' . $type; if ( is_readable( $dir . $name ) && filesize( $dir . $name ) > 0 ) { $files[ $type ] = $name; break; } } } $result = null; if ( '' !== $files['js'] && '' !== $files['css'] ) { $version = defined( 'WPAJXL_PLUGIN_VERSION' ) ? (string) WPAJXL_PLUGIN_VERSION : '2.0.0'; if ( $debug ) { // Bust browser caches while developing. $mtime = filemtime( $dir . $files['js'] ); $version = false !== $mtime ? $version . '-' . $mtime : $version; } $result = array( 'js_path' => $dir . $files['js'], 'js_url' => $url . $files['js'], 'css_path' => $dir . $files['css'], 'css_url' => $url . $files['css'], 'version' => $version, ); } self::$asset_cache[ $key ] = $result; return $result; } /** * Whether the built front-end files exist. * * @return bool */ public static function assets_available(): bool { return null !== self::asset_files(); } /** * Whether the current request is personalised (logged in, WooCommerce session, post password or * comment author cookie). * * @return bool */ public static function is_personalized_request(): bool { if ( is_user_logged_in() ) { return true; } try { if ( function_exists( 'WC' ) ) { $wc = WC(); if ( is_object( $wc ) && isset( $wc->session ) && is_object( $wc->session ) && method_exists( $wc->session, 'has_session' ) && $wc->session->has_session() ) { return true; } } } catch ( Throwable $e ) { unset( $e ); } // Only cookie names are inspected; values are never read. foreach ( array_keys( $_COOKIE ) as $name ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotValidated $name = (string) $name; if ( 0 === strpos( $name, 'wp-postpass_' ) || 0 === strpos( $name, 'comment_author_' ) ) { return true; } } return false; } /** * Enqueues the engine, its stylesheet and configuration (wp_enqueue_scripts). * * Pages that load normally ("bypass" marker) get no engine, but keep jQuery when "legacy_jquery" is * on: 1.x enqueued it on every front-end page and site code written for 1.x may rely on it. * * @return void */ public function enqueue_assets(): void { $state = $this->page_state(); if ( ! $state['enqueue'] ) { if ( 'bypass' === $state['marker'] && self::read_bool( self::with_defaults( $this->effective_settings() ), 'legacy_jquery' ) ) { wp_enqueue_script( 'jquery' ); } return; } $assets = $state['assets']; $settings = $this->effective_settings(); $deps = self::read_bool( self::with_defaults( $settings ), 'legacy_jquery' ) ? array( 'jquery' ) : array(); wp_register_style( self::STYLE_HANDLE, $assets['css_url'], array(), $assets['version'] ); wp_register_script( self::SCRIPT_HANDLE, $assets['js_url'], $deps, $assets['version'], array( 'in_footer' => true, 'strategy' => 'defer', ) ); wp_add_inline_script( self::SCRIPT_HANDLE, $state['inline'], 'before' ); wp_enqueue_style( self::STYLE_HANDLE ); wp_enqueue_script( self::SCRIPT_HANDLE ); /** * Fires after the AJAX Loading front-end assets were enqueued. * * @since 2.0.0 */ do_action( 'ajax_loading_enqueue_scripts' ); } /** * Prints the marker meta tag (wp_head, priority 1) and the legacy `reload_helper` global, so code * written for 1.x can read or change it from the head on. Both are printed whenever the plugin is * active for the request, on pages that load the engine ("enabled") and on pages that load normally * ("bypass"), like 1.x did on every front-end page. * * Marker attributes: `content` (enabled|bypass), `data-version` (plugin version), `data-cache` * (client cache version), `data-site` (site identity, see site_id()), `data-personalized="1"` * (personalised request) and `data-body-class` (the server-rendered body classes, so the engine * can tell them apart from classes added by scripts; on "enabled" pages body_class_script() refines * it to the class attribute of the parsed `` before any body script runs). * * @return void */ public function print_marker(): void { $state = $this->page_state(); if ( '' === $state['marker'] ) { return; } $settings = self::with_defaults( $this->effective_settings() ); $version = defined( 'WPAJXL_PLUGIN_VERSION' ) ? (string) WPAJXL_PLUGIN_VERSION : '2.0.0'; $cache = (string) self::read_int( $settings, 'cache_version', 0, PHP_INT_MAX ); $attributes = sprintf( 'name="ajax-loading" content="%1$s" data-version="%2$s" data-cache="%3$s" data-site="%4$s"', esc_attr( $state['marker'] ), esc_attr( $version ), esc_attr( $cache ), esc_attr( self::site_id() ) ); if ( $state['personalized'] ) { $attributes .= ' data-personalized="1"'; } $body_classes = self::body_classes(); if ( null !== $body_classes ) { $attributes .= sprintf( ' data-body-class="%s"', esc_attr( $body_classes ) ); } echo '\n"; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- every attribute value is escaped above. if ( 'enabled' === $state['marker'] && null !== $body_classes ) { wp_print_inline_script_tag( self::body_class_script(), array( 'id' => 'ajax-loading-body-class-js' ) ); } wp_print_inline_script_tag( self::legacy_helper_script(), array( 'id' => 'ajax-loading-reload-helper-js' ) ); } /** * ES5 snippet that refines the marker's `data-body-class` to the class attribute of the parsed * `` element, read before any script in the body can change it. * * get_body_class() at wp_head misses classes the template passes to body_class( $class ), classes * written into the `` tag by hand and classes added by a `body_class` filter registered later. * A MutationObserver sees `` as soon as the parser inserts it; browsers run it before the first * script of the body. It does nothing when `` already exists (the snippet ran late, e.g. delayed * by a JavaScript optimiser), so the PHP value stays as the fallback. * * @return string */ private static function body_class_script(): string { return '(function(d){try{var m=d.querySelector(\'meta[name="ajax-loading"]\'),o;' . 'if(!m||d.body||!window.MutationObserver){return;}' . 'o=new MutationObserver(function(){var b=d.body,c;if(!b){return;}o.disconnect();c=b.getAttribute("class");' . 'if(c!==null){m.setAttribute("data-body-class",c.replace(/\s+/g," ").replace(/^ | $/g,""));}});' . 'o.observe(d.documentElement,{childList:true});}catch(e){}})(document);'; } /** * Short identity of the current site, printed as the marker's `data-site`. * * The engine never swaps in a page whose marker names another site (for example another site of a * subdirectory multisite network on the same origin). Built from the blog id and the raw `siteurl` * option, which neither changes with the request scheme nor is filtered by multilingual plugins. * * @return string 12 lowercase hexadecimal characters. */ public static function site_id(): string { $blog_id = function_exists( 'get_current_blog_id' ) ? (int) get_current_blog_id() : 1; return substr( md5( $blog_id . '|' . untrailingslashit( (string) get_option( 'siteurl' ) ) ), 0, 12 ); } /** * Server-rendered body classes of the current request (space separated, already escaped by core). * * @return string|null Null when they cannot be computed. */ private static function body_classes(): ?string { if ( ! function_exists( 'get_body_class' ) ) { return null; } try { $classes = get_body_class(); } catch ( Throwable $e ) { return null; } if ( ! is_array( $classes ) ) { return null; } $classes = array_filter( array_map( 'strval', array_filter( $classes, 'is_scalar' ) ), 'strlen' ); return trim( (string) preg_replace( '/\s+/', ' ', implode( ' ', $classes ) ) ); } /** * Disables core speculative loading (WordPress 6.8+) on pages where the engine runs, so links are * not requested twice. Pages that load normally keep it. * * @param array|null $config Speculation rules configuration. * @return array|null */ public function filter_speculation_rules( $config ) { $state = $this->page_state(); if ( ! $state['enqueue'] ) { return $config; } $settings = $this->effective_settings(); $disable = self::read_bool( self::with_defaults( $settings ), 'disable_core_speculation' ); /** * Filters whether core speculative loading is disabled while AJAX navigation is active. * * @since 2.0.0 * * @param bool $disable Whether to disable it. * @param array $settings Effective settings. */ $disable = (bool) apply_filters( 'ajax_loading_disable_core_speculative_loading', $disable, $settings ); return $disable ? null : $config; } /** * Removes the emoji detection script and styles on the front end when "disable_emojis" is on * (1.x behaviour, kept as an option). * * @return void */ public function maybe_disable_emojis(): void { if ( is_admin() ) { return; } try { if ( ! $this->settings->is_enabled() ) { return; } $settings = self::with_defaults( $this->effective_settings() ); } catch ( Throwable $e ) { return; } if ( ! self::read_bool( $settings, 'disable_emojis' ) ) { return; } remove_action( 'wp_head', 'print_emoji_detection_script', 7 ); remove_action( 'embed_head', 'print_emoji_detection_script' ); remove_action( 'wp_print_styles', 'print_emoji_styles' ); remove_action( 'wp_enqueue_scripts', 'wp_enqueue_emoji_styles' ); remove_action( 'enqueue_embed_scripts', 'wp_enqueue_emoji_styles' ); remove_filter( 'the_content_feed', 'wp_staticize_emoji' ); remove_filter( 'comment_text_rss', 'wp_staticize_emoji' ); add_filter( 'wp_resource_hints', array( $this, 'filter_emoji_resource_hints' ), 10, 2 ); } /** * Removes the emoji CDN dns-prefetch hint printed by older WordPress versions. * * @param array $urls Resource hint URLs. * @param string $relation Relation type. * @return array */ public function filter_emoji_resource_hints( $urls, $relation ) { if ( 'dns-prefetch' !== $relation || ! is_array( $urls ) ) { return $urls; } return array_values( array_filter( $urls, static function ( $url ) { $href = is_array( $url ) ? ( $url['href'] ?? '' ) : $url; return ! ( is_string( $href ) && false !== strpos( $href, '/images/core/emoji/' ) ); } ) ); } /** * Computes (once per request) what the page outputs: marker content, whether assets are enqueued, * the inline configuration script and the personalised flag. * * @return array{marker:string,enqueue:bool,assets:array|null,inline:string,personalized:bool} */ private function page_state(): array { if ( null !== $this->state ) { return $this->state; } $state = array( 'marker' => '', 'enqueue' => false, 'assets' => null, 'inline' => '', 'personalized' => false, ); try { if ( $this->is_active_for_request() ) { $assets = self::asset_files(); if ( null !== $assets ) { $state['personalized'] = self::is_personalized_request(); if ( $this->is_bypass_page() ) { $state['marker'] = 'bypass'; } else { $inline = self::build_inline_script( self::get_config( $this->effective_settings() ) ); if ( '' === $inline ) { // The configuration could not be encoded: load this page (and links to it) normally. $state['marker'] = 'bypass'; } else { $state['marker'] = 'enabled'; $state['enqueue'] = true; $state['assets'] = $assets; $state['inline'] = $inline; } } } } } catch ( Throwable $e ) { $state = array( 'marker' => '', 'enqueue' => false, 'assets' => null, 'inline' => '', 'personalized' => false, ); } if ( did_action( 'wp' ) ) { $this->state = $state; } return $state; } /** * Checks the contexts in which the engine can never run. * * @return bool */ private function passes_hard_requirements(): bool { if ( ! $this->settings->is_enabled() ) { return false; } if ( is_admin() || wp_doing_ajax() || wp_doing_cron() || self::is_rest_request() ) { return false; } if ( function_exists( 'wp_is_json_request' ) && wp_is_json_request() ) { return false; } if ( is_feed() || is_embed() || is_robots() || is_trackback() || ( function_exists( 'is_favicon' ) && is_favicon() ) ) { return false; } if ( is_customize_preview() ) { return false; } if ( self::is_amp_request() || self::is_login_request() || self::is_builder_preview() ) { return false; } return true; } /** * Effective settings (safe-mode overrides applied). * * @return array */ private function effective_settings(): array { try { $settings = $this->settings->effective(); } catch ( Throwable $e ) { $settings = array(); } return is_array( $settings ) ? $settings : array(); } /** * Builds the inline "before" script: the configuration plus the legacy reload_helper global. * * @param array $config Configuration. * @return string Empty string when the configuration cannot be encoded. */ private static function build_inline_script( array $config ): string { $json = wp_json_encode( $config, JSON_HEX_TAG | JSON_HEX_AMP | JSON_UNESCAPED_SLASHES ); if ( ! is_string( $json ) || '' === $json ) { return ''; } return 'window.ajaxLoadingConfig = ' . $json . ";\n" . self::legacy_helper_script(); } /** * ES5 snippet defining window.reload_helper (1.x global) without overwriting values that site code * has already set. * * @return string */ private static function legacy_helper_script(): string { $helper = array( 'rootUrl' => home_url(), 'ids' => '', 'container_id' => 'body', 'mcdc' => 'header', 'searchID' => 'searchform', 'transition' => '', 'scrollTop' => '0', 'loader' => 'swing', 'bp_status' => '', ); $json = wp_json_encode( $helper, JSON_HEX_TAG | JSON_HEX_AMP | JSON_UNESCAPED_SLASHES ); if ( ! is_string( $json ) ) { $json = '{}'; } return '(function(d,w){var r=w.reload_helper,k;if(!r||typeof r!=="object"){w.reload_helper=d;return;}' . 'for(k in d){if(Object.prototype.hasOwnProperty.call(d,k)&&!Object.prototype.hasOwnProperty.call(r,k)){r[k]=d[k];}}})(' . $json . ',window);'; } /** * Managed head selectors (DESIGN §10), honouring the metadata/canonical/structured-data switches. * * @param bool $metadata Sync description/robots/Open Graph/Twitter/etc. * @param bool $canonical Sync canonical, shortlink, prev/next and hreflang alternates. * @param bool $structured Sync JSON-LD in the head. * @return string[] */ private static function head_selectors( bool $metadata, bool $canonical, bool $structured ): array { $selectors = array(); if ( $metadata ) { $selectors = array( 'meta[name="description"]', 'meta[name="keywords"]', 'meta[name="author"]', 'meta[name="robots"]', 'meta[name="googlebot"]', 'meta[name="bingbot"]', 'meta[property^="og:"]', 'meta[property^="article:"]', 'meta[property^="product:"]', 'meta[property^="profile:"]', 'meta[property^="book:"]', 'meta[property^="fb:"]', 'meta[name^="twitter:"]', 'meta[property^="twitter:"]', 'link[rel="alternate"][type="application/json+oembed"]', 'link[rel="alternate"][type="text/xml+oembed"]', 'link[rel="alternate"][type="application/json"]', 'link[rel="alternate"][type="application/rss+xml"][title]', 'meta[name="ajax-loading"]', ); } if ( $canonical ) { $selectors = array_merge( $selectors, array( 'link[rel="canonical"]', 'link[rel="shortlink"]', 'link[rel="prev"]', 'link[rel="next"]', 'link[rel="alternate"][hreflang]', ) ); } if ( $structured ) { $selectors[] = 'script[type="application/ld+json"]'; } return $selectors; } /** * Applies a list filter and normalises the result to unique non-empty strings. * * @param string $hook Filter name. * @param array $list Default list. * @param array $settings Settings passed to the filter. * @return string[] */ private static function filtered_list( string $hook, array $list, array $settings ): array { $filtered = apply_filters( $hook, $list, $settings ); if ( ! is_array( $filtered ) ) { $filtered = $list; } $out = array(); foreach ( $filtered as $item ) { if ( is_scalar( $item ) ) { $item = trim( (string) $item ); if ( '' !== $item && ! in_array( $item, $out, true ) ) { $out[] = $item; } } } return $out; } /** * Delay (ms) before legacy global callbacks such as wp_ajax_load_complete() are called. * * @param array $settings Settings. * @return int */ private static function legacy_callback_delay( array $settings ): int { /** * Filters the delay in milliseconds before legacy 1.x callbacks (wp_ajax_load_complete …) run after * a navigation completed. Default 0. * * @since 2.0.0 * * @param int $delay Delay in milliseconds. * @param array $settings Settings. */ $delay = apply_filters( 'ajax_loading_legacy_callback_delay', 0, $settings ); return is_numeric( $delay ) ? max( 0, min( 10000, (int) $delay ) ) : 0; } /** * Analytics mode, mapping the pre-release analytics_ga4 / analytics_gtm switches when analytics_mode is * absent from the stored settings. * * @param array $raw Settings as passed in. * @param array $settings Settings with defaults. * @return string none|gtag|datalayer */ private static function analytics_mode( array $raw, array $settings ): string { if ( ! array_key_exists( 'analytics_mode', $raw ) ) { if ( ! empty( $raw['analytics_ga4'] ) ) { return 'gtag'; } if ( ! empty( $raw['analytics_gtm'] ) ) { return 'datalayer'; } } return self::read_enum( $settings, 'analytics_mode' ); } /** * Loader logo URL from an attachment ID. * * @param int $attachment_id Attachment ID. * @return string */ private static function logo_url( int $attachment_id ): string { if ( $attachment_id <= 0 ) { return ''; } $url = wp_get_attachment_image_url( $attachment_id, 'medium' ); if ( ! $url ) { $mime = get_post_mime_type( $attachment_id ); if ( is_string( $mime ) && 0 === strpos( $mime, 'image/' ) ) { $url = wp_get_attachment_url( $attachment_id ); } } return is_string( $url ) ? $url : ''; } /** * Settings merged over the stored defaults and the built-in fallback defaults. * * @param array $settings Settings. * @return array */ private static function with_defaults( array $settings ): array { $defaults = array(); if ( class_exists( 'WPAJXL_Settings' ) ) { try { $stored_defaults = WPAJXL_Settings::instance()->defaults(); $defaults = is_array( $stored_defaults ) ? $stored_defaults : array(); } catch ( Throwable $e ) { $defaults = array(); } } return array_merge( self::fallback_defaults(), $defaults, $settings ); } /** * Built-in defaults for every setting the configuration reads (DESIGN §2 and §15.2). * * @return array */ private static function fallback_defaults(): array { return array( 'visibility' => 'everyone', 'safe_mode' => false, 'enable_desktop' => true, 'enable_mobile' => true, 'mobile_breakpoint' => 768, 'navigation_mode' => 'auto', 'content_selectors' => '', 'header_selector' => '', 'footer_selector' => '', 'header_mode' => 'preserve', 'footer_mode' => 'preserve', 'handle_404' => true, 'forms_search' => true, 'form_selectors' => '', 'exclude_urls' => '', 'exclude_selectors' => '', 'exclude_post_types' => array(), 'exclude_woocommerce' => true, 'exclude_membership' => true, 'integrations' => array(), 'interactive_blocks' => 'reload', 'execute_content_scripts' => true, 'refresh_localized_data' => true, 'run_new_inline_scripts' => true, 'dispatch_dom_events' => false, 'remove_stale_styles' => false, 'disable_emojis' => false, 'disable_core_speculation' => true, 'legacy_jquery' => false, 'loader_type' => 'bar', 'mobile_loader_type' => 'inherit', 'loader_color' => '#2271b1', 'loader_bg' => '#ffffff', 'loader_size' => 40, 'bar_height' => 3, 'bar_position' => 'top', 'loader_speed' => 900, 'overlay_opacity' => 0.7, 'loader_delay' => 150, 'loader_min_duration' => 250, 'loader_position' => 'center', 'loader_zindex' => 99999, 'loader_text' => '', 'loader_logo_id' => 0, 'transition_out' => 'fade', 'transition_in' => 'fade', 'transition_duration' => 250, 'transition_easing' => 'ease', 'transition_header_footer' => false, 'mobile_transition' => 'inherit', 'respect_reduced_motion' => true, 'scroll_behavior' => 'top', 'focus_target' => 'heading', 'announce_navigation' => true, 'prefetch_enabled' => false, 'prefetch_triggers' => array( 'hover', 'focus', 'touch' ), 'prefetch_hover_delay' => 80, 'prefetch_max_concurrent' => 2, 'prefetch_viewport_limit' => 6, 'prefetch_exclude' => '', 'network_aware' => true, 'cache_enabled' => false, 'cache_max_pages' => 10, 'cache_ttl' => 300, 'cache_max_kb' => 4096, 'request_timeout' => 10000, 'cache_version' => 1, 'preserve_scroll' => true, 'preserve_form_state' => false, 'sync_menu_state' => true, 'sync_metadata' => true, 'sync_canonical' => true, 'sync_structured_data' => true, 'analytics_mode' => 'none', 'analytics_timing' => false, 'gtm_event_name' => 'ajax_page_view', 'analytics_require_consent' => false, 'analytics_callback' => '', 'debug_mode' => false, 'error_logging' => false, 'auto_disable' => true, 'auto_disable_threshold' => 3, 'auto_disable_minutes' => 30, ); } /** * Allowed values of enumerated settings. * * @return array */ private static function enums(): array { $loaders = array( 'bar', 'spinner', 'overlay', 'text', 'logo', 'skeleton', 'none' ); $transitions = array( 'fade', 'fade-through', 'slide-left', 'slide-right', 'slide-up', 'slide-down', 'scale', 'smooth', 'none' ); return array( 'navigation_mode' => array( 'auto', 'custom', 'body' ), 'header_mode' => array( 'preserve', 'replace' ), 'footer_mode' => array( 'preserve', 'replace' ), 'interactive_blocks' => array( 'reload', 'replace' ), 'loader_type' => $loaders, 'mobile_loader_type' => array_merge( array( 'inherit' ), $loaders ), 'bar_position' => array( 'top', 'bottom' ), 'loader_position' => array( 'center', 'top-left', 'top-right', 'bottom-left', 'bottom-right' ), 'transition_out' => $transitions, 'transition_in' => $transitions, 'transition_easing' => array( 'ease', 'ease-in-out', 'ease-out', 'linear', 'snappy' ), 'mobile_transition' => array( 'inherit', 'none', 'fade' ), 'scroll_behavior' => array( 'top', 'content', 'none' ), 'focus_target' => array( 'heading', 'container', 'none' ), 'analytics_mode' => array( 'none', 'gtag', 'datalayer' ), 'prefetch_triggers' => array( 'hover', 'focus', 'touch', 'viewport' ), ); } /** * Reads a boolean setting (tolerates legacy 'on'/'off' strings). * * @param array $settings Settings with defaults. * @param string $key Key. * @return bool */ private static function read_bool( array $settings, string $key ): bool { $value = array_key_exists( $key, $settings ) ? $settings[ $key ] : ( self::fallback_defaults()[ $key ] ?? false ); if ( is_bool( $value ) ) { return $value; } if ( is_string( $value ) ) { return in_array( strtolower( trim( $value ) ), array( '1', 'on', 'true', 'yes' ), true ); } return (bool) $value; } /** * Reads an integer setting clamped to a range. * * @param array $settings Settings with defaults. * @param string $key Key. * @param int $min Minimum. * @param int $max Maximum. * @return int */ private static function read_int( array $settings, string $key, int $min, int $max ): int { $fallback = (int) ( self::fallback_defaults()[ $key ] ?? $min ); $value = $settings[ $key ] ?? $fallback; $value = is_numeric( $value ) ? (int) $value : $fallback; return max( $min, min( $max, $value ) ); } /** * Reads a float setting clamped to a range. * * @param array $settings Settings with defaults. * @param string $key Key. * @param float $min Minimum. * @param float $max Maximum. * @return float */ private static function read_float( array $settings, string $key, float $min, float $max ): float { $fallback = (float) ( self::fallback_defaults()[ $key ] ?? $min ); $value = $settings[ $key ] ?? $fallback; $value = is_numeric( $value ) ? (float) $value : $fallback; return round( max( $min, min( $max, $value ) ), 4 ); } /** * Reads an enumerated setting; unknown values fall back to the default. * * @param array $settings Settings with defaults. * @param string $key Key. * @return string */ private static function read_enum( array $settings, string $key ): string { $allowed = self::enums()[ $key ] ?? array(); $fallback = (string) ( self::fallback_defaults()[ $key ] ?? ( $allowed[0] ?? '' ) ); $value = isset( $settings[ $key ] ) && is_string( $settings[ $key ] ) ? strtolower( trim( $settings[ $key ] ) ) : ''; return in_array( $value, $allowed, true ) ? $value : $fallback; } /** * Reads a multi-select setting (array or comma separated string) limited to allowed values. * * @param array $settings Settings with defaults. * @param string $key Key. * @return string[] */ private static function read_multi( array $settings, string $key ): array { $allowed = self::enums()[ $key ] ?? array(); $value = $settings[ $key ] ?? array(); if ( is_string( $value ) ) { $value = explode( ',', $value ); } if ( ! is_array( $value ) ) { return array(); } $out = array(); foreach ( $value as $item ) { $item = is_string( $item ) ? strtolower( trim( $item ) ) : ''; if ( in_array( $item, $allowed, true ) && ! in_array( $item, $out, true ) ) { $out[] = $item; } } return $out; } /** * Reads a single-line text setting (control characters removed, length limited). * * @param array $settings Settings with defaults. * @param string $key Key. * @param int $max Maximum length. * @return string */ private static function read_text( array $settings, string $key, int $max ): string { $value = $settings[ $key ] ?? ''; if ( ! is_scalar( $value ) ) { return ''; } $value = trim( (string) preg_replace( '/[\x00-\x1F\x7F]/', ' ', (string) $value ) ); if ( function_exists( 'mb_substr' ) ) { return (string) mb_substr( $value, 0, $max ); } return substr( $value, 0, $max ); } /** * Reads a text setting that must match a pattern; otherwise the default. * * @param array $settings Settings with defaults. * @param string $key Key. * @param string $pattern Regular expression. * @return string */ private static function read_pattern( array $settings, string $key, string $pattern ): string { $fallback = (string) ( self::fallback_defaults()[ $key ] ?? '' ); $value = self::read_text( $settings, $key, 100 ); if ( '' === $value ) { return $fallback; } return preg_match( $pattern, $value ) ? $value : $fallback; } /** * Reads a hex colour setting. * * @param array $settings Settings with defaults. * @param string $key Key. * @return string */ private static function read_color( array $settings, string $key ): string { $fallback = (string) ( self::fallback_defaults()[ $key ] ?? '#000000' ); $value = isset( $settings[ $key ] ) && is_string( $settings[ $key ] ) ? strtolower( trim( $settings[ $key ] ) ) : ''; return preg_match( '/^#(?:[0-9a-f]{3}){1,2}$/', $value ) ? $value : $fallback; } /** * Splits a `lines` setting (string or array) into trimmed, non-empty lines. * * @param mixed $value Setting value. * @return string[] */ private static function read_lines( $value ): array { if ( is_string( $value ) ) { $value = preg_split( '/\r\n|\r|\n/', $value ); } if ( ! is_array( $value ) ) { return array(); } $out = array(); foreach ( $value as $line ) { if ( ! is_scalar( $line ) ) { continue; } $line = trim( (string) preg_replace( '/[\x00-\x1F\x7F]/', '', (string) $line ) ); if ( '' !== $line && ! in_array( $line, $out, true ) ) { $out[] = substr( $line, 0, 500 ); } if ( count( $out ) >= 200 ) { break; } } return $out; } /** * Whether this is a REST API request. * * @return bool */ private static function is_rest_request(): bool { if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) { return true; } return function_exists( 'wp_is_serving_rest_request' ) && wp_is_serving_rest_request(); } /** * Whether this is an AMP page (official AMP plugin and AMP for WP). * * @return bool */ private static function is_amp_request(): bool { try { if ( function_exists( 'amp_is_request' ) ) { return (bool) amp_is_request(); } if ( function_exists( 'is_amp_endpoint' ) ) { return (bool) is_amp_endpoint(); } if ( function_exists( 'ampforwp_is_amp_endpoint' ) ) { return (bool) ampforwp_is_amp_endpoint(); } } catch ( Throwable $e ) { return true; } return false; } /** * Whether this is the login, signup or activation screen. * * @return bool */ private static function is_login_request(): bool { global $pagenow; if ( is_string( $pagenow ) && in_array( $pagenow, array( 'wp-login.php', 'wp-signup.php', 'wp-activate.php', 'wp-register.php' ), true ) ) { return true; } return isset( $_SERVER['SCRIPT_NAME'] ) && function_exists( 'is_login' ) && is_login(); } /** * Whether the request renders a page-builder editor or live preview (Elementor, Beaver Builder, Divi, * Bricks, Oxygen, Breakdance, Brizy, Thrive, WPBakery, SiteOrigin, Avada, Flatsome). * * @return bool */ private static function is_builder_preview(): bool { $flags = array( 'elementor-preview', 'fl_builder', 'et_fb', 'et_pb_preview', 'bricks', 'ct_builder', 'oxygen_iframe', 'breakdance', 'breakdance_iframe', 'brizy-edit', 'brizy-edit-iframe', 'tve', 'vc_editable', 'siteorigin_panels_live_editor', 'fb-edit', 'uxb_iframe', ); foreach ( $flags as $flag ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only presence check of public editor flags. if ( isset( $_GET[ $flag ] ) ) { return true; } } try { if ( function_exists( 'et_core_is_fb_enabled' ) && et_core_is_fb_enabled() ) { return true; } if ( function_exists( 'bricks_is_builder' ) && bricks_is_builder() ) { return true; } if ( function_exists( 'vc_is_inline' ) && vc_is_inline() ) { return true; } if ( class_exists( 'FLBuilderModel', false ) && method_exists( 'FLBuilderModel', 'is_builder_active' ) && FLBuilderModel::is_builder_active() ) { return true; } if ( class_exists( '\Elementor\Plugin', false ) ) { $elementor = \Elementor\Plugin::$instance; if ( is_object( $elementor ) && isset( $elementor->preview ) && is_object( $elementor->preview ) && method_exists( $elementor->preview, 'is_preview_mode' ) && $elementor->preview->is_preview_mode() ) { return true; } } } catch ( Throwable $e ) { return true; } return false; } /** * Plugin root directory with trailing slash. * * @return string */ private static function plugin_dir(): string { return defined( 'WPAJXL_PLUGIN_ABS_PATH' ) ? trailingslashit( (string) WPAJXL_PLUGIN_ABS_PATH ) : plugin_dir_path( __DIR__ ); } /** * Plugin root URL with trailing slash. * * @return string */ private static function plugin_url(): string { return defined( 'WPAJXL_PLUGIN_URL' ) ? trailingslashit( (string) WPAJXL_PLUGIN_URL ) : plugin_dir_url( __DIR__ ); } }