diff --git a/src/wp-includes/class-walker-comment.php b/src/wp-includes/class-walker-comment.php
index 3d2eb012e4903..15feb8a8a2329 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.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()
@@ -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 );
}
diff --git a/src/wp-includes/class-wp-comment-type.php b/src/wp-includes/class-wp-comment-type.php
index 64566d5ef35d1..58846367b78d1 100644
--- a/src/wp-includes/class-wp-comment-type.php
+++ b/src/wp-includes/class-wp-comment-type.php
@@ -99,6 +99,48 @@ 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; {@see Walker_Comment::end_el()}
+ * (or the `end-callback` argument) closes the element after any child
+ * comments have been rendered. Which element that is depends on the `style`
+ * argument, so the callback has to open a `
` when `$args['style']` is
+ * 'div' and an `
` otherwise, the way {@see Walker_Comment::comment()}
+ * does. Opening the wrong one leaves the markup mismatched.
+ *
+ * The callback must echo its output. Unlike the `render_callback` argument
+ * of register_block_type(), a returned string is discarded.
+ *
+ * Output from the callback is printed unescaped; the callback is
+ * responsible for escaping all output.
+ *
+ * The built-in comment types register without a callback and cannot be
+ * re-registered, but setting one on them through the
+ * {@see 'register_comment_type_args'} filter is supported. It grants no more
+ * than the `callback` argument of wp_list_comments() already does. Note that
+ * a callback on the 'comment' type also takes over the walker's handling of
+ * unapproved comments, which strips links from a pending comment's text for
+ * everyone but its author.
+ *
+ * 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.
*
@@ -183,11 +225,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 );
diff --git a/src/wp-includes/comment.php b/src/wp-includes/comment.php
index d8bbc0158922d..04cbe1dfd52f2 100644
--- a/src/wp-includes/comment.php
+++ b/src/wp-includes/comment.php
@@ -434,22 +434,35 @@ 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. Internal types
- * are excluded from comment queries and counts by default, through
- * the {@see 'default_excluded_comment_types'} filter. 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 excluded from comment queries and counts by
+ * default, through the
+ * {@see 'default_excluded_comment_types'} filter.
+ * 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 echo only the opening of the list
+ * element - a `` when the `style` argument is 'div' and an
+ * `
` otherwise; Walker_Comment::end_el() closes the element.
+ * A returned string is discarded. 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.
+ * @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..30828517003b3 100644
--- a/tests/phpunit/tests/comment/walker.php
+++ b/tests/phpunit/tests/comment/walker.php
@@ -54,6 +54,323 @@ 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 '' . 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 );
+ }
+
+ /**
+ * end_el() closes a `
` under the 'div' style, so a callback that always opened an
+ * `
` would produce mismatched markup. The callback owns that choice.
+ *
+ * @ticket 35214
+ */
+ public function test_render_callback_pairs_with_the_div_style() {
+ 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
+ );
+ }
+
+ /**
+ * Built-in types register without a callback and cannot be re-registered, but the
+ * registration args filter can still set one. That is the sanctioned override path,
+ * so pin it rather than leave it as an accident of the filter's placement.
+ *
+ * @ticket 35214
+ */
+ public function test_render_callback_can_be_added_to_a_built_in_type_by_filter() {
+ 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() {
+ 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 '';
+ },
+ )
+ );
+
+ $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 '' . 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() {
+ $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() {
+ 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 {
diff --git a/tests/phpunit/tests/comment/wpCommentType.php b/tests/phpunit/tests/comment/wpCommentType.php
index dc316aac4a735..2cdba49013f42 100644
--- a/tests/phpunit/tests/comment/wpCommentType.php
+++ b/tests/phpunit/tests/comment/wpCommentType.php
@@ -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 );
}
/**