Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 76
0.00% covered (danger)
0.00%
0 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
Inline_Search_Highlighter
0.00% covered (danger)
0.00%
0 / 76
0.00% covered (danger)
0.00%
0 / 9
2070
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 setup
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 process_results
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 filter_highlighted_title
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
42
 filter_highlighted_excerpt
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
30
 process_result_highlighting
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 extract_highlight_field
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
56
 is_search_result
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
30
 filter_render_highlighted_block
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
210
1<?php
2/**
3 * Search Highlighter: Handles highlighting of search terms in content
4 *
5 * @package automattic/jetpack-search
6 */
7
8namespace Automattic\Jetpack\Search;
9
10/**
11 * Search Highlighter class
12 */
13class Inline_Search_Highlighter {
14    /**
15     * Stores highlighted content from search results.
16     *
17     * @var array
18     */
19    private $highlighted_content;
20
21    /**
22     * Stores the list of post IDs that are actual search results.
23     *
24     * @var array
25     */
26    private $search_result_ids;
27
28    /**
29     * Constructor
30     *
31     * @param array      $search_result_ids     Array of post IDs from search results.
32     * @param array|null $results          Optional. The search result data from the API to process immediately.
33     */
34    public function __construct( array $search_result_ids = array(), ?array $results = null ) {
35        $this->search_result_ids   = $search_result_ids;
36        $this->highlighted_content = array();
37
38        // Process API results immediately if provided
39        if ( $results !== null ) {
40            $this->process_results( $results );
41        }
42    }
43
44    /**
45     * Set up the WordPress filters for highlighting.
46     */
47    public function setup(): void {
48        add_filter( 'the_title', array( $this, 'filter_highlighted_title' ), 10, 2 );
49        add_filter( 'the_excerpt', array( $this, 'filter_highlighted_excerpt' ) );
50        add_filter( 'render_block_core/post-excerpt', array( $this, 'filter_render_highlighted_block' ), 10, 3 );
51    }
52
53    /**
54     * Process highlighting data for search results.
55     *
56     * @param array $results The search result data from the API.
57     */
58    public function process_results( array $results ): void {
59        $this->highlighted_content = array();
60
61        if ( empty( $results ) ) {
62            return;
63        }
64
65        foreach ( $results as $result ) {
66            $post_id = (int) ( $result['fields']['post_id'] ?? 0 );
67            $this->process_result_highlighting( $result, $post_id );
68        }
69    }
70
71    /**
72     * Filter the post title to show highlighted version.
73     *
74     * @param string   $title   The post title.
75     * @param int|null $post_id Optional. The post ID. Default null.
76     *
77     * @return string The filtered title.
78     */
79    public function filter_highlighted_title( string $title, ?int $post_id = null ): string {
80        $incompatible_themes = array(
81            'blaskan', // Blaskan incorrectly uses wp_nav_menu() in body_class filter, causing infinite recursion.
82            'p2020',   // P2 loads o2 as a plugin, which calls the_title() filter incorrectly.
83        );
84
85        $theme          = wp_get_theme();
86        $theme_name     = strtolower( $theme->get( 'Name' ) );
87        $theme_template = strtolower( $theme->get_template() );
88
89        foreach ( $incompatible_themes as $incompatible_theme ) {
90            if ( $theme_name === $incompatible_theme || $theme_template === $incompatible_theme ) {
91                return $title;
92            }
93        }
94
95        if ( ! $this->is_search_result( $post_id ) ) {
96            return $title;
97        }
98
99        if ( ! empty( $this->highlighted_content[ $post_id ]['title'] ) ) {
100            return $this->highlighted_content[ $post_id ]['title'];
101        }
102
103        return $title;
104    }
105
106    /**
107     * Filter the post content to show highlighted version.
108     *
109     * @param string $content The post content.
110     *
111     * @return string The filtered content.
112     */
113    public function filter_highlighted_excerpt( string $content ): string {
114        $post_id = get_the_ID();
115
116        if ( ! $this->is_search_result( $post_id ) ) {
117            return $content;
118        }
119
120        // Skip highlighting if we're in a block theme context in favour of filter_render_highlighted_block().
121        if ( function_exists( 'wp_is_block_theme' ) && wp_is_block_theme() ) {
122            return $content;
123        }
124
125        if ( ! empty( $this->highlighted_content[ $post_id ]['content'] ) ) {
126            return wpautop( $this->highlighted_content[ $post_id ]['content'] );
127        }
128
129        return $content;
130    }
131
132    /**
133     * Process highlighting data for a single search result.
134     *
135     * @param array $result  The search result data from the API.
136     * @param int   $post_id The post ID for this result.
137     */
138    private function process_result_highlighting( array $result, int $post_id ): void {
139        if ( empty( $result['highlight'] ) ) {
140            return;
141        }
142
143        $title   = $this->extract_highlight_field( $result, 'title' );
144        $content = $this->extract_highlight_field( $result, 'content' );
145
146        $this->highlighted_content[ $post_id ] = array(
147            'title'   => $title,
148            'content' => $content,
149        );
150    }
151
152    /**
153     * Extract a highlight field from the search result, handling different field formats.
154     *
155     * @param array  $result The search result data from the API.
156     * @param string $field  The field name to extract.
157     *
158     * @return string The extracted highlighted field.
159     */
160    private function extract_highlight_field( array $result, string $field ): string {
161        if ( isset( $result['highlight'][ $field ] ) && is_array( $result['highlight'][ $field ] ) ) {
162            return $result['highlight'][ $field ][0];
163        }
164
165        // Try field variants with suffixes (e.g., 'title.default') if no direct match found.
166        foreach ( $result['highlight'] as $key => $value ) {
167            if ( str_starts_with( $key, $field . '.' ) ) {
168                if ( is_array( $value ) && ! empty( $value ) ) {
169                    return $value[0];
170                }
171            }
172        }
173
174        return '';
175    }
176
177    /**
178     * Check if the current post is a search result from our API
179     *
180     * @param int|null $post_id The post ID to check.
181     *
182     * @return bool Whether the post is a search result.
183     */
184    public function is_search_result( ?int $post_id ): bool {
185        // o2 is initially returning null due to mishandling of the_title() filter.
186        if ( null === $post_id ) {
187            return false;
188        }
189
190        return is_search() && in_the_loop() && ! empty( $this->search_result_ids ) && in_array( $post_id, $this->search_result_ids, true );
191    }
192
193    /**
194     * Filter for rendering highlighted content when highlighting is returned from the API.
195     *
196     * @param string $block_content The block content.
197     * @param array  $block The block data.
198     * @param object $instance The block instance.
199     *
200     * @return string The filtered block content.
201     * @since 0.50.0
202     */
203    public function filter_render_highlighted_block( string $block_content, array $block, object $instance ): string {
204        if ( ! isset( $instance->context['postId'] ) || ! $this->is_search_result( $instance->context['postId'] ) ) {
205            return $block_content;
206        }
207
208        $highlighted_content = $this->highlighted_content[ $instance->context['postId'] ] ?? null;
209
210        // If we don't have any highlighted content or comments, return the original block content
211        if ( empty( $highlighted_content['content'] ) && empty( $highlighted_content['comments'] ) ) {
212            return $block_content;
213        }
214
215        // Start with the content highlights if available
216        if ( ! empty( $highlighted_content['content'] ) ) {
217            $block_content = wpautop( $highlighted_content['content'] );
218        }
219
220        // Append comment highlights if available
221        if ( ! empty( $highlighted_content['comments'] ) ) {
222            $block_content .= ' ... ' . $highlighted_content['comments'];
223        }
224
225        // Handle more text display if needed
226        $more_text = ! empty( $block['attrs']['moreText'] ) ? '<a class="wp-block-post-excerpt__more-text">' . $block['attrs']['moreText'] . '</a>' : '';
227
228        $classes = array();
229        if ( isset( $block['attrs']['textAlign'] ) ) {
230            $classes[] = 'has-text-align-' . $block['attrs']['textAlign'];
231        }
232
233        if ( isset( $block['attrs']['style']['elements']['link']['color']['text'] ) ) {
234            $classes[] = 'has-link-color';
235        }
236
237        $wrapper_attributes = get_block_wrapper_attributes( array( 'class' => implode( ' ', $classes ) ) );
238
239        // Determine if we should show more text on new line based on block attributes
240        $show_more_on_new_line = ! isset( $block['attrs']['showMoreOnNewLine'] ) || $block['attrs']['showMoreOnNewLine'];
241
242        if ( $show_more_on_new_line && ! empty( $more_text ) ) {
243            $block_content .= '</p><p class="wp-block-post-excerpt__more-text">' . $more_text . '</p>';
244        } elseif ( ! empty( $more_text ) ) {
245            $block_content .= " $more_text</p>";
246        }
247
248        return sprintf( '<div %1$s>%2$s</div>', $wrapper_attributes, $block_content );
249    }
250}