get_settings(); // Bail early if the overlay is disabled globally. if ( empty( $settings['enabled'] ) ) { return; } // Validate request context to prevent loading on excluded pages or feeds. if ( $this->should_skip_current_request( $settings ) ) { return; } $plugin_url = defined( 'ACLS_PLUGIN_URL' ) ? ACLS_PLUGIN_URL : ( defined( 'Pure_CSS_Spinners_PLUGIN_URL' ) ? Pure_CSS_Spinners_PLUGIN_URL : plugin_dir_url( dirname( __DIR__ ) ) ); $version = defined( 'ACLS_VERSION' ) ? ACLS_VERSION : ( defined( 'Pure_CSS_Spinners_VERSION' ) ? Pure_CSS_Spinners_VERSION : '1.1.6' ); // Enqueue the primary frontend stylesheet. wp_enqueue_style( $this->frontend_style_handle, $plugin_url . 'public/css/acls-spinners.css', array(), $version ); // Enqueue the dynamic configuration styles via the external stylesheet endpoint. $dynamic_css = $this->get_dynamic_css( $settings ); if ( ! empty( $dynamic_css ) ) { $dynamic_ver = substr( md5( $dynamic_css ), 0, 12 ); wp_enqueue_style( $this->dynamic_style_handle, admin_url( 'admin-post.php?action=acls_dynamic_css' ), array( $this->frontend_style_handle ), $dynamic_ver ); } // Enqueue the main frontend JavaScript file. wp_enqueue_script( $this->frontend_script_handle, $plugin_url . 'public/js/acls-public.js', array(), $version, true ); // Apply the compatible loading strategy and render non-executable configuration markup. $this->add_defer_strategy( $this->frontend_script_handle ); $this->should_render_config = true; } /** * Enqueue public spinner CSS for the admin settings preview only. * * This intentionally does not enqueue the public JavaScript because the * overlay behavior should not run throughout wp-admin. * * @since 1.0.0 * * @param string $hook Current admin page hook. * @return void */ public function enqueue_admin_preview_assets( $hook ) { // Only load the preview styles on the specific AeroSpin CSS Loading Spinners settings page. $valid_hooks = array( 'toplevel_page_acls-settings', 'settings_page_acls-settings', 'toplevel_page_aerospin-css-loading-spinners', 'settings_page_aerospin-css-loading-spinners', 'settings_page_pure-css-spinners', 'settings_page_clearpath-spinners', 'settings_page_cp-spinners', ); if ( ! in_array( $hook, $valid_hooks, true ) ) { return; } // Check to prevent duplicate enqueueing if the style is already loaded. if ( wp_style_is( 'aerospin-css-loading-spinners-public', 'enqueued' ) || wp_style_is( 'acls-public', 'enqueued' ) || wp_style_is( $this->admin_preview_style_handle, 'enqueued' ) ) { return; } $plugin_url = defined( 'ACLS_PLUGIN_URL' ) ? ACLS_PLUGIN_URL : ( defined( 'Pure_CSS_Spinners_PLUGIN_URL' ) ? Pure_CSS_Spinners_PLUGIN_URL : plugin_dir_url( dirname( __DIR__ ) ) ); $version = defined( 'ACLS_VERSION' ) ? ACLS_VERSION : ( defined( 'Pure_CSS_Spinners_VERSION' ) ? Pure_CSS_Spinners_VERSION : '1.1.6' ); // Enqueue the admin preview styles. wp_enqueue_style( $this->admin_preview_style_handle, $plugin_url . 'public/css/acls-spinners.css', array(), $version ); } /* |-------------------------------------------------------------------------- | SECTION 3: Script Localization |-------------------------------------------------------------------------- */ /** * Localize sanitized frontend configuration. * * @since 1.0.0 * * @return void */ public function render_script_config() { if ( ! $this->should_render_config ) { return; } $script_json = wp_json_encode( $this->get_script_data( $this->get_settings() ), JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT ); if ( ! is_string( $script_json ) || '' === $script_json ) { return; } ?>
get_settings(); } $opacity = $this->clamp_int( isset( $settings['overlay_opacity'] ) ? $settings['overlay_opacity'] : 85, 0, 100 ) / 100; $size = $this->clamp_int( isset( $settings['spinner_size'] ) ? $settings['spinner_size'] : 60, 16, 240 ); $z_index = $this->clamp_int( isset( $settings['z_index'] ) ? $settings['z_index'] : 99999, 1, 2147483647 ); $fade = $this->clamp_int( isset( $settings['fade_out_speed'] ) ? $settings['fade_out_speed'] : 400, 0, 5000 ); $blur = $this->clamp_int( isset( $settings['overlay_blur'] ) ? $settings['overlay_blur'] : 0, 0, 30 ); return sprintf( '#acls-overlay,.acls-overlay,#cps-overlay,.cps-overlay{background-color:%1$s;z-index:%2$d;backdrop-filter:blur(%3$dpx);-webkit-backdrop-filter:blur(%3$dpx);transition-duration:%4$dms}.acls-spinner-wrap,.cps-spinner-wrap{--acls-color:%5$s;--acls-secondary:%6$s;--acls-size:%7$dpx;--cps-color:%5$s;--cps-secondary:%6$s;--cps-size:%7$dpx}', esc_attr( $this->get_overlay_rgba( isset( $settings['overlay_color'] ) ? $settings['overlay_color'] : '#ffffff', $opacity ) ), absint( $z_index ), absint( $blur ), absint( $fade ), esc_attr( $this->sanitize_color( isset( $settings['spinner_color'] ) ? $settings['spinner_color'] : '#4f46e5', '#4f46e5' ) ), esc_attr( $this->sanitize_color( isset( $settings['spinner_secondary'] ) ? $settings['spinner_secondary'] : '#e0e7ff', '#e0e7ff' ) ), absint( $size ) ); } /** * Output the sanitized, settings-specific stylesheet as an external response. * * @return void */ public function render_dynamic_stylesheet() { $css = $this->get_dynamic_css(); $etag = '"' . md5( $css ) . '"'; header( 'Content-Type: text/css; charset=UTF-8' ); header( 'X-Content-Type-Options: nosniff' ); header( 'Cache-Control: public, max-age=86400, stale-while-revalidate=604800' ); header( 'ETag: ' . $etag ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotValidated $if_none_match = isset( $_SERVER['HTTP_IF_NONE_MATCH'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_IF_NONE_MATCH'] ) ) : ''; if ( '' !== $if_none_match && trim( $if_none_match ) === $etag ) { status_header( 304 ); exit; } // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- CSS generated and sanitized via get_dynamic_css(). echo $css; exit; } /** * Convert a validated hexadecimal or RGBA color to an rgba CSS value. * * @param mixed $color Hexadecimal or RGBA color. * @param float $opacity Alpha channel value. * @return string */ private function get_overlay_rgba( $color, $opacity ) { $sanitized = $this->sanitize_color( $color, '#ffffff' ); // Check for valid RGBA color and apply the requested opacity. if ( preg_match( '/^rgba\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*([0-9.]+)\s*\)$/i', $sanitized, $matches ) ) { $r = (int) $matches[1]; $g = (int) $matches[2]; $b = (int) $matches[3]; return sprintf( 'rgba(%1$d,%2$d,%3$d,%4$s)', $r, $g, $b, (string) $opacity ); } $hex = ltrim( $sanitized, '#' ); if ( 3 === strlen( $hex ) ) { $hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2]; } if ( 6 === strlen( $hex ) ) { return sprintf( 'rgba(%1$d,%2$d,%3$d,%4$s)', hexdec( substr( $hex, 0, 2 ) ), hexdec( substr( $hex, 2, 2 ) ), hexdec( substr( $hex, 4, 2 ) ), (string) $opacity ); } return sprintf( 'rgba(255,255,255,%s)', (string) $opacity ); } /** * Get safe frontend script data. * * WP localization helpers are intentionally avoided because this payload is * configuration data, sanitized markup, and interface strings, not only * localization strings. * * @since 1.1.4 * * @param array $settings Normalized plugin settings. * @return array */ private function get_script_data( $settings ) { // Determine the active spinner style with a fallback to 'ring'. $spinner_style = isset( $settings['spinner_style'] ) ? $this->sanitize_spinner_key( $settings['spinner_style'] ) : 'ring'; return array( 'spinnerStyle' => $spinner_style, 'spinnerColor' => $this->sanitize_color( isset( $settings['spinner_color'] ) ? $settings['spinner_color'] : '#4f46e5', '#4f46e5' ), 'spinnerSecondary' => $this->sanitize_color( isset( $settings['spinner_secondary'] ) ? $settings['spinner_secondary'] : '#e0e7ff', '#e0e7ff' ), 'spinnerSize' => $this->clamp_int( isset( $settings['spinner_size'] ) ? $settings['spinner_size'] : 60, 16, 240 ), 'overlayColor' => $this->sanitize_color( isset( $settings['overlay_color'] ) ? $settings['overlay_color'] : '#ffffff', '#ffffff' ), 'overlayOpacity' => $this->clamp_int( isset( $settings['overlay_opacity'] ) ? $settings['overlay_opacity'] : 85, 0, 100 ), 'overlayBlur' => $this->clamp_int( isset( $settings['overlay_blur'] ) ? $settings['overlay_blur'] : 0, 0, 30 ), 'zIndex' => $this->clamp_int( isset( $settings['z_index'] ) ? $settings['z_index'] : 99999, 1, 2147483647 ), 'fadeOutSpeed' => $this->clamp_int( isset( $settings['fade_out_speed'] ) ? $settings['fade_out_speed'] : 400, 0, 5000 ), 'showOnExternal' => ! empty( $settings['show_on_external'] ), 'showOnAdminBar' => ! empty( $settings['show_on_admin_bar'] ), 'showOnForms' => ! empty( $settings['show_on_forms'] ), 'excludeSelectors' => isset( $settings['exclude_selectors'] ) ? $this->normalize_multiline_value( $settings['exclude_selectors'] ) : '', 'excludePages' => isset( $settings['exclude_pages'] ) ? $this->normalize_multiline_value( $settings['exclude_pages'] ) : '', 'minDisplayTime' => $this->clamp_int( isset( $settings['min_display_time'] ) ? $settings['min_display_time'] : 0, 0, 10000 ), 'spinnerHtml' => $this->get_spinner_html( $spinner_style ), 'i18n' => array( 'loading' => esc_html__( 'Loading', 'aerospin-css-loading-spinners' ), ), ); } /** * Add defer script strategy with compatibility fallback. * * @since 1.1.4 * * @param string $handle Script handle. * @return void */ private function add_defer_strategy( $handle ) { // Utilize WordPress 6.3+ script strategy loading methods when available. if ( function_exists( 'wp_script_add_data' ) ) { wp_script_add_data( $handle, 'strategy', 'defer' ); } } /* |-------------------------------------------------------------------------- | SECTION 4: Settings and Request Guards |-------------------------------------------------------------------------- */ /** * Get normalized settings. * * @since 1.1.4 * * @return array */ private function get_settings() { // Look for the procedural or helper class function dynamically. if ( function_exists( 'aerospin_css_loading_spinners_get_settings' ) ) { $settings = aerospin_css_loading_spinners_get_settings(); } elseif ( function_exists( 'Pure_CSS_Spinners_get_settings' ) ) { $settings = Pure_CSS_Spinners_get_settings(); } elseif ( class_exists( 'Pure_CSS_Spinners_Helpers' ) && is_callable( array( 'Pure_CSS_Spinners_Helpers', 'get_settings' ) ) ) { $settings = Pure_CSS_Spinners_Helpers::get_settings(); } else { $settings = array(); } // Apply sanitization safely if the helper functions exist. if ( function_exists( 'aerospin_css_loading_spinners_sanitize_settings' ) ) { $settings = aerospin_css_loading_spinners_sanitize_settings( $settings ); } elseif ( function_exists( 'Pure_CSS_Spinners_sanitize_settings' ) ) { $settings = Pure_CSS_Spinners_sanitize_settings( $settings ); } elseif ( class_exists( 'Pure_CSS_Spinners_Helpers' ) && is_callable( array( 'Pure_CSS_Spinners_Helpers', 'sanitize_settings' ) ) ) { $settings = Pure_CSS_Spinners_Helpers::sanitize_settings( $settings ); } return is_array( $settings ) ? $settings : array(); } /** * Determine whether assets should be skipped for the current request. * * @since 1.1.4 * * @param array $settings Normalized plugin settings. * @return bool */ private function should_skip_current_request( $settings ) { // Ignore backend, API requests, feeds, and embeds. if ( is_admin() || wp_doing_ajax() || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) || is_feed() || is_robots() || is_trackback() || is_embed() ) { return true; } // Skip WooCommerce pages known for heavy AJAX operations. if ( $this->is_woocommerce_sensitive_page() ) { return true; } $excluded_pages = isset( $settings['exclude_pages'] ) ? $settings['exclude_pages'] : ''; $current_path = $this->get_current_request_path(); // If we have no active exclusions, do not skip. if ( '' === $current_path || '' === $excluded_pages ) { return false; } // Break lines into an array for processing. $paths = preg_split( '/\r\n|\r|\n/', $excluded_pages ); if ( ! is_array( $paths ) ) { return false; } // Compare the current path against each rule line by line. foreach ( $paths as $path ) { $path = trim( (string) $path ); if ( '' === $path ) { continue; } if ( $this->path_matches_rule( $current_path, $path ) ) { return true; } } return false; } /** * Determine whether the current frontend request is a WooCommerce page * where AJAX cart/checkout behavior is likely to conflict with a global * navigation overlay. * * @since 1.1.4 * * @return bool */ private function is_woocommerce_sensitive_page() { if ( ! class_exists( 'WooCommerce' ) ) { return false; } if ( function_exists( 'is_cart' ) && is_cart() ) { return true; } if ( function_exists( 'is_checkout' ) && is_checkout() ) { return true; } if ( function_exists( 'is_account_page' ) && is_account_page() ) { return true; } return false; } /** * Check whether a current request path matches an exclusion rule. * * @since 1.1.4 * * @param string $current_path Current path. * @param string $rule Exclusion rule. * @return bool */ private function path_matches_rule( $current_path, $rule ) { // Normalize paths to ensure proper comparisons. $current_path = '/' . ltrim( (string) $current_path, '/' ); $rule = trim( (string) $rule ); if ( '' === $rule ) { return false; } $rule_path = $rule; // If the rule contains a full URL, verify host matching and isolate path. if ( false !== strpos( $rule, '://' ) ) { $rule_host = wp_parse_url( $rule, PHP_URL_HOST ); $site_host = wp_parse_url( home_url(), PHP_URL_HOST ); if ( $rule_host && $site_host && strtolower( $rule_host ) !== strtolower( $site_host ) ) { return false; } $parsed_path = wp_parse_url( $rule, PHP_URL_PATH ); if ( is_string( $parsed_path ) && '' !== $parsed_path ) { $rule_path = $parsed_path; } } $rule_path = '/' . ltrim( $rule_path, '/' ); // Strip trailing slashes unless it represents the root path. if ( '/' !== $rule_path ) { $rule_path = untrailingslashit( $rule_path ); } if ( '/' !== $current_path ) { $current_path = untrailingslashit( $current_path ); } // Check for exact matching. if ( $current_path === $rule_path ) { return true; } // Treat root exclusion lightly; if it reaches here, no exact match was found. if ( '/' === $rule_path ) { return false; } // Partial path matching (e.g. /category/ matching /category/item). return 0 === strpos( $current_path, trailingslashit( $rule_path ) ); } /** * Get the current request path. * * @since 1.1.4 * * @return string */ private function get_current_request_path() { // Enhancement: Added PHPCS ignore rule for InputNotValidated. // REQUEST_URI is a server routing variable mapped by the server itself, // making standard user input validation inapplicable. It is strictly sanitized below. // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotValidated $request_uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : ''; if ( empty( $request_uri ) ) { return ''; } // Isolate the actual path, safely dropping query strings and fragments. $path = wp_parse_url( $request_uri, PHP_URL_PATH ); if ( ! is_string( $path ) || '' === $path ) { return '/'; } // Ensure standard leading slash structure. return '/' . ltrim( $path, '/' ); } /* |-------------------------------------------------------------------------- | SECTION 5: Spinner Helpers |-------------------------------------------------------------------------- */ /** * Retrieve sanitized HTML markup for a specific spinner style. * * @since 1.0.0 * * @param string $style Spinner style key. * @return string */ private function get_spinner_html( $style ) { $style = $this->sanitize_spinner_key( $style ); if ( function_exists( 'aerospin_css_loading_spinners_get_spinner' ) ) { $spinner = aerospin_css_loading_spinners_get_spinner( $style, 'ring' ); if ( is_array( $spinner ) && isset( $spinner['html'] ) ) { return $this->sanitize_spinner_html( $spinner['html'] ); } } elseif ( function_exists( 'Pure_CSS_Spinners_get_spinner' ) ) { $spinner = Pure_CSS_Spinners_get_spinner( $style, 'ring' ); if ( is_array( $spinner ) && isset( $spinner['html'] ) ) { return $this->sanitize_spinner_html( $spinner['html'] ); } } elseif ( function_exists( 'cp_spinners_get_spinner' ) ) { $spinner = cp_spinners_get_spinner( $style, 'ring' ); if ( is_array( $spinner ) && isset( $spinner['html'] ) ) { return $this->sanitize_spinner_html( $spinner['html'] ); } } $spinners = $this->get_spinners(); if ( isset( $spinners[ $style ]['html'] ) ) { return $this->sanitize_spinner_html( $spinners[ $style ]['html'] ); } if ( isset( $spinners['ring']['html'] ) ) { return $this->sanitize_spinner_html( $spinners['ring']['html'] ); } return ''; } /** * Get sanitized spinner definitions. * * @since 1.1.4 * * @return array */ private function get_spinners() { if ( function_exists( 'aerospin_css_loading_spinners_get_spinners' ) ) { $spinners = aerospin_css_loading_spinners_get_spinners(); } elseif ( function_exists( 'Pure_CSS_Spinners_get_spinners' ) ) { $spinners = Pure_CSS_Spinners_get_spinners(); } elseif ( class_exists( 'Pure_CSS_Spinners_List' ) && is_callable( array( 'Pure_CSS_Spinners_List', 'get_spinners' ) ) ) { $spinners = Pure_CSS_Spinners_List::get_spinners(); } else { $spinners = array(); } return is_array( $spinners ) ? $spinners : array(); } /** * Sanitize spinner HTML using the narrow spinner allowlist. * * @since 1.1.4 * * @param string $html Raw spinner HTML. * @return string */ private function sanitize_spinner_html( $html ) { if ( function_exists( 'aerospin_css_loading_spinners_kses_spinner_html' ) ) { return aerospin_css_loading_spinners_kses_spinner_html( $html ); } elseif ( function_exists( 'Pure_CSS_Spinners_kses_spinner_html' ) ) { return Pure_CSS_Spinners_kses_spinner_html( $html ); } if ( class_exists( 'Pure_CSS_Spinners_Helpers' ) && is_callable( array( 'Pure_CSS_Spinners_Helpers', 'kses_spinner_html' ) ) ) { return Pure_CSS_Spinners_Helpers::kses_spinner_html( $html ); } // Apply strict tag stripping to output safe structural elements only. return wp_kses( $html, array( 'div' => array( 'aria-hidden' => true, 'class' => true, 'role' => true, ), 'span' => array( 'aria-hidden' => true, 'class' => true, 'role' => true, ), ) ); } /** * Sanitize a spinner key. * * @since 1.1.4 * * @param mixed $style Raw spinner key. * @return string */ private function sanitize_spinner_key( $style ) { if ( function_exists( 'aerospin_css_loading_spinners_sanitize_spinner_key' ) ) { return aerospin_css_loading_spinners_sanitize_spinner_key( $style, 'ring' ); } elseif ( function_exists( 'Pure_CSS_Spinners_sanitize_spinner_key' ) ) { return Pure_CSS_Spinners_sanitize_spinner_key( $style, 'ring' ); } if ( class_exists( 'Pure_CSS_Spinners_Helpers' ) && is_callable( array( 'Pure_CSS_Spinners_Helpers', 'sanitize_spinner_key' ) ) ) { return Pure_CSS_Spinners_Helpers::sanitize_spinner_key( $style, 'ring' ); } $style = sanitize_key( $this->safe_scalar( $style ) ); return '' === $style ? 'ring' : $style; } /* |-------------------------------------------------------------------------- | SECTION 6: Value Sanitizers |-------------------------------------------------------------------------- */ /** * Normalize a multiline setting for JavaScript configuration. * * @since 1.1.4 * * @param mixed $value Raw multiline value. * @return string */ private function normalize_multiline_value( $value ) { $value = $this->safe_scalar( $value ); // Standardize line endings to a single character format. $value = str_replace( array( "\r\n", "\r" ), "\n", $value ); return sanitize_textarea_field( $value ); } /** * Sanitize a color value. * * @since 1.1.4 * * @param mixed $value Raw color value. * @param string $default_value Fallback color. * @return string */ private function sanitize_color( $value, $default_value ) { if ( function_exists( 'aerospin_css_loading_spinners_sanitize_color' ) ) { return aerospin_css_loading_spinners_sanitize_color( $value, $default_value ); } elseif ( function_exists( 'Pure_CSS_Spinners_sanitize_color' ) ) { return Pure_CSS_Spinners_sanitize_color( $value, $default_value ); } if ( class_exists( 'Pure_CSS_Spinners_Helpers' ) && is_callable( array( 'Pure_CSS_Spinners_Helpers', 'sanitize_color' ) ) ) { return Pure_CSS_Spinners_Helpers::sanitize_color( $value, $default_value ); } $color = sanitize_hex_color( $this->safe_scalar( $value ) ); if ( $color ) { return $color; } $fallback = sanitize_hex_color( $this->safe_scalar( $default_value ) ); return $fallback ? $fallback : '#ffffff'; } /** * Clamp an integer value. * * @since 1.1.4 * * @param mixed $value Raw value. * @param int $min Minimum. * @param int $max Maximum. * @return int */ private function clamp_int( $value, $min, $max ) { $value = is_numeric( $value ) ? (int) $value : (int) $min; return max( (int) $min, min( (int) $max, $value ) ); } /** * Safely cast scalar values to strings. * * @since 1.1.4 * * @param mixed $value Raw value. * @return string */ private function safe_scalar( $value ) { if ( is_array( $value ) || is_object( $value ) ) { return ''; } return is_string( $value ) ? $value : (string) $value; } } } if ( ! class_exists( 'AeroSpin_CSS_Loading_Spinners_Public' ) ) { class_alias( 'ACLS_Public', 'AeroSpin_CSS_Loading_Spinners_Public' ); } if ( ! class_exists( 'Pure_CSS_Spinners_Public' ) ) { class_alias( 'ACLS_Public', 'Pure_CSS_Spinners_Public' ); } if ( ! class_exists( 'AreoSpin_Css_Loading_Spinners_Public' ) ) { class_alias( 'ACLS_Public', 'AreoSpin_Css_Loading_Spinners_Public' ); } /* |-------------------------------------------------------------------------- | SECTION 7: Initialize Public Controller |-------------------------------------------------------------------------- */ ACLS_Public::get_instance();