Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
106 / 106
100.00% covered (success)
100.00%
14 / 14
CRAP
100.00% covered (success)
100.00%
1 / 1
Hooked_Blocks
100.00% covered (success)
100.00%
106 / 106
100.00% covered (success)
100.00%
14 / 14
82
100.00% covered (success)
100.00%
1 / 1
 init
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 hook_block_types
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
14
 build_sharing_buttons
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 build_like
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 post_types
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 is_single_view_context
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
15
 is_single_view_slug
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
11
 already_placed
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
8
 viewed_post_declines
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
8
 viewed_post
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
3
 blocks_can_load
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 legacy_buttons_absent
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
5
 is_content_placement
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 wrap_in_group
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
4
1<?php
2/**
3 * Hooks the Sharing Buttons and Like blocks into single post and page templates.
4 *
5 * @package automattic/jetpack-sharing-likes
6 */
7
8declare( strict_types = 1 );
9
10namespace Automattic\Jetpack\Sharing_Likes\Hooked_Blocks;
11
12use Automattic\Jetpack\Sharing_Likes\Block_Names;
13use Automattic\Jetpack\Sharing_Likes\Post_Likes_Switch;
14use Automattic\Jetpack\Sharing_Likes\Post_Sharing_Switch;
15use Automattic\Jetpack\Sharing_Likes\Settings\Environment;
16
17/**
18 * Places `jetpack/sharing-buttons` and `jetpack/like` before or after `core/post-content`
19 * with the Block Hooks API, where `Template_Placements` asks for them.
20 *
21 * Callbacks read options and site facts each time, since WordPress.com REST requests switch blogs
22 * after `init`, and never resolve a template, since building one applies block hooks.
23 *
24 * @since $$next-version$$
25 */
26final class Hooked_Blocks {
27
28    private const ANCHOR = 'core/post-content';
29
30    /**
31     * Block Hooks position for each placement.
32     */
33    private const POSITIONS = array(
34        'before' => Template_Placements::BEFORE_CONTENT,
35        'after'  => Template_Placements::AFTER_CONTENT,
36    );
37
38    /**
39     * Alignments `core/group` shares with `core/post-content`.
40     */
41    private const ALIGNMENTS = array( 'wide', 'full' );
42
43    /**
44     * Hook everything up. Safe to call more than once: a hook keeps one copy of a callback per priority.
45     */
46    public static function init(): void {
47        // Ahead of Newsletter's Subscribe block at 10, whichever registers first.
48        add_filter( 'hooked_block_types', array( self::class, 'hook_block_types' ), 5, 4 );
49        add_filter( 'hooked_block_' . Block_Names::SHARING_BUTTONS, array( self::class, 'build_sharing_buttons' ), 10, 4 );
50        add_filter( 'hooked_block_' . Block_Names::LIKE, array( self::class, 'build_like' ), 10, 4 );
51    }
52
53    /**
54     * Add the blocks around `core/post-content` in the templates and patterns that show one post or page.
55     *
56     * @param mixed  $hooked_block_types Block types hooked at this anchor and position.
57     * @param string $relative_position  Position relative to the anchor.
58     * @param mixed  $anchor_block_type  Anchor block type; null for freeform content.
59     * @param mixed  $context            Template, template part, pattern array or post the anchor belongs to.
60     * @return mixed
61     */
62    public static function hook_block_types( $hooked_block_types, $relative_position, $anchor_block_type, $context ) {
63        if ( self::ANCHOR !== $anchor_block_type || ! is_array( $hooked_block_types ) || ! isset( self::POSITIONS[ $relative_position ] ) ) {
64            return $hooked_block_types;
65        }
66
67        $placement = self::POSITIONS[ $relative_position ];
68        $wanted    = array();
69
70        if ( in_array( $placement, Template_Placements::get( Template_Placements::FEATURE_SHARING ), true ) ) {
71            $wanted[] = Block_Names::SHARING_BUTTONS;
72        }
73        if ( in_array( $placement, Template_Placements::get( Template_Placements::FEATURE_LIKES ), true ) ) {
74            $wanted[] = Block_Names::LIKE;
75        }
76
77        if ( ! $wanted || ! self::is_single_view_context( $context, self::post_types() ) || ! self::blocks_can_load() ) {
78            return $hooked_block_types;
79        }
80
81        foreach ( $wanted as $block ) {
82            if (
83                self::legacy_buttons_absent( $block )
84                && ! self::viewed_post_declines( $block )
85                && ! self::already_placed( $block, $context )
86                && ! in_array( $block, $hooked_block_types, true )
87            ) {
88                $hooked_block_types[] = $block;
89            }
90        }
91
92        return $hooked_block_types;
93    }
94
95    /**
96     * Give the hooked Sharing Buttons block its buttons, in a group laid out like the post content.
97     *
98     * @param mixed  $parsed_hooked_block The hooked block, or null when suppressed.
99     * @param string $hooked_block_type   Hooked block type.
100     * @param string $relative_position   Position relative to the anchor.
101     * @param mixed  $parsed_anchor_block The anchor block.
102     * @return mixed
103     */
104    public static function build_sharing_buttons( $parsed_hooked_block, $hooked_block_type, $relative_position, $parsed_anchor_block ) {
105        if ( ! is_array( $parsed_hooked_block ) || ! self::is_content_placement( $relative_position, $parsed_anchor_block ) ) {
106            return $parsed_hooked_block;
107        }
108
109        return self::wrap_in_group( Sharing_Buttons_Markup::fill( $parsed_hooked_block ), $parsed_anchor_block );
110    }
111
112    /**
113     * Put the hooked Like block in a group laid out like the post content.
114     *
115     * @param mixed  $parsed_hooked_block The hooked block, or null when suppressed.
116     * @param string $hooked_block_type   Hooked block type.
117     * @param string $relative_position   Position relative to the anchor.
118     * @param mixed  $parsed_anchor_block The anchor block.
119     * @return mixed
120     */
121    public static function build_like( $parsed_hooked_block, $hooked_block_type, $relative_position, $parsed_anchor_block ) {
122        if ( ! is_array( $parsed_hooked_block ) || ! self::is_content_placement( $relative_position, $parsed_anchor_block ) ) {
123            return $parsed_hooked_block;
124        }
125
126        return self::wrap_in_group( $parsed_hooked_block, $parsed_anchor_block );
127    }
128
129    /**
130     * Post types whose single views get the blocks.
131     *
132     * @return string[]
133     */
134    private static function post_types(): array {
135        /**
136         * Filters the post types whose single views get the Sharing Buttons and Like blocks the template placements add.
137         *
138         * @since $$next-version$$
139         *
140         * @param string[] $post_types Post type names. Default posts and pages.
141         */
142        $post_types = apply_filters( 'jetpack_sharing_likes_template_placement_post_types', array( 'post', 'page' ) );
143
144        return is_array( $post_types ) ? array_values( array_unique( array_filter( $post_types, 'is_string' ) ) ) : array( 'post', 'page' );
145    }
146
147    /**
148     * Whether a context shows a single view of one of the post types: a template by the post types it serves,
149     * a pattern by its template types, else by the current request, which never holds in the Site Editor's REST requests.
150     *
151     * @param mixed    $context    Template, template part, pattern array or post.
152     * @param string[] $post_types Post types that get the blocks.
153     */
154    private static function is_single_view_context( $context, array $post_types ): bool {
155        if ( ! $post_types ) {
156            return false;
157        }
158
159        // Once the front end picks a template, its request has the last word: templates and typed patterns serve other views too.
160        if ( ( is_singular() || did_action( 'template_redirect' ) ) && ! is_singular( $post_types ) ) {
161            return false;
162        }
163
164        if ( $context instanceof \WP_Block_Template ) {
165            if ( 'wp_template' !== $context->type ) {
166                return false;
167            }
168
169            if ( self::is_single_view_slug( (string) $context->slug, $post_types ) ) {
170                return true;
171            }
172
173            // A custom template that declares no post types gets nothing, as with Newsletter's Subscribe block (see AGENTS.md).
174            return is_array( $context->post_types ) && (bool) array_intersect( $post_types, $context->post_types );
175        }
176
177        if ( is_array( $context ) ) {
178            $template_types = isset( $context['templateTypes'] ) && is_array( $context['templateTypes'] ) ? $context['templateTypes'] : array();
179
180            foreach ( $template_types as $template_type ) {
181                if ( is_string( $template_type ) && self::is_single_view_slug( $template_type, $post_types ) ) {
182                    return true;
183                }
184            }
185
186            return is_singular( $post_types );
187        }
188
189        return false;
190    }
191
192    /**
193     * Whether a template slug or type serves single views of one of the post types.
194     *
195     * @param string   $slug       Template slug.
196     * @param string[] $post_types Post types that get the blocks.
197     */
198    private static function is_single_view_slug( string $slug, array $post_types ): bool {
199        if ( 'singular' === $slug ) {
200            return true;
201        }
202
203        if ( in_array( 'page', $post_types, true ) && ( 'page' === $slug || str_starts_with( $slug, 'page-' ) ) ) {
204            return true;
205        }
206
207        // Pages have templates of their own; `single` serves every other post type without one.
208        $single_types = array_diff( $post_types, array( 'page' ) );
209
210        if ( 'single' === $slug ) {
211            return (bool) $single_types;
212        }
213
214        if ( ! str_starts_with( $slug, 'single-' ) ) {
215            return false;
216        }
217
218        // `single-{type}` or one post's `single-{type}-{slug}`; post type names can hold hyphens, so the longest that fits owns it.
219        $rest  = substr( $slug, strlen( 'single-' ) );
220        $owner = '';
221
222        foreach ( array_unique( array_merge( array_values( get_post_types() ), $single_types ) ) as $post_type ) {
223            if ( strlen( $post_type ) > strlen( $owner ) && ( $rest === $post_type || str_starts_with( $rest, $post_type . '-' ) ) ) {
224                $owner = $post_type;
225            }
226        }
227
228        return in_array( $owner, $single_types, true );
229    }
230
231    /**
232     * Whether the post being viewed, the template or the pattern already holds the block, as when the owner added it by hand.
233     *
234     * @param string $block   Block type.
235     * @param mixed  $context Template or pattern array, its content not yet hooked.
236     */
237    private static function already_placed( string $block, $context ): bool {
238        $post = self::viewed_post();
239
240        if ( $post && has_block( $block, $post->post_content ) ) {
241            return true;
242        }
243
244        if ( $context instanceof \WP_Block_Template ) {
245            $content = $context->content;
246        } else {
247            $content = $context['content'] ?? null;
248            $path    = $context['filePath'] ?? null;
249
250            // Core hands over a theme pattern before reading its file, the first time it is fetched.
251            if ( null === $content && is_string( $path ) && is_readable( $path ) ) {
252                $content = file_get_contents( $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- A local theme file.
253            }
254        }
255
256        return is_string( $content ) && has_block( $block, $content );
257    }
258
259    /**
260     * Whether the post being viewed keeps the block out, as it would the legacy buttons.
261     *
262     * Its own switch can turn either off, Likes skip password-protected posts, and on Simple a post
263     * that switched Likes on keeps the legacy button.
264     *
265     * @param string $block Block type.
266     */
267    private static function viewed_post_declines( string $block ): bool {
268        $post = self::viewed_post();
269
270        if ( ! $post || ! $post->ID ) {
271            return false;
272        }
273
274        if ( Block_Names::SHARING_BUTTONS === $block ) {
275            return (bool) get_post_meta( $post->ID, Post_Sharing_Switch::META_KEY, false );
276        }
277
278        if ( '' !== $post->post_password ) {
279            return true;
280        }
281
282        // Legacy Likes ignore a switch stored as an array.
283        $switched = get_post_meta( $post->ID, Post_Likes_Switch::META_KEY, true );
284        $switched = is_scalar( $switched ) ? (string) $switched : '';
285
286        return '0' === $switched || ( '' !== $switched && Environment::is_simple_site() );
287    }
288
289    /**
290     * The post a front-end singular request shows, if any.
291     */
292    private static function viewed_post(): ?\WP_Post {
293        $post = is_singular() ? get_queried_object() : null;
294
295        return $post instanceof \WP_Post ? $post : null;
296    }
297
298    /**
299     * Whether Jetpack's blocks load here, so the lazy loader registers the hooked ones as they render.
300     *
301     * Asks for the conditions rather than the block registry: both blocks are only registered
302     * on first render on the front end, after core has built the template.
303     */
304    private static function blocks_can_load(): bool {
305        return Environment::is_block_theme() && Environment::jetpack_blocks_load();
306    }
307
308    /**
309     * Whether the legacy feature shows no buttons, so visitors never get both.
310     *
311     * @param string $block Block type.
312     */
313    private static function legacy_buttons_absent( string $block ): bool {
314        if ( Block_Names::SHARING_BUTTONS === $block ) {
315            return ! Environment::sharing_module_running() || Environment::legacy_sharing_switched_off();
316        }
317
318        return Environment::likes_supported()
319            && ( ! Environment::likes_module_running() || Environment::legacy_likes_switched_off() );
320    }
321
322    /**
323     * Whether a hooked block sits right before or after the post content, where this class places it.
324     *
325     * @param mixed $relative_position   Position relative to the anchor.
326     * @param mixed $parsed_anchor_block The anchor block.
327     */
328    private static function is_content_placement( $relative_position, $parsed_anchor_block ): bool {
329        return is_array( $parsed_anchor_block )
330            && self::ANCHOR === ( $parsed_anchor_block['blockName'] ?? null )
331            && isset( self::POSITIONS[ $relative_position ] );
332    }
333
334    /**
335     * Wrap a block in a `core/group` with the anchor's alignment and layout, so it lines up with the post content.
336     *
337     * @param array $block               The hooked block.
338     * @param array $parsed_anchor_block The anchor block.
339     * @return array
340     */
341    private static function wrap_in_group( array $block, array $parsed_anchor_block ): array {
342        $attrs  = array();
343        $class  = 'wp-block-group';
344        $align  = $parsed_anchor_block['attrs']['align'] ?? null;
345        $layout = $parsed_anchor_block['attrs']['layout'] ?? null;
346
347        // The group saves its alignment class, as its editor save output does.
348        if ( in_array( $align, self::ALIGNMENTS, true ) ) {
349            $attrs['align'] = $align;
350            $class         .= ' align' . $align;
351        }
352
353        if ( is_array( $layout ) && $layout ) {
354            $attrs['layout'] = $layout;
355        }
356
357        return array(
358            'blockName'    => 'core/group',
359            'attrs'        => $attrs,
360            'innerBlocks'  => array( $block ),
361            'innerHTML'    => '<div class="' . $class . '"></div>',
362            'innerContent' => array( '<div class="' . $class . '">', null, '</div>' ),
363        );
364    }
365}