Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
72.49% covered (warning)
72.49%
361 / 498
50.00% covered (danger)
50.00%
21 / 42
CRAP
0.00% covered (danger)
0.00%
0 / 1
Search_Blocks
72.49% covered (warning)
72.49%
361 / 498
50.00% covered (danger)
50.00%
21 / 42
596.89
0.00% covered (danger)
0.00%
0 / 1
 init
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 is_free_plan
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 reset_is_free_plan_cache
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 is_woocommerce_active
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 set_is_woocommerce_active_for_testing
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 reset_is_woocommerce_active_cache
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 woocommerce_only_block_names
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 is_woocommerce_only_block
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 custom_taxonomy_map
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 resolve_taxonomy_slot
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 reset_custom_taxonomy_map_cache
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 supported_custom_taxonomies
83.33% covered (warning)
83.33%
15 / 18
0.00% covered (danger)
0.00%
0 / 1
4.07
 get_search_param_name
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
3
 enqueue_editor_assets
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
6
 register_block_category
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
 register_blocks
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
56
 inject_filter_checkbox_variations
100.00% covered (success)
100.00%
98 / 98
100.00% covered (success)
100.00%
1 / 1
7
 register_patterns
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
42
 get_search_template_content
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
3.01
 register_search_template
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
3.00
 get_parent_plugin_slug
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 prepend_search_template
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 seed_interactivity_state
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 build_seed_state
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 collect_filter_configs_from_post
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
56
 filter_block_helpers
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
4
 walk_blocks_for_filter_configs
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
56
 build_initial_state
100.00% covered (success)
100.00%
42 / 42
100.00% covered (success)
100.00%
1 / 1
10
 build_stock_status_labels
77.78% covered (warning)
77.78%
7 / 9
0.00% covered (danger)
0.00%
0 / 1
4.18
 is_initial_loading
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
 reset_initial_loading_cache
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
3.33
 pre_hydration_filter_view
66.67% covered (warning)
66.67%
10 / 15
0.00% covered (danger)
0.00%
0 / 1
3.33
 emit_filter_wrapper_context
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
2.00
 normalize_display_style
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 build_initial_strings
51.85% covered (warning)
51.85%
14 / 27
0.00% covered (danger)
0.00%
0 / 1
4.00
 parse_url_search_query
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 has_search_param
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 parse_url_sort
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 parse_url_price_range
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
7.03
 parse_price_bound
77.78% covered (warning)
77.78%
7 / 9
0.00% covered (danger)
0.00%
0 / 1
7.54
 parse_url_filters
95.24% covered (success)
95.24%
20 / 21
0.00% covered (danger)
0.00%
0 / 1
7
 parse_url_filter_logic
