From 0904ce2552941956c49ed8304c2f4710af8a2b1c Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 24 Jun 2026 14:48:12 -0700 Subject: [PATCH 1/4] Comments: Introduce a register_comment_type() API. Add a comment type registration API modeled on the post type and taxonomy APIs, a first step toward custom comment types. Introduce the WP_Comment_Type class and register_comment_type(), unregister_comment_type(), get_comment_type_object(), get_comment_types(), and comment_type_exists(). Register the built-in comment, pingback, trackback, and note types via create_initial_comment_types(), hooked on init at priority 0 and on change_locale so labels re-translate. The note type is marked internal. Make the comment_type() template tag fall back to a registered type's singular label for non-built-in types when no custom text is supplied. Built-in output and explicit overrides are unchanged. Registration provides labels and metadata only; it does not constrain the values stored in the comment_type column or alter WP_Comment_Query behavior. See #35214. --- src/wp-includes/class-wp-comment-type.php | 238 ++++++++++++++++++ src/wp-includes/comment-template.php | 16 +- src/wp-includes/comment.php | 293 ++++++++++++++++++++++ src/wp-includes/default-filters.php | 4 + src/wp-settings.php | 4 +- 5 files changed, 553 insertions(+), 2 deletions(-) create mode 100644 src/wp-includes/class-wp-comment-type.php diff --git a/src/wp-includes/class-wp-comment-type.php b/src/wp-includes/class-wp-comment-type.php new file mode 100644 index 0000000000000..374108568e9a3 --- /dev/null +++ b/src/wp-includes/class-wp-comment-type.php @@ -0,0 +1,238 @@ +name = $comment_type; + + $this->set_props( $args ); + } + + /** + * Sets comment type properties. + * + * See the register_comment_type() function for accepted arguments for `$args`. + * + * @since 7.1.0 + * + * @param array|string $args Array or string of arguments for registering a comment type. + */ + public function set_props( $args ) { + $args = wp_parse_args( $args ); + + /** + * Filters the arguments for registering a comment type. + * + * @since 7.1.0 + * + * @param array $args Array of arguments for registering a comment type. + * See the register_comment_type() function for accepted arguments. + * @param string $comment_type Comment type key. + */ + $args = apply_filters( 'register_comment_type_args', $args, $this->name ); + + $comment_type = $this->name; + + /** + * Filters the arguments for registering a specific comment type. + * + * The dynamic portion of the filter name, `$comment_type`, refers to the comment type key. + * + * Possible hook names include: + * + * - `register_comment_comment_type_args` + * - `register_pingback_comment_type_args` + * + * @since 7.1.0 + * + * @param array $args Array of arguments for registering a comment type. + * See the register_comment_type() function for accepted arguments. + * @param string $comment_type Comment type key. + */ + $args = apply_filters( "register_{$comment_type}_comment_type_args", $args, $this->name ); + + $defaults = array( + 'label' => false, + 'labels' => array(), + 'description' => '', + 'public' => true, + 'internal' => false, + 'show_ui' => null, + '_builtin' => false, + ); + + $args = array_merge( $defaults, $args ); + + // If not set, default to the setting for 'public'. + if ( null === $args['show_ui'] ) { + $args['show_ui'] = $args['public']; + } + + $args['name'] = $this->name; + + foreach ( $args as $property_name => $property_value ) { + $this->$property_name = $property_value; + } + + $this->labels = get_comment_type_labels( $this ); + $this->label = $this->labels->name; + } + + /** + * Returns the default labels for comment types. + * + * @since 7.1.0 + * + * @return (string|null)[][] The default labels for comment types. + */ + public static function get_default_labels() { + if ( ! empty( self::$default_labels ) ) { + return self::$default_labels; + } + + self::$default_labels = array( + 'name' => array( _x( 'Comments', 'comment type general name' ), null ), + 'singular_name' => array( _x( 'Comment', 'comment type singular name' ), null ), + ); + + return self::$default_labels; + } + + /** + * Resets the cache for the default labels. + * + * @since 7.1.0 + */ + public static function reset_default_labels() { + self::$default_labels = array(); + } +} diff --git a/src/wp-includes/comment-template.php b/src/wp-includes/comment-template.php index 43bd68ff972a4..38991f60929ad 100644 --- a/src/wp-includes/comment-template.php +++ b/src/wp-includes/comment-template.php @@ -1185,6 +1185,9 @@ function get_comment_type( $comment_id = 0 ) { * @param string|false $pingback_text Optional. String to display for pingback type. Default false. */ function comment_type( $comment_text = false, $trackback_text = false, $pingback_text = false ) { + // Whether the caller supplied custom text for the default comment label. + $comment_text_overridden = ( false !== $comment_text ); + if ( false === $comment_text ) { $comment_text = _x( 'Comment', 'noun' ); } @@ -1203,7 +1206,18 @@ function comment_type( $comment_text = false, $trackback_text = false, $pingback echo $pingback_text; break; default: - echo $comment_text; + /* + * For a registered, non-built-in comment type, fall back to its singular label + * when the caller did not supply custom text. Built-in types and explicit + * overrides keep their existing output. + */ + $comment_type_object = $comment_text_overridden ? null : get_comment_type_object( $type ); + + if ( $comment_type_object && ! $comment_type_object->_builtin && isset( $comment_type_object->labels->singular_name ) ) { + echo $comment_type_object->labels->singular_name; + } else { + echo $comment_text; + } } } diff --git a/src/wp-includes/comment.php b/src/wp-includes/comment.php index b93908adc0519..2ebc55a1804b0 100644 --- a/src/wp-includes/comment.php +++ b/src/wp-includes/comment.php @@ -273,6 +273,299 @@ function get_comments( $args = '' ) { return $query->query( $args ); } +/** + * Creates the initial comment types when 'init' action is fired. + * + * See register_comment_type() for accepted arguments. + * + * @since 7.1.0 + */ +function create_initial_comment_types() { + WP_Comment_Type::reset_default_labels(); + + register_comment_type( + 'comment', + array( + 'label' => __( 'Comments' ), + 'labels' => array( + 'singular_name' => _x( 'Comment', 'noun' ), + ), + 'public' => true, + '_builtin' => true, + ) + ); + + register_comment_type( + 'pingback', + array( + 'label' => __( 'Pingbacks' ), + 'labels' => array( + 'singular_name' => __( 'Pingback' ), + ), + 'public' => true, + '_builtin' => true, + ) + ); + + register_comment_type( + 'trackback', + array( + 'label' => __( 'Trackbacks' ), + 'labels' => array( + 'singular_name' => __( 'Trackback' ), + ), + 'public' => true, + '_builtin' => true, + ) + ); + + register_comment_type( + 'note', + array( + 'label' => _x( 'Notes', 'comment type general name' ), + 'labels' => array( + 'singular_name' => _x( 'Note', 'comment type singular name' ), + ), + 'public' => false, + 'internal' => true, + '_builtin' => true, + ) + ); +} + +/** + * Registers a comment type. + * + * Note: Comment type registrations should not be hooked before the {@see 'init'} action. + * This is because comment type slugs need to be reserved as part of the upgrade routine + * and global variables need to be available for the comment type to register itself. + * + * Comment types are stored verbatim in the `comment_type` column of the comments table. + * Registration provides labels and metadata for a type; it does not constrain which values + * may be stored. + * + * @since 7.1.0 + * + * @global WP_Comment_Type[] $wp_comment_types List of comment types. + * + * @param string $comment_type Comment type key. Must not exceed 20 characters and may only + * contain lowercase alphanumeric characters, dashes, and underscores. + * See sanitize_key(). + * @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 shown in the menu. Usually plural. + * Default is value of $labels['name']. + * @type string[] $labels An array of labels for this comment type. If not set, comment + * labels are inherited. 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. 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. + * } + * @return WP_Comment_Type|WP_Error The registered comment type object on success, + * WP_Error object on failure. + */ +function register_comment_type( $comment_type, $args = array() ) { + global $wp_comment_types; + + if ( ! is_array( $wp_comment_types ) ) { + $wp_comment_types = array(); + } + + // Sanitize comment type name. + $comment_type = sanitize_key( $comment_type ); + + if ( empty( $comment_type ) || strlen( $comment_type ) > 20 ) { + _doing_it_wrong( __FUNCTION__, __( 'Comment type names must be between 1 and 20 characters in length.' ), '7.1.0' ); + return new WP_Error( 'comment_type_length_invalid', __( 'Comment type names must be between 1 and 20 characters in length.' ) ); + } + + $comment_type_object = new WP_Comment_Type( $comment_type, $args ); + + $wp_comment_types[ $comment_type ] = $comment_type_object; + + /** + * Fires after a comment type is registered. + * + * @since 7.1.0 + * + * @param string $comment_type Comment type key. + * @param WP_Comment_Type $comment_type_object Comment type object. + */ + do_action( 'registered_comment_type', $comment_type, $comment_type_object ); + + /** + * Fires after a specific comment type is registered. + * + * The dynamic portion of the filter name, `$comment_type`, refers to the comment type key. + * + * Possible hook names include: + * + * - `registered_comment_type_comment` + * - `registered_comment_type_pingback` + * + * @since 7.1.0 + * + * @param string $comment_type Comment type key. + * @param WP_Comment_Type $comment_type_object Comment type object. + */ + do_action( "registered_comment_type_{$comment_type}", $comment_type, $comment_type_object ); + + return $comment_type_object; +} + +/** + * Unregisters a comment type. + * + * Cannot be used to unregister built-in comment types. + * + * @since 7.1.0 + * + * @global WP_Comment_Type[] $wp_comment_types List of comment types. + * + * @param string $comment_type Comment type key. + * @return true|WP_Error True on success, WP_Error on failure or if the comment type doesn't exist. + */ +function unregister_comment_type( $comment_type ) { + global $wp_comment_types; + + if ( ! comment_type_exists( $comment_type ) ) { + return new WP_Error( 'invalid_comment_type', __( 'Invalid comment type.' ) ); + } + + $comment_type_object = get_comment_type_object( $comment_type ); + + // Do not allow unregistering built-in comment types. + if ( $comment_type_object->_builtin ) { + return new WP_Error( 'invalid_comment_type', __( 'Unregistering a built-in comment type is not allowed.' ) ); + } + + unset( $wp_comment_types[ $comment_type ] ); + + /** + * Fires after a comment type is unregistered. + * + * @since 7.1.0 + * + * @param string $comment_type Comment type key. + */ + do_action( 'unregistered_comment_type', $comment_type ); + + return true; +} + +/** + * Retrieves a comment type object by name. + * + * @since 7.1.0 + * + * @global WP_Comment_Type[] $wp_comment_types List of comment types. + * + * @param string $comment_type The name of a registered comment type. + * @return WP_Comment_Type|null WP_Comment_Type object if it exists, null otherwise. + */ +function get_comment_type_object( $comment_type ) { + global $wp_comment_types; + + if ( ! is_scalar( $comment_type ) || empty( $wp_comment_types[ $comment_type ] ) ) { + return null; + } + + return $wp_comment_types[ $comment_type ]; +} + +/** + * Retrieves a list of registered comment type names or objects. + * + * @since 7.1.0 + * + * @global WP_Comment_Type[] $wp_comment_types List of comment types. + * + * @param array|string $args Optional. An array of key => value arguments to match against + * the comment type objects. Default empty array. + * @param string $output Optional. The type of output to return. Either comment type 'names' + * or 'objects'. Default 'names'. + * @param string $operator Optional. The logical operation to perform. 'or' means only one + * element from the array needs to match; 'and' means all elements + * must match; 'not' means no elements may match. Default 'and'. + * @return string[]|WP_Comment_Type[] An array of comment type names or objects. + */ +function get_comment_types( $args = array(), $output = 'names', $operator = 'and' ) { + global $wp_comment_types; + + $field = ( 'names' === $output ) ? 'name' : false; + + return wp_filter_object_list( $wp_comment_types, $args, $operator, $field ); +} + +/** + * Determines whether a comment type is registered. + * + * @since 7.1.0 + * + * @param string $comment_type Comment type name. + * @return bool Whether the comment type is registered. + */ +function comment_type_exists( $comment_type ) { + return (bool) get_comment_type_object( $comment_type ); +} + +/** + * Builds an object with all comment type labels out of a comment type object. + * + * @since 7.1.0 + * + * @param WP_Comment_Type $comment_type_object Comment type object. + * @return object { + * Comment type labels object. + * + * @type string $name General name for the comment type, usually plural. The same and + * overridden by `$comment_type_object->label`. Default 'Comments'. + * @type string $singular_name Name for one object of this comment type. Default 'Comment'. + * @type string $menu_name Label for the menu name. Default is the same as `name`. + * } + */ +function get_comment_type_labels( $comment_type_object ) { + $nohier_vs_hier_defaults = WP_Comment_Type::get_default_labels(); + + $nohier_vs_hier_defaults['menu_name'] = $nohier_vs_hier_defaults['name']; + + $labels = _get_custom_object_labels( $comment_type_object, $nohier_vs_hier_defaults ); + + $comment_type = $comment_type_object->name; + + $default_labels = clone $labels; + + /** + * Filters the labels of a specific comment type. + * + * The dynamic portion of the hook name, `$comment_type`, refers to the comment type slug. + * + * Possible hook names include: + * + * - `comment_type_labels_comment` + * - `comment_type_labels_pingback` + * + * @since 7.1.0 + * + * @see get_comment_type_labels() for the full list of comment type labels. + * + * @param object $labels Object with labels for the comment type as member variables. + */ + $labels = apply_filters( "comment_type_labels_{$comment_type}", $labels ); + + // Ensure that the filtered labels contain all required default values. + $labels = (object) array_merge( (array) $default_labels, (array) $labels ); + + return $labels; +} + /** * Retrieves all of the WordPress supported comment statuses. * diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index 5581828a10b61..966c69cca5cf9 100644 --- a/src/wp-includes/default-filters.php +++ b/src/wp-includes/default-filters.php @@ -532,6 +532,10 @@ add_action( 'split_shared_term', '_wp_check_split_nav_menu_terms', 10, 4 ); add_action( 'wp_split_shared_term_batch', '_wp_batch_split_terms' ); +// Comment types. +add_action( 'init', 'create_initial_comment_types', 0 ); // Highest priority. +add_action( 'change_locale', 'create_initial_comment_types' ); + // Comment type updates. add_action( 'admin_init', '_wp_check_for_scheduled_update_comment_type' ); add_action( 'wp_update_comment_type_batch', '_wp_batch_update_comment_type' ); diff --git a/src/wp-settings.php b/src/wp-settings.php index ef5c7784ee561..9ec2c3607271d 100644 --- a/src/wp-settings.php +++ b/src/wp-settings.php @@ -232,6 +232,7 @@ require ABSPATH . WPINC . '/comment.php'; require ABSPATH . WPINC . '/class-wp-comment.php'; require ABSPATH . WPINC . '/class-wp-comment-query.php'; +require ABSPATH . WPINC . '/class-wp-comment-type.php'; require ABSPATH . WPINC . '/class-walker-comment.php'; require ABSPATH . WPINC . '/comment-template.php'; require ABSPATH . WPINC . '/rewrite.php'; @@ -554,10 +555,11 @@ // Create common globals. require ABSPATH . WPINC . '/vars.php'; -// Make taxonomies and posts available to plugins and themes. +// Make taxonomies, posts, and comment types available to plugins and themes. // @plugin authors: warning: these get registered again on the init hook. create_initial_taxonomies(); create_initial_post_types(); +create_initial_comment_types(); wp_start_scraping_edited_file_errors(); From ebb51c5e7fa89a3aa9c48cf8e48f005b5a5f3645 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 24 Jun 2026 14:48:18 -0700 Subject: [PATCH 2/4] Comments: Add tests for the comment type registration API. Cover register_comment_type(), unregister_comment_type(), get_comment_type_object(), get_comment_types(), comment_type_exists(), the WP_Comment_Type class, and the registered/unregistered actions and label filters. Verify the built-in types are registered and cannot be unregistered, and that the comment_type() template tag renders registered type labels while preserving built-in output. See #35214. --- tests/phpunit/tests/comment/commentType.php | 117 ++++++++ tests/phpunit/tests/comment/types.php | 281 ++++++++++++++++++ tests/phpunit/tests/comment/wpCommentType.php | 120 ++++++++ 3 files changed, 518 insertions(+) create mode 100644 tests/phpunit/tests/comment/commentType.php create mode 100644 tests/phpunit/tests/comment/types.php create mode 100644 tests/phpunit/tests/comment/wpCommentType.php diff --git a/tests/phpunit/tests/comment/commentType.php b/tests/phpunit/tests/comment/commentType.php new file mode 100644 index 0000000000000..da7d316afbe0a --- /dev/null +++ b/tests/phpunit/tests/comment/commentType.php @@ -0,0 +1,117 @@ +post->create(); + } + + public function tear_down() { + global $wp_comment_types; + + foreach ( array_keys( $wp_comment_types ) as $comment_type ) { + if ( ! $wp_comment_types[ $comment_type ]->_builtin ) { + unset( $wp_comment_types[ $comment_type ] ); + } + } + + parent::tear_down(); + } + + /** + * Returns the output of comment_type() for a comment of the given type. + * + * @param string $type Comment type stored on the comment. + * @param mixed ...$args Optional arguments passed through to comment_type(). + * @return string Captured output. + */ + private function get_comment_type_output( $type, ...$args ) { + $comment_id = self::factory()->comment->create( + array( + 'comment_post_ID' => self::$post_id, + 'comment_type' => $type, + ) + ); + + $GLOBALS['comment'] = get_comment( $comment_id ); + + ob_start(); + comment_type( ...$args ); + $output = ob_get_clean(); + + unset( $GLOBALS['comment'] ); + + return $output; + } + + /** + * @ticket 35214 + */ + public function test_built_in_types_output_is_unchanged() { + $this->assertSame( 'Comment', $this->get_comment_type_output( 'comment' ) ); + $this->assertSame( 'Trackback', $this->get_comment_type_output( 'trackback' ) ); + $this->assertSame( 'Pingback', $this->get_comment_type_output( 'pingback' ) ); + } + + /** + * @ticket 35214 + */ + public function test_custom_text_overrides_are_respected() { + $this->assertSame( 'C', $this->get_comment_type_output( 'comment', 'C', 'T', 'P' ) ); + $this->assertSame( 'T', $this->get_comment_type_output( 'trackback', 'C', 'T', 'P' ) ); + $this->assertSame( 'P', $this->get_comment_type_output( 'pingback', 'C', 'T', 'P' ) ); + } + + /** + * @ticket 35214 + */ + public function test_registered_custom_type_outputs_its_label() { + register_comment_type( + 'foo', + array( + 'labels' => array( + 'singular_name' => 'Foo', + ), + ) + ); + + $this->assertSame( 'Foo', $this->get_comment_type_output( 'foo' ) ); + } + + /** + * @ticket 35214 + */ + public function test_unregistered_custom_type_falls_back_to_default_label() { + $this->assertSame( _x( 'Comment', 'noun' ), $this->get_comment_type_output( 'bar' ) ); + } + + /** + * @ticket 35214 + */ + public function test_custom_text_override_wins_over_registered_label() { + register_comment_type( + 'foo', + array( + 'labels' => array( + 'singular_name' => 'Foo', + ), + ) + ); + + $this->assertSame( 'Custom', $this->get_comment_type_output( 'foo', 'Custom' ) ); + } +} diff --git a/tests/phpunit/tests/comment/types.php b/tests/phpunit/tests/comment/types.php new file mode 100644 index 0000000000000..dd10a6e200d0c --- /dev/null +++ b/tests/phpunit/tests/comment/types.php @@ -0,0 +1,281 @@ +_builtin ) { + unset( $wp_comment_types[ $comment_type ] ); + } + } + + parent::tear_down(); + } + + /** + * @ticket 35214 + */ + public function test_register_comment_type() { + $this->assertNull( get_comment_type_object( 'foo' ) ); + + register_comment_type( 'foo' ); + + $cobj = get_comment_type_object( 'foo' ); + $this->assertInstanceOf( 'WP_Comment_Type', $cobj ); + $this->assertSame( 'foo', $cobj->name ); + + // Test some defaults. + $this->assertTrue( $cobj->public ); + $this->assertFalse( $cobj->internal ); + $this->assertFalse( $cobj->_builtin ); + } + + /** + * @ticket 35214 + */ + public function test_register_comment_type_return_value() { + $this->assertInstanceOf( 'WP_Comment_Type', register_comment_type( 'foo' ) ); + } + + /** + * @ticket 35214 + * + * @expectedIncorrectUsage register_comment_type + */ + public function test_register_comment_type_with_too_long_name() { + $this->assertInstanceOf( 'WP_Error', register_comment_type( 'comment_type_with_a_too_long_name' ) ); + } + + /** + * @ticket 35214 + * + * @expectedIncorrectUsage register_comment_type + */ + public function test_register_comment_type_with_empty_name() { + $this->assertInstanceOf( 'WP_Error', register_comment_type( '' ) ); + } + + /** + * @ticket 35214 + */ + public function test_register_comment_type_show_ui_should_default_to_value_of_public() { + register_comment_type( 'public_type', array( 'public' => true ) ); + $this->assertTrue( get_comment_type_object( 'public_type' )->show_ui ); + + register_comment_type( 'private_type', array( 'public' => false ) ); + $this->assertFalse( get_comment_type_object( 'private_type' )->show_ui ); + } + + /** + * @ticket 35214 + */ + public function test_built_in_comment_types_are_registered() { + $this->assertTrue( comment_type_exists( 'comment' ) ); + $this->assertTrue( comment_type_exists( 'pingback' ) ); + $this->assertTrue( comment_type_exists( 'trackback' ) ); + $this->assertTrue( comment_type_exists( 'note' ) ); + } + + /** + * @ticket 35214 + */ + public function test_built_in_note_type_is_internal_and_non_public() { + $note = get_comment_type_object( 'note' ); + + $this->assertTrue( $note->internal ); + $this->assertFalse( $note->public ); + } + + /** + * @ticket 35214 + */ + public function test_comment_type_exists() { + $this->assertFalse( comment_type_exists( 'foo' ) ); + + register_comment_type( 'foo' ); + + $this->assertTrue( comment_type_exists( 'foo' ) ); + } + + /** + * @ticket 35214 + */ + public function test_get_comment_types_names() { + register_comment_type( 'foo' ); + + $types = get_comment_types(); + + $this->assertContains( 'comment', $types ); + $this->assertContains( 'foo', $types ); + } + + /** + * @ticket 35214 + */ + public function test_get_comment_types_objects() { + register_comment_type( 'foo' ); + + $types = get_comment_types( array(), 'objects' ); + + $this->assertInstanceOf( 'WP_Comment_Type', $types['foo'] ); + } + + /** + * @ticket 35214 + */ + public function test_get_comment_types_filtered_by_property() { + register_comment_type( 'foo', array( 'public' => false ) ); + + $public = get_comment_types( array( 'public' => true ) ); + + $this->assertContains( 'comment', $public ); + $this->assertNotContains( 'foo', $public ); + $this->assertNotContains( 'note', $public ); + } + + /** + * @ticket 35214 + * + * @covers ::unregister_comment_type + */ + public function test_unregister_comment_type() { + register_comment_type( 'foo' ); + + $this->assertTrue( unregister_comment_type( 'foo' ) ); + $this->assertNull( get_comment_type_object( 'foo' ) ); + } + + /** + * @ticket 35214 + * + * @covers ::unregister_comment_type + */ + public function test_unregister_comment_type_unknown_returns_error() { + $this->assertWPError( unregister_comment_type( 'does_not_exist' ) ); + } + + /** + * @ticket 35214 + * + * @covers ::unregister_comment_type + */ + public function test_unregister_comment_type_twice_returns_error() { + register_comment_type( 'foo' ); + + $this->assertTrue( unregister_comment_type( 'foo' ) ); + $this->assertWPError( unregister_comment_type( 'foo' ) ); + } + + /** + * @ticket 35214 + * + * @covers ::unregister_comment_type + * + * @dataProvider data_built_in_comment_types + */ + public function test_unregister_built_in_comment_type_is_not_allowed( $comment_type ) { + $this->assertWPError( unregister_comment_type( $comment_type ) ); + $this->assertTrue( comment_type_exists( $comment_type ) ); + } + + /** + * Data provider. + * + * @return array[] + */ + public function data_built_in_comment_types() { + return array( + array( 'comment' ), + array( 'pingback' ), + array( 'trackback' ), + array( 'note' ), + ); + } + + /** + * @ticket 35214 + */ + public function test_registered_comment_type_actions_fire() { + $action = new MockAction(); + $action_for_foo = new MockAction(); + + add_action( 'registered_comment_type', array( $action, 'action' ) ); + add_action( 'registered_comment_type_foo', array( $action_for_foo, 'action' ) ); + + register_comment_type( 'foo' ); + + $this->assertSame( 1, $action->get_call_count() ); + $this->assertSame( 1, $action_for_foo->get_call_count() ); + } + + /** + * @ticket 35214 + */ + public function test_unregistered_comment_type_action_fires() { + register_comment_type( 'foo' ); + + $action = new MockAction(); + add_action( 'unregistered_comment_type', array( $action, 'action' ) ); + + unregister_comment_type( 'foo' ); + + $this->assertSame( 1, $action->get_call_count() ); + } + + /** + * @ticket 35214 + */ + public function test_labels_are_built_from_args() { + register_comment_type( + 'foo', + array( + 'label' => 'Foos', + 'labels' => array( + 'singular_name' => 'Foo', + ), + ) + ); + + $cobj = get_comment_type_object( 'foo' ); + + $this->assertSame( 'Foos', $cobj->label ); + $this->assertSame( 'Foos', $cobj->labels->name ); + $this->assertSame( 'Foo', $cobj->labels->singular_name ); + } + + /** + * @ticket 35214 + */ + public function test_comment_type_labels_filter() { + add_filter( + 'comment_type_labels_foo', + static function ( $labels ) { + $labels->singular_name = 'Filtered Foo'; + return $labels; + } + ); + + register_comment_type( 'foo' ); + + $this->assertSame( 'Filtered Foo', get_comment_type_object( 'foo' )->labels->singular_name ); + } +} diff --git a/tests/phpunit/tests/comment/wpCommentType.php b/tests/phpunit/tests/comment/wpCommentType.php new file mode 100644 index 0000000000000..bf6d3c7df28dd --- /dev/null +++ b/tests/phpunit/tests/comment/wpCommentType.php @@ -0,0 +1,120 @@ +assertSame( 'foo', $comment_type->name ); + $this->assertTrue( $comment_type->public ); + $this->assertFalse( $comment_type->internal ); + $this->assertFalse( $comment_type->_builtin ); + $this->assertTrue( $comment_type->show_ui ); + $this->assertFalse( $comment_type->hierarchical ); + } + + /** + * @ticket 35214 + * + * @covers ::set_props + */ + public function test_set_props_overrides_defaults() { + $comment_type = new WP_Comment_Type( + 'foo', + array( + 'public' => false, + 'internal' => true, + 'description' => 'A test comment type.', + ) + ); + + $this->assertFalse( $comment_type->public ); + $this->assertTrue( $comment_type->internal ); + $this->assertSame( 'A test comment type.', $comment_type->description ); + // show_ui follows public when not explicitly set. + $this->assertFalse( $comment_type->show_ui ); + } + + /** + * @ticket 35214 + * + * @covers ::set_props + */ + public function test_register_comment_type_args_filter() { + $filter = static function ( $args ) { + $args['public'] = false; + return $args; + }; + + add_filter( 'register_comment_type_args', $filter ); + $comment_type = new WP_Comment_Type( 'foo' ); + remove_filter( 'register_comment_type_args', $filter ); + + $this->assertFalse( $comment_type->public ); + } + + /** + * @ticket 35214 + * + * @covers ::set_props + */ + public function test_register_specific_comment_type_args_filter() { + $filter = static function ( $args ) { + $args['description'] = 'Filtered description.'; + return $args; + }; + + add_filter( 'register_foo_comment_type_args', $filter ); + $comment_type = new WP_Comment_Type( 'foo' ); + $other_type = new WP_Comment_Type( 'bar' ); + remove_filter( 'register_foo_comment_type_args', $filter ); + + $this->assertSame( 'Filtered description.', $comment_type->description ); + $this->assertSame( '', $other_type->description ); + } + + /** + * @ticket 35214 + * + * @covers ::get_default_labels + * @covers ::reset_default_labels + */ + public function test_get_default_labels_returns_expected_defaults() { + WP_Comment_Type::reset_default_labels(); + + $labels = WP_Comment_Type::get_default_labels(); + + $this->assertSame( 'Comments', $labels['name'][0] ); + $this->assertSame( 'Comment', $labels['singular_name'][0] ); + } + + /** + * @ticket 35214 + * + * @covers ::get_default_labels + * @covers ::reset_default_labels + */ + public function test_reset_default_labels_clears_cache() { + // Prime the cache, then mutate the returned (by-value) array. + WP_Comment_Type::get_default_labels(); + + WP_Comment_Type::reset_default_labels(); + + // A fresh call rebuilds the defaults from translation functions. + $labels = WP_Comment_Type::get_default_labels(); + $this->assertSame( 'Comments', $labels['name'][0] ); + } +} From 506e9c65351739e8a389c7797cab889a9c60c97a Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 24 Jun 2026 15:02:40 -0700 Subject: [PATCH 3/4] Comments: Fix default labels for comment types registered without a label. Omit 'label' from the WP_Comment_Type::set_props() defaults so the property stays null when not provided. A false default was treated as a supplied value by _get_custom_object_labels(), which overwrote the default name with false, leaving labels->name and label as false for any type registered without an explicit label. This mirrors WP_Post_Type and WP_Taxonomy, which omit 'label' from their defaults for the same reason. Add a regression test covering default labels for a label-less comment type. See #35214. --- src/wp-includes/class-wp-comment-type.php | 7 ++++++- tests/phpunit/tests/comment/types.php | 13 +++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/wp-includes/class-wp-comment-type.php b/src/wp-includes/class-wp-comment-type.php index 374108568e9a3..6f88c5e7b499d 100644 --- a/src/wp-includes/class-wp-comment-type.php +++ b/src/wp-includes/class-wp-comment-type.php @@ -180,8 +180,13 @@ public function set_props( $args ) { */ $args = apply_filters( "register_{$comment_type}_comment_type_args", $args, $this->name ); + /* + * Note: 'label' is intentionally omitted from the defaults. Leaving the property + * unset (null) lets get_comment_type_labels() fall back to the default labels, the + * same way WP_Post_Type and WP_Taxonomy behave. A 'label' default of false would be + * treated as a provided value and overwrite the default name with false. + */ $defaults = array( - 'label' => false, 'labels' => array(), 'description' => '', 'public' => true, diff --git a/tests/phpunit/tests/comment/types.php b/tests/phpunit/tests/comment/types.php index dd10a6e200d0c..54f8e2aa9ad33 100644 --- a/tests/phpunit/tests/comment/types.php +++ b/tests/phpunit/tests/comment/types.php @@ -49,6 +49,19 @@ public function test_register_comment_type() { $this->assertFalse( $cobj->_builtin ); } + /** + * @ticket 35214 + */ + public function test_register_comment_type_without_labels_uses_default_labels() { + register_comment_type( 'foo' ); + + $cobj = get_comment_type_object( 'foo' ); + + $this->assertSame( 'Comments', $cobj->label ); + $this->assertSame( 'Comments', $cobj->labels->name ); + $this->assertSame( 'Comment', $cobj->labels->singular_name ); + } + /** * @ticket 35214 */ From 81eceff2165adda1c67ef90855fa984fcfe2b87f Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 24 Jun 2026 22:18:28 -0700 Subject: [PATCH 4/4] Comments: Escape the comment type label in comment_type(). The default branch of comment_type() echoed a registered type's singular_name label without escaping. Labels are developer-supplied (via register_comment_type() or the comment_type_labels_{$type} filter) and reach the public comment list unescaped, so wrap the output in esc_html() to match the post type and taxonomy label contract. Document that labels are stored unescaped and add a regression test asserting an HTML payload in the label is escaped. --- src/wp-includes/comment-template.php | 2 +- src/wp-includes/comment.php | 3 +++ tests/phpunit/tests/comment/commentType.php | 21 +++++++++++++++++++++ 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/wp-includes/comment-template.php b/src/wp-includes/comment-template.php index 38991f60929ad..5dc0c76ac3b21 100644 --- a/src/wp-includes/comment-template.php +++ b/src/wp-includes/comment-template.php @@ -1214,7 +1214,7 @@ function comment_type( $comment_text = false, $trackback_text = false, $pingback $comment_type_object = $comment_text_overridden ? null : get_comment_type_object( $type ); if ( $comment_type_object && ! $comment_type_object->_builtin && isset( $comment_type_object->labels->singular_name ) ) { - echo $comment_type_object->labels->singular_name; + echo esc_html( $comment_type_object->labels->singular_name ); } else { echo $comment_text; } diff --git a/src/wp-includes/comment.php b/src/wp-includes/comment.php index 2ebc55a1804b0..e8b4f28fe8ed9 100644 --- a/src/wp-includes/comment.php +++ b/src/wp-includes/comment.php @@ -556,6 +556,9 @@ function get_comment_type_labels( $comment_type_object ) { * * @see get_comment_type_labels() for the full list of comment type labels. * + * Labels are stored unescaped, mirroring the post type and taxonomy label + * contract; callers must escape them on output (for example with esc_html()). + * * @param object $labels Object with labels for the comment type as member variables. */ $labels = apply_filters( "comment_type_labels_{$comment_type}", $labels ); diff --git a/tests/phpunit/tests/comment/commentType.php b/tests/phpunit/tests/comment/commentType.php index da7d316afbe0a..0f0010001204b 100644 --- a/tests/phpunit/tests/comment/commentType.php +++ b/tests/phpunit/tests/comment/commentType.php @@ -114,4 +114,25 @@ public function test_custom_text_override_wins_over_registered_label() { $this->assertSame( 'Custom', $this->get_comment_type_output( 'foo', 'Custom' ) ); } + + /** + * The registered label is escaped on output to guard against HTML/script injection. + * + * @ticket 35214 + */ + public function test_registered_label_is_escaped_on_output() { + register_comment_type( + 'foo', + array( + 'labels' => array( + 'singular_name' => 'Foo', + ), + ) + ); + + $this->assertSame( + esc_html( 'Foo' ), + $this->get_comment_type_output( 'foo' ) + ); + } }