Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions src/wp-includes/class-walker-comment.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.1.0 Comments of a registered comment type with a `render_callback`
* are rendered via that callback.
*
* @see Walker::start_el()
* @see wp_list_comments()
Expand Down Expand Up @@ -185,6 +187,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 );
}
Expand Down
39 changes: 34 additions & 5 deletions src/wp-includes/class-wp-comment-type.php
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,34 @@ 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. The precedence chain is: an explicit `callback` passed to
* wp_list_comments(), then this callback, then the default markup.
*
* Like the `callback` argument of wp_list_comments(), the callback must only
* output the opening of the list element (an unclosed `<li>` by default);
* {@see Walker_Comment::end_el()} (or the `end-callback` argument) closes
* the element after any child comments have been rendered.
*
* Output from the callback is printed unescaped; the callback is
* responsible for escaping all output.
*
* Only applies when comments are rendered via wp_list_comments() (classic
* themes). Block themes render comments through the `core/comment-template`
* block and do not invoke this callback.
*
* Default null.
*
* @since 7.1.0
* @var callable|null
*/
public $render_callback = null;

/**
* Whether the comment type is hierarchical.
*
Expand Down Expand Up @@ -183,11 +211,12 @@ 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,
'_builtin' => false,
'labels' => array(),
'description' => '',
'public' => true,
'internal' => false,
'render_callback' => null,
'_builtin' => false,
);

$args = array_merge( $defaults, $args );
Expand Down
35 changes: 22 additions & 13 deletions src/wp-includes/comment.php
Original file line number Diff line number Diff line change
Expand Up @@ -361,19 +361,28 @@ function create_initial_comment_types() {
* @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. Core does not
* currently consult this flag; it is intended to drive default
* query exclusions in the future. 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. Core does
* not currently consult this flag; it is intended to drive
* default query exclusions in the future. Default false.
* @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) and, like it, must only output the opening of the list
* element; Walker_Comment::end_el() closes the element. An
* explicit wp_list_comments() `callback` takes precedence.
* Output is printed unescaped; the callback must escape all
* output. Applies only when comments are rendered via
* wp_list_comments() (classic themes). Default null.
* }
* @return WP_Comment_Type|WP_Error The registered comment type object on success,
* WP_Error object on failure.
Expand Down
243 changes: 243 additions & 0 deletions tests/phpunit/tests/comment/walker.php
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,249 @@ 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.
* @param array $args Optional. Arguments merged into the wp_list_comments() defaults.
* Default empty array.
* @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(
// Like a wp_list_comments() callback, only the element opening is output.
'render_callback' => static function ( $comment ) {
echo '<li 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 ) ) );

// Walker_Comment::end_el() closes the element the callback opened.
$this->assertStringContainsString( '<li class="review">Rendered by callback</li><!-- #comment-## -->', $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() {
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() {
$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 '<li>';
},
)
);

$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.
*
* This pins the open-tag contract end to end: the callback outputs only the
* element opening, children render within it, and end_el() closes each element.
*
* @ticket 35214
*/
public function test_child_comment_renders_within_render_callback_parent_element() {
register_comment_type(
'review',
array(
'render_callback' => static function ( $comment ) {
echo '<li class="review">' . 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, '<ul class="children">' );
$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, '</li><!-- #comment-## -->' ), '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() {
$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( '</li><!-- #comment-## -->', $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() {
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 );
}
}

class Comment_Callback_Test_Helper {
Expand Down
13 changes: 13 additions & 0 deletions tests/phpunit/tests/comment/wpCommentType.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,19 @@ public function test_instance_defaults() {
$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() {
$callback = static function () {};
$comment_type = new WP_Comment_Type( 'foo', array( 'render_callback' => $callback ) );

$this->assertSame( $callback, $comment_type->render_callback );
}

/**
Expand Down
Loading