*/ public static function integrations(): array { if ( isset( self::$cache['integrations'] ) ) { return self::$cache['integrations']; } $integrations = array(); foreach ( self::definitions() as $key => $definition ) { $detected = self::detect( $key ); $integrations[ $key ] = array( 'label' => $definition['label'], 'description' => $definition['description'], 'active' => $detected['active'], 'version' => $detected['version'], 'modes' => $definition['modes'], 'default' => $definition['default'], ); } /** * Filters the integration registry. * * @since 2.0.0 * * @param array $integrations Key => { label, description, active, version, modes, default }. */ $filtered = apply_filters( 'ajax_loading_integrations', $integrations ); $result = is_array( $filtered ) ? self::validate_registry( $filtered, $integrations ) : $integrations; // Cache only once plugins and the theme had the chance to add filters (also from an init callback). if ( did_action( 'init' ) && ! doing_action( 'init' ) ) { self::$cache['integrations'] = $result; } return $result; } /** * Default mode per integration key. * * @return array */ public static function defaults(): array { $defaults = array(); foreach ( self::integrations() as $key => $integration ) { $defaults[ $key ] = $integration['default']; } return $defaults; } /** * Allowed modes for an integration key (empty array for unknown keys). * * @param string $key Integration key. * @return string[] */ public static function allowed_modes( string $key ): array { $integrations = self::integrations(); return isset( $integrations[ $key ] ) ? $integrations[ $key ]['modes'] : array(); } /** * Complete, validated integration mode map: every registered key, unknown keys dropped, * disallowed or missing values replaced by the default. * * @param mixed $modes Stored map (key => mode). * @return array */ public static function normalize_modes( $modes ): array { $modes = is_array( $modes ) ? $modes : array(); $out = array(); foreach ( self::integrations() as $key => $integration ) { $value = isset( $modes[ $key ] ) && is_string( $modes[ $key ] ) ? strtolower( trim( $modes[ $key ] ) ) : ''; $out[ $key ] = in_array( $value, $integration['modes'], true ) ? $value : $integration['default']; } return $out; } /** * Validated mode of one integration from a settings array. * * @param array $settings Settings containing an `integrations` map. * @param string $key Integration key. * @return string Mode, or 'off' for unknown keys. */ public static function mode( array $settings, string $key ): string { $integrations = self::integrations(); if ( ! isset( $integrations[ $key ] ) ) { return 'off'; } $modes = isset( $settings['integrations'] ) && is_array( $settings['integrations'] ) ? $settings['integrations'] : array(); $value = isset( $modes[ $key ] ) && is_string( $modes[ $key ] ) ? strtolower( trim( $modes[ $key ] ) ) : ''; return in_array( $value, $integrations[ $key ]['modes'], true ) ? $value : $integrations[ $key ]['default']; } /** * Translated labels for the integration modes. * * @return array */ public static function mode_labels(): array { return array( 'auto' => __( 'Auto (built-in handling)', 'ajax-loading' ), 'exclude' => __( 'Exclude (full page load)', 'ajax-loading' ), 'off' => __( 'Off (no special handling)', 'ajax-loading' ), ); } /** * Environment report for diagnostics: list of { id, status (ok|info|warning|error), label, message }. * * Messages are plain text (escape on output). * * @return array */ public static function environment_report(): array { $settings = self::settings_snapshot(); $checks = array( 'versions' => __( 'Versions', 'ajax-loading' ), 'plugin_status' => __( 'Plugin status', 'ajax-loading' ), 'pause' => __( 'Pause', 'ajax-loading' ), 'visibility' => __( 'Visibility', 'ajax-loading' ), 'safe_mode' => __( 'Safe mode', 'ajax-loading' ), 'assets' => __( 'Front-end files', 'ajax-loading' ), 'permalinks' => __( 'Permalinks', 'ajax-loading' ), 'site_urls' => __( 'Home and site URL', 'ajax-loading' ), 'https' => __( 'HTTPS', 'ajax-loading' ), 'script_debug' => __( 'SCRIPT_DEBUG', 'ajax-loading' ), 'theme' => __( 'Theme', 'ajax-loading' ), 'block_layout' => __( 'Block theme layout classes', 'ajax-loading' ), 'caching' => __( 'Caching and optimisation plugins', 'ajax-loading' ), 'navigation_conflicts' => __( 'Other navigation plugins', 'ajax-loading' ), 'prefetch_plugins' => __( 'Other prefetch plugins', 'ajax-loading' ), 'speculative_loading' => __( 'Core speculative loading', 'ajax-loading' ), 'integrations' => __( 'Detected integrations', 'ajax-loading' ), 'elementor_pro' => __( 'Elementor Pro Page Transitions', 'ajax-loading' ), 'divi5' => __( 'Divi 5', 'ajax-loading' ), 'excluded_plugins' => __( 'Plugins that always use full page loads', 'ajax-loading' ), 'multilingual' => __( 'Multilingual', 'ajax-loading' ), 'interactivity' => __( 'Interactive blocks', 'ajax-loading' ), 'interactivity_router' => __( 'Interactivity router', 'ajax-loading' ), 'legacy_jquery' => __( 'Legacy jQuery dependency', 'ajax-loading' ), 'dom_extension' => __( 'PHP DOM extension', 'ajax-loading' ), ); $report = array(); foreach ( $checks as $id => $label ) { try { $item = self::run_check( $id, $settings ); } catch ( Throwable $e ) { $item = array( 'status' => 'warning', /* translators: %s: error message. */ 'message' => sprintf( __( 'This check could not be completed: %s', 'ajax-loading' ), wp_strip_all_tags( $e->getMessage() ) ), ); } if ( null === $item ) { continue; } $report[] = array( 'id' => $id, 'status' => in_array( $item['status'], array( 'ok', 'info', 'warning', 'error' ), true ) ? $item['status'] : 'info', 'label' => $label, 'message' => (string) $item['message'], ); } return $report; } /** * Integration definitions without detection. * * @return array */ private static function definitions(): array { $all = self::MODES; $safe = array( 'exclude', 'off' ); return array( 'woocommerce' => array( 'label' => 'WooCommerce', 'description' => __( 'Re-initialises product galleries, variation forms, tabs and sorting after navigation and refreshes cart fragments. Cart, checkout and account pages always load normally.', 'ajax-loading' ), 'modes' => $all, 'default' => 'auto', ), 'elementor' => array( 'label' => 'Elementor', 'description' => __( 'Initialises Elementor widgets and Pro popups in the new content without re-running Elementor\'s global start-up.', 'ajax-loading' ), 'modes' => $all, 'default' => 'auto', ), 'divi' => array( 'label' => 'Divi', 'description' => __( 'Re-runs Divi 4 module initialisation and refreshes per-page Divi styles. Divi 5 pages always load normally.', 'ajax-loading' ), 'modes' => $all, 'default' => 'auto', ), 'beaver_builder' => array( 'label' => 'Beaver Builder', 'description' => __( 'Runs the page-specific Beaver Builder layout script of the new page.', 'ajax-loading' ), 'modes' => $all, 'default' => 'auto', ), 'bricks' => array( 'label' => 'Bricks', 'description' => __( 'Refreshes Bricks page data and re-runs Bricks element scripts.', 'ajax-loading' ), 'modes' => $all, 'default' => 'auto', ), 'contact_form_7' => array( 'label' => 'Contact Form 7', 'description' => __( 'Initialises Contact Form 7 forms that appear in new content.', 'ajax-loading' ), 'modes' => $all, 'default' => 'auto', ), 'gravity_forms' => array( 'label' => 'Gravity Forms', 'description' => __( 'Runs Gravity Forms initialisation scripts and post-render events for forms in new content.', 'ajax-loading' ), 'modes' => $all, 'default' => 'auto', ), 'wpforms' => array( 'label' => 'WPForms', 'description' => __( 'Refreshes WPForms settings and re-runs its initialisation for forms in new content.', 'ajax-loading' ), 'modes' => $all, 'default' => 'auto', ), 'fluent_forms' => array( 'label' => 'Fluent Forms', 'description' => __( 'Re-initialises Fluent Forms in new content.', 'ajax-loading' ), 'modes' => $all, 'default' => 'auto', ), 'formidable' => array( 'label' => 'Formidable Forms', 'description' => __( 'Refreshes Formidable captchas; its form handlers keep working on new content.', 'ajax-loading' ), 'modes' => $all, 'default' => 'auto', ), 'ninja_forms' => array( 'label' => 'Ninja Forms', 'description' => __( 'Ninja Forms cannot be started again after a page has loaded, so pages that contain a Ninja Form load normally.', 'ajax-loading' ), 'modes' => $safe, 'default' => 'exclude', ), 'acf_forms' => array( 'label' => __( 'ACF front-end forms', 'ajax-loading' ), 'description' => __( 'ACF forms depend on page data printed in the footer, so pages that contain an ACF form load normally.', 'ajax-loading' ), 'modes' => $safe, 'default' => 'exclude', ), 'buddypress' => array( 'label' => 'BuddyPress', 'description' => __( 'BuddyPress starts once per page, so BuddyPress pages and links inside BuddyPress content load normally.', 'ajax-loading' ), 'modes' => $safe, 'default' => 'exclude', ), 'embeds' => array( 'label' => __( 'Embeds', 'ajax-loading' ), 'description' => __( 'Loads X (Twitter), Instagram, Facebook and WordPress post embeds that appear in new content.', 'ajax-loading' ), 'modes' => $all, 'default' => 'auto', ), 'lazyload' => array( 'label' => __( 'Lazy loading scripts', 'ajax-loading' ), 'description' => __( 'Makes lazysizes, vanilla-lazyload and the plugins that use them pick up images in new content.', 'ajax-loading' ), 'modes' => $all, 'default' => 'auto', ), 'comment_reply' => array( 'label' => __( 'Threaded comment replies', 'ajax-loading' ), 'description' => __( 'Keeps the "Reply" links of threaded comments working after navigation.', 'ajax-loading' ), 'modes' => $all, 'default' => 'auto', ), 'post_load_event' => array( 'label' => __( '"post-load" event', 'ajax-loading' ), 'description' => __( 'Triggers the jQuery "post-load" event on the body after each navigation (the event used by infinite scroll; many plugins re-initialise on it).', 'ajax-loading' ), 'modes' => $all, 'default' => 'auto', ), 'multilingual' => array( 'label' => __( 'Multilingual (WPML, Polylang, TranslatePress …)', 'ajax-loading' ), 'description' => __( 'Language switches always use a full page load so menus, cookies and translations stay consistent.', 'ajax-loading' ), 'modes' => $all, 'default' => 'auto', ), ); } /** * Server-side detection of an integration. * * @param string $key Integration key. * @return array{active:bool,version:string} */ private static function detect( string $key ): array { $active = false; $version = ''; try { switch ( $key ) { case 'woocommerce': $active = class_exists( 'WooCommerce', false ) || function_exists( 'WC' ); $version = self::constant( 'WC_VERSION' ); break; case 'elementor': $active = defined( 'ELEMENTOR_VERSION' ); $version = self::constant( 'ELEMENTOR_VERSION' ); break; case 'divi': $divi = self::divi_version(); $active = null !== $divi; $version = (string) $divi; break; case 'beaver_builder': $active = class_exists( 'FLBuilder', false ) || defined( 'FL_BUILDER_VERSION' ); $version = self::constant( 'FL_BUILDER_VERSION' ); break; case 'bricks': $active = defined( 'BRICKS_VERSION' ) || in_array( 'bricks', self::theme_slugs(), true ); $version = self::constant( 'BRICKS_VERSION' ); break; case 'contact_form_7': $active = defined( 'WPCF7_VERSION' ) || class_exists( 'WPCF7', false ); $version = self::constant( 'WPCF7_VERSION' ); break; case 'gravity_forms': $active = class_exists( 'GFForms', false ) || class_exists( 'GFCommon', false ); if ( class_exists( 'GFForms', false ) && property_exists( 'GFForms', 'version' ) ) { $version = is_scalar( GFForms::$version ) ? (string) GFForms::$version : ''; } break; case 'wpforms': $active = defined( 'WPFORMS_VERSION' ) || function_exists( 'wpforms' ); $version = self::constant( 'WPFORMS_VERSION' ); break; case 'fluent_forms': $active = defined( 'FLUENTFORM' ) || defined( 'FLUENTFORM_VERSION' ); $version = self::constant( 'FLUENTFORM_VERSION' ); break; case 'formidable': $active = class_exists( 'FrmAppHelper', false ) || class_exists( 'FrmForm', false ); if ( class_exists( 'FrmAppHelper', false ) && method_exists( 'FrmAppHelper', 'plugin_version' ) ) { $version = (string) FrmAppHelper::plugin_version(); } break; case 'ninja_forms': $active = class_exists( 'Ninja_Forms', false ) || function_exists( 'Ninja_Forms' ); $version = defined( 'Ninja_Forms::VERSION' ) ? (string) constant( 'Ninja_Forms::VERSION' ) : ''; break; case 'acf_forms': $active = class_exists( 'ACF', false ) || function_exists( 'acf_form' ); $version = self::constant( 'ACF_VERSION' ); break; case 'buddypress': $active = function_exists( 'buddypress' ) || class_exists( 'BuddyPress', false ); $version = self::constant( 'BP_VERSION' ); break; case 'embeds': case 'post_load_event': // Core features / conventions that are always available. $active = true; break; case 'lazyload': $active = ! empty( self::detect_constants( self::lazyload_plugins() ) ); break; case 'comment_reply': $active = (bool) get_option( 'thread_comments' ); break; case 'multilingual': $active = ! empty( self::multilingual_plugins() ); break; } } catch ( Throwable $e ) { $active = false; $version = ''; } return array( 'active' => (bool) $active, 'version' => $version, ); } /** * Keeps filtered registry entries well-formed (filters may add keys or return partial data). * * @param array $filtered Filtered registry. * @param array $original Unfiltered registry. * @return array */ private static function validate_registry( array $filtered, array $original ): array { $result = array(); foreach ( $filtered as $key => $entry ) { if ( ! is_string( $key ) || ! preg_match( '/^[a-z0-9_]{1,64}$/', $key ) || ! is_array( $entry ) ) { continue; } $base = isset( $original[ $key ] ) ? $original[ $key ] : array( 'label' => $key, 'description' => '', 'active' => false, 'version' => '', 'modes' => self::MODES, 'default' => 'auto', ); $modes = isset( $entry['modes'] ) && is_array( $entry['modes'] ) ? array_values( array_intersect( self::MODES, $entry['modes'] ) ) : $base['modes']; if ( empty( $modes ) ) { $modes = $base['modes']; } $default = isset( $entry['default'] ) && in_array( $entry['default'], $modes, true ) ? $entry['default'] : $base['default']; if ( ! in_array( $default, $modes, true ) ) { $default = $modes[0]; } $result[ $key ] = array( 'label' => isset( $entry['label'] ) && is_scalar( $entry['label'] ) ? (string) $entry['label'] : (string) $base['label'], 'description' => isset( $entry['description'] ) && is_scalar( $entry['description'] ) ? (string) $entry['description'] : (string) $base['description'], 'active' => isset( $entry['active'] ) ? (bool) $entry['active'] : (bool) $base['active'], 'version' => isset( $entry['version'] ) && is_scalar( $entry['version'] ) ? (string) $entry['version'] : (string) $base['version'], 'modes' => $modes, 'default' => $default, ); } return $result; } /** * Runs one environment check. * * @param string $id Check id. * @param array $settings Settings snapshot. * @return array{status:string,message:string}|null Null when the check does not apply. */ private static function run_check( string $id, array $settings ): ?array { switch ( $id ) { case 'versions': return self::check_versions(); case 'plugin_status': return self::check_plugin_status( $settings ); case 'pause': return self::check_pause( $settings ); case 'visibility': return 'admins' === $settings['effective']['visibility'] ? self::item( 'warning', __( 'Test mode: AJAX navigation is only active for administrators. Visitors get normal page loads.', 'ajax-loading' ) ) : self::item( 'ok', __( 'AJAX navigation is active for every visitor.', 'ajax-loading' ) ); case 'safe_mode': return ! empty( $settings['effective']['safe_mode'] ) ? self::item( 'info', __( 'Safe mode is on: prefetching, caching, custom forms, body replacement and page-builder re-initialisation are turned off.', 'ajax-loading' ) ) : self::item( 'ok', __( 'Safe mode is off.', 'ajax-loading' ) ); case 'assets': return self::check_assets(); case 'permalinks': return self::check_permalinks(); case 'site_urls': return self::check_site_urls(); case 'https': return self::check_https(); case 'script_debug': return ( defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG ) ? self::item( 'info', __( 'SCRIPT_DEBUG is on: the unminified front-end script is loaded.', 'ajax-loading' ) ) : self::item( 'ok', __( 'SCRIPT_DEBUG is off: the minified front-end script is loaded.', 'ajax-loading' ) ); case 'theme': return self::check_theme( $settings ); case 'block_layout': return self::check_block_layout( $settings ); case 'caching': return self::check_caching(); case 'navigation_conflicts': return self::check_navigation_conflicts(); case 'prefetch_plugins': return self::check_prefetch_plugins( $settings ); case 'speculative_loading': return self::check_speculative_loading( $settings ); case 'integrations': return self::check_integrations( $settings ); case 'elementor_pro': return self::check_elementor_pro(); case 'divi5': return self::check_divi5(); case 'excluded_plugins': return self::check_excluded_plugins( $settings ); case 'multilingual': return self::check_multilingual( $settings ); case 'interactivity': return self::check_interactivity( $settings ); case 'interactivity_router': return self::check_interactivity_router(); case 'legacy_jquery': return ! empty( $settings['effective']['legacy_jquery'] ) ? self::item( 'info', __( 'jQuery is loaded on every page for custom code written for AJAX Loading 1.x. Turn this off if no custom code uses jQuery.', 'ajax-loading' ) ) : self::item( 'ok', __( 'The front-end script has no jQuery dependency.', 'ajax-loading' ) ); case 'dom_extension': return extension_loaded( 'dom' ) ? self::item( 'info', __( 'The PHP DOM extension is available (not required by AJAX Loading).', 'ajax-loading' ) ) : self::item( 'info', __( 'The PHP DOM extension is not installed (not required by AJAX Loading).', 'ajax-loading' ) ); } return null; } /** * PHP and WordPress versions. * * @return array */ private static function check_versions(): array { $wp_version = self::wp_version(); $message = sprintf( /* translators: 1: WordPress version, 2: PHP version. */ __( 'WordPress %1$s, PHP %2$s.', 'ajax-loading' ), $wp_version, PHP_VERSION ); if ( version_compare( $wp_version, '6.0', '<' ) || version_compare( PHP_VERSION, '7.4', '<' ) ) { return self::item( 'error', $message . ' ' . __( 'AJAX Loading requires WordPress 6.0 and PHP 7.4 or newer.', 'ajax-loading' ) ); } return self::item( 'ok', $message ); } /** * Master switch state. * * @param array $settings Settings snapshot. * @return array */ private static function check_plugin_status( array $settings ): array { if ( ! $settings['stored'] ) { return self::item( 'warning', __( 'The plugin settings have not been created yet, so AJAX navigation is off. Save the settings once to create them.', 'ajax-loading' ) ); } return 'on' === $settings['all']['enable_plugin'] ? self::item( 'ok', __( 'AJAX navigation is enabled.', 'ajax-loading' ) ) : self::item( 'info', __( 'AJAX navigation is disabled. Pages load normally.', 'ajax-loading' ) ); } /** * Pause state. * * @param array $settings Settings snapshot. * @return array */ private static function check_pause( array $settings ): array { $until = (int) $settings['all']['pause_until']; if ( $until > time() ) { $format = trim( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ) ); $when = function_exists( 'wp_date' ) ? wp_date( '' !== $format ? $format : 'Y-m-d H:i', $until ) : gmdate( 'Y-m-d H:i', $until ) . ' UTC'; return self::item( 'warning', sprintf( /* translators: %s: date and time. */ __( 'AJAX navigation is paused until %s. Pages load normally until then.', 'ajax-loading' ), (string) $when ) ); } return self::item( 'ok', __( 'Not paused.', 'ajax-loading' ) ); } /** * Build files present. * * @return array */ private static function check_assets(): array { $files = class_exists( 'WPAJXL_Frontend' ) ? WPAJXL_Frontend::asset_files() : null; if ( null === $files ) { return self::item( 'error', __( 'The front-end files in assets/dist/ are missing, so AJAX navigation cannot start. Reinstall the plugin (or run "npm run build" in a development copy).', 'ajax-loading' ) ); } return self::item( 'ok', sprintf( /* translators: 1: script file name, 2: stylesheet file name. */ __( 'Front-end files found (%1$s, %2$s).', 'ajax-loading' ), wp_basename( $files['js_path'] ), wp_basename( $files['css_path'] ) ) ); } /** * Permalink structure. * * @return array */ private static function check_permalinks(): array { $structure = (string) get_option( 'permalink_structure' ); if ( '' === $structure ) { return self::item( 'info', __( 'Plain permalinks are in use. AJAX navigation works, but pretty permalinks give more precise exclusions and are recommended.', 'ajax-loading' ) ); } return self::item( 'ok', sprintf( /* translators: %s: permalink structure, e.g. /%postname%/. */ __( 'Pretty permalinks are in use (%s).', 'ajax-loading' ), $structure ) ); } /** * Home and site URL consistency. * * @return array */ private static function check_site_urls(): array { $home = wp_parse_url( home_url( '/' ) ); $site = wp_parse_url( site_url( '/' ) ); if ( ! is_array( $home ) || ! is_array( $site ) || empty( $home['host'] ) ) { return self::item( 'error', __( 'The home URL could not be read. Check Settings → General.', 'ajax-loading' ) ); } $origin = static function ( array $parts ): string { $scheme = isset( $parts['scheme'] ) ? strtolower( (string) $parts['scheme'] ) : ''; $host = isset( $parts['host'] ) ? strtolower( (string) $parts['host'] ) : ''; $port = isset( $parts['port'] ) ? ':' . (int) $parts['port'] : ''; return $scheme . '://' . $host . $port; }; if ( $origin( $home ) !== $origin( $site ) ) { return self::item( 'warning', sprintf( /* translators: 1: home URL origin, 2: site URL origin. */ __( 'The home URL (%1$s) and the WordPress address (%2$s) use a different scheme, host or port. Links between them load normally; make both addresses match if that is not intended.', 'ajax-loading' ), $origin( $home ), $origin( $site ) ) ); } $path = isset( $home['path'] ) ? '/' . trim( (string) $home['path'], '/' ) : '/'; if ( '/' !== $path ) { return self::item( 'info', sprintf( /* translators: %s: URL path such as /blog. */ __( 'The site lives in a subdirectory (%s). Only links inside it use AJAX navigation.', 'ajax-loading' ), $path ) ); } return self::item( 'ok', __( 'The home URL and the WordPress address match.', 'ajax-loading' ) ); } /** * HTTPS configuration. * * @return array */ private static function check_https(): array { $home_scheme = strtolower( (string) wp_parse_url( home_url( '/' ), PHP_URL_SCHEME ) ); if ( 'https' !== $home_scheme ) { return self::item( 'info', __( 'The home URL uses HTTP. AJAX navigation works, but HTTPS is recommended (some browser features used for prefetching and analytics need a secure context).', 'ajax-loading' ) ); } if ( ! is_ssl() ) { return self::item( 'warning', __( 'The home URL uses HTTPS but this admin page was loaded over HTTP. If visitors can reach the site over HTTP, links are treated as another origin and load normally. Redirect HTTP to HTTPS (or check your proxy configuration).', 'ajax-loading' ) ); } return self::item( 'ok', __( 'The site uses HTTPS.', 'ajax-loading' ) ); } /** * Theme type and suggested content selector. * * @param array $settings Settings snapshot. * @return array */ private static function check_theme( array $settings ): array { $theme = wp_get_theme(); $name = $theme->exists() ? (string) $theme->get( 'Name' ) : (string) get_stylesheet(); $block = function_exists( 'wp_is_block_theme' ) && wp_is_block_theme(); $known = array( 'twentyten' => '#main', 'twentyeleven' => '#main', 'twentytwelve' => '#main', 'twentythirteen' => '#main', 'twentyfourteen' => '#main', 'twentyfifteen' => '#content', 'twentysixteen' => '#content', 'twentyseventeen' => '#content', 'twentynineteen' => '#content', 'twentytwenty' => '#site-content', 'twentytwentyone' => '#content', 'astra' => '#content', 'generatepress' => '#content', 'storefront' => '#content', 'hello-elementor' => '#content', 'neve' => '#content', 'oceanwp' => '#main', 'blocksy' => '#main', 'divi' => '#main-content', 'extra' => '#main-content', 'bricks' => '#brx-content', ); $template = strtolower( (string) get_template() ); $suggested = $block ? '.wp-site-blocks > main' : ( $known[ $template ] ?? '' ); $type = $block ? __( 'block theme', 'ajax-loading' ) : __( 'classic theme', 'ajax-loading' ); /* translators: 1: theme name, 2: "block theme" or "classic theme". */ $message = sprintf( __( '%1$s (%2$s).', 'ajax-loading' ), $name, $type ); $mode = (string) $settings['effective']['navigation_mode']; if ( 'custom' === $mode ) { $message .= ' ' . __( 'Custom content selectors are configured.', 'ajax-loading' ); } elseif ( '' !== $suggested ) { $message .= ' ' . sprintf( /* translators: %s: CSS selector. */ __( 'Auto-detect normally finds the content area; if it does not, use the custom content selector %s.', 'ajax-loading' ), $suggested ); } else { $message .= ' ' . __( 'Auto-detect looks for common content containers (#content, main, .site-main …). Use the content check on this page to confirm it finds the right one.', 'ajax-loading' ); } return self::item( 'info', $message ); } /** * Block theme layout class stability (WordPress < 6.8). * * @param array $settings Settings snapshot. * @return array|null */ private static function check_block_layout( array $settings ): ?array { $block = function_exists( 'wp_is_block_theme' ) && wp_is_block_theme(); if ( ! $block ) { return null; } if ( version_compare( self::wp_version(), '6.8', '>=' ) ) { return self::item( 'ok', __( 'Block layout class names are stable between pages (WordPress 6.8+).', 'ajax-loading' ) ); } if ( 'replace' === $settings['effective']['footer_mode'] ) { return self::item( 'ok', __( 'This WordPress version numbers block layout classes per page; the footer is replaced on every navigation, which keeps its styles correct.', 'ajax-loading' ) ); } return self::item( 'warning', __( 'Before WordPress 6.8 block layout class names in the footer can change from page to page, so a preserved footer may lose its layout styles. Set the footer mode to "Replace" or update WordPress.', 'ajax-loading' ) ); } /** * Whether a full-page cache is likely to serve stored copies of front-end pages (WP_CACHE or a * detected page caching plugin). Such copies keep the marker, engine and configuration they were * built with until the cache is purged. Plugins that only optimise assets do not count. * * @return bool */ public static function has_page_cache(): bool { try { if ( defined( 'WP_CACHE' ) && WP_CACHE ) { return true; } return array() !== array_diff( self::detected_caching_plugins(), array( 'Autoptimize', 'Perfmatters' ) ); } catch ( Throwable $e ) { return false; } } /** * Names of the detected page cache / optimisation plugins. * * @return string[] */ private static function detected_caching_plugins(): array { $found = self::detect_constants( self::caching_plugins() ); if ( class_exists( 'WpFastestCache', false ) ) { $found[] = 'WP Fastest Cache'; } if ( function_exists( 'wp_cache_phase2' ) || defined( 'WPCACHEHOME' ) ) { $found[] = 'WP Super Cache'; } if ( defined( 'SiteGround_Optimizer\VERSION' ) || class_exists( 'SiteGround_Optimizer\Loader\Loader', false ) ) { $found[] = 'SiteGround Speed Optimizer'; } return array_values( array_unique( $found ) ); } /** * Page cache / optimisation plugins. * * @return array */ private static function check_caching(): array { $found = self::detected_caching_plugins(); $advice = __( 'Purge the page cache after changing AJAX Loading settings. If the plugin delays, combines or defers JavaScript, exclude the AJAX Loading script (handle ajax-loading-WPajaxLoad, assets/dist/frontend.min.js) and its inline configuration from those features.', 'ajax-loading' ); if ( ! empty( $found ) ) { return self::item( 'info', sprintf( /* translators: %s: comma separated plugin names. */ __( 'Detected: %s.', 'ajax-loading' ), implode( ', ', $found ) ) . ' ' . $advice ); } if ( defined( 'WP_CACHE' ) && WP_CACHE ) { return self::item( 'info', __( 'A page cache (WP_CACHE) is active.', 'ajax-loading' ) . ' ' . $advice ); } return self::item( 'ok', __( 'No page cache or optimisation plugin detected.', 'ajax-loading' ) ); } /** * Other AJAX navigation / page transition plugins. * * @return array */ private static function check_navigation_conflicts(): array { $needles = array( 'pjax', 'swup', 'barba', 'turbolinks', 'advanced-ajax-page-loader', 'ajaxify-wordpress-site', 'ajax-page-loader', 'ajax-page-load', 'ajax-navigation', 'page-transition' ); $conflicts = array(); foreach ( self::active_plugin_slugs() as $slug ) { if ( 'ajax-loading' === $slug ) { continue; } foreach ( $needles as $needle ) { if ( false !== strpos( $slug, $needle ) ) { $conflicts[] = $slug; break; } } } if ( ! empty( $conflicts ) ) { return self::item( 'warning', sprintf( /* translators: %s: comma separated plugin folder names. */ __( 'These plugins may also intercept links or animate page changes: %s. Running two navigation engines causes double loads and broken history; deactivate one of them.', 'ajax-loading' ), implode( ', ', $conflicts ) ) ); } return self::item( 'ok', __( 'No other AJAX navigation or page transition plugin detected.', 'ajax-loading' ) ); } /** * Other link prefetching plugins. * * @param array $settings Settings snapshot. * @return array|null */ private static function check_prefetch_plugins( array $settings ): ?array { $needles = array( 'instant-page', 'instantpage', 'flying-pages', 'quicklink', 'instantclick' ); $found = array(); foreach ( self::active_plugin_slugs() as $slug ) { foreach ( $needles as $needle ) { if ( false !== strpos( $slug, $needle ) ) { $found[] = $slug; break; } } } if ( empty( $found ) ) { return null; } $list = implode( ', ', $found ); if ( ! empty( $settings['effective']['prefetch_enabled'] ) ) { return self::item( 'warning', sprintf( /* translators: %s: comma separated plugin folder names. */ __( 'Link prefetching is enabled here and in %s. Pages may be requested twice; use only one of them.', 'ajax-loading' ), $list ) ); } return self::item( 'info', sprintf( /* translators: %s: comma separated plugin folder names. */ __( 'Link prefetching is handled by %s. That works together with AJAX navigation; keep the AJAX Loading prefetch option off.', 'ajax-loading' ), $list ) ); } /** * Core speculative loading (WordPress 6.8+). * * @param array $settings Settings snapshot. * @return array */ private static function check_speculative_loading( array $settings ): array { if ( ! function_exists( 'wp_get_speculation_rules_configuration' ) ) { return self::item( 'info', __( 'Not available in this WordPress version (added in 6.8).', 'ajax-loading' ) ); } $note = '' === (string) get_option( 'permalink_structure' ) ? ' ' . __( 'WordPress only uses it with pretty permalinks, so it is currently inactive anyway.', 'ajax-loading' ) : ''; if ( defined( 'SPECULATION_RULES_VERSION' ) ) { $note .= ' ' . __( 'The Speculative Loading plugin is active and may change these rules.', 'ajax-loading' ); } if ( ! empty( $settings['effective']['disable_core_speculation'] ) ) { return self::item( 'ok', __( 'Turned off on pages that use AJAX navigation (so links are not requested twice) and left on for pages that load normally.', 'ajax-loading' ) . $note ); } return self::item( 'info', __( 'Left on while AJAX navigation is active. With the default "conservative" eagerness the browser may request a link at the same moment AJAX navigation fetches it.', 'ajax-loading' ) . $note ); } /** * Detected integrations and their modes. * * @param array $settings Settings snapshot. * @return array */ private static function check_integrations( array $settings ): array { $modes = self::normalize_modes( $settings['effective']['integrations'] ); $labels = self::mode_labels(); $parts = array(); foreach ( self::integrations() as $key => $integration ) { if ( ! $integration['active'] || in_array( $key, array( 'embeds', 'post_load_event' ), true ) ) { continue; } $label = $integration['label'] . ( '' !== $integration['version'] ? ' ' . $integration['version'] : '' ); $parts[] = $label . ': ' . ( $labels[ $modes[ $key ] ] ?? $modes[ $key ] ); } if ( empty( $parts ) ) { return self::item( 'ok', __( 'No page builder, form, shop or membership plugin with a dedicated integration was detected.', 'ajax-loading' ) ); } return self::item( 'info', implode( '; ', $parts ) . '.' ); } /** * Elementor Pro Page Transitions. * * @return array|null */ private static function check_elementor_pro(): ?array { if ( ! defined( 'ELEMENTOR_PRO_VERSION' ) ) { return null; } $enabled = false; $kit_id = absint( get_option( 'elementor_active_kit' ) ); if ( $kit_id ) { $kit = get_post_meta( $kit_id, '_elementor_page_settings', true ); if ( is_array( $kit ) ) { foreach ( $kit as $name => $value ) { if ( 0 === strpos( (string) $name, 'page_transitions_' ) && ! empty( $value ) && 'none' !== $value ) { $enabled = true; break; } } } } if ( $enabled ) { return self::item( 'warning', __( 'Elementor Pro Page Transitions appear to be enabled (Site Settings → Page Transitions). Both features intercept link clicks; turn one of them off.', 'ajax-loading' ) ); } return self::item( 'ok', __( 'Elementor Pro is active and its Page Transitions do not appear to be enabled. Keep them off while AJAX navigation is on.', 'ajax-loading' ) ); } /** * Divi 5 detection. * * @return array|null */ private static function check_divi5(): ?array { $version = self::divi_version(); if ( null === $version ) { return null; } if ( (int) $version >= 5 ) { return self::item( 'warning', __( 'Divi 5 has no verified script API for AJAX navigation yet. Navigation to pages built with Divi falls back to normal page loads.', 'ajax-loading' ) ); } return self::item( 'ok', sprintf( /* translators: %s: version number. */ __( 'Divi %s detected; the Divi integration re-initialises modules after navigation.', 'ajax-loading' ), $version ) ); } /** * Ninja Forms / ACF forms / BuddyPress. * * @param array $settings Settings snapshot. * @return array|null */ private static function check_excluded_plugins( array $settings ): ?array { $modes = self::normalize_modes( $settings['effective']['integrations'] ); $integrations = self::integrations(); $excluded = array(); $ignored = array(); foreach ( array( 'ninja_forms', 'acf_forms', 'buddypress' ) as $key ) { if ( empty( $integrations[ $key ]['active'] ) ) { continue; } if ( 'exclude' === $modes[ $key ] ) { $excluded[] = $integrations[ $key ]['label']; } else { $ignored[] = $integrations[ $key ]['label']; } } if ( empty( $excluded ) && empty( $ignored ) ) { return null; } if ( ! empty( $ignored ) ) { return self::item( 'warning', sprintf( /* translators: %s: comma separated plugin names. */ __( 'The integration for %s is set to "Off". Their scripts cannot start again after navigation, so forms or pages may stop working; set it to "Exclude".', 'ajax-loading' ), implode( ', ', $ignored ) ) ); } return self::item( 'info', sprintf( /* translators: %s: comma separated plugin names. */ __( 'Pages with %s content load normally because those plugins cannot be started again after navigation.', 'ajax-loading' ), implode( ', ', $excluded ) ) ); } /** * Multilingual plugins. * * @param array $settings Settings snapshot. * @return array|null */ private static function check_multilingual( array $settings ): ?array { $plugins = self::multilingual_plugins(); if ( empty( $plugins ) ) { return null; } $list = implode( ', ', $plugins ); if ( 'off' === self::mode( $settings['effective'], 'multilingual' ) ) { return self::item( 'warning', sprintf( /* translators: %s: comma separated plugin names. */ __( '%s detected but the multilingual integration is off: switching language through AJAX navigation can leave menus, cookies and translations in the previous language.', 'ajax-loading' ), $list ) ); } return self::item( 'info', sprintf( /* translators: %s: comma separated plugin names. */ __( '%s detected: changing language always uses a normal page load.', 'ajax-loading' ), $list ) ); } /** * Interactivity API blocks. * * @param array $settings Settings snapshot. * @return array|null */ private static function check_interactivity( array $settings ): ?array { if ( ! function_exists( 'wp_interactivity' ) && ! function_exists( 'wp_interactivity_state' ) ) { return null; } if ( 'replace' === $settings['effective']['interactive_blocks'] ) { return self::item( 'warning', __( 'Interactive blocks (navigation overlay, image lightbox, search button, accordion, tabs, many WooCommerce blocks) are swapped in without being started; they will not respond until the next normal page load. "Reload" is recommended.', 'ajax-loading' ) ); } return self::item( 'ok', __( 'Pages whose new content contains interactive blocks (navigation overlay, image lightbox, accordion, many WooCommerce blocks) use a normal page load, because WordPress cannot start those blocks again.', 'ajax-loading' ) ); } /** * Query Loop enhanced pagination (Interactivity router) usage. * * @return array|null */ private static function check_interactivity_router(): ?array { if ( ! function_exists( 'wp_interactivity' ) && ! function_exists( 'wp_interactivity_state' ) ) { return null; } global $wpdb; $like = '%' . $wpdb->esc_like( '"enhancedPagination":true' ) . '%'; $count = (int) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- one-off diagnostic count, never on the front end. $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_type IN ('page','post','wp_template','wp_template_part','wp_block') AND post_status IN ('publish','private') AND post_content LIKE %s", $like ) ); if ( $count > 0 ) { return self::item( 'info', sprintf( /* translators: %d: number of templates/pages. */ _n( '%d template or page uses a Query Loop with enhanced pagination ("Force page reload" off). AJAX navigation stays inactive on pages where that loads the Interactivity router, because the router reloads the page on Back/Forward.', '%d templates or pages use a Query Loop with enhanced pagination ("Force page reload" off). AJAX navigation stays inactive on pages where that loads the Interactivity router, because the router reloads the page on Back/Forward.', $count, 'ajax-loading' ), $count ) ); } return self::item( 'ok', __( 'No Query Loop with enhanced pagination found in saved templates or pages.', 'ajax-loading' ) ); } /** * Settings snapshot used by the report (tolerates a missing settings class). * * @return array{stored:bool,all:array,effective:array} */ private static function settings_snapshot(): array { $all = array(); $effective = array(); if ( class_exists( 'WPAJXL_Settings' ) ) { try { $instance = WPAJXL_Settings::instance(); $all = (array) $instance->all(); $effective = (array) $instance->effective(); } catch ( Throwable $e ) { $all = array(); $effective = array(); } } $defaults = array( 'enable_plugin' => 'off', 'pause_until' => 0, 'visibility' => 'everyone', 'safe_mode' => false, 'navigation_mode' => 'auto', 'footer_mode' => 'preserve', 'interactive_blocks' => 'reload', 'prefetch_enabled' => false, 'disable_core_speculation' => true, 'legacy_jquery' => false, 'integrations' => array(), ); return array( 'stored' => false !== get_option( '_wp_ajax_loading', false ), 'all' => array_merge( $defaults, $all ), 'effective' => array_merge( $defaults, $effective ), ); } /** * Builds a report item. * * @param string $status ok|info|warning|error. * @param string $message Message. * @return array{status:string,message:string} */ private static function item( string $status, string $message ): array { return array( 'status' => $status, 'message' => $message, ); } /** * Running WordPress version. * * @return string */ private static function wp_version(): string { return (string) get_bloginfo( 'version' ); } /** * Value of a constant as string, or ''. * * @param string $name Constant name. * @return string */ private static function constant( string $name ): string { if ( ! defined( $name ) ) { return ''; } $value = constant( $name ); return is_scalar( $value ) ? (string) $value : ''; } /** * Names of the products whose constant is defined. * * @param array $map Constant => product name. * @return string[] */ private static function detect_constants( array $map ): array { $found = array(); foreach ( $map as $constant => $name ) { if ( defined( $constant ) ) { $found[] = $name; } } return $found; } /** * Caching / optimisation plugin constants. * * @return array */ private static function caching_plugins(): array { return array( 'WP_ROCKET_VERSION' => 'WP Rocket', 'W3TC_VERSION' => 'W3 Total Cache', 'LSCWP_V' => 'LiteSpeed Cache', 'CACHE_ENABLER_VERSION' => 'Cache Enabler', 'WPHB_VERSION' => 'Hummingbird', 'BREEZE_VERSION' => 'Breeze', 'AUTOPTIMIZE_PLUGIN_VERSION' => 'Autoptimize', 'FLYING_PRESS_VERSION' => 'FlyingPress', 'NITROPACK_VERSION' => 'NitroPack', 'WPO_VERSION' => 'WP-Optimize', 'PERFMATTERS_VERSION' => 'Perfmatters', 'COMET_CACHE_VERSION' => 'Comet Cache', 'SWCFPC_VERSION' => 'Super Page Cache', ); } /** * Lazy-loading plugin constants. * * @return array */ private static function lazyload_plugins(): array { return array( 'WP_ROCKET_VERSION' => 'WP Rocket', 'ROCKET_LL_VERSION' => 'Lazy Load by WP Rocket', 'A3_LAZY_VERSION' => 'a3 Lazy Load', 'WP_SMUSH_VERSION' => 'Smush', 'EWWW_IMAGE_OPTIMIZER_VERSION' => 'EWWW Image Optimizer', 'LSCWP_V' => 'LiteSpeed Cache', 'AUTOPTIMIZE_PLUGIN_VERSION' => 'Autoptimize', 'PERFMATTERS_VERSION' => 'Perfmatters', 'OPTML_VERSION' => 'Optimole', 'FLYING_PRESS_VERSION' => 'FlyingPress', ); } /** * Active multilingual plugins. * * @return string[] */ private static function multilingual_plugins(): array { $found = self::detect_constants( array( 'ICL_SITEPRESS_VERSION' => 'WPML', 'POLYLANG_VERSION' => 'Polylang', 'TRP_PLUGIN_VERSION' => 'TranslatePress', 'WEGLOT_VERSION' => 'Weglot', 'QTX_VERSION' => 'qTranslate-XT', ) ); if ( class_exists( 'Inpsyde\MultilingualPress\MultilingualPress', false ) ) { $found[] = 'MultilingualPress'; } return $found; } /** * Divi (theme, Extra or Divi Builder plugin) version, or null when Divi is not active. * * @return string|null */ private static function divi_version(): ?string { if ( array_key_exists( 'divi_version', self::$cache ) ) { return self::$cache['divi_version']; } $version = null; $template = strtolower( (string) get_template() ); if ( in_array( $template, array( 'divi', 'extra' ), true ) ) { $theme = wp_get_theme( get_template() ); $version = $theme->exists() ? (string) $theme->get( 'Version' ) : ''; } elseif ( defined( 'ET_BUILDER_PLUGIN_VERSION' ) ) { $version = self::constant( 'ET_BUILDER_PLUGIN_VERSION' ); } elseif ( defined( 'ET_BUILDER_VERSION' ) ) { $version = self::constant( 'ET_BUILDER_VERSION' ); } self::$cache['divi_version'] = $version; return $version; } /** * Lower-case template and stylesheet slugs. * * @return string[] */ private static function theme_slugs(): array { return array_values( array_unique( array( strtolower( (string) get_template() ), strtolower( (string) get_stylesheet() ) ) ) ); } /** * Folder names (or file names) of active plugins, including network-activated ones. * * @return string[] */ private static function active_plugin_slugs(): array { if ( isset( self::$cache['plugin_slugs'] ) ) { return self::$cache['plugin_slugs']; } $plugins = get_option( 'active_plugins', array() ); $plugins = is_array( $plugins ) ? $plugins : array(); if ( is_multisite() ) { $network = get_site_option( 'active_sitewide_plugins', array() ); if ( is_array( $network ) ) { $plugins = array_merge( $plugins, array_keys( $network ) ); } } $slugs = array(); foreach ( $plugins as $plugin ) { if ( ! is_string( $plugin ) || '' === $plugin ) { continue; } $dir = dirname( $plugin ); $slugs[] = strtolower( '.' === $dir ? basename( $plugin, '.php' ) : $dir ); } self::$cache['plugin_slugs'] = array_values( array_unique( $slugs ) ); return self::$cache['plugin_slugs']; } }