Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
88.64% covered (warning)
88.64%
78 / 88
75.00% covered (warning)
75.00%
9 / 12
CRAP
n/a
0 / 0
Automattic\Jetpack\Extensions\Featured_Image_Duplicate\register_plugin
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
Automattic\Jetpack\Extensions\Featured_Image_Duplicate\can_edit_post_meta
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
Automattic\Jetpack\Extensions\Featured_Image_Duplicate\register_meta_keys
100.00% covered (success)
100.00%
27 / 27
100.00% covered (success)
100.00%
1 / 1
2
Automattic\Jetpack\Extensions\Featured_Image_Duplicate\is_hide_supported
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
6.03
Automattic\Jetpack\Extensions\Featured_Image_Duplicate\classic_theme_supports_hiding
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
Automattic\Jetpack\Extensions\Featured_Image_Duplicate\get_template_hierarchy_for_post
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
3.03
Automattic\Jetpack\Extensions\Featured_Image_Duplicate\blocks_contain_featured_image
93.33% covered (success)
93.33%
14 / 15
0.00% covered (danger)
0.00%
0 / 1
10.03
Automattic\Jetpack\Extensions\Featured_Image_Duplicate\add_editor_feature_flag
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
Automattic\Jetpack\Extensions\Featured_Image_Duplicate\maybe_filter_thumbnail_id
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
3
Automattic\Jetpack\Extensions\Featured_Image_Duplicate\should_hide_for_post
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
Automattic\Jetpack\Extensions\Featured_Image_Duplicate\filter_thumbnail_id
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
5
Automattic\Jetpack\Extensions\Featured_Image_Duplicate\hide_featured_image_block
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
1<?php
2/**
3 * Block Editor - Featured image duplicate notice.
4 *
5 * @package automattic/jetpack
6 */
7
8namespace Automattic\Jetpack\Extensions\Featured_Image_Duplicate;
9
10use Jetpack_Gutenberg;
11use WP_Block;
12use WP_Block_Supports;
13use WP_Post;
14
15if ( ! defined( 'ABSPATH' ) ) {
16    exit( 0 );
17}
18
19const FEATURE_NAME       = 'featured-image-duplicate';
20const HIDE_META_KEY      = '_jetpack_hide_featured_image';
21const DISMISSED_META_KEY = '_jetpack_featured_image_duplicate_dismissed';
22const POST_TYPES         = array( 'post', 'page' );
23
24/**
25 * Make the editor plugin available.
26 *
27 * @return void
28 */
29function register_plugin() {
30    Jetpack_Gutenberg::set_extension_available( FEATURE_NAME );
31}
32add_action( 'jetpack_register_gutenberg_extensions', __NAMESPACE__ . '\register_plugin' );
33
34/**
35 * Only users who can edit the post may change its meta.
36 *
37 * @param bool   $allowed   Whether the user can edit the meta.
38 * @param string $meta_key  Meta key.
39 * @param int    $object_id Post ID.
40 * @return bool
41 */
42function can_edit_post_meta( $allowed, $meta_key, $object_id ) {
43    return current_user_can( 'edit_post', (int) $object_id );
44}
45
46/**
47 * Register the hide and dismiss meta for posts and pages.
48 *
49 * @return void
50 */
51function register_meta_keys() {
52    foreach ( POST_TYPES as $post_type ) {
53        register_post_meta(
54            $post_type,
55            HIDE_META_KEY,
56            array(
57                'type'              => 'boolean',
58                'description'       => __( 'Whether to hide the featured image on this post’s own page.', 'jetpack' ),
59                'single'            => true,
60                'default'           => false,
61                'show_in_rest'      => true,
62                'revisions_enabled' => true, // Saved with autosaves, so Preview of a published post matches the checkbox.
63                'auth_callback'     => __NAMESPACE__ . '\can_edit_post_meta',
64            )
65        );
66        register_post_meta(
67            $post_type,
68            DISMISSED_META_KEY,
69            array(
70                'type'              => 'integer',
71                'description'       => __( 'Featured image ID whose duplicate notice was dismissed.', 'jetpack' ),
72                'single'            => true,
73                'default'           => 0,
74                'show_in_rest'      => true,
75                'sanitize_callback' => 'absint',
76                'auth_callback'     => __NAMESPACE__ . '\can_edit_post_meta',
77            )
78        );
79    }
80}
81add_action( 'init', __NAMESPACE__ . '\register_meta_keys' );
82
83/**
84 * Whether hiding the featured image on this post's own page is known to work on the active theme.
85 *
86 * @param WP_Post|int $post Post or post ID.
87 * @return bool
88 */
89function is_hide_supported( $post ) {
90    $post = get_post( $post );
91    if ( ! $post instanceof WP_Post || ! in_array( $post->post_type, POST_TYPES, true ) ) {
92        return false;
93    }
94
95    if ( ! wp_is_block_theme() ) {
96        return classic_theme_supports_hiding( $post->post_type );
97    }
98
99    // Block themes: check the template hierarchy to see if the Featured Image block is present in the template WordPress renders for this post.
100    $hierarchy = get_template_hierarchy_for_post( $post );
101    $contents  = wp_list_pluck( get_block_templates( array( 'slug__in' => $hierarchy ), 'wp_template' ), 'content', 'slug' ); // Slug => content.
102
103    // Several templates may match; walk the hierarchy in priority order to find the one WordPress renders.
104    foreach ( $hierarchy as $slug ) {
105        if ( isset( $contents[ $slug ] ) ) {
106            return blocks_contain_featured_image( parse_blocks( $contents[ $slug ] ) );
107        }
108    }
109
110    return false;
111}
112
113/**
114 * Whether the classic theme declares Content Options featured image support for this post type.
115 *
116 * @param string $post_type Post type.
117 * @return bool
118 */
119function classic_theme_supports_hiding( $post_type ) {
120    $options = get_theme_support( 'jetpack-content-options' ); // Declared by the theme or Jetpack's compat file; no site owner action needed.
121    return ! empty( $options[0]['featured-images'][ $post_type ] );
122}
123
124/**
125 * Template slugs WordPress tries for this post, most specific first.
126 *
127 * @param WP_Post $post Post.
128 * @return string[]
129 */
130function get_template_hierarchy_for_post( WP_Post $post ): array {
131    if ( 'page' === $post->post_type ) {
132        $hierarchy = array( "page-{$post->post_name}", "page-{$post->ID}", 'page', 'singular', 'index' );
133    } else {
134        $hierarchy = array( "single-{$post->post_type}-{$post->post_name}", "single-{$post->post_type}", 'single', 'singular', 'index' );
135    }
136
137    $custom = get_page_template_slug( $post );
138    if ( $custom ) {
139        array_unshift( $hierarchy, $custom );
140    }
141
142    return $hierarchy;
143}
144
145/**
146 * Whether the blocks render the current post's featured image.
147 *
148 * Follows patterns and template parts; skips Query Loops, which show other posts.
149 *
150 * @param array $blocks Parsed blocks.
151 * @param int   $depth  Recursion depth.
152 * @return bool
153 */
154function blocks_contain_featured_image( array $blocks, $depth = 0 ): bool {
155    if ( $depth > 5 ) {
156        return false;
157    }
158
159    foreach ( resolve_pattern_blocks( $blocks ) as $block ) {
160        $name  = $block['blockName'] ?? '';
161        $inner = $block['innerBlocks'] ?? array();
162
163        if ( 'core/post-featured-image' === $name ) {
164            return true;
165        }
166
167        if ( 'core/query' === $name ) {
168            continue;
169        }
170
171        if ( 'core/template-part' === $name && ! empty( $block['attrs']['slug'] ) ) {
172            $part  = get_block_template( ( $block['attrs']['theme'] ?? get_stylesheet() ) . '//' . $block['attrs']['slug'], 'wp_template_part' );
173            $inner = $part ? parse_blocks( $part->content ) : array();
174        }
175
176        if ( $inner && blocks_contain_featured_image( $inner, $depth + 1 ) ) {
177            return true;
178        }
179    }
180
181    return false;
182}
183
184/**
185 * Tell the editor whether hiding works for the post being edited.
186 *
187 * @param array $flags Editor feature flags.
188 * @return array
189 */
190function add_editor_feature_flag( $flags ): array {
191    $flags['featured-image-hide-supported'] = is_hide_supported( get_post() );
192    return $flags;
193}
194add_filter( 'jetpack_block_editor_feature_flags', __NAMESPACE__ . '\add_editor_feature_flag' );
195
196/**
197 * Only filter thumbnail reads on pages that hide the featured image.
198 *
199 * @return void
200 */
201function maybe_filter_thumbnail_id() {
202    // Block themes hide the Featured Image block instead; see hide_featured_image_block().
203    if ( ! wp_is_block_theme() && should_hide_for_post( get_queried_object_id() ) ) {
204        // Late, since core overrides the thumbnail ID in _wp_preview_post_thumbnail_filter.
205        add_filter( 'get_post_metadata', __NAMESPACE__ . '\filter_thumbnail_id', PHP_INT_MAX, 3 );
206    }
207}
208add_action( 'template_redirect', __NAMESPACE__ . '\maybe_filter_thumbnail_id' ); // Fires on every front-end page load.
209
210/**
211 * Whether we're on this post's own page and the writer chose to hide its featured image there.
212 *
213 * @param int $post_id Post ID.
214 * @return bool
215 */
216function should_hide_for_post( $post_id ): bool {
217    return is_singular( POST_TYPES )
218        && get_queried_object_id() === $post_id
219        && (bool) get_post_meta( $post_id, HIDE_META_KEY, true );
220}
221
222/**
223 * Filter the post thumbnail ID to hide the featured image on post's own page.
224 *
225 * Outside the loop (Open Graph, schema) and inside blocks (Latest Posts) it's still returned.
226 *
227 * @param mixed  $value     Short-circuit value.
228 * @param int    $object_id Post ID.
229 * @param string $meta_key  Meta key.
230 * @return mixed
231 */
232function filter_thumbnail_id( $value, $object_id, $meta_key ) {
233    if (
234        '_thumbnail_id' !== $meta_key
235        || get_queried_object_id() !== (int) $object_id
236        || ! in_the_loop() // Not in the main loop.
237        || null !== WP_Block_Supports::$block_to_render // Inside a block, don't hide the thumbnail.
238    ) {
239        return $value;
240    }
241
242    return false;
243}
244
245/**
246 * Block themes: skip the template's Featured Image block on the post's own page.
247 *
248 * @param string   $block_content Rendered block.
249 * @param array    $block         Parsed block.
250 * @param WP_Block $instance      Block instance.
251 * @return string
252 */
253function hide_featured_image_block( $block_content, $block, $instance ): string {
254    $context = $instance->context; // Set from the template and parent blocks.
255
256    // Inside a Query Loop it's a list of posts, not this post's header.
257    if ( array_key_exists( 'queryId', $context ) || empty( $context['postId'] ) ) {
258        return $block_content;
259    }
260
261    return should_hide_for_post( (int) $context['postId'] ) ? '' : $block_content;
262}
263add_filter( 'render_block_core/post-featured-image', __NAMESPACE__ . '\hide_featured_image_block', 10, 3 );