diff --git a/src/wp-includes/class-walker-comment.php b/src/wp-includes/class-walker-comment.php index 3d2eb012e4903..1c0c98795500e 100644 --- a/src/wp-includes/class-walker-comment.php +++ b/src/wp-includes/class-walker-comment.php @@ -185,6 +185,19 @@ public function start_el( &$output, $data_object, $depth = 0, $args = array(), $ return; } + /* + * Allow a registered comment type to render itself. An explicit `callback` + * argument passed to wp_list_comments() takes precedence and is handled above. + */ + $comment_type_object = get_comment_type_object( $comment->comment_type ); + + if ( $comment_type_object && is_callable( $comment_type_object->render_callback ) ) { + ob_start(); + call_user_func( $comment_type_object->render_callback, $comment, $args, $depth ); + $output .= ob_get_clean(); + return; + } + if ( 'comment' === $comment->comment_type ) { add_filter( 'comment_text', array( $this, 'filter_comment_text' ), 40, 2 ); } diff --git a/src/wp-includes/class-wp-comment-type.php b/src/wp-includes/class-wp-comment-type.php index 6f88c5e7b499d..a8c0463eb16d7 100644 --- a/src/wp-includes/class-wp-comment-type.php +++ b/src/wp-includes/class-wp-comment-type.php @@ -104,6 +104,21 @@ final class WP_Comment_Type { */ public $_builtin = false; + /** + * Callback used to render a comment of this type in comment lists. + * + * When set to a callable, {@see Walker_Comment} invokes it to render a + * comment of this type, receiving the same arguments as the `callback` + * argument of wp_list_comments(): the comment object, the arguments array, + * and the depth. An explicit `callback` passed to wp_list_comments() still + * takes precedence. Output from the callback is printed unescaped; the + * callback is responsible for escaping all output. Default null. + * + * @since 7.1.0 + * @var callable|null + */ + public $render_callback = null; + /** * Whether the comment type is hierarchical. * @@ -187,12 +202,13 @@ public function set_props( $args ) { * treated as a provided value and overwrite the default name with false. */ $defaults = array( - 'labels' => array(), - 'description' => '', - 'public' => true, - 'internal' => false, - 'show_ui' => null, - '_builtin' => false, + 'labels' => array(), + 'description' => '', + 'public' => true, + 'internal' => false, + 'show_ui' => null, + 'render_callback' => null, + '_builtin' => false, ); $args = array_merge( $defaults, $args ); diff --git a/src/wp-includes/comment.php b/src/wp-includes/comment.php index 2ebc55a1804b0..4e3f6f58f0996 100644 --- a/src/wp-includes/comment.php +++ b/src/wp-includes/comment.php @@ -365,8 +365,14 @@ function create_initial_comment_types() { * the admin interface or by front-end users. Default true. * @type bool $internal Whether the comment type is for internal use only and should be * excluded from default public-facing contexts. Default false. - * @type bool $show_ui Whether to generate and allow a UI for managing this comment type - * in the admin. Default is value of $public. + * @type bool $show_ui Whether to generate and allow a UI for managing this comment + * type in the admin. Default is value of $public. + * @type callable $render_callback Callback used to render a comment of this type in comment + * lists. Receives the same arguments as the `callback` argument + * of wp_list_comments() (the comment, the arguments, and the + * depth). An explicit wp_list_comments() `callback` takes + * precedence. Output is printed unescaped; the callback must + * escape all output. Default null. * } * @return WP_Comment_Type|WP_Error The registered comment type object on success, * WP_Error object on failure. diff --git a/tests/phpunit/tests/comment/walker.php b/tests/phpunit/tests/comment/walker.php index 504c5e3b0f2a9..434b2c12b3d38 100644 --- a/tests/phpunit/tests/comment/walker.php +++ b/tests/phpunit/tests/comment/walker.php @@ -54,6 +54,185 @@ public function test_has_children() { array( $comment_child, $comment_parent ) ); } + + /** + * Renders the comments on the test post and returns the markup. + * + * @param array $comments Comments to render. + * @return string Rendered markup. + */ + private function render_comments( $comments, $args = array() ) { + return wp_list_comments( + array_merge( + array( + 'echo' => false, + 'walker' => new Walker_Comment(), + ), + $args + ), + $comments + ); + } + + /** + * A registered comment type's render_callback is used to render its comments. + * + * @ticket 35214 + */ + public function test_render_callback_renders_registered_comment_type() { + register_comment_type( + 'review', + array( + 'render_callback' => static function ( $comment ) { + echo '
  • ' . esc_html( $comment->comment_content ) . '
  • '; + }, + ) + ); + + $comment_id = self::factory()->comment->create( + array( + 'comment_post_ID' => $this->post_id, + 'comment_type' => 'review', + 'comment_content' => 'Rendered by callback', + 'comment_approved' => '1', + ) + ); + + $output = $this->render_comments( array( get_comment( $comment_id ) ) ); + + $this->assertStringContainsString( 'class="review"', $output ); + $this->assertStringContainsString( 'Rendered by callback', $output ); + + unregister_comment_type( 'review' ); + } + + /** + * An explicit wp_list_comments() callback takes precedence over a type's render_callback. + * + * @ticket 35214 + */ + public function test_explicit_callback_takes_precedence_over_render_callback() { + register_comment_type( + 'review', + array( + 'render_callback' => static function () { + echo 'FROM_TYPE_CALLBACK'; + }, + ) + ); + + $comment_id = self::factory()->comment->create( + array( + 'comment_post_ID' => $this->post_id, + 'comment_type' => 'review', + 'comment_approved' => '1', + ) + ); + + $output = $this->render_comments( + array( get_comment( $comment_id ) ), + array( + 'callback' => static function () { + echo 'FROM_LIST_CALLBACK'; + }, + ) + ); + + $this->assertStringContainsString( 'FROM_LIST_CALLBACK', $output ); + $this->assertStringNotContainsString( 'FROM_TYPE_CALLBACK', $output ); + + unregister_comment_type( 'review' ); + } + + /** + * Built-in comment types without a render_callback render normally. + * + * @ticket 35214 + */ + public function test_comment_type_without_render_callback_renders_normally() { + $comment_id = self::factory()->comment->create( + array( + 'comment_post_ID' => $this->post_id, + 'comment_type' => 'comment', + 'comment_content' => 'A normal comment', + 'comment_approved' => '1', + ) + ); + + $output = $this->render_comments( array( get_comment( $comment_id ) ) ); + + $this->assertStringContainsString( 'A normal comment', $output ); + } + + /** + * The render_callback receives the comment, the arguments array, and the depth, + * matching the wp_list_comments() `callback` contract. + * + * @ticket 35214 + */ + public function test_render_callback_receives_comment_args_and_depth() { + $received = array(); + + register_comment_type( + 'review', + array( + 'render_callback' => static function ( $comment, $args, $depth ) use ( &$received ) { + $received = array( + 'comment' => $comment, + 'args' => $args, + 'depth' => $depth, + ); + echo '
  • '; + }, + ) + ); + + $comment_id = self::factory()->comment->create( + array( + 'comment_post_ID' => $this->post_id, + 'comment_type' => 'review', + 'comment_approved' => '1', + ) + ); + + $this->render_comments( array( get_comment( $comment_id ) ) ); + + $this->assertInstanceOf( 'WP_Comment', $received['comment'], 'The callback should receive the comment object.' ); + $this->assertSame( (string) $comment_id, (string) $received['comment']->comment_ID, 'The callback should receive the rendered comment.' ); + $this->assertIsArray( $received['args'], 'The callback should receive the arguments array.' ); + $this->assertSame( 1, $received['depth'], 'A top-level comment should be rendered at depth 1.' ); + + unregister_comment_type( 'review' ); + } + + /** + * A render_callback that is not callable is ignored and the comment renders normally. + * + * @ticket 35214 + */ + public function test_non_callable_render_callback_is_ignored() { + register_comment_type( + 'review', + array( + 'render_callback' => 'this_is_not_a_callable_function', + ) + ); + + $comment_id = self::factory()->comment->create( + array( + 'comment_post_ID' => $this->post_id, + 'comment_type' => 'review', + 'comment_content' => 'Falls back to normal rendering', + 'comment_approved' => '1', + ) + ); + + $output = $this->render_comments( array( get_comment( $comment_id ) ) ); + + $this->assertStringContainsString( 'Falls back to normal rendering', $output ); + + unregister_comment_type( 'review' ); + } } class Comment_Callback_Test_Helper { diff --git a/tests/phpunit/tests/comment/wpCommentType.php b/tests/phpunit/tests/comment/wpCommentType.php index bf6d3c7df28dd..9dd8c3a37187b 100644 --- a/tests/phpunit/tests/comment/wpCommentType.php +++ b/tests/phpunit/tests/comment/wpCommentType.php @@ -24,6 +24,19 @@ public function test_instance_defaults() { $this->assertFalse( $comment_type->_builtin ); $this->assertTrue( $comment_type->show_ui ); $this->assertFalse( $comment_type->hierarchical ); + $this->assertNull( $comment_type->render_callback ); + } + + /** + * @ticket 35214 + * + * @covers ::set_props + */ + public function test_render_callback_is_stored() { + $callback = static function () {}; + $comment_type = new WP_Comment_Type( 'foo', array( 'render_callback' => $callback ) ); + + $this->assertSame( $callback, $comment_type->render_callback ); } /**