ic static function is_initial_loading(): bool { if ( null !== self::$is_initial_loading_cache ) { return self::$is_initial_loading_cache; } // `has_search_param()` not `parse_url_search_query() !== ''` — an // explicit `?s=` (empty value) still means "visitor landed on a // search page" and should fire an unfiltered initial fetch. if ( static::has_search_param() ) { self::$is_initial_loading_cache = true; return true; } if ( ! empty( static::parse_url_filters() ) ) { self::$is_initial_loading_cache = true; return true; } self::$is_initial_loading_cache = null !== static::parse_url_price_range(); return self::$is_initial_loading_cache; } /** * Reset the `is_initial_loading()` memo. Tests only — PHPUnit reuses a * single process so `$_GET` from an earlier test would pin the value. * Guarded against accidental production use. */ public static function reset_initial_loading_cache(): void { if ( defined( 'ABSPATH' ) && ! defined( 'PHPUNIT_COMPOSER_INSTALL' ) ) { return; } self::$is_initial_loading_cache = null; } /** * Pre-hydration view state for a filter block's wrapper. Centralizes the * seeded-state read shared by filter-checkbox and filter-date so each * render.php branches on a single struct rather than re-deriving the * same flags inline. * * @param string $filter_key The filter key (e.g. `category`, `post_type`). * @return array{has_buckets:bool,is_initial_loading:bool,show_wrapper:bool} */ public static function pre_hydration_filter_view( string $filter_key ): array { if ( ! function_exists( 'wp_interactivity_state' ) ) { return array( 'has_buckets' => false, 'is_initial_loading' => false, 'show_wrapper' => false, ); } // `aggregations` is seeded as `stdClass` when empty (so JS sees `{}`, // not `[]`); cast before subscripting so the read works in either shape. $state = wp_interactivity_state( 'jetpack-search' ); $aggs = (array) ( $state['aggregations'] ?? array() ); $has_buckets = ! empty( $aggs[ $filter_key ]['buckets'] ?? array() ); $is_initial_loading = static::is_initial_loading(); return array( 'has_buckets' => $has_buckets, 'is_initial_loading' => $is_initial_loading, 'show_wrapper' => $has_buckets || $is_initial_loading, ); } /** * Emit the `data-wp-context` attribute for a filter block's wrapper. The * seeded `wrapperHidden` value is what the IA SSR pass evaluates * `data-wp-bind--hidden="context.wrapperHidden"` against, and what the * `syncFilterWrapperVisibility` callback updates after hydration. * * @param string $filter_key The filter key. * @param bool $show_wrapper Whether the wrapper should be visible on first paint. */ public static function emit_filter_wrapper_context( string $filter_key, bool $show_wrapper ): void { if ( ! function_exists( 'wp_interactivity_data_wp_context' ) ) { return; } echo wp_kses_data( wp_interactivity_data_wp_context( array( 'filterKey' => $filter_key, 'wrapperHidden' => ! $show_wrapper, ) ) ); } /** * Normalize the shared `displayStyle` attribute to one of the two CSS * variants. `filter-wc-stock-status` and `filter-wc-rating` deliberately * don't ship a chip variant and don't call this helper. * * @param mixed $value Raw attribute value. * @return string Either 'checkbox-list' or 'chips'. */ public static function normalize_display_style( $value ): string { return 'chips' === $value ? 'chips' : 'checkbox-list'; } /** * Seed translated view-bundle strings for the Interactivity API store. * * @return array */ protected static function build_initial_strings(): array { if ( ! function_exists( '__' ) || ! function_exists( '_n' ) ) { return array( 'searching' => 'Searching…', 'resultsCountSingle' => 'Found %d result', 'resultsCountPlural' => 'Found %d results', 'removeFilter' => 'Remove %s', 'ratingStarsTop' => '5 stars', 'ratingStarsAndUpSingle' => '%d star and up', 'ratingStarsAndUpPlural' => '%d stars and up', 'priceRangeFromTo' => '%1$s – %2$s', 'priceRangeFrom' => '%s+', 'priceRangeUpTo' => 'Under %s', 'priceLabel' => 'Price', 'suggestionLabelQuery' => 'Suggestions', 'suggestionLabelTaxonomy' => 'Popular Filters', 'suggestionLabelPost' => 'Articles', 'aiErrorMessage' => 'Sorry, an error occurred while generating an answer.', 'aiErrorCode' => 'Error code: %s', ); } return array( 'searching' => __( 'Searching…', 'jetpack-search-pkg' ), /* translators: %d: number of results. */ 'resultsCountSingle' => _n( 'Found %d result', 'Found %d results', 1, 'jetpack-search-pkg' ), /* translators: %d: number of results. */ 'resultsCountPlural' => _n( 'Found %d result', 'Found %d results', 2, 'jetpack-search-pkg' ), /* translators: %s: filter label (e.g. "Category: News"). Announced by screen readers when focus lands on a filter pill's remove button. */ 'removeFilter' => __( 'Remove %s', 'jetpack-search-pkg' ), /* translators: Active-filter chip label for the 5-star row. The 5-star row is "exactly 5 stars" — no "& up" affordance — because there is no higher rating. Mirrors the row's aria-label in filter-wc-rating/render.php. */ 'ratingStarsTop' => __( '5 stars', 'jetpack-search-pkg' ), /* translators: %d: rating threshold (singular form, i.e. 1). Active-filter chip label for the "1 star and up" threshold row. Mirrors the row's aria-label in filter-wc-rating/render.php. */ 'ratingStarsAndUpSingle' => _n( '%d star and up', '%d stars and up', 1, 'jetpack-search-pkg' ), /* translators: %d: rating threshold (plural form, i.e. 2-4). Active-filter chip label for the "X stars and up" threshold rows. Mirrors the row's aria-label in filter-wc-rating/render.php. */ 'ratingStarsAndUpPlural' => _n( '%d star and up', '%d stars and up', 2, 'jetpack-search-pkg' ), /* translators: 1: minimum price (already includes the currency symbol). 2: maximum price (already includes the currency symbol). Renders an active "Price: $10 – $50" filter pill. */ 'priceRangeFromTo' => __( '%1$s – %2$s', 'jetpack-search-pkg' ), /* translators: %s: minimum price (already includes the currency symbol). Renders an active "Price: $10+" filter pill (no upper bound) — compact "and above" form aligned with mainstream e-commerce filter chips. */ 'priceRangeFrom' => __( '%s+', 'jetpack-search-pkg' ), /* translators: %s: maximum price (already includes the currency symbol). Renders an active "Price: Under $50" filter pill (no lower bound) — mirrors Amazon/eBay/Walmart's "Under $X" convention. */ 'priceRangeUpTo' => __( 'Under %s', 'jetpack-search-pkg' ), /* translators: Group label for the price filter pill ("Price: $10 – $50"). Mirrors the price block's default heading; falls back to this when no price block is on the page. */ 'priceLabel' => __( 'Price', 'jetpack-search-pkg' ), /* translators: Group label for the typed-query suggestions section of the Search Input autocomplete dropdown. */ 'suggestionLabelQuery' => __( 'Suggestions', 'jetpack-search-pkg' ), /* translators: Group label for the taxonomy (category / tag) section of the Search Input autocomplete dropdown. */ 'suggestionLabelTaxonomy' => __( 'Popular Filters', 'jetpack-search-pkg' ), /* translators: Group label for the post-title section of the Search Input autocomplete dropdown. */ 'suggestionLabelPost' => __( 'Articles', 'jetpack-search-pkg' ), /* translators: Heading shown on the AI Answer panel when the agent endpoint returns an error. The technical message + HTTP/JSON-RPC code render below this string. */ 'aiErrorMessage' => __( 'Sorry, an error occurred while generating an answer.', 'jetpack-search-pkg' ), /* translators: %s: numeric error code. Surfaces the HTTP / JSON-RPC code that came back with the AI Answer failure, under the technical message. */ 'aiErrorCode' => __( 'Error code: %s', 'jetpack-search-pkg' ), ); } /** * Rotating loading hints for the "Show more" extended AI answer. * Mirrors the overlay verbatim so visitors switching surfaces see * the same copy. * * @return array */ protected static function build_ai_extended_loading_hints(): array { // Strings omit trailing `…` — render.php appends an animated ellipsis, // so a static one would double up. Overlay does the same. if ( ! function_exists( '__' ) ) { return array( 'Searching harder', 'Looking deeper into this', 'Finding a more complete answer', 'Analyzing additional sources', 'Gathering more details', 'Pulling in more context', 'Expanding the search', 'Rolling up my virtual sleeves', 'Digging through the archives', 'Putting on my reading glasses', 'Checking under the digital couch cushions', 'Consulting the oracle', 'Asking a smarter algorithm', 'Brewing a fresh batch of insights', 'Unleashing the full power of search', ); } return array( __( 'Searching harder', 'jetpack-search-pkg' ), __( 'Looking deeper into this', 'jetpack-search-pkg' ), __( 'Finding a more complete answer', 'jetpack-search-pkg' ), __( 'Analyzing additional sources', 'jetpack-search-pkg' ), __( 'Gathering more details', 'jetpack-search-pkg' ), __( 'Pulling in more context', 'jetpack-search-pkg' ), __( 'Expanding the search', 'jetpack-search-pkg' ), __( 'Rolling up my virtual sleeves', 'jetpack-search-pkg' ), __( 'Digging through the archives', 'jetpack-search-pkg' ), __( 'Putting on my reading glasses', 'jetpack-search-pkg' ), __( 'Checking under the digital couch cushions', 'jetpack-search-pkg' ), __( 'Consulting the oracle', 'jetpack-search-pkg' ), __( 'Asking a smarter algorithm', 'jetpack-search-pkg' ), __( 'Brewing a fresh batch of insights', 'jetpack-search-pkg' ), __( 'Unleashing the full power of search', 'jetpack-search-pkg' ), ); } /** * Parse the search query from the URL using whichever key * `get_search_param_name()` returns (`s` on search routes, `q` elsewhere). * Public so render templates can seed their input from the same source. * * @return string */ public static function parse_url_search_query(): string { $key = self::get_search_param_name(); // phpcs:ignore WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- read-only URL state; coerced to string + sanitize_text_field( wp_unslash( ... ) ) on the next line. $raw = $_GET[ $key ] ?? ''; if ( ! is_scalar( $raw ) ) { return ''; } return trim( sanitize_text_field( wp_unslash( (string) $raw ) ) ); } /** * Whether the search-query key is present in `$_GET` (any value). * Distinguishes `?s=` (blank search) from a URL that omits the key — * `parse_url_search_query()` collapses both to `''`. Array-shaped * `?s[]=foo` reads as "not present" to stay in lockstep. * * @return bool */ public static function has_search_param(): bool { $key = self::get_search_param_name(); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only URL presence check; the value is never read here. return isset( $_GET[ $key ] ) && is_scalar( $_GET[ $key ] ); } /** * Parse the sort order from the URL, defaulting to 'relevance'. Allowed * values track `Results_Sort::get_all_option_keys()` — on non-Woo sites * a `?orderby=price_asc` deep link collapses to `relevance` (mirrors * `store/url-state.js`). * * @return string */ protected static function parse_url_sort(): string { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only URL state. $orderby = isset( $_GET['orderby'] ) ? sanitize_key( wp_unslash( $_GET['orderby'] ) ) : ''; $allowed = array_values( array_filter( Results_Sort::get_all_option_keys(), static function ( $key ) { return 'relevance' !== $key; } ) ); return in_array( $orderby, $allowed, true ) ? $orderby : 'relevance'; } /** * Parse the price range from the URL. Mirrors `store/url-state.js`. * Either bound may be null for a half-open range; non-numeric or * negative values null out. Returns null entirely on non-Woo sites — * `min_price`/`max_price` are WC-only and a stray param shouldn't drive * the API into a `range` clause for a field the index doesn't have. * * @return array{min: float|null, max: float|null}|null */ protected static function parse_url_price_range(): ?array { if ( ! self::woocommerce_blocks_enabled() ) { return null; } // phpcs:disable WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- coerced to float in parse_price_bound(). $min = self::parse_price_bound( $_GET['min_price'] ?? null ); $max = self::parse_price_bound( $_GET['max_price'] ?? null ); // phpcs:enable if ( null === $min && null === $max ) { return null; } // Inverted bounds → empty ES `range` clause / zero results silently. // Treat as garbage and bail so the page falls back to unfiltered search. if ( null !== $min && null !== $max && $min > $max ) { return null; } return array( 'min' => $min, 'max' => $max, ); } /** * Coerce a single price-range URL value into a finite, non-negative float. * * @param mixed $raw Raw value pulled from $_GET. * @return float|null */ private static function parse_price_bound( $raw ): ?float { if ( null === $raw || '' === $raw || ! is_scalar( $raw ) ) { return null; } // `is_numeric` keeps PHP in lockstep with JS's `Number()`: rejects // partially-numeric strings ("1.5.3") that `(float)` would silently // extract as `1.5` while `Number()` returns `NaN`. $raw = wp_unslash( $raw ); if ( ! is_numeric( $raw ) ) { return null; } $num = (float) $raw; if ( ! is_finite( $num ) || $num < 0 ) { return null; } return $num; } /** * Parse `?[]=` URL params into `{ [filterKey]: string[] }`. * Mirrors the shape `store/url-state.js` writes (see AGENTS.md § URL format). * No registered-key filtering here — `filterConfigs` aren't available until * blocks render. The JS layer gates on hydration. * * Scalar `?post_type=` is also accepted as a shortcut for * `?post_types[]=` — matches WP/WC's own URL convention. Merged into * any existing array selections so `?post_type=foo&post_types[]=bar` reads * as `[foo, bar]`. Singular-form-on-an-array-key keeps its existing * "ignored noise" behaviour for every other filter. * * @return array */ protected static function parse_url_filters(): array { // phpcs:ignore WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- read-only URL state; sanitized per-value below. $raw = wp_unslash( $_GET ); if ( ! is_array( $raw ) ) { return array(); } $out = array(); foreach ( $raw as $key => $values ) { $filter_key = sanitize_key( (string) $key ); if ( '' === $filter_key || in_array( $filter_key, self::RESERVED_QUERY_PARAMS, true ) ) { continue; } if ( 'post_type' === $filter_key ) { // `is_string` (not `is_scalar`) keeps the gate consistent with // `parse_url_filter_logic`'s value check — `$_GET` only ever // carries strings or arrays, and the array case takes the // `is_array( $values )` branch immediately below. if ( ! is_string( $values ) ) { continue; } // `sanitize_key`, not `sanitize_text_field` — post-type slugs are // always lowercase + `[a-z0-9_-]`; the lowercase pass keeps a // `?post_type=Product` URL from reaching ES with the wrong case // and silently returning zero results. $slug = sanitize_key( $values ); if ( '' === $slug ) { continue; } $existing = $out['post_types'] ?? array(); $out['post_types'] = array_values( array_unique( array_merge( $existing, array( $slug ) ) ) ); continue; } if ( ! is_array( $values ) ) { continue; } $clean = array_values( array_filter( array_map( 'sanitize_text_field', $values ), static function ( $v ) { return '' !== $v; } ) ); if ( $clean ) { $existing = $out[ $filter_key ] ?? array(); $out[ $filter_key ] = array_values( array_unique( array_merge( $existing, $clean ) ) ); } } return $out; } /** * Parse `?query_type_=and` overrides into `{ [filterKey]: 'and' }`. * Only literal `'and'` is honoured — anything else is dropped so it * can't round-trip back through `pushStateToUrl`. Mirrors * `store/url-state.js`. * * @param array $active_filters Result of parse_url_filters(). * @return array */ protected static function parse_url_filter_logic( array $active_filters ): array { // phpcs:ignore WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- read-only URL state; sanitized per-value below. $raw = wp_unslash( $_GET ); if ( ! is_array( $raw ) ) { return array(); } $out = array(); foreach ( $raw as $key => $value ) { if ( ! is_string( $key ) || 0 !== strpos( $key, 'query_type_' ) ) { continue; } if ( ! is_string( $value ) || 'and' !== $value ) { continue; } $filter_key = sanitize_key( substr( $key, strlen( 'query_type_' ) ) ); if ( '' === $filter_key || in_array( $filter_key, self::RESERVED_QUERY_PARAMS, true ) ) { continue; } if ( empty( $active_filters[ $filter_key ] ) ) { continue; } $out[ $filter_key ] = 'and'; } return $out; } }