Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
120 / 120
100.00% covered (success)
100.00%
16 / 16
CRAP
100.00% covered (success)
100.00%
1 / 1
Hooked_Blocks
100.00% covered (success)
100.00%
120 / 120
100.00% covered (success)
100.00%
16 / 16
90
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
 is_managed_by_template
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
5
 anchors
100.00% covered (success)
100.00%
6 / 6
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 0.4.0
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     * Whether a template saved in the Site Editor decides itself where the block goes, so the placements no longer change it.
131     *
132     * That holds once it has the block, or once every `core/post-content` in it lists the block in `ignoredHookedBlocks`,
133     * which core records on saving a template the block was hooked into, and keeps after the owner removes the block.
134     *
135     * @since 0.6.0
136     *
137     * @param string $content The saved template content, without block hooks applied.
138     * @param string $block   Block type.
139     */
140    public static function is_managed_by_template( string $content, string $block ): bool {
141        if ( has_block( $block, $content ) ) {
142            return true;
143        }
144
145        $anchors = self::anchors( parse_blocks( $content ) );
146
147        foreach ( $anchors as $anchor ) {
148            $ignored = $anchor['attrs']['metadata']['ignoredHookedBlocks'] ?? null;
149
150            if ( ! is_array( $ignored ) || ! in_array( $block, $ignored, true ) ) {
151                return false;
152            }
153        }
154
155        return (bool) $anchors;
156    }
157
158    /**
159     * Every `core/post-content` block, at any depth.
160     *
161     * @param array $blocks Parsed blocks.
162     * @return array[]
163     */
164    private static function anchors( array $blocks ): array {
165        $anchors = array();
166
167        foreach ( $blocks as $block ) {
168            if ( self::ANCHOR === $block['blockName'] ) {
169                $anchors[] = $block;
170            }
171
172            $anchors = array_merge( $anchors, self::anchors( $block['innerBlocks'] ) );
173        }
174
175        return $anchors;
176    }
177
178    /**
179     * Post types whose single views get the blocks.
180     *
181     * @return string[]
182     */
183    private static function post_types(): array {
184        /**
185         * Filters the post types whose single views get the Sharing Buttons and Like blocks the template placements add.
186         *
187         * @since 0.4.0
188         *
189         * @param string[] $post_types Post type names. Default posts and pages.
190         */
191        $post_types = apply_filters( 'jetpack_sharing_likes_template_placement_post_types', array( 'post', 'page' ) );
192
193        return is_array( $post_types ) ? array_values( array_unique( array_filter( $post_types, 'is_string' ) ) ) : array( 'post', 'page' );
194    }
195
196    /**
197     * Whether a context shows a single view of one of the post types: a template by the post types it serves,
198     * a pattern by its template types, else by the current request, which never holds in the Site Editor's REST requests.
199     *
200     * @param mixed    $context    Template, template part, pattern array or post.
201     * @param string[] $post_types Post types that get the blocks.
202     */
203    private static function is_single_view_context( $context, array $post_types ): bool {
204        if ( ! $post_types ) {
205            return false;
206        }
207
208        // Once the front end picks a template, its request has the last word: templates and typed patterns serve other views too.
209        if ( ( is_singular() || did_action( 'template_redirect' ) ) && ! is_singular( $post_types ) ) {
210            return false;
211        }
212
213        if ( $context instanceof \WP_Block_Template ) {
214            if ( 'wp_template' !== $context->type ) {
215                return false;
216            }
217
218            if ( self::is_single_view_slug( (string) $context->slug, $post_types ) ) {
219                return true;
220            }
221
222            // A custom template that declares no post types gets nothing, as with Newsletter's Subscribe block (see AGENTS.md).
223            return is_array( $context->post_types ) && (bool) array_intersect( $post_types, $context->post_types );
224        }
225
226        if ( is_array( $context ) ) {
227            $template_types = isset( $context['templateTypes'] ) && is_array( $context['templateTypes'] ) ? $context['templateTypes'] : array();
228
229            foreach ( $template_types as $template_type ) {
230                if ( is_string( $template_type ) && self::is_single_view_slug( $template_type, $post_types ) ) {
231                    return true;
232                }
233            }
234
235            return is_singular( $post_types );
236        }
237
238        return false;
239    }
240
241    /**
242     * Whether a template slug or type serves single views of one of the post types.
243     *
244     * @param string   $slug       Template slug.
245     * @param string[] $post_types Post types that get the blocks.
246     */
247    private static function is_single_view_slug( string $slug, array $post_types ): bool {
248        if ( 'singular' === $slug ) {
249            return true;
250        }
251
252        if ( in_array( 'page', $post_types, true ) && ( 'page' === $slug || str_starts_with( $slug, 'page-' ) ) ) {
253            return true;
254        }
255
256        // Pages have templates of their own; `single` serves every other post type without one.
257        $single_types = array_diff( $post_types, array( 'page' ) );
258
259        if ( 'single' === $slug ) {
260            return (bool) $single_types;
261        }
262
263        if ( ! str_starts_with( $slug, 'single-' ) ) {
264            return false;
265        }
266
267        // `single-{type}` or one post's `single-{type}-{slug}`; post type names can hold hyphens, so the longest that fits owns it.
268        $rest  = substr( $slug, strlen( 'single-' ) );
269        $owner = '';
270
271        foreach ( array_unique( array_merge( array_values( get_post_types() ), $single_types ) ) as $post_type ) {
272            if ( strlen( $post_type ) > strlen( $owner ) && ( $rest === $post_type || str_starts_with( $rest, $post_type . '-' ) ) ) {
273                $owner = $post_type;
274            }
275        }
276
277        return in_array( $owner, $single_types, true );
278    }
279
280    /**
281     * Whether the post being viewed, the template or the pattern already holds the block, as when the owner added it by hand.
282     *
283     * @param string $block   Block type.
284     * @param mixed  $context Template or pattern array, its content not yet hooked.
285     */
286    private static function already_placed( string $block, $context ): bool {
287        $post = self::viewed_post();
288
289        if ( $post && has_block( $block, $post->post_content ) ) {
290            return true;
291        }
292
293        if ( $context instanceof \WP_Block_Template ) {
294            $content = $context->content;
295        } else {
296            $content = $context['content'] ?? null;
297            $path    = $context['filePath'] ?? null;
298
299            // Core hands over a theme pattern before reading its file, the first time it is fetched.
300            if ( null === $content && is_string( $path ) && is_readable( $path ) ) {
301                $content = file_get_contents( $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- A local theme file.
302            }
303        }
304
305        return is_string( $content ) && has_block( $block, $content );
306    }
307
308    /**
309     * Whether the post being viewed keeps the block out, as it would the legacy buttons.
310     *
311     * Its own switch can turn either off, Likes skip password-protected posts, and on Simple a post
312     * that switched Likes on keeps the legacy button.
313     *
314     * @param string $block Block type.
315     */
316    private static function viewed_post_declines( string $block ): bool {
317        $post = self::viewed_post();
318
319        if ( ! $post || ! $post->ID ) {
320            return false;
321        }
322
323        if ( Block_Names::SHARING_BUTTONS === $block ) {
324            return (bool) get_post_meta( $post->ID, Post_Sharing_Switch::META_KEY, false );
325        }
326
327        if ( '' !== $post->post_password ) {
328            return true;
329        }
330
331        // Legacy Likes ignore a switch stored as an array.
332        $switched = get_post_meta( $post->ID, Post_Likes_Switch::META_KEY, true );
333        $switched = is_scalar( $switched ) ? (string) $switched : '';
334
335        return '0' === $switched || ( '' !== $switched && Environment::is_simple_site() );
336    }
337
338    /**
339     * The post a front-end singular request shows, if any.
340     */
341    private static function viewed_post(): ?\WP_Post {
342        $post = is_singular() ? get_queried_object() : null;
343
344        return $post instanceof \WP_Post ? $post : null;
345    }
346
347    /**
348     * Whether Jetpack's blocks load here, so the lazy loader registers the hooked ones as they render.
349     *
350     * Asks for the conditions rather than the block registry: both blocks are only registered
351     * on first render on the front end, after core has built the template.
352     */
353    private static function blocks_can_load(): bool {
354        return Environment::is_block_theme() && Environment::jetpack_blocks_load();
355    }
356
357    /**
358     * Whether the legacy feature shows no buttons, so visitors never get both.
359     *
360     * @param string $block Block type.
361     */
362    private static function legacy_buttons_absent( string $block ): bool {
363        if ( Block_Names::SHARING_BUTTONS === $block ) {
364            return ! Environment::sharing_module_running() || Environment::legacy_sharing_switched_off();
365        }
366
367        return Environment::likes_supported()
368            && ( ! Environment::likes_module_running() || Environment::legacy_likes_switched_off() );
369    }
370
371    /**
372     * Whether a hooked block sits right before or after the post content, where this class places it.
373     *
374     * @param mixed $relative_position   Position relative to the anchor.
375     * @param mixed $parsed_anchor_block The anchor block.
376     */
377    private static function is_content_placement( $relative_position, $parsed_anchor_block ): bool {
378        return is_array( $parsed_anchor_block )
379            && self::ANCHOR === ( $parsed_anchor_block['blockName'] ?? null )
380            && isset( self::POSITIONS[ $relative_position ] );
381    }
382
383    /**
384     * Wrap a block in a `core/group` with the anchor's alignment and layout, so it lines up with the post content.
385     *
386     * @param array $block               The hooked block.
387     * @param array $parsed_anchor_block The anchor block.
388     * @return array
389     */
390    private static function wrap_in_group( array $block, array $parsed_anchor_block ): array {
391        $attrs  = array();
392        $class  = 'wp-block-group';
393        $align  = $parsed_anchor_block['attrs']['align'] ?? null;
394        $layout = $parsed_anchor_block['attrs']['layout'] ?? null;
395
396        // The group saves its alignment class, as its editor save output does.
397        if ( in_array( $align, self::ALIGNMENTS, true ) ) {
398            $attrs['align'] = $align;
399            $class         .= ' align' . $align;
400        }
401
402        if ( is_array( $layout ) && $layout ) {
403            $attrs['layout'] = $layout;
404        }
405
406        return array(
407            'blockName'    => 'core/group',
408            'attrs'        => $attrs,
409            'innerBlocks'  => array( $block ),
410            'innerHTML'    => '<div class="' . $class . '"></div>',
411            'innerContent' => array( '<div class="' . $class . '">', null, '</div>' ),
412        );
413    }
414}