Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
23.04% covered (danger)
23.04%
44 / 191
26.32% covered (danger)
26.32%
5 / 19
CRAP
0.00% covered (danger)
0.00%
0 / 1
Block_Editor
23.04% covered (danger)
23.04%
44 / 191
26.32% covered (danger)
26.32%
5 / 19
3537.57
0.00% covered (danger)
0.00%
0 / 1
 init
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 __construct
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 locale_data
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
72
 is_enabled
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 keep_allowed_blocks
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 forget_blocks
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
20
 restore
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
56
 allowed_html
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
12
 render
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
30
 render_in_email
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
20
 blocks_to_text
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
42
 list_to_text
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
90
 inline_to_text
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 comment_to_edit
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
42
 enqueue_admin
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
12
 settings
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
12
 labels
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 allowed
68.97% covered (warning)
68.97%
20 / 29
0.00% covered (danger)
0.00%
0 / 1
27.68
 paragraph
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * Blocks in comments.
4 *
5 * @package automattic/jetpack-comments
6 */
7
8namespace Automattic\Jetpack\Comments;
9
10use Automattic\Jetpack\Assets;
11
12/**
13 * Keeps the blocks the editor offers, and draws them in the comment.
14 */
15class Block_Editor {
16
17    /**
18     * Singleton instance.
19     *
20     * @var Block_Editor|null
21     */
22    private static $instance = null;
23
24    /**
25     * Whether the comment being filtered now holds blocks: set at 9, cleared at 11, so the
26     * wider kses rules last for that comment's pass alone.
27     *
28     * @var bool
29     */
30    private $has_blocks = false;
31
32    /**
33     * Register the hooks. Safe to call more than once.
34     *
35     * @return Block_Editor
36     */
37    public static function init() {
38        if ( null === self::$instance ) {
39            self::$instance = new self();
40        }
41
42        return self::$instance;
43    }
44
45    /**
46     * Hook in around core's comment filtering.
47     */
48    private function __construct() {
49        // Either side of kses at 10, which lets the markup of these blocks through.
50        add_filter( 'pre_comment_content', array( $this, 'keep_allowed_blocks' ), 9 );
51        add_filter( 'pre_comment_content', array( $this, 'forget_blocks' ), 11 );
52        add_filter( 'wp_kses_allowed_html', array( $this, 'allowed_html' ), 10, 2 );
53        // Ahead of wpautop at 30.
54        add_filter( 'comment_text', array( __CLASS__, 'render' ), 5, 2 );
55        add_filter( 'render_block_core/image', array( Images::class, 'render_core' ), 10, 2 );
56        // Core writes the stored content into its comment emails; WordPress.com has its own templates.
57        add_filter( 'comment_notification_text', array( __CLASS__, 'render_in_email' ), 10, 2 );
58        add_filter( 'comment_moderation_text', array( __CLASS__, 'render_in_email' ), 10, 2 );
59        // The edit-comment screen, for a comment that holds blocks.
60        add_action( 'admin_enqueue_scripts', array( __CLASS__, 'enqueue_admin' ) );
61    }
62
63    /**
64     * Core's translations of the strings in strings.php, and the plural rules under '', in one Jed messages set.
65     *
66     * @return object
67     */
68    public static function locale_data() {
69        $locale = determine_locale();
70
71        if ( 'en_US' === $locale ) {
72            return (object) array();
73        }
74
75        // Keyed by the WordPress version and the package version, whose updates bring new strings.
76        $key      = 'jetpack_comments_editor_i18n_' . md5( $locale . get_bloginfo( 'version' ) . Comments::PACKAGE_VERSION );
77        $messages = get_transient( $key );
78
79        if ( ! is_array( $messages ) ) {
80            $messages = array();
81            $wanted   = array_flip( require __DIR__ . '/strings.php' ) + array( '' => true );
82
83            foreach ( array( 'wp-a11y', 'wp-block-editor', 'wp-block-library', 'wp-blocks', 'wp-components', 'wp-format-library', 'wp-rich-text' ) as $handle ) {
84                $json = load_script_textdomain( $handle, 'default' );
85                $data = is_string( $json ) ? json_decode( $json, true ) : null;
86
87                if ( isset( $data['locale_data']['messages'] ) && is_array( $data['locale_data']['messages'] ) ) {
88                    $messages += array_intersect_key( $data['locale_data']['messages'], $wanted );
89                }
90            }
91
92            // Core translates block titles in PHP, so they are in its .mo, not the script files.
93            foreach ( array( 'Paragraph', 'List', 'List Item', 'Quote', 'Code', 'Embed', 'Image' ) as $title ) {
94                // phpcs:ignore WordPress.WP.I18n.NonSingularStringLiteralText, WordPress.WP.I18n.TextDomainMismatch -- Core's own strings.
95                $messages[ "block title\u{0004}$title" ] = array( _x( $title, 'block title', 'default' ) );
96            }
97
98            // A day, so an updated language pack shows soon after.
99            set_transient( $key, $messages, DAY_IN_SECONDS );
100        }
101
102        return (object) $messages;
103    }
104
105    /**
106     * Whether the block editor replaces the comment textarea.
107     *
108     * @return bool
109     */
110    public static function is_enabled() {
111        /**
112         * Offer the block editor in the Jetpack Comments form.
113         *
114         * @since 0.4.0
115         *
116         * @param bool $enabled Whether to offer the block editor. Default true, unless WordPress.com's
117         *                      "blocks in comments" Discussion setting is off.
118         */
119        return (bool) apply_filters( 'jetpack_comments_block_editor', (bool) get_option( 'enable_blocks_comments', true ) );
120    }
121
122    /**
123     * Drop any block the editor does not offer.
124     *
125     * @param string $content Slashed comment content.
126     * @return string
127     */
128    public function keep_allowed_blocks( $content ) {
129        $this->has_blocks = self::is_enabled() && has_blocks( $content );
130
131        if ( ! $this->has_blocks ) {
132            return $content;
133        }
134
135        return wp_slash( serialize_blocks( self::allowed( parse_blocks( wp_unslash( $content ) ) ) ) );
136    }
137
138    /**
139     * End the wider kses rules with the comment they were for.
140     *
141     * @param string $content Slashed comment content.
142     * @return string
143     */
144    public function forget_blocks( $content ) {
145        if ( $this->has_blocks && ( has_block( 'core/embed', $content ) || has_block( 'core/image', $content ) ) ) {
146            $content = wp_slash( serialize_blocks( self::restore( parse_blocks( wp_unslash( $content ) ) ) ) );
147        }
148
149        $this->has_blocks = false;
150
151        return $content;
152    }
153
154    /**
155     * Undo kses at every depth: the embed and image URLs as they were.
156     *
157     * @param array $blocks Parsed blocks.
158     * @param int   $images Images kept so far.
159     * @return array
160     */
161    private static function restore( array $blocks, &$images = 0 ) {
162        foreach ( $blocks as $i => $block ) {
163            // Core's kses pass over block attributes wrote & as &amp; in each embed's URL. Put it back,
164            // or the editor's save no longer matches the markup when the comment is edited.
165            if ( 'core/embed' === $block['blockName'] && isset( $block['attrs']['url'] ) && is_string( $block['attrs']['url'] ) ) {
166                $blocks[ $i ]['attrs']['url'] = html_entity_decode( $block['attrs']['url'], ENT_QUOTES );
167            }
168
169            if ( 'core/image' === $block['blockName'] ) {
170                $blocks[ $i ] = Images::block( $block, null, $images ) ?? $block;
171            }
172
173            if ( $block['innerBlocks'] ) {
174                $blocks[ $i ]['innerBlocks'] = self::restore( $block['innerBlocks'], $images );
175            }
176        }
177
178        return $blocks;
179    }
180
181    /**
182     * Let the tags these blocks save through kses, for a comment that holds them.
183     *
184     * @param array|mixed $tags    Allowed tags. Null when an earlier filter drops them.
185     * @param string      $context The kses context.
186     * @return array
187     */
188    public function allowed_html( $tags, $context ) {
189        if ( 'pre_comment_content' !== $context || ! $this->has_blocks ) {
190            return $tags;
191        }
192
193        return array_merge(
194            (array) $tags,
195            array(
196                'p'          => array(),
197                'br'         => array(),
198                'li'         => array(),
199                // The one class each block saves, without which the editor reads it as invalid.
200                'blockquote' => array(
201                    'cite'  => true,
202                    'class' => array( 'values' => array( 'wp-block-quote' ) ),
203                ),
204                'pre'        => array( 'class' => array( 'values' => array( 'wp-block-code' ) ) ),
205                'ul'         => array( 'class' => array( 'values' => array( 'wp-block-list' ) ) ),
206                // Verbum's lists could also start elsewhere, or count down.
207                'ol'         => array(
208                    'class'    => array( 'values' => array( 'wp-block-list' ) ),
209                    'start'    => true,
210                    'reversed' => true,
211                ),
212                'figure'     => array( 'class' => array( 'values' => array( 'wp-block-embed', 'wp-block-image' ) ) ),
213                'div'        => array( 'class' => array( 'values' => array( 'wp-block-embed__wrapper' ) ) ),
214            )
215        );
216    }
217
218    /**
219     * The comment's HTML without the block delimiters, and without any block the editor does not offer.
220     *
221     * @param string|mixed     $content Comment content.
222     * @param \WP_Comment|null $comment The comment, where the caller has it.
223     * @return string|mixed
224     */
225    public static function render( $content, $comment = null ) {
226        if ( ! is_string( $content ) || ! has_blocks( $content ) ) {
227            return $content;
228        }
229
230        // Only an approved comment on the front end draws an embed or image; the rest show the link.
231        $embed = $comment instanceof \WP_Comment && '1' === (string) $comment->comment_approved && ! is_admin();
232
233        // With their attributes cleared, what is left of the delimiters is this shape alone.
234        return (string) preg_replace( '#<!-- /?wp:[a-z0-9/-]+ /?-->#', '', serialize_blocks( self::allowed( parse_blocks( $content ), $embed ) ) );
235    }
236
237    /**
238     * Core's comment email, with the comment's block markup replaced by the text it draws.
239     *
240     * @param string|mixed $message    The email body.
241     * @param int          $comment_id The comment the email is about.
242     * @return string|mixed
243     */
244    public static function render_in_email( $message, $comment_id ) {
245        if ( ! is_string( $message ) ) {
246            return $message;
247        }
248
249        $comment = get_comment( $comment_id );
250
251        if ( ! $comment instanceof \WP_Comment || ! has_blocks( $comment->comment_content ) ) {
252            return $message;
253        }
254
255        // The exact string core wrote, so a body another filter rewrote is left alone, not half replaced.
256        $raw = wp_specialchars_decode( $comment->comment_content );
257
258        // Pruned as if saving, which keeps how a list counts and leaves an embed as its URL, fetching nothing.
259        $text = self::blocks_to_text( self::allowed( parse_blocks( $comment->comment_content ) ) );
260
261        return str_replace( $raw, $text, $message );
262    }
263
264    /**
265     * The comment's blocks as the plain text core's email body holds.
266     *
267     * @param array $blocks Parsed blocks, pruned to the ones the editor offers.
268     * @param int   $depth  How far inside nested lists these blocks sit.
269     * @return string
270     */
271    private static function blocks_to_text( array $blocks, $depth = 0 ) {
272        $parts = array();
273
274        foreach ( $blocks as $block ) {
275            if ( 'core/list' === $block['blockName'] ) {
276                $parts[] = self::list_to_text( $block, $depth );
277                continue;
278            }
279
280            if ( 'core/image' === $block['blockName'] ) {
281                $parts[] = $block['attrs']['url'];
282                continue;
283            }
284
285            if ( 'core/quote' === $block['blockName'] ) {
286                // The marker WordPress.com's own comment emails use, so a quote still reads as one.
287                $lines   = explode( "\n", self::blocks_to_text( $block['innerBlocks'], $depth ) );
288                $parts[] = implode( "\n", array_map( fn( $line ) => rtrim( '> ' . $line ), $lines ) );
289                continue;
290            }
291
292            $text = self::inline_to_text( $block['innerHTML'] );
293
294            if ( '' !== $text ) {
295                $parts[] = $text;
296            }
297        }
298
299        return implode( "\n\n", $parts );
300    }
301
302    /**
303     * A list's items, each marked the way the list is set to count.
304     *
305     * @param array $list  A core/list block.
306     * @param int   $depth How far inside nested lists this list sits.
307     * @return string
308     */
309    private static function list_to_text( array $list, $depth ) {
310        $attrs    = is_array( $list['attrs'] ) ? $list['attrs'] : array();
311        $items    = $list['innerBlocks'];
312        $ordered  = ! empty( $attrs['ordered'] );
313        $reversed = $ordered && ! empty( $attrs['reversed'] );
314        $first    = isset( $attrs['start'] ) ? (int) $attrs['start'] : ( $reversed ? count( $items ) : 1 );
315        $indent   = str_repeat( '  ', $depth );
316        $lines    = array();
317
318        foreach ( $items as $i => $item ) {
319            $marker = $ordered ? ( $first + $i * ( $reversed ? -1 : 1 ) ) . '. ' : '* ';
320            $nested = self::blocks_to_text( $item['innerBlocks'], $depth + 1 );
321            $line   = $indent . $marker . self::inline_to_text( $item['innerHTML'] );
322
323            $lines[] = '' === $nested ? $line : $line . "\n" . $nested;
324        }
325
326        return implode( "\n", $lines );
327    }
328
329    /**
330     * One block's markup as the text it reads like.
331     *
332     * @param string $html The block's inner HTML.
333     * @return string
334     */
335    private static function inline_to_text( $html ) {
336        $html = (string) preg_replace( '#<br\s*/?>#i', "\n", $html );
337
338        return trim( html_entity_decode( wp_strip_all_tags( $html ), ENT_QUOTES, 'UTF-8' ) );
339    }
340
341    /**
342     * The comment the edit-comment screen is open on, where it holds blocks and the block editor is on.
343     *
344     * @return \WP_Comment|null
345     */
346    private static function comment_to_edit() {
347        global $pagenow;
348
349        if ( ! self::is_enabled() ) {
350            return null;
351        }
352
353        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- which comment the screen shows, read only.
354        $comment = 'comment.php' === $pagenow && isset( $_GET['c'] ) ? get_comment( absint( $_GET['c'] ) ) : null;
355
356        return $comment instanceof \WP_Comment && has_blocks( $comment->comment_content ) ? $comment : null;
357    }
358
359    /**
360     * Load the editor onto the edit-comment screen.
361     *
362     * @return void
363     */
364    public static function enqueue_admin() {
365        $comment = self::comment_to_edit();
366        // The comment as this editor writes it, so blocks from Verbum's editor open as blocks it knows.
367        $content = $comment ? serialize_blocks( self::allowed( parse_blocks( $comment->comment_content ) ) ) : '';
368
369        // With nothing left, the editor would save the comment empty; the raw markup is safer to edit.
370        if ( ! has_blocks( $content ) ) {
371            return;
372        }
373
374        Assets::register_script(
375            'jetpack-comments-admin',
376            '../../build/admin.js',
377            __FILE__,
378            array(
379                'in_footer' => true,
380                'strategy'  => 'defer',
381                'enqueue'   => true,
382            )
383        );
384
385        wp_add_inline_script(
386            'jetpack-comments-admin',
387            'window.jetpackCommentsEditorLocale = ' . wp_json_encode( self::locale_data(), JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP ) . ';'
388                . 'window.jetpackCommentsEditorLabels = ' . wp_json_encode( self::labels(), JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP ) . ';'
389                . 'window.jetpackCommentsEditorContent = ' . wp_json_encode( $content, JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP ) . ';',
390            'before'
391        );
392    }
393
394    /**
395     * What a host needs to run the editor outside the comment form.
396     *
397     * @return array
398     */
399    public static function settings() {
400        $lengths    = wp_get_comment_fields_max_lengths();
401        $assets_url = trailingslashit( Assets::normalize_path( plugins_url( '../../build', __FILE__ ) ) );
402        $asset      = include dirname( __DIR__, 2 ) . '/build/editor-loader.asset.php';
403
404        return array(
405            // Where the lazy chunks live; a plugin that combines scripts moves the bundle elsewhere.
406            'assetsUrl'    => $assets_url,
407            // Versioned like the chunks, since a host that does not enqueue the entry gets no version from WordPress.
408            'loaderUrl'    => $assets_url . 'editor-loader.js?minify=false&ver=' . $asset['version'],
409            'maxLength'    => isset( $lengths['comment_content'] ) ? (int) $lengths['comment_content'] : 65525,
410            'blocks'       => self::is_enabled(),
411            'editorLocale' => self::is_enabled() ? self::locale_data() : (object) array(),
412            'editor'       => self::labels(),
413        );
414    }
415
416    /**
417     * What the editor needs from PHP: its own strings, the embed route, and whether it offers images.
418     *
419     * @return array
420     */
421    public static function labels() {
422        return array(
423            'blockTools' => __( 'Block tools', 'jetpack-comments' ),
424            'addBlock'   => __( 'Add block', 'jetpack-comments' ),
425            'embedUrl'   => Embeds::is_enabled() ? Checkpoint_Endpoint::route_url( Embeds::ROUTE ) : '',
426            'images'     => Images::is_enabled(),
427            'imageError' => __( 'This image could not be loaded. Check its URL and try again.', 'jetpack-comments' ),
428        );
429    }
430
431    /**
432     * The allowed blocks, at every depth.
433     *
434     * @param array     $blocks Parsed blocks.
435     * @param bool|null $render Null while saving; at render, whether embeds and images may draw.
436     * @param int       $images Images kept so far.
437     * @return array
438     */
439    private static function allowed( array $blocks, $render = null, &$images = 0 ) {
440        $kept = array();
441
442        foreach ( $blocks as $block ) {
443            // Verbum's editor also offered captions on both; this one keeps their text.
444            if ( 'core/image' === $block['blockName'] || 'core/embed' === $block['blockName'] ) {
445                $media = 'core/embed' === $block['blockName'] ? Embeds::block( $block, $render ) : Images::block( $block, $render, $images );
446
447                if ( $media ) {
448                    $kept[] = $media;
449                }
450
451                if ( preg_match( '#<figcaption[^>]*>(.*?)</figcaption>#s', $block['innerHTML'], $caption ) && '' !== trim( $caption[1] ) ) {
452                    $kept[] = self::paragraph( trim( $caption[1] ) );
453                }
454
455                continue;
456            }
457
458            // Verbum kept headings where the site's comment tags allowed them; this editor has none.
459            if ( 'core/heading' === $block['blockName'] ) {
460                if ( preg_match( '#<h([1-6])[^>]*>(.*?)</h\1>#s', $block['innerHTML'], $heading ) && '' !== trim( $heading[2] ) ) {
461                    $kept[] = self::paragraph( '<strong>' . trim( $heading[2] ) . '</strong>' );
462                }
463                continue;
464            }
465
466            // A null name is the markup between blocks, which kses sees like any other.
467            if ( null !== $block['blockName'] && ! in_array( $block['blockName'], array( 'core/paragraph', 'core/list', 'core/list-item', 'core/quote', 'core/code' ), true ) ) {
468                continue;
469            }
470
471            // The markup carries everything these blocks draw, but the editor reads a list's kind, start, and
472            // direction from its attributes alone. Rendering strips delimiters that carry none.
473            $block['attrs'] = null === $render && 'core/list' === $block['blockName'] ? array_intersect_key( $block['attrs'], array_flip( array( 'ordered', 'start', 'reversed' ) ) ) : array();
474
475            $inner                 = $block['innerBlocks'];
476            $content               = $block['innerContent'];
477            $block['innerBlocks']  = array();
478            $block['innerContent'] = array();
479
480            // Each null in innerContent marks where the next inner block goes.
481            foreach ( $content as $chunk ) {
482                if ( null !== $chunk ) {
483                    $block['innerContent'][] = $chunk;
484                    continue;
485                }
486
487                // An image or embed in a quote can come back as more than one block.
488                foreach ( self::allowed( array( array_shift( $inner ) ), $render, $images ) as $child ) {
489                    $block['innerBlocks'][]  = $child;
490                    $block['innerContent'][] = null;
491                }
492            }
493
494            $kept[] = $block;
495        }
496
497        return $kept;
498    }
499
500    /**
501     * A paragraph block holding the given HTML.
502     *
503     * @param string $html Inline HTML.
504     * @return array
505     */
506    public static function paragraph( $html ) {
507        $html = "\n<p>$html</p>\n";
508
509        return array(
510            'blockName'    => 'core/paragraph',
511            'attrs'        => array(),
512            'innerBlocks'  => array(),
513            'innerHTML'    => $html,
514            'innerContent' => array( $html ),
515        );
516    }
517}