Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
66.57% covered (warning)
66.57%
221 / 332
31.03% covered (danger)
31.03%
9 / 29
CRAP
0.00% covered (danger)
0.00%
0 / 1
Instant_Search
66.67% covered (warning)
66.67%
220 / 330
31.03% covered (danger)
31.03%
9 / 29
445.48
0.00% covered (danger)
0.00%
0 / 1
 instance
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 init_hooks
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
2.01
 load_assets
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 load_assets_with_parameters
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
2
 inject_javascript_options
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 register_jetpack_instant_sidebar
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
1
 load_and_initialize_tracks
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 filter__posts_pre_query
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 fetch_search_result_if_empty
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
20
 instant_api
74.47% covered (warning)
74.47%
35 / 47
0.00% covered (danger)
0.00%
0 / 1
11.66
 get_search_aggregations_results
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
20
 auto_config_search
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 auto_config_theme_sidebar_search_widget
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 auto_config_overlay_sidebar_widgets
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
5
 auto_config_non_fse_theme_sidebar_search_widget
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
4
 get_next_jp_search_widget_id
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 get_search_widget_indices
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
5
 widget_has_search_block
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
8.06
 content_has_search_block
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 auto_config_fse_theme_footer_search_block
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
20
 replace_block_patterns
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 get_block_pattern_content
80.00% covered (warning)
80.00%
8 / 10
0.00% covered (danger)
0.00%
0 / 1
7.39
 get_template_part
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
20
 template_parts_have_search_block
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
20
 inject_search_widget_to_block
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
3.01
 get_preconfig_widget_options
79.25% covered (warning)
79.25%
42 / 53
0.00% covered (danger)
0.00%
0 / 1
3.08
 auto_config_excluded_post_types
