PluginProbe ʕ •ᴥ•ʔ
Jetpack – WP Security, Backup, Speed, & Growth / 16.1-beta
Jetpack – WP Security, Backup, Speed, & Growth v16.1-beta
16.2-a.3 16.1.2 16.2-a.1 16.1.1 16.1 16.1-beta 16.1-beta.2 16.1-beta.3 16.1-a.5 16.1-a.3 16.0.1 16.1-a.1 16.0 16.0-beta 16.0-a.7 16.0-a.5 15.9.1 16.0-a.3 16.0-a.1 15.9 15.9-beta 15.9-a.7 15.9-a.5 15.9-a.3 15.9-a.1 15.8 15.8-beta 15.8-a.7 15.8-a.5 5.2.5 5.3.4 5.4.4 5.5.5 5.6.5 5.7.5 5.8.4 5.9.4 6.0.4 6.1 6.1.1 6.1.2 6.1.3 6.1.4 6.1.5 6.2 6.2.1 6.2.2 6.2.3 6.2.4 6.2.5 6.3 6.3.1 6.3.2 6.3.3 6.3.4 6.3.5 6.3.6 6.3.7 6.4 6.4.1 6.4.2 6.4.3 6.4.4 6.4.5 6.4.6 6.5 6.5.1 6.5.2 6.5.3 6.5.4 6.6 6.6.1 6.6.2 6.6.3 6.6.4 6.6.5 6.7 6.7.1 6.7.2 6.7.3 6.7.4 6.8 6.8.1 6.8.2 6.8.3 6.8.4 6.8.5 6.9 6.9.1 6.9.2 6.9.3 6.9.4 7.0 7.0.1 7.0.2 7.0.3 7.0.4 7.0.5 7.1 7.1.1 7.1.2 7.1.3 7.1.4 7.1.5 7.2 7.2.1 7.2.1.1 7.2.2 7.2.3 7.2.4 7.2.5 7.3 7.3.0.1 7.3.1 7.3.1.1 7.3.2 7.3.3 7.3.4 7.3.5 7.4 7.4.1 7.4.2 7.4.3 7.4.4 7.4.5 7.5 7.5.0.1 7.5.1 7.5.2 7.5.3 7.5.4 7.5.5 7.5.6 7.5.7 7.6 7.6.1 7.6.2 7.6.3 7.6.4 7.7 7.7.1 7.7.2 7.7.3 7.7.4 7.7.5 7.7.6 7.8 7.8.1 7.8.2 7.8.3 7.8.4 7.9 7.9.1 7.9.2 7.9.3 7.9.4 8.0 8.0.1 8.0.2 8.0.3 8.1 8.1.1 8.1.2 8.1.3 8.1.4 8.2 8.2.0.1 8.2.1 8.2.2 8.2.3 8.2.4 8.2.5 8.2.6 8.3 8.3.1 8.3.2 8.3.3 8.4 8.4.1 8.4.2 8.4.3 8.4.4 8.4.5 8.5 8.5.1 8.5.2 8.5.3 8.6 8.6.1 8.6.2 8.6.3 8.6.4 8.7 8.7.0.1 8.7.1 8.7.2 8.7.3 8.7.4 8.8 8.8.1 8.8.2 8.8.3 8.8.4 8.8.5 8.9 8.9.1 8.9.2 8.9.3 8.9.4 9.0 9.0.1 9.0.2 9.0.3 9.0.4 9.0.5 9.1 9.1.1 9.1.2 9.1.3 9.2 9.2.1 9.2.2 9.2.3 9.2.4 9.3 9.3.1 9.3.2 9.3.3 9.3.4 9.3.5 9.4 9.4.1 9.4.2 9.4.3 9.4.4 9.5 9.5.1 9.5.2 9.5.3 9.5.4 9.5.5 9.6 9.6.1 9.6.2 9.6.3 9.6.4 9.7 9.7.1 9.7.2 15.7-beta.2 9.7.3 15.7.1 9.8 15.8-a.1 9.8.1 15.8-a.3 9.8.2 2.0.9 9.8.3 2.1.7 9.9 2.2.10 9.9.1 2.3.10 9.9.2 2.4.7 9.9.3 2.5.5 2.6.6 2.7.5 2.8.5 2.9.6 3.0.6 3.1.5 3.2.5 3.3.6 3.4.6 3.5.6 3.6.4 3.7.5 3.8.5 3.9.10 4.0.7 4.1.4 4.2.5 4.3.5 4.4.5 4.5.3 4.6.3 4.7.4 4.8.5 4.9.3 5.0.3 5.1.4 trunk 10.0 10.0.1 10.0.2 10.1 10.1.1 10.1.2 10.2 10.2.1 10.2.2 10.2.3 10.3 10.3.1 10.3.2 10.4 10.4.1 10.4.2 10.5 10.5.1 10.5.2 10.5.3 10.6 10.6.1 10.6.2 10.7 10.7.1 10.7.2 10.8 10.8.1 10.8.2 10.9 10.9.1 10.9.2 10.9.3 11.0 11.0.1 11.0.2 11.1 11.1.1 11.1.2 11.1.3 11.1.4 11.2 11.2.1 11.2.2 11.3 11.3.1 11.3.2 11.3.3 11.3.4 11.4 11.4.1 11.4.2 11.5 11.5.1 11.5.2 11.5.3 11.6 11.6.1 11.6.2 11.7 11.7.1 11.7.2 11.7.3 11.8 11.8.3 11.8.4 11.8.5 11.8.6 11.9 11.9.1 11.9.2 11.9.3 12.0 12.0.1 12.0.2 12.1 12.1.1 12.1.2 12.2 12.2.1 12.2.2 12.3 12.3.1 12.4 12.4.1 12.5 12.5.1 12.6 12.6.1 12.6.2 12.6.3 12.7 12.7.1 12.7.2 12.8 12.8.1 12.8.2 12.9 12.9.1 12.9.2 12.9.3 12.9.4 13.0 13.0.1 13.1 13.1.1 13.1.2 13.1.3 13.1.4 13.2 13.2.1 13.2.2 13.2.3 13.3 13.3.1 13.3.2 13.4 13.4.1 13.4.2 13.4.3 13.4.4 13.5 13.5.1 13.6 13.6.1 13.7 13.7.1 13.8 13.8.1 13.8.2 13.9 13.9.1 14.0 14.1 14.2 14.2.1 14.3 14.4 14.4.1 14.5 14.6 14.7 14.8 14.9 14.9.1 15.0 15.0.1 15.0.2 15.1 15.1.1 15.2 15.3 15.3.1 15.4 15.5 15.6 15.7 15.7-a.1 15.7-a.3 15.7-a.5 15.7-a.7 15.7-beta
jetpack / jetpack_vendor / automattic / jetpack-search / src / search-blocks / class-search-blocks.php
jetpack / jetpack_vendor / automattic / jetpack-search / src / search-blocks Last commit date
blocks 1 month ago patterns 2 months ago templates 2 months ago class-custom-taxonomy-slot-mapping.php 2 months ago class-filter-post-type.php 2 months ago class-overlay-template.php 2 months ago class-product-overlay-template.php 2 months ago class-product-search-template.php 2 months ago class-search-blocks.php 3 weeks ago class-search-template.php 2 months ago class-singleton-template-cpt.php 1 month ago class-theme-chrome-slug-resolver.php 3 months ago class-wc-block-helpers.php 2 months ago
class-search-blocks.php
2753 lines
1 <?php
2 /**
3 * Search Blocks: Interactivity API block registration and state initialization.
4 *
5 * @package automattic/jetpack-search
6 */
7
8 namespace Automattic\Jetpack\Search;
9
10 use Automattic\Jetpack\Status;
11
12 /**
13 * Registers Jetpack Search Interactivity API blocks and initializes their shared state.
14 */
15 class 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 $blocks_dir = __DIR__ . '/blocks';
735 $block_dirs = glob( $blocks_dir . '/*', GLOB_ONLYDIR );
736
737 if ( ! $block_dirs ) {
738 return;
739 }
740
741 $wc_blocks_enabled = self::woocommerce_blocks_enabled();
742 foreach ( $block_dirs as $block_dir ) {
743 if ( ! file_exists( $block_dir . '/block.json' ) ) {
744 continue;
745 }
746 if ( ! $wc_blocks_enabled && self::is_woocommerce_only_block( basename( $block_dir ) ) ) {
747 continue;
748 }
749 register_block_type( $block_dir );
750 }
751
752 add_filter( 'get_block_type_variations', array( static::class, 'inject_filter_checkbox_variations' ), 10, 2 );
753 static::register_patterns();
754 }
755
756 /**
757 * Register the shared store as the `jetpack-search/store` Script Module.
758 * See AGENTS.md § Shared store / bundles for why this is externalized.
759 */
760 public static function register_store_script_module() {
761 if ( ! function_exists( 'wp_register_script_module' ) ) {
762 return;
763 }
764
765 $base_path = Package::get_installed_path() . 'build/search-blocks/store/';
766 $asset_file = $base_path . 'index.asset.php';
767 if ( ! file_exists( $asset_file ) ) {
768 return;
769 }
770 $asset = require $asset_file;
771
772 wp_register_script_module(
773 'jetpack-search/store',
774 plugins_url( 'index.js', $base_path . 'index.js' ),
775 $asset['dependencies'] ?? array(),
776 $asset['version'] ?? false
777 );
778 }
779
780 /**
781 * Relativize Jetpack Search Script Module URLs so the browser fetches them
782 * same-origin with the page.
783 *
784 * `wp_register_script_module()` resolves src via `plugins_url()`, which
785 * returns the canonical `site_url()` host. When a visitor is on a
786 * different host (Multisite mapped domains, www vs non-www without a
787 * canonical redirect, asset-offload plugins, reverse-proxy staging) the
788 * `<script type="module">` becomes cross-origin and is blocked with
789 * `MissingAllowOriginHeader` — ES modules always go through the CORS
790 * algorithm, even without a `crossorigin` attribute, and typical WP
791 * hosts don't send `Access-Control-Allow-Origin` for `wp-content/*`.
792 *
793 * Stripping scheme + host with `wp_make_link_relative()` lets the browser
794 * resolve against the page's actual origin. No `$_SERVER['HTTP_HOST']`
795 * trust — emitting an attacker-controllable host into a `<script src>`
796 * would be a cache-poisoning vector.
797 *
798 * No-op when the src host is a deliberately external host (CDN that
799 * doesn't match `home_url()`/`site_url()`); operators of those setups
800 * configure CORS on the CDN themselves.
801 *
802 * Identifier gate covers both shapes Jetpack Search ships: directly-
803 * registered modules with a slash (`jetpack-search/store`,
804 * `jetpack-search/overlay-bootstrap`) and the per-block view modules
805 * WP auto-registers from `block.json`'s `viewScriptModule`, which run
806 * `generate_block_asset_handle()` and emit hyphen-joined IDs like
807 * `jetpack-search-results-list-view-script-module`.
808 *
809 * @param string $src Module src URL.
810 * @param string $identifier Module identifier (e.g. `jetpack-search/results-list`
811 * or `jetpack-search-results-list-view-script-module`).
812 * @return string Relativized src on match, original otherwise.
813 */
814 public static function same_origin_script_module_src( $src, $identifier ) {
815 if ( ! is_string( $src ) || '' === $src || ! is_string( $identifier ) ) {
816 return $src;
817 }
818 if ( 0 !== strpos( $identifier, 'jetpack-search/' ) && 0 !== strpos( $identifier, 'jetpack-search-' ) ) {
819 return $src;
820 }
821
822 $src_host = wp_parse_url( $src, PHP_URL_HOST );
823 if ( ! $src_host ) {
824 return $src;
825 }
826
827 $canonical_hosts = array_map(
828 'strtolower',
829 array_filter(
830 array(
831 wp_parse_url( home_url(), PHP_URL_HOST ),
832 wp_parse_url( site_url(), PHP_URL_HOST ),
833 )
834 )
835 );
836
837 if ( ! in_array( strtolower( $src_host ), $canonical_hosts, true ) ) {
838 return $src;
839 }
840
841 return wp_make_link_relative( $src );
842 }
843
844 /**
845 * Inject named block variations for the filter-checkbox block.
846 *
847 * Uses the `get_block_type_variations` filter (WP 6.5+) rather than
848 * `register_block_variation()` — the latter is JS-only and has no PHP
849 * equivalent. Variation names + default attributes mirror the
850 * instant-search overlay so both surfaces describe the same filters.
851 *
852 * @param array $variations Variations registered on the block type.
853 * @param \WP_Block_Type $block_type Block type the filter is being applied to.
854 * @return array
855 */
856 public static function inject_filter_checkbox_variations( $variations, $block_type ) {
857 if ( ! isset( $block_type->name ) || 'jetpack-search/filter-checkbox' !== $block_type->name ) {
858 return $variations;
859 }
860
861 $additions = array(
862 array(
863 'name' => 'category',
864 'title' => __( 'Filter by Category', 'jetpack-search-pkg' ),
865 'description' => __( 'Show category checkboxes with live result counts.', 'jetpack-search-pkg' ),
866 'attributes' => array(
867 'filterType' => 'taxonomy',
868 'taxonomy' => 'category',
869 'label' => __( 'Category', 'jetpack-search-pkg' ),
870 ),
871 'isActive' => array( 'filterType', 'taxonomy' ),
872 ),
873 array(
874 'name' => 'post_tag',
875 'title' => __( 'Filter by Tag', 'jetpack-search-pkg' ),
876 'description' => __( 'Show tag checkboxes with live result counts.', 'jetpack-search-pkg' ),
877 'attributes' => array(
878 'filterType' => 'taxonomy',
879 'taxonomy' => 'post_tag',
880 'label' => __( 'Tag', 'jetpack-search-pkg' ),
881 ),
882 'isActive' => array( 'filterType', 'taxonomy' ),
883 ),
884 array(
885 'name' => 'post_type',
886 'title' => __( 'Filter by Post Type', 'jetpack-search-pkg' ),
887 'description' => __( 'Show post type checkboxes with live result counts.', 'jetpack-search-pkg' ),
888 'attributes' => array(
889 'filterType' => 'post_type',
890 'label' => __( 'Post Type', 'jetpack-search-pkg' ),
891 ),
892 'isActive' => array( 'filterType' ),
893 ),
894 array(
895 'name' => 'author',
896 'title' => __( 'Filter by Author', 'jetpack-search-pkg' ),
897 'description' => __( 'Show author checkboxes with live result counts.', 'jetpack-search-pkg' ),
898 'attributes' => array(
899 'filterType' => 'author',
900 'label' => __( 'Author', 'jetpack-search-pkg' ),
901 ),
902 'isActive' => array( 'filterType' ),
903 ),
904 );
905
906 // WC-only product-taxonomy variations. `product_brand` gets an extra
907 // `taxonomy_exists()` probe — it isn't core WC, it ships via extensions
908 // (WC Brands, Perfect Brands) or recent bundled WC versions.
909 if ( self::woocommerce_blocks_enabled() ) {
910 $additions[] = array(
911 'name' => 'product_cat',
912 'title' => __( 'Filter by Product Category', 'jetpack-search-pkg' ),
913 'description' => __( 'Show product category checkboxes with live result counts.', 'jetpack-search-pkg' ),
914 'attributes' => array(
915 'filterType' => 'taxonomy',
916 'taxonomy' => 'product_cat',
917 'label' => __( 'Product Category', 'jetpack-search-pkg' ),
918 ),
919 'isActive' => array( 'filterType', 'taxonomy' ),
920 );
921 $additions[] = array(
922 'name' => 'product_tag',
923 'title' => __( 'Filter by Product Tag', 'jetpack-search-pkg' ),
924 'description' => __( 'Show product tag checkboxes with live result counts.', 'jetpack-search-pkg' ),
925 'attributes' => array(
926 'filterType' => 'taxonomy',
927 'taxonomy' => 'product_tag',
928 'label' => __( 'Product Tag', 'jetpack-search-pkg' ),
929 ),
930 'isActive' => array( 'filterType', 'taxonomy' ),
931 );
932 if ( taxonomy_exists( 'product_brand' ) ) {
933 $additions[] = array(
934 'name' => 'product_brand',
935 'title' => __( 'Filter by Product Brand', 'jetpack-search-pkg' ),
936 'description' => __( 'Show product brand checkboxes with live result counts.', 'jetpack-search-pkg' ),
937 'attributes' => array(
938 'filterType' => 'taxonomy',
939 'taxonomy' => 'product_brand',
940 'label' => __( 'Product Brand', 'jetpack-search-pkg' ),
941 ),
942 'isActive' => array( 'filterType', 'taxonomy' ),
943 );
944 }
945 }
946
947 $additions[] = array(
948 'name' => 'custom_taxonomy',
949 'title' => __( 'Filter by Custom Taxonomy', 'jetpack-search-pkg' ),
950 'description' => __( 'Show checkboxes for a custom taxonomy. Pick which taxonomy in the block settings after inserting.', 'jetpack-search-pkg' ),
951 'attributes' => array(
952 'filterType' => 'taxonomy',
953 'taxonomy' => '',
954 'label' => '',
955 ),
956 // Match on filterType only so identity survives the author picking a
957 // slug. The dedicated variations pin `taxonomy` in their isActive
958 // arrays, so WP's most-specific-match resolution still routes named
959 // slugs to those; Custom Taxonomy claims every other taxonomy.
960 'isActive' => array( 'filterType' ),
961 );
962
963 // Merge by `name` so an upstream variation (block.json or earlier filter)
964 // wins over our preset of the same name; plain `array_merge` would
965 // append duplicates and the inserter would render two cards.
966 $variations = (array) $variations;
967 $existing_keys = array_flip( array_column( $variations, 'name' ) );
968 foreach ( $additions as $variation ) {
969 if ( ! isset( $existing_keys[ $variation['name'] ] ) ) {
970 $variations[] = $variation;
971 }
972 }
973 return $variations;
974 }
975
976 /**
977 * Register block patterns. Files prefixed `wc-` compose WooCommerce-only
978 * blocks and load only when WC is active (mirrors `filter-wc-*` blocks).
979 */
980 protected static function register_patterns() {
981 $patterns_dir = __DIR__ . '/patterns';
982 if ( ! is_dir( $patterns_dir ) ) {
983 return;
984 }
985 $pattern_files = glob( $patterns_dir . '/*.php' );
986 if ( ! $pattern_files ) {
987 return;
988 }
989 $wc_blocks_enabled = self::woocommerce_blocks_enabled();
990 foreach ( $pattern_files as $pattern_file ) {
991 if ( ! $wc_blocks_enabled && 0 === strpos( basename( $pattern_file ), 'wc-' ) ) {
992 continue;
993 }
994 require_once $pattern_file;
995 }
996 }
997
998 /**
999 * Derive a block-pattern's content from a chrome-free layout template (the
1000 * overlay templates, which already ship without header/footer/main page
1001 * chrome), so patterns stay in sync with the template they mirror instead of
1002 * carrying a hand-copied second copy of the layout.
1003 *
1004 * @param string $template_file Template basename under `templates/`.
1005 * @return string Block markup ready for `register_block_pattern()`, or '' when unreadable.
1006 */
1007 public static function pattern_content_from_template( string $template_file ): string {
1008 $template_path = __DIR__ . '/templates/' . basename( $template_file );
1009 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- local, bundled template file.
1010 $raw = is_readable( $template_path ) ? (string) file_get_contents( $template_path ) : '';
1011 if ( '' === $raw ) {
1012 return '';
1013 }
1014 return trim( static::substitute_template_placeholders( $raw ) );
1015 }
1016
1017 /**
1018 * Build the full search page template content.
1019 *
1020 * Markup lives in `templates/jetpack-search.html` with a `{{FILTER_HEADING}}`
1021 * placeholder so the sidebar heading still goes through `esc_html__()`.
1022 *
1023 * @return string Block markup for a complete page template.
1024 */
1025 protected static function get_search_template_content(): string {
1026 $template_path = __DIR__ . '/templates/jetpack-search.html';
1027 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- local, bundled template file.
1028 $raw = is_readable( $template_path ) ? (string) file_get_contents( $template_path ) : '';
1029 return static::sync_filters_popover_content( static::substitute_template_placeholders( $raw ) );
1030 }
1031
1032 /**
1033 * Register the Jetpack Search page template so it surfaces in the Site
1034 * Editor and resolves via the template hierarchy. DB-stored customizations
1035 * still win automatically — the `custom` source beats `plugin`. Classic
1036 * themes are skipped: the registry is only consulted by block themes.
1037 */
1038 public static function register_search_template() {
1039 if ( ! function_exists( 'register_block_template' ) || ! static::block_templates_active() ) {
1040 return;
1041 }
1042 $content = static::get_search_template_content();
1043 // Bail on missing/unreadable file: our slug is prepended to the search
1044 // hierarchy, so registering empty content would render a blank page on
1045 // `/?s=…`. Falling through lets core resolve the theme's `search.html`.
1046 if ( '' === $content ) {
1047 return;
1048 }
1049 static::replace_block_template(
1050 static::get_parent_plugin_slug() . '//' . self::SEARCH_TEMPLATE_SLUG,
1051 array(
1052 'title' => __( 'Jetpack Search Results', 'jetpack-search-pkg' ),
1053 'description' => __( 'Displays search results with Jetpack Search filters.', 'jetpack-search-pkg' ),
1054 'content' => $content,
1055 )
1056 );
1057 }
1058
1059 /**
1060 * Whether the overlay should paint the WooCommerce product variant for the
1061 * current request. True only when the override option is on and the request
1062 * is a product search — mirrors the embedded/inline interception
1063 * (`is_woocommerce_product_search()` already folds in the WC probe). The
1064 * overlay opens client-side from any search box, so this only flips on the
1065 * server-rendered product-search request (deep link or product-archive
1066 * search), not on a live form intercept from a non-product page.
1067 *
1068 * @return bool
1069 */
1070 protected static function should_use_product_overlay(): bool {
1071 return static::woocommerce_search_template_override_enabled()
1072 && static::is_woocommerce_product_search();
1073 }
1074
1075 /**
1076 * Read the dedicated overlay-template markup.
1077 *
1078 * Distinct from `get_search_template_content()`: a modal isn't a page, so
1079 * the overlay markup ships without `header`/`main`/`footer` template-parts
1080 * rather than runtime-stripping them.
1081 *
1082 * Picks the product variant on a WooCommerce product search (see
1083 * `should_use_product_overlay()`). Source of truth, in order:
1084 * 1. Customized singleton CPT (`Overlay_Template` / `Product_Overlay_Template`).
1085 * 2. The bundled `jetpack-search-overlay{-product}.html`.
1086 *
1087 * @return string Block markup for the overlay body.
1088 */
1089 protected static function get_overlay_template_content(): string {
1090 $is_product = static::should_use_product_overlay();
1091 $key = $is_product ? 'product' : 'default';
1092 if ( isset( self::$overlay_template_content_cache[ $key ] ) ) {
1093 return self::$overlay_template_content_cache[ $key ];
1094 }
1095 $cpt_class = $is_product ? Product_Overlay_Template::class : Overlay_Template::class;
1096 $customized = $cpt_class::get_customized_content();
1097 if ( null !== $customized ) {
1098 self::$overlay_template_content_cache[ $key ] = $customized;
1099 return self::$overlay_template_content_cache[ $key ];
1100 }
1101 $file = $is_product ? 'jetpack-search-overlay-product.html' : 'jetpack-search-overlay.html';
1102 $template_path = __DIR__ . '/templates/' . $file;
1103 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- local, bundled template file; wp_remote_get() is for remote URLs.
1104 $raw = is_readable( $template_path ) ? (string) file_get_contents( $template_path ) : '';
1105 self::$overlay_template_content_cache[ $key ] = static::sync_filters_popover_content( $raw );
1106 return self::$overlay_template_content_cache[ $key ];
1107 }
1108
1109 /**
1110 * Reset the `get_overlay_template_content()` memo. Tests only — PHPUnit
1111 * reuses a single process, so a CPT-customized overlay saved in one test
1112 * would otherwise be pinned (or a bundled read would mask it) in the next.
1113 * Guarded against accidental production use.
1114 */
1115 public static function reset_overlay_template_content_cache(): void {
1116 if ( defined( 'ABSPATH' ) && ! defined( 'PHPUNIT_COMPOSER_INSTALL' ) ) {
1117 return;
1118 }
1119 self::$overlay_template_content_cache = array();
1120 }
1121
1122 /**
1123 * Echo the Search-blocks overlay shell into `wp_footer`. Block markup
1124 * carries `data-wp-interactive` so the IA's standard `DOMContentLoaded`
1125 * hydration picks it up — no client-side fetch needed. The caller (`init()`)
1126 * gates registration on `is_block_template_overlay_enabled()`.
1127 */
1128 public static function print_block_template_overlay() {
1129 $rendered = self::$block_template_overlay_rendered_html;
1130 if ( null === $rendered || '' === $rendered ) {
1131 return;
1132 }
1133 $config = wp_json_encode(
1134 array(
1135 'searchInputSelector' => 'input[name="s"]:not(.jetpack-search-input__field), #searchform input.search-field, .search-form input.search-field, .searchform input.search-field',
1136 'overlayTriggerSelector' => '.jetpack-search-block-overlay-trigger, .jetpack-instant-search__open-overlay-button, header#site-header .search-toggle[data-toggle-target]',
1137 ),
1138 JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP
1139 );
1140 ?>
1141 <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>
1142 <?php
1143 // `<template>` keeps the region out of `document.querySelectorAll` so
1144 // the IA runtime's DOMContentLoaded walk skips it. The bootstrap clones
1145 // into the shell on first open and hydrates there.
1146 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- do_blocks output.
1147 printf( '<template id="jetpack-search-block-overlay-template">%s</template>', $rendered );
1148 ?>
1149 <div
1150 id="jetpack-search-block-overlay"
1151 class="jetpack-search-block-overlay"
1152 role="dialog"
1153 aria-modal="true"
1154 aria-label="<?php echo esc_attr__( 'Search', 'jetpack-search-pkg' ); ?>"
1155 hidden
1156 >
1157 <div class="jetpack-search-block-overlay__card">
1158 <button
1159 type="button"
1160 class="jetpack-search-block-overlay__close"
1161 aria-label="<?php echo esc_attr__( 'Close search', 'jetpack-search-pkg' ); ?>"
1162 >
1163 <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
1164 <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" />
1165 </svg>
1166 </button>
1167 <div class="jetpack-search-block-overlay__content"></div>
1168 </div>
1169 </div>
1170 <?php
1171 }
1172
1173 /**
1174 * Register + enqueue the overlay-bootstrap Script Module that wires
1175 * theme-defined search triggers to the rendered shell. Inline-CSS for the
1176 * modal chrome. Config emits alongside the overlay HTML in
1177 * `print_block_template_overlay()`.
1178 */
1179 public static function enqueue_block_template_overlay_assets() {
1180 if ( ! function_exists( 'wp_register_script_module' ) ) {
1181 return;
1182 }
1183 $base_path = Package::get_installed_path() . 'build/search-blocks/overlay-bootstrap/';
1184 $asset_file = $base_path . 'index.asset.php';
1185 if ( ! file_exists( $asset_file ) ) {
1186 return;
1187 }
1188 $asset = require $asset_file;
1189 wp_register_script_module(
1190 'jetpack-search/overlay-bootstrap',
1191 plugins_url( 'index.js', $base_path . 'index.js' ),
1192 $asset['dependencies'] ?? array(),
1193 $asset['version'] ?? false
1194 );
1195 wp_enqueue_script_module( 'jetpack-search/overlay-bootstrap' );
1196
1197 wp_register_style( 'jetpack-search-block-overlay', false, array(), $asset['version'] ?? false );
1198 wp_enqueue_style( 'jetpack-search-block-overlay' );
1199 wp_add_inline_style( 'jetpack-search-block-overlay', static::block_template_overlay_inline_css() );
1200
1201 // Shared responsive layout CSS (narrow-width sidebar collapse +
1202 // in-header popover toggle). Same rules ship to the embedded /
1203 // WC-product page templates via `enqueue_search_page_assets()`.
1204 static::enqueue_search_layout_style();
1205
1206 // Render here (during `wp_enqueue_scripts`) so view-module enqueues
1207 // from `do_blocks()` land before the importmap prints — see
1208 // AGENTS.md § Hydration & SSR seeding.
1209 self::$block_template_overlay_rendered_html = trim(
1210 do_blocks( static::get_overlay_template_content() )
1211 );
1212 }
1213
1214 /**
1215 * Print the body-sampler `<script>` that sets `--jp-search-page-ink` /
1216 * `--jp-search-page-surface` on `:root` from the body's resolved `color` /
1217 * `backgroundColor`. Skips writing surface when bg is transparent (the
1218 * theme paints on the browser canvas) or when bg equals ink (vintage
1219 * frame-themes like Twenty Sixteen use body as a colored border around a
1220 * lighter `.site` content wrapper). See AGENTS.md § Theme tokens.
1221 *
1222 * The `wp_body_open` hook registers unconditionally in `init()`, before
1223 * the module-active check in `Initializer::init_search_blocks()` — so the
1224 * module gate lives here instead, front-end only.
1225 */
1226 public static function print_theme_token_sampler(): void {
1227 if ( is_admin() || ! ( new Module_Control() )->is_active() ) {
1228 return;
1229 }
1230 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>";
1231 }
1232
1233 /**
1234 * Inline CSS for the overlay modal chrome. Block content brings its own
1235 * theme styling; this is just the scrim, centered card, 60px header strip,
1236 * close button, mobile padding tweaks, and scroll lock. The responsive
1237 * sidebar-collapse + in-header popover rules shared with the page
1238 * templates live in `search_layout_inline_css()`.
1239 *
1240 * Surface/ink hoist `--jp-search-page-*` (with the legacy
1241 * `--wp--preset--color--*` chain as fallback — see AGENTS.md § Theme
1242 * tokens) onto two custom props so in-card surfaces share one source.
1243 * Hairlines use `color-mix(--jp-search-overlay-ink, --jp-search-overlay-surface)`.
1244 *
1245 * @return string
1246 */
1247 protected static function block_template_overlay_inline_css(): string {
1248 return <<<'CSS'
1249 .jetpack-search-block-overlay {
1250 position: fixed;
1251 inset: 0;
1252 z-index: 100000;
1253 display: flex;
1254 justify-content: center;
1255 align-items: flex-start;
1256 background: rgba(31, 31, 31, 0.7);
1257 overflow-y: auto;
1258 padding: 3em 1em;
1259 transition: opacity 0.1s ease-in;
1260 }
1261 .jetpack-search-block-overlay[hidden] {
1262 display: none;
1263 }
1264 @media (prefers-reduced-motion: reduce) {
1265 .jetpack-search-block-overlay {
1266 transition: none;
1267 }
1268 }
1269 .jetpack-search-block-overlay__card {
1270 position: relative;
1271 width: 100%;
1272 max-width: 1080px;
1273 --jp-search-overlay-surface: var(--jp-search-page-surface, var(--wp--preset--color--base, var(--wp--preset--color--background, #fff)));
1274 --jp-search-overlay-ink: var(--jp-search-page-ink, var(--wp--preset--color--contrast, var(--wp--preset--color--foreground, #1d2327)));
1275 /* Single source for the content group's inset. The corner-join in
1276 * search_layout_inline_css() zeroes the block-start/block-end of this
1277 * padding on the group and re-adds the same tokens on the columns, so the
1278 * sidebar hairline reaches both card edges — keep them as var()s so the
1279 * two sites can't drift. */
1280 --jp-search-overlay-content-pad-block-start: 0.5em;
1281 --jp-search-overlay-content-pad-inline: 2em;
1282 --jp-search-overlay-content-pad-block-end: 2em;
1283 background: var(--jp-search-overlay-surface);
1284 color: var(--jp-search-overlay-ink);
1285 border: 1px solid rgba(128, 128, 128, 0.25);
1286 border-radius: 4px;
1287 box-shadow: 0 8px 32px rgba(0, 0, 0, 0.18);
1288 padding-top: 60px;
1289 }
1290 /* Token-aware card / scrim separation (SEARCH-270): tint the resolved surface
1291 * ~5% toward ink and paint the hairline border with the same ink-over-surface
1292 * mix used for the header `::before`. Both auto-invert polarity per theme, so
1293 * dark themes get a card that visibly layers above the scrim without losing
1294 * the themed surface color. The static `rgba(128,128,128,.25)` border + the
1295 * un-tinted token chain stay as the fallback for browsers without `color-mix`. */
1296 @supports (background: color-mix(in sRGB, black 50%, white)) {
1297 .jetpack-search-block-overlay__card {
1298 --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))));
1299 border-color: color-mix(in sRGB, var(--jp-search-overlay-ink) 20%, var(--jp-search-overlay-surface));
1300 /* Ink-derived shadow inverts polarity per theme (SEARCH-289): a dark drop
1301 * shadow on light cards, a soft light halo on dark — a flat black shadow is
1302 * invisible against the dark scrim. Static black above is the fallback. */
1303 box-shadow: 0 8px 32px color-mix(in sRGB, var(--jp-search-overlay-ink) 22%, transparent);
1304 }
1305 }
1306 /* Single hairline over the full 60px header strip — siblings paint with a seam (SEARCH-260). */
1307 .jetpack-search-block-overlay__card::before {
1308 content: "";
1309 position: absolute;
1310 top: 60px;
1311 left: 0;
1312 right: 0;
1313 height: 1px;
1314 background: transparent;
1315 pointer-events: none;
1316 }
1317 @supports (background: color-mix(in sRGB, black 50%, white)) {
1318 .jetpack-search-block-overlay__card::before {
1319 background: color-mix(in sRGB, var(--jp-search-overlay-ink) 15%, var(--jp-search-overlay-surface));
1320 }
1321 }
1322 .jetpack-search-block-overlay__close {
1323 position: absolute;
1324 top: 0;
1325 right: 0;
1326 width: 60px;
1327 height: 60px;
1328 display: flex;
1329 align-items: center;
1330 justify-content: center;
1331 background: transparent;
1332 border: 0;
1333 cursor: pointer;
1334 color: inherit;
1335 }
1336 /* Opaque ink-over-surface mix — `color-mix(currentColor, transparent)` collapses to full-opacity ink on Safari <16.4 and swallows the X icon. */
1337 @supports (background: color-mix(in sRGB, black 50%, white)) {
1338 .jetpack-search-block-overlay__close:hover,
1339 .jetpack-search-block-overlay__close:focus-visible {
1340 background: color-mix(in sRGB, var(--jp-search-overlay-ink) 14%, var(--jp-search-overlay-surface));
1341 }
1342 }
1343 .jetpack-search-block-overlay__close svg {
1344 width: 24px;
1345 height: 24px;
1346 }
1347 /* Pin in-overlay button `color` against host-theme `button:hover|:focus` overrides
1348 * (fieldguide and similar legacy themes flip button color to a brand accent on hover).
1349 * Our block-level rules set `color: inherit` at (0,1,0); a stray `button:hover` rule
1350 * at (0,1,1) outranks it on `:hover`, and the `color-mix(currentColor X%, …)` hover
1351 * affordances on the filters-popover trigger, results-sort trigger, suggestions
1352 * options, etc. then resolve against the host's hover color (often white-on-white).
1353 * Card-scoped `:hover|:focus|[aria-expanded=true]` lands at (0,2,1) — beats the
1354 * theme rule without escalating to `!important`. */
1355 .jetpack-search-block-overlay__card button:hover,
1356 .jetpack-search-block-overlay__card button:focus,
1357 .jetpack-search-block-overlay__card button:focus-visible,
1358 .jetpack-search-block-overlay__card button[aria-expanded="true"] {
1359 color: var(--jp-search-overlay-ink);
1360 }
1361 /* Load More is a solid theme `core/button` on the search page, which is right
1362 * there. Inside the overlay card it sits on the resolved card surface, where the
1363 * theme's solid button background (and its accent `:hover`, e.g. Twenty Sixteen's
1364 * #007acc) reads as a heavy slab that clashes with the card's otherwise
1365 * `currentColor`-ghost controls (close, sort/filter triggers, active-filter
1366 * pills). Card-scoped (0,2,0 / 0,2,1) so it only restyles the in-overlay button
1367 * to the same ghost affordance — the page button is untouched. */
1368 .jetpack-search-block-overlay__card .jetpack-search-load-more__button {
1369 background: transparent;
1370 border: 1px solid;
1371 border-color: color-mix(in sRGB, currentColor 20%, transparent);
1372 color: var(--jp-search-overlay-ink);
1373 }
1374 .jetpack-search-block-overlay__card .jetpack-search-load-more__button:hover:not(:disabled),
1375 .jetpack-search-block-overlay__card .jetpack-search-load-more__button:focus-visible {
1376 background: color-mix(in sRGB, currentColor 8%, transparent);
1377 }
1378 /* Promote the first child (search-input) to a 60px header strip flush with
1379 * the close button, matching the legacy `__box` header. Suppress the input
1380 * block's own border-bottom — the card's `::before` hairline handles the
1381 * separator across the whole strip. */
1382 .jetpack-search-block-overlay__card .wp-block-jetpack-search-search-input {
1383 position: absolute;
1384 top: 0;
1385 left: 0;
1386 right: 60px;
1387 height: 60px;
1388 margin: 0;
1389 padding: 0;
1390 }
1391 .jetpack-search-block-overlay__card .wp-block-jetpack-search-search-input .jetpack-search-input__inside-wrapper {
1392 height: 100%;
1393 display: flex;
1394 align-items: stretch;
1395 gap: 0;
1396 padding: 0;
1397 border-bottom: 0;
1398 }
1399 .jetpack-search-block-overlay__card .wp-block-jetpack-search-search-input .jetpack-search-input__icon {
1400 flex: 0 0 60px;
1401 width: 60px;
1402 height: 60px;
1403 padding: 18px;
1404 box-sizing: border-box;
1405 opacity: 0.5;
1406 }
1407 .jetpack-search-block-overlay__card .wp-block-jetpack-search-search-input .jetpack-search-input__field {
1408 flex: 1 1 auto;
1409 min-width: 0;
1410 height: 100%;
1411 font-size: 18px;
1412 line-height: 1;
1413 margin: 0;
1414 padding: 0;
1415 background: transparent;
1416 }
1417 .jetpack-search-block-overlay__card .wp-block-jetpack-search-search-input .jetpack-search-input__clear {
1418 flex: 0 0 60px;
1419 width: 60px;
1420 height: 60px;
1421 padding: 0;
1422 font-size: 0.875rem;
1423 font-weight: 400;
1424 line-height: 1;
1425 }
1426 /* Suggestions panel covers the full card width (cancel the input's `right: 60px`
1427 * offset) and sits above `results-sort` / `filters-popover` (both `z-index: 20`).
1428 * Background reads from `--jp-search-overlay-surface` so the panel tracks the
1429 * resolved card surface — a hardcoded `#fff` would re-introduce the white-on-dark
1430 * bug on legacy `--background`/`--foreground` themes. */
1431 .jetpack-search-block-overlay__card .wp-block-jetpack-search-search-input .jetpack-search-input__suggestions {
1432 right: -60px;
1433 z-index: 30;
1434 background: var(--jp-search-overlay-surface, #fff);
1435 }
1436 /* Top padding clears the absolutely-positioned 60px header strip (SEARCH-243). */
1437 .jetpack-search-block-overlay__content > .wp-block-group:first-child {
1438 padding: var(--jp-search-overlay-content-pad-block-start) var(--jp-search-overlay-content-pad-inline) var(--jp-search-overlay-content-pad-block-end);
1439 }
1440 @media (max-width: 781px) {
1441 .jetpack-search-block-overlay {
1442 padding: 0;
1443 }
1444 .jetpack-search-block-overlay__card {
1445 min-height: 100vh;
1446 border: 0;
1447 border-radius: 0;
1448 box-shadow: none;
1449 --jp-search-overlay-content-pad-inline: 1em;
1450 --jp-search-overlay-content-pad-block-end: 1em;
1451 }
1452 }
1453 /* Mirror legacy `$break-lg: 992px → $modal-max-width-lg: 95%` from `instant-search/components/search-results.scss`. */
1454 @media (min-width: 992px) {
1455 .jetpack-search-block-overlay__card {
1456 max-width: 95%;
1457 }
1458 }
1459 /* Body-scroll lock while open. JS side stashes/restores scrollY on toggle. */
1460 body.jetpack-search-block-overlay-open {
1461 position: fixed;
1462 left: 0;
1463 right: 0;
1464 width: 100%;
1465 overflow: hidden;
1466 }
1467 CSS;
1468 }
1469
1470 /**
1471 * Register + enqueue the shared responsive layout CSS on the embedded /
1472 * WC-product page templates (`jetpack-search.html`,
1473 * `jetpack-search-product-results.html`). Self-gates on `is_search()` so
1474 * non-search requests skip the work entirely; the overlay path enqueues
1475 * the same handle unconditionally from its own asset hook.
1476 */
1477 public static function enqueue_search_page_assets() {
1478 if ( ! is_search() ) {
1479 return;
1480 }
1481 static::enqueue_search_layout_style();
1482 }
1483
1484 /**
1485 * Register + enqueue the inline CSS that drives the shared responsive
1486 * layout pattern across all three Search Blocks templates — narrow-width
1487 * sidebar collapse and in-header popover toggle. Called from both the
1488 * overlay enqueue path and the page-template enqueue path.
1489 *
1490 * `wp_register_style` / `wp_enqueue_style` are idempotent, but
1491 * `wp_add_inline_style` is **not** — it appends to an internal array on
1492 * every call, so a second invocation would double the inline payload.
1493 * The `wp_style_is( …, 'enqueued' )` short-circuit makes the helper safe
1494 * to call from multiple sites in one request.
1495 */
1496 public static function enqueue_search_layout_style() {
1497 // No src — this handle exists only as a target for `wp_add_inline_style`.
1498 // Version tracks the package so a release bust cache-invalidates any
1499 // reusing site's inline-style cache.
1500 wp_register_style( 'jetpack-search-layout', false, array(), Package::VERSION );
1501 if ( wp_style_is( 'jetpack-search-layout', 'enqueued' ) ) {
1502 return;
1503 }
1504 wp_enqueue_style( 'jetpack-search-layout' );
1505 wp_add_inline_style( 'jetpack-search-layout', static::search_layout_inline_css() );
1506 }
1507
1508 /**
1509 * Inline CSS for the responsive search-results layout shared across the
1510 * overlay, embedded (`jetpack-search.html`), and WC product
1511 * (`jetpack-search-product-results.html`) templates.
1512 *
1513 * Below 992px the right-column filter sidebar collapses to a popover
1514 * trigger docked next to results-sort; at >= 992px the sidebar is the
1515 * sole filter UI and the in-header popover is hidden so the two don't
1516 * double up. Same breakpoint as the legacy Instant Search overlay
1517 * (`.jetpack-instant-search__search-results-secondary { display: none }`
1518 * below `$break-lg`). Lives next to `block_template_overlay_inline_css()`
1519 * (which keeps overlay-only chrome) because the rules target the
1520 * templates' outer columns + results-header, none of which any single
1521 * block owns.
1522 *
1523 * @return string
1524 */
1525 protected static function search_layout_inline_css(): string {
1526 return <<<'CSS'
1527 /* The block group already carries `layout.type:flex` which makes the WordPress
1528 * block-layout system emit `display:flex` / `flex-wrap:nowrap` /
1529 * `justify-content:space-between`. Restating them is defensive (decouples us
1530 * from block-layout CSS being present); the operative net-new rule is
1531 * `align-items: center`, which centers `results-count` against the controls
1532 * cluster. */
1533 .jetpack-search-layout__results-header {
1534 display: flex;
1535 flex-wrap: nowrap;
1536 justify-content: space-between;
1537 align-items: center;
1538 }
1539 /* Right-side controls cluster: sort + filters-popover trigger. Without this
1540 * the three `__results-header` children get spread evenly by the parent's
1541 * `space-between`; nesting sort + popover here pins them as one block on the
1542 * trailing edge. */
1543 .jetpack-search-layout__results-header-controls {
1544 display: flex;
1545 flex-wrap: nowrap;
1546 align-items: center;
1547 gap: 0.75rem;
1548 }
1549 /* Name the columns row as the layout container so the sidebar/popover flip
1550 * tracks its inline-size. The `@media` rules below are the universal base —
1551 * they fire in every browser (including legacy ones without container-query
1552 * support) and they drive standalone usage outside the named container (no
1553 * container ancestor → only `@media` applies). The `@container` rule further
1554 * down overrides `@media` via source-order cascade at equal specificity when
1555 * the named container is in scope AND narrower than 992px. The override has
1556 * to undo `@media (min-width: 992px)`'s `popover { display: none }`
1557 * explicitly: in the "wide viewport, narrow container" case `@media
1558 * (min-width: 992px)` keeps firing on the viewport width and would otherwise
1559 * leave the visitor with no filter UI at all. `@container (min-width: 992px)`
1560 * isn't defined — container width is bounded by viewport width in practice,
1561 * so the matching `@media (min-width: 992px)` already covers the wide case. */
1562 .wp-block-columns:has(> .jetpack-search-layout__filters-column) {
1563 container-type: inline-size;
1564 container-name: jetpack-search-layout;
1565 }
1566 /* Below 992px the right-column filter sidebar collapses to a popover trigger
1567 * docked next to results-sort. The trigger comes from the
1568 * `jetpack-search/filters-popover` block that ships in each template. The
1569 * selector is scoped to the named outer column so nested `wp-block-column`s
1570 * inside result-card templates aren't affected. */
1571 @media (max-width: 991.98px) {
1572 .jetpack-search-layout__filters-column {
1573 display: none;
1574 }
1575 /* `!important` defends against the parent `wp:columns` block-layout CSS
1576 * that pins `.wp-block-column` to its inline `flex-basis` (or to an even
1577 * split when no width is set). Once the filter column is `display:none`,
1578 * the results column has to be able to claim the full row at any
1579 * specificity. */
1580 .jetpack-search-layout__results-column {
1581 flex-basis: 100% !important;
1582 }
1583 }
1584 /* Sidebar left divider tracks `currentColor` so the hairline stays subtle on
1585 * light themes and visible on dark themes, matching the search-input
1586 * underline. We only set color; each template's column block sets
1587 * `border-left-width: 1px` inline. Fallback to `transparent` so themes/UAs
1588 * without `color-mix` support get an invisible divider rather than a hard
1589 * grey rule. */
1590 .jetpack-search-layout__filters-column {
1591 border-left-color: transparent;
1592 }
1593 @supports (border-color: color-mix(in sRGB, black 50%, white)) {
1594 .jetpack-search-layout__filters-column {
1595 border-left-color: color-mix(in sRGB, currentColor 15%, transparent);
1596 }
1597 }
1598 /* Sidebar-showing rules (>= 992px). The corner-join is structural: the
1599 * columns row is pulled flush to the search-input hairline and breathing
1600 * room re-added as internal column padding, so the filters column's
1601 * `border-left` runs the row's full height — hairline (top) to end-of-div
1602 * (bottom). Three vertical gaps are neutralised:
1603 *
1604 * a) `margin-block-start` on the row (outer group's `spacing.blockGap` or
1605 * the theme's default block-gap) — zeroed.
1606 *
1607 * b) `.is-layout-flex { align-items: center }` (theme/core default), which
1608 * centres the shorter filters column and drops its top edge. Overridden
1609 * to `stretch` (not `flex-start`) so the column also grows to full row
1610 * height; it's flow layout, so its content stays top-aligned regardless.
1611 *
1612 * c) Overlay-only: SEARCH-243's content-group inset sits outside the
1613 * columns, so the stretched column stops short of the card edges (below
1614 * the `::before` hairline at the top, and short of the bottom). Zeroed
1615 * on the group's block axis and re-added on the columns, both sides
1616 * reading the same `--jp-search-overlay-content-pad-*` tokens the group
1617 * itself uses — so the divider reaches both edges while content keeps
1618 * its breathing room, and the two sites can't drift.
1619 *
1620 * `.is-layout-flex` bumps the columns selector to (0,3,0) to outrank
1621 * per-container `blockGap` CSS; `:has(> filters-column)` scopes it to our
1622 * rows. These target the row/group, which `@container` can't reach from
1623 * inside the named container, so they stay viewport-driven — harmless when
1624 * the sidebar collapses below 992px. (b) is also a no-op wherever WP core's
1625 * `.wp-block-columns { align-items: normal !important }` is present; see
1626 * AGENTS.md. */
1627 @media (min-width: 992px) {
1628 /* Sidebar shown, popover-in-results-header hidden. The `@container
1629 * (max-width: 991.98px)` block below re-shows the popover when the
1630 * named container is narrower than the viewport. */
1631 .jetpack-search-layout__results-header .jetpack-search-filters-popover {
1632 display: none;
1633 }
1634 .wp-block-columns.is-layout-flex:has(> .jetpack-search-layout__filters-column) {
1635 align-items: stretch;
1636 margin-block-start: 0;
1637 }
1638 .jetpack-search-block-overlay__content > .wp-block-group:first-child:has(.jetpack-search-layout__filters-column) {
1639 padding-top: 0;
1640 padding-bottom: 0;
1641 }
1642 .wp-block-columns:has(> .jetpack-search-layout__filters-column) > .wp-block-column {
1643 padding-top: var(--jp-search-overlay-content-pad-block-start, 0.5em);
1644 }
1645 .jetpack-search-block-overlay__content .wp-block-columns:has(> .jetpack-search-layout__filters-column) > .wp-block-column {
1646 padding-bottom: var(--jp-search-overlay-content-pad-block-end, 2em);
1647 }
1648 }
1649 /* @container override: applies when the named container exists. Placed AFTER
1650 * the `@media` rules so source-order cascade lets it win over them at equal
1651 * specificity. In "wide viewport, narrow container" (the case the change is
1652 * meant to fix), `@media (max-width: 991.98px)` doesn't fire but `@media
1653 * (min-width: 992px)` does — this block undoes the latter's `popover {
1654 * display: none }` via `display: inline-block` and hides the sidebar that
1655 * the `@media (max-width)` rule wouldn't have hidden at this viewport. Same
1656 * `!important` reasoning on `flex-basis: 100%` as the @media block. */
1657 @container jetpack-search-layout (max-width: 991.98px) {
1658 .jetpack-search-layout__filters-column {
1659 display: none;
1660 }
1661 .jetpack-search-layout__results-column {
1662 flex-basis: 100% !important;
1663 }
1664 .jetpack-search-layout__results-header .jetpack-search-filters-popover {
1665 display: inline-block;
1666 }
1667 }
1668 CSS;
1669 }
1670
1671 /**
1672 * Product-search counterpart of `get_search_template_content()`.
1673 *
1674 * @return string Block markup for the product-search template.
1675 */
1676 protected static function get_product_search_template_content(): string {
1677 $template_path = __DIR__ . '/templates/jetpack-search-product-results.html';
1678 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- local, bundled template file.
1679 $raw = is_readable( $template_path ) ? (string) file_get_contents( $template_path ) : '';
1680 return static::sync_filters_popover_content( static::substitute_template_placeholders( $raw ) );
1681 }
1682
1683 /**
1684 * Substitute `{{FILTER_HEADING}}` / `{{HEADER_SLUG}}` / `{{FOOTER_SLUG}}` in a
1685 * bundled template. Empty input passes through.
1686 *
1687 * @param string $raw Raw template-file contents.
1688 * @return string
1689 */
1690 protected static function substitute_template_placeholders( string $raw ): string {
1691 if ( '' === $raw ) {
1692 return $raw;
1693 }
1694 $slugs = static::resolve_chrome_slugs();
1695 return str_replace(
1696 array( '{{FILTER_HEADING}}', '{{HEADER_SLUG}}', '{{FOOTER_SLUG}}' ),
1697 array(
1698 esc_html__( 'Filter options', 'jetpack-search-pkg' ),
1699 $slugs['header'],
1700 $slugs['footer'],
1701 ),
1702 $raw
1703 );
1704 }
1705
1706 /**
1707 * Active theme's chrome slugs. Test seam; resolver lives on
1708 * `Theme_Chrome_Slug_Resolver`.
1709 *
1710 * @return array{header:string,footer:string}
1711 */
1712 protected static function resolve_chrome_slugs(): array {
1713 return Theme_Chrome_Slug_Resolver::resolve();
1714 }
1715
1716 /**
1717 * Names of the "source of truth" filter-composition blocks — whichever is
1718 * present in a template supplies the canonical filter config that
1719 * `jetpack-search/filters-popover` mirrors. See {@see sync_filters_popover_content()}.
1720 */
1721 const FILTERS_SOURCE_BLOCK_NAMES = array( 'jetpack-search/filters', 'jetpack-search/filters-product' );
1722
1723 /**
1724 * `jetpack-search/filters-popover` (the collapsible/mobile filter panel) and
1725 * `jetpack-search/filters` / `jetpack-search/filters-product` (the wide-viewport
1726 * sidebar) are two independently-serialized copies of the same filter
1727 * configuration, shown one-or-the-other via a CSS breakpoint. Nothing keeps
1728 * them in sync, so editing one silently leaves the other stale (SEARCH-307).
1729 *
1730 * Rather than a two-way sync, the sidebar block is treated as the single
1731 * source of truth: every time template content is read — for rendering or
1732 * for editing — the popover's inner blocks are recomputed to mirror
1733 * whatever the sidebar currently contains. A direct edit to the popover's
1734 * own inner blocks still saves, but is overwritten back to match the
1735 * sidebar on the next read; self-healing, no migration needed for content
1736 * that already diverged before this existed.
1737 *
1738 * @param string $content Block markup, e.g. a full template or singleton-CPT post_content.
1739 * @return string Block markup with the popover's inner blocks synced to the sidebar's, unchanged if either block is absent.
1740 */
1741 public static function sync_filters_popover_content( string $content ): string {
1742 if ( '' === $content || false === strpos( $content, 'jetpack-search/filters-popover' ) ) {
1743 return $content;
1744 }
1745 $blocks = parse_blocks( $content );
1746 $source = static::find_block_by_name( $blocks, self::FILTERS_SOURCE_BLOCK_NAMES );
1747 if ( null === $source ) {
1748 return $content;
1749 }
1750 $replaced = static::replace_block_inner_content( $blocks, 'jetpack-search/filters-popover', $source );
1751 return $replaced ? serialize_blocks( $blocks ) : $content;
1752 }
1753
1754 /**
1755 * Depth-first search for the first block matching one of `$names`.
1756 *
1757 * @param array<int,array<string,mixed>> $blocks Parsed blocks (`parse_blocks()` shape).
1758 * @param string[] $names Block names to match.
1759 * @return array<string,mixed>|null
1760 */
1761 protected static function find_block_by_name( array $blocks, array $names ): ?array {
1762 foreach ( $blocks as $block ) {
1763 if ( in_array( $block['blockName'], $names, true ) ) {
1764 return $block;
1765 }
1766 if ( ! empty( $block['innerBlocks'] ) ) {
1767 $found = static::find_block_by_name( $block['innerBlocks'], $names );
1768 if ( null !== $found ) {
1769 return $found;
1770 }
1771 }
1772 }
1773 return null;
1774 }
1775
1776 /**
1777 * Depth-first search that overwrites the first block named `$name` with
1778 * `$source`'s inner blocks. `innerBlocks`/`innerContent`/`innerHTML` are
1779 * copied together from `$source` so the null-placeholder bookkeeping in
1780 * `innerContent` stays internally consistent regardless of how many
1781 * filters `$source` has.
1782 *
1783 * @param array<int,array<string,mixed>> $blocks Parsed blocks, modified in place.
1784 * @param string $name Block name to replace.
1785 * @param array<string,mixed> $source Block whose inner content is cloned onto the match.
1786 * @return bool Whether a match was found and replaced.
1787 */
1788 protected static function replace_block_inner_content( array &$blocks, string $name, array $source ): bool {
1789 foreach ( $blocks as &$block ) {
1790 if ( $block['blockName'] === $name ) {
1791 $block['innerBlocks'] = $source['innerBlocks'];
1792 $block['innerContent'] = $source['innerContent'];
1793 $block['innerHTML'] = $source['innerHTML'];
1794 return true;
1795 }
1796 if ( ! empty( $block['innerBlocks'] ) && static::replace_block_inner_content( $block['innerBlocks'], $name, $source ) ) {
1797 return true;
1798 }
1799 }
1800 return false;
1801 }
1802
1803 /**
1804 * Idempotent wrapper around `register_block_template`. Unregisters first so
1805 * a stale entry from a prior init (long-lived PHP-FPM worker) is replaced
1806 * rather than triggering `doing_it_wrong`.
1807 *
1808 * @param string $name Fully-qualified template name.
1809 * @param array<string,mixed> $args Args for register_block_template().
1810 */
1811 protected static function replace_block_template( string $name, array $args ) {
1812 if ( class_exists( '\WP_Block_Templates_Registry' ) ) {
1813 $registry = \WP_Block_Templates_Registry::get_instance();
1814 if ( $registry->is_registered( $name ) ) {
1815 $registry->unregister( $name );
1816 }
1817 }
1818 register_block_template( $name, $args );
1819 }
1820
1821 /**
1822 * Counterpart of `register_search_template()` for product search.
1823 */
1824 public static function register_product_search_template() {
1825 if ( ! function_exists( 'register_block_template' ) || ! static::block_templates_active() ) {
1826 return;
1827 }
1828 $content = static::get_product_search_template_content();
1829 if ( '' === $content ) {
1830 return;
1831 }
1832 static::replace_block_template(
1833 static::get_parent_plugin_slug() . '//' . self::PRODUCT_SEARCH_TEMPLATE_SLUG,
1834 array(
1835 'title' => __( 'Jetpack Search Product Results', 'jetpack-search-pkg' ),
1836 'description' => __( 'Displays WooCommerce product search results with Jetpack Search filters.', 'jetpack-search-pkg' ),
1837 'content' => $content,
1838 )
1839 );
1840 }
1841
1842 /**
1843 * Directory slug of the plugin that owns the template in the Site Editor UI.
1844 * Picked by preference so the more-specific "Jetpack Search" label wins
1845 * when both the standalone plugin and the Jetpack monolith are active:
1846 * `jetpack-search` → `jetpack` → `jetpack-search` fallback.
1847 *
1848 * @return string
1849 */
1850 protected static function get_parent_plugin_slug(): string {
1851 $active = Helper::get_active_plugins();
1852 $preferred = array(
1853 'jetpack-search' => 'jetpack-search/jetpack-search.php',
1854 'jetpack' => 'jetpack/jetpack.php',
1855 );
1856 foreach ( $preferred as $slug => $plugin_file ) {
1857 if ( in_array( $plugin_file, $active, true ) ) {
1858 return $slug;
1859 }
1860 }
1861 return 'jetpack-search';
1862 }
1863
1864 /**
1865 * Prepend the Jetpack Search slug to the search template hierarchy on
1866 * block-theme search requests. Existing occurrences are stripped first
1867 * so a second init pass / another filter on the same hook can't dup.
1868 *
1869 * WooCommerce product-search carve-out: override off → leave to WC's
1870 * prepend; override on → fall through here, then
1871 * `route_woocommerce_product_search_template()` swaps WC's slug for ours.
1872 *
1873 * @param string[] $templates Template hierarchy slugs.
1874 * @return string[]
1875 */
1876 public static function prepend_search_template( $templates ) {
1877 if ( ! is_search() || ! static::block_templates_active() ) {
1878 return $templates;
1879 }
1880 if ( ! static::woocommerce_search_template_override_enabled() && static::is_woocommerce_product_search() ) {
1881 return $templates;
1882 }
1883 $templates = array_values(
1884 array_filter(
1885 (array) $templates,
1886 static function ( $slug ) {
1887 return self::SEARCH_TEMPLATE_SLUG !== $slug;
1888 }
1889 )
1890 );
1891 array_unshift( $templates, self::SEARCH_TEMPLATE_SLUG );
1892 return $templates;
1893 }
1894
1895 /**
1896 * Classic-theme counterpart to `prepend_search_template()`. Block-theme
1897 * hierarchy filters are a no-op on classic themes — `locate_template()`
1898 * walks slugs as `{slug}.php` and a classic theme doesn't ship one for
1899 * our slug. So let the hierarchy resolve normally, then swap the path
1900 * via `template_include` to our bundled PHP shim, which renders the same
1901 * block markup inside the theme's `get_header()`/`get_footer()`.
1902 *
1903 * @param string $template Resolved template path.
1904 * @return string
1905 */
1906 public static function route_classic_theme_search_template( $template ) {
1907 if ( ! is_search() ) {
1908 return $template;
1909 }
1910 $is_product_search = static::is_woocommerce_product_search();
1911 // Override off: leave product search to WooCommerce / the theme's own
1912 // archive routing — we don't impose the product shim without opt-in.
1913 if ( ! static::woocommerce_search_template_override_enabled() && $is_product_search ) {
1914 return $template;
1915 }
1916 // Override on + product search: route to the product-results shim.
1917 // Bail back to the theme's template if neither a customization nor a
1918 // bundled body is available — rendering header/footer around an empty
1919 // body looks broken.
1920 if ( $is_product_search ) {
1921 if ( null === Product_Search_Template::get_customized_content() && '' === static::get_classic_theme_product_search_body() ) {
1922 return $template;
1923 }
1924 return __DIR__ . '/templates/classic-theme-product-search.php';
1925 }
1926 // Regular (non-product) search: only Embedded takes over the whole search
1927 // page. Inline registers this router too (for the product shim above) but
1928 // leaves regular searches to the theme, so bail here unless Embedded.
1929 if ( Module_Control::EXPERIENCE_EMBEDDED !== ( new Module_Control() )->get_experience() ) {
1930 return $template;
1931 }
1932 // Same empty-body bail-out for the generic shim. A saved empty
1933 // customization ('' vs null) is honored as intentional.
1934 if ( null === Search_Template::get_customized_content() && '' === static::get_classic_theme_search_body() ) {
1935 return $template;
1936 }
1937 return __DIR__ . '/templates/classic-theme-search.php';
1938 }
1939
1940 /**
1941 * Classic-theme search body — same markup as the block-theme path with
1942 * top-level `core/template-part` references stripped so the theme's
1943 * `get_header()` / `get_footer()` drive the chrome.
1944 *
1945 * Source of truth: customized `Search_Template` CPT → bundled `jetpack-search.html`.
1946 * Public because `templates/classic-theme-search.php` calls it from outside the class.
1947 *
1948 * @return string Block markup, no template-part wrappers.
1949 */
1950 public static function get_classic_theme_search_body(): string {
1951 $customized = Search_Template::get_customized_content();
1952 if ( null !== $customized ) {
1953 return $customized;
1954 }
1955 return static::strip_top_level_template_parts( static::get_search_template_content() );
1956 }
1957
1958 /**
1959 * Product-search counterpart to {@see get_classic_theme_search_body()} —
1960 * source of truth for the classic-theme product-results shim. Customized
1961 * `Product_Search_Template` CPT → bundled `jetpack-search-product-results.html`.
1962 * Public because `templates/classic-theme-product-search.php` calls it from
1963 * outside the class.
1964 *
1965 * @return string Block markup, no template-part wrappers.
1966 */
1967 public static function get_classic_theme_product_search_body(): string {
1968 $customized = Product_Search_Template::get_customized_content();
1969 if ( null !== $customized ) {
1970 return $customized;
1971 }
1972 return static::strip_top_level_template_parts( static::get_product_search_template_content() );
1973 }
1974
1975 /**
1976 * Strip top-level `core/template-part` self-closing comments — classic
1977 * themes can't resolve their slugs. Non-greedy `.*?` capped by `-->` keeps
1978 * matching cleanly across template revisions and across nested attribute
1979 * payloads.
1980 *
1981 * @param string $content Block markup, possibly empty.
1982 * @return string
1983 */
1984 protected static function strip_top_level_template_parts( string $content ): string {
1985 if ( '' === $content ) {
1986 return '';
1987 }
1988 return (string) preg_replace( '#<!--\s*wp:template-part\s+.*?/-->\s*#s', '', $content );
1989 }
1990
1991 /**
1992 * Inline layout `<style>` block the classic-theme shims emit before the
1993 * bundled block markup. Classic themes don't emit core's block-supports
1994 * layout CSS, so two traits the bundled templates rely on collapse on
1995 * classic themes: the inner group's 1.5rem `blockGap` vanishes (search
1996 * input runs straight into the results row), and `alignwide` has no
1997 * effect (content stretches edge-to-edge because `template_include`
1998 * bypasses the theme's own content wrapper). Reapplying both, scoped to
1999 * `<main class="wp-block-group">`, restores parity without leaking
2000 * outside the shim. Shared by both shims so a future layout tweak
2001 * touches one place. The `<style>` `id` is unique per render — routing
2002 * ensures only one shim runs per request, so duplicate IDs can't occur.
2003 *
2004 * Public because both `templates/classic-theme-search.php` and
2005 * `templates/classic-theme-product-search.php` call it from outside the
2006 * class.
2007 *
2008 * @return string Inline `<style>` element ready to echo.
2009 */
2010 public static function get_classic_theme_layout_style(): string {
2011 return <<<'HTML'
2012 <style id="jetpack-search-classic-theme-layout">
2013 main.wp-block-group {
2014 max-width: var(--wp--style--global--wide-size, 1280px);
2015 margin-inline: auto;
2016 padding-inline: clamp(1rem, 4vw, 2rem);
2017 }
2018 main.wp-block-group .is-layout-flow > * + * {
2019 margin-block-start: var(--wp--style--block-gap, 1.5rem);
2020 }
2021 </style>
2022 HTML;
2023 }
2024
2025 /**
2026 * Test override for `block_templates_active()`. Null = read the live state.
2027 *
2028 * @var bool|null
2029 */
2030 private static $block_templates_active_for_testing = null;
2031
2032 /**
2033 * Test seam. Set true/false to force `block_templates_active()`, or null to clear.
2034 *
2035 * @param bool|null $active Forced value, or null to clear.
2036 */
2037 public static function set_block_templates_active_for_testing( ?bool $active ): void {
2038 self::$block_templates_active_for_testing = $active;
2039 }
2040
2041 /**
2042 * Whether the theme resolves block templates. Overridable seam over
2043 * `wp_is_block_theme()` for tests.
2044 *
2045 * @return bool
2046 */
2047 protected static function block_templates_active(): bool {
2048 if ( null !== self::$block_templates_active_for_testing ) {
2049 return self::$block_templates_active_for_testing;
2050 }
2051 return wp_is_block_theme();
2052 }
2053
2054 /**
2055 * Front the `jetpack-search-product-results` template for WC product
2056 * search. Drops WC's `product-search-results` and unshifts ours so it
2057 * resolves before any `jetpack-search` prepend for the generic route.
2058 *
2059 * @param string[] $templates Template hierarchy slugs.
2060 * @return string[]
2061 */
2062 public static function route_woocommerce_product_search_template( $templates ) {
2063 // FSE-hierarchy work — classic themes resolve template slugs as `{slug}.php`
2064 // and there's no `jetpack-search-product-results.php`. The classic-theme
2065 // equivalent runs through `route_classic_theme_search_template()` instead.
2066 if ( ! static::block_templates_active() || ! static::is_woocommerce_product_search() ) {
2067 return $templates;
2068 }
2069 $templates = array_values(
2070 array_filter(
2071 (array) $templates,
2072 static function ( $slug ) {
2073 return self::WC_PRODUCT_SEARCH_TEMPLATE_SLUG !== $slug
2074 && self::PRODUCT_SEARCH_TEMPLATE_SLUG !== $slug;
2075 }
2076 )
2077 );
2078 array_unshift( $templates, self::PRODUCT_SEARCH_TEMPLATE_SLUG );
2079 return $templates;
2080 }
2081
2082 /**
2083 * Seed the Interactivity API store with initial state. Per-block render
2084 * callbacks deep-merge their own entries on top (e.g. filter-checkbox
2085 * writes its filterConfig). See AGENTS.md § Hydration & SSR seeding.
2086 */
2087 public static function seed_interactivity_state() {
2088 if ( ! function_exists( 'wp_interactivity_state' ) ) {
2089 return;
2090 }
2091 wp_interactivity_state(
2092 'jetpack-search',
2093 static::build_seed_state( static::collect_filter_configs_from_post() )
2094 );
2095 }
2096
2097 /**
2098 * Compose the final seeded state for `wp_interactivity_state()`.
2099 *
2100 * @param array<string, array<string, mixed>> $filter_configs Map of filter configs.
2101 * @return array<string, mixed>
2102 */
2103 public static function build_seed_state( array $filter_configs ): array {
2104 $state = static::build_initial_state();
2105 $state['filterConfigs'] = $filter_configs;
2106 return $state;
2107 }
2108
2109 /**
2110 * Walk the current post's block tree for filter blocks and build the
2111 * filterConfigs map. Template-part scans are not performed — a filter
2112 * inside a template part still works, but its config isn't available to
2113 * the search-results SSR until hydration.
2114 *
2115 * @return array<string, array<string, mixed>>
2116 */
2117 protected static function collect_filter_configs_from_post(): array {
2118 if ( ! function_exists( 'get_post' ) || ! function_exists( 'parse_blocks' ) ) {
2119 return array();
2120 }
2121 // Bail if any helper is missing — half-loaded feature would ship inconsistent filterConfigs.
2122 $helpers = static::filter_block_helpers();
2123 foreach ( $helpers as $helper ) {
2124 if ( ! class_exists( $helper ) ) {
2125 return array();
2126 }
2127 }
2128 $post = get_post();
2129 if ( ! $post || empty( $post->post_content ) ) {
2130 return array();
2131 }
2132 if ( ! static::post_content_has_filter_block( $post, array_keys( $helpers ) ) ) {
2133 return array();
2134 }
2135 $configs = array();
2136 static::walk_blocks_for_filter_configs( parse_blocks( $post->post_content ), $configs );
2137 return $configs;
2138 }
2139
2140 /**
2141 * Does the post contain any of the given block names? SEARCH-295: a
2142 * has_block() scan to gate parse_blocks() on large, filter-less posts.
2143 *
2144 * @param \WP_Post $post Post to scan.
2145 * @param string[] $block_names Block names to scan for.
2146 * @return bool
2147 */
2148 protected static function post_content_has_filter_block( \WP_Post $post, array $block_names ): bool {
2149 foreach ( $block_names as $block_name ) {
2150 if ( has_block( $block_name, $post ) ) {
2151 return true;
2152 }
2153 }
2154 return false;
2155 }
2156
2157 /**
2158 * Map of filter block name → helper class. Add a new filter block type
2159 * by appending one entry here.
2160 *
2161 * @return array<string, class-string>
2162 */
2163 protected static function filter_block_helpers(): array {
2164 $helpers = array(
2165 'jetpack-search/filter-checkbox' => Filter_Checkbox::class,
2166 'jetpack-search/filter-date' => Filter_Date::class,
2167 'jetpack-search/filter-wc-rating' => Filter_Wc_Rating::class,
2168 'jetpack-search/filter-wc-attribute' => Filter_Wc_Attribute::class,
2169 'jetpack-search/filter-wc-stock-status' => Search_Product_Filter_Status::class,
2170 );
2171 if ( self::woocommerce_blocks_enabled() ) {
2172 return $helpers;
2173 }
2174 // Non-Woo sites: drop WC-only entries so the filter-config walk stays
2175 // symmetric with what `register_blocks()` actually registered.
2176 foreach ( array_keys( $helpers ) as $name ) {
2177 if ( self::is_woocommerce_only_block( $name ) ) {
2178 unset( $helpers[ $name ] );
2179 }
2180 }
2181 return $helpers;
2182 }
2183
2184 /**
2185 * Recursively walk a parsed block tree, pushing each filter block's
2186 * config into `$configs` by reference.
2187 *
2188 * @param array $blocks Parsed block tree from parse_blocks().
2189 * @param array $configs Accumulator map keyed by filterKey.
2190 * @return void
2191 */
2192 protected static function walk_blocks_for_filter_configs( array $blocks, array &$configs ): void {
2193 $helpers = static::filter_block_helpers();
2194 foreach ( $blocks as $block ) {
2195 if ( ! is_array( $block ) ) {
2196 continue;
2197 }
2198 $block_name = (string) ( $block['blockName'] ?? '' );
2199 if ( isset( $helpers[ $block_name ] ) ) {
2200 $helper = $helpers[ $block_name ];
2201 $attrs = (array) ( $block['attrs'] ?? array() );
2202 $key = $helper::derive_filter_key( $attrs );
2203 if ( '' !== $key ) {
2204 $configs[ $key ] = $helper::build_config( $attrs, $key );
2205 }
2206 }
2207
2208 if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) {
2209 static::walk_blocks_for_filter_configs( $block['innerBlocks'], $configs );
2210 }
2211 }
2212 }
2213
2214 /**
2215 * Build the initial state array for the jetpack-search Interactivity API store.
2216 *
2217 * @return array<string, mixed>
2218 */
2219 public static function build_initial_state() {
2220 $is_private = class_exists( Status::class ) ? ( new Status() )->is_private_site() : false;
2221 $is_wpcom = class_exists( Helper::class ) ? Helper::is_wpcom() : false;
2222 $site_id = class_exists( Helper::class ) ? Helper::get_wpcom_site_id() : 0;
2223 $is_jetpack_photon_enabled = method_exists( 'Jetpack', 'is_module_active' ) && \Jetpack::is_module_active( 'photon' );
2224 $search_query = static::parse_url_search_query();
2225 $active_filters = static::parse_url_filters();
2226 $filter_logic = static::parse_url_filter_logic( $active_filters );
2227 $price_range = static::parse_url_price_range();
2228 $is_initial_loading = static::is_initial_loading();
2229 $searching_text = function_exists( '__' ) ? __( 'Searching…', 'jetpack-search-pkg' ) : 'Searching…';
2230
2231 return array(
2232 // Connection / routing config.
2233 'siteId' => $site_id,
2234 'apiRoot' => function_exists( 'rest_url' ) ? esc_url_raw( rest_url() ) : '',
2235 'nonce' => function_exists( 'wp_create_nonce' ) ? wp_create_nonce( 'wp_rest' ) : '',
2236 'isPrivateSite' => $is_private,
2237 'isWpcom' => $is_wpcom,
2238 'isPhotonEnabled' => ( $is_wpcom || $is_jetpack_photon_enabled ) && ! $is_private,
2239 // TrainTracks gate, mirroring instant search's `disableTracking`
2240 // (Helper::get_search_options): suppresses `_tkq` pushes for
2241 // `?disable_tracking=1` crawlers/QA and the filter override.
2242 'disableTracking' => static::is_tracking_disabled(),
2243 // Threaded through url-state so `?orderby=price_asc` round-trips on Woo only.
2244 'isWooCommerceBlocksEnabled' => self::woocommerce_blocks_enabled(),
2245 'homeUrl' => function_exists( 'home_url' ) ? home_url() : '',
2246 // Blog locale (not viewer's profile locale) for consistent
2247 // logged-out formatting. BCP47-ish (`en-US`).
2248 'locale' => function_exists( 'get_locale' )
2249 ? str_replace( '_', '-', get_locale() )
2250 : 'en-US',
2251 // PHP-style token string parsed client-side by `wp-date-format.js`
2252 // (IA view bundle can't import `@wordpress/date`). Empty → Intl fallback.
2253 'dateFormat' => function_exists( 'get_option' )
2254 ? (string) get_option( 'date_format', '' )
2255 : '',
2256
2257 // URL-seeded so deep links render on first paint.
2258 'searchQuery' => $search_query,
2259 // `?s=` (empty value) must still fire the initial fetch; `searchQuery`
2260 // alone collapses present-but-empty and missing to `''`.
2261 'hasSearchParam' => static::has_search_param(),
2262 'searchParamName' => static::get_search_param_name(),
2263 'sortOrder' => static::parse_url_sort(),
2264 'activeFilters' => $active_filters,
2265 'filterLogic' => $filter_logic,
2266 'priceRange' => $price_range,
2267 // Scalar `?filter_id=value`; seeded as `{}` so JS readers see a defined shape.
2268 'staticFilterSelections' => (object) array(),
2269
2270 // Each filter block's render.php deep-merges its entry. Shape:
2271 // `{ [key]: { filterKey, filterType, taxonomy, effectiveSlug, label, showCount, maxItems } }`.
2272 'filterConfigs' => array(),
2273
2274 // JS hydration fills these. `aggregations` is stdClass so JS sees `{}`.
2275 'results' => array(),
2276 'aggregations' => (object) array(),
2277 // See AGENTS.md § Filter bucket lifecycle.
2278 'retainedFilterOptions' => (object) array(),
2279 'totalResults' => 0,
2280 'pageHandle' => null,
2281
2282 // `isLoading` true on deep links keeps the empty-state hidden until
2283 // JS fires the initial fetch (otherwise "No results found" flashes).
2284 'isLoading' => $is_initial_loading,
2285 'isLoadingMore' => false,
2286 'hasError' => false,
2287
2288 // Seeded so SSR resolves `data-wp-text` on first paint.
2289 'resultsCountText' => $is_initial_loading ? $searching_text : '',
2290
2291 'strings' => static::build_initial_strings(),
2292 'priceCurrencySymbol' => '$',
2293
2294 // Top-level (not under `strings`) — keeps Phan's `array<string,string>`
2295 // contract on `strings` intact.
2296 'aiExtendedLoadingHints' => static::build_ai_extended_loading_hints(),
2297
2298 'wcStockStatusLabels' => static::build_stock_status_labels(),
2299 );
2300 }
2301
2302 /**
2303 * Slug → display-label map for `wc_stock_status` selections, used by the
2304 * active-filters block for product-aware chips. RSM-1932 will swap this
2305 * for WC's translated labels (`wc_get_product_stock_status_options()`)
2306 * without changing the shape. Empty when the status helper isn't loaded.
2307 *
2308 * @return array<string, string>
2309 */
2310 protected static function build_stock_status_labels(): array {
2311 if ( ! class_exists( Search_Product_Filter_Status::class ) ) {
2312 return array();
2313 }
2314 $labels = array();
2315 foreach ( Search_Product_Filter_Status::get_options() as $option ) {
2316 $value = (string) ( $option['value'] ?? '' );
2317 if ( '' === $value ) {
2318 continue;
2319 }
2320 $labels[ $value ] = (string) ( $option['label'] ?? $value );
2321 }
2322 return $labels;
2323 }
2324
2325 /**
2326 * Whether the URL carries a search query, filter, or price range — i.e.
2327 * the JS store will fire an initial fetch on hydration. Render callbacks
2328 * use this to emit pre-hydration affordances (skeleton, "Searching…").
2329 *
2330 * URL-derived rather than read back from `wp_interactivity_state()`
2331 * because FSE pre-resolves block attributes before `wp_enqueue_scripts`
2332 * fires, so a state-read would silently return false on the very pages
2333 * this is meant to flag. Mirrors the `isLoading` seed exactly.
2334 *
2335 * @return bool
2336 */
2337 public static function is_initial_loading(): bool {
2338 if ( null !== self::$is_initial_loading_cache ) {
2339 return self::$is_initial_loading_cache;
2340 }
2341 // `has_search_param()` not `parse_url_search_query() !== ''` — an
2342 // explicit `?s=` (empty value) still means "visitor landed on a
2343 // search page" and should fire an unfiltered initial fetch.
2344 if ( static::has_search_param() ) {
2345 self::$is_initial_loading_cache = true;
2346 return true;
2347 }
2348 if ( ! empty( static::parse_url_filters() ) ) {
2349 self::$is_initial_loading_cache = true;
2350 return true;
2351 }
2352 self::$is_initial_loading_cache = null !== static::parse_url_price_range();
2353 return self::$is_initial_loading_cache;
2354 }
2355
2356 /**
2357 * Reset the `is_initial_loading()` memo. Tests only — PHPUnit reuses a
2358 * single process so `$_GET` from an earlier test would pin the value.
2359 * Guarded against accidental production use.
2360 */
2361 public static function reset_initial_loading_cache(): void {
2362 if ( defined( 'ABSPATH' ) && ! defined( 'PHPUNIT_COMPOSER_INSTALL' ) ) {
2363 return;
2364 }
2365 self::$is_initial_loading_cache = null;
2366 }
2367
2368 /**
2369 * Pre-hydration view state for a filter block's wrapper. Centralizes the
2370 * seeded-state read shared by filter-checkbox and filter-date so each
2371 * render.php branches on a single struct rather than re-deriving the
2372 * same flags inline.
2373 *
2374 * @param string $filter_key The filter key (e.g. `category`, `post_type`).
2375 * @return array{has_buckets:bool,is_initial_loading:bool,show_wrapper:bool}
2376 */
2377 public static function pre_hydration_filter_view( string $filter_key ): array {
2378 if ( ! function_exists( 'wp_interactivity_state' ) ) {
2379 return array(
2380 'has_buckets' => false,
2381 'is_initial_loading' => false,
2382 'show_wrapper' => false,
2383 );
2384 }
2385 // `aggregations` is seeded as `stdClass` when empty (so JS sees `{}`,
2386 // not `[]`); cast before subscripting so the read works in either shape.
2387 $state = wp_interactivity_state( 'jetpack-search' );
2388 $aggs = (array) ( $state['aggregations'] ?? array() );
2389 $has_buckets = ! empty( $aggs[ $filter_key ]['buckets'] ?? array() );
2390 $is_initial_loading = static::is_initial_loading();
2391 return array(
2392 'has_buckets' => $has_buckets,
2393 'is_initial_loading' => $is_initial_loading,
2394 'show_wrapper' => $has_buckets || $is_initial_loading,
2395 );
2396 }
2397
2398 /**
2399 * Emit the `data-wp-context` attribute for a filter block's wrapper. The
2400 * seeded `wrapperHidden` value is what the IA SSR pass evaluates
2401 * `data-wp-bind--hidden="context.wrapperHidden"` against, and what the
2402 * `syncFilterWrapperVisibility` callback updates after hydration.
2403 *
2404 * @param string $filter_key The filter key.
2405 * @param bool $show_wrapper Whether the wrapper should be visible on first paint.
2406 */
2407 public static function emit_filter_wrapper_context( string $filter_key, bool $show_wrapper ): void {
2408 if ( ! function_exists( 'wp_interactivity_data_wp_context' ) ) {
2409 return;
2410 }
2411 echo wp_kses_data(
2412 wp_interactivity_data_wp_context(
2413 array(
2414 'filterKey' => $filter_key,
2415 'wrapperHidden' => ! $show_wrapper,
2416 )
2417 )
2418 );
2419 }
2420
2421 /**
2422 * Normalize the shared `displayStyle` attribute to one of the two CSS
2423 * variants. `filter-wc-stock-status` and `filter-wc-rating` deliberately
2424 * don't ship a chip variant and don't call this helper.
2425 *
2426 * @param mixed $value Raw attribute value.
2427 * @return string Either 'checkbox-list' or 'chips'.
2428 */
2429 public static function normalize_display_style( $value ): string {
2430 return 'chips' === $value ? 'chips' : 'checkbox-list';
2431 }
2432
2433 /**
2434 * Seed translated view-bundle strings for the Interactivity API store.
2435 *
2436 * @return array<string, string>
2437 */
2438 protected static function build_initial_strings(): array {
2439 if ( ! function_exists( '__' ) || ! function_exists( '_n' ) ) {
2440 return array(
2441 'searching' => 'Searching…',
2442 'resultsCountSingle' => 'Found %d result',
2443 'resultsCountPlural' => 'Found %d results',
2444 'removeFilter' => 'Remove %s',
2445 'ratingStarsTop' => '5 stars',
2446 'ratingStarsAndUpSingle' => '%d star and up',
2447 'ratingStarsAndUpPlural' => '%d stars and up',
2448 'priceRangeFromTo' => '%1$s – %2$s',
2449 'priceRangeFrom' => '%s+',
2450 'priceRangeUpTo' => 'Under %s',
2451 'priceLabel' => 'Price',
2452 'suggestionLabelQuery' => 'Suggestions',
2453 'suggestionLabelTaxonomy' => 'Popular Filters',
2454 'suggestionLabelPost' => 'Articles',
2455 'aiErrorMessage' => 'Sorry, an error occurred while generating an answer.',
2456 'aiErrorCode' => 'Error code: %s',
2457 );
2458 }
2459 return array(
2460 'searching' => __( 'Searching…', 'jetpack-search-pkg' ),
2461 /* translators: %d: number of results. */
2462 'resultsCountSingle' => _n( 'Found %d result', 'Found %d results', 1, 'jetpack-search-pkg' ),
2463 /* translators: %d: number of results. */
2464 'resultsCountPlural' => _n( 'Found %d result', 'Found %d results', 2, 'jetpack-search-pkg' ),
2465 /* translators: %s: filter label (e.g. "Category: News"). Announced by screen readers when focus lands on a filter pill's remove button. */
2466 'removeFilter' => __( 'Remove %s', 'jetpack-search-pkg' ),
2467 /* 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. */
2468 'ratingStarsTop' => __( '5 stars', 'jetpack-search-pkg' ),
2469 /* 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. */
2470 'ratingStarsAndUpSingle' => _n( '%d star and up', '%d stars and up', 1, 'jetpack-search-pkg' ),
2471 /* 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. */
2472 'ratingStarsAndUpPlural' => _n( '%d star and up', '%d stars and up', 2, 'jetpack-search-pkg' ),
2473 /* 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. */
2474 'priceRangeFromTo' => __( '%1$s – %2$s', 'jetpack-search-pkg' ),
2475 /* 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. */
2476 'priceRangeFrom' => __( '%s+', 'jetpack-search-pkg' ),
2477 /* 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. */
2478 'priceRangeUpTo' => __( 'Under %s', 'jetpack-search-pkg' ),
2479 /* 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. */
2480 'priceLabel' => __( 'Price', 'jetpack-search-pkg' ),
2481 /* translators: Group label for the typed-query suggestions section of the Search Input autocomplete dropdown. */
2482 'suggestionLabelQuery' => __( 'Suggestions', 'jetpack-search-pkg' ),
2483 /* translators: Group label for the taxonomy (category / tag) section of the Search Input autocomplete dropdown. */
2484 'suggestionLabelTaxonomy' => __( 'Popular Filters', 'jetpack-search-pkg' ),
2485 /* translators: Group label for the post-title section of the Search Input autocomplete dropdown. */
2486 'suggestionLabelPost' => __( 'Articles', 'jetpack-search-pkg' ),
2487 /* 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. */
2488 'aiErrorMessage' => __( 'Sorry, an error occurred while generating an answer.', 'jetpack-search-pkg' ),
2489 /* translators: %s: numeric error code. Surfaces the HTTP / JSON-RPC code that came back with the AI Answer failure, under the technical message. */
2490 'aiErrorCode' => __( 'Error code: %s', 'jetpack-search-pkg' ),
2491 );
2492 }
2493
2494 /**
2495 * Rotating loading hints for the "Show more" extended AI answer.
2496 * Mirrors the overlay verbatim so visitors switching surfaces see
2497 * the same copy.
2498 *
2499 * @return array<int, string>
2500 */
2501 protected static function build_ai_extended_loading_hints(): array {
2502 // Strings omit trailing `…` — render.php appends an animated ellipsis,
2503 // so a static one would double up. Overlay does the same.
2504 if ( ! function_exists( '__' ) ) {
2505 return array(
2506 'Searching harder',
2507 'Looking deeper into this',
2508 'Finding a more complete answer',
2509 'Analyzing additional sources',
2510 'Gathering more details',
2511 'Pulling in more context',
2512 'Expanding the search',
2513 'Rolling up my virtual sleeves',
2514 'Digging through the archives',
2515 'Putting on my reading glasses',
2516 'Checking under the digital couch cushions',
2517 'Consulting the oracle',
2518 'Asking a smarter algorithm',
2519 'Brewing a fresh batch of insights',
2520 'Unleashing the full power of search',
2521 );
2522 }
2523 return array(
2524 __( 'Searching harder', 'jetpack-search-pkg' ),
2525 __( 'Looking deeper into this', 'jetpack-search-pkg' ),
2526 __( 'Finding a more complete answer', 'jetpack-search-pkg' ),
2527 __( 'Analyzing additional sources', 'jetpack-search-pkg' ),
2528 __( 'Gathering more details', 'jetpack-search-pkg' ),
2529 __( 'Pulling in more context', 'jetpack-search-pkg' ),
2530 __( 'Expanding the search', 'jetpack-search-pkg' ),
2531 __( 'Rolling up my virtual sleeves', 'jetpack-search-pkg' ),
2532 __( 'Digging through the archives', 'jetpack-search-pkg' ),
2533 __( 'Putting on my reading glasses', 'jetpack-search-pkg' ),
2534 __( 'Checking under the digital couch cushions', 'jetpack-search-pkg' ),
2535 __( 'Consulting the oracle', 'jetpack-search-pkg' ),
2536 __( 'Asking a smarter algorithm', 'jetpack-search-pkg' ),
2537 __( 'Brewing a fresh batch of insights', 'jetpack-search-pkg' ),
2538 __( 'Unleashing the full power of search', 'jetpack-search-pkg' ),
2539 );
2540 }
2541
2542 /**
2543 * Parse the search query from the URL using whichever key
2544 * `get_search_param_name()` returns (`s` on search routes, `q` elsewhere).
2545 * Public so render templates can seed their input from the same source.
2546 *
2547 * @return string
2548 */
2549 public static function parse_url_search_query(): string {
2550 $key = self::get_search_param_name();
2551 // 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.
2552 $raw = $_GET[ $key ] ?? '';
2553 if ( ! is_scalar( $raw ) ) {
2554 return '';
2555 }
2556 return trim( sanitize_text_field( wp_unslash( (string) $raw ) ) );
2557 }
2558
2559 /**
2560 * Whether the search-query key is present in `$_GET` (any value).
2561 * Distinguishes `?s=` (blank search) from a URL that omits the key —
2562 * `parse_url_search_query()` collapses both to `''`. Array-shaped
2563 * `?s[]=foo` reads as "not present" to stay in lockstep.
2564 *
2565 * @return bool
2566 */
2567 public static function has_search_param(): bool {
2568 $key = self::get_search_param_name();
2569 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only URL presence check; the value is never read here.
2570 return isset( $_GET[ $key ] ) && is_scalar( $_GET[ $key ] );
2571 }
2572
2573 /**
2574 * Parse the sort order from the URL, defaulting to 'relevance'. Allowed
2575 * values track `Results_Sort::get_all_option_keys()` — on non-Woo sites
2576 * a `?orderby=price_asc` deep link collapses to `relevance` (mirrors
2577 * `store/url-state.js`).
2578 *
2579 * @return string
2580 */
2581 protected static function parse_url_sort(): string {
2582 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only URL state.
2583 $orderby = isset( $_GET['orderby'] ) ? sanitize_key( wp_unslash( $_GET['orderby'] ) ) : '';
2584 $allowed = array_values(
2585 array_filter(
2586 Results_Sort::get_all_option_keys(),
2587 static function ( $key ) {
2588 return 'relevance' !== $key;
2589 }
2590 )
2591 );
2592 return in_array( $orderby, $allowed, true ) ? $orderby : 'relevance';
2593 }
2594
2595 /**
2596 * Parse the price range from the URL. Mirrors `store/url-state.js`.
2597 * Either bound may be null for a half-open range; non-numeric or
2598 * negative values null out. Returns null entirely on non-Woo sites —
2599 * `min_price`/`max_price` are WC-only and a stray param shouldn't drive
2600 * the API into a `range` clause for a field the index doesn't have.
2601 *
2602 * @return array{min: float|null, max: float|null}|null
2603 */
2604 protected static function parse_url_price_range(): ?array {
2605 if ( ! self::woocommerce_blocks_enabled() ) {
2606 return null;
2607 }
2608 // phpcs:disable WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- coerced to float in parse_price_bound().
2609 $min = self::parse_price_bound( $_GET['min_price'] ?? null );
2610 $max = self::parse_price_bound( $_GET['max_price'] ?? null );
2611 // phpcs:enable
2612
2613 if ( null === $min && null === $max ) {
2614 return null;
2615 }
2616 // Inverted bounds → empty ES `range` clause / zero results silently.
2617 // Treat as garbage and bail so the page falls back to unfiltered search.
2618 if ( null !== $min && null !== $max && $min > $max ) {
2619 return null;
2620 }
2621 return array(
2622 'min' => $min,
2623 'max' => $max,
2624 );
2625 }
2626
2627 /**
2628 * Coerce a single price-range URL value into a finite, non-negative float.
2629 *
2630 * @param mixed $raw Raw value pulled from $_GET.
2631 * @return float|null
2632 */
2633 private static function parse_price_bound( $raw ): ?float {
2634 if ( null === $raw || '' === $raw || ! is_scalar( $raw ) ) {
2635 return null;
2636 }
2637 // `is_numeric` keeps PHP in lockstep with JS's `Number()`: rejects
2638 // partially-numeric strings ("1.5.3") that `(float)` would silently
2639 // extract as `1.5` while `Number()` returns `NaN`.
2640 $raw = wp_unslash( $raw );
2641 if ( ! is_numeric( $raw ) ) {
2642 return null;
2643 }
2644 $num = (float) $raw;
2645 if ( ! is_finite( $num ) || $num < 0 ) {
2646 return null;
2647 }
2648 return $num;
2649 }
2650
2651 /**
2652 * Parse `?<filterKey>[]=<value>` URL params into `{ [filterKey]: string[] }`.
2653 * Mirrors the shape `store/url-state.js` writes (see AGENTS.md § URL format).
2654 * No registered-key filtering here — `filterConfigs` aren't available until
2655 * blocks render. The JS layer gates on hydration.
2656 *
2657 * Scalar `?post_type=<slug>` is also accepted as a shortcut for
2658 * `?post_types[]=<slug>` — matches WP/WC's own URL convention. Merged into
2659 * any existing array selections so `?post_type=foo&post_types[]=bar` reads
2660 * as `[foo, bar]`. Singular-form-on-an-array-key keeps its existing
2661 * "ignored noise" behaviour for every other filter.
2662 *
2663 * @return array<string, string[]>
2664 */
2665 protected static function parse_url_filters(): array {
2666 // phpcs:ignore WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- read-only URL state; sanitized per-value below.
2667 $raw = wp_unslash( $_GET );
2668 if ( ! is_array( $raw ) ) {
2669 return array();
2670 }
2671
2672 $out = array();
2673 foreach ( $raw as $key => $values ) {
2674 $filter_key = sanitize_key( (string) $key );
2675 if ( '' === $filter_key || in_array( $filter_key, self::RESERVED_QUERY_PARAMS, true ) ) {
2676 continue;
2677 }
2678 if ( 'post_type' === $filter_key ) {
2679 // `is_string` (not `is_scalar`) keeps the gate consistent with
2680 // `parse_url_filter_logic`'s value check — `$_GET` only ever
2681 // carries strings or arrays, and the array case takes the
2682 // `is_array( $values )` branch immediately below.
2683 if ( ! is_string( $values ) ) {
2684 continue;
2685 }
2686 // `sanitize_key`, not `sanitize_text_field` — post-type slugs are
2687 // always lowercase + `[a-z0-9_-]`; the lowercase pass keeps a
2688 // `?post_type=Product` URL from reaching ES with the wrong case
2689 // and silently returning zero results.
2690 $slug = sanitize_key( $values );
2691 if ( '' === $slug ) {
2692 continue;
2693 }
2694 $existing = $out['post_types'] ?? array();
2695 $out['post_types'] = array_values( array_unique( array_merge( $existing, array( $slug ) ) ) );
2696 continue;
2697 }
2698 if ( ! is_array( $values ) ) {
2699 continue;
2700 }
2701 $clean = array_values(
2702 array_filter(
2703 array_map( 'sanitize_text_field', $values ),
2704 static function ( $v ) {
2705 return '' !== $v;
2706 }
2707 )
2708 );
2709 if ( $clean ) {
2710 $existing = $out[ $filter_key ] ?? array();
2711 $out[ $filter_key ] = array_values( array_unique( array_merge( $existing, $clean ) ) );
2712 }
2713 }
2714 return $out;
2715 }
2716
2717 /**
2718 * Parse `?query_type_<key>=and` overrides into `{ [filterKey]: 'and' }`.
2719 * Only literal `'and'` is honoured — anything else is dropped so it
2720 * can't round-trip back through `pushStateToUrl`. Mirrors
2721 * `store/url-state.js`.
2722 *
2723 * @param array<string, string[]> $active_filters Result of parse_url_filters().
2724 * @return array<string, string>
2725 */
2726 protected static function parse_url_filter_logic( array $active_filters ): array {
2727 // phpcs:ignore WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- read-only URL state; sanitized per-value below.
2728 $raw = wp_unslash( $_GET );
2729 if ( ! is_array( $raw ) ) {
2730 return array();
2731 }
2732
2733 $out = array();
2734 foreach ( $raw as $key => $value ) {
2735 if ( ! is_string( $key ) || 0 !== strpos( $key, 'query_type_' ) ) {
2736 continue;
2737 }
2738 if ( ! is_string( $value ) || 'and' !== $value ) {
2739 continue;
2740 }
2741 $filter_key = sanitize_key( substr( $key, strlen( 'query_type_' ) ) );
2742 if ( '' === $filter_key || in_array( $filter_key, self::RESERVED_QUERY_PARAMS, true ) ) {
2743 continue;
2744 }
2745 if ( empty( $active_filters[ $filter_key ] ) ) {
2746 continue;
2747 }
2748 $out[ $filter_key ] = 'and';
2749 }
2750 return $out;
2751 }
2752 }
2753