Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
77.22% covered (warning)
77.22%
651 / 843
59.04% covered (warning)
59.04%
49 / 83
CRAP
0.00% covered (danger)
0.00%
0 / 1
Search_Blocks
77.22% covered (warning)
77.22%
651 / 843
59.04% covered (warning)
59.04%
49 / 83
1323.17
0.00% covered (danger)
0.00%
0 / 1
 init
97.22% covered (success)
97.22%
35 / 36
0.00% covered (danger)
0.00%
0 / 1
12
 owns_search_results
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 is_tracking_disabled
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
3
 filter__posts_pre_query
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 is_block_template_overlay_enabled
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 is_block_template_overlay_filter_on
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 is_free_plan
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 reset_is_free_plan_cache
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 supports_paid_search
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 set_supports_paid_search_for_testing
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 reset_supports_paid_search_cache
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 woocommerce_blocks_enabled
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 woocommerce_version_supported
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
3
 set_woocommerce_blocks_enabled_for_testing
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 reset_woocommerce_blocks_enabled_cache
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 woocommerce_search_template_override_enabled
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 is_woocommerce_product_search
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 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%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 enqueue_editor_assets
0.00% covered (danger)
0.00%
0 / 29
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 / 10
0.00% covered (danger)
0.00%
0 / 1
12
 register_store_script_module
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
3.00
 same_origin_script_module_src
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
8
 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
 pattern_content_from_template
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 get_search_template_content
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 register_search_template
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
4
 should_use_product_overlay
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 get_overlay_template_content
78.57% covered (warning)
78.57%
11 / 14
0.00% covered (danger)
0.00%
0 / 1
7.48
 reset_overlay_template_content_cache
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
3.33
 print_block_template_overlay
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
12
 enqueue_block_template_overlay_assets
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
12
 print_theme_token_sampler
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 block_template_overlay_inline_css
n/a
0 / 0
n/a
0 / 0
1
 enqueue_search_page_assets
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 enqueue_search_layout_style
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 search_layout_inline_css
n/a
0 / 0
n/a
0 / 0
1
 get_product_search_template_content
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 substitute_template_placeholders
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
2.00
 resolve_chrome_slugs
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 sync_filters_popover_content
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
5
 find_block_by_name
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
5
 replace_block_inner_content
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
5.03
 replace_block_template
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 register_product_search_template
84.62% covered (warning)
84.62%
11 / 13
0.00% covered (danger)
0.00%
0 / 1
4.06
 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%
14 / 14
100.00% covered (success)
100.00%
1 / 1
5
 route_classic_theme_search_template
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
10.04
 get_classic_theme_search_body
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 get_classic_theme_product_search_body
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 strip_top_level_template_parts
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 get_classic_theme_layout_style
n/a
0 / 0
n/a
0 / 0
1
 set_block_templates_active_for_testing
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 block_templates_active
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 route_woocommerce_product_search_template
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
4
 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
78.57% covered (warning)
78.57%
11 / 14
0.00% covered (danger)
0.00%
0 / 1
8.63
 post_content_has_filter_block
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 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
84.62% covered (warning)
84.62%
11 / 13
0.00% covered (danger)
0.00%
0 / 1
7.18
 build_initial_state
100.00% covered (success)
100.00%
56 / 56
100.00% covered (success)
100.00%
1 / 1
14
 get_instant_search_query_options
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 build_stock_status_labels
77.78% covered (warning)
77.78%
7 / 9
0.00% covered (danger)
0.00%
0 / 1
4.18
 block_directories
93.33% covered (success)
93.33%
14 / 15
0.00% covered (danger)
0.00%
0 / 1
8.02
 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.35% covered (warning)
51.35%
19 / 37
0.00% covered (danger)
0.00%
0 / 1
4.04
 build_ai_extended_loading_hints
51.43% covered (warning)
51.43%
18 / 35
0.00% covered (danger)
0.00%
0 / 1
2.46
 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
96.77% covered (success)
96.77%
30 / 31
0.00% covered (danger)
0.00%
0 / 1
10
 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 (mirrors `RESERVED_PARAMS` in `store/url-state.js`).
