Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
43.80% covered (danger)
43.80%
120 / 274
28.57% covered (danger)
28.57%
6 / 21
CRAP
0.00% covered (danger)
0.00%
0 / 1
Search_Blocks
43.80% covered (danger)
43.80%
120 / 274
28.57% covered (danger)
28.57%
6 / 21
1367.77
0.00% covered (danger)
0.00%
0 / 1
 init
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 enqueue_editor_assets
0.00% covered (danger)
0.00%
0 / 13
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 / 14
0.00% covered (danger)
0.00%
0 / 1
30
 register_variations
0.00% covered (danger)
0.00%
0 / 59
0.00% covered (danger)
0.00%
0 / 1
12
 register_patterns
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
20
 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%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 gate_active_filters
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 collect_filter_configs_from_post
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
42
 walk_blocks_for_filter_configs
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
56
 build_initial_state
100.00% covered (success)
100.00%
30 / 30
100.00% covered (success)
100.00%
1 / 1
11
 build_initial_strings
53.85% covered (warning)
53.85%
7 / 13
0.00% covered (danger)
0.00%
0 / 1
3.88
 parse_url_sort
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
3
 parse_url_price_range
40.00% covered (danger)
40.00%
4 / 10
0.00% covered (danger)
0.00%
0 / 1
13.78
 parse_price_bound
22.22% covered (danger)
22.22%
2 / 9
0.00% covered (danger)
0.00%
0 / 1
30.05
 parse_url_filters