64.71% covered (warning)
64.71%
11 / 17
0.00% covered (danger)
0.00%
0 / 1
9.15
 auto_config_result_format
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
3
 add_body_class
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
3 * Instant Search: Our modern and customizable search experience.
4 *
5 * @package automattic/jetpack-search
6 */
7
8namespace Automattic\Jetpack\Search;
9
10use Automattic\Jetpack\Assets;
11use WP_Block_Parser;
12use WP_Block_Patterns_Registry;
13use WP_Error;
14use WP_Query;
15use WP_REST_Templates_Controller;
16
17if ( ! defined( 'ABSPATH' ) ) {
18    exit( 0 );
19}
20
21/**
22 * Class responsible for enabling the Instant Search experience on the site.
23 */
24class Instant_Search extends Classic_Search {
25    /**
26     * The name of instant search sidebar.
27     *
28     * 'sidebar' is broken to 'side-bar' on purpose to walk around the mechanism that WP automatically adds widgets to it.
29     *
30     * @since 9.8.0
31     * @var string
32     */
33    const INSTANT_SEARCH_SIDEBAR     = 'jetpack-instant-search-side-bar';
34    const OLD_INSTANT_SEARCH_SIDEBAR = 'jetpack-instant-search-sidebar';
35
36    const AUTO_CONFIG_SIDEBAR = 'sidebar-1';
37
38    /**
39     * The singleton instance of this class.
40     * Instant_Search shouldn't share the variable with its parent.
41     *
42     * @var Instant_Search
43     */
44    private static $instance;
45
46    /**
47     * Variable to save old sidebars_widgets value.
48     *
49     * The value is set when action `after_switch_theme` is applied and cleared on filter `pre_update_option_sidebars_widgets`.
50     * The filters mentioned above run on /wp-admin/themes.php?activated=true, a request closely following switching theme.
51     *
52     * @since 9.8.0
53     *
54     * @var array
55     */
56    protected $old_sidebars_widgets;
57
58    /**
59     * Returns a class singleton. Initializes with first-time setup if given a blog ID parameter.
60     *
61     * @param string $blog_id Blog id.
62     * @return static The class singleton.
63     */
64    public static function instance( $blog_id = null ) {
65        if ( ! isset( self::$instance ) ) {
66            if ( null === $blog_id ) {
67                $blog_id = Helper::get_wpcom_site_id();
68            }
69            self::$instance = new static();
70            self::$instance->setup( $blog_id );
71        }
72        return self::$instance;
73    }
74
75    /**
76     * Setup the various hooks needed for the plugin to take over search duties.
77     *
78     * @since 5.0.0
79     */
80    public function init_hooks() {
81        if ( ! is_admin() ) {
82            add_filter( 'posts_pre_query', array( $this, 'filter__posts_pre_query' ), 10, 2 );
83
84            add_action( 'init', array( $this, 'set_filters_from_widgets' ) );
85
86            add_action( 'wp_enqueue_scripts', array( $this, 'load_assets' ) );
87            add_action( 'wp_footer', array( 'Automattic\Jetpack\Search\Helper', 'print_instant_search_sidebar' ) );
88            add_filter( 'body_class', array( $this, 'add_body_class' ), 10 );
89        } else {
90            add_action( 'update_option', array( $this, 'track_widget_updates' ), 10, 3 );
91        }
92
93        add_action( 'widgets_init', array( $this, 'register_jetpack_instant_sidebar' ) );
94        add_action( 'jetpack_deactivate_module_search', array( $this, 'move_search_widgets_to_inactive' ) );
95    }
96
97    /**
98     * Loads assets for Jetpack Instant Search Prototype featuring Search As You Type experience.
99     */
100    public function load_assets() {
101        $this->load_assets_with_parameters( Package::get_installed_path() );
102    }
103
104    /**
105     * Loads assets according to parameters provided.
106     *
107     * @param string $package_base_path - Base path for the search package.
108     */
109    public function load_assets_with_parameters( $package_base_path ) {
110        Assets::register_script(
111            'jetpack-instant-search',
112            'build/instant-search/jp-search.js',
113            $package_base_path . '/src', // A full path to a file or a directory inside a plugin.
114            array(
115                'dependencies'     => array( 'wp-i18n' ),
116                'in_footer'        => true,
117                'textdomain'       => 'jetpack-search-pkg',
118                // CSS is extracted by webpack but not auto-injected, allowing WordPress to control loading
119                'css_path'         => 'build/instant-search/jp-search.chunk-main-payload.css',
120                'css_dependencies' => array(),
121            )
122        );
123        Assets::enqueue_script( 'jetpack-instant-search' );
124        $this->load_and_initialize_tracks();
125        $this->inject_javascript_options();
126    }
127
128    /**
129     * Passes all options to the JS app.
130     */
131    protected function inject_javascript_options() {
132        $options = Helper::generate_initial_javascript_state();
133        // Use wp_add_inline_script instead of wp_localize_script, see https://core.trac.wordpress.org/ticket/25280.
134        wp_add_inline_script( 'jetpack-instant-search', 'var JetpackInstantSearchOptions=' . wp_json_encode( $options, JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP ) . ';', 'before' );
135    }
136
137    /**
138     * Registers a widget sidebar for Instant Search.
139     */
140    public function register_jetpack_instant_sidebar() {
141        $args = array(
142            'name'          => __( 'Jetpack Search Sidebar', 'jetpack-search-pkg' ),
143            'id'            => self::INSTANT_SEARCH_SIDEBAR,
144            'description'   => __( 'Customize the sidebar inside the Jetpack Search overlay', 'jetpack-search-pkg' ),
145            'class'         => '',
146            'before_widget' => '<div id="%1$s" class="widget %2$s">',
147            'after_widget'  => '</div>',
148            'before_title'  => '<h2 class="widgettitle">',
149            'after_title'   => '</h2>',
150        );
151        register_sidebar( $args );
152    }
153
154    /**
155     * Loads scripts for Tracks analytics library
156     */
157    public function load_and_initialize_tracks() {
158        wp_enqueue_script( 'jp-tracks', '//stats.wp.com/w.js', array(), gmdate( 'YW' ), true );
159    }
160
161    /**
162     * Bypass the normal Search query since we will run it with instant search.
163     *
164     * @since 8.3.0
165     *
166     * @param array    $posts Current array of posts (still pre-query).
167     * @param WP_Query $query The WP_Query being filtered.
168     *
169     * @return array Array of matching posts.
170     */
171    public function filter__posts_pre_query( $posts, $query ) {
172        if ( ! $this->should_handle_query( $query ) ) {
173            // Intentionally not adding the 'jetpack_search_abort' action since this should fire for every request except for search.
174            return $posts;
175        }
176
177        /**
178         * Bypass the main query and return dummy data
179         *  WP Core doesn't call the set_found_posts and its filters when filtering
180         *  posts_pre_query like we do, so need to do these manually.
181         */
182        $query->found_posts   = 1;
183        $query->max_num_pages = 1;
184
185        return array();
186    }
187
188    /**
189     * Run the aggregations API query for any filtering
190     *
191     * @since 8.3.0
192     */
193    public function fetch_search_result_if_empty() {
194        if ( ! empty( $this->search_result ) ) {
195            return;
196        }
197
198        if ( is_admin() ) {
199            return;
200        }
201
202        if ( empty( $this->aggregations ) ) {
203            return;
204        }
205
206        $builder = new WPES\Query_Builder();
207        $this->add_aggregations_to_es_query_builder( $this->aggregations, $builder );
208        $this->search_result = $this->instant_api(
209            array(
210                'aggregations' => $builder->build_aggregation(),
211                'size'         => 0,
212                'from'         => 0,
213            )
214        );
215    }
216
217    /**
218     * Run an instant search on the WordPress.com public API.
219     *
220     * @since 8.3.0
221     *
222     * @param array $args Args conforming to the WP.com v1.3/sites/<blog_id>/search endpoint.
223     *
224     * @return object|WP_Error The response from the public API, or a WP_Error.
225     */
226    public function instant_api( array $args ) {
227        global $wp_version;
228        $start_time = microtime( true );
229
230        // Cache locally to avoid remote request slowing the page.
231        $transient_name = 'jetpack_instant_search_cache_' . md5( wp_json_encode( $args, JSON_UNESCAPED_SLASHES ) );
232        $cache          = get_transient( $transient_name );
233        if ( false !== $cache ) {
234            return $cache;
235        }
236
237        $service_url = add_query_arg(
238            $args,
239            sprintf(
240                'https://public-api.wordpress.com/rest/v1.3/sites/%d/search',
241                $this->jetpack_blog_id
242            )
243        );
244
245        $request_args = array(
246            'timeout'    => 10,
247            'user-agent' => "WordPress/{$wp_version} | Jetpack-Search/" . Package::VERSION,
248        );
249
250        $request  = wp_remote_get( esc_url_raw( $service_url ), $request_args );
251        $end_time = microtime( true );
252
253        if ( is_wp_error( $request ) ) {
254            return $request;
255        }
256
257        $response_code = wp_remote_retrieve_response_code( $request );
258        $response      = json_decode( wp_remote_retrieve_body( $request ), true );
259
260        if ( isset( $response['swap_classic_to_inline_search'] ) && $response['swap_classic_to_inline_search'] === false ) {
261            update_option( Module_Control::SEARCH_MODULE_SWAP_CLASSIC_TO_INLINE_OPTION_KEY, false );
262        }
263
264        if ( ! $response_code || $response_code < 200 || $response_code >= 300 ) {
265            /**
266             * Fires after a search query request has failed
267             *
268             * @module search
269             *
270             * @since  5.6.0
271             *
272             * @param array Array containing the response code and response from the failed search query
273             */
274            do_action(
275                'failed_jetpack_search_query',
276                array(
277                    'response_code' => $response_code,
278                    'json'          => $response,
279                )
280            );
281
282            return new WP_Error( 'invalid_search_api_response', 'Invalid response from API - ' . $response_code );
283        }
284
285        $took = is_array( $response ) && ! empty( $response['took'] )
286        ? $response['took']
287        : null;
288
289        $query = array(
290            'args'          => $args,
291            'response'      => $response,
292            'response_code' => $response_code,
293            'elapsed_time'  => ( $end_time - $start_time ) * 1000, // Convert from float seconds to ms.
294            'es_time'       => $took,
295            'url'           => $service_url,
296        );
297
298        /**
299         * Fires after a search request has been performed.
300         *
301         * Includes the following info in the $query parameter:
302         *
303         * array args Array of Elasticsearch arguments for the search
304         * array response Raw API response, JSON decoded
305         * int response_code HTTP response code of the request
306         * float elapsed_time Roundtrip time of the search request, in milliseconds
307         * float es_time Amount of time Elasticsearch spent running the request, in milliseconds
308         * string url API url that was queried
309         *
310         * @module search
311         *
312         * @since  5.0.0
313         * @since  5.8.0 This action now fires on all queries instead of just successful queries.
314         *
315         * @param array $query Array of information about the query performed
316         */
317        do_action( 'did_jetpack_search_query', $query );
318
319        // Update local cache.
320        set_transient( $transient_name, $response, 4 * HOUR_IN_SECONDS );
321
322        return $response;
323    }
324
325    /**
326     * Get the raw Aggregation results from the Elasticsearch response.
327     *
328     * @since  8.4.0
329     *
330     * @return array Array of Aggregations performed on the search.
331     */
332    public function get_search_aggregations_results() {
333        $this->fetch_search_result_if_empty();
334        if ( empty( $this->search_result ) || is_wp_error( $this->search_result ) || ! isset( $this->search_result['aggregations'] ) ) {
335            return array();
336        }
337
338        return $this->search_result['aggregations'];
339    }
340
341    /**
342     * Automatically configure necessary settings for instant search
343     *
344     * @since  8.3.0
345     */
346    public function auto_config_search() {
347        $this->auto_config_excluded_post_types();
348        $this->auto_config_overlay_sidebar_widgets();
349        $this->auto_config_theme_sidebar_search_widget();
350        $this->auto_config_result_format();
351    }
352
353    /**
354     * Auto config search widget or block for current theme.
355     */
356    public function auto_config_theme_sidebar_search_widget() {
357        if ( \current_theme_supports( 'block-templates' ) ) {
358            $this->auto_config_fse_theme_footer_search_block();
359        } else {
360            $this->auto_config_non_fse_theme_sidebar_search_widget();
361        }
362    }
363
364    /**
365     * Automatically copy configured search widgets from theme sidebar to the overlay sidebar.
366     * If there's nothing to copy, we create one.
367     *
368     * @since  8.8.0
369     */
370    public function auto_config_overlay_sidebar_widgets() {
371        $sidebars                               = get_option( 'sidebars_widgets', array() );
372        list(, $sidebar_jp_searchbox_wiget_id ) = $this->get_search_widget_indices( $sidebars, self::INSTANT_SEARCH_SIDEBAR );
373        // If there's JP search widget in overly sidebar, abort.
374        if ( false !== $sidebar_jp_searchbox_wiget_id ) {
375            return;
376        }
377
378        // Init overlay sidebar if it doesn't exists.
379        if ( ! isset( $sidebars[ self::INSTANT_SEARCH_SIDEBAR ] ) ) {
380            $this->register_jetpack_instant_sidebar();
381            $sidebars[ self::INSTANT_SEARCH_SIDEBAR ] = array();
382        }
383
384        $widget_opt_name = Helper::get_widget_option_name();
385        $widget_options  = get_option( $widget_opt_name, array() );
386
387        $next_id = $this->get_next_jp_search_widget_id( $widget_options );
388
389        list(, $sidebar_jp_searchbox_wiget_id ) = $this->get_search_widget_indices( $sidebars, self::AUTO_CONFIG_SIDEBAR );
390        if ( false !== $sidebar_jp_searchbox_wiget_id && isset( $widget_options[ $sidebar_jp_searchbox_wiget_id ] ) ) {
391            // If there is a JP search widget in the theme sidebar, copy it over to the search overlay sidebar.
392            $widget_options[ $next_id ] = $widget_options[ $sidebar_jp_searchbox_wiget_id ];
393        } else {
394            // If JP Search widget doesn't exist in the theme sidebar, we have nothing to copy from, so we create a new one within the overlay sidebar.
395            $search_widget = new Search_Widget();
396            $search_widget->_set( $next_id );
397            $search_widget->_register_one( $next_id );
398            $widget_options[ $next_id ] = $this->get_preconfig_widget_options();
399        }
400        array_unshift( $sidebars[ self::INSTANT_SEARCH_SIDEBAR ], Helper::build_widget_id( $next_id ) );
401        update_option( $widget_opt_name, $widget_options );
402        update_option( 'sidebars_widgets', $sidebars );
403        return true;
404    }
405
406    /**
407     * Replace core search widget in theme sidebar if exists.
408     */
409    public function auto_config_non_fse_theme_sidebar_search_widget() {
410        $sidebars = get_option( 'sidebars_widgets', array() );
411        if ( ! isset( $sidebars[ self::AUTO_CONFIG_SIDEBAR ] ) ) {
412            return;
413        }
414
415        list( $sidebar_searchbox_idx, $sidebar_jp_searchbox_wiget_id ) = $this->get_search_widget_indices( $sidebars );
416        // If there's JP search widget in theme sidebar, abort.
417        if ( false !== $sidebar_jp_searchbox_wiget_id ) {
418            return;
419        }
420
421        $widget_opt_name = Helper::get_widget_option_name();
422        $widget_options  = get_option( $widget_opt_name, array() );
423
424        list($sidebar_searchbox_idx, ) = $this->get_search_widget_indices( $sidebars );
425        $next_id                       = $this->get_next_jp_search_widget_id( $widget_options );
426        $preconfig_opts                = $this->get_preconfig_widget_options();
427
428        $widget_options[ $next_id ] = $preconfig_opts;
429        if ( false !== $sidebar_searchbox_idx ) {
430            // Replace core search widget with JP search widget.
431            $sidebars[ self::AUTO_CONFIG_SIDEBAR ][ $sidebar_searchbox_idx ] = Helper::build_widget_id( $next_id );
432        } else {
433            // No core search widget found, so we don't need to replace anything.
434            return true;
435        }
436
437        update_option( $widget_opt_name, $widget_options );
438        update_option( 'sidebars_widgets', $sidebars );
439        return true;
440    }
441
442    /**
443     * Get the next ID for the Jetpack Search widget, which is equivalent to the last JP Search widget ID + 1.
444     *
445     * @param array $widget_options - jetpack widget option value.
446     *
447     * @return int
448     */
449    public function get_next_jp_search_widget_id( $widget_options ) {
450        return ! empty( $widget_options ) ? max(
451            array_map(
452                function ( $val ) {
453                    return intval( $val );
454                },
455                array_keys( $widget_options )
456            )
457        ) + 1 : 1;
458    }
459
460    /**
461     * Get search and JP Search widget indices in theme sidebar.
462     *
463     * @param array  $sidebars - theme `sidebars_widgets` option value.
464     * @param string $sidebar_id - the sidebar id to search on.
465     *
466     * @return array - core search widget index and JP search widget id.
467     */
468    protected function get_search_widget_indices( $sidebars, $sidebar_id = 'sidebar-1' ) {
469        $sidebar_searchbox_idx   = false;
470        $sidebar_jp_searchbox_id = false;
471        if ( isset( $sidebars[ $sidebar_id ] ) ) {
472            foreach ( (array) $sidebars[ $sidebar_id ] as $idx => $widget_id ) {
473                if ( $this->widget_has_search_block( $widget_id ) ) {
474                    // The array index of wp search widget.
475                    $sidebar_searchbox_idx = $idx;
476                }
477                if ( str_starts_with( $widget_id, Helper::FILTER_WIDGET_BASE ) ) {
478                    // The id of Jetpack Search widget.
479                    $sidebar_jp_searchbox_id = str_replace( Helper::FILTER_WIDGET_BASE . '-', '', $widget_id );
480                }
481            }
482        }
483        return array( $sidebar_searchbox_idx, $sidebar_jp_searchbox_id );
484    }
485
486    /**
487     * Returns true if search widget or block exists in widgets
488     *
489     * @param string $widget_id - widget ID.
490     */
491    protected function widget_has_search_block( $widget_id ) {
492        // test search widget.
493        if ( str_starts_with( $widget_id, 'search-' ) ) {
494            return true;
495        }
496        // test search block widget.
497        if ( str_starts_with( $widget_id, 'block-' ) ) {
498            $widget_blocks = get_option( 'widget_block', array() );
499            $widget_index  = str_replace( 'block-', '', $widget_id );
500            // A single block could be of type string or array.
501            if ( isset( $widget_blocks[ $widget_index ]['content'] ) && str_contains( (string) $widget_blocks[ $widget_index ]['content'], 'wp:search' ) ) {
502                return true;
503            }
504            if ( isset( $widget_blocks[ $widget_index ] ) && is_string( $widget_blocks[ $widget_index ] ) && str_contains( $widget_blocks[ $widget_index ], 'wp:search' ) ) {
505                return true;
506            }
507        }
508        return false;
509    }
510
511    /**
512     * Returns true if $block_content has core search block
513     *
514     * @param string $block_content - Block content.
515     *
516     * @return boolean
517     */
518    public static function content_has_search_block( $block_content ) {
519        return preg_match( '/(<!--\swp:search\s[^>]*-->)/i', $block_content ) > 0;
520    }
521
522    /**
523     * Add a search widget above footer for block templates.
524     */
525    public function auto_config_fse_theme_footer_search_block() {
526        if ( ! class_exists( 'WP_REST_Templates_Controller' ) ) {
527            return;
528        }
529        // We currently check only for a core search block.
530        // In the future, we will need to check for a Jetpack Search block once it's available.
531        if ( $this->template_parts_have_search_block() ) {
532            return;
533        }
534
535        $footer = $this->get_template_part( 'footer' );
536        if ( ! $footer instanceof \WP_Block_Template ) {
537            return;
538        }
539
540        $content          = $this->replace_block_patterns( $footer->content );
541        $template_part_id = $footer->id;
542        $request          = new \WP_REST_Request( 'PUT', "/wp/v2/template-parts/{$template_part_id}" );
543        $request->set_header( 'content-type', 'application/json' );
544        $request->set_param( 'content', static::inject_search_widget_to_block( $content ) );
545        $request->set_param( 'id', $template_part_id );
546        $controller = new WP_REST_Templates_Controller( 'wp_template_part' );
547        return $controller->update_item( $request );
548    }
549
550    /**
551     * Replace pattern blocks with their content.
552     * We don't want to replace recursively for the sake of simplicity.
553     *
554     * @param string $block_content - Content of template part.
555     */
556    protected function replace_block_patterns( $block_content ) {
557        $matches = array();
558        if ( preg_match( '/<!--\s*wp:pattern\s+{.*}\s*\/-->/', $block_content, $matches ) > 0 ) {
559            foreach ( $matches as $match ) {
560                $pattern_content = $this->get_block_pattern_content( $match );
561                $block_content   = str_replace( $match, $pattern_content, $block_content );
562            }
563        }
564        return $block_content;
565    }
566
567    /**
568     * Extracts block content only if it consists of a single pattern block.
569     *
570     * @param string $block_pattern - Block content.
571     */
572    protected function get_block_pattern_content( $block_pattern ) {
573        if ( ! class_exists( 'WP_Block_Parser' ) || ! class_exists( 'WP_Block_Patterns_Registry' ) ) {
574            return $block_pattern;
575        }
576        $blocks = ( new WP_Block_Parser() )->parse( $block_pattern );
577        if ( is_countable( $blocks ) && 1 === count( $blocks ) && 'core/pattern' === $blocks[0]['blockName'] ) {
578            $slug     = $blocks[0]['attrs']['slug'];
579            $registry = WP_Block_Patterns_Registry::get_instance();
580            if ( $registry->is_registered( $slug ) ) {
581                $pattern = $registry->get_registered( $slug );
582                return $pattern['content'] ?? '';
583            }
584        }
585        return $block_pattern;
586    }
587
588    /**
589     * Get template part for current theme.
590     *
591     * @param string $template_part_name - header, footer, home etc.
592     *
593     * @return \WP_Block_Template
594     */
595    protected function get_template_part( $template_part_name ) {
596        // Check whether block theme functions exist.
597        if ( ! function_exists( 'get_block_template' ) ) {
598            return null;
599        }
600        $active_theme     = \wp_get_theme()->get_stylesheet();
601        $template_part_id = "{$active_theme}//{$template_part_name}";
602        $template_part    = \get_block_template( $template_part_id, 'wp_template_part' );
603        if ( is_wp_error( $template_part ) || empty( $template_part ) ) {
604            return null;
605        }
606        return $template_part;
607    }
608
609    /**
610     * Returns true if  'header', 'footer' or 'home' has core search block
611     *
612     * @return boolean
613     */
614    protected function template_parts_have_search_block() {
615        $template_part_names = array( 'header', 'footer', 'home' );
616        foreach ( $template_part_names as $part_name ) {
617            $part = $this->get_template_part( $part_name );
618            if ( $part instanceof \WP_Block_Template && static::content_has_search_block( $part->content ) ) {
619                return true;
620            }
621        }
622        return false;
623    }
624
625    /**
626     * Append Search block to block if no 'wp:search' exists already.
627     *
628     * @param string $block_content - the content to append the search block.
629     */
630    public static function inject_search_widget_to_block( $block_content ) {
631        $search_block = sprintf(
632            '<!-- wp:search {"label":"","buttonText":"%s"} /-->',
633            __( 'Search', 'jetpack-search-pkg' )
634        );
635
636        // Place the search block on bottom of the first column if there's any.
637        $column_end_pattern = '/(<\s*\/div[^>]*>\s*<!--\s*\/wp:column\s+[^>]*-->)/';
638        if ( preg_match( $column_end_pattern, $block_content ) ) {
639            return preg_replace( $column_end_pattern, "\n" . $search_block . "\n$1", $block_content, 1 );
640        }
641
642        // Place the search block on top of footer contents in the most inner group.
643        $group_start_pattern = '/((<!--\s*wp:group\s[^>]*-->[.\s]*<\s*div[^>]*>\s*)+)/';
644        if ( preg_match( $group_start_pattern, $block_content, $matches ) ) {
645            return preg_replace( $group_start_pattern, "$1\n" . $search_block . "\n", $block_content, 1 );
646        }
647
648        return $block_content;
649    }
650
651    /**
652     * Autoconfig search by adding filter widgets
653     *
654     * @since  8.4.0
655     *
656     * @return array Array of config settings for search widget.
657     */
658    protected function get_preconfig_widget_options() {
659        $settings = array(
660            'title'   => '',
661            'filters' => array(),
662        );
663
664        $post_types = get_post_types(
665            array(
666                'public'   => true,
667                '_builtin' => false,
668            )
669        );
670
671        if ( ! empty( $post_types ) ) {
672            $settings['filters'][] = array(
673                'name'  => '',
674                'type'  => 'post_type',
675                'count' => 5,
676            );
677        }
678
679        // Grab a maximum of 3 taxonomies.
680        $taxonomies = array_slice(
681            get_taxonomies(
682                array(
683                    'public'   => true,
684                    '_builtin' => false,
685                )
686            ),
687            0,
688            3
689        );
690
691        foreach ( $taxonomies as $t ) {
692            $settings['filters'][] = array(
693                'name'     => '',
694                'type'     => 'taxonomy',
695                'taxonomy' => $t,
696                'count'    => 5,
697            );
698        }
699
700        $settings['filters'][] = array(
701            'name'     => '',
702            'type'     => 'taxonomy',
703            'taxonomy' => 'category',
704            'count'    => 5,
705        );
706
707        $settings['filters'][] = array(
708            'name'     => '',
709            'type'     => 'taxonomy',
710            'taxonomy' => 'post_tag',
711            'count'    => 5,
712        );
713
714        $settings['filters'][] = array(
715            'name'     => '',
716            'type'     => 'date_histogram',
717            'count'    => 5,
718            'field'    => 'post_date',
719            'interval' => 'year',
720        );
721
722        return $settings;
723    }
724
725    /**
726     * Automatically configure post types to exclude from one of the search widgets.
727     * Used primarily for backward compatibility with older Jetpack plugins, which used to store excluded post type configuration within the Jetpack Search plugin instead of as an option.
728     *
729     * @since  8.8.0
730     */
731    public function auto_config_excluded_post_types() {
732        // if `excluded_post_types` exists, then we do nothing.
733        if ( false !== get_option( Options::OPTION_PREFIX . 'excluded_post_types', false ) ) {
734            return;
735        }
736        $post_types         = get_post_types(
737            array(
738                'exclude_from_search' => false,
739                'public'              => true,
740            )
741        );
742        $enabled_post_types = array();
743        $widget_options     = get_option( Helper::get_widget_option_name(), array() );
744
745        // Prior to Jetpack 8.8, post types were enabled via Jetpack Search widgets rather than disabled via the Customizer.
746        // To continue supporting post types set up in the old way, we iterate through each Jetpack Search
747        // widget configuration and append each enabled post type to $enabled_post_types.
748        foreach ( $widget_options as $widget_option ) {
749            if ( isset( $widget_option['post_types'] ) && is_array( $widget_option['post_types'] ) ) {
750                foreach ( $widget_option['post_types'] as $enabled_post_type ) {
751                    $enabled_post_types[ $enabled_post_type ] = $enabled_post_type;
752                }
753            }
754        }
755
756        if ( ! empty( $enabled_post_types ) ) {
757            $post_types_to_disable = array_diff( $post_types, $enabled_post_types );
758            // better to use `add_option` which wouldn't override option value if exists.
759            add_option( Options::OPTION_PREFIX . 'excluded_post_types', implode( ',', $post_types_to_disable ) );
760        }
761    }
762
763    /**
764     * Automatically set result format.
765     *
766     * @since  9.6.0
767     */
768    public function auto_config_result_format() {
769        $result_format_option_name = Options::OPTION_PREFIX . 'result_format';
770        // Default format `expanded`.
771        $result_format_option_value = Options::RESULT_FORMAT_EXPANDED;
772
773        // Result format already set, skip.
774        if ( get_option( $result_format_option_name, false ) ) {
775            return;
776        }
777
778        // Check if WooCommerce plugin is active (based on https://docs.woocommerce.com/document/create-a-plugin/).
779        if ( in_array(
780            'woocommerce/woocommerce.php',
781            apply_filters( 'active_plugins', Helper::get_active_plugins() ),
782            true
783        ) ) {
784            $result_format_option_value = Options::RESULT_FORMAT_PRODUCT;
785        }
786
787        update_option( $result_format_option_name, $result_format_option_value );
788        return true;
789    }
790
791    /**
792     * Add current theme name as a body class for easier override
793     *
794     * @param string[] $classes An array of body class names.
795     *
796     * @return string[] The array of classes after filtering
797     */
798    public function add_body_class( $classes ) {
799        $classes[] = 'jps-theme-' . get_stylesheet();
800        return $classes;
801    }
802}