diff --git a/src/wp-includes/class-walker-comment.php b/src/wp-includes/class-walker-comment.php index 3d2eb012e4903..36517490fe6cc 100644 --- a/src/wp-includes/class-walker-comment.php +++ b/src/wp-includes/class-walker-comment.php @@ -158,6 +158,8 @@ public function display_element( $element, &$children_elements, $max_depth, $dep * @since 2.7.0 * @since 5.9.0 Renamed `$comment` to `$data_object` and `$id` to `$current_object_id` * to match parent class for PHP 8 named parameter support. + * @since 7.3.0 Comments of a registered comment type with a `render_callback` + * are rendered via that callback. * * @see Walker::start_el() * @see wp_list_comments() @@ -185,6 +187,16 @@ public function start_el( &$output, $data_object, $depth = 0, $args = array(), $ return; } + // A registered comment type's render callback, after an explicit `callback` argument. + $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 886fadaf14f3d..ec682b72a67db 100644 --- a/src/wp-includes/class-wp-comment-type.php +++ b/src/wp-includes/class-wp-comment-type.php @@ -95,6 +95,26 @@ final class WP_Comment_Type { */ public $_builtin = false; + /** + * Callback used to render a comment of this type in wp_list_comments(). + * + * Receives the same arguments as the wp_list_comments() `callback` argument, + * which takes precedence over it. Like that callback, it must echo only the + * opening of the element (a `
` when `$args['style']` is 'div', an `
  • ` + * otherwise), which Walker_Comment::end_el() closes. A returned string is + * discarded, and output is not escaped. + * + * Setting a callback on the 'comment' type through the + * {@see 'register_comment_type_args'} filter also replaces the walker's link + * stripping for pending comments. + * + * Not used by the `core/comment-template` block. Default null. + * + * @since 7.3.0 + * @var callable|null + */ + public $render_callback = null; + /** * Whether the comment type is hierarchical. * @@ -172,11 +192,12 @@ public function set_props( $args ): void { // No 'label' default, so get_comment_type_labels() can fall back to the default name. $defaults = array( - 'labels' => array(), - 'description' => '', - 'public' => true, - 'internal' => false, - '_builtin' => false, + 'labels' => array(), + 'description' => '', + 'public' => true, + 'internal' => false, + '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 765514ca1f508..fc4370cd824c7 100644 --- a/src/wp-includes/comment.php +++ b/src/wp-includes/comment.php @@ -434,23 +434,29 @@ function create_initial_comment_types(): void { * @param array|string $args { * Optional. Array or string of arguments for registering a comment type. Default empty array. * - * @type string $label Name of the comment type. Usually plural. - * Default is the value of $labels['name']. - * @type string[] $labels An array of labels for this comment type. If not set, the - * default comment labels are used. See get_comment_type_labels() - * for a full list of supported labels. - * @type string $description A short descriptive summary of what the comment type is. - * Default empty. - * @type bool $public Whether the comment type is intended for use publicly either via - * the admin interface or by front-end users. Core does not - * currently act on this argument. Default true. - * @type bool $internal Whether the comment type is for internal use only. Internal - * types are meant to be excluded from comment queries and counts - * by default. Core does not currently act on this argument. - * Default false. - * @type bool $_builtin For internal core use only. Marks the type as native to - * WordPress, which blocks it from being re-registered or - * unregistered. Default false. + * @type string $label Name of the comment type. Usually plural. + * Default is the value of $labels['name']. + * @type string[] $labels An array of labels for this comment type. If not set, the + * default comment labels are used. See get_comment_type_labels() + * for a full list of supported labels. + * @type string $description A short descriptive summary of what the comment type is. + * Default empty. + * @type bool $public Whether the comment type is intended for use publicly either + * via the admin interface or by front-end users. Core does not + * currently act on this argument. Default true. + * @type bool $internal Whether the comment type is for internal use only. Internal + * types are meant to be excluded from comment queries and + * counts by default. Core does not currently act on this + * argument. Default false. + * @type callable $render_callback Callback used to render a comment of this type in + * wp_list_comments(). Receives the same arguments as, and is + * overridden by, the wp_list_comments() `callback` argument. + * Must echo only the opening `
  • ` (or `
    ` for the 'div' + * style), unescaped; a returned string is discarded. + * Default null. + * @type bool $_builtin For internal core use only. Marks the type as native to + * WordPress, which blocks it from being re-registered or + * unregistered. Default false. * } * @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..e68472ca62663 100644 --- a/tests/phpunit/tests/comment/walker.php +++ b/tests/phpunit/tests/comment/walker.php @@ -54,6 +54,346 @@ public function test_has_children() { array( $comment_child, $comment_parent ) ); } + + /** + * Renders the comments on the test post and returns the markup. + * + * @param WP_Comment[] $comments Comments to render. + * @param array $args Optional. Arguments merged into the wp_list_comments() defaults. + * Default empty array. + * @return string Rendered markup. + */ + private function render_comments( array $comments, array $args = array() ): string { + return (string) 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(): void { + register_comment_type( + 'review', + array( + // Like a wp_list_comments() callback, only the element opening is output. + '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 ) ) ); + + // Walker_Comment::end_el() closes the element the callback opened. + $this->assertStringContainsString( '
  • Rendered by callback
  • ', $output ); + } + + /** + * A render_callback can open a `
    ` that end_el() closes under the 'div' style. + * + * @ticket 35214 + */ + public function test_render_callback_pairs_with_the_div_style(): void { + register_comment_type( + 'review', + array( + 'render_callback' => static function ( $comment, $args ) { + $tag = ( 'div' === $args['style'] ) ? 'div' : 'li'; + + echo '<' . $tag . ' class="review">' . 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 ) ), array( 'style' => 'div' ) ); + + $this->assertStringContainsString( + '
    Rendered by callback
    ', + $output + ); + } + + /** + * The register_comment_type_args filter can add a render_callback to a built-in type. + * + * @ticket 35214 + */ + public function test_render_callback_can_be_added_to_a_built_in_type_by_filter(): void { + add_filter( + 'register_comment_type_args', + static function ( $args, $comment_type ) { + if ( 'comment' === $comment_type ) { + $args['render_callback'] = static function () { + echo '
  • '; + }; + } + + return $args; + }, + 10, + 2 + ); + + // Rebuild the built-ins so the filter applies to them. + create_initial_comment_types(); + + $comment_id = self::factory()->comment->create( + array( + 'comment_post_ID' => $this->post_id, + 'comment_type' => 'comment', + 'comment_approved' => '1', + ) + ); + + $output = $this->render_comments( array( get_comment( $comment_id ) ) ); + + $this->assertStringContainsString( '
  • ', $output ); + } + + /** + * 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(): void { + 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 ); + } + + /** + * Built-in comment types without a render_callback render normally. + * + * @ticket 35214 + */ + public function test_comment_type_without_render_callback_renders_normally(): void { + $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(): void { + $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.' ); + } + + /** + * A child comment renders inside the element opened by its parent's render_callback. + * + * @ticket 35214 + */ + public function test_child_comment_renders_within_render_callback_parent_element(): void { + register_comment_type( + 'review', + array( + 'render_callback' => static function ( $comment ) { + echo '
  • ' . esc_html( $comment->comment_content ); + }, + ) + ); + + $parent_id = self::factory()->comment->create( + array( + 'comment_post_ID' => $this->post_id, + 'comment_type' => 'review', + 'comment_content' => 'Parent review', + 'comment_approved' => '1', + ) + ); + $child_id = self::factory()->comment->create( + array( + 'comment_post_ID' => $this->post_id, + 'comment_type' => 'review', + 'comment_content' => 'Child review', + 'comment_approved' => '1', + 'comment_parent' => $parent_id, + ) + ); + + $output = $this->render_comments( array( get_comment( $parent_id ), get_comment( $child_id ) ) ); + + $parent_pos = strpos( $output, 'Parent review' ); + $children_pos = strpos( $output, '
      ' ); + $child_pos = strpos( $output, 'Child review' ); + + $this->assertNotFalse( $children_pos, 'A children list should be rendered.' ); + $this->assertGreaterThan( $parent_pos, $children_pos, 'The children list should open after the parent content.' ); + $this->assertGreaterThan( $children_pos, $child_pos, 'The child should render inside the children list.' ); + $this->assertSame( 2, substr_count( $output, '' ), 'Each element should be closed exactly once by end_el().' ); + } + + /** + * A comment stored with the legacy empty string type renders the default markup. + * + * @ticket 35214 + */ + public function test_legacy_empty_type_renders_default_markup(): void { + $comment_id = self::factory()->comment->create( + array( + 'comment_post_ID' => $this->post_id, + 'comment_type' => '', + 'comment_content' => 'Legacy comment', + 'comment_approved' => '1', + ) + ); + + $output = $this->render_comments( array( get_comment( $comment_id ) ) ); + + $this->assertStringContainsString( 'Legacy comment', $output ); + $this->assertStringContainsString( '', $output ); + } + + /** + * 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(): void { + 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 ); + } + + /** + * A string returned by a render_callback is discarded. + * + * @ticket 35214 + */ + public function test_render_callback_return_value_is_discarded(): void { + register_comment_type( + 'review', + array( + 'render_callback' => static function () { + return '
    • RETURNED_MARKER'; + }, + ) + ); + + $comment_id = self::factory()->comment->create( + array( + 'comment_post_ID' => $this->post_id, + 'comment_type' => 'review', + 'comment_content' => 'Return value test', + 'comment_approved' => '1', + ) + ); + + $output = $this->render_comments( array( get_comment( $comment_id ) ) ); + + $this->assertStringNotContainsString( 'RETURNED_MARKER', $output ); + } } class Comment_Callback_Test_Helper { diff --git a/tests/phpunit/tests/comment/wpCommentType.php b/tests/phpunit/tests/comment/wpCommentType.php index bae6b5100d248..4fc4947c49bb8 100644 --- a/tests/phpunit/tests/comment/wpCommentType.php +++ b/tests/phpunit/tests/comment/wpCommentType.php @@ -23,6 +23,19 @@ public function test_instance_defaults(): void { $this->assertFalse( $comment_type->internal ); $this->assertFalse( $comment_type->_builtin ); $this->assertFalse( $comment_type->hierarchical ); + $this->assertNull( $comment_type->render_callback ); + } + + /** + * @ticket 35214 + * + * @covers ::set_props + */ + public function test_render_callback_is_stored(): void { + $callback = static function () {}; + $comment_type = new WP_Comment_Type( 'foo', array( 'render_callback' => $callback ) ); + + $this->assertSame( $callback, $comment_type->render_callback ); } /**