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