43.75% covered (danger)
43.75%
7 / 16
0.00% covered (danger)
0.00%
0 / 1
27.80
1<?php
2/**
3 * Search Blocks: Interactivity API block registration and state initialization.
4 *
5 * @package automattic/jetpack-search
6 */
7
8namespace Automattic\Jetpack\Search;
9
10use Automattic\Jetpack\Status;
11
12/**
13 * Registers Jetpack Search Interactivity API blocks and initializes their shared state.
14 */
15class Search_Blocks {
16
17    /**
18     * Reserved query params that must not be parsed as filter keys. Mirrors
19     * `RESERVED_PARAMS` in store/url-state.js.
20     *
21     * Includes both `s` (used on the WP search route) and `q` (used by the
22     * inline blocks on non-search pages, see `get_search_param_name()`) so
23     * neither name can be misread as a filter key.
24     */
25    const RESERVED_QUERY_PARAMS = array( 's', 'q', 'orderby', 'min_price', 'max_price' );
26
27    /**
28     * URL param the inline search blocks use to carry the query string when
29     * embedded on a non-search page (e.g. `/about/?q=boots`). On the WP
30     * search route (`is_search()`) the canonical `s` key is used instead.
31     *
32     * The non-`s` name on singular pages is what dodges core's
33     * `WP_Query::get_posts()` AND'ing a `post_content LIKE` clause into the
34     * singular page lookup and 404'ing the page on refresh. `q` matches the
35     * de-facto search-URL convention (Google, GitHub, Wikipedia, etc.) so
36     * shared links read naturally. See
37     * `docs/explorations/embedded-search-refresh-404.md` (RSM-1754).
38     */
39    const NON_SEARCH_QUERY_PARAM = 'q';
40
41    /**
42     * Template slug used for the Jetpack Search page template.
43     *
44     * Intentionally distinct from WordPress's `search` slug so the plugin
45     * template never collides with (and gets deduplicated against) a block
46     * theme's own `search.html`. `search_template_hierarchy` prepends this
47     * slug so it still wins on `/?s=...` requests.
48     */
49    const SEARCH_TEMPLATE_SLUG = 'jetpack-search';
50
51    /**
52     * Per-request memo backing `is_initial_loading()`. Lifted out of the
53     * method's local `static` so tests can clear it between cases via
54     * `reset_initial_loading_cache()` — function-local statics aren't
55     * reachable from outside the function, so they'd otherwise leak the
56     * first test's URL state into every subsequent test in the same
57     * PHPUnit process.
58     *
59     * @var bool|null
60     */
61    private static $is_initial_loading_cache = null;
62
63    /**
64     * Per-request memo backing `is_free_plan()`. Block render callbacks
65     * (`search-results`, `powered-by`) call into the plan gate on every
66     * inner render, including the auto-injected colophon path. WP's option
67     * cache absorbs the redundancy in steady state, but on a cold cache
68     * `Plan::get_plan_info()` falls back to a synchronous WPCOM HTTP call —
69     * memoizing here fences that hazard to a single well-known site per
70     * request.
71     *
72     * @var bool|null
73     */
74    private static $is_free_plan_cache = null;
75
76    /**
77     * Per-request memo backing `is_woocommerce_active()`. Centralized here
78     * (rather than inside any one WC-aware block helper) so every gate that
79     * needs the answer — block-registration filters that hide WC-only blocks
80     * on non-Woo sites, render callbacks that drop product-format sort keys,
81     * the editor-side localized config, the Interactivity store seed —
82     * shares the same `class_exists()` probe.
83     *
84     * @var bool|null
85     */
86    private static $is_woocommerce_active_cache = null;
87
88    /**
89     * Per-request memo backing `supported_custom_taxonomies()`. Derived from
90     * the Sync allowlist intersected with registered taxonomies and unioned
91     * with the map's user-facing keys; same inputs every request.
92     *
93     * @var string[]|null
94     */
95    private static $supported_custom_taxonomies_cache = null;
96
97    /**
98     * Register block types and hook into WordPress.
99     *
100     * Two gates apply:
101     *
102     * 1. The caller (Initializer) gates the whole method behind the
103     *    `jetpack_search_blocks_enabled` feature flag — when off, the blocks
104     *    don't exist at all.
105     * 2. Within this method, only the *template-takeover* surface (registering
106     *    the Jetpack Search block template and prepending it to
107     *    `search_template_hierarchy`) is additionally gated on the saved
108     *    experience being `'embedded'`. Everything else — block registration,
109     *    editor assets, and Interactivity API state seeding — runs whenever
110     *    the feature flag is on, since admins can insert Search blocks
111     *    anywhere blocks are configurable (post content, sidebar widgets,
112     *    custom templates) regardless of which experience the dashboard has
113     *    saved. Those blocks need the seeded base state (`apiRoot`, `nonce`,
114     *    URL-derived `searchQuery` / `activeFilters`, `filterConfigs` slot,
115     *    etc.) to hydrate; per-block `render.php` files only contribute their
116     *    own config and rely on the global seed for the base.
117     *
118     * Why the template gate: with four experiences (`embedded` / `overlay` /
119     * `inline` / `off`), only Embedded should override the theme's
120     * `search.html`. A site that saves Overlay or Inline still expects
121     * `/?s=…` to resolve through the theme — the Jetpack template is the
122     * right answer only when the user has explicitly opted into the
123     * block-built search page.
124     *
125     * `Module_Control::get_experience()` reads `get_option( 'jetpack_search_experience' )`
126     * (object-cached) and falls back to deriving from the legacy booleans, so
127     * this is cheap on every request. `update_experience()` writes the option
128     * synchronously, so the next request after a save sees the new gate.
129     */
130    public static function init() {
131        add_action( 'init', array( static::class, 'register_blocks' ) );
132        add_filter( 'block_categories_all', array( static::class, 'register_block_category' ) );
133        add_action( 'enqueue_block_editor_assets', array( static::class, 'enqueue_editor_assets' ) );
134        Custom_Taxonomy_Slot_Mapping::init();
135        // FSE block-template rendering runs *before* `wp_head()` (see
136        // `wp-includes/template-canvas.php`), so blocks would resolve
137        // `data-wp-bind` / `data-wp-text` against an unseeded IA store if we
138        // only hooked `wp_enqueue_scripts`. Seeding on `template_redirect`
139        // closes that gap; the second call from `wp_enqueue_scripts` is a
140        // deep-merge no-op and keeps classic-theme paths covered.
141        add_action( 'template_redirect', array( static::class, 'seed_interactivity_state' ) );
142        add_action( 'wp_enqueue_scripts', array( static::class, 'seed_interactivity_state' ) );
143
144        if ( Module_Control::EXPERIENCE_EMBEDDED === ( new Module_Control() )->get_experience() ) {
145            add_action( 'init', array( static::class, 'register_search_template' ) );
146            add_filter( 'search_template_hierarchy', array( static::class, 'prepend_search_template' ) );
147        }
148    }
149
150    /**
151     * Per-request memoized read of `Plan::is_free_plan()`. Use from any
152     * block render callback that needs the plan gate — avoids paying the
153     * `get_option()` array-parse cost on every block, and ensures the
154     * cold-cache WPCOM round-trip in `Plan::get_plan_info()` happens at
155     * most once per request even when several blocks ask.
156     *
157     * @return bool
158     */
159    public static function is_free_plan(): bool {
160        if ( null === self::$is_free_plan_cache ) {
161            self::$is_free_plan_cache = ( new Plan() )->is_free_plan();
162        }
163        return self::$is_free_plan_cache;
164    }
165
166    /**
167     * Reset the `is_free_plan()` memo. Tests only — production callers
168     * should never need this; the boolean state of the site's plan
169     * doesn't change inside a single request.
170     */
171    public static function reset_is_free_plan_cache() {
172        self::$is_free_plan_cache = null;
173    }
174
175    /**
176     * Whether WooCommerce is loaded on this site. Use from any gate that
177     * needs to skip a WC-only feature (block registration of `filter-wc-*`
178     * blocks, the product-format sort keys on `results-sort`, etc.). The
179     * result is memoized per-request so adding a new caller doesn't
180     * multiply autoloader probes.
181     *
182     * **Load-order contract:** must be called at or after `plugins_loaded`.
183     * WooCommerce includes its main `WooCommerce` class only when its plugin
184     * file runs (during `plugins_loaded`), so an earlier call would return
185     * false on a WC site. Every existing caller fires from a hook later than
186     * that — `enqueue_block_editor_assets`, `template_redirect`,
187     * `wp_enqueue_scripts`, or block render — so the contract is naturally
188     * satisfied. New callers earlier in the request lifecycle should defer
189     * the probe to a `plugins_loaded`-or-later hook.
190     *
191     * **Filter:** `jetpack_search_blocks_is_woocommerce_active` lets a site
192     * force the gate either way — e.g. a WC site that wants to hide
193     * WC-only Search blocks from a non-shop content area, or a non-Woo
194     * site that wants to render WC-only blocks for a staging preview.
195     * Filter fires once per request, before the result is memoized, so a
196     * filter that probes the database or another expensive condition pays
197     * its cost once and is then served from the cache for the remainder
198     * of the request.
199     *
200     * @return bool
201     */
202    public static function is_woocommerce_active(): bool {
203        if ( null === self::$is_woocommerce_active_cache ) {
204            // Pass `false` so a missing class doesn't fire the autoloader
205            // on non-Woo sites — the gate is hit on every request, and
206            // any upstream autoloader work is wasted when the answer is "no".
207            $probed = class_exists( 'WooCommerce', false );
208
209            /**
210             * Override whether Jetpack Search treats WooCommerce as active.
211             *
212             * Cast to bool before caching so a filter returning a truthy
213             * non-bool (e.g. `1`) doesn't poison strictly-typed callers.
214             *
215             * @since $$next-version$$
216             *
217             * @param bool $is_active Result of the WooCommerce class probe.
218             */
219            self::$is_woocommerce_active_cache = (bool) apply_filters(
220                'jetpack_search_blocks_is_woocommerce_active',
221                $probed
222            );
223        }
224        return self::$is_woocommerce_active_cache;
225    }
226
227    /**
228     * Force the `is_woocommerce_active()` answer to a specific boolean —
229     * tests only. Pass `null` to clear the override and revive the real
230     * `class_exists()` probe (also done by `reset_is_woocommerce_active_cache()`).
231     *
232     * @internal
233     *
234     * @param bool|null $value Forced answer or null to clear.
235     */
236    public static function set_is_woocommerce_active_for_testing( ?bool $value ): void {
237        self::$is_woocommerce_active_cache = $value;
238    }
239
240    /**
241     * Reset the `is_woocommerce_active()` memo. Tests only.
242     *
243     * @internal
244     */
245    public static function reset_is_woocommerce_active_cache(): void {
246        self::$is_woocommerce_active_cache = null;
247    }
248
249    /**
250     * Canonical list of WooCommerce-only block names. Single source of
251     * truth for the WC-only gate applied to block registration
252     * (`register_blocks()`), the `filter_block_helpers()` map, and the
253     * editor's `register-blocks.js` bundle (read after being localized
254     * onto `window.JetpackSearchBlocksConfig.woocommerceOnlyBlocks` in
255     * `enqueue_editor_assets()`).
256     *
257     * Add a new WC-only block by appending one entry — every gate picks
258     * it up automatically. Names are full namespaced names (not bare
259     * slugs) so the list reads identically to what `BLOCKS` contains in
260     * `register-blocks.js` and what `filter_block_helpers()` keys against.
261     *
262     * @return string[]
263     */
264    public static function woocommerce_only_block_names(): array {
265        return array(
266            'jetpack-search/filter-wc-attribute',
267            'jetpack-search/filter-wc-price',
268            'jetpack-search/filter-wc-rating',
269            'jetpack-search/filter-wc-stock-status',
270            'jetpack-search/filters-product',
271        );
272    }
273
274    /**
275     * Whether a block name (or block-directory basename) belongs to a
276     * WooCommerce-only block. Membership is decided by exact match against
277     * `woocommerce_only_block_names()`; either form (full namespaced name
278     * or bare directory basename) works because `register_blocks()` walks
279     * directory basenames while the helpers map and editor bundle hold
280     * full names.
281     *
282     * @param string $block_name Full block name (`jetpack-search/filter-wc-rating`)
283     *                           or bare directory basename (`filter-wc-rating`).
284     * @return bool
285     */
286    public static function is_woocommerce_only_block( string $block_name ): bool {
287        $candidate = false === strpos( $block_name, '/' )
288            ? 'jetpack-search/' . $block_name
289            : $block_name;
290        return in_array( $candidate, self::woocommerce_only_block_names(), true );
291    }
292
293    /**
294     * Built-in taxonomies that have their own dedicated filter-checkbox
295     * variations — Category / Tag plus the three WooCommerce product
296     * taxonomies. Excluded from the "Custom Taxonomy" picker (both server
297     * and editor) so site builders reach for the dedicated variation rather
298     * than the generic Custom Taxonomy entry. Mirrors `BUILT_IN_TAXONOMY_SLUGS`
299     * in filter-checkbox/edit.js — must stay in lockstep.
300     *
301     * @var string[]
302     */
303    const BUILT_IN_CUSTOM_TAXONOMY_EXCLUSIONS = array(
304        'category',
305        'post_tag',
306        'product_cat',
307        'product_tag',
308        'product_brand',
309    );
310
311    /**
312     * Back-compat proxy for `Custom_Taxonomy_Slot_Mapping::get_map()`.
313     * The slot-mapping logic lives in its own class; this proxy keeps the
314     * editor enqueue + `supported_custom_taxonomies()` call sites stable.
315     *
316     * @return array<string, string>
317     */
318    public static function custom_taxonomy_map(): array {
319        return Custom_Taxonomy_Slot_Mapping::get_map();
320    }
321
322    /**
323     * Back-compat proxy for `Custom_Taxonomy_Slot_Mapping::resolve_slot()`.
324     * Used by `Filter_Checkbox::build_config()` to seed `effectiveSlug`
325     * on each filterConfig at config-build time.
326     *
327     * @param string $taxonomy User-facing taxonomy slug.
328     * @return string Effective ES field slug.
329     */
330    public static function resolve_taxonomy_slot( string $taxonomy ): string {
331        return Custom_Taxonomy_Slot_Mapping::resolve_slot( $taxonomy );
332    }
333
334    /**
335     * Reset both the slot-mapping memo and the `supported_custom_taxonomies()`
336     * memo. Tests only — production WP runs a single request per process and
337     * the map is derived purely from a filter hook, so callers should never
338     * need to clear the cache.
339     *
340     * @internal
341     */
342    public static function reset_custom_taxonomy_map_cache(): void {
343        Custom_Taxonomy_Slot_Mapping::reset_cache_for_testing();
344        self::$supported_custom_taxonomies_cache = null;
345    }
346
347    /**
348     * Custom-taxonomy slugs the "Custom Taxonomy" filter variation should
349     * offer in the editor picker. A taxonomy is "supported" when:
350     *
351     *   1. It's registered locally AND in Jetpack Search's indexable
352     *      allowlist (`Sync\Modules\Search::get_all_taxonomies()`), so an
353     *      aggregation against it will actually return buckets; OR
354     *   2. It's a key in `custom_taxonomy_map()` AND registered locally —
355     *      the mapping routes its queries through a reserved slot.
356     *
357     * Built-in slugs already covered by dedicated filter variations
358     * (`category`, `post_tag`, `product_cat`, `product_tag`, `product_brand`)
359     * are stripped so the Custom Taxonomy variation never offers them as
360     * alternatives.
361     *
362     * The Sync allowlist comes from `automattic/jetpack-sync` (a runtime
363     * dependency of `automattic/jetpack-search`); the class_exists guard is
364     * defensive for partial installs / isolated tests where the Sync
365     * package isn't autoloaded — in that case the helper falls back to
366     * "map keys only", which is the most conservative answer.
367     *
368     * @return string[] Distinct, zero-indexed list of supported taxonomy slugs.
369     */
370    public static function supported_custom_taxonomies(): array {
371        if ( null !== self::$supported_custom_taxonomies_cache ) {
372            return self::$supported_custom_taxonomies_cache;
373        }
374
375        // Limit to public taxonomies so the picker stays in lockstep with
376        // the editor's `core.getTaxonomies()` call, which only returns
377        // REST-visible (public) taxonomies. Avoids surfacing a private
378        // taxonomy that happens to be in the Sync allowlist.
379        $registered = function_exists( 'get_taxonomies' )
380            ? array_values( get_taxonomies( array( 'public' => true ), 'names' ) )
381            : array();
382
383        $indexed = class_exists( '\\Automattic\\Jetpack\\Sync\\Modules\\Search' )
384            ? \Automattic\Jetpack\Sync\Modules\Search::get_all_taxonomies()
385            : array();
386
387        $map_keys = array_keys( self::custom_taxonomy_map() );
388
389        // A registered taxonomy is supported when it sits in the index
390        // allowlist OR when the site owner has mapped it to a slot.
391        $candidates = array_unique( array_merge( $indexed, $map_keys ) );
392        $supported  = array_values(
393            array_diff(
394                array_values( array_intersect( $registered, $candidates ) ),
395                self::BUILT_IN_CUSTOM_TAXONOMY_EXCLUSIONS
396            )
397        );
398
399        self::$supported_custom_taxonomies_cache = $supported;
400        return $supported;
401    }
402
403    /**
404     * URL param key the inline search experience uses for the current request.
405     *
406     * On WP's search route (`is_search()`) the canonical `s` key is used so
407     * the blocks interoperate with core's search routing, body classes, and
408     * any theme/plugin code keyed off `s`. On non-search pages — singular
409     * permalinks, archives, the front page — the blocks switch to
410     * `NON_SEARCH_QUERY_PARAM` (`q`) so a refresh of an inline-search URL
411     * like `/about/?q=boots` doesn't trip core's `WP_Query::get_posts()`
412     * `post_content LIKE` AND clause and 404 the page.
413     *
414     * @return string
415     */
416    public static function get_search_param_name(): string {
417        return function_exists( 'is_search' ) && is_search() ? 's' : self::NON_SEARCH_QUERY_PARAM;
418    }
419
420    /**
421     * Enqueue the client-side block registration bundle in the block editor.
422     *
423     * WordPress bootstraps server-side block metadata into the editor, but a
424     * client-side registerBlockType() call is still needed for each block so
425     * the editor knows how to render a preview. This script registers all
426     * Jetpack Search blocks with ServerSideRender for the editor preview.
427     */
428    public static function enqueue_editor_assets() {
429        $base_path  = Package::get_installed_path() . 'build/search-blocks-editor/';
430        $asset_file = $base_path . 'register-blocks.asset.php';
431        if ( ! file_exists( $asset_file ) ) {
432            return;
433        }
434        $asset = require $asset_file;
435
436        // Convert the filesystem path to a URL. plugins_url() resolves against
437        // the nearest plugin directory, which handles the jetpack_vendor
438        // location that Composer installs the package into.
439        $url = plugins_url( 'register-blocks.js', $base_path . 'register-blocks.js' );
440
441        wp_enqueue_script(
442            'jetpack-search-blocks-register',
443            $url,
444            $asset['dependencies'] ?? array(),
445            $asset['version'] ?? false,
446            true
447        );
448
449        // Surface the WC gate to the editor bundle. `isWooCommerceActive`
450        // drives per-component branches (e.g. the results-sort inspector
451        // hiding product-format checkboxes, the results-list inspector
452        // hiding the Product layout) and the `register-blocks.js`
453        // registration loop. `woocommerceOnlyBlocks` is the canonical
454        // list the registration loop intersects with — keeping it
455        // localized (rather than duplicated in JS) means
456        // `Search_Blocks::woocommerce_only_block_names()` is the single
457        // source of truth across the PHP and JS sides.
458        // `wp_add_inline_script` (rather than `wp_localize_script`) per
459        // core ticket #25280 — the latter HTML-encodes ampersands inside
460        // nested values.
461        wp_add_inline_script(
462            'jetpack-search-blocks-register',
463            'window.JetpackSearchBlocksConfig = ' . wp_json_encode(
464                array(
465                    'isWooCommerceActive'       => self::is_woocommerce_active(),
466                    'woocommerceOnlyBlocks'     => self::woocommerce_only_block_names(),
467                    // `supportedCustomTaxonomies` drives the "Custom Taxonomy"
468                    // picker in filter-checkbox/edit.js — only taxonomies in
469                    // this list (Jetpack-Search-indexed OR mapped to a
470                    // reserved slot via `jetpack_search_custom_taxonomy_map`)
471                    // are offered. See `supported_custom_taxonomies()` for
472                    // the derivation and the FAQ link:
473                    // https://jetpack.com/support/search/frequently-asked-questions/#troubleshoot-custom-tax
474                    'supportedCustomTaxonomies' => self::supported_custom_taxonomies(),
475                    // `customTaxonomyMap` is keyed by the user-facing slug;
476                    // the picker uses key membership to append a "(mapped)"
477                    // suffix to those entries' labels so authors know the
478                    // filter routes through a reserved slot.
479                    'customTaxonomyMap'         => (object) self::custom_taxonomy_map(),
480                ),
481                JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP
482            ) . ';',
483            'before'
484        );
485    }
486
487    /**
488     * Add a "Jetpack Search" block category so our blocks appear under that
489     * heading in the inserter instead of "Uncategorized".
490     *
491     * @param array $categories Existing block categories.
492     * @return array
493     */
494    public static function register_block_category( $categories ) {
495        foreach ( $categories as $category ) {
496            if ( 'jetpack-search' === ( $category['slug'] ?? '' ) ) {
497                return $categories;
498            }
499        }
500        $categories[] = array(
501            'slug'  => 'jetpack-search',
502            'title' => __( 'Jetpack Search', 'jetpack-search-pkg' ),
503        );
504        return $categories;
505    }
506
507    /**
508     * Register all search blocks from their block.json files.
509     */
510    public static function register_blocks() {
511        // Register block pattern category first so patterns can reference it.
512        if ( function_exists( 'register_block_pattern_category' ) ) {
513            register_block_pattern_category(
514                'jetpack-search',
515                array( 'label' => __( 'Jetpack Search', 'jetpack-search-pkg' ) )
516            );
517        }
518
519        $blocks_dir = __DIR__ . '/blocks';
520        $block_dirs = glob( $blocks_dir . '/*', GLOB_ONLYDIR );
521
522        if ( ! $block_dirs ) {
523            return;
524        }
525
526        $is_wc = self::is_woocommerce_active();
527        foreach ( $block_dirs as $block_dir ) {
528            if ( ! file_exists( $block_dir . '/block.json' ) ) {
529                continue;
530            }
531            if ( ! $is_wc && self::is_woocommerce_only_block( basename( $block_dir ) ) ) {
532                continue;
533            }
534            register_block_type( $block_dir );
535        }
536
537        add_filter( 'get_block_type_variations', array( static::class, 'inject_filter_checkbox_variations' ), 10, 2 );
538        static::register_patterns();
539    }
540
541    /**
542     * Inject named block variations for the filter-checkbox block.
543     *
544     * Hooks `get_block_type_variations` (added in WP 6.5) rather than calling
545     * `register_block_variation()` because the latter is a JS-only API; no
546     * matching PHP function exists in WordPress core. Filtering on the block
547     * type's own variations getter is the supported PHP-side path and keeps
548     * the editor-only JS bundle out of the ESM pipeline. Jetpack already
549     * requires WP 6.8+, so the hook is always live in supported environments.
550     *
551     * Variation names and default `taxonomy` / `filterType` attributes
552     * intentionally mirror the filter types exposed by the instant-search
553     * overlay so the two surfaces describe the same filters.
554     *
555     * @param array          $variations Variations registered on the block type.
556     * @param \WP_Block_Type $block_type Block type the filter is being applied to.
557     * @return array
558     */
559    public static function inject_filter_checkbox_variations( $variations, $block_type ) {
560        if ( ! isset( $block_type->name ) || 'jetpack-search/filter-checkbox' !== $block_type->name ) {
561            return $variations;
562        }
563
564        $additions = array(
565            array(
566                'name'        => 'category',
567                'title'       => __( 'Filter by Category', 'jetpack-search-pkg' ),
568                'description' => __( 'Show category checkboxes with live result counts.', 'jetpack-search-pkg' ),
569                'attributes'  => array(
570                    'filterType' => 'taxonomy',
571                    'taxonomy'   => 'category',
572                    'label'      => __( 'Category', 'jetpack-search-pkg' ),
573                ),
574                'isActive'    => array( 'filterType', 'taxonomy' ),
575            ),
576            array(
577                'name'        => 'post_tag',
578                'title'       => __( 'Filter by Tag', 'jetpack-search-pkg' ),
579                'description' => __( 'Show tag checkboxes with live result counts.', 'jetpack-search-pkg' ),
580                'attributes'  => array(
581                    'filterType' => 'taxonomy',
582                    'taxonomy'   => 'post_tag',
583                    'label'      => __( 'Tag', 'jetpack-search-pkg' ),
584                ),
585                'isActive'    => array( 'filterType', 'taxonomy' ),
586            ),
587            array(
588                'name'        => 'post_type',
589                'title'       => __( 'Filter by Post Type', 'jetpack-search-pkg' ),
590                'description' => __( 'Show post type checkboxes with live result counts.', 'jetpack-search-pkg' ),
591                'attributes'  => array(
592                    'filterType' => 'post_type',
593                    'label'      => __( 'Post Type', 'jetpack-search-pkg' ),
594                ),
595                'isActive'    => array( 'filterType' ),
596            ),
597            array(
598                'name'        => 'author',
599                'title'       => __( 'Filter by Author', 'jetpack-search-pkg' ),
600                'description' => __( 'Show author checkboxes with live result counts.', 'jetpack-search-pkg' ),
601                'attributes'  => array(
602                    'filterType' => 'author',
603                    'label'      => __( 'Author', 'jetpack-search-pkg' ),
604                ),
605                'isActive'    => array( 'filterType' ),
606            ),
607        );
608
609        // WC-only product taxonomies. Gated on `is_woocommerce_active()` so
610        // they don't appear in the inserter on non-Woo sites where the
611        // taxonomies happen to exist via another plugin (or a previous WC
612        // install that left them registered). `product_brand` layers an
613        // extra `taxonomy_exists()` probe on top because it isn't a core WC
614        // taxonomy — extensions like WC Brands / Perfect Brands / recent
615        // bundled WC versions provide it. The three product variations stay
616        // grouped before `custom_taxonomy` below so the inserter renders
617        // them as a contiguous cluster.
618        if ( self::is_woocommerce_active() ) {
619            $additions[] = array(
620                'name'        => 'product_cat',
621                'title'       => __( 'Filter by Product Category', 'jetpack-search-pkg' ),
622                'description' => __( 'Show product category checkboxes with live result counts.', 'jetpack-search-pkg' ),
623                'attributes'  => array(
624                    'filterType' => 'taxonomy',
625                    'taxonomy'   => 'product_cat',
626                    'label'      => __( 'Product Category', 'jetpack-search-pkg' ),
627                ),
628                'isActive'    => array( 'filterType', 'taxonomy' ),
629            );
630            $additions[] = array(
631                'name'        => 'product_tag',
632                'title'       => __( 'Filter by Product Tag', 'jetpack-search-pkg' ),
633                'description' => __( 'Show product tag checkboxes with live result counts.', 'jetpack-search-pkg' ),
634                'attributes'  => array(
635                    'filterType' => 'taxonomy',
636                    'taxonomy'   => 'product_tag',
637                    'label'      => __( 'Product Tag', 'jetpack-search-pkg' ),
638                ),
639                'isActive'    => array( 'filterType', 'taxonomy' ),
640            );
641            if ( taxonomy_exists( 'product_brand' ) ) {
642                $additions[] = array(
643                    'name'        => 'product_brand',
644                    'title'       => __( 'Filter by Product Brand', 'jetpack-search-pkg' ),
645                    'description' => __( 'Show product brand checkboxes with live result counts.', 'jetpack-search-pkg' ),
646                    'attributes'  => array(
647                        'filterType' => 'taxonomy',
648                        'taxonomy'   => 'product_brand',
649                        'label'      => __( 'Product Brand', 'jetpack-search-pkg' ),
650                    ),
651                    'isActive'    => array( 'filterType', 'taxonomy' ),
652                );
653            }
654        }
655
656        $additions[] = array(
657            'name'        => 'custom_taxonomy',
658            'title'       => __( 'Filter by Custom Taxonomy', 'jetpack-search-pkg' ),
659            'description' => __( 'Show checkboxes for a custom taxonomy. Pick which taxonomy in the block settings after inserting.', 'jetpack-search-pkg' ),
660            'attributes'  => array(
661                'filterType' => 'taxonomy',
662                'taxonomy'   => '',
663                'label'      => '',
664            ),
665            // Match on filterType only (no taxonomy comparison) so the
666            // variation identity survives once the author picks a slug
667            // via the inspector. Category, Tag, and the product taxonomy
668            // variations all pin `taxonomy` in their isActive arrays, so
669            // WP's most-specific-match resolution still routes those
670            // slugs to their dedicated variations — Custom Taxonomy
671            // claims every other registered taxonomy.
672            'isActive'    => array( 'filterType' ),
673        );
674
675        // Merge by `name` so a variation already registered upstream (block.json
676        // or a higher-priority filter) wins over our preset of the same name —
677        // `array_merge` would otherwise append duplicates and the inserter
678        // would render two cards for the same variation.
679        $variations    = (array) $variations;
680        $existing_keys = array_flip( array_column( $variations, 'name' ) );
681        foreach ( $additions as $variation ) {
682            if ( ! isset( $existing_keys[ $variation['name'] ] ) ) {
683                $variations[] = $variation;
684            }
685        }
686        return $variations;
687    }
688
689    /**
690     * Register block patterns.
691     *
692     * Convention: a pattern file whose basename starts with `wc-` composes
693     * WooCommerce-only blocks and is loaded only when WC is active. Mirrors
694     * the `filter-wc-*` block-slug convention so a new WC-only pattern
695     * auto-enrolls in the gate without an extra registration step.
696     */
697    protected static function register_patterns() {
698        $patterns_dir = __DIR__ . '/patterns';
699        if ( ! is_dir( $patterns_dir ) ) {
700            return;
701        }
702        $pattern_files = glob( $patterns_dir . '/*.php' );
703        if ( ! $pattern_files ) {
704            return;
705        }
706        $is_wc = self::is_woocommerce_active();
707        foreach ( $pattern_files as $pattern_file ) {
708            if ( ! $is_wc && 0 === strpos( basename( $pattern_file ), 'wc-' ) ) {
709                continue;
710            }
711            require_once $pattern_file;
712        }
713    }
714
715    /**
716     * Build the full search page template content.
717     *
718     * Mirrors the "Blog Search Page" pattern's layout (see
719     * `src/search-blocks/patterns/blog-search.php`) wrapped in header/main/
720     * footer template parts so the plugin-registered template renders the
721     * same page users get from inserting the pattern directly. Markup lives
722     * in `templates/jetpack-search.html` — the canonical block-theme format
723     * for block templates — with a `{{FILTER_HEADING}}` placeholder for the
724     * filter-sidebar heading so that string still goes through `esc_html__()`.
725     *
726     * Memoized: `register_search_template()` runs on every `init`, and the
727     * template markup is identical every request, so read the file and run
728     * the translation substitution once per process.
729     *
730     * @return string Block markup for a complete page template.
731     */
732    protected static function get_search_template_content(): string {
733        static $content = null;
734        if ( null !== $content ) {
735            return $content;
736        }
737        $template_path = __DIR__ . '/templates/jetpack-search.html';
738        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- local, bundled template file; wp_remote_get() is for remote URLs.
739        $raw     = is_readable( $template_path ) ? (string) file_get_contents( $template_path ) : '';
740        $content = str_replace(
741            '{{FILTER_HEADING}}',
742            esc_html__( 'Filter options', 'jetpack-search-pkg' ),
743            $raw
744        );
745        return $content;
746    }
747
748    /**
749     * Register the Jetpack Search page template with the block-template
750     * registry so it surfaces in the Site Editor's Templates list and can be
751     * resolved via the template hierarchy.
752     *
753     * Uses `register_block_template()` (WP 6.7+). Jetpack requires WP 6.8+,
754     * so the function is always present at runtime — the function_exists
755     * guard is defensive for phpstan/phan and edge environments.
756     *
757     * DB-stored customizations continue to take precedence: if a site owner
758     * edits this template in the Site Editor, the `custom` source wins during
759     * resolution automatically.
760     */
761    public static function register_search_template() {
762        if ( ! function_exists( 'register_block_template' ) ) {
763            return;
764        }
765        $content = static::get_search_template_content();
766        // Skip registration if the bundled template file is missing or
767        // unreadable. Since this template's slug is prepended to the
768        // search hierarchy, registering with empty content would take
769        // over `/?s=...` and render a blank page; bailing here lets core
770        // fall through to the theme's `search.html` instead.
771        if ( '' === $content ) {
772            return;
773        }
774        register_block_template(
775            static::get_parent_plugin_slug() . '//' . self::SEARCH_TEMPLATE_SLUG,
776            array(
777                'title'       => __( 'Jetpack Search Results', 'jetpack-search-pkg' ),
778                'description' => __( 'Displays search results with Jetpack Search filters.', 'jetpack-search-pkg' ),
779                'content'     => $content,
780            )
781        );
782    }
783
784    /**
785     * Directory slug of the plugin that should own the template in the
786     * Site Editor UI.
787     *
788     * The Templates list labels plugin-registered templates by looking up an
789     * active plugin whose directory slug matches the namespace portion of
790     * the registered template name. We pick the slug by preference rather
791     * than by install path so that on sites running both the Jetpack
792     * monolith and the standalone Jetpack Search plugin, the more-specific
793     * "Jetpack Search" label always wins:
794     *
795     * - Jetpack Search plugin active → `jetpack-search` → "Jetpack Search"
796     * - Otherwise Jetpack plugin active → `jetpack` → "Jetpack"
797     * - Neither active (unexpected) → `jetpack-search` fallback
798     *
799     * @return string
800     */
801    protected static function get_parent_plugin_slug(): string {
802        // Helper::get_active_plugins() already centralizes single-site +
803        // multisite active-plugin discovery (reads `active_plugins`, unions
804        // network-activated plugins from `active_sitewide_plugins`, dedupes).
805        // Reuse it so multisite/activation behavior stays consistent across
806        // the package if it ever evolves.
807        $active    = Helper::get_active_plugins();
808        $preferred = array(
809            'jetpack-search' => 'jetpack-search/jetpack-search.php',
810            'jetpack'        => 'jetpack/jetpack.php',
811        );
812        foreach ( $preferred as $slug => $plugin_file ) {
813            if ( in_array( $plugin_file, $active, true ) ) {
814                return $slug;
815            }
816        }
817        return 'jetpack-search';
818    }
819
820    /**
821     * Prepend the Jetpack Search template slug to the search template hierarchy
822     * so `/?s=…` requests resolve to our plugin-registered template instead of
823     * the theme's `search.html`.
824     *
825     * Core resolves each slug in order, stopping at the first template it
826     * finds. Because our slug is unique (`jetpack-search`, not `search`), the
827     * theme's `search.html` is never consulted when this prepend is in effect.
828     * Site Editor customizations (stored in the DB keyed by this slug) still
829     * take precedence over the plugin-registered default.
830     *
831     * Existing occurrences of the slug are stripped first so the hierarchy
832     * can't accumulate duplicates from a second init pass or another filter
833     * on the same hook.
834     *
835     * @param string[] $templates Template hierarchy slugs.
836     * @return string[]
837     */
838    public static function prepend_search_template( $templates ) {
839        $templates = array_values(
840            array_filter(
841                (array) $templates,
842                static function ( $slug ) {
843                    return self::SEARCH_TEMPLATE_SLUG !== $slug;
844                }
845            )
846        );
847        array_unshift( $templates, self::SEARCH_TEMPLATE_SLUG );
848        return $templates;
849    }
850
851    /**
852     * Seed the Interactivity API store with initial state.
853     *
854     * Individual block render.php files may also call wp_interactivity_state()
855     * — core deep-merges each call, so each block can contribute its own
856     * entries (e.g. filter-checkbox writes its filterConfig). Filter blocks
857     * placed in templates or template parts contribute their config the same
858     * way; the complete registry exists by the time JS hydrates.
859     *
860     * URL-derived `activeFilters` is passed straight through; the JS store
861     * gates it against the complete `filterConfigs` registry on hydration
862     * (see `gateActiveFilters()` in `store/index.js`), so any stray params
863     * don't round-trip back into subsequent search URLs.
864     */
865    public static function seed_interactivity_state() {
866        if ( ! function_exists( 'wp_interactivity_state' ) ) {
867            return;
868        }
869        wp_interactivity_state(
870            'jetpack-search',
871            static::build_seed_state( static::collect_filter_configs_from_post() )
872        );
873    }
874
875    /**
876     * Compose the final seeded state for `wp_interactivity_state()`.
877     *
878     * `activeFilters` is passed through from the URL — the JS store gates
879     * against the complete `filterConfigs` registry on hydration.
880     *
881     * @param array<string, array<string, mixed>> $filter_configs Map of filter
882     *   configs collected from the current post (or injected by tests).
883     * @return array<string, mixed>
884     */
885    public static function build_seed_state( array $filter_configs ): array {
886        $state                  = static::build_initial_state();
887        $state['filterConfigs'] = $filter_configs;
888        return $state;
889    }
890
891    /**
892     * Walk the current post's block tree for jetpack-search/filter-checkbox blocks
893     * and build the matching filterConfigs map.
894     *
895     * Covers the common case where a page uses the Blog Search Page pattern
896     * (or blocks inserted directly into $post->post_content). Template-part
897     * / block-theme scans are not performed here — a filter block placed
898     * inside a template part will still work, but its config won't be
899     * available to the search-results SSR until hydration.
900     *
901     * @return array<string, array<string, mixed>>
902     */
903    protected static function collect_filter_configs_from_post(): array {
904        if ( ! function_exists( 'get_post' ) || ! function_exists( 'parse_blocks' ) ) {
905            return array();
906        }
907        // Bail if any helper is missing — half-loaded feature would ship inconsistent filterConfigs.
908        foreach ( static::filter_block_helpers() as $helper ) {
909            if ( ! class_exists( $helper ) ) {
910                return array();
911            }
912        }
913        $post = get_post();
914        if ( ! $post || empty( $post->post_content ) ) {
915            return array();
916        }
917        $configs = array();
918        static::walk_blocks_for_filter_configs( parse_blocks( $post->post_content ), $configs );
919        return $configs;
920    }
921
922    /**
923     * Map of filter block name → helper class. Add a new filter block type
924     * by appending one entry here.
925     *
926     * @return array<string, class-string>
927     */
928    protected static function filter_block_helpers(): array {
929        $helpers = array(
930            'jetpack-search/filter-checkbox'        => Filter_Checkbox::class,
931            'jetpack-search/filter-date'            => Filter_Date::class,
932            'jetpack-search/filter-wc-rating'       => Filter_Wc_Rating::class,
933            'jetpack-search/filter-wc-attribute'    => Filter_Wc_Attribute::class,
934            'jetpack-search/filter-wc-stock-status' => Search_Product_Filter_Status::class,
935        );
936        if ( self::is_woocommerce_active() ) {
937            return $helpers;
938        }
939        // On non-Woo sites the WC-only blocks aren't registered (see
940        // `register_blocks()`), so any saved instance in post content has no
941        // renderer. Drop them from the helper map too — that keeps the
942        // filter-config walk symmetrical with what the inserter offers.
943        foreach ( array_keys( $helpers ) as $name ) {
944            if ( self::is_woocommerce_only_block( $name ) ) {
945                unset( $helpers[ $name ] );
946            }
947        }
948        return $helpers;
949    }
950
951    /**
952     * Recursively walk a parsed block tree, pushing each filter block's
953     * config into `$configs` by reference.
954     *
955     * @param array $blocks  Parsed block tree from parse_blocks().
956     * @param array $configs Accumulator map keyed by filterKey.
957     * @return void
958     */
959    protected static function walk_blocks_for_filter_configs( array $blocks, array &$configs ): void {
960        $helpers = static::filter_block_helpers();
961        foreach ( $blocks as $block ) {
962            if ( ! is_array( $block ) ) {
963                continue;
964            }
965            $block_name = (string) ( $block['blockName'] ?? '' );
966            if ( isset( $helpers[ $block_name ] ) ) {
967                $helper = $helpers[ $block_name ];
968                $attrs  = (array) ( $block['attrs'] ?? array() );
969                $key    = $helper::derive_filter_key( $attrs );
970                if ( '' !== $key ) {
971                    $configs[ $key ] = $helper::build_config( $attrs, $key );
972                }
973            }
974
975            if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) {
976                static::walk_blocks_for_filter_configs( $block['innerBlocks'], $configs );
977            }
978        }
979    }
980
981    /**
982     * Build the initial state array for the jetpack-search Interactivity API store.
983     *
984     * @return array<string, mixed>
985     */
986    public static function build_initial_state() {
987        $is_private         = class_exists( Status::class ) ? ( new Status() )->is_private_site() : false;
988        $is_wpcom           = class_exists( Helper::class ) ? Helper::is_wpcom() : false;
989        $site_id            = class_exists( Helper::class ) ? Helper::get_wpcom_site_id() : 0;
990        $search_query       = static::parse_url_search_query();
991        $active_filters     = static::parse_url_filters();
992        $filter_logic       = static::parse_url_filter_logic( $active_filters );
993        $price_range        = static::parse_url_price_range();
994        $is_initial_loading = static::is_initial_loading();
995        $searching_text     = function_exists( '__' ) ? __( 'Searching…', 'jetpack-search-pkg' ) : 'Searching…';
996
997        return array(
998            // Connection / routing config.
999            'siteId'                => $site_id,
1000            'apiRoot'               => function_exists( 'rest_url' ) ? esc_url_raw( rest_url() ) : '',
1001            'nonce'                 => function_exists( 'wp_create_nonce' ) ? wp_create_nonce( 'wp_rest' ) : '',
1002            'isPrivateSite'         => $is_private,
1003            'isWpcom'               => $is_wpcom,
1004            // Whether the product-format sort keys (rating, price asc/desc)
1005            // are valid on this site, plus a JS-side gate any WC-only block
1006            // can read. The store threads it into url-state so a
1007            // `?orderby=price_asc` deep link round-trips on Woo sites and
1008            // collapses to relevance everywhere else.
1009            'isWooCommerceActive'   => self::is_woocommerce_active(),
1010            'homeUrl'               => function_exists( 'home_url' ) ? home_url() : '',
1011            // BCP47-ish locale (e.g. `en-US`) for Intl.DateTimeFormat on the
1012            // client. Converts WP's `en_US` underscore form. Uses the blog
1013            // locale (site setting) rather than the viewer's user-profile
1014            // locale so formatting is consistent for logged-out visitors
1015            // hitting a search page.
1016            'locale'                => function_exists( 'get_locale' )
1017                ? str_replace( '_', '-', get_locale() )
1018                : 'en-US',
1019
1020            // Search state, seeded from the URL so a deep link like
1021            // /?s=boots&orderby=newest&category[]=news renders correctly on
1022            // first paint.
1023            'searchQuery'           => $search_query,
1024            // Whether the search-query URL key was present in `$_GET`, even
1025            // when its value is empty. The JS store's `initialize()` reads
1026            // this so a `?s=` deep link still fires the initial search —
1027            // `searchQuery` alone can't carry that signal because an empty
1028            // param and a missing param both round-trip as `''`.
1029            'hasSearchParam'        => static::has_search_param(),
1030            // URL key the JS store uses to read/write the search query. `s`
1031            // on the WP search route, `q` on non-search pages — see
1032            // `get_search_param_name()`. Threaded through the seed so the JS
1033            // store reads from the same key the seed pulled `searchQuery`
1034            // from.
1035            'searchParamName'       => static::get_search_param_name(),
1036            'sortOrder'             => static::parse_url_sort(),
1037            'activeFilters'         => $active_filters,
1038            'filterLogic'           => $filter_logic,
1039            'priceRange'            => $price_range,
1040
1041            // filterConfigs: each filter-checkbox block's render.php merges its
1042            // own entry here. Shape: { [filterKey]: { filterKey, filterType,
1043            // taxonomy, effectiveSlug, label, showCount, maxItems } }. The
1044            // `effectiveSlug` is resolved server-side at config-build time
1045            // against `jetpack_search_custom_taxonomy_map`, so JS query
1046            // builders never have to consult the global map themselves.
1047            'filterConfigs'         => array(),
1048
1049            // Note: `staticPostTypes` (contributed by `jetpack-search/filter-post-type`)
1050            // is intentionally NOT seeded here. FSE block templates can render
1051            // before `wp_enqueue_scripts` fires (where this seed runs), so
1052            // pre-seeding the slot with `{ include: [], exclude: [] }` would
1053            // merge AFTER the block contribution and clobber it. Letting
1054            // render.php own the slot keeps template-rendered blocks working;
1055            // the JS reader treats `state.staticPostTypes` undefined as
1056            // "no constraint" via Array.isArray() checks in store/api.js.
1057
1058            // Results + aggregations are populated by the JS store on hydration —
1059            // seed empty defaults so template bindings always have a shape to read.
1060            // `aggregations` is a stdClass so JS sees `{}`, not `[]`.
1061            'results'               => array(),
1062            'aggregations'          => (object) array(),
1063            // Per-filter union of values seen across the session's aggregation
1064            // responses. The JS store appends to this on each successful fetch
1065            // so checkbox-filter lists can keep options visible even after a
1066            // narrower query drops them from ES results.
1067            'retainedFilterOptions' => (object) array(),
1068            'totalResults'          => 0,
1069            'pageHandle'            => null,
1070
1071            // UI state. `isLoading` is seeded true when the URL carries a
1072            // search query or filter selection so the empty-state region inside
1073            // `jetpack-search/results-list` stays hidden between first paint and JS
1074            // hydrating the initial fetch — otherwise a "No results found" flash
1075            // appears on deep links.
1076            'isLoading'             => $is_initial_loading,
1077            'isLoadingMore'         => false,
1078            'hasError'              => false,
1079
1080            // One-shot pre-hydration skeleton gate. The IA SSR pass evaluates
1081            // `data-wp-bind--hidden` against literal seeded values (it can't
1082            // run JS getters), so skeleton elements bind directly to this
1083            // boolean. JS flips it to true once `actions.search()` resolves
1084            // and never resets it — subsequent re-searches keep live results
1085            // on screen without re-flashing placeholders.
1086            'skeletonHidden'        => false,
1087
1088            // Seeded so the SSR pass can resolve `data-wp-text` to a real
1089            // string on first paint; `actions.search()` keeps it in lockstep
1090            // with `isLoading` / `totalResults` via `computeResultsCountText`.
1091            'resultsCountText'      => $is_initial_loading ? $searching_text : '',
1092
1093            // Translated view-bundle strings. The Interactivity API view bundle
1094            // can't import @wordpress/i18n (only @wordpress/interactivity is
1095            // registered as a script module), so any JS-produced text is seeded
1096            // here and read via state.strings.* on the client. Both _n() forms
1097            // are seeded so the client can pick based on the live totalResults
1098            // without a round trip; languages with more than two plural forms
1099            // degrade to "plural for all count > 1" as an accepted tradeoff.
1100            'strings'               => static::build_initial_strings(),
1101
1102            // Currency symbol displayed inside the price filter pill rendered
1103            // by the active-filters block. Defaults to `$`; the price block's
1104            // render.php overrides this with the author's currencySymbol
1105            // attribute so a single chip on the page reflects whatever symbol
1106            // the price input itself uses. The stored numeric value stays
1107            // locale-agnostic — only the display string carries the symbol.
1108            'priceCurrencySymbol'   => '$',
1109
1110            // Display labels for `wc_stock_status` selections, keyed by slug.
1111            // Seeded from the status block's static option list so an active-
1112            // filters chip for "instock" reads "In stock" rather than the raw
1113            // slug. RSM-1932 will swap this with WC's translated labels so
1114            // non-English locales render correctly; the map shape stays the
1115            // same.
1116            'wcStockStatusLabels'   => static::build_stock_status_labels(),
1117        );
1118    }
1119
1120    /**
1121     * Slug → display label map for `wc_stock_status` selections, used by the
1122     * active-filters block to render product-aware chips.
1123     *
1124     * Sourced from the status block's `get_options()` so there's one source of
1125     * truth for the label set; in RSM-1932 we'll switch to WC's translated
1126     * labels (`wc_get_product_stock_status_options()`) without changing this
1127     * shape. Returns an empty array when the status helper class isn't loaded
1128     * — defensive for environments that pull the search package in isolation
1129     * (tests, partial installs, or sites where the status block PR hasn't
1130     * landed yet).
1131     *
1132     * @return array<string, string>
1133     */
1134    protected static function build_stock_status_labels(): array {
1135        if ( ! class_exists( Search_Product_Filter_Status::class ) ) {
1136            return array();
1137        }
1138        $labels = array();
1139        foreach ( Search_Product_Filter_Status::get_options() as $option ) {
1140            $value = (string) ( $option['value'] ?? '' );
1141            if ( '' === $value ) {
1142                continue;
1143            }
1144            $labels[ $value ] = (string) ( $option['label'] ?? $value );
1145        }
1146        return $labels;
1147    }
1148
1149    /**
1150     * Whether the page starts in a loading state — i.e. the URL carries a
1151     * search query, filter selection, or price range, so the JS store will
1152     * fire an initial fetch on hydration.
1153     *
1154     * Render.php callers branch on this to emit pre-hydration affordances
1155     * (skeleton placeholders, seeded "Searching…" text). The value is derived
1156     * from the request URL rather than read back through
1157     * `wp_interactivity_state()` because in block themes individual block
1158     * renders can run before `seed_interactivity_state()` finishes (FSE
1159     * pre-resolves template attributes by walking blocks before the
1160     * `wp_enqueue_scripts` hook fires) — so a state-read fallback would
1161     * silently return false on the very pages this helper is meant to flag.
1162     * The condition mirrors the `isLoading` value seeded into
1163     * `build_initial_state()` exactly so PHP-time and JS-side answers stay
1164     * in lockstep.
1165     *
1166     * @return bool
1167     */
1168    public static function is_initial_loading(): bool {
1169        // Memoize per-request: the URL doesn't change mid-request, and this
1170        // helper is hit by every block render.php (one per filter block plus
1171        // search-results, results-count, etc.) AND by `build_initial_state()`,
1172        // each of which would otherwise re-parse `$_GET` independently.
1173        if ( null !== self::$is_initial_loading_cache ) {
1174            return self::$is_initial_loading_cache;
1175        }
1176        // `has_search_param()` rather than `parse_url_search_query() !== ''` —
1177        // an explicit `?s=` (empty value) still means the visitor landed on a
1178        // search page and expects an initial unfiltered result set, the same
1179        // as submitting a blank search form. The non-empty case is a subset
1180        // of "param present" so this guard subsumes the old text-query check.
1181        if ( static::has_search_param() ) {
1182            self::$is_initial_loading_cache = true;
1183            return true;
1184        }
1185        if ( ! empty( static::parse_url_filters() ) ) {
1186            self::$is_initial_loading_cache = true;
1187            return true;
1188        }
1189        self::$is_initial_loading_cache = null !== static::parse_url_price_range();
1190        return self::$is_initial_loading_cache;
1191    }
1192
1193    /**
1194     * Reset the `is_initial_loading()` memo. Test-only — production WP runs
1195     * a single request per process, so the memo never needs clearing there.
1196     * The PHPUnit harness reuses one process across every test method, so
1197     * without this hook a `$_GET` set by an earlier test would pin the
1198     * memoized value and silently override later tests' URL fixtures.
1199     *
1200     * Guarded so a misconfigured production caller can't accidentally drop
1201     * the cache mid-request: bail when running under WordPress (`ABSPATH`
1202     * defined) but not under PHPUnit (`PHPUNIT_COMPOSER_INSTALL` is set by
1203     * PHPUnit's composer-installed autoloader).
1204     */
1205    public static function reset_initial_loading_cache(): void {
1206        if ( defined( 'ABSPATH' ) && ! defined( 'PHPUNIT_COMPOSER_INSTALL' ) ) {
1207            return;
1208        }
1209        self::$is_initial_loading_cache = null;
1210    }
1211
1212    /**
1213     * Pre-hydration view state for a filter block's wrapper. Centralizes the
1214     * seeded-state read shared by filter-checkbox and filter-date so each
1215     * render.php branches on a single struct rather than re-deriving the
1216     * same flags inline.
1217     *
1218     * @param string $filter_key The filter key (e.g. `category`, `post_type`).
1219     * @return array{has_buckets:bool,is_initial_loading:bool,show_wrapper:bool}
1220     */
1221    public static function pre_hydration_filter_view( string $filter_key ): array {
1222        if ( ! function_exists( 'wp_interactivity_state' ) ) {
1223            return array(
1224                'has_buckets'        => false,
1225                'is_initial_loading' => false,
1226                'show_wrapper'       => false,
1227            );
1228        }
1229        // `aggregations` is seeded as `stdClass` when empty (so JS sees `{}`,
1230        // not `[]`); cast before subscripting so the read works in either shape.
1231        $state              = wp_interactivity_state( 'jetpack-search' );
1232        $aggs               = (array) ( $state['aggregations'] ?? array() );
1233        $has_buckets        = ! empty( $aggs[ $filter_key ]['buckets'] ?? array() );
1234        $is_initial_loading = static::is_initial_loading();
1235        return array(
1236            'has_buckets'        => $has_buckets,
1237            'is_initial_loading' => $is_initial_loading,
1238            'show_wrapper'       => $has_buckets || $is_initial_loading,
1239        );
1240    }
1241
1242    /**
1243     * Emit the `data-wp-context` attribute for a filter block's wrapper. The
1244     * seeded `wrapperHidden` value is what the IA SSR pass evaluates
1245     * `data-wp-bind--hidden="context.wrapperHidden"` against, and what the
1246     * `syncFilterWrapperVisibility` callback updates after hydration.
1247     *
1248     * @param string $filter_key   The filter key.
1249     * @param bool   $show_wrapper Whether the wrapper should be visible on first paint.
1250     */
1251    public static function emit_filter_wrapper_context( string $filter_key, bool $show_wrapper ): void {
1252        if ( ! function_exists( 'wp_interactivity_data_wp_context' ) ) {
1253            return;
1254        }
1255        echo wp_kses_data(
1256            wp_interactivity_data_wp_context(
1257                array(
1258                    'filterKey'     => $filter_key,
1259                    'wrapperHidden' => ! $show_wrapper,
1260                )
1261            )
1262        );
1263    }
1264
1265    /**
1266     * Normalize the `displayStyle` attribute shared by the bucket-driven
1267     * filter blocks (`filter-checkbox`, `filter-date`, `filter-wc-attribute`)
1268     * so render wrappers always emit one of the two supported CSS variants.
1269     * Per-block classes delegate here so every adopting block applies the
1270     * same fallback rule.
1271     *
1272     * `filter-wc-stock-status` (single option) and `filter-wc-rating` (star
1273     * rows + "& up" suffix + count badge) deliberately don't ship a chip
1274     * variant — see the PR thread for the discussion — so they don't call
1275     * this helper today. Adding them later is a one-attribute change in
1276     * their respective `block.json` / `render.php` / `edit.js`; the helper
1277     * doesn't need updating.
1278     *
1279     * @param mixed $value Raw attribute value (string, null, or unexpected type).
1280     * @return string Either 'checkbox-list' or 'chips'.
1281     */
1282    public static function normalize_display_style( $value ): string {
1283        return 'chips' === $value ? 'chips' : 'checkbox-list';
1284    }
1285
1286    /**
1287     * Seed translated view-bundle strings for the Interactivity API store.
1288     *
1289     * @return array<string, string>
1290     */
1291    protected static function build_initial_strings(): array {
1292        if ( ! function_exists( '__' ) || ! function_exists( '_n' ) ) {
1293            return array(
1294                'searching'              => 'Searching…',
1295                'resultsCountSingle'     => 'Found %d result',
1296                'resultsCountPlural'     => 'Found %d results',
1297                'removeFilter'           => 'Remove %s',
1298                'ratingStarsTop'         => '5 stars',
1299                'ratingStarsAndUpSingle' => '%d star and up',
1300                'ratingStarsAndUpPlural' => '%d stars and up',
1301                'priceRangeFromTo'       => '%1$s – %2$s',
1302                'priceRangeFrom'         => '%s+',
1303                'priceRangeUpTo'         => 'Under %s',
1304                'priceLabel'             => 'Price',
1305            );
1306        }
1307        return array(
1308            'searching'              => __( 'Searching…', 'jetpack-search-pkg' ),
1309            /* translators: %d: number of results. */
1310            'resultsCountSingle'     => _n( 'Found %d result', 'Found %d results', 1, 'jetpack-search-pkg' ),
1311            /* translators: %d: number of results. */
1312            'resultsCountPlural'     => _n( 'Found %d result', 'Found %d results', 2, 'jetpack-search-pkg' ),
1313            /* translators: %s: filter label (e.g. "Category: News"). Announced by screen readers when focus lands on a filter pill's remove button. */
1314            'removeFilter'           => __( 'Remove %s', 'jetpack-search-pkg' ),
1315            /* 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. */
1316            'ratingStarsTop'         => __( '5 stars', 'jetpack-search-pkg' ),
1317            /* 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. */
1318            'ratingStarsAndUpSingle' => _n( '%d star and up', '%d stars and up', 1, 'jetpack-search-pkg' ),
1319            /* 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. */
1320            'ratingStarsAndUpPlural' => _n( '%d star and up', '%d stars and up', 2, 'jetpack-search-pkg' ),
1321            /* 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. */
1322            'priceRangeFromTo'       => __( '%1$s – %2$s', 'jetpack-search-pkg' ),
1323            /* 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. */
1324            'priceRangeFrom'         => __( '%s+', 'jetpack-search-pkg' ),
1325            /* 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. */
1326            'priceRangeUpTo'         => __( 'Under %s', 'jetpack-search-pkg' ),
1327            /* 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. */
1328            'priceLabel'             => __( 'Price', 'jetpack-search-pkg' ),
1329        );
1330    }
1331
1332    /**
1333     * Parse the search query from the URL, reading whichever key
1334     * `get_search_param_name()` says is active for this request (`s` on
1335     * the WP search route, `q` everywhere else). Sanitization mirrors
1336     * what WP would have done for `s` (sanitize_text_field + trim).
1337     *
1338     * Public so block render templates (e.g. `search-input/render.php`)
1339     * can seed their initial `value=` from the same source the
1340     * Interactivity store seeds `searchQuery` from.
1341     *
1342     * @return string
1343     */
1344    public static function parse_url_search_query(): string {
1345        $key = self::get_search_param_name();
1346        // 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.
1347        $raw = $_GET[ $key ] ?? '';
1348        if ( ! is_scalar( $raw ) ) {
1349            return '';
1350        }
1351        return trim( sanitize_text_field( wp_unslash( (string) $raw ) ) );
1352    }
1353
1354    /**
1355     * Whether the active search-query URL key is present in `$_GET`, regardless
1356     * of value. Distinguishes `?s=` (visitor submitted a blank search and
1357     * expects an unfiltered result set) from a URL that omits `s` entirely
1358     * (homepage, archive, etc.) — `parse_url_search_query()` collapses both
1359     * to `''`. Mirrors the JS-side `hasSearchParam` field seeded into the
1360     * Interactivity store.
1361     *
1362     * Array-shaped `?s[]=foo` is treated as "not present" to stay in lockstep
1363     * with `parse_url_search_query()` (which returns `''` for non-scalar
1364     * input) — otherwise a malformed deep link would flip the page into the
1365     * loading state with no usable query to fire.
1366     *
1367     * @return bool
1368     */
1369    public static function has_search_param(): bool {
1370        $key = self::get_search_param_name();
1371        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only URL presence check; the value is never read here.
1372        return isset( $_GET[ $key ] ) && is_scalar( $_GET[ $key ] );
1373    }
1374
1375    /**
1376     * Parse the sort order from the URL, defaulting to 'relevance'. Valid
1377     * values come from `Results_Sort::get_all_option_keys()` so the seeded
1378     * sort matches what the results-sort block would render — including the
1379     * product-format keys when WooCommerce is active. On non-Woo sites a
1380     * `?orderby=price_asc` deep link collapses to `relevance`, mirroring the
1381     * JS-side gate in store/url-state.js.
1382     *
1383     * @return string
1384     */
1385    protected static function parse_url_sort(): string {
1386        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only URL state.
1387        $orderby = isset( $_GET['orderby'] ) ? sanitize_key( wp_unslash( $_GET['orderby'] ) ) : '';
1388        $allowed = array_values(
1389            array_filter(
1390                Results_Sort::get_all_option_keys(),
1391                static function ( $key ) {
1392                    return 'relevance' !== $key;
1393                }
1394            )
1395        );
1396        return in_array( $orderby, $allowed, true ) ? $orderby : 'relevance';
1397    }
1398
1399    /**
1400     * Parse the price range from the URL, mirroring the contract in
1401     * src/search-blocks/store/url-state.js. Either bound may be null for a
1402     * half-open range; non-numeric or negative values yield null so a
1403     * garbage URL can't drive the API into producing zero results.
1404     *
1405     * Returns null when neither bound is set, so callers can early-out
1406     * without checking individual fields. Also returns null on non-Woo
1407     * sites — `min_price` / `max_price` are WC-only and the price filter
1408     * block (`filter-wc-price`) isn't registered there, so a stray
1409     * `?min_price=10` in the URL can't drive the API into building a
1410     * `range` clause for a field the index doesn't have.
1411     *
1412     * @return array{min: float|null, max: float|null}|null
1413     */
1414    protected static function parse_url_price_range(): ?array {
1415        if ( ! self::is_woocommerce_active() ) {
1416            return null;
1417        }
1418        // phpcs:disable WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- read-only URL state; coerced to float in parse_price_bound() which discards any non-numeric input.
1419        $min = self::parse_price_bound( $_GET['min_price'] ?? null );
1420        $max = self::parse_price_bound( $_GET['max_price'] ?? null );
1421        // phpcs:enable
1422
1423        if ( null === $min && null === $max ) {
1424            return null;
1425        }
1426        // Both bounds present but inverted (min > max) yields an empty ES
1427        // `range` clause that returns zero results silently. Treat the URL
1428        // as garbage and bail so the page renders a normal (unfiltered)
1429        // search rather than a guaranteed-empty one. Mirrors the same
1430        // rejection in store/url-state.js.
1431        if ( null !== $min && null !== $max && $min > $max ) {
1432            return null;
1433        }
1434        return array(
1435            'min' => $min,
1436            'max' => $max,
1437        );
1438    }
1439
1440    /**
1441     * Coerce a single price-range URL value into a finite, non-negative float.
1442     *
1443     * @param mixed $raw Raw value pulled from $_GET.
1444     * @return float|null
1445     */
1446    private static function parse_price_bound( $raw ): ?float {
1447        if ( null === $raw || '' === $raw || ! is_scalar( $raw ) ) {
1448            return null;
1449        }
1450        // `is_numeric` rejects partially-numeric strings like "1.5.3" that
1451        // the (float) cast would silently extract as 1.5 — JS's Number()
1452        // returns NaN for the same input, so without this gate the PHP
1453        // initial render and JS hydration disagree on parsed value.
1454        $raw = wp_unslash( $raw );
1455        if ( ! is_numeric( $raw ) ) {
1456            return null;
1457        }
1458        $num = (float) $raw;
1459        if ( ! is_finite( $num ) || $num < 0 ) {
1460            return null;
1461        }
1462        return $num;
1463    }
1464
1465    /**
1466     * Parse flat filter selections from the current request URL.
1467     *
1468     * Accepts any top-level array-shaped `?<filterKey>[]=<value>` param
1469     * (the same shape store/url-state.js writes) and returns an
1470     * { [filterKey]: string[] } map. The JS layer drops filters whose keys
1471     * are not registered in `filterConfigs`; doing the same here would
1472     * require access to block attributes at state-seed time (before blocks
1473     * render), which we don't have. Values are sanitized so any garbage
1474     * round-tripped through the URL never reaches ES.
1475     *
1476     * @return array<string, string[]>
1477     */
1478    protected static function parse_url_filters(): array {
1479        // phpcs:ignore WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- read-only URL state; sanitized per-value below.
1480        $raw = wp_unslash( $_GET );
1481        if ( ! is_array( $raw ) ) {
1482            return array();
1483        }
1484
1485        $out = array();
1486        foreach ( $raw as $key => $values ) {
1487            $filter_key = sanitize_key( (string) $key );
1488            if ( '' === $filter_key || in_array( $filter_key, self::RESERVED_QUERY_PARAMS, true ) ) {
1489                continue;
1490            }
1491            if ( ! is_array( $values ) ) {
1492                continue;
1493            }
1494            $clean = array_values(
1495                array_filter(
1496                    array_map( 'sanitize_text_field', $values ),
1497                    static function ( $v ) {
1498                        return '' !== $v;
1499                    }
1500                )
1501            );
1502            if ( $clean ) {
1503                $out[ $filter_key ] = $clean;
1504            }
1505        }
1506        return $out;
1507    }
1508
1509    /**
1510     * Parse the per-filter AND/OR override params (`?query_type_<key>=and`)
1511     * from the current request URL. Returns `{ [filterKey]: 'and' }` —
1512     * matches the JS-side parser in `store/url-state.js`. Only the literal
1513     * value `'and'` is honoured; anything else collapses to the default and
1514     * is omitted so it can never round-trip back through `pushStateToUrl`.
1515     *
1516     * Filter keys for which no active selection exists are dropped because
1517     * they'd otherwise hang around in state and re-emit on the next URL push.
1518     *
1519     * @param array<string, string[]> $active_filters Result of parse_url_filters().
1520     * @return array<string, string>
1521     */
1522    protected static function parse_url_filter_logic( array $active_filters ): array {
1523        // phpcs:ignore WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- read-only URL state; sanitized per-value below.
1524        $raw = wp_unslash( $_GET );
1525        if ( ! is_array( $raw ) ) {
1526            return array();
1527        }
1528
1529        $out = array();
1530        foreach ( $raw as $key => $value ) {
1531            if ( ! is_string( $key ) || 0 !== strpos( $key, 'query_type_' ) ) {
1532                continue;
1533            }
1534            if ( ! is_string( $value ) || 'and' !== $value ) {
1535                continue;
1536            }
1537            $filter_key = sanitize_key( substr( $key, strlen( 'query_type_' ) ) );
1538            if ( '' === $filter_key || in_array( $filter_key, self::RESERVED_QUERY_PARAMS, true ) ) {
1539                continue;
1540            }
1541            if ( empty( $active_filters[ $filter_key ] ) ) {
1542                continue;
1543            }
1544            $out[ $filter_key ] = 'and';
1545        }
1546        return $out;
1547    }
1548}