hooks_registered ) { return; } $this->hooks_registered = true; add_action( 'update_option_' . self::OPTION, array( $this, 'handle_option_updated' ), 10, 2 ); add_action( 'add_option_' . self::OPTION, array( $this, 'handle_option_added' ), 10, 2 ); } /** * `update_option_{option}` callback: fires `ajax_loading_settings_updated`. * * @param mixed $old_value Previous raw value. * @param mixed $value New raw value. * @return void */ public function handle_option_updated( $old_value, $value ): void { $this->flush_cache(); /** * Fires after the AJAX Loading settings changed. * * @since 2.0.0 * * @param array $old_value Previous settings. * @param array $value New settings. */ do_action( 'ajax_loading_settings_updated', is_array( $old_value ) ? $old_value : array(), is_array( $value ) ? $value : array() ); } /** * `add_option_{option}` callback: fires `ajax_loading_settings_updated`. * * @param string $option Option name. * @param mixed $value New raw value. * @return void */ public function handle_option_added( $option, $value ): void { $this->handle_option_updated( array(), $value ); } /** * Clears memoised schema and settings. * * @return void */ public function flush_cache(): void { $this->schema_cache = null; $this->cache_stored = null; $this->cache_raw = null; } /* * --------------------------------------------------------------------- * Schema * --------------------------------------------------------------------- */ /** * Returns the settings schema keyed by setting name. * * Each entry has `type`, `default`, `tab` and optionally `choices`, `min`, * `max`, `step`, `format`, `pattern`, `max_length`, `required`, `section`. * For `map` fields `choices` maps each sub key to its allowed values. * * @return array */ public function schema(): array { if ( null !== $this->schema_cache ) { return $this->schema_cache; } /** * Filters the settings schema. * * Built-in keys keep their type; invalid entries are discarded. * * @since 2.0.0 * * @param array $schema Schema keyed by setting name. */ $schema = $this->validate_schema( apply_filters( 'ajax_loading_settings_schema', $this->base_schema() ) ); // Read from an `ajax_loading_integrations` callback while the schema is built: incomplete, never memoised. if ( $this->merging_integrations ) { return $schema; } // Filters are registered by the end of `init` (plugins and themes may add them from an init // callback, and the front end reads the settings from init:10 on): memoise only after it. if ( self::init_done() ) { $schema = $this->with_registered_integrations( $schema ); $this->schema_cache = $schema; } return $schema; } /** * Whether `init` has completed: every init callback ran, so every filter added from one is registered. * * @return bool */ public static function init_done(): bool { return did_action( 'init' ) > 0 && ! doing_action( 'init' ); } /** * Adds integrations registered through the `ajax_loading_integrations` filter to the `integrations` * map, so the mode chosen for them on the SEO & Integrations tab is saved. Keys already in the * schema keep their schema choices and default. * * Only called once `init` has completed (the registry is complete then), and only when the registry is filtered: * every built-in integration is already part of the schema. * * @param array $schema Validated schema. * @return array */ private function with_registered_integrations( array $schema ): array { if ( ! has_filter( 'ajax_loading_integrations' ) || ! class_exists( 'WPAJXL_Compat' ) || ! isset( $schema['integrations'] ) || 'map' !== $schema['integrations']['type'] ) { return $schema; } // A registry filter may read the settings; never recurse into the registry from there. $this->merging_integrations = true; try { $registry = WPAJXL_Compat::integrations(); } catch ( Throwable $e ) { $registry = array(); } finally { $this->merging_integrations = false; } foreach ( $registry as $key => $info ) { if ( ! is_string( $key ) || '' === $key || isset( $schema['integrations']['choices'][ $key ] ) || ! is_array( $info ) || empty( $info['modes'] ) || ! is_array( $info['modes'] ) ) { continue; } $modes = array_values( array_intersect( array_map( 'strval', $info['modes'] ), WPAJXL_Compat::MODES ) ); if ( empty( $modes ) ) { continue; } $default = isset( $info['default'] ) && in_array( (string) $info['default'], $modes, true ) ? (string) $info['default'] : $modes[0]; $schema['integrations']['choices'][ $key ] = $modes; $schema['integrations']['default'][ $key ] = $default; } return $schema; } /** * Returns the default value of every setting. * * @return array */ public function defaults(): array { $defaults = array(); foreach ( $this->schema() as $key => $field ) { $defaults[ $key ] = $field['default']; } return $defaults; } /** * Returns the keys rendered on a settings tab. * * @param string $tab Tab slug. * @return string[] */ public function fields_for_tab( string $tab ): array { $keys = array(); foreach ( $this->schema() as $key => $field ) { if ( $field['tab'] === $tab ) { $keys[] = $key; } } return $keys; } /** * Returns the keys that are never taken from form input. * * @return string[] */ public function hidden_keys(): array { return $this->fields_for_tab( self::HIDDEN_TAB ); } /** * Returns the allowed modes of one integration key. * * @param string $key Integration key. * @return string[] Empty when the key is unknown. */ public function integration_modes( string $key ): array { $schema = $this->schema(); if ( isset( $schema['integrations']['choices'][ $key ] ) ) { return $schema['integrations']['choices'][ $key ]; } return array(); } /** * Builds the built-in schema. * * @return array */ private function base_schema(): array { if ( null !== $this->base_schema ) { return $this->base_schema; } $loader_types = array( 'bar', 'spinner', 'overlay', 'text', 'logo', 'skeleton', 'none' ); $transitions = array( 'fade', 'fade-through', 'slide-left', 'slide-right', 'slide-up', 'slide-down', 'scale', 'smooth', 'none' ); $all_modes = array( 'auto', 'exclude', 'off' ); $exclude_modes = array( 'exclude', 'off' ); $integration_choices = array( 'woocommerce' => $all_modes, 'elementor' => $all_modes, 'divi' => $all_modes, 'beaver_builder' => $all_modes, 'bricks' => $all_modes, 'contact_form_7' => $all_modes, 'gravity_forms' => $all_modes, 'wpforms' => $all_modes, 'fluent_forms' => $all_modes, 'formidable' => $all_modes, 'ninja_forms' => $exclude_modes, 'acf_forms' => $exclude_modes, 'buddypress' => $exclude_modes, 'embeds' => $all_modes, 'lazyload' => $all_modes, 'comment_reply' => $all_modes, 'post_load_event' => $all_modes, 'multilingual' => $all_modes, ); $integration_defaults = array(); foreach ( $integration_choices as $key => $modes ) { $integration_defaults[ $key ] = in_array( 'auto', $modes, true ) ? 'auto' : 'exclude'; } $this->base_schema = array( // General. 'enable_plugin' => array( 'type' => 'onoff', 'default' => 'on', 'tab' => 'general', ), 'settings_version' => array( 'type' => 'int', 'default' => self::SCHEMA_VERSION, 'tab' => self::HIDDEN_TAB, 'min' => 1, 'max' => 1000, ), 'visibility' => array( 'type' => 'enum', 'default' => 'everyone', 'tab' => 'general', 'choices' => array( 'everyone', 'admins' ), ), 'safe_mode' => array( 'type' => 'bool', 'default' => false, 'tab' => 'tools', ), 'pause_until' => array( 'type' => 'int', 'default' => 0, 'tab' => self::HIDDEN_TAB, 'min' => 0, 'max' => PHP_INT_MAX, ), 'enable_desktop' => array( 'type' => 'bool', 'default' => true, 'tab' => 'general', ), 'enable_mobile' => array( 'type' => 'bool', 'default' => true, 'tab' => 'general', ), 'mobile_breakpoint' => array( 'type' => 'int', 'default' => 768, 'tab' => 'general', 'min' => 320, 'max' => 2000, ), 'navigation_mode' => array( 'type' => 'enum', 'default' => 'auto', 'tab' => 'general', 'choices' => array( 'auto', 'custom', 'body' ), ), 'content_selectors' => array( 'type' => 'lines', 'default' => '', 'tab' => 'general', 'format' => 'selectors', ), 'header_selector' => array( 'type' => 'text', 'default' => '', 'tab' => 'general', 'format' => 'selector', 'max_length' => self::LINE_MAX_CHARS, ), 'footer_selector' => array( 'type' => 'text', 'default' => '', 'tab' => 'general', 'format' => 'selector', 'max_length' => self::LINE_MAX_CHARS, ), 'header_mode' => array( 'type' => 'enum', 'default' => 'preserve', 'tab' => 'general', 'choices' => array( 'preserve', 'replace' ), ), 'footer_mode' => array( 'type' => 'enum', 'default' => 'preserve', 'tab' => 'general', 'choices' => array( 'preserve', 'replace' ), ), 'handle_404' => array( 'type' => 'bool', 'default' => true, 'tab' => 'general', ), 'forms_search' => array( 'type' => 'bool', 'default' => true, 'tab' => 'general', ), 'form_selectors' => array( 'type' => 'lines', 'default' => '', 'tab' => 'general', 'format' => 'selectors', ), 'execute_content_scripts' => array( 'type' => 'bool', 'default' => true, 'tab' => 'general', 'section' => 'advanced', ), 'refresh_localized_data' => array( 'type' => 'bool', 'default' => true, 'tab' => 'general', 'section' => 'advanced', ), 'run_new_inline_scripts' => array( 'type' => 'bool', 'default' => true, 'tab' => 'general', 'section' => 'advanced', ), 'dispatch_dom_events' => array( 'type' => 'bool', 'default' => false, 'tab' => 'general', 'section' => 'advanced', ), 'remove_stale_styles' => array( 'type' => 'bool', 'default' => false, 'tab' => 'general', 'section' => 'advanced', ), 'disable_emojis' => array( 'type' => 'bool', 'default' => false, 'tab' => 'general', 'section' => 'advanced', ), 'legacy_jquery' => array( 'type' => 'bool', 'default' => false, 'tab' => 'general', 'section' => 'advanced', ), // Exclusions. 'exclude_urls' => array( 'type' => 'lines', 'default' => '', 'tab' => 'exclusions', 'format' => 'patterns', ), 'exclude_selectors' => array( 'type' => 'lines', 'default' => '', 'tab' => 'exclusions', 'format' => 'selectors', ), 'exclude_post_types' => array( 'type' => 'post_types', 'default' => array(), 'tab' => 'exclusions', ), 'exclude_woocommerce' => array( 'type' => 'bool', 'default' => true, 'tab' => 'exclusions', ), 'exclude_membership' => array( 'type' => 'bool', 'default' => true, 'tab' => 'exclusions', ), 'interactive_blocks' => array( 'type' => 'enum', 'default' => 'reload', 'tab' => 'exclusions', 'choices' => array( 'reload', 'replace' ), ), // SEO & integrations. 'integrations' => array( 'type' => 'map', 'default' => $integration_defaults, 'tab' => 'seo', 'choices' => $integration_choices, ), 'sync_metadata' => array( 'type' => 'bool', 'default' => true, 'tab' => 'seo', ), 'sync_canonical' => array( 'type' => 'bool', 'default' => true, 'tab' => 'seo', ), 'sync_structured_data' => array( 'type' => 'bool', 'default' => true, 'tab' => 'seo', ), 'analytics_mode' => array( 'type' => 'enum', 'default' => 'none', 'tab' => 'seo', 'choices' => array( 'none', 'gtag', 'datalayer' ), ), 'analytics_timing' => array( 'type' => 'bool', 'default' => false, 'tab' => 'seo', ), 'gtm_event_name' => array( 'type' => 'text', 'default' => 'ajax_page_view', 'tab' => 'seo', 'pattern' => '/^[A-Za-z0-9_.\-]{1,64}\z/', 'max_length' => 64, 'required' => true, ), 'analytics_require_consent' => array( 'type' => 'bool', 'default' => false, 'tab' => 'seo', ), 'analytics_callback' => array( 'type' => 'text', 'default' => '', 'tab' => 'seo', 'pattern' => '/^[A-Za-z_$][A-Za-z0-9_$]*(\.[A-Za-z_$][A-Za-z0-9_$]*)*\z/', 'max_length' => 100, ), // Performance. 'disable_core_speculation' => array( 'type' => 'bool', 'default' => true, 'tab' => 'performance', ), // Loading experience. 'loader_type' => array( 'type' => 'enum', 'default' => 'bar', 'tab' => 'loading', 'choices' => $loader_types, ), 'mobile_loader_type' => array( 'type' => 'enum', 'default' => 'inherit', 'tab' => 'loading', 'choices' => array_merge( array( 'inherit' ), $loader_types ), ), 'loader_color' => array( 'type' => 'color', 'default' => '#2271b1', 'tab' => 'loading', ), 'loader_bg' => array( 'type' => 'color', 'default' => '#ffffff', 'tab' => 'loading', ), 'loader_size' => array( 'type' => 'int', 'default' => 40, 'tab' => 'loading', 'min' => 8, 'max' => 200, ), 'bar_height' => array( 'type' => 'int', 'default' => 3, 'tab' => 'loading', 'min' => 1, 'max' => 12, ), 'bar_position' => array( 'type' => 'enum', 'default' => 'top', 'tab' => 'loading', 'choices' => array( 'top', 'bottom' ), ), 'loader_speed' => array( 'type' => 'int', 'default' => 900, 'tab' => 'loading', 'min' => 200, 'max' => 5000, ), 'overlay_opacity' => array( 'type' => 'float', 'default' => 0.7, 'tab' => 'loading', 'min' => 0, 'max' => 1, 'step' => 0.05, ), 'loader_delay' => array( 'type' => 'int', 'default' => 150, 'tab' => 'loading', 'min' => 0, 'max' => 2000, ), 'loader_min_duration' => array( 'type' => 'int', 'default' => 250, 'tab' => 'loading', 'min' => 0, 'max' => 3000, ), 'loader_position' => array( 'type' => 'enum', 'default' => 'center', 'tab' => 'loading', 'choices' => array( 'center', 'top-left', 'top-right', 'bottom-left', 'bottom-right' ), ), 'loader_zindex' => array( 'type' => 'int', 'default' => 99999, 'tab' => 'loading', 'min' => 1, 'max' => 2147483647, ), 'loader_text' => array( 'type' => 'text', 'default' => '', 'tab' => 'loading', 'max_length' => 100, ), 'loader_logo_id' => array( 'type' => 'int', 'default' => 0, 'tab' => 'loading', 'min' => 0, 'max' => PHP_INT_MAX, ), 'transition_out' => array( 'type' => 'enum', 'default' => 'fade', 'tab' => 'loading', 'choices' => $transitions, ), 'transition_in' => array( 'type' => 'enum', 'default' => 'fade', 'tab' => 'loading', 'choices' => $transitions, ), 'transition_duration' => array( 'type' => 'int', 'default' => 250, 'tab' => 'loading', 'min' => 0, 'max' => 2000, ), 'transition_easing' => array( 'type' => 'enum', 'default' => 'ease', 'tab' => 'loading', 'choices' => array( 'ease', 'ease-in-out', 'ease-out', 'linear', 'snappy' ), ), 'transition_header_footer' => array( 'type' => 'bool', 'default' => false, 'tab' => 'loading', ), 'mobile_transition' => array( 'type' => 'enum', 'default' => 'inherit', 'tab' => 'loading', 'choices' => array( 'inherit', 'none', 'fade' ), ), 'respect_reduced_motion' => array( 'type' => 'bool', 'default' => true, 'tab' => 'loading', ), 'scroll_behavior' => array( 'type' => 'enum', 'default' => 'top', 'tab' => 'loading', 'choices' => array( 'top', 'content', 'none' ), ), 'focus_target' => array( 'type' => 'enum', 'default' => 'heading', 'tab' => 'loading', 'choices' => array( 'heading', 'container', 'none' ), ), 'announce_navigation' => array( 'type' => 'bool', 'default' => true, 'tab' => 'loading', ), // Performance (continued). 'prefetch_enabled' => array( 'type' => 'bool', 'default' => false, 'tab' => 'performance', ), 'prefetch_triggers' => array( 'type' => 'multi', 'default' => array( 'hover', 'focus', 'touch' ), 'tab' => 'performance', 'choices' => array( 'hover', 'focus', 'touch', 'viewport' ), ), 'prefetch_hover_delay' => array( 'type' => 'int', 'default' => 80, 'tab' => 'performance', 'min' => 0, 'max' => 1000, ), 'prefetch_max_concurrent' => array( 'type' => 'int', 'default' => 2, 'tab' => 'performance', 'min' => 1, 'max' => 6, ), 'prefetch_viewport_limit' => array( 'type' => 'int', 'default' => 6, 'tab' => 'performance', 'min' => 1, 'max' => 30, ), 'prefetch_exclude' => array( 'type' => 'lines', 'default' => '', 'tab' => 'performance', 'format' => 'patterns', ), 'network_aware' => array( 'type' => 'bool', 'default' => true, 'tab' => 'performance', ), 'cache_enabled' => array( 'type' => 'bool', 'default' => false, 'tab' => 'performance', ), 'cache_max_pages' => array( 'type' => 'int', 'default' => 10, 'tab' => 'performance', 'min' => 1, 'max' => 50, ), 'cache_ttl' => array( 'type' => 'int', 'default' => 300, 'tab' => 'performance', 'min' => 10, 'max' => 3600, ), 'cache_max_kb' => array( 'type' => 'int', 'default' => 4096, 'tab' => 'performance', 'min' => 256, 'max' => 20480, ), 'request_timeout' => array( 'type' => 'int', 'default' => 10000, 'tab' => 'performance', 'min' => 2000, 'max' => 60000, ), 'cache_version' => array( 'type' => 'int', 'default' => 1, 'tab' => self::HIDDEN_TAB, 'min' => 1, 'max' => self::MAX_CACHE_VERSION, ), 'preserve_scroll' => array( 'type' => 'bool', 'default' => true, 'tab' => 'performance', ), 'preserve_form_state' => array( 'type' => 'bool', 'default' => false, 'tab' => 'performance', ), 'sync_menu_state' => array( 'type' => 'bool', 'default' => true, 'tab' => 'performance', ), // Tools & diagnostics. 'debug_mode' => array( 'type' => 'bool', 'default' => false, 'tab' => 'tools', ), 'error_logging' => array( 'type' => 'bool', 'default' => false, 'tab' => 'tools', ), 'auto_disable' => array( 'type' => 'bool', 'default' => true, 'tab' => 'tools', ), 'auto_disable_threshold' => array( 'type' => 'int', 'default' => 3, 'tab' => 'tools', 'min' => 2, 'max' => 20, ), 'auto_disable_minutes' => array( 'type' => 'int', 'default' => 30, 'tab' => 'tools', 'min' => 1, 'max' => 1440, ), ); return $this->base_schema; } /** * Validates a (filtered) schema; built-in keys always survive with their type. * * @param mixed $schema Candidate schema. * @return array */ private function validate_schema( $schema ): array { $base = $this->base_schema(); if ( ! is_array( $schema ) ) { $schema = array(); } $result = array(); foreach ( $base as $key => $field ) { $candidate = ( isset( $schema[ $key ] ) && is_array( $schema[ $key ] ) ) ? $this->normalize_field( $schema[ $key ] ) : null; if ( null === $candidate || $candidate['type'] !== $field['type'] ) { $candidate = $this->normalize_field( $field ); } if ( null !== $candidate ) { $result[ $key ] = $candidate; } } foreach ( $schema as $key => $field ) { if ( isset( $result[ $key ] ) || ! is_string( $key ) || '' === $key || ! is_array( $field ) ) { continue; } if ( sanitize_key( $key ) !== $key || '_ajxl_tab' === $key ) { continue; } $candidate = $this->normalize_field( $field ); if ( null !== $candidate ) { $result[ $key ] = $candidate; } } return $result; } /** * Normalises one schema entry; returns null when it is unusable. * * @param array $field Schema entry. * @return array|null */ private function normalize_field( array $field ) { if ( ! isset( $field['type'] ) || ! in_array( $field['type'], self::TYPES, true ) || ! array_key_exists( 'default', $field ) ) { return null; } $field['tab'] = ( isset( $field['tab'] ) && is_string( $field['tab'] ) && '' !== $field['tab'] ) ? sanitize_key( $field['tab'] ) : 'general'; switch ( $field['type'] ) { case 'enum': case 'multi': $choices = array(); if ( isset( $field['choices'] ) && is_array( $field['choices'] ) ) { foreach ( $field['choices'] as $choice ) { if ( is_string( $choice ) && '' !== $choice ) { $choices[] = strtolower( $choice ); } } } $choices = array_values( array_unique( $choices ) ); if ( empty( $choices ) ) { return null; } $field['choices'] = $choices; break; case 'map': $choices = array(); if ( isset( $field['choices'] ) && is_array( $field['choices'] ) ) { foreach ( $field['choices'] as $sub_key => $allowed ) { if ( ! is_string( $sub_key ) || '' === $sub_key || ! is_array( $allowed ) ) { continue; } $modes = array(); foreach ( $allowed as $mode ) { if ( is_string( $mode ) && '' !== $mode ) { $modes[] = strtolower( $mode ); } } $modes = array_values( array_unique( $modes ) ); if ( ! empty( $modes ) ) { $choices[ $sub_key ] = $modes; } } } if ( empty( $choices ) || ! is_array( $field['default'] ) ) { return null; } $field['choices'] = $choices; break; case 'int': case 'float': foreach ( array( 'min', 'max', 'step' ) as $bound ) { if ( isset( $field[ $bound ] ) && ! is_int( $field[ $bound ] ) && ! is_float( $field[ $bound ] ) ) { unset( $field[ $bound ] ); } } break; case 'text': if ( isset( $field['pattern'] ) && ( ! is_string( $field['pattern'] ) || '' === $field['pattern'] ) ) { unset( $field['pattern'] ); } break; } // Defaults are trusted code: validate them outside of any input validation run. $validating = $this->validating_input; $this->validating_input = false; $default = $this->sanitize_value( $field['default'], $field, null ); $this->validating_input = $validating; if ( null === $default ) { if ( 'enum' !== $field['type'] ) { return null; } $default = $field['choices'][0]; } $field['default'] = $default; return $field; } /* * --------------------------------------------------------------------- * Reading * --------------------------------------------------------------------- */ /** * Returns the stored settings merged over the defaults, normalised to * schema types, without the `ajax_loading_settings` filter. * * A missing option, or a stored array without `enable_plugin`, reads as * disabled: only an explicit 'on' turns the plugin on (1.x semantics). * Unknown keys are preserved for forward compatibility. * * @return array */ public function stored(): array { $raw = get_option( self::OPTION, null ); $after_init = self::init_done(); if ( null !== $this->cache_stored && $raw === $this->cache_raw && $after_init === $this->cache_after_init ) { return $this->cache_stored; } $schema = $this->schema(); $stored = array(); if ( null === $raw ) { foreach ( $schema as $key => $field ) { $stored[ $key ] = $field['default']; } $stored['enable_plugin'] = 'off'; } else { // A 1.x value not migrated yet reads exactly as the migration will write it. $source = $this->is_legacy_value( $raw ) ? $this->migrate_legacy( $raw ) : $raw; foreach ( $schema as $key => $field ) { $value = null; if ( array_key_exists( $key, $source ) ) { $value = $this->sanitize_value( $source[ $key ], $field, $field['default'] ); } $stored[ $key ] = ( null === $value ) ? $field['default'] : $value; } if ( ! array_key_exists( 'enable_plugin', $source ) ) { $stored['enable_plugin'] = 'off'; } foreach ( $source as $key => $value ) { if ( ! isset( $schema[ $key ] ) ) { $stored[ $key ] = $value; } } } // Never memoise values built from the incomplete schema seen inside the integration registry filter. if ( ! $this->merging_integrations ) { $this->cache_raw = $raw; $this->cache_after_init = $after_init; $this->cache_stored = $stored; } return $stored; } /** * Returns all settings (stored merged over defaults), filtered. * * @return array */ public function all(): array { $stored = $this->stored(); /** * Filters the AJAX Loading settings at read time. * * @since 2.0.0 * * @param array $settings Settings keyed by name. */ $filtered = apply_filters( 'ajax_loading_settings', $stored ); return is_array( $filtered ) ? array_merge( $stored, $filtered ) : $stored; } /** * Returns one setting. * * @param string $key Setting name. * @param mixed $fallback Returned when the key does not exist. * @return mixed */ public function get( string $key, $fallback = null ) { $all = $this->all(); return array_key_exists( $key, $all ) ? $all[ $key ] : $fallback; } /** * Returns all() with the safe mode overrides applied. * * @return array */ public function effective(): array { $settings = $this->all(); if ( empty( $settings['safe_mode'] ) ) { return $settings; } $settings['prefetch_enabled'] = false; $settings['cache_enabled'] = false; $settings['form_selectors'] = ''; $settings['header_mode'] = 'preserve'; $settings['footer_mode'] = 'preserve'; $settings['dispatch_dom_events'] = false; $settings['remove_stale_styles'] = false; $settings['interactive_blocks'] = 'reload'; if ( isset( $settings['navigation_mode'] ) && 'body' === $settings['navigation_mode'] ) { $settings['navigation_mode'] = 'auto'; } if ( isset( $settings['integrations'] ) && is_array( $settings['integrations'] ) ) { foreach ( self::SAFE_MODE_EXCLUDED_INTEGRATIONS as $key ) { if ( isset( $settings['integrations'][ $key ] ) && 'off' !== $settings['integrations'][ $key ] ) { $settings['integrations'][ $key ] = 'exclude'; } } } return $settings; } /** * Whether AJAX navigation is switched on and not paused. * * @return bool */ public function is_enabled(): bool { return 'on' === $this->get( 'enable_plugin' ) && ! $this->is_paused(); } /** * Whether navigation is temporarily paused. * * @return bool */ public function is_paused(): bool { $until = $this->get( 'pause_until', 0 ); return is_numeric( $until ) && (int) $until > time(); } /* * --------------------------------------------------------------------- * Sanitising * --------------------------------------------------------------------- */ /** * Sanitises a complete settings array. * * Known keys are sanitised per type; missing or invalid keys come from * $base (when valid) or the defaults. Unknown keys are dropped. * * @param array $input Values to sanitise. * @param array|null $base Fallback values; defaults when null. * @return array */ public function sanitize( array $input, ?array $base = null ): array { $schema = $this->schema(); if ( null === $base ) { $base = $this->defaults(); } $output = array(); foreach ( $schema as $key => $field ) { $fallback = null; if ( array_key_exists( $key, $base ) ) { $fallback = $this->sanitize_value( $base[ $key ], $field, $field['default'] ); } if ( null === $fallback ) { $fallback = $field['default']; } if ( array_key_exists( $key, $input ) ) { $value = $this->sanitize_value( $input[ $key ], $field, $fallback ); if ( null === $value ) { if ( $this->validating_input ) { $this->invalid[] = $key; } $value = $fallback; } } else { $value = $fallback; } $output[ $key ] = $value; } if ( isset( $schema['settings_version'] ) ) { $output['settings_version'] = self::SCHEMA_VERSION; } return $output; } /** * `register_setting()` sanitize callback for the settings form. * * With `_ajxl_tab`, the input is merged over the current settings and * checkbox-like fields of that tab missing from the input count as * unchecked. Hidden keys never come from form input. Writes performed by * this class pass through unchanged (already sanitised). * * @param mixed $input Raw option value. * @return array */ public function sanitize_option_input( $input ): array { $current = $this->stored(); if ( $this->internal_write > 0 ) { if ( ! is_array( $input ) ) { return $this->sanitize( array(), $current ); } // Keep unknown keys our own writes carry (forward compatibility). return $this->sanitize( $input, $current ) + array_diff_key( $input, $this->schema() ); } if ( ! is_array( $input ) ) { return $this->sanitize( array(), $current ); } $schema = $this->schema(); foreach ( $this->hidden_keys() as $key ) { unset( $input[ $key ] ); } $tab = ''; if ( isset( $input['_ajxl_tab'] ) && is_string( $input['_ajxl_tab'] ) ) { $tab = sanitize_key( $input['_ajxl_tab'] ); } $has_tab = array_key_exists( '_ajxl_tab', $input ); unset( $input['_ajxl_tab'] ); if ( $has_tab ) { if ( ! in_array( $tab, self::TABS, true ) ) { // Unknown tab: change nothing. $input = array(); } else { foreach ( $this->fields_for_tab( $tab ) as $key ) { if ( array_key_exists( $key, $input ) ) { continue; } switch ( $schema[ $key ]['type'] ) { case 'bool': $input[ $key ] = false; break; case 'onoff': $input[ $key ] = 'off'; break; case 'multi': case 'post_types': $input[ $key ] = array(); break; } } } } $this->invalid = array(); $this->validating_input = true; try { $output = $this->sanitize( $input, $current ); } finally { $this->validating_input = false; } $this->report_invalid(); return $output; } /** * Adds one settings error listing rejected fields (admin form only). * * @return void */ private function report_invalid(): void { $invalid = array_values( array_unique( $this->invalid ) ); $this->invalid = array(); if ( empty( $invalid ) || ! function_exists( 'add_settings_error' ) ) { return; } add_settings_error( self::OPTION, 'ajax_loading_invalid_values', sprintf( /* translators: %s: comma-separated list of setting keys. */ __( 'Settings saved, but some values were invalid and their previous values were kept: %s', 'ajax-loading' ), implode( ', ', $invalid ) ), 'warning' ); } /** * Sanitises one value for a schema field. * * @param mixed $value Raw value. * @param array $field Schema entry. * @param mixed $fallback Fallback for `map` sub keys (array) – unused otherwise. * @return mixed|null Sanitised value, or null when invalid. */ private function sanitize_value( $value, array $field, $fallback = null ) { switch ( $field['type'] ) { case 'onoff': $bool = self::to_bool( $value ); if ( null === $bool ) { return null; } return $bool ? 'on' : 'off'; case 'bool': return self::to_bool( $value ); case 'int': $int = self::to_int( $value ); if ( null === $int ) { return null; } if ( isset( $field['min'] ) && $int < $field['min'] ) { $int = (int) $field['min']; } if ( isset( $field['max'] ) && $int > $field['max'] ) { $int = (int) $field['max']; } return $int; case 'float': return $this->sanitize_float( $value, $field ); case 'enum': if ( ! is_string( $value ) && ! is_int( $value ) ) { return null; } $value = strtolower( trim( (string) $value ) ); return in_array( $value, $field['choices'], true ) ? $value : null; case 'text': return $this->sanitize_text( $value, $field ); case 'color': if ( ! is_string( $value ) ) { return null; } $value = trim( $value ); if ( '' !== $value && '#' !== $value[0] ) { $value = '#' . $value; } $value = sanitize_hex_color( $value ); return ( is_string( $value ) && '' !== $value ) ? strtolower( $value ) : null; case 'lines': return $this->sanitize_lines( $value, $field ); case 'multi': return $this->sanitize_multi( $value, $field ); case 'map': return $this->sanitize_map( $value, $field, $fallback ); case 'post_types': return $this->sanitize_post_types( $value ); } return null; } /** * Sanitises a float field (clamped and snapped to `step`). * * @param mixed $value Raw value. * @param array $field Schema entry. * @return float|null */ private function sanitize_float( $value, array $field ) { if ( is_string( $value ) ) { $value = trim( $value ); } if ( is_bool( $value ) || ! is_numeric( $value ) ) { return null; } $number = (float) $value; if ( ! is_finite( $number ) ) { return null; } $min = isset( $field['min'] ) ? (float) $field['min'] : null; $max = isset( $field['max'] ) ? (float) $field['max'] : null; if ( ! empty( $field['step'] ) && $field['step'] > 0 ) { $origin = ( null !== $min ) ? $min : 0.0; $number = $origin + round( ( $number - $origin ) / $field['step'] ) * $field['step']; } if ( null !== $min && $number < $min ) { $number = $min; } if ( null !== $max && $number > $max ) { $number = $max; } return round( $number, 4 ); } /** * Sanitises a single-line text field. * * @param mixed $value Raw value. * @param array $field Schema entry. * @return string|null */ private function sanitize_text( $value, array $field ) { if ( ! is_string( $value ) && ! is_int( $value ) && ! is_float( $value ) ) { return null; } $value = (string) $value; if ( isset( $field['format'] ) && 'selector' === $field['format'] ) { $value = self::clean_selector( $value ); } else { $value = sanitize_text_field( $value ); } $max = isset( $field['max_length'] ) ? max( 1, (int) $field['max_length'] ) : self::TEXT_MAX_CHARS; $value = trim( self::truncate( $value, $max ) ); if ( '' === $value ) { return empty( $field['required'] ) ? '' : null; } if ( ! empty( $field['pattern'] ) && 1 !== preg_match( $field['pattern'], $value ) ) { return null; } return $value; } /** * Sanitises a newline separated list. * * Lines are trimmed, empty and duplicate lines dropped, at most * LINES_MAX lines of LINE_MAX_CHARS characters are kept. * * @param mixed $value Raw value (string or list of strings). * @param array $field Schema entry. * @return string|null */ private function sanitize_lines( $value, array $field ) { if ( is_array( $value ) ) { $parts = array(); foreach ( $value as $item ) { if ( is_string( $item ) || is_int( $item ) || is_float( $item ) ) { $parts[] = (string) $item; } } $value = implode( "\n", $parts ); } if ( ! is_string( $value ) ) { return null; } $format = isset( $field['format'] ) ? $field['format'] : 'patterns'; $lines = preg_split( '/\r\n|\r|\n/', $value ); $output = array(); $seen = array(); foreach ( (array) $lines as $line ) { $line = ( 'selectors' === $format ) ? self::clean_selector( $line ) : self::clean_pattern( $line ); $line = trim( self::truncate( $line, self::LINE_MAX_CHARS ) ); if ( '' === $line || isset( $seen[ $line ] ) ) { continue; } $seen[ $line ] = true; $output[] = $line; if ( count( $output ) >= self::LINES_MAX ) { break; } } return implode( "\n", $output ); } /** * Sanitises a `multi` field: list of allowed values in schema order. * * Accepts a list (`[ 'hover', 'focus' ]`), checkbox maps * (`[ 'hover' => 'on' ]`) or a comma separated string. * * @param mixed $value Raw value. * @param array $field Schema entry. * @return array|null */ private function sanitize_multi( $value, array $field ) { if ( is_string( $value ) ) { $value = ( '' === trim( $value ) ) ? array() : explode( ',', $value ); } if ( ! is_array( $value ) ) { return null; } $wanted = array(); foreach ( $value as $key => $item ) { if ( is_string( $key ) && in_array( strtolower( $key ), $field['choices'], true ) ) { if ( true === self::to_bool( $item ) ) { $wanted[ strtolower( $key ) ] = true; } continue; } if ( is_string( $item ) ) { $wanted[ strtolower( trim( $item ) ) ] = true; } } $output = array(); foreach ( $field['choices'] as $choice ) { if ( isset( $wanted[ $choice ] ) ) { $output[] = $choice; } } return $output; } /** * Sanitises a `map` field (sub key => allowed mode). * * Unknown sub keys are dropped; missing or invalid ones keep the * fallback value, then the default. * * @param mixed $value Raw value. * @param array $field Schema entry. * @param mixed $fallback Previous map (array) or null. * @return array|null */ private function sanitize_map( $value, array $field, $fallback ) { if ( ! is_array( $value ) ) { return null; } $fallback = is_array( $fallback ) ? $fallback : array(); $defaults = is_array( $field['default'] ) ? $field['default'] : array(); $output = array(); foreach ( $field['choices'] as $sub_key => $allowed ) { $previous = null; if ( isset( $fallback[ $sub_key ] ) && is_string( $fallback[ $sub_key ] ) && in_array( $fallback[ $sub_key ], $allowed, true ) ) { $previous = $fallback[ $sub_key ]; } elseif ( isset( $defaults[ $sub_key ] ) && is_string( $defaults[ $sub_key ] ) && in_array( $defaults[ $sub_key ], $allowed, true ) ) { $previous = $defaults[ $sub_key ]; } else { $previous = $allowed[0]; } if ( array_key_exists( $sub_key, $value ) ) { $mode = is_string( $value[ $sub_key ] ) ? strtolower( trim( $value[ $sub_key ] ) ) : ''; if ( in_array( $mode, $allowed, true ) ) { $output[ $sub_key ] = $mode; continue; } if ( $this->validating_input ) { $this->invalid[] = $sub_key; } } $output[ $sub_key ] = $previous; } return $output; } /** * Sanitises a `post_types` field. * * While validating untrusted input after `init`, names must be public * registered post types. When reading stored values the list is only * key-sanitised, so post types registered later in the request survive. * * @param mixed $value Raw value. * @return array|null */ private function sanitize_post_types( $value ) { if ( is_string( $value ) ) { $value = ( '' === trim( $value ) ) ? array() : explode( ',', $value ); } if ( ! is_array( $value ) ) { return null; } $known = null; if ( $this->validating_input && function_exists( 'get_post_types' ) && did_action( 'init' ) ) { $known = array_values( (array) get_post_types( array( 'public' => true ), 'names' ) ); } $output = array(); foreach ( $value as $key => $item ) { $name = ''; if ( is_string( $key ) && ! is_numeric( $key ) ) { if ( true === self::to_bool( $item ) ) { $name = $key; } } elseif ( is_string( $item ) ) { $name = $item; } $name = substr( sanitize_key( $name ), 0, 20 ); if ( '' === $name || in_array( $name, $output, true ) ) { continue; } if ( null !== $known && ! in_array( $name, $known, true ) ) { continue; } $output[] = $name; } return $output; } /** * Cleans one CSS selector (or selector list line). * * Strips characters that can never be part of a selector we output into * JSON/attributes (`<`, `{`, `}`, control characters). The `>` child * combinator is valid CSS and is kept; output is always escaped. * * @param string $value Raw selector. * @return string */ private static function clean_selector( string $value ): string { $value = wp_check_invalid_utf8( $value, true ); $value = preg_replace( '/[\x00-\x1F\x7F]+/', ' ', $value ); $value = str_replace( array( '<', '{', '}' ), '', (string) $value ); $value = preg_replace( '/[ \t\n\r\f\v]+/', ' ', $value ); return trim( (string) $value ); } /** * Cleans one URL pattern line (§7.2 syntax). * * @param string $value Raw pattern. * @return string */ private static function clean_pattern( string $value ): string { $value = wp_check_invalid_utf8( $value, true ); $value = preg_replace( '/[\x00-\x1F\x7F]+/', '', $value ); $value = str_replace( array( '<', '>', '"', '{', '}', '`', '\\' ), '', (string) $value ); $value = preg_replace( '/[ \t\n\r\f\v]+/', ' ', $value ); return trim( (string) $value ); } /** * Converts loose boolean representations. * * @param mixed $value Raw value. * @return bool|null Null when not a recognisable boolean. */ public static function to_bool( $value ) { if ( is_bool( $value ) ) { return $value; } if ( is_int( $value ) ) { return 0 !== $value; } if ( is_float( $value ) ) { return 0.0 !== $value; } if ( is_string( $value ) ) { $value = strtolower( trim( $value ) ); if ( in_array( $value, array( '1', 'true', 'on', 'yes' ), true ) ) { return true; } if ( in_array( $value, array( '0', 'false', 'off', 'no', '' ), true ) ) { return false; } } return null; } /** * Converts a numeric value to int without overflow warnings. * * @param mixed $value Raw value. * @return int|null */ private static function to_int( $value ) { if ( is_int( $value ) ) { return $value; } if ( is_string( $value ) ) { $value = trim( $value ); if ( '' === $value || ! is_numeric( $value ) ) { return null; } if ( 1 === preg_match( '/^[+-]?\d+$/', $value ) ) { // Integer strings saturate at PHP_INT_MAX/MIN without warnings. return (int) $value; } $value = (float) $value; } if ( is_float( $value ) ) { if ( ! is_finite( $value ) ) { return null; } if ( $value >= PHP_INT_MAX ) { return PHP_INT_MAX; } if ( $value <= PHP_INT_MIN ) { return PHP_INT_MIN; } return (int) round( $value ); } return null; } /** * Multibyte-safe truncation (WordPress polyfills mb_substr()). * * @param string $value Text. * @param int $length Maximum characters. * @return string */ private static function truncate( string $value, int $length ): string { if ( strlen( $value ) <= $length ) { return $value; } if ( function_exists( 'mb_substr' ) ) { return (string) mb_substr( $value, 0, $length, 'UTF-8' ); } return substr( $value, 0, $length ); } /* * --------------------------------------------------------------------- * Writing * --------------------------------------------------------------------- */ /** * Merges values over the current settings, sanitises and saves them. * * Hidden keys (pause_until, cache_version) are accepted here because this * is the internal API. `ajax_loading_settings_updated` fires on change. * * @param array $values Values to change. * @return bool True when the stored settings now match. */ public function update( array $values ): bool { $current = $this->stored(); $new = $this->sanitize( array_merge( $current, $values ), $current ); return $this->write( $new ); } /** * Restores the defaults (plugin on, not paused) and invalidates client caches. * * @return bool */ public function reset(): bool { $new = $this->defaults(); $new['cache_version'] = $this->next_cache_version(); return $this->write( $new ); } /** * Increments the client cache version (clears visitors' navigation caches). * * @return int The cache version now stored. */ public function bump_cache_version(): int { $this->update( array( 'cache_version' => $this->next_cache_version() ) ); $stored = $this->stored(); return (int) $stored['cache_version']; } /** * Pauses AJAX navigation for a while. * * @param int $seconds Duration; capped at 30 days. <= 0 resumes. * @return void */ public function pause( int $seconds ): void { if ( $seconds <= 0 ) { $this->resume(); return; } $seconds = min( $seconds, self::MAX_PAUSE_SECONDS ); $this->update( array( 'pause_until' => time() + $seconds ) ); } /** * Ends a pause. * * @return void */ public function resume(): void { $this->update( array( 'pause_until' => 0 ) ); } /** * Returns the export document. * * Uses the stored values (not the read-time filter) and leaves out the * hidden keys. * * @return array */ public function export(): array { $stored = $this->stored(); $settings = array(); foreach ( $this->schema() as $key => $field ) { if ( self::HIDDEN_TAB === $field['tab'] ) { continue; } $settings[ $key ] = $stored[ $key ]; } return array( 'plugin' => 'ajax-loading', 'version' => WPAJXL_PLUGIN_VERSION, 'exported' => gmdate( 'c' ), 'settings' => $settings, ); } /** * Imports an export document. * * Keys missing from the document keep their current value; hidden keys * in the document are ignored; client caches are invalidated. * * @param array|string $data Export array or its JSON. * @return true|WP_Error */ public function import( $data ) { if ( is_string( $data ) ) { if ( strlen( $data ) > self::IMPORT_MAX_BYTES ) { return new WP_Error( 'ajax_loading_import_too_large', __( 'The import file is too large.', 'ajax-loading' ) ); } $data = preg_replace( '/^\xEF\xBB\xBF/', '', $data ); $decoded = json_decode( (string) $data, true, 64 ); if ( ! is_array( $decoded ) ) { return new WP_Error( 'ajax_loading_import_invalid_json', __( 'The import file is not valid JSON.', 'ajax-loading' ) ); } $data = $decoded; } if ( ! is_array( $data ) ) { return new WP_Error( 'ajax_loading_import_invalid', __( 'The import data is not valid.', 'ajax-loading' ) ); } if ( ! isset( $data['plugin'] ) || 'ajax-loading' !== $data['plugin'] ) { return new WP_Error( 'ajax_loading_import_wrong_plugin', __( 'This file is not an AJAX Loading settings export.', 'ajax-loading' ) ); } if ( ! isset( $data['settings'] ) || ! is_array( $data['settings'] ) ) { return new WP_Error( 'ajax_loading_import_no_settings', __( 'The import file does not contain any settings.', 'ajax-loading' ) ); } $schema = $this->schema(); $settings = $data['settings']; foreach ( $this->hidden_keys() as $key ) { unset( $settings[ $key ] ); } $settings = array_intersect_key( $settings, $schema ); if ( empty( $settings ) ) { return new WP_Error( 'ajax_loading_import_no_settings', __( 'The import file does not contain any settings.', 'ajax-loading' ) ); } $current = $this->stored(); $this->invalid = array(); $this->validating_input = true; try { $new = $this->sanitize( $settings, $current ); } finally { $this->validating_input = false; $this->invalid = array(); } $new['cache_version'] = $this->next_cache_version(); if ( ! $this->write( $new ) ) { return new WP_Error( 'ajax_loading_import_failed', __( 'The settings could not be saved.', 'ajax-loading' ) ); } return true; } /** * Returns the next cache version (wraps before int overflow on 32-bit). * * @return int */ private function next_cache_version(): int { $stored = $this->stored(); $current = isset( $stored['cache_version'] ) ? (int) $stored['cache_version'] : 1; return ( $current >= self::MAX_CACHE_VERSION || $current < 1 ) ? 1 : $current + 1; } /** * Saves a complete settings array. * * @param array $value Sanitised settings. * @return bool True when the stored value now equals $value. */ private function write( array $value ): bool { $exists = null !== get_option( self::OPTION, null ); ++$this->internal_write; try { if ( $exists ) { $result = update_option( self::OPTION, $value ); } else { // Read on every front-end request: autoload. $result = add_option( self::OPTION, $value, '', true ); } } finally { --$this->internal_write; } $this->flush_cache(); if ( $result ) { return true; } $saved = get_option( self::OPTION, null ); return is_array( $saved ) && $saved == $value; // phpcs:ignore Universal.Operators.StrictComparisons.LooseEqual -- order-insensitive array comparison. } /* * --------------------------------------------------------------------- * Lifecycle * --------------------------------------------------------------------- */ /** * Runs migrations when the stored version is older than this release. * * Cheap on every request: two autoloaded option reads and a version compare. * - fresh install (neither option exists): default settings (enabled); * - 1.x settings: `enable_plugin` kept exactly, `disable_emojis` and * `legacy_jquery` switched on (1.x behaviour), everything else default; * - a settings array without the 2.x `settings_version` stamp is 1.x data * even when the version option is current (rollback to 1.x, settings * saved there, then updated to 2.x again) and is migrated the same way; * - a missing settings option is never re-created once a version exists. * * @return void */ public function maybe_upgrade(): void { $version = get_option( self::VERSION_OPTION, '' ); $version = ( is_string( $version ) || is_numeric( $version ) ) ? trim( (string) $version ) : ''; $raw = get_option( self::OPTION, null ); if ( '' !== $version && version_compare( $version, WPAJXL_PLUGIN_VERSION, '>=' ) && ! ( is_array( $raw ) && $this->is_legacy_value( $raw ) ) ) { return; } $is_upgrade = '' !== $version || null !== $raw; $saved = true; if ( null === $raw ) { if ( '' === $version ) { $saved = $this->seed_defaults(); } } elseif ( $this->is_legacy_value( $raw ) ) { $saved = $this->write( $this->migrate_legacy( $raw ) ); } // Keep the old version when the settings write failed so the next request retries. if ( ! $saved ) { $this->flush_cache(); return; } if ( '' === $version || version_compare( $version, WPAJXL_PLUGIN_VERSION, '<' ) ) { update_option( self::VERSION_OPTION, WPAJXL_PLUGIN_VERSION ); } $this->flush_cache(); if ( $is_upgrade ) { /** * Fires after AJAX Loading upgraded its stored data. * * @since 2.0.0 * * @param string $from Previously stored version ('' when unknown). * @param string $to Current version. */ do_action( 'ajax_loading_upgraded', $version, WPAJXL_PLUGIN_VERSION ); } } /** * Activation: migrate, then create the defaults when no settings exist. * * @return void */ public function activate(): void { $this->maybe_upgrade(); if ( null === get_option( self::OPTION, null ) ) { $this->seed_defaults(); } $version = get_option( self::VERSION_OPTION, '' ); if ( ! is_string( $version ) || '' === $version || version_compare( $version, WPAJXL_PLUGIN_VERSION, '<' ) ) { update_option( self::VERSION_OPTION, WPAJXL_PLUGIN_VERSION ); } $this->flush_cache(); } /** * Creates the settings option with defaults; never overwrites. * * @return bool False only when the option was missing and could not be created. */ private function seed_defaults(): bool { if ( null !== get_option( self::OPTION, null ) ) { return true; } return $this->write( $this->defaults() ); } /** * Whether a stored value predates the 2.x schema. * * @param mixed $raw Stored option value. * @return bool */ private function is_legacy_value( $raw ): bool { if ( ! is_array( $raw ) ) { return true; } return ! isset( $raw['settings_version'] ) || ! is_numeric( $raw['settings_version'] ) || (int) $raw['settings_version'] < self::SCHEMA_VERSION; } /** * Builds 2.x settings from a 1.x value. * * @param mixed $raw Stored 1.x value. * @return array */ private function migrate_legacy( $raw ): array { $old = is_array( $raw ) ? $raw : array(); $schema = $this->schema(); $new = $this->defaults(); foreach ( $old as $key => $value ) { if ( 'enable_plugin' === $key ) { continue; } if ( ! isset( $schema[ $key ] ) ) { $new[ $key ] = $value; continue; } $clean = $this->sanitize_value( $value, $schema[ $key ], $new[ $key ] ); if ( null !== $clean ) { $new[ $key ] = $clean; } } // 1.x only treated the exact string 'on' as enabled. $new['enable_plugin'] = ( isset( $old['enable_plugin'] ) && 'on' === $old['enable_plugin'] ) ? 'on' : 'off'; $new['disable_emojis'] = true; $new['legacy_jquery'] = true; $new['settings_version'] = self::SCHEMA_VERSION; return $new; } }