95.24% covered (success)
95.24%
20 / 21
0.00% covered (danger)
0.00%
0 / 1
7
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    const RESERVED_QUERY_PARAMS = array( 's', 'orderby', 'min_price', 'max_price' );
22
23    /**
24     * Template slug used for the Jetpack Search page template.
25     *
26     * Intentionally distinct from WordPress's `search` slug so the plugin
27     * template never collides with (and gets deduplicated against) a block
28     * theme's own `search.html`. `search_template_hierarchy` prepends this
29     * slug so it still wins on `/?s=...` requests.
30     */
31    const SEARCH_TEMPLATE_SLUG = 'jetpack-search';
32
33    /**
34     * Register block types and hook into WordPress.
35     *
36     * The caller (Initializer) is responsible for gating this behind the
37     * `jetpack_search_blocks_enabled` feature flag.
38     */
39    public static function init() {
40        add_action( 'init', array( static::class, 'register_blocks' ) );
41        add_action( 'init', array( static::class, 'register_search_template' ) );
42        add_filter( 'block_categories_all', array( static::class, 'register_block_category' ) );
43        add_filter( 'search_template_hierarchy', array( static::class, 'prepend_search_template' ) );
44        add_action( 'wp_enqueue_scripts', array( static::class, 'seed_interactivity_state' ) );
45        add_action( 'enqueue_block_editor_assets', array( static::class, 'enqueue_editor_assets' ) );
46    }
47
48    /**
49     * Enqueue the client-side block registration bundle in the block editor.
50     *
51     * WordPress bootstraps server-side block metadata into the editor, but a
52     * client-side registerBlockType() call is still needed for each block so
53     * the editor knows how to render a preview. This script registers all
54     * Jetpack Search blocks with ServerSideRender for the editor preview.
55     */
56    public static function enqueue_editor_assets() {
57        $base_path  = Package::get_installed_path() . 'build/search-blocks-editor/';
58        $asset_file = $base_path . 'register-blocks.asset.php';
59        if ( ! file_exists( $asset_file ) ) {
60            return;
61        }
62        $asset = require $asset_file;
63
64        // Convert the filesystem path to a URL. plugins_url() resolves against
65        // the nearest plugin directory, which handles the jetpack_vendor
66        // location that Composer installs the package into.
67        $url = plugins_url( 'register-blocks.js', $base_path . 'register-blocks.js' );
68
69        wp_enqueue_script(
70            'jetpack-search-blocks-register',
71            $url,
72            $asset['dependencies'] ?? array(),
73            $asset['version'] ?? false,
74            true
75        );
76    }
77
78    /**
79     * Add a "Jetpack Search" block category so our blocks appear under that
80     * heading in the inserter instead of "Uncategorized".
81     *
82     * @param array $categories Existing block categories.
83     * @return array
84     */
85    public static function register_block_category( $categories ) {
86        foreach ( $categories as $category ) {
87            if ( 'jetpack-search' === ( $category['slug'] ?? '' ) ) {
88                return $categories;
89            }
90        }
91        $categories[] = array(
92            'slug'  => 'jetpack-search',
93            'title' => __( 'Jetpack Search', 'jetpack-search-pkg' ),
94        );
95        return $categories;
96    }
97
98    /**
99     * Register all search blocks from their block.json files.
100     */
101    public static function register_blocks() {
102        // Register block pattern category first so patterns can reference it.
103        if ( function_exists( 'register_block_pattern_category' ) ) {
104            register_block_pattern_category(
105                'jetpack-search',
106                array( 'label' => __( 'Jetpack Search', 'jetpack-search-pkg' ) )
107            );
108        }
109
110        $blocks_dir = __DIR__ . '/blocks';
111        $block_dirs = glob( $blocks_dir . '/*', GLOB_ONLYDIR );
112
113        if ( ! $block_dirs ) {
114            return;
115        }
116
117        foreach ( $block_dirs as $block_dir ) {
118            if ( file_exists( $block_dir . '/block.json' ) ) {
119                register_block_type( $block_dir );
120            }
121        }
122
123        static::register_variations();
124        static::register_patterns();
125    }
126
127    /**
128     * Register named block variations for the filter-checkbox block.
129     *
130     * PHP-side registration keeps the editor-only JS bundle out of the ESM
131     * pipeline. Variation names and default `taxonomy` / `filterType`
132     * attributes intentionally mirror the filter types exposed by the
133     * instant-search overlay so the two surfaces describe the same filters.
134     */
135    protected static function register_variations() {
136        if ( ! function_exists( 'register_block_variation' ) ) {
137            return;
138        }
139
140        $variations = array(
141            array(
142                'name'        => 'category',
143                'title'       => __( 'Filter by Category', 'jetpack-search-pkg' ),
144                'description' => __( 'Show category checkboxes with live result counts.', 'jetpack-search-pkg' ),
145                'attributes'  => array(
146                    'filterType' => 'taxonomy',
147                    'taxonomy'   => 'category',
148                    'label'      => __( 'Category', 'jetpack-search-pkg' ),
149                ),
150                'isActive'    => array( 'filterType', 'taxonomy' ),
151            ),
152            array(
153                'name'        => 'post_tag',
154                'title'       => __( 'Filter by Tag', 'jetpack-search-pkg' ),
155                'description' => __( 'Show tag checkboxes with live result counts.', 'jetpack-search-pkg' ),
156                'attributes'  => array(
157                    'filterType' => 'taxonomy',
158                    'taxonomy'   => 'post_tag',
159                    'label'      => __( 'Tag', 'jetpack-search-pkg' ),
160                ),
161                'isActive'    => array( 'filterType', 'taxonomy' ),
162            ),
163            array(
164                'name'        => 'post_type',
165                'title'       => __( 'Filter by Post Type', 'jetpack-search-pkg' ),
166                'description' => __( 'Show post type checkboxes with live result counts.', 'jetpack-search-pkg' ),
167                'attributes'  => array(
168                    'filterType' => 'post_type',
169                    'label'      => __( 'Post Type', 'jetpack-search-pkg' ),
170                ),
171                'isActive'    => array( 'filterType' ),
172            ),
173            array(
174                'name'        => 'author',
175                'title'       => __( 'Filter by Author', 'jetpack-search-pkg' ),
176                'description' => __( 'Show author checkboxes with live result counts.', 'jetpack-search-pkg' ),
177                'attributes'  => array(
178                    'filterType' => 'author',
179                    'label'      => __( 'Author', 'jetpack-search-pkg' ),
180                ),
181                'isActive'    => array( 'filterType' ),
182            ),
183            array(
184                'name'        => 'custom_taxonomy',
185                'title'       => __( 'Filter by Custom Taxonomy', 'jetpack-search-pkg' ),
186                'description' => __( 'Show checkboxes for any registered taxonomy.', 'jetpack-search-pkg' ),
187                'attributes'  => array(
188                    'filterType' => 'taxonomy',
189                    'taxonomy'   => '',
190                    'label'      => '',
191                ),
192                'isActive'    => array( 'filterType', 'taxonomy' ),
193            ),
194        );
195
196        foreach ( $variations as $variation ) {
197            // @phan-suppress-next-line PhanUndeclaredFunction -- Guarded by function_exists() above; stub missing from wordpress-stubs.
198            register_block_variation( 'jetpack/filter-checkbox', $variation );
199        }
200    }
201
202    /**
203     * Register block patterns.
204     */
205    protected static function register_patterns() {
206        $patterns_dir = __DIR__ . '/patterns';
207        if ( ! is_dir( $patterns_dir ) ) {
208            return;
209        }
210        $pattern_files = glob( $patterns_dir . '/*.php' );
211        if ( ! $pattern_files ) {
212            return;
213        }
214        foreach ( $pattern_files as $pattern_file ) {
215            require_once $pattern_file;
216        }
217    }
218
219    /**
220     * Build the full search page template content.
221     *
222     * Mirrors the "Blog Search Page" pattern's layout (see
223     * `src/search-blocks/patterns/blog-search.php`) wrapped in header/main/
224     * footer template parts so the plugin-registered template renders the
225     * same page users get from inserting the pattern directly. Markup lives
226     * in `templates/jetpack-search.html` — the canonical block-theme format
227     * for block templates — with a `{{FILTER_HEADING}}` placeholder for the
228     * filter-sidebar heading so that string still goes through `esc_html__()`.
229     *
230     * Memoized: `register_search_template()` runs on every `init`, and the
231     * template markup is identical every request, so read the file and run
232     * the translation substitution once per process.
233     *
234     * @return string Block markup for a complete page template.
235     */
236    protected static function get_search_template_content(): string {
237        static $content = null;
238        if ( null !== $content ) {
239            return $content;
240        }
241        $template_path = __DIR__ . '/templates/jetpack-search.html';
242        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- local, bundled template file; wp_remote_get() is for remote URLs.
243        $raw     = is_readable( $template_path ) ? (string) file_get_contents( $template_path ) : '';
244        $content = str_replace(
245            '{{FILTER_HEADING}}',
246            esc_html__( 'Filter options', 'jetpack-search-pkg' ),
247            $raw
248        );
249        return $content;
250    }
251
252    /**
253     * Register the Jetpack Search page template with the block-template
254     * registry so it surfaces in the Site Editor's Templates list and can be
255     * resolved via the template hierarchy.
256     *
257     * Uses `register_block_template()` (WP 6.7+). Jetpack requires WP 6.8+,
258     * so the function is always present at runtime — the function_exists
259     * guard is defensive for phpstan/phan and edge environments.
260     *
261     * DB-stored customizations continue to take precedence: if a site owner
262     * edits this template in the Site Editor, the `custom` source wins during
263     * resolution automatically.
264     */
265    public static function register_search_template() {
266        if ( ! function_exists( 'register_block_template' ) ) {
267            return;
268        }
269        $content = static::get_search_template_content();
270        // Skip registration if the bundled template file is missing or
271        // unreadable. Since this template's slug is prepended to the
272        // search hierarchy, registering with empty content would take
273        // over `/?s=...` and render a blank page; bailing here lets core
274        // fall through to the theme's `search.html` instead.
275        if ( '' === $content ) {
276            return;
277        }
278        register_block_template(
279            static::get_parent_plugin_slug() . '//' . self::SEARCH_TEMPLATE_SLUG,
280            array(
281                'title'       => __( 'Jetpack Search Results', 'jetpack-search-pkg' ),
282                'description' => __( 'Displays search results with Jetpack Search filters.', 'jetpack-search-pkg' ),
283                'content'     => $content,
284            )
285        );
286    }
287
288    /**
289     * Directory slug of the plugin that should own the template in the
290     * Site Editor UI.
291     *
292     * The Templates list labels plugin-registered templates by looking up an
293     * active plugin whose directory slug matches the namespace portion of
294     * the registered template name. We pick the slug by preference rather
295     * than by install path so that on sites running both the Jetpack
296     * monolith and the standalone Jetpack Search plugin, the more-specific
297     * "Jetpack Search" label always wins:
298     *
299     * - Jetpack Search plugin active → `jetpack-search` → "Jetpack Search"
300     * - Otherwise Jetpack plugin active → `jetpack` → "Jetpack"
301     * - Neither active (unexpected) → `jetpack-search` fallback
302     *
303     * @return string
304     */
305    protected static function get_parent_plugin_slug(): string {
306        // Helper::get_active_plugins() already centralizes single-site +
307        // multisite active-plugin discovery (reads `active_plugins`, unions
308        // network-activated plugins from `active_sitewide_plugins`, dedupes).
309        // Reuse it so multisite/activation behavior stays consistent across
310        // the package if it ever evolves.
311        $active    = Helper::get_active_plugins();
312        $preferred = array(
313            'jetpack-search' => 'jetpack-search/jetpack-search.php',
314            'jetpack'        => 'jetpack/jetpack.php',
315        );
316        foreach ( $preferred as $slug => $plugin_file ) {
317            if ( in_array( $plugin_file, $active, true ) ) {
318                return $slug;
319            }
320        }
321        return 'jetpack-search';
322    }
323
324    /**
325     * Prepend the Jetpack Search template slug to the search template hierarchy
326     * so `/?s=…` requests resolve to our plugin-registered template instead of
327     * the theme's `search.html`.
328     *
329     * Core resolves each slug in order, stopping at the first template it
330     * finds. Because our slug is unique (`jetpack-search`, not `search`), the
331     * theme's `search.html` is never consulted when this prepend is in effect.
332     * Site Editor customizations (stored in the DB keyed by this slug) still
333     * take precedence over the plugin-registered default.
334     *
335     * Existing occurrences of the slug are stripped first so the hierarchy
336     * can't accumulate duplicates from a second init pass or another filter
337     * on the same hook.
338     *
339     * @param string[] $templates Template hierarchy slugs.
340     * @return string[]
341     */
342    public static function prepend_search_template( $templates ) {
343        $templates = array_values(
344            array_filter(
345                (array) $templates,
346                static function ( $slug ) {
347                    return self::SEARCH_TEMPLATE_SLUG !== $slug;
348                }
349            )
350        );
351        array_unshift( $templates, self::SEARCH_TEMPLATE_SLUG );
352        return $templates;
353    }
354
355    /**
356     * Seed the Interactivity API store with initial state.
357     *
358     * Individual block render.php files may also call wp_interactivity_state()
359     * — core deep-merges each call, so each block can contribute its own
360     * entries (e.g. filter-checkbox writes its filterConfig).
361     *
362     * Pre-populates `filterConfigs` by scanning the current post content for
363     * jetpack/filter-checkbox blocks so the seeded state always carries the
364     * known filter schema regardless of block order in the tree. That in turn
365     * lets `gate_active_filters()` drop URL-derived `activeFilters` keys that
366     * aren't registered anywhere on the post, preventing unrelated array
367     * params from round-tripping into subsequent search URLs.
368     */
369    public static function seed_interactivity_state() {
370        if ( ! function_exists( 'wp_interactivity_state' ) ) {
371            return;
372        }
373        wp_interactivity_state(
374            'jetpack-search',
375            static::build_seed_state( static::collect_filter_configs_from_post() )
376        );
377    }
378
379    /**
380     * Compose the final seeded state for `wp_interactivity_state()`. Takes
381     * $filter_configs as an argument so tests can exercise the full gating +
382     * isLoading recomputation path without a WP post lookup.
383     *
384     * @param array<string, array<string, mixed>> $filter_configs Map of filter
385     *   configs collected from the current post (or injected by tests).
386     * @return array<string, mixed>
387     */
388    public static function build_seed_state( array $filter_configs ): array {
389        $state                  = static::build_initial_state();
390        $state['filterConfigs'] = $filter_configs;
391        $state['activeFilters'] = static::gate_active_filters(
392            $state['activeFilters'] ?? array(),
393            $filter_configs
394        );
395        // Recompute isLoading from the *post-gating* state. build_initial_state()
396        // derives it from the raw URL params, so a URL that carried only
397        // unregistered `?foo[]=bar` params (e.g. from another plugin) would
398        // leave isLoading=true after gating emptied activeFilters — and since
399        // the JS `initialize()` only fires a search when `searchQuery`,
400        // `hasActiveFilters`, or `priceRange` is truthy, the spinner would
401        // never clear.
402        $state['isLoading'] = '' !== $state['searchQuery']
403            || ! empty( $state['activeFilters'] )
404            || null !== $state['priceRange'];
405        return $state;
406    }
407
408    /**
409     * Drop active-filter keys that aren't registered by any filter-checkbox
410     * block on the current post. parse_url_filters() accepts any array-shaped
411     * top-level URL param, so without this gate a stray `?foo[]=bar` seeded
412     * by another plugin would get merged into `activeFilters` and then
413     * re-serialized back into subsequent search URLs. Mirrors the same gating
414     * that store/url-state.js applies on the client side.
415     *
416     * Skipped when `$filter_configs` is empty — no filter blocks means we
417     * don't know what's valid, and we don't want to silently drop filters
418     * a filter block placed inside a template part would accept after hydration.
419     *
420     * @param array<string, string[]>             $active_filters Parsed active filters.
421     * @param array<string, array<string, mixed>> $filter_configs Known filter configs keyed by filterKey.
422     * @return array<string, string[]>
423     */
424    public static function gate_active_filters( array $active_filters, array $filter_configs ): array {
425        if ( empty( $filter_configs ) ) {
426            return $active_filters;
427        }
428        $allowed = array_fill_keys( array_keys( $filter_configs ), true );
429        return array_intersect_key( $active_filters, $allowed );
430    }
431
432    /**
433     * Walk the current post's block tree for jetpack/filter-checkbox blocks
434     * and build the matching filterConfigs map.
435     *
436     * Covers the common case where a page uses the Blog Search Page pattern
437     * (or blocks inserted directly into $post->post_content). Template-part
438     * / block-theme scans are not performed here — a filter block placed
439     * inside a template part will still work, but its config won't be
440     * available to the search-results SSR until hydration.
441     *
442     * @return array<string, array<string, mixed>>
443     */
444    protected static function collect_filter_configs_from_post(): array {
445        if ( ! function_exists( 'get_post' ) || ! function_exists( 'parse_blocks' ) || ! class_exists( Filter_Checkbox::class ) ) {
446            return array();
447        }
448        $post = get_post();
449        if ( ! $post || empty( $post->post_content ) ) {
450            return array();
451        }
452        $configs = array();
453        static::walk_blocks_for_filter_configs( parse_blocks( $post->post_content ), $configs );
454        return $configs;
455    }
456
457    /**
458     * Recursively walk a parsed block tree and push filter-checkbox configs
459     * into `$configs`. Passing `$configs` by reference keeps the recursion
460     * flat — callers don't need to merge children's maps back into parents'.
461     *
462     * @param array $blocks  Parsed block tree from parse_blocks().
463     * @param array $configs Accumulator map keyed by filterKey.
464     * @return void
465     */
466    protected static function walk_blocks_for_filter_configs( array $blocks, array &$configs ): void {
467        foreach ( $blocks as $block ) {
468            if ( ! is_array( $block ) ) {
469                continue;
470            }
471            if ( 'jetpack/filter-checkbox' === ( $block['blockName'] ?? '' ) ) {
472                $attrs = (array) ( $block['attrs'] ?? array() );
473                $key   = Filter_Checkbox::derive_filter_key( $attrs );
474                if ( '' !== $key ) {
475                    $configs[ $key ] = Filter_Checkbox::build_config( $attrs, $key );
476                }
477            }
478            if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) {
479                static::walk_blocks_for_filter_configs( $block['innerBlocks'], $configs );
480            }
481        }
482    }
483
484    /**
485     * Build the initial state array for the jetpack-search Interactivity API store.
486     *
487     * @return array<string, mixed>
488     */
489    public static function build_initial_state() {
490        $is_private     = class_exists( Status::class ) ? ( new Status() )->is_private_site() : false;
491        $is_wpcom       = class_exists( Helper::class ) ? Helper::is_wpcom() : false;
492        $site_id        = class_exists( Helper::class ) ? Helper::get_wpcom_site_id() : 0;
493        $search_query   = function_exists( 'get_search_query' ) ? (string) get_search_query() : '';
494        $active_filters = static::parse_url_filters();
495        $price_range    = static::parse_url_price_range();
496
497        return array(
498            // Connection / routing config.
499            'siteId'        => $site_id,
500            'apiRoot'       => function_exists( 'rest_url' ) ? esc_url_raw( rest_url() ) : '',
501            'nonce'         => function_exists( 'wp_create_nonce' ) ? wp_create_nonce( 'wp_rest' ) : '',
502            'isPrivateSite' => $is_private,
503            'isWpcom'       => $is_wpcom,
504            'homeUrl'       => function_exists( 'home_url' ) ? home_url() : '',
505            // BCP47-ish locale (e.g. `en-US`) for Intl.DateTimeFormat on the
506            // client. Converts WP's `en_US` underscore form. Uses the blog
507            // locale (site setting) rather than the viewer's user-profile
508            // locale so formatting is consistent for logged-out visitors
509            // hitting a search page.
510            'locale'        => function_exists( 'get_locale' )
511                ? str_replace( '_', '-', get_locale() )
512                : 'en-US',
513
514            // Search state, seeded from the URL so a deep link like
515            // /?s=boots&orderby=newest&category[]=news renders correctly on
516            // first paint.
517            'searchQuery'   => $search_query,
518            'sortOrder'     => static::parse_url_sort(),
519            'activeFilters' => $active_filters,
520            'priceRange'    => $price_range,
521
522            // filterConfigs: each filter-checkbox block's render.php merges its
523            // own entry here. Shape: { [filterKey]: { filterKey, filterType,
524            // taxonomy, label, showCount, maxItems } }.
525            'filterConfigs' => array(),
526
527            // Results + aggregations are populated by the JS store on hydration —
528            // seed empty defaults so template bindings always have a shape to read.
529            // `aggregations` is a stdClass so JS sees `{}`, not `[]`.
530            'results'       => array(),
531            'aggregations'  => (object) array(),
532            'totalResults'  => 0,
533            'pageHandle'    => null,
534
535            // UI state. `isLoading` is seeded true when the URL carries a
536            // search query or filter selection so the no-results block stays
537            // hidden between first paint and JS hydrating the initial fetch —
538            // otherwise a "No results found" flash appears on deep links.
539            'isLoading'     => '' !== $search_query || ! empty( $active_filters ) || null !== $price_range,
540            'isLoadingMore' => false,
541            'hasError'      => false,
542
543            // Translated view-bundle strings. The Interactivity API view bundle
544            // can't import @wordpress/i18n (only @wordpress/interactivity is
545            // registered as a script module), so any JS-produced text is seeded
546            // here and read via state.strings.* on the client. Both _n() forms
547            // are seeded so the client can pick based on the live totalResults
548            // without a round trip; languages with more than two plural forms
549            // degrade to "plural for all count > 1" as an accepted tradeoff.
550            'strings'       => static::build_initial_strings(),
551        );
552    }
553
554    /**
555     * Seed translated view-bundle strings for the Interactivity API store.
556     *
557     * @return array<string, string>
558     */
559    protected static function build_initial_strings(): array {
560        if ( ! function_exists( '__' ) || ! function_exists( '_n' ) ) {
561            return array(
562                'searching'          => 'Searching…',
563                'resultsCountSingle' => 'Found %d result',
564                'resultsCountPlural' => 'Found %d results',
565                'removeFilter'       => 'Remove %s',
566            );
567        }
568        return array(
569            'searching'          => __( 'Searching…', 'jetpack-search-pkg' ),
570            /* translators: %d: number of results. */
571            'resultsCountSingle' => _n( 'Found %d result', 'Found %d results', 1, 'jetpack-search-pkg' ),
572            /* translators: %d: number of results. */
573            'resultsCountPlural' => _n( 'Found %d result', 'Found %d results', 2, 'jetpack-search-pkg' ),
574            /* translators: %s: filter label (e.g. "Category: News"). Announced by screen readers when focus lands on a filter pill's remove button. */
575            'removeFilter'       => __( 'Remove %s', 'jetpack-search-pkg' ),
576        );
577    }
578
579    /**
580     * Parse the sort order from the URL, defaulting to 'relevance'. Valid
581     * values mirror the UI keys in src/instant-search/lib/constants.js
582     * SORT_OPTIONS so deep links work across both surfaces.
583     *
584     * @return string
585     */
586    protected static function parse_url_sort(): string {
587        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only URL state.
588        $orderby = isset( $_GET['orderby'] ) ? sanitize_key( wp_unslash( $_GET['orderby'] ) ) : '';
589        return in_array( $orderby, array( 'newest', 'oldest' ), true ) ? $orderby : 'relevance';
590    }
591
592    /**
593     * Parse the price range from the URL, mirroring the contract in
594     * src/search-blocks/store/url-state.js. Either bound may be null for a
595     * half-open range; non-numeric or negative values yield null so a
596     * garbage URL can't drive the API into producing zero results.
597     *
598     * Returns null when neither bound is set, so callers can early-out
599     * without checking individual fields.
600     *
601     * @return array{min: float|null, max: float|null}|null
602     */
603    protected static function parse_url_price_range(): ?array {
604        // 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.
605        $min = self::parse_price_bound( $_GET['min_price'] ?? null );
606        $max = self::parse_price_bound( $_GET['max_price'] ?? null );
607        // phpcs:enable
608
609        if ( null === $min && null === $max ) {
610            return null;
611        }
612        // Both bounds present but inverted (min > max) yields an empty ES
613        // `range` clause that returns zero results silently. Treat the URL
614        // as garbage and bail so the page renders a normal (unfiltered)
615        // search rather than a guaranteed-empty one. Mirrors the same
616        // rejection in store/url-state.js.
617        if ( null !== $min && null !== $max && $min > $max ) {
618            return null;
619        }
620        return array(
621            'min' => $min,
622            'max' => $max,
623        );
624    }
625
626    /**
627     * Coerce a single price-range URL value into a finite, non-negative float.
628     *
629     * @param mixed $raw Raw value pulled from $_GET.
630     * @return float|null
631     */
632    private static function parse_price_bound( $raw ): ?float {
633        if ( null === $raw || '' === $raw || ! is_scalar( $raw ) ) {
634            return null;
635        }
636        // `is_numeric` rejects partially-numeric strings like "1.5.3" that
637        // the (float) cast would silently extract as 1.5 — JS's Number()
638        // returns NaN for the same input, so without this gate the PHP
639        // initial render and JS hydration disagree on parsed value.
640        $raw = wp_unslash( $raw );
641        if ( ! is_numeric( $raw ) ) {
642            return null;
643        }
644        $num = (float) $raw;
645        if ( ! is_finite( $num ) || $num < 0 ) {
646            return null;
647        }
648        return $num;
649    }
650
651    /**
652     * Parse flat filter selections from the current request URL.
653     *
654     * Accepts any top-level array-shaped `?<filterKey>[]=<value>` param
655     * (the same shape store/url-state.js writes) and returns an
656     * { [filterKey]: string[] } map. The JS layer drops filters whose keys
657     * are not registered in `filterConfigs`; doing the same here would
658     * require access to block attributes at state-seed time (before blocks
659     * render), which we don't have. Values are sanitized so any garbage
660     * round-tripped through the URL never reaches ES.
661     *
662     * @return array<string, string[]>
663     */
664    protected static function parse_url_filters(): array {
665        // phpcs:ignore WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- read-only URL state; sanitized per-value below.
666        $raw = wp_unslash( $_GET );
667        if ( ! is_array( $raw ) ) {
668            return array();
669        }
670
671        $out = array();
672        foreach ( $raw as $key => $values ) {
673            $filter_key = sanitize_key( (string) $key );
674            if ( '' === $filter_key || in_array( $filter_key, self::RESERVED_QUERY_PARAMS, true ) ) {
675                continue;
676            }
677            if ( ! is_array( $values ) ) {
678                continue;
679            }
680            $clean = array_values(
681                array_filter(
682                    array_map( 'sanitize_text_field', $values ),
683                    static function ( $v ) {
684                        return '' !== $v;
685                    }
686                )
687            );
688            if ( $clean ) {
689                $out[ $filter_key ] = $clean;
690            }
691        }
692        return $out;
693    }
694}