array(), 'strong' => array(), 'em' => array(), 'br' => array(), 'a' => array( 'href' => array(), 'target' => array(), 'rel' => array(), ), ); } /** * Sections per tab, in display order. * * @return array tab => list of [ id, title, intro, keys, collapsible ]. */ public static function sections() { return array( 'general' => array( array( 'id' => 'activation', 'title' => __( 'Activation', 'ajax-loading' ), 'intro' => __( 'Turn AJAX navigation on, try it in test mode, and choose the devices that use it.', 'ajax-loading' ), 'keys' => array( 'enable_plugin', 'visibility', 'enable_desktop', 'enable_mobile', 'mobile_breakpoint' ), ), array( 'id' => 'content', 'title' => __( 'Content regions', 'ajax-loading' ), 'intro' => __( 'Which part of the page is replaced when a visitor follows a link. Everything else (header, menus, footer) stays in place.', 'ajax-loading' ), 'keys' => array( 'navigation_mode', 'content_selectors', 'header_selector', 'header_mode', 'footer_selector', 'footer_mode', 'handle_404' ), ), array( 'id' => 'forms', 'title' => __( 'Forms', 'ajax-loading' ), 'intro' => __( 'Search and filter forms that use the GET method can be submitted without a full reload. POST forms are never handled.', 'ajax-loading' ), 'keys' => array( 'forms_search', 'form_selectors' ), ), array( 'id' => 'advanced', 'title' => __( 'Advanced: scripts and compatibility', 'ajax-loading' ), 'intro' => __( 'The defaults suit most sites. Change these only when a theme or plugin needs it.', 'ajax-loading' ), 'keys' => array( 'execute_content_scripts', 'run_new_inline_scripts', 'refresh_localized_data', 'dispatch_dom_events', 'remove_stale_styles', 'legacy_jquery', 'disable_emojis' ), 'collapsible' => true, ), ), 'loading' => array( array( 'id' => 'indicator', 'title' => __( 'Loading indicator', 'ajax-loading' ), 'intro' => __( 'Shown while the next page is loading. Fast navigations finish before the indicator appears.', 'ajax-loading' ), 'keys' => array( 'loader_type', 'mobile_loader_type', 'loader_color', 'loader_bg', 'loader_text', 'loader_logo_id', 'loader_size', 'bar_height', 'bar_position', 'loader_position', 'overlay_opacity', 'loader_speed', 'loader_zindex' ), ), array( 'id' => 'timing', 'title' => __( 'Indicator timing', 'ajax-loading' ), 'intro' => '', 'keys' => array( 'loader_delay', 'loader_min_duration' ), ), array( 'id' => 'transitions', 'title' => __( 'Page transitions', 'ajax-loading' ), 'intro' => __( 'Animations played when the old content leaves and the new content enters. Only opacity and transform are animated, so they stay smooth on slow devices.', 'ajax-loading' ), 'keys' => array( 'transition_out', 'transition_in', 'transition_duration', 'transition_easing', 'transition_header_footer', 'mobile_transition', 'respect_reduced_motion' ), ), array( 'id' => 'after', 'title' => __( 'After navigation', 'ajax-loading' ), 'intro' => __( 'Scrolling, keyboard focus and screen reader announcements once the new page is shown.', 'ajax-loading' ), 'keys' => array( 'scroll_behavior', 'focus_target', 'announce_navigation' ), ), ), 'performance' => array( array( 'id' => 'prefetch', 'title' => __( 'Prefetching', 'ajax-loading' ), 'intro' => __( 'Start loading a page as soon as a visitor shows intent, so it opens almost instantly. Logged-in visitors and personalised pages (cart, comments) are never prefetched.', 'ajax-loading' ), 'keys' => array( 'prefetch_enabled', 'prefetch_triggers', 'prefetch_hover_delay', 'prefetch_max_concurrent', 'prefetch_viewport_limit', 'prefetch_exclude', 'network_aware' ), ), array( 'id' => 'cache', 'title' => __( 'In-browser page cache', 'ajax-loading' ), 'intro' => __( 'Keeps recently visited pages in the visitor\'s browser memory. Nothing is stored on your server and the cache ends when the tab is closed.', 'ajax-loading' ), 'keys' => array( 'cache_enabled', 'cache_max_pages', 'cache_ttl', 'cache_max_kb' ), ), array( 'id' => 'requests', 'title' => __( 'Requests', 'ajax-loading' ), 'intro' => '', 'keys' => array( 'request_timeout', 'disable_core_speculation' ), ), array( 'id' => 'state', 'title' => __( 'Page state', 'ajax-loading' ), 'intro' => __( 'What is restored or updated so AJAX pages behave like normal page loads.', 'ajax-loading' ), 'keys' => array( 'preserve_scroll', 'preserve_form_state', 'sync_menu_state' ), ), ), 'seo' => array( array( 'id' => 'metadata', 'title' => __( 'Search engines and social sharing', 'ajax-loading' ), 'intro' => __( 'The page title is always updated. These options keep the rest of the in sync, so copied links, browser extensions and SEO tools see the right data.', 'ajax-loading' ), 'keys' => array( 'sync_metadata', 'sync_canonical', 'sync_structured_data' ), ), array( 'id' => 'analytics', 'title' => __( 'Analytics', 'ajax-loading' ), 'intro' => __( 'Make sure every AJAX navigation is counted as a page view exactly once.', 'ajax-loading' ), 'keys' => array( 'analytics_mode', 'analytics_timing', 'gtm_event_name', 'analytics_require_consent', 'analytics_callback' ), ), array( 'id' => 'integrations', 'title' => __( 'Integrations', 'ajax-loading' ), 'intro' => __( 'Built-in adapters re-initialise popular plugins after each navigation. "Auto" uses the adapter, "Exclude" makes pages that use the integration load normally, "Off" ignores it.', 'ajax-loading' ), 'keys' => array( 'integrations' ), ), ), 'exclusions' => array( array( 'id' => 'rules', 'title' => __( 'Your exclusions', 'ajax-loading' ), 'intro' => __( 'Links that match any of these rules load normally (full page load).', 'ajax-loading' ), 'keys' => array( 'exclude_urls', 'exclude_selectors', 'exclude_post_types' ), ), array( 'id' => 'builtin', 'title' => __( 'Protected areas', 'ajax-loading' ), 'intro' => '', 'keys' => array( 'exclude_woocommerce', 'exclude_membership', 'interactive_blocks' ), ), ), 'tools' => array( array( 'id' => 'safety', 'title' => __( 'Safety', 'ajax-loading' ), 'intro' => __( 'Whenever AJAX navigation cannot handle a page, the browser simply loads it normally. These options add extra protection.', 'ajax-loading' ), 'keys' => array( 'safe_mode', 'auto_disable', 'auto_disable_threshold', 'auto_disable_minutes' ), ), array( 'id' => 'debugging', 'title' => __( 'Debugging', 'ajax-loading' ), 'intro' => '', 'keys' => array( 'debug_mode', 'error_logging' ), ), ), ); } /** * Human metadata per setting key. * * Keys: label, desc, control (radio|select|media), choices (value => label), * choice_desc (value => text), unit, placeholder, maxlength, pattern, rows, * show_if (list of { fields: [], in|not_in: [] }; all conditions must match). * * @return array */ public static function meta() { if ( null !== self::$meta ) { return self::$meta; } $ms = _x( 'ms', 'milliseconds unit', 'ajax-loading' ); $px = _x( 'px', 'pixels unit', 'ajax-loading' ); $seconds = _x( 'seconds', 'time unit', 'ajax-loading' ); $minutes = _x( 'minutes', 'time unit', 'ajax-loading' ); $loader_choices = array( 'bar' => __( 'Progress bar', 'ajax-loading' ), 'spinner' => __( 'Spinner', 'ajax-loading' ), 'overlay' => __( 'Overlay', 'ajax-loading' ), 'text' => __( 'Text', 'ajax-loading' ), 'logo' => __( 'Your logo', 'ajax-loading' ), 'skeleton' => __( 'Skeleton placeholder', 'ajax-loading' ), 'none' => __( 'None', 'ajax-loading' ), ); $transition_choices = array( 'fade' => __( 'Fade', 'ajax-loading' ), 'fade-through' => __( 'Fade through', 'ajax-loading' ), 'slide-left' => __( 'Slide left', 'ajax-loading' ), 'slide-right' => __( 'Slide right', 'ajax-loading' ), 'slide-up' => __( 'Slide up', 'ajax-loading' ), 'slide-down' => __( 'Slide down', 'ajax-loading' ), 'scale' => __( 'Scale', 'ajax-loading' ), 'smooth' => __( 'Smooth (fade and lift)', 'ajax-loading' ), 'none' => __( 'None', 'ajax-loading' ), ); $any_loader = array( array( 'fields' => array( 'loader_type', 'mobile_loader_type' ), 'not_in' => array( 'none', 'inherit' ), ), ); $loader_is = static function ( array $types ) { return array( array( 'fields' => array( 'loader_type', 'mobile_loader_type' ), 'in' => $types, ), ); }; $prefetch_on = array( array( 'fields' => array( 'prefetch_enabled' ), 'in' => array( '1' ), ), ); self::$meta = array( // General. 'enable_plugin' => array( 'label' => __( 'Enable AJAX navigation', 'ajax-loading' ), 'desc' => __( 'Master switch. When off, your site loads every page normally.', 'ajax-loading' ), ), 'visibility' => array( 'label' => __( 'Who gets AJAX navigation', 'ajax-loading' ), 'control' => 'radio', 'choices' => array( 'everyone' => __( 'Everyone', 'ajax-loading' ), 'admins' => __( 'Administrators only (test mode)', 'ajax-loading' ), ), 'choice_desc' => array( 'admins' => __( 'Try the plugin on your live site first: visitors keep normal page loads until you switch to everyone.', 'ajax-loading' ), ), ), 'enable_desktop' => array( 'label' => __( 'Desktop devices', 'ajax-loading' ), 'desc' => __( 'Use AJAX navigation on screens wider than the mobile breakpoint.', 'ajax-loading' ), ), 'enable_mobile' => array( 'label' => __( 'Mobile devices', 'ajax-loading' ), 'desc' => __( 'Use AJAX navigation on screens up to the mobile breakpoint.', 'ajax-loading' ), ), 'mobile_breakpoint' => array( 'label' => __( 'Mobile breakpoint', 'ajax-loading' ), 'desc' => __( 'Screens this wide or narrower count as mobile. Also used by the mobile indicator and animation options.', 'ajax-loading' ), 'unit' => $px, ), 'navigation_mode' => array( 'label' => __( 'Content detection', 'ajax-loading' ), 'control' => 'radio', 'choices' => array( 'auto' => __( 'Automatic (recommended)', 'ajax-loading' ), 'custom' => __( 'Custom selectors', 'ajax-loading' ), 'body' => __( 'Whole page body', 'ajax-loading' ), ), 'choice_desc' => array( 'auto' => __( 'Finds your theme\'s main content area on both pages and only swaps it. If the two pages use different layouts, the page loads normally.', 'ajax-loading' ), 'custom' => __( 'Swaps exactly the regions listed in "Content selectors". Each must exist on both pages.', 'ajax-loading' ), 'body' => __( 'Replaces everything except the admin bar. Heavier, and header scripts may need to re-initialise.', 'ajax-loading' ), ), ), 'content_selectors' => array( 'label' => __( 'Content selectors', 'ajax-loading' ), 'desc' => sprintf( /* translators: 1: example selector, 2: example selector. */ __( 'One CSS selector per line, for example %1$s or %2$s. In Custom mode these regions are replaced; in Automatic mode they are tried before the built-in candidates.', 'ajax-loading' ), '#content', 'main' ), 'placeholder' => '#content', 'rows' => 3, ), 'header_selector' => array( 'label' => __( 'Header selector', 'ajax-loading' ), 'desc' => __( 'Leave empty to detect the site header automatically.', 'ajax-loading' ), 'placeholder' => __( 'Auto-detect', 'ajax-loading' ), 'maxlength' => 500, ), 'header_mode' => array( 'label' => __( 'Header', 'ajax-loading' ), 'control' => 'radio', 'choices' => array( 'preserve' => __( 'Keep it (recommended)', 'ajax-loading' ), 'replace' => __( 'Replace it on every navigation', 'ajax-loading' ), ), 'desc' => __( 'Replace the header only when it shows page-specific content, such as a different hero image per page.', 'ajax-loading' ), ), 'footer_selector' => array( 'label' => __( 'Footer selector', 'ajax-loading' ), 'desc' => __( 'Leave empty to detect the site footer automatically.', 'ajax-loading' ), 'placeholder' => __( 'Auto-detect', 'ajax-loading' ), 'maxlength' => 500, ), 'footer_mode' => array( 'label' => __( 'Footer', 'ajax-loading' ), 'control' => 'radio', 'choices' => array( 'preserve' => __( 'Keep it (recommended)', 'ajax-loading' ), 'replace' => __( 'Replace it on every navigation', 'ajax-loading' ), ), 'desc' => __( 'Replace the footer only when it shows page-specific content, such as widgets that differ from page to page.', 'ajax-loading' ), ), 'handle_404' => array( 'label' => __( 'Load "not found" pages with AJAX', 'ajax-loading' ), 'desc' => __( 'Show your theme\'s 404 page without a full reload. When off, broken links load normally.', 'ajax-loading' ), ), 'forms_search' => array( 'label' => __( 'AJAX search forms', 'ajax-loading' ), 'desc' => __( 'Submit WordPress search forms (theme search form and Search block) without a full reload.', 'ajax-loading' ), ), 'form_selectors' => array( 'label' => __( 'Additional GET forms', 'ajax-loading' ), 'desc' => __( 'CSS selectors of other GET forms, such as product filters, one per line. Forms with file uploads are never handled.', 'ajax-loading' ), 'placeholder' => '.filter-form', 'rows' => 3, ), 'execute_content_scripts' => array( 'label' => __( 'Run scripts inside new content', 'ajax-loading' ), 'desc' => __( 'Execute inline and external scripts that are part of the replaced content, like a normal page load does.', 'ajax-loading' ), ), 'run_new_inline_scripts' => array( 'label' => __( 'Run new inline scripts', 'ajax-loading' ), 'desc' => __( 'Execute inline scripts outside the content area that only exist on the destination page. Analytics snippets are never re-run.', 'ajax-loading' ), ), 'refresh_localized_data' => array( 'label' => sprintf( /* translators: %s: script id suffix. */ __( 'Refresh changed %s data', 'ajax-loading' ), '-js-extra' ), 'desc' => __( 'Re-run WordPress script data blocks that differ on the new page, so plugins read the new page\'s settings.', 'ajax-loading' ), ), 'dispatch_dom_events' => array( 'label' => __( 'Fire page-load events again', 'ajax-loading' ), 'desc' => sprintf( /* translators: 1: DOMContentLoaded, 2: load. */ __( 'Dispatch synthetic %1$s and %2$s events after each navigation for old scripts that only initialise on page load. Some scripts may then initialise twice; enable only when needed.', 'ajax-loading' ), 'DOMContentLoaded', 'load' ), ), 'remove_stale_styles' => array( 'label' => __( 'Remove unused stylesheets', 'ajax-loading' ), 'desc' => __( 'Remove stylesheets that the new page does not use. Keeps long sessions lean, but a stylesheet needed again must be downloaded again.', 'ajax-loading' ), ), 'legacy_jquery' => array( 'label' => __( 'Load jQuery for custom callbacks', 'ajax-loading' ), 'desc' => sprintf( /* translators: %s: callback name. */ __( 'Version 1.x always loaded jQuery. Keep this on if your own %s code uses jQuery.', 'ajax-loading' ), 'wp_ajax_load_complete' ), ), 'disable_emojis' => array( 'label' => __( 'Disable WordPress emoji scripts', 'ajax-loading' ), 'desc' => __( 'Removes the emoji detection script and styles from the front end, as version 1.x did. Modern browsers show emoji natively.', 'ajax-loading' ), ), // Loading experience. 'loader_type' => array( 'label' => __( 'Indicator', 'ajax-loading' ), 'control' => 'select', 'choices' => $loader_choices, 'desc' => __( 'Shown while the next page loads.', 'ajax-loading' ), ), 'mobile_loader_type' => array( 'label' => __( 'Indicator on mobile', 'ajax-loading' ), 'control' => 'select', 'choices' => array( 'inherit' => __( 'Same as desktop', 'ajax-loading' ) ) + $loader_choices, 'desc' => __( 'Indicator used on screens up to the mobile breakpoint.', 'ajax-loading' ), ), 'loader_color' => array( 'label' => __( 'Indicator colour', 'ajax-loading' ), 'desc' => __( 'Main colour of the progress bar, the spinner and the loading text.', 'ajax-loading' ), 'show_if' => $loader_is( array( 'bar', 'spinner', 'overlay', 'text', 'logo' ) ), ), 'loader_bg' => array( 'label' => __( 'Background colour', 'ajax-loading' ), 'desc' => __( 'Used by the overlay, the skeleton placeholder and the plate behind the spinner, logo and text.', 'ajax-loading' ), 'show_if' => $loader_is( array( 'spinner', 'overlay', 'text', 'logo', 'skeleton' ) ), ), 'loader_text' => array( 'label' => __( 'Loading text', 'ajax-loading' ), 'desc' => __( 'Leave empty to use the translated default.', 'ajax-loading' ), 'placeholder' => __( 'Loading…', 'ajax-loading' ), 'maxlength' => 100, 'show_if' => $loader_is( array( 'text', 'overlay' ) ), ), 'loader_logo_id' => array( 'label' => __( 'Logo image', 'ajax-loading' ), 'desc' => __( 'Image shown by the "Your logo" indicator. Square images of about 200 pixels work best.', 'ajax-loading' ), 'control' => 'media', 'show_if' => $loader_is( array( 'logo' ) ), ), 'loader_size' => array( 'label' => __( 'Indicator size', 'ajax-loading' ), 'desc' => __( 'Size of the spinner or logo.', 'ajax-loading' ), 'unit' => $px, 'show_if' => $loader_is( array( 'spinner', 'overlay', 'logo' ) ), ), 'bar_height' => array( 'label' => __( 'Bar height', 'ajax-loading' ), 'desc' => __( 'Thickness of the progress bar.', 'ajax-loading' ), 'unit' => $px, 'show_if' => $loader_is( array( 'bar' ) ), ), 'bar_position' => array( 'label' => __( 'Bar position', 'ajax-loading' ), 'control' => 'radio', 'choices' => array( 'top' => __( 'Top of the screen', 'ajax-loading' ), 'bottom' => __( 'Bottom of the screen', 'ajax-loading' ), ), 'desc' => __( 'Edge of the screen the progress bar runs along.', 'ajax-loading' ), 'show_if' => $loader_is( array( 'bar' ) ), ), 'loader_position' => array( 'label' => __( 'Indicator position', 'ajax-loading' ), 'control' => 'select', 'choices' => array( 'center' => __( 'Centre', 'ajax-loading' ), 'top-left' => __( 'Top left', 'ajax-loading' ), 'top-right' => __( 'Top right', 'ajax-loading' ), 'bottom-left' => __( 'Bottom left', 'ajax-loading' ), 'bottom-right' => __( 'Bottom right', 'ajax-loading' ), ), 'desc' => __( 'Where the spinner, text or logo appears.', 'ajax-loading' ), 'show_if' => $loader_is( array( 'spinner', 'text', 'logo' ) ), ), 'overlay_opacity' => array( 'label' => __( 'Overlay opacity', 'ajax-loading' ), 'desc' => __( '0 is fully transparent, 1 is solid.', 'ajax-loading' ), 'show_if' => $loader_is( array( 'overlay' ) ), ), 'loader_speed' => array( 'label' => __( 'Animation speed', 'ajax-loading' ), 'desc' => __( 'Length of one animation cycle. Lower is faster.', 'ajax-loading' ), 'unit' => $ms, 'show_if' => $any_loader, ), 'loader_zindex' => array( 'label' => __( 'Stacking order (z-index)', 'ajax-loading' ), 'desc' => __( 'Raise this if the indicator is hidden behind a sticky header or popup.', 'ajax-loading' ), 'show_if' => $any_loader, ), 'loader_delay' => array( 'label' => __( 'Show the indicator after', 'ajax-loading' ), 'desc' => __( 'Navigations that finish sooner never show an indicator, so fast pages do not flicker.', 'ajax-loading' ), 'unit' => $ms, 'show_if' => $any_loader, ), 'loader_min_duration' => array( 'label' => __( 'Minimum display time', 'ajax-loading' ), 'desc' => __( 'Once visible, the indicator stays at least this long.', 'ajax-loading' ), 'unit' => $ms, 'show_if' => $any_loader, ), 'transition_out' => array( 'label' => __( 'Leave animation', 'ajax-loading' ), 'control' => 'select', 'choices' => $transition_choices, 'desc' => __( 'Played on the old content while the next page loads.', 'ajax-loading' ), ), 'transition_in' => array( 'label' => __( 'Enter animation', 'ajax-loading' ), 'control' => 'select', 'choices' => $transition_choices, 'desc' => __( 'Played on the new content.', 'ajax-loading' ), ), 'transition_duration' => array( 'label' => __( 'Animation duration', 'ajax-loading' ), 'desc' => __( 'Length of each leave and enter animation.', 'ajax-loading' ), 'unit' => $ms, 'show_if' => array( array( 'fields' => array( 'transition_out', 'transition_in', 'mobile_transition' ), 'not_in' => array( 'none', 'inherit' ), ), ), ), 'transition_easing' => array( 'label' => __( 'Easing', 'ajax-loading' ), 'control' => 'select', 'choices' => array( 'ease' => __( 'Ease', 'ajax-loading' ), 'ease-in-out' => __( 'Ease in and out', 'ajax-loading' ), 'ease-out' => __( 'Ease out', 'ajax-loading' ), 'linear' => __( 'Linear', 'ajax-loading' ), 'snappy' => __( 'Snappy', 'ajax-loading' ), ), 'desc' => __( 'Speed curve of the animations.', 'ajax-loading' ), 'show_if' => array( array( 'fields' => array( 'transition_out', 'transition_in', 'mobile_transition' ), 'not_in' => array( 'none', 'inherit' ), ), ), ), 'transition_header_footer' => array( 'label' => __( 'Animate header and footer', 'ajax-loading' ), 'desc' => __( 'Also animate the header and footer when they are set to "Replace" on the General tab.', 'ajax-loading' ), ), 'mobile_transition' => array( 'label' => __( 'Animation on mobile', 'ajax-loading' ), 'control' => 'radio', 'choices' => array( 'inherit' => __( 'Same as desktop', 'ajax-loading' ), 'fade' => __( 'Fade only', 'ajax-loading' ), 'none' => __( 'None', 'ajax-loading' ), ), 'desc' => __( 'Animation used on screens up to the mobile breakpoint.', 'ajax-loading' ), ), 'respect_reduced_motion' => array( 'label' => __( 'Respect reduced motion', 'ajax-loading' ), 'desc' => __( 'Skip animations for visitors whose device asks for reduced motion. Strongly recommended for accessibility.', 'ajax-loading' ), ), 'scroll_behavior' => array( 'label' => __( 'Scroll after navigation', 'ajax-loading' ), 'control' => 'radio', 'choices' => array( 'top' => __( 'Scroll to the top of the page', 'ajax-loading' ), 'content' => __( 'Scroll to the top of the content area', 'ajax-loading' ), 'none' => __( 'Do not scroll', 'ajax-loading' ), ), 'desc' => __( 'Links to a #section always scroll to that section. Back and forward restore the previous position (see Performance).', 'ajax-loading' ), ), 'focus_target' => array( 'label' => __( 'Move keyboard focus to', 'ajax-loading' ), 'control' => 'radio', 'choices' => array( 'heading' => __( 'The main heading (recommended)', 'ajax-loading' ), 'container' => __( 'The content area', 'ajax-loading' ), 'none' => __( 'Leave focus where it is', 'ajax-loading' ), ), 'desc' => __( 'Keyboard and screen reader users continue from here on the new page.', 'ajax-loading' ), ), 'announce_navigation' => array( 'label' => __( 'Announce page changes', 'ajax-loading' ), 'desc' => __( 'Tell screen reader users that a new page has loaded, with its title.', 'ajax-loading' ), ), // Performance. 'prefetch_enabled' => array( 'label' => __( 'Prefetch links', 'ajax-loading' ), 'desc' => __( 'Uses a little extra bandwidth to make navigation feel instant.', 'ajax-loading' ), ), 'prefetch_triggers' => array( 'label' => __( 'Prefetch when a link is', 'ajax-loading' ), 'choices' => array( 'hover' => __( 'Hovered with the mouse', 'ajax-loading' ), 'focus' => __( 'Focused with the keyboard', 'ajax-loading' ), 'touch' => __( 'Touched (before the tap completes)', 'ajax-loading' ), 'viewport' => __( 'Visible on screen', 'ajax-loading' ), ), 'desc' => __( 'When a link starts loading in the background. Choose one or more.', 'ajax-loading' ), 'show_if' => $prefetch_on, ), 'prefetch_hover_delay' => array( 'label' => __( 'Hover delay', 'ajax-loading' ), 'desc' => __( 'How long the pointer must rest on a link before it is prefetched.', 'ajax-loading' ), 'unit' => $ms, 'show_if' => array_merge( $prefetch_on, array( array( 'fields' => array( 'prefetch_triggers' ), 'in' => array( 'hover' ), ), ) ), ), 'prefetch_max_concurrent' => array( 'label' => __( 'Parallel prefetches', 'ajax-loading' ), 'desc' => __( 'Maximum number of pages prefetched at the same time.', 'ajax-loading' ), 'show_if' => $prefetch_on, ), 'prefetch_viewport_limit' => array( 'label' => __( 'Visible links per page', 'ajax-loading' ), 'desc' => __( 'Maximum number of on-screen links prefetched per page.', 'ajax-loading' ), 'show_if' => array_merge( $prefetch_on, array( array( 'fields' => array( 'prefetch_triggers' ), 'in' => array( 'viewport' ), ), ) ), ), 'prefetch_exclude' => array( 'label' => __( 'Never prefetch', 'ajax-loading' ), 'desc' => __( 'URL patterns, one per line, using the same syntax as the Exclusions tab. Use it for pages that are expensive to generate.', 'ajax-loading' ), 'placeholder' => '/search/*', 'rows' => 3, 'show_if' => $prefetch_on, ), 'network_aware' => array( 'label' => __( 'Respect data saver', 'ajax-loading' ), 'desc' => __( 'Skip prefetching when the visitor has data saver on or a very slow connection.', 'ajax-loading' ), 'show_if' => $prefetch_on, ), 'cache_enabled' => array( 'label' => __( 'Cache visited pages', 'ajax-loading' ), 'desc' => __( 'Returning to a recently visited page is instant. Logged-in visitors and personalised pages are never cached.', 'ajax-loading' ), ), 'cache_max_pages' => array( 'label' => __( 'Pages to keep', 'ajax-loading' ), 'desc' => __( 'Maximum number of pages kept. The oldest page is dropped first when the limit is reached.', 'ajax-loading' ), 'show_if' => array( array( 'fields' => array( 'cache_enabled' ), 'in' => array( '1' ), ), ), ), 'cache_ttl' => array( 'label' => __( 'Keep pages for', 'ajax-loading' ), 'desc' => __( 'How long a cached page may be reused.', 'ajax-loading' ), 'unit' => $seconds, 'show_if' => array( array( 'fields' => array( 'cache_enabled' ), 'in' => array( '1' ), ), ), ), 'cache_max_kb' => array( 'label' => __( 'Memory budget', 'ajax-loading' ), 'desc' => __( 'Upper limit for the HTML kept in memory.', 'ajax-loading' ), 'unit' => _x( 'KB', 'kilobytes unit', 'ajax-loading' ), 'show_if' => array( array( 'fields' => array( 'cache_enabled' ), 'in' => array( '1' ), ), ), ), 'request_timeout' => array( 'label' => __( 'Request timeout', 'ajax-loading' ), 'desc' => __( 'If a page takes longer than this, the browser loads it normally instead.', 'ajax-loading' ), 'unit' => $ms, ), 'disable_core_speculation' => array( 'label' => __( 'Turn off WordPress speculative loading', 'ajax-loading' ), 'desc' => __( 'WordPress 6.8 and later prefetch links on their own. Turning this off while AJAX navigation is active avoids downloading pages twice.', 'ajax-loading' ), ), 'preserve_scroll' => array( 'label' => __( 'Restore scroll position', 'ajax-loading' ), 'desc' => __( 'Back and forward return to where the visitor was on that page.', 'ajax-loading' ), ), 'preserve_form_state' => array( 'label' => __( 'Restore search and filter inputs', 'ajax-loading' ), 'desc' => __( 'Back and forward restore what the visitor typed into search and filter forms.', 'ajax-loading' ), ), 'sync_menu_state' => array( 'label' => __( 'Highlight the current menu item', 'ajax-loading' ), 'desc' => __( 'Keep your menus\' "current page" highlighting and its screen reader equivalent in sync.', 'ajax-loading' ), ), // SEO and integrations. 'sync_metadata' => array( 'label' => __( 'Update meta tags', 'ajax-loading' ), 'desc' => __( 'Description, robots, Open Graph, Twitter cards and similar tags.', 'ajax-loading' ), ), 'sync_canonical' => array( 'label' => __( 'Update canonical and alternate links', 'ajax-loading' ), 'desc' => __( 'Canonical URL, previous/next, shortlink and language alternates (hreflang).', 'ajax-loading' ), ), 'sync_structured_data' => array( 'label' => __( 'Update structured data', 'ajax-loading' ), 'desc' => __( 'Swap the JSON-LD (schema.org) blocks in the page head. They are never executed.', 'ajax-loading' ), ), 'analytics_mode' => array( 'label' => __( 'Page view tracking', 'ajax-loading' ), 'control' => 'radio', 'choices' => array( 'none' => __( 'Automatic: let Google Analytics or Tag Manager detect navigations', 'ajax-loading' ), 'gtag' => __( 'Send GA4 page_view events (gtag.js)', 'ajax-loading' ), 'datalayer' => __( 'Push a dataLayer event for Google Tag Manager', 'ajax-loading' ), ), 'choice_desc' => array( 'none' => __( 'Recommended. GA4 Enhanced Measurement ("Page changes based on browser history events") or a GTM History Change trigger counts each navigation.', 'ajax-loading' ), 'gtag' => __( 'Requires gtag.js on your site. Turn off GA4\'s history-based page views, or every navigation is counted twice.', 'ajax-loading' ), 'datalayer' => __( 'Create a Custom Event trigger in Tag Manager for the event name below.', 'ajax-loading' ), ), 'notice' => array( 'status' => 'warning', 'in' => array( 'gtag' ), 'text' => sprintf( /* translators: 1: opening strong tag, 2: closing strong tag, 3: GA4 option name. */ __( '%1$sAvoid double-counted page views.%2$s In Google Analytics go to Admin → Data streams → your web stream → Enhanced measurement → Page views → Show advanced settings, and turn off %3$s. Otherwise GA4 counts every AJAX navigation a second time.', 'ajax-loading' ), '', '', '' . esc_html__( 'Page changes based on browser history events', 'ajax-loading' ) . '' ), ), ), 'analytics_timing' => array( 'label' => __( 'Send navigation timing', 'ajax-loading' ), 'desc' => __( 'Also send how long each AJAX navigation took, in milliseconds.', 'ajax-loading' ), 'show_if' => array( array( 'fields' => array( 'analytics_mode' ), 'in' => array( 'gtag', 'datalayer' ), ), ), ), 'gtm_event_name' => array( 'label' => __( 'dataLayer event name', 'ajax-loading' ), 'desc' => __( 'Letters, numbers, dots, dashes and underscores; up to 64 characters.', 'ajax-loading' ), 'placeholder' => 'ajax_page_view', 'maxlength' => 64, 'pattern' => '[A-Za-z0-9_.\-]{1,64}', 'show_if' => array( array( 'fields' => array( 'analytics_mode' ), 'in' => array( 'datalayer' ), ), ), ), 'analytics_require_consent' => array( 'label' => __( 'Require statistics consent', 'ajax-loading' ), 'desc' => sprintf( /* translators: %s: consent category name. */ __( 'Only track navigations when the WP Consent API reports consent for %s. Has no effect if no consent plugin is installed.', 'ajax-loading' ), 'statistics' ), ), 'analytics_callback' => array( 'label' => __( 'Custom tracking function', 'ajax-loading' ), 'desc' => __( 'Name of a global JavaScript function (dotted paths allowed) called after every navigation with the navigation details. Leave empty for none.', 'ajax-loading' ), 'placeholder' => 'myTheme.trackPage', 'maxlength' => 100, 'pattern' => '[A-Za-z_$][\w$]*(\.[A-Za-z_$][\w$]*)*', ), 'integrations' => array( 'label' => __( 'Integrations', 'ajax-loading' ), 'desc' => __( 'How AJAX navigation handles each plugin or feature. "Detected" means it is active on this site.', 'ajax-loading' ), ), // Exclusions. 'exclude_urls' => array( 'label' => __( 'Excluded URLs', 'ajax-loading' ), 'desc' => __( 'One pattern per line. See the pattern syntax below.', 'ajax-loading' ), 'placeholder' => "/shop/*\n/landing-page/", 'rows' => 5, ), 'exclude_selectors' => array( 'label' => __( 'Excluded links (CSS selectors)', 'ajax-loading' ), 'desc' => __( 'Links inside or matching these selectors load normally. One selector per line.', 'ajax-loading' ), 'placeholder' => ".main-menu .external\n#footer-links a", 'rows' => 4, ), 'exclude_post_types' => array( 'label' => __( 'Excluded post types', 'ajax-loading' ), 'desc' => __( 'Single entries and archives of these types always load normally.', 'ajax-loading' ), ), 'exclude_woocommerce' => array( 'label' => __( 'Protect WooCommerce pages', 'ajax-loading' ), 'desc' => __( 'Cart, checkout, My Account, order pages and cart actions always load normally.', 'ajax-loading' ), ), 'exclude_membership' => array( 'label' => __( 'Protect login and membership pages', 'ajax-loading' ), 'desc' => __( 'Login, registration, checkout and account pages of popular membership plugins load normally.', 'ajax-loading' ), ), 'interactive_blocks' => array( 'label' => __( 'Interactive blocks', 'ajax-loading' ), 'control' => 'radio', 'choices' => array( 'reload' => __( 'Load these pages normally (recommended)', 'ajax-loading' ), 'replace' => __( 'Replace them with AJAX', 'ajax-loading' ), ), 'desc' => __( 'Content built with the WordPress Interactivity API (for example some Query Loop, Search and Navigation blocks) often stops working after an AJAX swap.', 'ajax-loading' ), ), // Tools. 'safe_mode' => array( 'label' => __( 'Safe mode', 'ajax-loading' ), 'desc' => __( 'Conservative behaviour for troubleshooting: no prefetching or caching, header and footer kept, page builder pages load normally, no synthetic events.', 'ajax-loading' ), ), 'auto_disable' => array( 'label' => __( 'Automatic fallback', 'ajax-loading' ), 'desc' => __( 'If navigation fails repeatedly for a visitor, switch that visitor to normal page loads for a while.', 'ajax-loading' ), ), 'auto_disable_threshold' => array( 'label' => __( 'Failures before fallback', 'ajax-loading' ), 'desc' => __( 'Number of failed navigations within 5 minutes.', 'ajax-loading' ), 'show_if' => array( array( 'fields' => array( 'auto_disable' ), 'in' => array( '1' ), ), ), ), 'auto_disable_minutes' => array( 'label' => __( 'Fallback duration', 'ajax-loading' ), 'desc' => __( 'How long the visitor gets normal page loads after repeated failures.', 'ajax-loading' ), 'unit' => $minutes, 'show_if' => array( array( 'fields' => array( 'auto_disable' ), 'in' => array( '1' ), ), ), ), 'debug_mode' => array( 'label' => __( 'Debug logging', 'ajax-loading' ), 'desc' => __( 'Write navigation details to the browser console. Turn off on production sites.', 'ajax-loading' ), ), 'error_logging' => array( 'label' => __( 'Record navigation errors', 'ajax-loading' ), 'desc' => __( 'Store the last 50 failed navigations (error code and page path only, no personal data) and show them under Recent errors.', 'ajax-loading' ), ), ); return self::$meta; } /** * Metadata for one key (with a readable fallback label). * * @param string $key Setting key. * @return array */ public static function field_meta( $key ) { $meta = self::meta(); $item = isset( $meta[ $key ] ) ? $meta[ $key ] : array(); if ( empty( $item['label'] ) ) { $item['label'] = ucfirst( str_replace( '_', ' ', (string) $key ) ); } return $item; } /** * Dashboard tab a schema entry belongs to ('' = not shown). * * @param string $key Setting key. * @param mixed $def Schema entry. * @return string */ public static function ui_tab_of( $key, $def ) { if ( in_array( $key, self::HIDDEN_KEYS, true ) || ! is_array( $def ) || empty( $def['tab'] ) || ! is_string( $def['tab'] ) ) { return ''; } $tab = strtolower( $def['tab'] ); if ( 'advanced' === $tab || 0 === strpos( $tab, 'general' ) ) { return 'general'; } return in_array( $tab, self::SETTINGS_TABS, true ) ? $tab : ''; } /** * Schema keys rendered on a tab, in schema order. * * @param array $schema Settings schema. * @param string $tab Tab slug. * @return string[] */ public static function keys_for_tab( array $schema, $tab ) { $keys = array(); foreach ( $schema as $key => $def ) { if ( self::ui_tab_of( $key, $def ) === $tab ) { $keys[] = (string) $key; } } return $keys; } /** * Map of key => type for every visible field (consumed by the admin script). * * @param array $schema Settings schema. * @return array */ public static function types( array $schema ) { $types = array(); foreach ( $schema as $key => $def ) { if ( '' !== self::ui_tab_of( $key, $def ) && ! empty( $def['type'] ) ) { $types[ $key ] = (string) $def['type']; } } return $types; } /** * Normalised list of allowed values of an enum/multi schema entry. * * Accepts a plain list of values or a value => label map. * * @param array $def Schema entry. * @return string[] */ public static function choice_values( array $def ) { if ( empty( $def['choices'] ) || ! is_array( $def['choices'] ) ) { return array(); } $choices = $def['choices']; $is_list = array_keys( $choices ) === range( 0, count( $choices ) - 1 ); $raw = $is_list ? array_values( $choices ) : array_keys( $choices ); $values = array(); foreach ( $raw as $value ) { if ( is_scalar( $value ) && '' !== (string) $value ) { $values[] = (string) $value; } } return array_values( array_unique( $values ) ); } /** * Human label of a choice value. * * @param string $key Setting key. * @param string $value Choice value. * @param array $def Optional schema entry (value => label maps are used as a fallback). * @return string */ public static function choice_label( $key, $value, array $def = array() ) { $meta = self::field_meta( $key ); $value = (string) $value; if ( isset( $meta['choices'][ $value ] ) ) { return $meta['choices'][ $value ]; } if ( isset( $def['choices'][ $value ] ) && is_string( $def['choices'][ $value ] ) ) { return $def['choices'][ $value ]; } return ucfirst( str_replace( array( '-', '_' ), ' ', $value ) ); } /** * Input name for a key. * * @param string $key Setting key. * @param string $suffix Optional extra bracket part (e.g. '[]' or '[woocommerce]'). * @return string */ public static function name( $key, $suffix = '' ) { return self::OPTION . '[' . $key . ']' . $suffix; } /** * Element id for a key. * * @param string $key Setting key. * @param string $suffix Optional suffix. * @return string */ public static function id( $key, $suffix = '' ) { $id = 'ajxl-field-' . str_replace( '_', '-', sanitize_key( $key ) ); return '' === $suffix ? $id : $id . '-' . sanitize_key( $suffix ); } /** * Loose boolean conversion for stored values. * * @param mixed $value Value. * @return bool */ public static function to_bool( $value ) { if ( is_bool( $value ) ) { return $value; } if ( is_string( $value ) ) { return in_array( strtolower( $value ), array( '1', 'on', 'yes', 'true' ), true ); } return ! empty( $value ); } /** * Render all sections of a settings tab. * * Fields of the tab that are not listed in a known section (for example * fields added through the 'ajax_loading_settings_schema' filter) are * rendered in a trailing "More settings" section so none is ever lost. * * @param string $tab Tab slug. * @param array $schema Settings schema. * @param array $values Stored settings. * @return void */ public static function render_tab( $tab, array $schema, array $values ) { $tab_keys = self::keys_for_tab( $schema, $tab ); $sections = self::sections(); $sections = isset( $sections[ $tab ] ) ? $sections[ $tab ] : array(); $placed = array(); foreach ( $sections as $section ) { $keys = array_values( array_intersect( $section['keys'], $tab_keys ) ); if ( empty( $keys ) ) { continue; } $placed = array_merge( $placed, $keys ); self::render_section( $section, $keys, $schema, $values ); } $rest = array_values( array_diff( $tab_keys, $placed ) ); if ( ! empty( $rest ) ) { self::render_section( array( 'id' => 'more', 'title' => __( 'More settings', 'ajax-loading' ), 'intro' => '', ), $rest, $schema, $values ); } } /** * Render one section card. * * @param array $section Section definition. * @param array $keys Keys to render. * @param array $schema Settings schema. * @param array $values Stored settings. * @return void */ public static function render_section( array $section, array $keys, array $schema, array $values ) { $collapsible = ! empty( $section['collapsible'] ); $id = 'ajxl-section-' . sanitize_key( $section['id'] ); $table_keys = array(); $block_keys = array(); foreach ( $keys as $key ) { $type = isset( $schema[ $key ]['type'] ) ? $schema[ $key ]['type'] : 'text'; if ( 'map' === $type ) { $block_keys[] = $key; } else { $table_keys[] = $key; } } if ( $collapsible ) { printf( '
', esc_attr( $id ) ); echo '

' . esc_html( $section['title'] ) . '

'; } else { printf( '
', esc_attr( $id ) ); printf( '

%s

', esc_attr( $id ), esc_html( $section['title'] ) ); } if ( ! empty( $section['intro'] ) ) { echo '

' . esc_html( $section['intro'] ) . '

'; } if ( ! empty( $table_keys ) ) { echo ''; foreach ( $table_keys as $key ) { $value = array_key_exists( $key, $values ) ? $values[ $key ] : ( isset( $schema[ $key ]['default'] ) ? $schema[ $key ]['default'] : '' ); self::render_row( $key, $schema[ $key ], $value ); } echo ''; } foreach ( $block_keys as $key ) { $value = array_key_exists( $key, $values ) ? $values[ $key ] : ( isset( $schema[ $key ]['default'] ) ? $schema[ $key ]['default'] : array() ); self::render_map( $key, $schema[ $key ], $value ); } echo $collapsible ? '
' : ''; } /** * Render a form-table row for one field. * * @param string $key Setting key. * @param array $def Schema entry. * @param mixed $value Current value. * @return void */ public static function render_row( $key, array $def, $value ) { $meta = self::field_meta( $key ); $type = isset( $def['type'] ) ? (string) $def['type'] : 'text'; $control = self::control_for( $type, $def, $meta ); $id = self::id( $key ); $grouped = in_array( $control, array( 'radio', 'checkboxes' ), true ); $attrs = sprintf( ' class="ajxl-field ajxl-field--%s" data-ajxl-field="%s"', esc_attr( $control ), esc_attr( $key ) ); if ( ! empty( $meta['show_if'] ) ) { $attrs .= ' data-ajxl-show-if="' . esc_attr( wp_json_encode( $meta['show_if'] ) ) . '"'; } echo ''; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- attributes escaped above. echo ''; if ( $grouped ) { printf( '%s', esc_attr( $id ), esc_html( $meta['label'] ) ); } else { printf( '', esc_attr( $id ), esc_html( $meta['label'] ) ); } echo ''; self::render_control( $key, $def, $value, $meta, $control ); if ( ! empty( $meta['desc'] ) ) { printf( '

%s

', esc_attr( $id ), wp_kses( $meta['desc'], self::allowed_html() ) ); } echo ''; if ( ! empty( $meta['notice']['text'] ) && ! empty( $meta['notice']['in'] ) ) { self::render_notice_row( $key, $value, $meta['notice'] ); } } /** * Callout row shown under a field while the field has one of the given values. * * Initial visibility is computed on the server (works without JavaScript); * the admin script toggles it live. * * @param string $key Setting key. * @param mixed $value Current value. * @param array $notice { status, in[], text }. * @return void */ private static function render_notice_row( $key, $value, array $notice ) { $values = array_map( 'strval', (array) $notice['in'] ); $visible = is_scalar( $value ) && in_array( (string) $value, $values, true ); $status = isset( $notice['status'] ) && in_array( $notice['status'], array( 'info', 'warning', 'error' ), true ) ? $notice['status'] : 'info'; $cond = array( array( 'fields' => array( $key ), 'in' => $values, ), ); printf( '

%4$s

', esc_attr( wp_json_encode( $cond ) ), $visible ? '' : ' hidden', esc_attr( $status ), wp_kses( $notice['text'], self::allowed_html() ) ); } /** * Pick the control for a field type. * * @param string $type Schema type. * @param array $def Schema entry. * @param array $meta Field metadata. * @return string */ private static function control_for( $type, array $def, array $meta ) { switch ( $type ) { case 'onoff': case 'bool': return 'toggle'; case 'int': case 'float': return ( isset( $meta['control'] ) && 'media' === $meta['control'] ) ? 'media' : 'number'; case 'enum': if ( isset( $meta['control'] ) && in_array( $meta['control'], array( 'radio', 'select' ), true ) ) { return $meta['control']; } return count( self::choice_values( $def ) ) <= 3 ? 'radio' : 'select'; case 'multi': case 'post_types': return 'checkboxes'; case 'lines': return 'textarea'; case 'color': return 'color'; default: return 'text'; } } /** * Echo the control markup for a field. * * @param string $key Setting key. * @param array $def Schema entry. * @param mixed $value Current value. * @param array $meta Field metadata. * @param string $control Control kind. * @return void */ private static function render_control( $key, array $def, $value, array $meta, $control ) { $id = self::id( $key ); $type = isset( $def['type'] ) ? (string) $def['type'] : 'text'; $described = empty( $meta['desc'] ) ? '' : sprintf( ' aria-describedby="%s-desc"', esc_attr( $id ) ); switch ( $control ) { case 'toggle': $is_onoff = ( 'onoff' === $type ); $checked = $is_onoff ? ( 'on' === $value ) : self::to_bool( $value ); // The hidden input carries the "unchecked" value; a checked box overrides it (last value wins). printf( '', esc_attr( self::name( $key ) ), $is_onoff ? 'off' : '0' ); printf( '', esc_attr( $id ), esc_attr( self::name( $key ) ), $is_onoff ? 'on' : '1', checked( $checked, true, false ), $described // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped when built. ); return; case 'number': // The unit is part of the accessible description, so "300" is announced as "300 seconds". $described_ids = array(); if ( ! empty( $meta['desc'] ) ) { $described_ids[] = $id . '-desc'; } if ( ! empty( $meta['unit'] ) ) { $described_ids[] = $id . '-unit'; } $described = $described_ids ? sprintf( ' aria-describedby="%s"', esc_attr( implode( ' ', $described_ids ) ) ) : ''; $step = isset( $def['step'] ) ? $def['step'] : ( 'float' === $type ? 'any' : 1 ); $min = isset( $def['min'] ) ? sprintf( ' min="%s"', esc_attr( $def['min'] ) ) : ''; $max = isset( $def['max'] ) ? sprintf( ' max="%s"', esc_attr( $def['max'] ) ) : ''; $wide = ( isset( $def['max'] ) && $def['max'] > 99999 ) ? 'regular-text' : 'small-text'; printf( '', esc_attr( $wide ), esc_attr( $id ), esc_attr( self::name( $key ) ), esc_attr( is_scalar( $value ) ? (string) $value : '' ), esc_attr( $step ), $min, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped when built. $max, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped when built. $described, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped when built. 'float' === $type ? 'decimal' : 'numeric' ); if ( ! empty( $meta['unit'] ) ) { printf( ' %2$s', esc_attr( $id ), esc_html( $meta['unit'] ) ); } return; case 'media': self::render_media( $key, $value, $meta ); return; case 'select': printf( ''; return; case 'radio': printf( '
', esc_attr( $id ), $described ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- $described escaped when built. printf( '%s', esc_html( $meta['label'] ) ); foreach ( self::choice_values( $def ) as $index => $choice ) { $choice_id = $id . '-' . $index; echo '
'; printf( ' ', esc_attr( $choice_id ), esc_attr( self::name( $key ) ), esc_attr( $choice ), checked( (string) $value, $choice, false ), esc_html( self::choice_label( $key, $choice, $def ) ) ); if ( ! empty( $meta['choice_desc'][ $choice ] ) ) { printf( '

%s

', esc_html( $meta['choice_desc'][ $choice ] ) ); } echo '
'; } echo '
'; return; case 'checkboxes': $selected = array_map( 'strval', is_array( $value ) ? $value : array() ); $choices = 'post_types' === $type ? self::post_type_choices( $selected ) : array(); if ( 'post_types' !== $type ) { foreach ( self::choice_values( $def ) as $choice ) { $choices[ $choice ] = self::choice_label( $key, $choice, $def ); } } printf( '
', esc_attr( $id ), $described ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- $described escaped when built. printf( '%s', esc_html( $meta['label'] ) ); // Empty sentinel so that unchecking every box still submits the key. printf( '', esc_attr( self::name( $key, '[]' ) ) ); if ( empty( $choices ) ) { echo '

' . esc_html__( 'Nothing to choose from on this site.', 'ajax-loading' ) . '

'; } $index = 0; foreach ( $choices as $choice => $label ) { $choice_id = $id . '-' . $index++; printf( '
', esc_attr( $choice_id ), esc_attr( self::name( $key, '[]' ) ), esc_attr( $choice ), checked( in_array( (string) $choice, $selected, true ), true, false ), esc_html( $label ) ); } echo '
'; return; case 'textarea': $text = is_array( $value ) ? implode( "\n", array_map( 'strval', array_filter( $value, 'is_scalar' ) ) ) : (string) $value; printf( '', esc_attr( $id ), esc_attr( self::name( $key ) ), isset( $meta['rows'] ) ? (int) $meta['rows'] : 4, esc_attr( isset( $meta['placeholder'] ) ? $meta['placeholder'] : '' ), $described, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped when built. esc_textarea( $text ) ); return; case 'color': $default = isset( $def['default'] ) && is_string( $def['default'] ) ? $def['default'] : ''; printf( '', esc_attr( $id ), esc_attr( self::name( $key ) ), esc_attr( is_string( $value ) ? $value : $default ), esc_attr( $default ), $described // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped when built. ); return; default: $pattern = empty( $meta['pattern'] ) ? '' : sprintf( ' pattern="%s"', esc_attr( $meta['pattern'] ) ); printf( '', esc_attr( $id ), esc_attr( self::name( $key ) ), esc_attr( is_scalar( $value ) ? (string) $value : '' ), isset( $meta['maxlength'] ) ? (int) $meta['maxlength'] : 500, esc_attr( isset( $meta['placeholder'] ) ? $meta['placeholder'] : '' ), $pattern, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped when built. $described // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped when built. ); } } /** * Media (attachment id) control with a wp.media picker. * * Without JavaScript the attachment id can be typed into the number field. * * @param string $key Setting key. * @param mixed $value Attachment id. * @param array $meta Field metadata. * @return void */ private static function render_media( $key, $value, array $meta ) { $id = self::id( $key ); $attach = absint( $value ); $url = $attach ? wp_get_attachment_image_url( $attach, 'medium' ) : ''; $url = $url ? $url : ''; $describe = empty( $meta['desc'] ) ? '' : sprintf( ' aria-describedby="%s-desc"', esc_attr( $id ) ); echo '
'; printf( '
%s
', $url ? sprintf( '', esc_url( $url ) ) : '' . esc_html__( 'No image selected', 'ajax-loading' ) . '' ); // With JavaScript the number input is hidden: the group carries the field label for both buttons. printf( '
', esc_attr( $id ) ); printf( ' ', esc_attr( $id ), esc_attr( self::name( $key ) ), esc_attr( (string) $attach ), esc_url( $url ), $describe // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped when built. ); echo '' . esc_html__( 'Attachment ID', 'ajax-loading' ) . ''; echo ' '; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- $describe escaped when built. echo ''; echo '
'; } /** * Public post types as value => label (selected but no longer public types are kept visible). * * @param string[] $selected Selected names. * @return array */ public static function post_type_choices( array $selected = array() ) { $choices = array(); foreach ( get_post_types( array( 'public' => true ), 'objects' ) as $name => $object ) { $label = isset( $object->labels->name ) ? $object->labels->name : $name; $choices[ $name ] = sprintf( '%1$s (%2$s)', $label, $name ); } asort( $choices, SORT_NATURAL | SORT_FLAG_CASE ); foreach ( $selected as $name ) { if ( '' !== $name && ! isset( $choices[ $name ] ) ) { $choices[ $name ] = $name; } } return $choices; } /** * Allowed modes of an integration. * * Uses (in order) the integration's own 'modes', per-key schema choices, a * flat schema choice list, then the documented defaults (§15.2). * * @param string $key Integration key. * @param array $def Schema entry of the map setting. * @param array $info Integration info from WPAJXL_Compat::integrations(). * @return string[] */ public static function integration_modes( $key, array $def, array $info ) { $exclude_only = array( 'ninja_forms', 'acf_forms', 'buddypress' ); if ( ! empty( $info['modes'] ) && is_array( $info['modes'] ) ) { $modes = array_map( 'strval', array_values( $info['modes'] ) ); } elseif ( isset( $def['choices'][ $key ] ) && is_array( $def['choices'][ $key ] ) ) { $modes = self::choice_values( array( 'choices' => $def['choices'][ $key ] ) ); } else { $modes = self::choice_values( $def ); if ( empty( $modes ) || array_diff( $modes, array( 'auto', 'exclude', 'off' ) ) ) { $modes = array( 'auto', 'exclude', 'off' ); } if ( in_array( $key, $exclude_only, true ) ) { $modes = array_values( array_diff( $modes, array( 'auto' ) ) ); } } return empty( $modes ) ? array( 'auto', 'exclude', 'off' ) : $modes; } /** * Human label of an integration mode. * * @param string $mode Mode. * @return string */ public static function integration_mode_label( $mode ) { static $labels = null; if ( null === $labels ) { $labels = WPAJXL_Admin::invoke( 'WPAJXL_Compat', 'mode_labels', array(), array() ); $labels = array_merge( array( 'auto' => __( 'Auto (built-in handling)', 'ajax-loading' ), 'exclude' => __( 'Exclude (full page load)', 'ajax-loading' ), 'off' => __( 'Off (no special handling)', 'ajax-loading' ), ), is_array( $labels ) ? array_filter( $labels, 'is_string' ) : array() ); } return isset( $labels[ $mode ] ) ? $labels[ $mode ] : ucfirst( (string) $mode ); } /** * Render a map setting (integrations) as a table with one select per row. * * @param string $key Setting key. * @param array $def Schema entry. * @param mixed $value Current map. * @return void */ public static function render_map( $key, array $def, $value ) { $value = is_array( $value ) ? $value : array(); $defaults = isset( $def['default'] ) && is_array( $def['default'] ) ? $def['default'] : array(); $integrations = 'integrations' === $key ? WPAJXL_Admin::invoke( 'WPAJXL_Compat', 'integrations', array(), array() ) : array(); $integrations = is_array( $integrations ) ? $integrations : array(); $rows = array_unique( array_merge( array_keys( $integrations ), array_keys( $defaults ), array_keys( $value ) ) ); $meta = self::field_meta( $key ); $id = self::id( $key ); printf( '
', esc_attr( $key ) ); $described = ''; if ( ! empty( $meta['desc'] ) ) { printf( '

%2$s

', esc_attr( $id ), wp_kses( $meta['desc'], self::allowed_html() ) ); $described = sprintf( ' aria-describedby="%s-desc"', esc_attr( $id ) ); } printf( '', esc_attr( $meta['label'] ), $described ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- $described escaped when built. echo ''; echo ''; echo ''; echo ''; echo ''; foreach ( $rows as $row ) { $row = (string) $row; $info = isset( $integrations[ $row ] ) && is_array( $integrations[ $row ] ) ? $integrations[ $row ] : array(); $label = ! empty( $info['label'] ) ? (string) $info['label'] : ucwords( str_replace( '_', ' ', $row ) ); $modes = self::integration_modes( $row, $def, $info ); $fall = isset( $info['default'] ) ? (string) $info['default'] : ( isset( $defaults[ $row ] ) ? (string) $defaults[ $row ] : $modes[0] ); $current = isset( $value[ $row ] ) && is_scalar( $value[ $row ] ) ? (string) $value[ $row ] : $fall; $sel_id = $id . '-' . sanitize_key( $row ); echo ''; echo ''; } if ( empty( $rows ) ) { echo ''; } echo '
' . esc_html__( 'Integration', 'ajax-loading' ) . '' . esc_html__( 'On this site', 'ajax-loading' ) . '' . esc_html__( 'Behaviour', 'ajax-loading' ) . '
'; if ( ! empty( $info['description'] ) ) { echo '

' . esc_html( (string) $info['description'] ) . '

'; } echo '
'; if ( array_key_exists( 'active', $info ) ) { echo ! empty( $info['active'] ) ? WPAJXL_Admin::pill( 'ok', __( 'Detected', 'ajax-loading' ) ) // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- pill() escapes. : WPAJXL_Admin::pill( 'neutral', __( 'Not detected', 'ajax-loading' ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- pill() escapes. } else { echo '—'; } echo ''; printf( '
' . esc_html__( 'No integrations are available.', 'ajax-loading' ) . '
'; } }