19     * `s` is the WP search route key; `q` is what the inline blocks use on
20     * non-search pages (see `get_search_param_name()`). Neither may be parsed as a filter key.
21     */
22    const RESERVED_QUERY_PARAMS = array( 's', 'q', 'orderby', 'min_price', 'max_price' );
23
24    /**
25     * Query-string param for inline search on non-search pages (e.g. `/about/?q=boots`).
26     * Not `s`, because on singular pages WP's `WP_Query::get_posts()` AND's a
27     * `post_content LIKE` clause into the lookup and 404s on refresh. See
28     * `docs/explorations/embedded-search-refresh-404.md` (RSM-1754).
29     */
30    const NON_SEARCH_QUERY_PARAM = 'q';
31
32    /**
33     * Jetpack Search page template slug. Distinct from WP's `search` slug so a
34     * block theme's `search.html` doesn't dedupe ours; `search_template_hierarchy`
35     * prepends this slug so it still wins on `/?s=...`.
36     */
37    const SEARCH_TEMPLATE_SLUG = 'jetpack-search';
38
39    /**
40     * Jetpack product-search template slug. Separate from `SEARCH_TEMPLATE_SLUG`
41     * so it gets its own Site Editor entry.
42     */
43    const PRODUCT_SEARCH_TEMPLATE_SLUG = 'jetpack-search-product-results';
44
45    /**
46     * Mirror of `ProductSearchResultsTemplate::SLUG`, inlined to avoid a hard
47     * dependency on the WooCommerce class.
48     */
49    const WC_PRODUCT_SEARCH_TEMPLATE_SLUG = 'product-search-results';
50
51    /**
52     * Lowest WC version that registers the `product-search-results` template
53     * (WC 6.5 bundled WC Blocks 7.4, the release that first added it). Below
54     * this, WC-only Search features have nothing to front and the gate stays closed.
55     */
56    const MIN_WOOCOMMERCE_VERSION = '6.5.0';
57
58    /**
59     * Per-request memo for `is_initial_loading()`. Lifted out of a method-local
60     * `static` so tests can clear it via `reset_initial_loading_cache()`;
61     * otherwise URL state from the first test leaks into subsequent ones.
62     *
63     * @var bool|null
64     */
65    private static $is_initial_loading_cache = null;
66
67    /**
68     * Per-request memo for `get_overlay_template_content()`, keyed `default` /
69     * `product`. Lifted out of a method-local `static` so tests can clear it via
70     * `reset_overlay_template_content_cache()` — otherwise a CPT-customized
71     * overlay saved mid-test would be pinned by an earlier bundled-file read.
72     *
73     * @var array<string,string>
74     */
75    private static $overlay_template_content_cache = array();
76
77    /**
78     * Per-request memo for `is_free_plan()`. Avoids the cold-cache hazard where
79     * `Plan::get_plan_info()` falls back to a synchronous WPCOM HTTP call —
80     * render callbacks hit the plan gate on every inner render.
81     *
82     * @var bool|null
83     */
84    private static $is_free_plan_cache = null;
85
86    /**
87     * Per-request memo for `supports_paid_search()`. Separate from
88     * `is_free_plan_cache` because the two answers can disagree: a site with
89     * no plan info is neither on the free plan nor on a paid one.
90     *
91     * @var bool|null
92     */
93    private static $supports_paid_search_cache = null;
94
95    /**
96     * Per-request memo for `woocommerce_blocks_enabled()`. Centralized so every
97     * gate (registration, render, editor config, IA store seed) shares one probe.
98     *
99     * @var bool|null
100     */
101    private static $woocommerce_blocks_enabled_cache = null;
102
103    /**
104     * Per-request memo for `supported_custom_taxonomies()`. Derived from the
105     * Sync allowlist intersected with registered taxonomies and unioned with
106     * the map's user-facing keys — same inputs every request.
107     *
108     * @var string[]|null
109     */
110    private static $supported_custom_taxonomies_cache = null;
111
112    /**
113     * Cached rendered overlay-template HTML. Filled during `wp_enqueue_scripts`
114     * so the embedded blocks' view-module enqueues land before
115     * `wp_print_import_map()` (footer priority 1) — see AGENTS.md
116     * § Hydration & SSR seeding.
117     *
118     * @var string|null
119     */
120    private static $block_template_overlay_rendered_html = null;
121
122    /**
123     * Register block types and hook into WordPress.
124     *
125     * Two gates apply: the caller (`Initializer`) gates everything behind the
126     * `jetpack_search_blocks_enabled` feature flag, and within this method the
127     * template-takeover surface (registering the Search template and prepending
128     * it to `search_template_hierarchy`) is additionally gated on the saved
129     * experience being `'embedded'` — only Embedded should override the theme's
130     * `search.html`. Block registration, editor assets, and IA state seeding
131     * always run so blocks inserted anywhere (post content, widgets, custom
132     * templates) get their base seed.
133     */
134    public static function init() {
135        add_action( 'init', array( static::class, 'register_blocks' ) );
136        add_filter( 'block_categories_all', array( static::class, 'register_block_category' ) );
137        add_action( 'enqueue_block_editor_assets', array( static::class, 'enqueue_editor_assets' ) );
138        add_action( 'wp_body_open', array( static::class, 'print_theme_token_sampler' ) );
139        // Relativize `jetpack-search/*` Script Module URLs whose host matches
140        // the site canonical so the rendered `<script type="module">` is
141        // same-origin with the page. ES modules go through CORS even without
142        // a `crossorigin` attribute, and `wp-content/*` typically lacks the
143        // `Access-Control-Allow-Origin` header — see
144        // `same_origin_script_module_src()`.
145        add_filter( 'script_module_loader_src', array( static::class, 'same_origin_script_module_src' ), 10, 2 );
146        Custom_Taxonomy_Slot_Mapping::init();
147        // Both hooks needed; see AGENTS.md § Hydration & SSR seeding.
148        add_action( 'template_redirect', array( static::class, 'seed_interactivity_state' ) );
149        add_action( 'wp_enqueue_scripts', array( static::class, 'seed_interactivity_state' ) );
150
151        $experience = ( new Module_Control() )->get_experience();
152
153        if ( Module_Control::EXPERIENCE_EMBEDDED === $experience ) {
154            if ( static::block_templates_active() ) {
155                // Block themes: register the template and front it via the FSE hierarchy filter.
156                add_action( 'init', array( static::class, 'register_search_template' ) );
157                add_filter( 'search_template_hierarchy', array( static::class, 'prepend_search_template' ) );
158                add_action( 'wp_enqueue_scripts', array( static::class, 'enqueue_search_page_assets' ) );
159                Theme_Chrome_Slug_Resolver::register_hooks();
160            } else {
161                // Classic themes: no FSE hierarchy to prepend to, so swap the
162                // resolved template path via `template_include`. The block markup
163                // renders inside the theme's `get_header()`/`get_footer()`.
164                //
165                // Priority 20: WooCommerce's `WC_Template_Loader::template_loader`
166                // hooks at priority 10 and rewrites the path to `archive-product.php`
167                // on product-archive requests — that includes product search. We
168                // need to run *after* WC so the override actually sticks; running
169                // at 10 (same priority, later registration) is order-of-load
170                // dependent. Higher priorities (anything > 20 used by chrome
171                // filters) aren't relevant — nothing else swaps the path.
172                add_filter( 'template_include', array( static::class, 'route_classic_theme_search_template' ), 20 );
173                // No Site Editor entry on classic themes; the singleton CPTs give
174                // authors the standard block editor on hidden posts instead. Both
175                // init regardless of the WooCommerce override option so admins can
176                // pre-customize either template before activating the relevant
177                // surface — matching `Search_Template`'s "expose URLs before
178                // activation" rule. The override option still gates the actual
179                // front-end render path in `route_classic_theme_search_template()`.
180                Search_Template::init();
181                Product_Search_Template::init();
182            }
183        }
184
185        // Inline on a classic theme: the theme renders regular searches, but a
186        // WooCommerce product search can still be routed to the Jetpack product
187        // shim. `route_classic_theme_search_template()` is product-only for
188        // Inline (it bails on regular searches unless Embedded); the block-theme
189        // Inline path is covered by the `search_template_hierarchy` route below.
190        // Init the product CPT regardless of the override so admins can
191        // pre-customize it (expose-before-activation, as in the Embedded branch).
192        if (
193            Module_Control::EXPERIENCE_INLINE === $experience
194            && ! static::block_templates_active()
195        ) {
196            add_filter( 'template_include', array( static::class, 'route_classic_theme_search_template' ), 20 );
197            Product_Search_Template::init();
198        }
199
200        // Blocks render results client-side, so the server-side search is wasted work.
201        // (Classic/Instant init is also suppressed in `Initializer::init_search_blocks()`.)
202        if ( ! is_admin() && static::owns_search_results() ) {
203            add_filter( 'posts_pre_query', array( static::class, 'filter__posts_pre_query' ), 10, 2 );
204        }
205
206        // Priority 20: after WC's priority-10 prepend so the result is load-order
207        // independent. Gated to server-rendered experiences (Embedded / Inline) —
208        // Overlay intercepts client-side, and a stale option from a since-switched
209        // experience must not keep rerouting the template hierarchy.
210        if (
211            static::woocommerce_search_template_override_enabled()
212            && in_array( $experience, array( Module_Control::EXPERIENCE_EMBEDDED, Module_Control::EXPERIENCE_INLINE ), true )
213        ) {
214            add_action( 'init', array( static::class, 'register_product_search_template' ) );
215            add_filter( 'search_template_hierarchy', array( static::class, 'route_woocommerce_product_search_template' ), 20 );
216            // Inline experience doesn't go through the EMBEDDED branch above, so
217            // hook the page-template CSS enqueue here too. Idempotent on EMBEDDED
218            // — `add_action` dedupes same callback at same priority.
219            add_action( 'wp_enqueue_scripts', array( static::class, 'enqueue_search_page_assets' ) );
220        }
221
222        // Two-tier gate: register the editable template CPT + admin-init editor
223        // handler whenever the operator filter is on, so admins can edit the
224        // overlay template *before* opting into the blocks Overlay experience
225        // (e.g. preview the editor from the Beta card while the preact Overlay
226        // is still the active arm — without this split, the editorUrl seeded
227        // into the React initial state at page load would be null and the
228        // link would become a no-op once the user switched to the Beta card
229        // without a page refresh). The front-end render hooks stay gated on
230        // the active experience — they only paint the overlay when the user
231        // has actually committed to it.
232        if ( static::is_block_template_overlay_filter_on() ) {
233            Overlay_Template::init();
234            // The product overlay only renders on a Woo store, so its editable
235            // CPT is pointless off Woo. Init regardless of the override option
236            // (parity with `Product_Search_Template`) so admins can pre-customize
237            // before flipping it on; the front-end render path stays gated by
238            // the override option in `should_use_product_overlay()`.
239            if ( static::woocommerce_blocks_enabled() ) {
240                Product_Overlay_Template::init();
241            }
242        }
243        if ( static::is_block_template_overlay_enabled() ) {
244            add_action( 'wp_enqueue_scripts', array( static::class, 'enqueue_block_template_overlay_assets' ) );
245            add_action( 'wp_footer', array( static::class, 'print_block_template_overlay' ) );
246        }
247    }
248
249    /**
250     * Whether the Search blocks own the front-end search results for the active
251     * experience, meaning the server should run no search of its own.
252     *
253     * True for Embedded (the blocks template takes over the search page) and for
254     * the enabled blocks Overlay (a full-screen modal over the theme's search
255     * page). The Overlay arm goes through `is_block_template_overlay_enabled()`
256     * — operator filter plus saved experience — so a stale `overlay_blocks`
257     * option can't keep suppressing server search after the overlay is turned
258     * off. Drives both the Classic/Instant init suppression in
259     * `Initializer::init_search_blocks()` and the `posts_pre_query` short-circuit
260     * registered in `init()`.
261     *
262     * @return bool
263     */
264    public static function owns_search_results(): bool {
265        return Module_Control::EXPERIENCE_EMBEDDED === ( new Module_Control() )->get_experience()
266            || static::is_block_template_overlay_enabled();
267    }
268
269    /**
270     * Whether TrainTracks analytics are suppressed for this request. Mirrors
271     * instant search (Helper::get_search_options): the `?disable_tracking=1`
272     * crawler/QA param plus the `jetpack_instant_search_disable_tracking`
273     * operator filter. Gates both the `_tkq` pushes (seeded into
274     * `state.disableTracking`) and whether the Tracks consumer script loads.
275     *
276     * @return bool
277     */
278    public static function is_tracking_disabled(): bool {
279        return ( class_exists( Helper::class ) && Helper::is_tracking_disabled() )
280            || apply_filters( 'jetpack_instant_search_disable_tracking', false );
281    }
282
283    /**
284     * Short-circuit the main front-end search query when the blocks own results
285     * (see AGENTS.md § Search experiences). Registered only when
286     * `owns_search_results()` is true and off `is_admin()`.
287     *
288     * Pagination totals are set to `1` (not `0`) so `have_posts()`-gated
289     * templates still render the shell the client hydrates — WP core skips
290     * `set_found_posts()` when `posts_pre_query` returns an array.
291     *
292     * @param array|null $posts Posts to return in place of the query (null by default).
293     * @param \WP_Query  $query The WP_Query being filtered.
294     * @return array|null Empty array to short-circuit, or $posts to let it run.
295     */
296    public static function filter__posts_pre_query( $posts, $query ) {
297        if ( ! $query->is_main_query() || ! $query->is_search() ) {
298            return $posts;
299        }
300
301        $query->found_posts   = 1;
302        $query->max_num_pages = 1;
303
304        return array();
305    }
306
307    /**
308     * Whether to replace the legacy instant-search overlay with the
309     * server-rendered Search blocks template
310     * (`templates/jetpack-search-overlay.html`).
311     *
312     * Two conditions: the operator filter `is_block_template_overlay_filter_on()`
313     * is on (defaults true), AND the site owner has chosen
314     * `Module_Control::EXPERIENCE_OVERLAY_BLOCKS` in the dashboard. When both
315     * hold, the legacy `SearchApp` is bypassed via
316     * `jetpack_search_init_instant_search` in `Initializer::init_search_blocks()`.
317     *
318     * @return bool
319     */
320    public static function is_block_template_overlay_enabled(): bool {
321        if ( ! static::is_block_template_overlay_filter_on() ) {
322            return false;
323        }
324        return Module_Control::EXPERIENCE_OVERLAY_BLOCKS === ( new Module_Control() )->get_experience();
325    }
326
327    /**
328     * Whether the operator filter that exposes the blocks-powered overlay is on.
329     *
330     * Lighter than `is_block_template_overlay_enabled()` — doesn't require the
331     * user to have opted into the new overlay. Use for one-time setup that
332     * should run *before* opt-in (e.g. registering the editable template CPT
333     * so admins can preview the editor while preact Overlay is still active).
334     *
335     * @return bool
336     */
337    public static function is_block_template_overlay_filter_on(): bool {
338        /**
339         * Opt out of the experimental Search blocks overlay. Available by
340         * default; return false to hide the Beta card from the Experience
341         * Selector and disable the editable-template CPT.
342         *
343         * @param bool $enabled Default true.
344         */
345        return (bool) apply_filters( 'jetpack_search_overlay_block_template_enabled', true );
346    }
347
348    /**
349     * Memoized `Plan::is_free_plan()`. See `$is_free_plan_cache`.
350     *
351     * @return bool
352     */
353    public static function is_free_plan(): bool {
354        if ( null === self::$is_free_plan_cache ) {
355            self::$is_free_plan_cache = ( new Plan() )->is_free_plan();
356        }
357        return self::$is_free_plan_cache;
358    }
359
360    /**
361     * Reset the `is_free_plan()` memo. Tests only.
362     */
363    public static function reset_is_free_plan_cache() {
364        self::$is_free_plan_cache = null;
365    }
366
367    /**
368     * Whether the site has a paid Jetpack Search subscription. Paid-only block
369     * surfaces (AI Answer) call this on every render.
370     *
371     * Both probes are needed: `supports_instant_search()` is true on the free
372     * Search plan too ("plan supports the feature"), so it alone would let free
373     * through. `! is_free_plan()` excludes free + forced-free; `supports_instant_search()`
374     * excludes the no-plan case (which `is_free_plan()` returns false for).
375     *
376     * No `apply_filters()` wrapper by design — a filter that any plugin could
377     * flip would defeat a paid-feature gate. Tests bypass via
378     * `set_supports_paid_search_for_testing()`.
379     *
380     * @return bool
381     */
382    public static function supports_paid_search(): bool {
383        if ( null === self::$supports_paid_search_cache ) {
384            $plan                             = new Plan();
385            self::$supports_paid_search_cache = $plan->supports_instant_search() && ! $plan->is_free_plan();
386        }
387        return self::$supports_paid_search_cache;
388    }
389
390    /**
391     * Force the `supports_paid_search()` answer — tests only. Pass `null` to clear.
392     *
393     * @internal
394     * @param bool|null $value Forced answer or null to clear.
395     */
396    public static function set_supports_paid_search_for_testing( ?bool $value ): void {
397        self::$supports_paid_search_cache = $value;
398    }
399
400    /**
401     * Reset the `supports_paid_search()` memo. Tests only.
402     *
403     * @internal
404     */
405    public static function reset_supports_paid_search_cache(): void {
406        self::$supports_paid_search_cache = null;
407    }
408
409    /**
410     * Whether Jetpack Search exposes its WooCommerce-only blocks, filter
411     * variations, and render paths. See AGENTS.md § WooCommerce gating.
412     *
413     * **Load-order contract:** call at or after `plugins_loaded`. WC includes
414     * its main class during `plugins_loaded`, so an earlier call returns false
415     * on a WC site. Existing callers all fire later (`enqueue_block_editor_assets`,
416     * `template_redirect`, `wp_enqueue_scripts`, block render).
417     *
418     * @return bool
419     */
420    public static function woocommerce_blocks_enabled(): bool {
421        if ( null === self::$woocommerce_blocks_enabled_cache ) {
422            // `false` second arg: skip the autoloader on non-Woo sites — this
423            // gate is hit on every request and autoloader work would be wasted.
424            $probed = class_exists( 'WooCommerce', false ) && self::woocommerce_version_supported();
425
426            /**
427             * Whether Jetpack Search exposes its WooCommerce-only blocks,
428             * filter variations, and render paths. Default is the
429             * `class_exists( 'WooCommerce', false )` probe AND a minimum
430             * WooCommerce version check.
431             *
432             * @since 0.59.0
433             *
434             * @param bool $enabled Defaults to the WooCommerce class + version probe.
435             */
436            self::$woocommerce_blocks_enabled_cache = (bool) apply_filters(
437                'jetpack_search_woocommerce_blocks_enabled',
438                $probed
439            );
440        }
441        return self::$woocommerce_blocks_enabled_cache;
442    }
443
444    /**
445     * Whether the active WooCommerce is at `MIN_WOOCOMMERCE_VERSION` or newer.
446     * Older or absent WooCommerce reads as unsupported.
447     *
448     * @since 7.1.0
449     *
450     * @param string|null $version WooCommerce version to test; defaults to the
451     *   live `WC_VERSION` constant. Override is for tests pinning a version.
452     * @return bool
453     */
454    public static function woocommerce_version_supported( ?string $version = null ): bool {
455        // `constant()` keeps static analysis happy — WC isn't a dependency here.
456        $version = $version ?? ( defined( 'WC_VERSION' ) ? (string) constant( 'WC_VERSION' ) : '' );
457        return '' !== $version && version_compare( $version, self::MIN_WOOCOMMERCE_VERSION, '>=' );
458    }
459
460    /**
461     * Force the `woocommerce_blocks_enabled()` answer to a specific boolean —
462     * tests only. Pass `null` to clear the override and revive the real
463     * `class_exists()` probe (also done by `reset_woocommerce_blocks_enabled_cache()`).
464     *
465     * @internal
466     *
467     * @param bool|null $value Forced answer or null to clear.
468     */
469    public static function set_woocommerce_blocks_enabled_for_testing( ?bool $value ): void {
470        self::$woocommerce_blocks_enabled_cache = $value;
471    }
472
473    /**
474     * Reset the `woocommerce_blocks_enabled()` memo. Tests only.
475     *
476     * @internal
477     */
478    public static function reset_woocommerce_blocks_enabled_cache(): void {
479        self::$woocommerce_blocks_enabled_cache = null;
480    }
481
482    /**
483     * The `jetpack_search_override_woocommerce_search_template` opt-in
484     * (default off), set from the Search dashboard.
485     *
486     * @return bool
487     */
488    public static function woocommerce_search_template_override_enabled(): bool {
489        return (bool) get_option( 'jetpack_search_override_woocommerce_search_template', false );
490    }
491
492    /**
493     * Whether the current request is a WooCommerce product search — a search
494     * query scoped to the `product` post-type archive on a Woo-enabled site.
495     *
496     * Theme-agnostic. Block-theme-only behavior (FSE hierarchy work) gates on
497     * {@see block_templates_active()} at the call site so this predicate also
498     * drives the classic-theme product shim. Public to keep the WC-gating
499     * surface (see AGENTS.md § WooCommerce gating) discoverable from outside
500     * the class; the in-class callers are `route_classic_theme_search_template()`
501     * and `route_woocommerce_product_search_template()`.
502     *
503     * @return bool
504     */
505    public static function is_woocommerce_product_search(): bool {
506        return self::woocommerce_blocks_enabled()
507            && is_search()
508            && is_post_type_archive( 'product' );
509    }
510
511    /**
512     * Canonical list of WooCommerce-only block names. Single source of truth
513     * for the WC gate across registration, helpers, and editor bundle — see
514     * AGENTS.md § WooCommerce gating. Add an entry and every gate picks it up.
515     *
516     * @return string[]
517     */
518    public static function woocommerce_only_block_names(): array {
519        return array(
520            'jetpack-search/filter-wc-attribute',
521            'jetpack-search/filter-wc-price',
522            'jetpack-search/filter-wc-rating',
523            'jetpack-search/filter-wc-stock-status',
524            'jetpack-search/filters-product',
525        );
526    }
527
528    /**
529     * Whether a block name belongs to a WooCommerce-only block. Accepts either
530     * the full namespaced name or a bare directory basename (the registration
531     * loop walks basenames; helpers and editor hold full names).
532     *
533     * @param string $block_name Full block name (`jetpack-search/filter-wc-rating`)
534     *                           or bare directory basename (`filter-wc-rating`).
535     * @return bool
536     */
537    public static function is_woocommerce_only_block( string $block_name ): bool {
538        $candidate = false === strpos( $block_name, '/' )
539            ? 'jetpack-search/' . $block_name
540            : $block_name;
541        return in_array( $candidate, self::woocommerce_only_block_names(), true );
542    }
543
544    /**
545     * Built-in taxonomies that have dedicated filter-checkbox variations.
546     * Excluded from the "Custom Taxonomy" picker so authors reach for the
547     * dedicated variation. Mirrors `BUILT_IN_TAXONOMY_SLUGS` in
548     * `filter-checkbox/edit.js` — must stay in lockstep.
549     *
550     * @var string[]
551     */
552    const BUILT_IN_CUSTOM_TAXONOMY_EXCLUSIONS = array(
553        'category',
554        'post_tag',
555        'product_cat',
556        'product_tag',
557        'product_brand',
558    );
559
560    /**
561     * Back-compat proxy for `Custom_Taxonomy_Slot_Mapping::get_map()`.
562     *
563     * @return array<string, string>
564     */
565    public static function custom_taxonomy_map(): array {
566        return Custom_Taxonomy_Slot_Mapping::get_map();
567    }
568
569    /**
570     * Back-compat proxy for `Custom_Taxonomy_Slot_Mapping::resolve_slot()`.
571     *
572     * @param string $taxonomy User-facing taxonomy slug.
573     * @return string Effective ES field slug.
574     */
575    public static function resolve_taxonomy_slot( string $taxonomy ): string {
576        return Custom_Taxonomy_Slot_Mapping::resolve_slot( $taxonomy );
577    }
578
579    /**
580     * Reset both the slot-mapping and `supported_custom_taxonomies()` memos. Tests only.
581     *
582     * @internal
583     */
584    public static function reset_custom_taxonomy_map_cache(): void {
585        Custom_Taxonomy_Slot_Mapping::reset_cache_for_testing();
586        self::$supported_custom_taxonomies_cache = null;
587    }
588
589    /**
590     * Custom-taxonomy slugs the "Custom Taxonomy" filter variation offers.
591     *
592     * Supported when registered locally AND either (a) in the Jetpack Search
593     * indexable allowlist (`Sync\Modules\Search::get_all_taxonomies()`, so
594     * aggregations actually return buckets) or (b) a key in `custom_taxonomy_map()`
595     * (queries route through a reserved slot). Built-ins with dedicated variations
596     * are stripped. The Sync `class_exists` guard is defensive — partial installs
597     * fall back to "map keys only".
598     *
599     * @return string[] Distinct, zero-indexed list of supported taxonomy slugs.
600     */
601    public static function supported_custom_taxonomies(): array {
602        if ( null !== self::$supported_custom_taxonomies_cache ) {
603            return self::$supported_custom_taxonomies_cache;
604        }
605
606        // Public taxonomies only — the editor's `core.getTaxonomies()` only returns
607        // REST-visible ones, and a private taxonomy in the Sync allowlist shouldn't surface.
608        $registered = function_exists( 'get_taxonomies' )
609            ? array_values( get_taxonomies( array( 'public' => true ), 'names' ) )
610            : array();
611
612        $indexed = class_exists( '\\Automattic\\Jetpack\\Sync\\Modules\\Search' )
613            ? \Automattic\Jetpack\Sync\Modules\Search::get_all_taxonomies()
614            : array();
615
616        $map_keys = array_keys( self::custom_taxonomy_map() );
617
618        $candidates = array_unique( array_merge( $indexed, $map_keys ) );
619        $supported  = array_values(
620            array_diff(
621                array_values( array_intersect( $registered, $candidates ) ),
622                self::BUILT_IN_CUSTOM_TAXONOMY_EXCLUSIONS
623            )
624        );
625
626        self::$supported_custom_taxonomies_cache = $supported;
627        return $supported;
628    }
629
630    /**
631     * URL param key the inline search experience uses for the current request.
632     * On the WP search route `s`; elsewhere `q` (see `NON_SEARCH_QUERY_PARAM`).
633     *
634     * Uses direct property access on `$wp_query` rather than the `is_search()`
635     * global function, because the function calls `_doing_it_wrong()` when
636     * invoked before the query has finished running (e.g. during block render).
637     *
638     * @return string
639     */
640    public static function get_search_param_name(): string {
641        global $wp_query;
642        if ( isset( $wp_query ) && ! empty( $wp_query->is_search ) ) {
643            return 's';
644        }
645        return self::NON_SEARCH_QUERY_PARAM;
646    }
647
648    /**
649     * Enqueue the client-side block registration bundle in the block editor.
650     *
651     * WP bootstraps server-side block metadata into the editor, but each block
652     * still needs a client-side `registerBlockType()` call so the editor knows
653     * how to render a preview. This script does that with ServerSideRender.
654     */
655    public static function enqueue_editor_assets() {
656        $base_path  = Package::get_installed_path() . 'build/search-blocks-editor/';
657        $asset_file = $base_path . 'register-blocks.asset.php';
658        if ( ! file_exists( $asset_file ) ) {
659            return;
660        }
661        $asset = require $asset_file;
662
663        // `plugins_url()` resolves against the nearest plugin directory, which
664        // handles the `jetpack_vendor` location Composer installs into.
665        $url = plugins_url( 'register-blocks.js', $base_path . 'register-blocks.js' );
666
667        wp_enqueue_script(
668            'jetpack-search-blocks-register',
669            $url,
670            $asset['dependencies'] ?? array(),
671            $asset['version'] ?? false,
672            true
673        );
674
675        // Surface PHP gates to the editor bundle so block edits and the
676        // registration loop branch consistently with server-side renders.
677        // `wp_add_inline_script` (not `wp_localize_script`) per core #25280 —
678        // the latter HTML-encodes ampersands inside nested values.
679        wp_add_inline_script(
680            'jetpack-search-blocks-register',
681            'window.JetpackSearchBlocksConfig = ' . wp_json_encode(
682                array(
683                    'isWooCommerceBlocksEnabled' => self::woocommerce_blocks_enabled(),
684                    'woocommerceOnlyBlocks'      => self::woocommerce_only_block_names(),
685                    'supportsPaidSearch'         => self::supports_paid_search(),
686                    'supportedCustomTaxonomies'  => self::supported_custom_taxonomies(),
687                    'customTaxonomyMap'          => (object) self::custom_taxonomy_map(),
688                    // Resolved the same way `search-results/render.php` resolves the
689                    // live value, so the editor placeholder never claims a default
690                    // visitors won't actually get.
691                    'defaultResultsPerPage'      => Helper::resolve_results_per_page(),
692                    'maxResultsPerPage'          => Helper::get_max_posts_per_page(),
693                ),
694                JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP
695            ) . ';',
696            'before'
697        );
698    }
699
700    /**
701     * Add a "Jetpack Search" block category so our blocks appear under that
702     * heading in the inserter instead of "Uncategorized".
703     *
704     * @param array $categories Existing block categories.
705     * @return array
706     */
707    public static function register_block_category( $categories ) {
708        foreach ( $categories as $category ) {
709            if ( 'jetpack-search' === ( $category['slug'] ?? '' ) ) {
710                return $categories;
711            }
712        }
713        $categories[] = array(
714            'slug'  => 'jetpack-search',
715            'title' => __( 'Jetpack Search', 'jetpack-search-pkg' ),
716        );
717        return $categories;
718    }
719
720    /**
721     * Register all search blocks from their block.json files.
722     */
723    public static function register_blocks() {
724        // Register block pattern category first so patterns can reference it.
725        if ( function_exists( 'register_block_pattern_category' ) ) {
726            register_block_pattern_category(
727                'jetpack-search',
728                array( 'label' => __( 'Jetpack Search', 'jetpack-search-pkg' ) )
729            );
730        }
731
732        self::register_store_script_module();
733
734        foreach ( self::block_directories() as $block_dir ) {
735            register_block_type( $block_dir );
736        }
737
738        add_filter( 'get_block_type_variations', array( static::class, 'inject_filter_checkbox_variations' ), 10, 2 );
739        static::register_patterns();
740    }
741
742    /**
743     * Register the shared store as the `jetpack-search/store` Script Module.
744     * See AGENTS.md § Shared store / bundles for why this is externalized.
745     */
746    public static function register_store_script_module() {
747        if ( ! function_exists( 'wp_register_script_module' ) ) {
748            return;
749        }
750
751        $base_path  = Package::get_installed_path() . 'build/search-blocks/store/';
752        $asset_file = $base_path . 'index.asset.php';
753        if ( ! file_exists( $asset_file ) ) {
754            return;
755        }
756        $asset = require $asset_file;
757
758        wp_register_script_module(
759            'jetpack-search/store',
760            plugins_url( 'index.js', $base_path . 'index.js' ),
761            $asset['dependencies'] ?? array(),
762            $asset['version'] ?? false
763        );
764    }
765
766    /**
767     * Relativize Jetpack Search Script Module URLs so the browser fetches them
768     * same-origin with the page.
769     *
770     * `wp_register_script_module()` resolves src via `plugins_url()`, which
771     * returns the canonical `site_url()` host. When a visitor is on a
772     * different host (Multisite mapped domains, www vs non-www without a
773     * canonical redirect, asset-offload plugins, reverse-proxy staging) the
774     * `<script type="module">` becomes cross-origin and is blocked with
775     * `MissingAllowOriginHeader` — ES modules always go through the CORS
776     * algorithm, even without a `crossorigin` attribute, and typical WP
777     * hosts don't send `Access-Control-Allow-Origin` for `wp-content/*`.
778     *
779     * Stripping scheme + host with `wp_make_link_relative()` lets the browser
780     * resolve against the page's actual origin. No `$_SERVER['HTTP_HOST']`
781     * trust — emitting an attacker-controllable host into a `<script src>`
782     * would be a cache-poisoning vector.
783     *
784     * No-op when the src host is a deliberately external host (CDN that
785     * doesn't match `home_url()`/`site_url()`); operators of those setups
786     * configure CORS on the CDN themselves.
787     *
788     * Identifier gate covers both shapes Jetpack Search ships: directly-
789     * registered modules with a slash (`jetpack-search/store`,
790     * `jetpack-search/overlay-bootstrap`) and the per-block view modules
791     * WP auto-registers from `block.json`'s `viewScriptModule`, which run
792     * `generate_block_asset_handle()` and emit hyphen-joined IDs like
793     * `jetpack-search-results-list-view-script-module`.
794     *
795     * @param string $src        Module src URL.
796     * @param string $identifier Module identifier (e.g. `jetpack-search/results-list`
797     *                           or `jetpack-search-results-list-view-script-module`).
798     * @return string Relativized src on match, original otherwise.
799     */
800    public static function same_origin_script_module_src( $src, $identifier ) {
801        if ( ! is_string( $src ) || '' === $src || ! is_string( $identifier ) ) {
802            return $src;
803        }
804        if ( 0 !== strpos( $identifier, 'jetpack-search/' ) && 0 !== strpos( $identifier, 'jetpack-search-' ) ) {
805            return $src;
806        }
807
808        $src_host = wp_parse_url( $src, PHP_URL_HOST );
809        if ( ! $src_host ) {
810            return $src;
811        }
812
813        $canonical_hosts = array_map(
814            'strtolower',
815            array_filter(
816                array(
817                    wp_parse_url( home_url(), PHP_URL_HOST ),
818                    wp_parse_url( site_url(), PHP_URL_HOST ),
819                )
820            )
821        );
822
823        if ( ! in_array( strtolower( $src_host ), $canonical_hosts, true ) ) {
824            return $src;
825        }
826
827        return wp_make_link_relative( $src );
828    }
829
830    /**
831     * Inject named block variations for the filter-checkbox block.
832     *
833     * Uses the `get_block_type_variations` filter (WP 6.5+) rather than
834     * `register_block_variation()` — the latter is JS-only and has no PHP
835     * equivalent. Variation names + default attributes mirror the
836     * instant-search overlay so both surfaces describe the same filters.
837     *
838     * @param array          $variations Variations registered on the block type.
839     * @param \WP_Block_Type $block_type Block type the filter is being applied to.
840     * @return array
841     */
842    public static function inject_filter_checkbox_variations( $variations, $block_type ) {
843        if ( ! isset( $block_type->name ) || 'jetpack-search/filter-checkbox' !== $block_type->name ) {
844            return $variations;
845        }
846
847        $additions = array(
848            array(
849                'name'        => 'category',
850                'title'       => __( 'Filter by Category', 'jetpack-search-pkg' ),
851                'description' => __( 'Show category checkboxes with live result counts.', 'jetpack-search-pkg' ),
852                'attributes'  => array(
853                    'filterType' => 'taxonomy',
854                    'taxonomy'   => 'category',
855                    'label'      => __( 'Category', 'jetpack-search-pkg' ),
856                ),
857                'isActive'    => array( 'filterType', 'taxonomy' ),
858            ),
859            array(
860                'name'        => 'post_tag',
861                'title'       => __( 'Filter by Tag', 'jetpack-search-pkg' ),
862                'description' => __( 'Show tag checkboxes with live result counts.', 'jetpack-search-pkg' ),
863                'attributes'  => array(
864                    'filterType' => 'taxonomy',
865                    'taxonomy'   => 'post_tag',
866                    'label'      => __( 'Tag', 'jetpack-search-pkg' ),
867                ),
868                'isActive'    => array( 'filterType', 'taxonomy' ),
869            ),
870            array(
871                'name'        => 'post_type',
872                'title'       => __( 'Filter by Post Type', 'jetpack-search-pkg' ),
873                'description' => __( 'Show post type checkboxes with live result counts.', 'jetpack-search-pkg' ),
874                'attributes'  => array(
875                    'filterType' => 'post_type',
876                    'label'      => __( 'Post Type', 'jetpack-search-pkg' ),
877                ),
878                'isActive'    => array( 'filterType' ),
879            ),
880            array(
881                'name'        => 'author',
882                'title'       => __( 'Filter by Author', 'jetpack-search-pkg' ),
883                'description' => __( 'Show author checkboxes with live result counts.', 'jetpack-search-pkg' ),
884                'attributes'  => array(
885                    'filterType' => 'author',
886                    'label'      => __( 'Author', 'jetpack-search-pkg' ),
887                ),
888                'isActive'    => array( 'filterType' ),
889            ),
890        );
891
892        // WC-only product-taxonomy variations. `product_brand` gets an extra
893        // `taxonomy_exists()` probe — it isn't core WC, it ships via extensions
894        // (WC Brands, Perfect Brands) or recent bundled WC versions.
895        if ( self::woocommerce_blocks_enabled() ) {
896            $additions[] = array(
897                'name'        => 'product_cat',
898                'title'       => __( 'Filter by Product Category', 'jetpack-search-pkg' ),
899                'description' => __( 'Show product category checkboxes with live result counts.', 'jetpack-search-pkg' ),
900                'attributes'  => array(
901                    'filterType' => 'taxonomy',
902                    'taxonomy'   => 'product_cat',
903                    'label'      => __( 'Product Category', 'jetpack-search-pkg' ),
904                ),
905                'isActive'    => array( 'filterType', 'taxonomy' ),
906            );
907            $additions[] = array(
908                'name'        => 'product_tag',
909                'title'       => __( 'Filter by Product Tag', 'jetpack-search-pkg' ),
910                'description' => __( 'Show product tag checkboxes with live result counts.', 'jetpack-search-pkg' ),
911                'attributes'  => array(
912                    'filterType' => 'taxonomy',
913                    'taxonomy'   => 'product_tag',
914                    'label'      => __( 'Product Tag', 'jetpack-search-pkg' ),
915                ),
916                'isActive'    => array( 'filterType', 'taxonomy' ),
917            );
918            if ( taxonomy_exists( 'product_brand' ) ) {
919                $additions[] = array(
920                    'name'        => 'product_brand',
921                    'title'       => __( 'Filter by Product Brand', 'jetpack-search-pkg' ),
922                    'description' => __( 'Show product brand checkboxes with live result counts.', 'jetpack-search-pkg' ),
923                    'attributes'  => array(
924                        'filterType' => 'taxonomy',
925                        'taxonomy'   => 'product_brand',
926                        'label'      => __( 'Product Brand', 'jetpack-search-pkg' ),
927                    ),
928                    'isActive'    => array( 'filterType', 'taxonomy' ),
929                );
930            }
931        }
932
933        $additions[] = array(
934            'name'        => 'custom_taxonomy',
935            'title'       => __( 'Filter by Custom Taxonomy', 'jetpack-search-pkg' ),
936            'description' => __( 'Show checkboxes for a custom taxonomy. Pick which taxonomy in the block settings after inserting.', 'jetpack-search-pkg' ),
937            'attributes'  => array(
938                'filterType' => 'taxonomy',
939                'taxonomy'   => '',
940                'label'      => '',
941            ),
942            // Match on filterType only so identity survives the author picking a
943            // slug. The dedicated variations pin `taxonomy` in their isActive
944            // arrays, so WP's most-specific-match resolution still routes named
945            // slugs to those; Custom Taxonomy claims every other taxonomy.
946            'isActive'    => array( 'filterType' ),
947        );
948
949        // Merge by `name` so an upstream variation (block.json or earlier filter)
950        // wins over our preset of the same name; plain `array_merge` would
951        // append duplicates and the inserter would render two cards.
952        $variations    = (array) $variations;
953        $existing_keys = array_flip( array_column( $variations, 'name' ) );
954        foreach ( $additions as $variation ) {
955            if ( ! isset( $existing_keys[ $variation['name'] ] ) ) {
956                $variations[] = $variation;
957            }
958        }
959        return $variations;
960    }
961
962    /**
963     * Register block patterns. Files prefixed `wc-` compose WooCommerce-only
964     * blocks and load only when WC is active (mirrors `filter-wc-*` blocks).
965     */
966    protected static function register_patterns() {
967        $patterns_dir = __DIR__ . '/patterns';
968        if ( ! is_dir( $patterns_dir ) ) {
969            return;
970        }
971        $pattern_files = glob( $patterns_dir . '/*.php' );
972        if ( ! $pattern_files ) {
973            return;
974        }
975        $wc_blocks_enabled = self::woocommerce_blocks_enabled();
976        foreach ( $pattern_files as $pattern_file ) {
977            if ( ! $wc_blocks_enabled && 0 === strpos( basename( $pattern_file ), 'wc-' ) ) {
978                continue;
979            }
980            require_once $pattern_file;
981        }
982    }
983
984    /**
985     * Derive a block-pattern's content from a chrome-free layout template (the
986     * overlay templates, which already ship without header/footer/main page
987     * chrome), so patterns stay in sync with the template they mirror instead of
988     * carrying a hand-copied second copy of the layout.
989     *
990     * @param string $template_file Template basename under `templates/`.
991     * @return string Block markup ready for `register_block_pattern()`, or '' when unreadable.
992     */
993    public static function pattern_content_from_template( string $template_file ): string {
994        $template_path = __DIR__ . '/templates/' . basename( $template_file );
995        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- local, bundled template file.
996        $raw = is_readable( $template_path ) ? (string) file_get_contents( $template_path ) : '';
997        if ( '' === $raw ) {
998            return '';
999        }
1000        return trim( static::substitute_template_placeholders( $raw ) );
1001    }
1002
1003    /**
1004     * Build the full search page template content.
1005     *
1006     * Markup lives in `templates/jetpack-search.html` with a `{{FILTER_HEADING}}`
1007     * placeholder so the sidebar heading still goes through `esc_html__()`.
1008     *
1009     * @return string Block markup for a complete page template.
1010     */
1011    protected static function get_search_template_content(): string {
1012        $template_path = __DIR__ . '/templates/jetpack-search.html';
1013        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- local, bundled template file.
1014        $raw = is_readable( $template_path ) ? (string) file_get_contents( $template_path ) : '';
1015        return static::sync_filters_popover_content( static::substitute_template_placeholders( $raw ) );
1016    }
1017
1018    /**
1019     * Register the Jetpack Search page template so it surfaces in the Site
1020     * Editor and resolves via the template hierarchy. DB-stored customizations
1021     * still win automatically — the `custom` source beats `plugin`. Classic
1022     * themes are skipped: the registry is only consulted by block themes.
1023     */
1024    public static function register_search_template() {
1025        if ( ! function_exists( 'register_block_template' ) || ! static::block_templates_active() ) {
1026            return;
1027        }
1028        $content = static::get_search_template_content();
1029        // Bail on missing/unreadable file: our slug is prepended to the search
1030        // hierarchy, so registering empty content would render a blank page on
1031        // `/?s=…`. Falling through lets core resolve the theme's `search.html`.
1032        if ( '' === $content ) {
1033            return;
1034        }
1035        static::replace_block_template(
1036            static::get_parent_plugin_slug() . '//' . self::SEARCH_TEMPLATE_SLUG,
1037            array(
1038                'title'       => __( 'Jetpack Search Results', 'jetpack-search-pkg' ),
1039                'description' => __( 'Displays search results with Jetpack Search filters.', 'jetpack-search-pkg' ),
1040                'content'     => $content,
1041            )
1042        );
1043    }
1044
1045    /**
1046     * Whether the overlay should paint the WooCommerce product variant for the
1047     * current request. True only when the override option is on and the request
1048     * is a product search — mirrors the embedded/inline interception
1049     * (`is_woocommerce_product_search()` already folds in the WC probe). The
1050     * overlay opens client-side from any search box, so this only flips on the
1051     * server-rendered product-search request (deep link or product-archive
1052     * search), not on a live form intercept from a non-product page.
1053     *
1054     * @return bool
1055     */
1056    protected static function should_use_product_overlay(): bool {
1057        return static::woocommerce_search_template_override_enabled()
1058            && static::is_woocommerce_product_search();
1059    }
1060
1061    /**
1062     * Read the dedicated overlay-template markup.
1063     *
1064     * Distinct from `get_search_template_content()`: a modal isn't a page, so
1065     * the overlay markup ships without `header`/`main`/`footer` template-parts
1066     * rather than runtime-stripping them.
1067     *
1068     * Picks the product variant on a WooCommerce product search (see
1069     * `should_use_product_overlay()`). Source of truth, in order:
1070     *   1. Customized singleton CPT (`Overlay_Template` / `Product_Overlay_Template`).
1071     *   2. The bundled `jetpack-search-overlay{-product}.html`.
1072     *
1073     * @return string Block markup for the overlay body.
1074     */
1075    protected static function get_overlay_template_content(): string {
1076        $is_product = static::should_use_product_overlay();
1077        $key        = $is_product ? 'product' : 'default';
1078        if ( isset( self::$overlay_template_content_cache[ $key ] ) ) {
1079            return self::$overlay_template_content_cache[ $key ];
1080        }
1081        $cpt_class  = $is_product ? Product_Overlay_Template::class : Overlay_Template::class;
1082        $customized = $cpt_class::get_customized_content();
1083        if ( null !== $customized ) {
1084            self::$overlay_template_content_cache[ $key ] = $customized;
1085            return self::$overlay_template_content_cache[ $key ];
1086        }
1087        $file          = $is_product ? 'jetpack-search-overlay-product.html' : 'jetpack-search-overlay.html';
1088        $template_path = __DIR__ . '/templates/' . $file;
1089        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- local, bundled template file; wp_remote_get() is for remote URLs.
1090        $raw = is_readable( $template_path ) ? (string) file_get_contents( $template_path ) : '';
1091        self::$overlay_template_content_cache[ $key ] = static::sync_filters_popover_content( $raw );
1092        return self::$overlay_template_content_cache[ $key ];
1093    }
1094
1095    /**
1096     * Reset the `get_overlay_template_content()` memo. Tests only — PHPUnit
1097     * reuses a single process, so a CPT-customized overlay saved in one test
1098     * would otherwise be pinned (or a bundled read would mask it) in the next.
1099     * Guarded against accidental production use.
1100     */
1101    public static function reset_overlay_template_content_cache(): void {
1102        if ( defined( 'ABSPATH' ) && ! defined( 'PHPUNIT_COMPOSER_INSTALL' ) ) {
1103            return;
1104        }
1105        self::$overlay_template_content_cache = array();
1106    }
1107
1108    /**
1109     * Echo the Search-blocks overlay shell into `wp_footer`. Block markup
1110     * carries `data-wp-interactive` so the IA's standard `DOMContentLoaded`
1111     * hydration picks it up — no client-side fetch needed. The caller (`init()`)
1112     * gates registration on `is_block_template_overlay_enabled()`.
1113     */
1114    public static function print_block_template_overlay() {
1115        $rendered = self::$block_template_overlay_rendered_html;
1116        if ( null === $rendered || '' === $rendered ) {
1117            return;
1118        }
1119        $config = wp_json_encode(
1120            array(
1121                'searchInputSelector'    => 'input[name="s"]:not(.jetpack-search-input__field), #searchform input.search-field, .search-form input.search-field, .searchform input.search-field',
1122                'overlayTriggerSelector' => '.jetpack-search-block-overlay-trigger, .jetpack-instant-search__open-overlay-button, header#site-header .search-toggle[data-toggle-target]',
1123            ),
1124            JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP
1125        );
1126        ?>
1127        <script id="jetpack-search-block-overlay-config">window.JetpackSearchBlockOverlay=<?php echo $config; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- wp_json_encode + JSON_HEX_* flags. ?>;</script>
1128        <?php
1129        // `<template>` keeps the region out of `document.querySelectorAll` so
1130        // the IA runtime's DOMContentLoaded walk skips it. The bootstrap clones
1131        // into the shell on first open and hydrates there.
1132        // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- do_blocks output.
1133        printf( '<template id="jetpack-search-block-overlay-template">%s</template>', $rendered );
1134        ?>
1135        <div
1136            id="jetpack-search-block-overlay"
1137            class="jetpack-search-block-overlay"
1138            role="dialog"
1139            aria-modal="true"
1140            aria-label="<?php echo esc_attr__( 'Search', 'jetpack-search-pkg' ); ?>"
1141            hidden
1142        >
1143            <div class="jetpack-search-block-overlay__card">
1144                <button
1145                    type="button"
1146                    class="jetpack-search-block-overlay__close"
1147                    aria-label="<?php echo esc_attr__( 'Close search', 'jetpack-search-pkg' ); ?>"
1148                >
1149                    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
1150                        <path d="M18.3 5.71a1 1 0 0 0-1.41 0L12 10.59 7.11 5.7A1 1 0 0 0 5.7 7.11L10.59 12 5.7 16.89a1 1 0 1 0 1.41 1.41L12 13.41l4.89 4.89a1 1 0 0 0 1.41-1.41L13.41 12l4.89-4.89a1 1 0 0 0 0-1.4z" fill="currentColor" />
1151                    </svg>
1152                </button>
1153                <div class="jetpack-search-block-overlay__content"></div>
1154            </div>
1155        </div>
1156        <?php
1157    }
1158
1159    /**
1160     * Register + enqueue the overlay-bootstrap Script Module that wires
1161     * theme-defined search triggers to the rendered shell. Inline-CSS for the
1162     * modal chrome. Config emits alongside the overlay HTML in
1163     * `print_block_template_overlay()`.
1164     */
1165    public static function enqueue_block_template_overlay_assets() {
1166        if ( ! function_exists( 'wp_register_script_module' ) ) {
1167            return;
1168        }
1169        $base_path  = Package::get_installed_path() . 'build/search-blocks/overlay-bootstrap/';
1170        $asset_file = $base_path . 'index.asset.php';
1171        if ( ! file_exists( $asset_file ) ) {
1172            return;
1173        }
1174        $asset = require $asset_file;
1175        wp_register_script_module(
1176            'jetpack-search/overlay-bootstrap',
1177            plugins_url( 'index.js', $base_path . 'index.js' ),
1178            $asset['dependencies'] ?? array(),
1179            $asset['version'] ?? false
1180        );
1181        wp_enqueue_script_module( 'jetpack-search/overlay-bootstrap' );
1182
1183        wp_register_style( 'jetpack-search-block-overlay', false, array(), $asset['version'] ?? false );
1184        wp_enqueue_style( 'jetpack-search-block-overlay' );
1185        wp_add_inline_style( 'jetpack-search-block-overlay', static::block_template_overlay_inline_css() );
1186
1187        // Shared responsive layout CSS (narrow-width sidebar collapse +
1188        // in-header popover toggle). Same rules ship to the embedded /
1189        // WC-product page templates via `enqueue_search_page_assets()`.
1190        static::enqueue_search_layout_style();
1191
1192        // Render here (during `wp_enqueue_scripts`) so view-module enqueues
1193        // from `do_blocks()` land before the importmap prints — see
1194        // AGENTS.md § Hydration & SSR seeding.
1195        self::$block_template_overlay_rendered_html = trim(
1196            No_Results::render_self_contained( static::get_overlay_template_content() )
1197        );
1198    }
1199
1200    /**
1201     * Print the body-sampler `<script>` that sets `--jp-search-page-ink` /
1202     * `--jp-search-page-surface` on `:root` from the body's resolved `color` /
1203     * `backgroundColor`. Skips writing surface when bg is transparent (the
1204     * theme paints on the browser canvas) or when bg equals ink (vintage
1205     * frame-themes like Twenty Sixteen use body as a colored border around a
1206     * lighter `.site` content wrapper). See AGENTS.md § Theme tokens.
1207     *
1208     * The `wp_body_open` hook registers unconditionally in `init()`, before
1209     * the module-active check in `Initializer::init_search_blocks()` — so the
1210     * module gate lives here instead, front-end only.
1211     */
1212    public static function print_theme_token_sampler(): void {
1213        if ( is_admin() || ! ( new Module_Control() )->is_active() ) {
1214            return;
1215        }
1216        echo "<script id='jetpack-search-theme-token-sampler'>(function(){try{var c=getComputedStyle(document.body),r=document.documentElement,ink=c.color,bg=c.backgroundColor;if(ink){r.style.setProperty('--jp-search-page-ink',ink);}if(bg&&bg!==ink&&bg!=='rgba(0, 0, 0, 0)'&&bg!=='transparent'){r.style.setProperty('--jp-search-page-surface',bg);}}catch(e){}})();</script>";
1217    }
1218
1219    /**
1220     * Inline CSS for the overlay modal chrome. Block content brings its own
1221     * theme styling; this is just the scrim, centered card, 60px header strip,
1222     * close button, mobile padding tweaks, and scroll lock. The responsive
1223     * sidebar-collapse + in-header popover rules shared with the page
1224     * templates live in `search_layout_inline_css()`.
1225     *
1226     * Surface/ink hoist `--jp-search-page-*` (with the legacy
1227     * `--wp--preset--color--*` chain as fallback — see AGENTS.md § Theme
1228     * tokens) onto two custom props so in-card surfaces share one source.
1229     * Hairlines use `color-mix(--jp-search-overlay-ink, --jp-search-overlay-surface)`.
1230     *
1231     * @return string
1232     */
1233    protected static function block_template_overlay_inline_css(): string {
1234        return <<<'CSS'
1235.jetpack-search-block-overlay {
1236    position: fixed;
1237    inset: 0;
1238    z-index: 100000;
1239    display: flex;
1240    justify-content: center;
1241    align-items: flex-start;
1242    background: rgba(31, 31, 31, 0.7);
1243    overflow-y: auto;
1244    padding: 3em 1em;
1245    transition: opacity 0.1s ease-in;
1246}
1247.jetpack-search-block-overlay[hidden] {
1248    display: none;
1249}
1250@media (prefers-reduced-motion: reduce) {
1251    .jetpack-search-block-overlay {
1252        transition: none;
1253    }
1254}
1255.jetpack-search-block-overlay__card {
1256    position: relative;
1257    width: 100%;
1258    max-width: 1080px;
1259    --jp-search-overlay-surface: var(--jp-search-page-surface, var(--wp--preset--color--base, var(--wp--preset--color--background, #fff)));
1260    --jp-search-overlay-ink: var(--jp-search-page-ink, var(--wp--preset--color--contrast, var(--wp--preset--color--foreground, #1d2327)));
1261    /* Single source for the content group's inset. The corner-join in
1262     * search_layout_inline_css() zeroes the block-start/block-end of this
1263     * padding on the group and re-adds the same tokens on the columns, so the
1264     * sidebar hairline reaches both card edges — keep them as var()s so the
1265     * two sites can't drift. */
1266    --jp-search-overlay-content-pad-block-start: 0.5em;
1267    --jp-search-overlay-content-pad-inline: 2em;
1268    --jp-search-overlay-content-pad-block-end: 2em;
1269    background: var(--jp-search-overlay-surface);
1270    color: var(--jp-search-overlay-ink);
1271    border: 1px solid rgba(128, 128, 128, 0.25);
1272    border-radius: 4px;
1273    box-shadow: 0 8px 32px rgba(0, 0, 0, 0.18);
1274    padding-top: 60px;
1275}
1276/* Token-aware card / scrim separation (SEARCH-270): tint the resolved surface
1277 * ~5% toward ink and paint the hairline border with the same ink-over-surface
1278 * mix used for the header `::before`. Both auto-invert polarity per theme, so
1279 * dark themes get a card that visibly layers above the scrim without losing
1280 * the themed surface color. The static `rgba(128,128,128,.25)` border + the
1281 * un-tinted token chain stay as the fallback for browsers without `color-mix`. */
1282@supports (background: color-mix(in sRGB, black 50%, white)) {
1283    .jetpack-search-block-overlay__card {
1284        --jp-search-overlay-surface: color-mix(in sRGB, var(--jp-search-page-ink, var(--wp--preset--color--contrast, var(--wp--preset--color--foreground, #1d2327))) 5%, var(--jp-search-page-surface, var(--wp--preset--color--base, var(--wp--preset--color--background, #fff))));
1285        border-color: color-mix(in sRGB, var(--jp-search-overlay-ink) 20%, var(--jp-search-overlay-surface));
1286        /* Ink-derived shadow inverts polarity per theme (SEARCH-289): a dark drop
1287         * shadow on light cards, a soft light halo on dark — a flat black shadow is
1288         * invisible against the dark scrim. Static black above is the fallback. */
1289        box-shadow: 0 8px 32px color-mix(in sRGB, var(--jp-search-overlay-ink) 22%, transparent);
1290    }
1291}
1292/* Single hairline over the full 60px header strip — siblings paint with a seam (SEARCH-260). */
1293.jetpack-search-block-overlay__card::before {
1294    content: "";
1295    position: absolute;
1296    top: 60px;
1297    left: 0;
1298    right: 0;
1299    height: 1px;
1300    background: transparent;
1301    pointer-events: none;
1302}
1303@supports (background: color-mix(in sRGB, black 50%, white)) {
1304    .jetpack-search-block-overlay__card::before {
1305        background: color-mix(in sRGB, var(--jp-search-overlay-ink) 15%, var(--jp-search-overlay-surface));
1306    }
1307}
1308.jetpack-search-block-overlay__close {
1309    position: absolute;
1310    top: 0;
1311    right: 0;
1312    width: 60px;
1313    height: 60px;
1314    display: flex;
1315    align-items: center;
1316    justify-content: center;
1317    background: transparent;
1318    border: 0;
1319    cursor: pointer;
1320    color: inherit;
1321}
1322/* Opaque ink-over-surface mix — `color-mix(currentColor, transparent)` collapses to full-opacity ink on Safari <16.4 and swallows the X icon. */
1323@supports (background: color-mix(in sRGB, black 50%, white)) {
1324    .jetpack-search-block-overlay__close:hover,
1325    .jetpack-search-block-overlay__close:focus-visible {
1326        background: color-mix(in sRGB, var(--jp-search-overlay-ink) 14%, var(--jp-search-overlay-surface));
1327    }
1328}
1329.jetpack-search-block-overlay__close svg {
1330    width: 24px;
1331    height: 24px;
1332}
1333/* Pin in-overlay button `color` against host-theme `button:hover|:focus` overrides
1334 * (fieldguide and similar legacy themes flip button color to a brand accent on hover).
1335 * Our block-level rules set `color: inherit` at (0,1,0); a stray `button:hover` rule
1336 * at (0,1,1) outranks it on `:hover`, and the `color-mix(currentColor X%, …)` hover
1337 * affordances on the filters-popover trigger, results-sort trigger, suggestions
1338 * options, etc. then resolve against the host's hover color (often white-on-white).
1339 * Card-scoped `:hover|:focus|[aria-expanded=true]` lands at (0,2,1) — beats the
1340 * theme rule without escalating to `!important`. */
1341.jetpack-search-block-overlay__card button:hover,
1342.jetpack-search-block-overlay__card button:focus,
1343.jetpack-search-block-overlay__card button:focus-visible,
1344.jetpack-search-block-overlay__card button[aria-expanded="true"] {
1345    color: var(--jp-search-overlay-ink);
1346}
1347/* Load More is a solid theme `core/button` on the search page, which is right
1348 * there. Inside the overlay card it sits on the resolved card surface, where the
1349 * theme's solid button background (and its accent `:hover`, e.g. Twenty Sixteen's
1350 * #007acc) reads as a heavy slab that clashes with the card's otherwise
1351 * `currentColor`-ghost controls (close, sort/filter triggers, active-filter
1352 * pills). Card-scoped (0,2,0 / 0,2,1) so it only restyles the in-overlay button
1353 * to the same ghost affordance — the page button is untouched. */
1354.jetpack-search-block-overlay__card .jetpack-search-load-more__button {
1355    background: transparent;
1356    border: 1px solid;
1357    border-color: color-mix(in sRGB, currentColor 20%, transparent);
1358    color: var(--jp-search-overlay-ink);
1359}
1360.jetpack-search-block-overlay__card .jetpack-search-load-more__button:hover:not(:disabled),
1361.jetpack-search-block-overlay__card .jetpack-search-load-more__button:focus-visible {
1362    background: color-mix(in sRGB, currentColor 8%, transparent);
1363}
1364/* Promote the first child (search-input) to a 60px header strip flush with
1365 * the close button, matching the legacy `__box` header. Suppress the input
1366 * block's own border-bottom — the card's `::before` hairline handles the
1367 * separator across the whole strip. */
1368.jetpack-search-block-overlay__card .wp-block-jetpack-search-search-input {
1369    position: absolute;
1370    top: 0;
1371    left: 0;
1372    right: 60px;
1373    height: 60px;
1374    margin: 0;
1375    padding: 0;
1376}
1377.jetpack-search-block-overlay__card .wp-block-jetpack-search-search-input .jetpack-search-input__inside-wrapper {
1378    height: 100%;
1379    display: flex;
1380    align-items: stretch;
1381    gap: 0;
1382    padding: 0;
1383    border-bottom: 0;
1384}
1385.jetpack-search-block-overlay__card .wp-block-jetpack-search-search-input .jetpack-search-input__icon {
1386    flex: 0 0 60px;
1387    width: 60px;
1388    height: 60px;
1389    padding: 18px;
1390    box-sizing: border-box;
1391    opacity: 0.5;
1392}
1393.jetpack-search-block-overlay__card .wp-block-jetpack-search-search-input .jetpack-search-input__field {
1394    flex: 1 1 auto;
1395    min-width: 0;
1396    height: 100%;
1397    font-size: 18px;
1398    line-height: 1;
1399    margin: 0;
1400    padding: 0;
1401    background: transparent;
1402}
1403.jetpack-search-block-overlay__card .wp-block-jetpack-search-search-input .jetpack-search-input__clear {
1404    flex: 0 0 60px;
1405    width: 60px;
1406    height: 60px;
1407    padding: 0;
1408    font-size: 0.875rem;
1409    font-weight: 400;
1410    line-height: 1;
1411}
1412/* Suggestions panel covers the full card width (cancel the input's `right: 60px`
1413 * offset) and sits above `results-sort` / `filters-popover` (both `z-index: 20`).
1414 * Background reads from `--jp-search-overlay-surface` so the panel tracks the
1415 * resolved card surface — a hardcoded `#fff` would re-introduce the white-on-dark
1416 * bug on legacy `--background`/`--foreground` themes. */
1417.jetpack-search-block-overlay__card .wp-block-jetpack-search-search-input .jetpack-search-input__suggestions {
1418    right: -60px;
1419    z-index: 30;
1420    background: var(--jp-search-overlay-surface, #fff);
1421}
1422/* Top padding clears the absolutely-positioned 60px header strip (SEARCH-243). */
1423.jetpack-search-block-overlay__content > .wp-block-group:first-child {
1424    padding: var(--jp-search-overlay-content-pad-block-start) var(--jp-search-overlay-content-pad-inline) var(--jp-search-overlay-content-pad-block-end);
1425}
1426@media (max-width: 781px) {
1427    .jetpack-search-block-overlay {
1428        padding: 0;
1429    }
1430    .jetpack-search-block-overlay__card {
1431        min-height: 100vh;
1432        border: 0;
1433        border-radius: 0;
1434        box-shadow: none;
1435        --jp-search-overlay-content-pad-inline: 1em;
1436        --jp-search-overlay-content-pad-block-end: 1em;
1437    }
1438}
1439/* Mirror legacy `$break-lg: 992px → $modal-max-width-lg: 95%` from `instant-search/components/search-results.scss`. */
1440@media (min-width: 992px) {
1441    .jetpack-search-block-overlay__card {
1442        max-width: 95%;
1443    }
1444}
1445/* Body-scroll lock while open. JS side stashes/restores scrollY on toggle. */
1446body.jetpack-search-block-overlay-open {
1447    position: fixed;
1448    left: 0;
1449    right: 0;
1450    width: 100%;
1451    overflow: hidden;
1452}
1453CSS;
1454    }
1455
1456    /**
1457     * Register + enqueue the shared responsive layout CSS on the embedded /
1458     * WC-product page templates (`jetpack-search.html`,
1459     * `jetpack-search-product-results.html`). Self-gates on `is_search()` so
1460     * non-search requests skip the work entirely; the overlay path enqueues
1461     * the same handle unconditionally from its own asset hook.
1462     */
1463    public static function enqueue_search_page_assets() {
1464        if ( ! is_search() ) {
1465            return;
1466        }
1467        static::enqueue_search_layout_style();
1468    }
1469
1470    /**
1471     * Register + enqueue the inline CSS that drives the shared responsive
1472     * layout pattern across all three Search Blocks templates — narrow-width
1473     * sidebar collapse and in-header popover toggle. Called from both the
1474     * overlay enqueue path and the page-template enqueue path.
1475     *
1476     * `wp_register_style` / `wp_enqueue_style` are idempotent, but
1477     * `wp_add_inline_style` is **not** — it appends to an internal array on
1478     * every call, so a second invocation would double the inline payload.
1479     * The `wp_style_is( …, 'enqueued' )` short-circuit makes the helper safe
1480     * to call from multiple sites in one request.
1481     */
1482    public static function enqueue_search_layout_style() {
1483        // No src — this handle exists only as a target for `wp_add_inline_style`.
1484        // Version tracks the package so a release bust cache-invalidates any
1485        // reusing site's inline-style cache.
1486        wp_register_style( 'jetpack-search-layout', false, array(), Package::VERSION );
1487        if ( wp_style_is( 'jetpack-search-layout', 'enqueued' ) ) {
1488            return;
1489        }
1490        wp_enqueue_style( 'jetpack-search-layout' );
1491        wp_add_inline_style( 'jetpack-search-layout', static::search_layout_inline_css() );
1492    }
1493
1494    /**
1495     * Inline CSS for the responsive search-results layout shared across the
1496     * overlay, embedded (`jetpack-search.html`), and WC product
1497     * (`jetpack-search-product-results.html`) templates.
1498     *
1499     * Below 992px the right-column filter sidebar collapses to a popover
1500     * trigger docked next to results-sort; at >= 992px the sidebar is the
1501     * sole filter UI and the in-header popover is hidden so the two don't
1502     * double up. Same breakpoint as the legacy Instant Search overlay
1503     * (`.jetpack-instant-search__search-results-secondary { display: none }`
1504     * below `$break-lg`). Lives next to `block_template_overlay_inline_css()`
1505     * (which keeps overlay-only chrome) because the rules target the
1506     * templates' outer columns + results-header, none of which any single
1507     * block owns.
1508     *
1509     * @return string
1510     */
1511    protected static function search_layout_inline_css(): string {
1512        return <<<'CSS'
1513/* The block group already carries `layout.type:flex` which makes the WordPress
1514 * block-layout system emit `display:flex` / `flex-wrap:nowrap` /
1515 * `justify-content:space-between`. Restating them is defensive (decouples us
1516 * from block-layout CSS being present); the operative net-new rule is
1517 * `align-items: center`, which centers `results-count` against the controls
1518 * cluster. */
1519.jetpack-search-layout__results-header {
1520    display: flex;
1521    flex-wrap: nowrap;
1522    justify-content: space-between;
1523    align-items: center;
1524}
1525/* Right-side controls cluster: sort + filters-popover trigger. Without this
1526 * the three `__results-header` children get spread evenly by the parent's
1527 * `space-between`; nesting sort + popover here pins them as one block on the
1528 * trailing edge. */
1529.jetpack-search-layout__results-header-controls {
1530    display: flex;
1531    flex-wrap: nowrap;
1532    align-items: center;
1533    gap: 0.75rem;
1534}
1535/* Name the columns row as the layout container so the sidebar/popover flip
1536 * tracks its inline-size. The `@media` rules below are the universal base —
1537 * they fire in every browser (including legacy ones without container-query
1538 * support) and they drive standalone usage outside the named container (no
1539 * container ancestor → only `@media` applies). The `@container` rule further
1540 * down overrides `@media` via source-order cascade at equal specificity when
1541 * the named container is in scope AND narrower than 992px. The override has
1542 * to undo `@media (min-width: 992px)`'s `popover { display: none }`
1543 * explicitly: in the "wide viewport, narrow container" case `@media
1544 * (min-width: 992px)` keeps firing on the viewport width and would otherwise
1545 * leave the visitor with no filter UI at all. `@container (min-width: 992px)`
1546 * isn't defined — container width is bounded by viewport width in practice,
1547 * so the matching `@media (min-width: 992px)` already covers the wide case. */
1548.wp-block-columns:has(> .jetpack-search-layout__filters-column) {
1549    container-type: inline-size;
1550    container-name: jetpack-search-layout;
1551}
1552/* Below 992px the right-column filter sidebar collapses to a popover trigger
1553 * docked next to results-sort. The trigger comes from the
1554 * `jetpack-search/filters-popover` block that ships in each template. The
1555 * selector is scoped to the named outer column so nested `wp-block-column`s
1556 * inside result-card templates aren't affected. */
1557@media (max-width: 991.98px) {
1558    .jetpack-search-layout__filters-column {
1559        display: none;
1560    }
1561    /* `!important` defends against the parent `wp:columns` block-layout CSS
1562     * that pins `.wp-block-column` to its inline `flex-basis` (or to an even
1563     * split when no width is set). Once the filter column is `display:none`,
1564     * the results column has to be able to claim the full row at any
1565     * specificity. */
1566    .jetpack-search-layout__results-column {
1567        flex-basis: 100% !important;
1568    }
1569}
1570/* Sidebar left divider tracks `currentColor` so the hairline stays subtle on
1571 * light themes and visible on dark themes, matching the search-input
1572 * underline. We only set color; each template's column block sets
1573 * `border-left-width: 1px` inline. Fallback to `transparent` so themes/UAs
1574 * without `color-mix` support get an invisible divider rather than a hard
1575 * grey rule. */
1576.jetpack-search-layout__filters-column {
1577    border-left-color: transparent;
1578}
1579@supports (border-color: color-mix(in sRGB, black 50%, white)) {
1580    .jetpack-search-layout__filters-column {
1581        border-left-color: color-mix(in sRGB, currentColor 15%, transparent);
1582    }
1583}
1584/* Sidebar-showing rules (>= 992px). The corner-join is structural: the
1585 * columns row is pulled flush to the search-input hairline and breathing
1586 * room re-added as internal column padding, so the filters column's
1587 * `border-left` runs the row's full height — hairline (top) to end-of-div
1588 * (bottom). Three vertical gaps are neutralised:
1589 *
1590 *   a) `margin-block-start` on the row (outer group's `spacing.blockGap` or
1591 *      the theme's default block-gap) — zeroed.
1592 *
1593 *   b) `.is-layout-flex { align-items: center }` (theme/core default), which
1594 *      centres the shorter filters column and drops its top edge. Overridden
1595 *      to `stretch` (not `flex-start`) so the column also grows to full row
1596 *      height; it's flow layout, so its content stays top-aligned regardless.
1597 *
1598 *   c) Overlay-only: SEARCH-243's content-group inset sits outside the
1599 *      columns, so the stretched column stops short of the card edges (below
1600 *      the `::before` hairline at the top, and short of the bottom). Zeroed
1601 *      on the group's block axis and re-added on the columns, both sides
1602 *      reading the same `--jp-search-overlay-content-pad-*` tokens the group
1603 *      itself uses — so the divider reaches both edges while content keeps
1604 *      its breathing room, and the two sites can't drift.
1605 *
1606 * `.is-layout-flex` bumps the columns selector to (0,3,0) to outrank
1607 * per-container `blockGap` CSS; `:has(> filters-column)` scopes it to our
1608 * rows. These target the row/group, which `@container` can't reach from
1609 * inside the named container, so they stay viewport-driven — harmless when
1610 * the sidebar collapses below 992px. (b) is also a no-op wherever WP core's
1611 * `.wp-block-columns { align-items: normal !important }` is present; see
1612 * AGENTS.md. */
1613@media (min-width: 992px) {
1614    /* Sidebar shown, popover-in-results-header hidden. The `@container
1615     * (max-width: 991.98px)` block below re-shows the popover when the
1616     * named container is narrower than the viewport. */
1617    .jetpack-search-layout__results-header .jetpack-search-filters-popover {
1618        display: none;
1619    }
1620    .wp-block-columns.is-layout-flex:has(> .jetpack-search-layout__filters-column) {
1621        align-items: stretch;
1622        margin-block-start: 0;
1623    }
1624    .jetpack-search-block-overlay__content > .wp-block-group:first-child:has(.jetpack-search-layout__filters-column) {
1625        padding-top: 0;
1626        padding-bottom: 0;
1627    }
1628    .wp-block-columns:has(> .jetpack-search-layout__filters-column) > .wp-block-column {
1629        padding-top: var(--jp-search-overlay-content-pad-block-start, 0.5em);
1630    }
1631    .jetpack-search-block-overlay__content .wp-block-columns:has(> .jetpack-search-layout__filters-column) > .wp-block-column {
1632        padding-bottom: var(--jp-search-overlay-content-pad-block-end, 2em);
1633    }
1634}
1635/* @container override: applies when the named container exists. Placed AFTER
1636 * the `@media` rules so source-order cascade lets it win over them at equal
1637 * specificity. In "wide viewport, narrow container" (the case the change is
1638 * meant to fix), `@media (max-width: 991.98px)` doesn't fire but `@media
1639 * (min-width: 992px)` does — this block undoes the latter's `popover {
1640 * display: none }` via `display: inline-block` and hides the sidebar that
1641 * the `@media (max-width)` rule wouldn't have hidden at this viewport. Same
1642 * `!important` reasoning on `flex-basis: 100%` as the @media block. */
1643@container jetpack-search-layout (max-width: 991.98px) {
1644    .jetpack-search-layout__filters-column {
1645        display: none;
1646    }
1647    .jetpack-search-layout__results-column {
1648        flex-basis: 100% !important;
1649    }
1650    .jetpack-search-layout__results-header .jetpack-search-filters-popover {
1651        display: inline-block;
1652    }
1653}
1654CSS;
1655    }
1656
1657    /**
1658     * Product-search counterpart of `get_search_template_content()`.
1659     *
1660     * @return string Block markup for the product-search template.
1661     */
1662    protected static function get_product_search_template_content(): string {
1663        $template_path = __DIR__ . '/templates/jetpack-search-product-results.html';
1664        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- local, bundled template file.
1665        $raw = is_readable( $template_path ) ? (string) file_get_contents( $template_path ) : '';
1666        return static::sync_filters_popover_content( static::substitute_template_placeholders( $raw ) );
1667    }
1668
1669    /**
1670     * Substitute `{{FILTER_HEADING}}` / `{{HEADER_SLUG}}` / `{{FOOTER_SLUG}}` in a
1671     * bundled template. Empty input passes through.
1672     *
1673     * @param string $raw Raw template-file contents.
1674     * @return string
1675     */
1676    protected static function substitute_template_placeholders( string $raw ): string {
1677        if ( '' === $raw ) {
1678            return $raw;
1679        }
1680        $slugs = static::resolve_chrome_slugs();
1681        return str_replace(
1682            array( '{{FILTER_HEADING}}', '{{HEADER_SLUG}}', '{{FOOTER_SLUG}}' ),
1683            array(
1684                esc_html__( 'Filter options', 'jetpack-search-pkg' ),
1685                $slugs['header'],
1686                $slugs['footer'],
1687            ),
1688            $raw
1689        );
1690    }
1691
1692    /**
1693     * Active theme's chrome slugs. Test seam; resolver lives on
1694     * `Theme_Chrome_Slug_Resolver`.
1695     *
1696     * @return array{header:string,footer:string}
1697     */
1698    protected static function resolve_chrome_slugs(): array {
1699        return Theme_Chrome_Slug_Resolver::resolve();
1700    }
1701
1702    /**
1703     * Names of the "source of truth" filter-composition blocks — whichever is
1704     * present in a template supplies the canonical filter config that
1705     * `jetpack-search/filters-popover` mirrors. See {@see sync_filters_popover_content()}.
1706     */
1707    const FILTERS_SOURCE_BLOCK_NAMES = array( 'jetpack-search/filters', 'jetpack-search/filters-product' );
1708
1709    /**
1710     * `jetpack-search/filters-popover` (the collapsible/mobile filter panel) and
1711     * `jetpack-search/filters` / `jetpack-search/filters-product` (the wide-viewport
1712     * sidebar) are two independently-serialized copies of the same filter
1713     * configuration, shown one-or-the-other via a CSS breakpoint. Nothing keeps
1714     * them in sync, so editing one silently leaves the other stale (SEARCH-307).
1715     *
1716     * Rather than a two-way sync, the sidebar block is treated as the single
1717     * source of truth: every time template content is read — for rendering or
1718     * for editing — the popover's inner blocks are recomputed to mirror
1719     * whatever the sidebar currently contains. A direct edit to the popover's
1720     * own inner blocks still saves, but is overwritten back to match the
1721     * sidebar on the next read; self-healing, no migration needed for content
1722     * that already diverged before this existed.
1723     *
1724     * @param string $content Block markup, e.g. a full template or singleton-CPT post_content.
1725     * @return string Block markup with the popover's inner blocks synced to the sidebar's, unchanged if either block is absent.
1726     */
1727    public static function sync_filters_popover_content( string $content ): string {
1728        if ( '' === $content || false === strpos( $content, 'jetpack-search/filters-popover' ) ) {
1729            return $content;
1730        }
1731        $blocks = parse_blocks( $content );
1732        $source = static::find_block_by_name( $blocks, self::FILTERS_SOURCE_BLOCK_NAMES );
1733        if ( null === $source ) {
1734            return $content;
1735        }
1736        $replaced = static::replace_block_inner_content( $blocks, 'jetpack-search/filters-popover', $source );
1737        return $replaced ? serialize_blocks( $blocks ) : $content;
1738    }
1739
1740    /**
1741     * Depth-first search for the first block matching one of `$names`.
1742     *
1743     * @param array<int,array<string,mixed>> $blocks Parsed blocks (`parse_blocks()` shape).
1744     * @param string[]                       $names  Block names to match.
1745     * @return array<string,mixed>|null
1746     */
1747    protected static function find_block_by_name( array $blocks, array $names ): ?array {
1748        foreach ( $blocks as $block ) {
1749            if ( in_array( $block['blockName'], $names, true ) ) {
1750                return $block;
1751            }
1752            if ( ! empty( $block['innerBlocks'] ) ) {
1753                $found = static::find_block_by_name( $block['innerBlocks'], $names );
1754                if ( null !== $found ) {
1755                    return $found;
1756                }
1757            }
1758        }
1759        return null;
1760    }
1761
1762    /**
1763     * Depth-first search that overwrites the first block named `$name` with
1764     * `$source`'s inner blocks. `innerBlocks`/`innerContent`/`innerHTML` are
1765     * copied together from `$source` so the null-placeholder bookkeeping in
1766     * `innerContent` stays internally consistent regardless of how many
1767     * filters `$source` has.
1768     *
1769     * @param array<int,array<string,mixed>> $blocks Parsed blocks, modified in place.
1770     * @param string                         $name   Block name to replace.
1771     * @param array<string,mixed>            $source Block whose inner content is cloned onto the match.
1772     * @return bool Whether a match was found and replaced.
1773     */
1774    protected static function replace_block_inner_content( array &$blocks, string $name, array $source ): bool {
1775        foreach ( $blocks as &$block ) {
1776            if ( $block['blockName'] === $name ) {
1777                $block['innerBlocks']  = $source['innerBlocks'];
1778                $block['innerContent'] = $source['innerContent'];
1779                $block['innerHTML']    = $source['innerHTML'];
1780                return true;
1781            }
1782            if ( ! empty( $block['innerBlocks'] ) && static::replace_block_inner_content( $block['innerBlocks'], $name, $source ) ) {
1783                return true;
1784            }
1785        }
1786        return false;
1787    }
1788
1789    /**
1790     * Idempotent wrapper around `register_block_template`. Unregisters first so
1791     * a stale entry from a prior init (long-lived PHP-FPM worker) is replaced
1792     * rather than triggering `doing_it_wrong`.
1793     *
1794     * @param string              $name Fully-qualified template name.
1795     * @param array<string,mixed> $args Args for register_block_template().
1796     */
1797    protected static function replace_block_template( string $name, array $args ) {
1798        if ( class_exists( '\WP_Block_Templates_Registry' ) ) {
1799            $registry = \WP_Block_Templates_Registry::get_instance();
1800            if ( $registry->is_registered( $name ) ) {
1801                $registry->unregister( $name );
1802            }
1803        }
1804        register_block_template( $name, $args );
1805    }
1806
1807    /**
1808     * Counterpart of `register_search_template()` for product search.
1809     */
1810    public static function register_product_search_template() {
1811        if ( ! function_exists( 'register_block_template' ) || ! static::block_templates_active() ) {
1812            return;
1813        }
1814        $content = static::get_product_search_template_content();
1815        if ( '' === $content ) {
1816            return;
1817        }
1818        static::replace_block_template(
1819            static::get_parent_plugin_slug() . '//' . self::PRODUCT_SEARCH_TEMPLATE_SLUG,
1820            array(
1821                'title'       => __( 'Jetpack Search Product Results', 'jetpack-search-pkg' ),
1822                'description' => __( 'Displays WooCommerce product search results with Jetpack Search filters.', 'jetpack-search-pkg' ),
1823                'content'     => $content,
1824            )
1825        );
1826    }
1827
1828    /**
1829     * Directory slug of the plugin that owns the template in the Site Editor UI.
1830     * Picked by preference so the more-specific "Jetpack Search" label wins
1831     * when both the standalone plugin and the Jetpack monolith are active:
1832     * `jetpack-search` → `jetpack` → `jetpack-search` fallback.
1833     *
1834     * @return string
1835     */
1836    protected static function get_parent_plugin_slug(): string {
1837        $active    = Helper::get_active_plugins();
1838        $preferred = array(
1839            'jetpack-search' => 'jetpack-search/jetpack-search.php',
1840            'jetpack'        => 'jetpack/jetpack.php',
1841        );
1842        foreach ( $preferred as $slug => $plugin_file ) {
1843            if ( in_array( $plugin_file, $active, true ) ) {
1844                return $slug;
1845            }
1846        }
1847        return 'jetpack-search';
1848    }
1849
1850    /**
1851     * Prepend the Jetpack Search slug to the search template hierarchy on
1852     * block-theme search requests. Existing occurrences are stripped first
1853     * so a second init pass / another filter on the same hook can't dup.
1854     *
1855     * WooCommerce product-search carve-out: override off → leave to WC's
1856     * prepend; override on → fall through here, then
1857     * `route_woocommerce_product_search_template()` swaps WC's slug for ours.
1858     *
1859     * @param string[] $templates Template hierarchy slugs.
1860     * @return string[]
1861     */
1862    public static function prepend_search_template( $templates ) {
1863        if ( ! is_search() || ! static::block_templates_active() ) {
1864            return $templates;
1865        }
1866        if ( ! static::woocommerce_search_template_override_enabled() && static::is_woocommerce_product_search() ) {
1867            return $templates;
1868        }
1869        $templates = array_values(
1870            array_filter(
1871                (array) $templates,
1872                static function ( $slug ) {
1873                    return self::SEARCH_TEMPLATE_SLUG !== $slug;
1874                }
1875            )
1876        );
1877        array_unshift( $templates, self::SEARCH_TEMPLATE_SLUG );
1878        return $templates;
1879    }
1880
1881    /**
1882     * Classic-theme counterpart to `prepend_search_template()`. Block-theme
1883     * hierarchy filters are a no-op on classic themes — `locate_template()`
1884     * walks slugs as `{slug}.php` and a classic theme doesn't ship one for
1885     * our slug. So let the hierarchy resolve normally, then swap the path
1886     * via `template_include` to our bundled PHP shim, which renders the same
1887     * block markup inside the theme's `get_header()`/`get_footer()`.
1888     *
1889     * @param string $template Resolved template path.
1890     * @return string
1891     */
1892    public static function route_classic_theme_search_template( $template ) {
1893        if ( ! is_search() ) {
1894            return $template;
1895        }
1896        $is_product_search = static::is_woocommerce_product_search();
1897        // Override off: leave product search to WooCommerce / the theme's own
1898        // archive routing — we don't impose the product shim without opt-in.
1899        if ( ! static::woocommerce_search_template_override_enabled() && $is_product_search ) {
1900            return $template;
1901        }
1902        // Override on + product search: route to the product-results shim.
1903        // Bail back to the theme's template if neither a customization nor a
1904        // bundled body is available — rendering header/footer around an empty
1905        // body looks broken.
1906        if ( $is_product_search ) {
1907            if ( null === Product_Search_Template::get_customized_content() && '' === static::get_classic_theme_product_search_body() ) {
1908                return $template;
1909            }
1910            return __DIR__ . '/templates/classic-theme-product-search.php';
1911        }
1912        // Regular (non-product) search: only Embedded takes over the whole search
1913        // page. Inline registers this router too (for the product shim above) but
1914        // leaves regular searches to the theme, so bail here unless Embedded.
1915        if ( Module_Control::EXPERIENCE_EMBEDDED !== ( new Module_Control() )->get_experience() ) {
1916            return $template;
1917        }
1918        // Same empty-body bail-out for the generic shim. A saved empty
1919        // customization ('' vs null) is honored as intentional.
1920        if ( null === Search_Template::get_customized_content() && '' === static::get_classic_theme_search_body() ) {
1921            return $template;
1922        }
1923        return __DIR__ . '/templates/classic-theme-search.php';
1924    }
1925
1926    /**
1927     * Classic-theme search body — same markup as the block-theme path with
1928     * top-level `core/template-part` references stripped so the theme's
1929     * `get_header()` / `get_footer()` drive the chrome.
1930     *
1931     * Source of truth: customized `Search_Template` CPT → bundled `jetpack-search.html`.
1932     * Public because `templates/classic-theme-search.php` calls it from outside the class.
1933     *
1934     * @return string Block markup, no template-part wrappers.
1935     */
1936    public static function get_classic_theme_search_body(): string {
1937        $customized = Search_Template::get_customized_content();
1938        if ( null !== $customized ) {
1939            return $customized;
1940        }
1941        return static::strip_top_level_template_parts( static::get_search_template_content() );
1942    }
1943
1944    /**
1945     * Product-search counterpart to {@see get_classic_theme_search_body()} —
1946     * source of truth for the classic-theme product-results shim. Customized
1947     * `Product_Search_Template` CPT → bundled `jetpack-search-product-results.html`.
1948     * Public because `templates/classic-theme-product-search.php` calls it from
1949     * outside the class.
1950     *
1951     * @return string Block markup, no template-part wrappers.
1952     */
1953    public static function get_classic_theme_product_search_body(): string {
1954        $customized = Product_Search_Template::get_customized_content();
1955        if ( null !== $customized ) {
1956            return $customized;
1957        }
1958        return static::strip_top_level_template_parts( static::get_product_search_template_content() );
1959    }
1960
1961    /**
1962     * Strip top-level `core/template-part` self-closing comments — classic
1963     * themes can't resolve their slugs. Non-greedy `.*?` capped by `-->` keeps
1964     * matching cleanly across template revisions and across nested attribute
1965     * payloads.
1966     *
1967     * @param string $content Block markup, possibly empty.
1968     * @return string
1969     */
1970    protected static function strip_top_level_template_parts( string $content ): string {
1971        if ( '' === $content ) {
1972            return '';
1973        }
1974        return (string) preg_replace( '#<!--\s*wp:template-part\s+.*?/-->\s*#s', '', $content );
1975    }
1976
1977    /**
1978     * Inline layout `<style>` block the classic-theme shims emit before the
1979     * bundled block markup. Classic themes don't emit core's block-supports
1980     * layout CSS, so two traits the bundled templates rely on collapse on
1981     * classic themes: the inner group's 1.5rem `blockGap` vanishes (search
1982     * input runs straight into the results row), and `alignwide` has no
1983     * effect (content stretches edge-to-edge because `template_include`
1984     * bypasses the theme's own content wrapper). Reapplying both, scoped to
1985     * `<main class="wp-block-group">`, restores parity without leaking
1986     * outside the shim. Shared by both shims so a future layout tweak
1987     * touches one place. The `<style>` `id` is unique per render — routing
1988     * ensures only one shim runs per request, so duplicate IDs can't occur.
1989     *
1990     * Public because both `templates/classic-theme-search.php` and
1991     * `templates/classic-theme-product-search.php` call it from outside the
1992     * class.
1993     *
1994     * @return string Inline `<style>` element ready to echo.
1995     */
1996    public static function get_classic_theme_layout_style(): string {
1997        return <<<'HTML'
1998<style id="jetpack-search-classic-theme-layout">
1999main.wp-block-group {
2000    max-width: var(--wp--style--global--wide-size, 1280px);
2001    margin-inline: auto;
2002    padding-inline: clamp(1rem, 4vw, 2rem);
2003}
2004main.wp-block-group .is-layout-flow > * + * {
2005    margin-block-start: var(--wp--style--block-gap, 1.5rem);
2006}
2007</style>
2008HTML;
2009    }
2010
2011    /**
2012     * Test override for `block_templates_active()`. Null = read the live state.
2013     *
2014     * @var bool|null
2015     */
2016    private static $block_templates_active_for_testing = null;
2017
2018    /**
2019     * Test seam. Set true/false to force `block_templates_active()`, or null to clear.
2020     *
2021     * @param bool|null $active Forced value, or null to clear.
2022     */
2023    public static function set_block_templates_active_for_testing( ?bool $active ): void {
2024        self::$block_templates_active_for_testing = $active;
2025    }
2026
2027    /**
2028     * Whether the theme resolves block templates. Overridable seam over
2029     * `wp_is_block_theme()` for tests.
2030     *
2031     * @return bool
2032     */
2033    protected static function block_templates_active(): bool {
2034        if ( null !== self::$block_templates_active_for_testing ) {
2035            return self::$block_templates_active_for_testing;
2036        }
2037        return wp_is_block_theme();
2038    }
2039
2040    /**
2041     * Front the `jetpack-search-product-results` template for WC product
2042     * search. Drops WC's `product-search-results` and unshifts ours so it
2043     * resolves before any `jetpack-search` prepend for the generic route.
2044     *
2045     * @param string[] $templates Template hierarchy slugs.
2046     * @return string[]
2047     */
2048    public static function route_woocommerce_product_search_template( $templates ) {
2049        // FSE-hierarchy work — classic themes resolve template slugs as `{slug}.php`
2050        // and there's no `jetpack-search-product-results.php`. The classic-theme
2051        // equivalent runs through `route_classic_theme_search_template()` instead.
2052        if ( ! static::block_templates_active() || ! static::is_woocommerce_product_search() ) {
2053            return $templates;
2054        }
2055        $templates = array_values(
2056            array_filter(
2057                (array) $templates,
2058                static function ( $slug ) {
2059                    return self::WC_PRODUCT_SEARCH_TEMPLATE_SLUG !== $slug
2060                        && self::PRODUCT_SEARCH_TEMPLATE_SLUG !== $slug;
2061                }
2062            )
2063        );
2064        array_unshift( $templates, self::PRODUCT_SEARCH_TEMPLATE_SLUG );
2065        return $templates;
2066    }
2067
2068    /**
2069     * Seed the Interactivity API store with initial state. Per-block render
2070     * callbacks deep-merge their own entries on top (e.g. filter-checkbox
2071     * writes its filterConfig). See AGENTS.md § Hydration & SSR seeding.
2072     */
2073    public static function seed_interactivity_state() {
2074        if ( ! function_exists( 'wp_interactivity_state' ) ) {
2075            return;
2076        }
2077        wp_interactivity_state(
2078            'jetpack-search',
2079            static::build_seed_state( static::collect_filter_configs_from_post() )
2080        );
2081    }
2082
2083    /**
2084     * Compose the final seeded state for `wp_interactivity_state()`.
2085     *
2086     * @param array<string, array<string, mixed>> $filter_configs Map of filter configs.
2087     * @return array<string, mixed>
2088     */
2089    public static function build_seed_state( array $filter_configs ): array {
2090        $state                  = static::build_initial_state();
2091        $state['filterConfigs'] = $filter_configs;
2092        return $state;
2093    }
2094
2095    /**
2096     * Walk the current post's block tree for filter blocks and build the
2097     * filterConfigs map. Template-part scans are not performed — a filter
2098     * inside a template part still works, but its config isn't available to
2099     * the search-results SSR until hydration.
2100     *
2101     * @return array<string, array<string, mixed>>
2102     */
2103    protected static function collect_filter_configs_from_post(): array {
2104        if ( ! function_exists( 'get_post' ) || ! function_exists( 'parse_blocks' ) ) {
2105            return array();
2106        }
2107        // Bail if any helper is missing — half-loaded feature would ship inconsistent filterConfigs.
2108        $helpers = static::filter_block_helpers();
2109        foreach ( $helpers as $helper ) {
2110            if ( ! class_exists( $helper ) ) {
2111                return array();
2112            }
2113        }
2114        $post = get_post();
2115        if ( ! $post || empty( $post->post_content ) ) {
2116            return array();
2117        }
2118        if ( ! static::post_content_has_filter_block( $post, array_keys( $helpers ) ) ) {
2119            return array();
2120        }
2121        $configs = array();
2122        static::walk_blocks_for_filter_configs( parse_blocks( $post->post_content ), $configs );
2123        return $configs;
2124    }
2125
2126    /**
2127     * Does the post contain any of the given block names? SEARCH-295: a
2128     * has_block() scan to gate parse_blocks() on large, filter-less posts.
2129     *
2130     * @param \WP_Post $post        Post to scan.
2131     * @param string[] $block_names Block names to scan for.
2132     * @return bool
2133     */
2134    protected static function post_content_has_filter_block( \WP_Post $post, array $block_names ): bool {
2135        foreach ( $block_names as $block_name ) {
2136            if ( has_block( $block_name, $post ) ) {
2137                return true;
2138            }
2139        }
2140        return false;
2141    }
2142
2143    /**
2144     * Map of filter block name → helper class. Add a new filter block type
2145     * by appending one entry here.
2146     *
2147     * @return array<string, class-string>
2148     */
2149    protected static function filter_block_helpers(): array {
2150        $helpers = array(
2151            'jetpack-search/filter-checkbox'        => Filter_Checkbox::class,
2152            'jetpack-search/filter-date'            => Filter_Date::class,
2153            'jetpack-search/filter-wc-rating'       => Filter_Wc_Rating::class,
2154            'jetpack-search/filter-wc-attribute'    => Filter_Wc_Attribute::class,
2155            'jetpack-search/filter-wc-stock-status' => Search_Product_Filter_Status::class,
2156        );
2157        if ( self::woocommerce_blocks_enabled() ) {
2158            return $helpers;
2159        }
2160        // Non-Woo sites: drop WC-only entries so the filter-config walk stays
2161        // symmetric with what `register_blocks()` actually registered.
2162        foreach ( array_keys( $helpers ) as $name ) {
2163            if ( self::is_woocommerce_only_block( $name ) ) {
2164                unset( $helpers[ $name ] );
2165            }
2166        }
2167        return $helpers;
2168    }
2169
2170    /**
2171     * Recursively walk a parsed block tree, pushing each filter block's
2172     * config into `$configs` by reference.
2173     *
2174     * @param array $blocks  Parsed block tree from parse_blocks().
2175     * @param array $configs Accumulator map keyed by filterKey.
2176     * @return void
2177     */
2178    protected static function walk_blocks_for_filter_configs( array $blocks, array &$configs ): void {
2179        $helpers = static::filter_block_helpers();
2180        foreach ( $blocks as $block ) {
2181            if ( ! is_array( $block ) ) {
2182                continue;
2183            }
2184            $block_name = (string) ( $block['blockName'] ?? '' );
2185            if ( isset( $helpers[ $block_name ] ) ) {
2186                $helper = $helpers[ $block_name ];
2187                $attrs  = (array) ( $block['attrs'] ?? array() );
2188                $key    = $helper::derive_filter_key( $attrs );
2189                if ( '' !== $key ) {
2190                    $configs[ $key ] = $helper::build_config( $attrs, $key );
2191                }
2192            }
2193
2194            if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) {
2195                static::walk_blocks_for_filter_configs( $block['innerBlocks'], $configs );
2196            }
2197        }
2198    }
2199
2200    /**
2201     * Build the initial state array for the jetpack-search Interactivity API store.
2202     *
2203     * @return array<string, mixed>
2204     */
2205    public static function build_initial_state() {
2206        $is_private                = class_exists( Status::class ) ? ( new Status() )->is_private_site() : false;
2207        $is_wpcom                  = class_exists( Helper::class ) ? Helper::is_wpcom() : false;
2208        $site_id                   = class_exists( Helper::class ) ? Helper::get_wpcom_site_id() : 0;
2209        $is_jetpack_photon_enabled = method_exists( 'Jetpack', 'is_module_active' ) && \Jetpack::is_module_active( 'photon' );
2210        $search_query              = static::parse_url_search_query();
2211        $active_filters            = static::parse_url_filters();
2212        $filter_logic              = static::parse_url_filter_logic( $active_filters );
2213        $price_range               = static::parse_url_price_range();
2214        $is_initial_loading        = static::is_initial_loading();
2215        $searching_text            = function_exists( '__' ) ? __( 'Searching…', 'jetpack-search-pkg' ) : 'Searching…';
2216        $query_options             = static::get_instant_search_query_options();
2217
2218        return array(
2219            // Connection / routing config.
2220            'siteId'                     => $site_id,
2221            'apiRoot'                    => function_exists( 'rest_url' ) ? esc_url_raw( rest_url() ) : '',
2222            'nonce'                      => function_exists( 'wp_create_nonce' ) ? wp_create_nonce( 'wp_rest' ) : '',
2223            'isPrivateSite'              => $is_private,
2224            'isWpcom'                    => $is_wpcom,
2225            'isPhotonEnabled'            => ( $is_wpcom || $is_jetpack_photon_enabled ) && ! $is_private,
2226            // TrainTracks gate, mirroring instant search's `disableTracking`
2227            // (Helper::get_search_options): suppresses `_tkq` pushes for
2228            // `?disable_tracking=1` crawlers/QA and the filter override.
2229            'disableTracking'            => static::is_tracking_disabled(),
2230            // Threaded through url-state so `?orderby=price_asc` round-trips on Woo only.
2231            'isWooCommerceBlocksEnabled' => self::woocommerce_blocks_enabled(),
2232            'homeUrl'                    => function_exists( 'home_url' ) ? home_url() : '',
2233            // Blog locale (not viewer's profile locale) for consistent
2234            // logged-out formatting. BCP47-ish (`en-US`).
2235            'locale'                     => function_exists( 'get_locale' )
2236                ? str_replace( '_', '-', get_locale() )
2237                : 'en-US',
2238            // PHP-style token string parsed client-side by `wp-date-format.js`
2239            // (IA view bundle can't import `@wordpress/date`). Empty → Intl fallback.
2240            'dateFormat'                 => function_exists( 'get_option' )
2241                ? (string) get_option( 'date_format', '' )
2242                : '',
2243
2244            // URL-seeded so deep links render on first paint.
2245            'searchQuery'                => $search_query,
2246            // `?s=` (empty value) must still fire the initial fetch; `searchQuery`
2247            // alone collapses present-but-empty and missing to `''`.
2248            'hasSearchParam'             => static::has_search_param(),
2249            'searchParamName'            => static::get_search_param_name(),
2250            'sortOrder'                  => static::parse_url_sort(),
2251            'activeFilters'              => $active_filters,
2252            'filterLogic'                => $filter_logic,
2253            'priceRange'                 => $price_range,
2254            // Scalar `?filter_id=value`; seeded as `{}` so JS readers see a defined shape.
2255            'staticFilterSelections'     => (object) array(),
2256
2257            // Each filter block's render.php deep-merges its entry. Shape:
2258            // `{ [key]: { filterKey, filterType, taxonomy, effectiveSlug, label, showCount, maxItems } }`.
2259            'filterConfigs'              => array(),
2260
2261            // JS hydration fills these. `aggregations` is stdClass so JS sees `{}`.
2262            'results'                    => array(),
2263            'aggregations'               => (object) array(),
2264            // See AGENTS.md § Filter bucket lifecycle.
2265            'retainedFilterOptions'      => (object) array(),
2266            'totalResults'               => 0,
2267            'pageHandle'                 => null,
2268
2269            // `isLoading` true on deep links keeps the empty-state hidden until
2270            // JS fires the initial fetch (otherwise "No results found" flashes).
2271            'isLoading'                  => $is_initial_loading,
2272            'isLoadingMore'              => false,
2273            'hasError'                   => false,
2274
2275            // Seeded so SSR resolves `data-wp-text` on first paint.
2276            'resultsCountText'           => $is_initial_loading ? $searching_text : '',
2277
2278            'strings'                    => static::build_initial_strings(),
2279            'priceCurrencySymbol'        => '$',
2280
2281            // Top-level (not under `strings`) — keeps Phan's `array<string,string>`
2282            // contract on `strings` intact.
2283            'aiExtendedLoadingHints'     => static::build_ai_extended_loading_hints(),
2284
2285            'wcStockStatusLabels'        => static::build_stock_status_labels(),
2286
2287            // Query customization from `jetpack_instant_search_options` — same
2288            // keys Instant Search / Inline Search honor, so Embedded and the
2289            // blocks Overlay stay compatible with those filters.
2290            'highlightPhraseOnly'        => $query_options['highlightPhraseOnly'],
2291            'highlightFilterStopwords'   => $query_options['highlightFilterStopwords'],
2292            'highlightFields'            => $query_options['highlightFields'],
2293            'additionalBlogIds'          => $query_options['additionalBlogIds'],
2294            'adminQueryFilter'           => $query_options['adminQueryFilter'],
2295            'customResults'              => $query_options['customResults'],
2296        );
2297    }
2298
2299    /**
2300     * Read Instant Search query-customization options for the blocks store.
2301     *
2302     * @since 7.4.0
2303     *
2304     * @return array Query options with keys:
2305     *               `highlightPhraseOnly`, `highlightFilterStopwords`, `highlightFields`,
2306     *               `additionalBlogIds`, `adminQueryFilter`, and `customResults`.
2307     */
2308    public static function get_instant_search_query_options(): array {
2309        return Helper::get_instant_search_query_options();
2310    }
2311
2312    /**
2313     * Slug → display-label map for `wc_stock_status` selections, used by the
2314     * active-filters block for product-aware chips. RSM-1932 will swap this
2315     * for WC's translated labels (`wc_get_product_stock_status_options()`)
2316     * without changing the shape. Empty when the status helper isn't loaded.
2317     *
2318     * @return array<string, string>
2319     */
2320    protected static function build_stock_status_labels(): array {
2321        if ( ! class_exists( Search_Product_Filter_Status::class ) ) {
2322            return array();
2323        }
2324        $labels = array();
2325        foreach ( Search_Product_Filter_Status::get_options() as $option ) {
2326            $value = (string) ( $option['value'] ?? '' );
2327            if ( '' === $value ) {
2328                continue;
2329            }
2330            $labels[ $value ] = (string) ( $option['label'] ?? $value );
2331        }
2332        return $labels;
2333    }
2334
2335    /**
2336     * Every block directory to register, parents first then their children.
2337     *
2338     * A block directory may nest child blocks that only ever render inside it
2339     * (`no-results/slot`). They live there rather than beside their parent so
2340     * the relationship is obvious in the tree, and they inherit the parent's
2341     * WooCommerce gating for free — a skipped parent is never descended into.
2342     *
2343     * @internal Public only so the registration walk can be asserted directly.
2344     *
2345     * @return string[] Absolute directory paths, each holding a `block.json`.
2346     */
2347    public static function block_directories(): array {
2348        $block_dirs = glob( __DIR__ . '/blocks/*', GLOB_ONLYDIR );
2349        if ( ! $block_dirs ) {
2350            return array();
2351        }
2352
2353        $wc_blocks_enabled = self::woocommerce_blocks_enabled();
2354        $directories       = array();
2355        foreach ( $block_dirs as $block_dir ) {
2356            if ( ! file_exists( $block_dir . '/block.json' ) ) {
2357                continue;
2358            }
2359            if ( ! $wc_blocks_enabled && self::is_woocommerce_only_block( basename( $block_dir ) ) ) {
2360                continue;
2361            }
2362            $directories[] = $block_dir;
2363
2364            foreach ( (array) glob( $block_dir . '/*', GLOB_ONLYDIR ) as $child_dir ) {
2365                if ( file_exists( $child_dir . '/block.json' ) ) {
2366                    $directories[] = $child_dir;
2367                }
2368            }
2369        }
2370
2371        return $directories;
2372    }
2373
2374    /**
2375     * Whether the URL carries a search query, filter, or price range — i.e.
2376     * the JS store will fire an initial fetch on hydration. Render callbacks
2377     * use this to emit pre-hydration affordances (skeleton, "Searching…").
2378     *
2379     * URL-derived rather than read back from `wp_interactivity_state()`
2380     * because FSE pre-resolves block attributes before `wp_enqueue_scripts`
2381     * fires, so a state-read would silently return false on the very pages
2382     * this is meant to flag. Mirrors the `isLoading` seed exactly.
2383     *
2384     * @return bool
2385     */
2386    public static function is_initial_loading(): bool {
2387        if ( null !== self::$is_initial_loading_cache ) {
2388            return self::$is_initial_loading_cache;
2389        }
2390        // `has_search_param()` not `parse_url_search_query() !== ''` — an
2391        // explicit `?s=` (empty value) still means "visitor landed on a
2392        // search page" and should fire an unfiltered initial fetch.
2393        if ( static::has_search_param() ) {
2394            self::$is_initial_loading_cache = true;
2395            return true;
2396        }
2397        if ( ! empty( static::parse_url_filters() ) ) {
2398            self::$is_initial_loading_cache = true;
2399            return true;
2400        }
2401        self::$is_initial_loading_cache = null !== static::parse_url_price_range();
2402        return self::$is_initial_loading_cache;
2403    }
2404
2405    /**
2406     * Reset the `is_initial_loading()` memo. Tests only — PHPUnit reuses a
2407     * single process so `$_GET` from an earlier test would pin the value.
2408     * Guarded against accidental production use.
2409     */
2410    public static function reset_initial_loading_cache(): void {
2411        if ( defined( 'ABSPATH' ) && ! defined( 'PHPUNIT_COMPOSER_INSTALL' ) ) {
2412            return;
2413        }
2414        self::$is_initial_loading_cache = null;
2415    }
2416
2417    /**
2418     * Pre-hydration view state for a filter block's wrapper. Centralizes the
2419     * seeded-state read shared by filter-checkbox and filter-date so each
2420     * render.php branches on a single struct rather than re-deriving the
2421     * same flags inline.
2422     *
2423     * @param string $filter_key The filter key (e.g. `category`, `post_type`).
2424     * @return array{has_buckets:bool,is_initial_loading:bool,show_wrapper:bool}
2425     */
2426    public static function pre_hydration_filter_view( string $filter_key ): array {
2427        if ( ! function_exists( 'wp_interactivity_state' ) ) {
2428            return array(
2429                'has_buckets'        => false,
2430                'is_initial_loading' => false,
2431                'show_wrapper'       => false,
2432            );
2433        }
2434        // `aggregations` is seeded as `stdClass` when empty (so JS sees `{}`,
2435        // not `[]`); cast before subscripting so the read works in either shape.
2436        $state              = wp_interactivity_state( 'jetpack-search' );
2437        $aggs               = (array) ( $state['aggregations'] ?? array() );
2438        $has_buckets        = ! empty( $aggs[ $filter_key ]['buckets'] ?? array() );
2439        $is_initial_loading = static::is_initial_loading();
2440        return array(
2441            'has_buckets'        => $has_buckets,
2442            'is_initial_loading' => $is_initial_loading,
2443            'show_wrapper'       => $has_buckets || $is_initial_loading,
2444        );
2445    }
2446
2447    /**
2448     * Emit the `data-wp-context` attribute for a filter block's wrapper. The
2449     * seeded `wrapperHidden` value is what the IA SSR pass evaluates
2450     * `data-wp-bind--hidden="context.wrapperHidden"` against, and what the
2451     * `syncFilterWrapperVisibility` callback updates after hydration.
2452     *
2453     * @param string $filter_key   The filter key.
2454     * @param bool   $show_wrapper Whether the wrapper should be visible on first paint.
2455     */
2456    public static function emit_filter_wrapper_context( string $filter_key, bool $show_wrapper ): void {
2457        if ( ! function_exists( 'wp_interactivity_data_wp_context' ) ) {
2458            return;
2459        }
2460        echo wp_kses_data(
2461            wp_interactivity_data_wp_context(
2462                array(
2463                    'filterKey'     => $filter_key,
2464                    'wrapperHidden' => ! $show_wrapper,
2465                )
2466            )
2467        );
2468    }
2469
2470    /**
2471     * Normalize the shared `displayStyle` attribute to one of the two CSS
2472     * variants. `filter-wc-stock-status` and `filter-wc-rating` deliberately
2473     * don't ship a chip variant and don't call this helper.
2474     *
2475     * @param mixed $value Raw attribute value.
2476     * @return string Either 'checkbox-list' or 'chips'.
2477     */
2478    public static function normalize_display_style( $value ): string {
2479        return 'chips' === $value ? 'chips' : 'checkbox-list';
2480    }
2481
2482    /**
2483     * Seed translated view-bundle strings for the Interactivity API store.
2484     *
2485     * @return array<string, string>
2486     */
2487    protected static function build_initial_strings(): array {
2488        if ( ! function_exists( '__' ) || ! function_exists( '_n' ) ) {
2489            return array(
2490                'searching'               => 'Searching…',
2491                'resultsCountSingle'      => 'Found %d result',
2492                'resultsCountPlural'      => 'Found %d results',
2493                'removeFilter'            => 'Remove %s',
2494                'ratingStarsTop'          => '5 stars',
2495                'ratingStarsAndUpSingle'  => '%d star and up',
2496                'ratingStarsAndUpPlural'  => '%d stars and up',
2497                'priceRangeFromTo'        => '%1$s – %2$s',
2498                'priceRangeFrom'          => '%s+',
2499                'priceRangeUpTo'          => 'Under %s',
2500                'priceLabel'              => 'Price',
2501                'suggestionLabelQuery'    => 'Suggestions',
2502                'suggestionLabelTaxonomy' => 'Popular Filters',
2503                'suggestionLabelPost'     => 'Articles',
2504                'aiErrorMessage'          => 'Sorry, an error occurred while generating an answer.',
2505                'aiErrorCode'             => 'Error code: %s',
2506            );
2507        }
2508        return array(
2509            'searching'               => __( 'Searching…', 'jetpack-search-pkg' ),
2510            /* translators: %d: number of results. */
2511            'resultsCountSingle'      => _n( 'Found %d result', 'Found %d results', 1, 'jetpack-search-pkg' ),
2512            /* translators: %d: number of results. */
2513            'resultsCountPlural'      => _n( 'Found %d result', 'Found %d results', 2, 'jetpack-search-pkg' ),
2514            /* translators: %s: filter label (e.g. "Category: News"). Announced by screen readers when focus lands on a filter pill's remove button. */
2515            'removeFilter'            => __( 'Remove %s', 'jetpack-search-pkg' ),
2516            /* 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. */
2517            'ratingStarsTop'          => __( '5 stars', 'jetpack-search-pkg' ),
2518            /* 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. */
2519            'ratingStarsAndUpSingle'  => _n( '%d star and up', '%d stars and up', 1, 'jetpack-search-pkg' ),
2520            /* 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. */
2521            'ratingStarsAndUpPlural'  => _n( '%d star and up', '%d stars and up', 2, 'jetpack-search-pkg' ),
2522            /* 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. */
2523            'priceRangeFromTo'        => __( '%1$s – %2$s', 'jetpack-search-pkg' ),
2524            /* 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. */
2525            'priceRangeFrom'          => __( '%s+', 'jetpack-search-pkg' ),
2526            /* 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. */
2527            'priceRangeUpTo'          => __( 'Under %s', 'jetpack-search-pkg' ),
2528            /* 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. */
2529            'priceLabel'              => __( 'Price', 'jetpack-search-pkg' ),
2530            /* translators: Group label for the typed-query suggestions section of the Search Input autocomplete dropdown. */
2531            'suggestionLabelQuery'    => __( 'Suggestions', 'jetpack-search-pkg' ),
2532            /* translators: Group label for the taxonomy (category / tag) section of the Search Input autocomplete dropdown. */
2533            'suggestionLabelTaxonomy' => __( 'Popular Filters', 'jetpack-search-pkg' ),
2534            /* translators: Group label for the post-title section of the Search Input autocomplete dropdown. */
2535            'suggestionLabelPost'     => __( 'Articles', 'jetpack-search-pkg' ),
2536            /* translators: Heading shown on the AI Answer panel when the agent endpoint returns an error. The technical message + HTTP/JSON-RPC code render below this string. */
2537            'aiErrorMessage'          => __( 'Sorry, an error occurred while generating an answer.', 'jetpack-search-pkg' ),
2538            /* translators: %s: numeric error code. Surfaces the HTTP / JSON-RPC code that came back with the AI Answer failure, under the technical message. */
2539            'aiErrorCode'             => __( 'Error code: %s', 'jetpack-search-pkg' ),
2540        );
2541    }
2542
2543    /**
2544     * Rotating loading hints for the "Show more" extended AI answer.
2545     * Mirrors the overlay verbatim so visitors switching surfaces see
2546     * the same copy.
2547     *
2548     * @return array<int, string>
2549     */
2550    protected static function build_ai_extended_loading_hints(): array {
2551        // Strings omit trailing `…` — render.php appends an animated ellipsis,
2552        // so a static one would double up. Overlay does the same.
2553        if ( ! function_exists( '__' ) ) {
2554            return array(
2555                'Searching harder',
2556                'Looking deeper into this',
2557                'Finding a more complete answer',
2558                'Analyzing additional sources',
2559                'Gathering more details',
2560                'Pulling in more context',
2561                'Expanding the search',
2562                'Rolling up my virtual sleeves',
2563                'Digging through the archives',
2564                'Putting on my reading glasses',
2565                'Checking under the digital couch cushions',
2566                'Consulting the oracle',
2567                'Asking a smarter algorithm',
2568                'Brewing a fresh batch of insights',
2569                'Unleashing the full power of search',
2570            );
2571        }
2572        return array(
2573            __( 'Searching harder', 'jetpack-search-pkg' ),
2574            __( 'Looking deeper into this', 'jetpack-search-pkg' ),
2575            __( 'Finding a more complete answer', 'jetpack-search-pkg' ),
2576            __( 'Analyzing additional sources', 'jetpack-search-pkg' ),
2577            __( 'Gathering more details', 'jetpack-search-pkg' ),
2578            __( 'Pulling in more context', 'jetpack-search-pkg' ),
2579            __( 'Expanding the search', 'jetpack-search-pkg' ),
2580            __( 'Rolling up my virtual sleeves', 'jetpack-search-pkg' ),
2581            __( 'Digging through the archives', 'jetpack-search-pkg' ),
2582            __( 'Putting on my reading glasses', 'jetpack-search-pkg' ),
2583            __( 'Checking under the digital couch cushions', 'jetpack-search-pkg' ),
2584            __( 'Consulting the oracle', 'jetpack-search-pkg' ),
2585            __( 'Asking a smarter algorithm', 'jetpack-search-pkg' ),
2586            __( 'Brewing a fresh batch of insights', 'jetpack-search-pkg' ),
2587            __( 'Unleashing the full power of search', 'jetpack-search-pkg' ),
2588        );
2589    }
2590
2591    /**
2592     * Parse the search query from the URL using whichever key
2593     * `get_search_param_name()` returns (`s` on search routes, `q` elsewhere).
2594     * Public so render templates can seed their input from the same source.
2595     *
2596     * @return string
2597     */
2598    public static function parse_url_search_query(): string {
2599        $key = self::get_search_param_name();
2600        // 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.
2601        $raw = $_GET[ $key ] ?? '';
2602        if ( ! is_scalar( $raw ) ) {
2603            return '';
2604        }
2605        return trim( sanitize_text_field( wp_unslash( (string) $raw ) ) );
2606    }
2607
2608    /**
2609     * Whether the search-query key is present in `$_GET` (any value).
2610     * Distinguishes `?s=` (blank search) from a URL that omits the key —
2611     * `parse_url_search_query()` collapses both to `''`. Array-shaped
2612     * `?s[]=foo` reads as "not present" to stay in lockstep.
2613     *
2614     * @return bool
2615     */
2616    public static function has_search_param(): bool {
2617        $key = self::get_search_param_name();
2618        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only URL presence check; the value is never read here.
2619        return isset( $_GET[ $key ] ) && is_scalar( $_GET[ $key ] );
2620    }
2621
2622    /**
2623     * Parse the sort order from the URL, defaulting to 'relevance'. Allowed
2624     * values track `Results_Sort::get_all_option_keys()` — on non-Woo sites
2625     * a `?orderby=price_asc` deep link collapses to `relevance` (mirrors
2626     * `store/url-state.js`).
2627     *
2628     * @return string
2629     */
2630    protected static function parse_url_sort(): string {
2631        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only URL state.
2632        $orderby = isset( $_GET['orderby'] ) ? sanitize_key( wp_unslash( $_GET['orderby'] ) ) : '';
2633        $allowed = array_values(
2634            array_filter(
2635                Results_Sort::get_all_option_keys(),
2636                static function ( $key ) {
2637                    return 'relevance' !== $key;
2638                }
2639            )
2640        );
2641        return in_array( $orderby, $allowed, true ) ? $orderby : 'relevance';
2642    }
2643
2644    /**
2645     * Parse the price range from the URL. Mirrors `store/url-state.js`.
2646     * Either bound may be null for a half-open range; non-numeric or
2647     * negative values null out. Returns null entirely on non-Woo sites —
2648     * `min_price`/`max_price` are WC-only and a stray param shouldn't drive
2649     * the API into a `range` clause for a field the index doesn't have.
2650     *
2651     * @return array{min: float|null, max: float|null}|null
2652     */
2653    protected static function parse_url_price_range(): ?array {
2654        if ( ! self::woocommerce_blocks_enabled() ) {
2655            return null;
2656        }
2657        // phpcs:disable WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- coerced to float in parse_price_bound().
2658        $min = self::parse_price_bound( $_GET['min_price'] ?? null );
2659        $max = self::parse_price_bound( $_GET['max_price'] ?? null );
2660        // phpcs:enable
2661
2662        if ( null === $min && null === $max ) {
2663            return null;
2664        }
2665        // Inverted bounds → empty ES `range` clause / zero results silently.
2666        // Treat as garbage and bail so the page falls back to unfiltered search.
2667        if ( null !== $min && null !== $max && $min > $max ) {
2668            return null;
2669        }
2670        return array(
2671            'min' => $min,
2672            'max' => $max,
2673        );
2674    }
2675
2676    /**
2677     * Coerce a single price-range URL value into a finite, non-negative float.
2678     *
2679     * @param mixed $raw Raw value pulled from $_GET.
2680     * @return float|null
2681     */
2682    private static function parse_price_bound( $raw ): ?float {
2683        if ( null === $raw || '' === $raw || ! is_scalar( $raw ) ) {
2684            return null;
2685        }
2686        // `is_numeric` keeps PHP in lockstep with JS's `Number()`: rejects
2687        // partially-numeric strings ("1.5.3") that `(float)` would silently
2688        // extract as `1.5` while `Number()` returns `NaN`.
2689        $raw = wp_unslash( $raw );
2690        if ( ! is_numeric( $raw ) ) {
2691            return null;
2692        }
2693        $num = (float) $raw;
2694        if ( ! is_finite( $num ) || $num < 0 ) {
2695            return null;
2696        }
2697        return $num;
2698    }
2699
2700    /**
2701     * Parse `?<filterKey>[]=<value>` URL params into `{ [filterKey]: string[] }`.
2702     * Mirrors the shape `store/url-state.js` writes (see AGENTS.md § URL format).
2703     * No registered-key filtering here — `filterConfigs` aren't available until
2704     * blocks render. The JS layer gates on hydration.
2705     *
2706     * Scalar `?post_type=<slug>` is also accepted as a shortcut for
2707     * `?post_types[]=<slug>` — matches WP/WC's own URL convention. Merged into
2708     * any existing array selections so `?post_type=foo&post_types[]=bar` reads
2709     * as `[foo, bar]`. Singular-form-on-an-array-key keeps its existing
2710     * "ignored noise" behaviour for every other filter.
2711     *
2712     * @return array<string, string[]>
2713     */
2714    protected static function parse_url_filters(): array {
2715        // phpcs:ignore WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- read-only URL state; sanitized per-value below.
2716        $raw = wp_unslash( $_GET );
2717        if ( ! is_array( $raw ) ) {
2718            return array();
2719        }
2720
2721        $out = array();
2722        foreach ( $raw as $key => $values ) {
2723            $filter_key = sanitize_key( (string) $key );
2724            if ( '' === $filter_key || in_array( $filter_key, self::RESERVED_QUERY_PARAMS, true ) ) {
2725                continue;
2726            }
2727            if ( 'post_type' === $filter_key ) {
2728                // `is_string` (not `is_scalar`) keeps the gate consistent with
2729                // `parse_url_filter_logic`'s value check — `$_GET` only ever
2730                // carries strings or arrays, and the array case takes the
2731                // `is_array( $values )` branch immediately below.
2732                if ( ! is_string( $values ) ) {
2733                    continue;
2734                }
2735                // `sanitize_key`, not `sanitize_text_field` — post-type slugs are
2736                // always lowercase + `[a-z0-9_-]`; the lowercase pass keeps a
2737                // `?post_type=Product` URL from reaching ES with the wrong case
2738                // and silently returning zero results.
2739                $slug = sanitize_key( $values );
2740                if ( '' === $slug ) {
2741                    continue;
2742                }
2743                $existing          = $out['post_types'] ?? array();
2744                $out['post_types'] = array_values( array_unique( array_merge( $existing, array( $slug ) ) ) );
2745                continue;
2746            }
2747            if ( ! is_array( $values ) ) {
2748                continue;
2749            }
2750            $clean = array_values(
2751                array_filter(
2752                    array_map( 'sanitize_text_field', $values ),
2753                    static function ( $v ) {
2754                        return '' !== $v;
2755                    }
2756                )
2757            );
2758            if ( $clean ) {
2759                $existing           = $out[ $filter_key ] ?? array();
2760                $out[ $filter_key ] = array_values( array_unique( array_merge( $existing, $clean ) ) );
2761            }
2762        }
2763        return $out;
2764    }
2765
2766    /**
2767     * Parse `?query_type_<key>=and` overrides into `{ [filterKey]: 'and' }`.
2768     * Only literal `'and'` is honoured — anything else is dropped so it
2769     * can't round-trip back through `pushStateToUrl`. Mirrors
2770     * `store/url-state.js`.
2771     *
2772     * @param array<string, string[]> $active_filters Result of parse_url_filters().
2773     * @return array<string, string>
2774     */
2775    protected static function parse_url_filter_logic( array $active_filters ): array {
2776        // phpcs:ignore WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- read-only URL state; sanitized per-value below.
2777        $raw = wp_unslash( $_GET );
2778        if ( ! is_array( $raw ) ) {
2779            return array();
2780        }
2781
2782        $out = array();
2783        foreach ( $raw as $key => $value ) {
2784            if ( ! is_string( $key ) || 0 !== strpos( $key, 'query_type_' ) ) {
2785                continue;
2786            }
2787            if ( ! is_string( $value ) || 'and' !== $value ) {
2788                continue;
2789            }
2790            $filter_key = sanitize_key( substr( $key, strlen( 'query_type_' ) ) );
2791            if ( '' === $filter_key || in_array( $filter_key, self::RESERVED_QUERY_PARAMS, true ) ) {
2792                continue;
2793            }
2794            if ( empty( $active_filters[ $filter_key ] ) ) {
2795                continue;
2796            }
2797            $out[ $filter_key ] = 'and';
2798        }
2799        return $out;
2800    }
2801}