Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
90.11% covered (success)
90.11%
82 / 91
60.00% covered (warning)
60.00%
6 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
Theme_Chrome_Slug_Resolver
90.11% covered (success)
90.11%
82 / 91
60.00% covered (warning)
60.00%
6 / 10
40.47
0.00% covered (danger)
0.00%
0 / 1
 register_hooks
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 resolve
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 invalidate
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 maybe_invalidate_on_template_save
37.50% covered (danger)
37.50%
3 / 8
0.00% covered (danger)
0.00%
0 / 1
14.79
 extract_from_template_content
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
10
 compute
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
5
 read_cache
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
5.02
 write_cache
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 is_preview
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
3.33
 get_active_theme_template_content
66.67% covered (warning)
66.67%
4 / 6
0.00% covered (danger)
0.00%
0 / 1
4.59
1<?php
2/**
3 * Resolves the header/footer template-part slugs the active theme uses for
4 * its search results so the bundled Jetpack Search template can mirror them.
5 *
6 * @package automattic/jetpack-search
7 */
8
9namespace Automattic\Jetpack\Search;
10
11/**
12 * Resolves chrome (header/footer) slugs for the bundled search templates,
13 * cached in a site option, invalidated on theme switch + relevant Site
14 * Editor saves.
15 *
16 * Resolution chain (per slot): theme's `search.html` → `index.html` →
17 * hardcoded `header`/`footer` defaults. The 237-theme wordpress.org
18 * survey behind SEARCH-217 found this two-rung chain covers every theme
19 * that ships parts at non-default slugs (the long tail of `wp:pattern`-
20 * wrap themes already works with the defaults because they still ship
21 * `parts/header.html` / `parts/footer.html`).
22 */
23class Theme_Chrome_Slug_Resolver {
24
25    const OPTION_NAME = 'jetpack_search_resolved_chrome_slugs';
26
27    const DEFAULTS = array(
28        'header' => 'header',
29        'footer' => 'footer',
30    );
31
32    /**
33     * Hook invalidation actions. Idempotent — safe to call from init.
34     *
35     * `save_post_wp_template_part` is broader than the wp_template hook
36     * (no narrowing by slug or theme): part edits are rare and even an
37     * over-eager invalidation is cheap (one extra `compute()` next request).
38     */
39    public static function register_hooks() {
40        add_action( 'switch_theme', array( static::class, 'invalidate' ) );
41        add_action( 'save_post_wp_template', array( static::class, 'maybe_invalidate_on_template_save' ), 10, 2 );
42        add_action( 'save_post_wp_template_part', array( static::class, 'invalidate' ) );
43    }
44
45    /**
46     * Resolved chrome slugs for the active theme.
47     *
48     * @return array{header:string,footer:string}
49     */
50    public static function resolve(): array {
51        $stylesheet = (string) get_stylesheet();
52        if ( static::is_preview() ) {
53            // Preview themes: resolve fresh, never read or write the cache.
54            return static::compute();
55        }
56        $cached = static::read_cache( $stylesheet );
57        if ( null !== $cached ) {
58            return $cached;
59        }
60        $computed = static::compute();
61        static::write_cache( $stylesheet, $computed );
62        return $computed;
63    }
64
65    /**
66     * Clear the cache. Hooked to `switch_theme` and template-part saves.
67     */
68    public static function invalidate() {
69        delete_option( self::OPTION_NAME );
70    }
71
72    /**
73     * `save_post_wp_template` handler — only invalidate when the saved
74     * template is one we read from (`search` or `index`) on the active
75     * theme. Cuts noise from unrelated template edits.
76     *
77     * @param int      $post_id Post ID.
78     * @param \WP_Post $post    Post object.
79     */
80    public static function maybe_invalidate_on_template_save( $post_id, $post ) {
81        if ( ! $post instanceof \WP_Post ) {
82            return;
83        }
84        if ( 'search' !== $post->post_name && 'index' !== $post->post_name ) {
85            return;
86        }
87        $terms = wp_get_post_terms( $post_id, 'wp_theme', array( 'fields' => 'names' ) );
88        if ( is_wp_error( $terms ) || ! in_array( (string) get_stylesheet(), (array) $terms, true ) ) {
89            return;
90        }
91        static::invalidate();
92    }
93
94    /**
95     * Pull the first and last top-level `core/template-part` slugs out of
96     * template markup. Slugs outside `[a-zA-Z0-9_-]` are rejected so the
97     * JSON round-trip in the bundled-template substitution can't break.
98     * A single top-level template-part is treated as header-only. Only
99     * top-level blocks are walked — parts nested inside `wp:group`
100     * containers are skipped on purpose (a theme that buries its chrome
101     * inside a wrapper falls back to the resolver's later rungs).
102     *
103     * @param string $template_content Block markup.
104     * @return array{header:?string,footer:?string}
105     */
106    public static function extract_from_template_content( string $template_content ): array {
107        $header = null;
108        $footer = null;
109        $count  = 0;
110        if ( '' === $template_content || ! function_exists( 'parse_blocks' ) ) {
111            return array(
112                'header' => $header,
113                'footer' => $footer,
114            );
115        }
116        foreach ( parse_blocks( $template_content ) as $block ) {
117            if ( 'core/template-part' !== ( $block['blockName'] ?? '' ) ) {
118                continue;
119            }
120            $slug = $block['attrs']['slug'] ?? null;
121            if ( ! is_string( $slug ) || '' === $slug || ! preg_match( '/^[a-zA-Z0-9_-]+$/', $slug ) ) {
122                continue;
123            }
124            if ( null === $header ) {
125                $header = $slug;
126            }
127            $footer = $slug;
128            ++$count;
129        }
130        if ( $count < 2 ) {
131            $footer = null;
132        }
133        return array(
134            'header' => $header,
135            'footer' => $footer,
136        );
137    }
138
139    /**
140     * Run the resolution chain. Doesn't touch the cache (caller decides).
141     * The active stylesheet is implicit via `get_active_theme_template_content()`.
142     *
143     * @return array{header:string,footer:string}
144     */
145    protected static function compute(): array {
146        $found = array(
147            'header' => null,
148            'footer' => null,
149        );
150        foreach ( array( 'search', 'index' ) as $template_name ) {
151            if ( null !== $found['header'] && null !== $found['footer'] ) {
152                break;
153            }
154            $content = static::get_active_theme_template_content( $template_name );
155            if ( null === $content ) {
156                continue;
157            }
158            $extracted       = static::extract_from_template_content( $content );
159            $found['header'] = $found['header'] ?? $extracted['header'];
160            $found['footer'] = $found['footer'] ?? $extracted['footer'];
161        }
162        return array(
163            'header' => $found['header'] ?? self::DEFAULTS['header'],
164            'footer' => $found['footer'] ?? self::DEFAULTS['footer'],
165        );
166    }
167
168    /**
169     * Read the option-backed cache. Returns null on miss or when the
170     * stored stylesheet doesn't match the active one (handles the rare
171     * case where `switch_theme` fired without clearing the option).
172     *
173     * @param string $stylesheet Active stylesheet.
174     * @return array{header:string,footer:string}|null
175     */
176    protected static function read_cache( string $stylesheet ): ?array {
177        $raw = get_option( self::OPTION_NAME );
178        if ( ! is_array( $raw ) || ( $raw['stylesheet'] ?? null ) !== $stylesheet ) {
179            return null;
180        }
181        $header = $raw['header'] ?? null;
182        $footer = $raw['footer'] ?? null;
183        if ( ! is_string( $header ) || ! is_string( $footer ) ) {
184            return null;
185        }
186        return array(
187            'header' => $header,
188            'footer' => $footer,
189        );
190    }
191
192    /**
193     * Persist the resolved slugs to the option-backed cache.
194     *
195     * `autoload=false`: the option changes only on theme switches /
196     * search.html edits, so we don't want it in the `alloptions` payload
197     * fetched on every request. The first `get_option()` per request
198     * issues its own DB query (or hits the object cache on sites that
199     * have one) instead.
200     *
201     * Cache-cold race: two concurrent requests can both compute and write
202     * with last-writer-wins semantics. Same input → same output, so
203     * correctness isn't affected.
204     *
205     * @param string                             $stylesheet Active stylesheet.
206     * @param array{header:string,footer:string} $slugs      Resolved slugs.
207     */
208    protected static function write_cache( string $stylesheet, array $slugs ) {
209        update_option(
210            self::OPTION_NAME,
211            array(
212                'stylesheet' => $stylesheet,
213                'header'     => $slugs['header'],
214                'footer'     => $slugs['footer'],
215            ),
216            false
217        );
218    }
219
220    /**
221     * Whether the current request is a theme preview (Customizer or
222     * Site Editor theme preview). Cache reads and writes are skipped in
223     * this state so a preview never pollutes the production-theme cache.
224     *
225     * @return bool
226     */
227    protected static function is_preview(): bool {
228        if ( function_exists( 'is_customize_preview' ) && is_customize_preview() ) {
229            return true;
230        }
231        // Site Editor theme preview surfaces as `?wp_theme_preview=<theme-slug>`.
232        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only preview detection.
233        return ! empty( $_GET['wp_theme_preview'] );
234    }
235
236    /**
237     * Resolved markup for an active-theme template. Overridable seam.
238     *
239     * @param string $template_name Bare template slug (no `theme//` prefix).
240     * @return string|null
241     */
242    protected static function get_active_theme_template_content( string $template_name ): ?string {
243        if ( ! function_exists( 'get_block_template' ) ) {
244            return null;
245        }
246        $tmpl = get_block_template( (string) get_stylesheet() . '//' . $template_name, 'wp_template' );
247        if ( ! $tmpl || empty( $tmpl->content ) ) {
248            return null;
249        }
250        return (string) $tmpl->content;
251    }
252}