diff --git a/lib/experimental/html/class-wp-html-tag-processor.php b/lib/experimental/html/class-wp-html-tag-processor.php
index 0dfa57f30f2aab..9539f0d626e9d6 100644
--- a/lib/experimental/html/class-wp-html-tag-processor.php
+++ b/lib/experimental/html/class-wp-html-tag-processor.php
@@ -1412,6 +1412,49 @@ public function get_attribute( $name ) {
return html_entity_decode( $raw_value );
}
+ /**
+ * Returns the lowercase names of all attributes matching a given prefix in the currently-opened tag.
+ *
+ * Note that matching is case-insensitive. This is in accordance with the spec:
+ *
+ * > There must never be two or more attributes on
+ * > the same start tag whose names are an ASCII
+ * > case-insensitive match for each other.
+ * - HTML 5 spec
+ *
+ * @see https://html.spec.whatwg.org/multipage/syntax.html#attributes-2:ascii-case-insensitive
+ *
+ * Example:
+ *
+ * $p = new WP_HTML_Tag_Processor( 'Test
' );
+ * $p->next_tag( [ 'class_name' => 'test' ] ) === true;
+ * $p->get_attribute_names_with_prefix( 'data-' ) === array( 'data-enabled', 'data-test-id' );
+ *
+ * $p->next_tag( [] ) === false;
+ * $p->get_attribute_names_with_prefix( 'data-' ) === null;
+ *
+ *
+ * @since 6.2.0
+ *
+ * @param string $prefix Prefix of requested attribute names.
+ * @return array|null List of attribute names, or `null` if not at a tag.
+ */
+ function get_attribute_names_with_prefix( $prefix ) {
+ if ( $this->is_closing_tag || null === $this->tag_name_starts_at ) {
+ return null;
+ }
+
+ $comparable = strtolower( $prefix );
+
+ $matches = array();
+ foreach ( array_keys( $this->attributes ) as $attr_name ) {
+ if ( str_starts_with( $attr_name, $comparable ) ) {
+ $matches[] = $attr_name;
+ }
+ }
+ return $matches;
+ }
+
/**
* Returns the lowercase name of the currently-opened tag.
*
diff --git a/phpunit/html/wp-html-tag-processor-test.php b/phpunit/html/wp-html-tag-processor-test.php
index e6b3d4d8071af5..6750410f26d43c 100644
--- a/phpunit/html/wp-html-tag-processor-test.php
+++ b/phpunit/html/wp-html-tag-processor-test.php
@@ -74,6 +74,19 @@ public function test_get_attribute_returns_null_when_not_in_open_tag() {
$this->assertNull( $p->get_attribute( 'class' ), 'Accessing an attribute of a non-existing tag did not return null' );
}
+ /**
+ * @ticket 56299
+ *
+ * @covers next_tag
+ * @covers get_attribute
+ */
+ public function test_get_attribute_returns_null_when_in_closing_tag() {
+ $p = new WP_HTML_Tag_Processor( '
Test
' );
+ $this->assertTrue( $p->next_tag( 'div' ), 'Querying an existing tag did not return true' );
+ $this->assertTrue( $p->next_tag( array( 'tag_closers' => 'visit' ) ), 'Querying an existing closing tag did not return true' );
+ $this->assertNull( $p->get_attribute( 'class' ), 'Accessing an attribute of a closing tag did not return null' );
+ }
+
/**
* @ticket 56299
*
@@ -195,6 +208,87 @@ public function test_set_attribute_is_case_insensitive() {
$this->assertEquals( 'Test
', $p->get_updated_html(), 'A case-insensitive set_attribute call did not update the existing attribute.' );
}
+ /**
+ * @ticket 56299
+ *
+ * @covers get_attribute_names_with_prefix
+ */
+ public function test_get_attribute_names_with_prefix_returns_null_before_finding_tags() {
+ $p = new WP_HTML_Tag_Processor( 'Test
' );
+ $this->assertNull( $p->get_attribute_names_with_prefix( 'data-' ) );
+ }
+
+ /**
+ * @ticket 56299
+ *
+ * @covers get_attribute_names_with_prefix
+ */
+ public function test_get_attribute_names_with_prefix_returns_null_when_not_in_open_tag() {
+ $p = new WP_HTML_Tag_Processor( 'Test
' );
+ $p->next_tag( 'p' );
+ $this->assertNull( $p->get_attribute_names_with_prefix( 'data-' ), 'Accessing attributes of a non-existing tag did not return null' );
+ }
+
+ /**
+ * @ticket 56299
+ *
+ * @covers get_attribute_names_with_prefix
+ */
+ public function test_get_attribute_names_with_prefix_returns_null_when_in_closing_tag() {
+ $p = new WP_HTML_Tag_Processor( 'Test
' );
+ $p->next_tag( 'div' );
+ $p->next_tag( array( 'tag_closers' => 'visit' ) );
+ $this->assertNull( $p->get_attribute_names_with_prefix( 'data-' ), 'Accessing attributes of a closing tag did not return null' );
+ }
+
+ /**
+ * @ticket 56299
+ *
+ * @covers get_attribute_names_with_prefix
+ */
+ public function test_get_attribute_names_with_prefix_returns_empty_array_when_no_attributes_present() {
+ $p = new WP_HTML_Tag_Processor( 'Test
' );
+ $p->next_tag( 'div' );
+ $this->assertSame( array(), $p->get_attribute_names_with_prefix( 'data-' ), 'Accessing the attributes on a tag without any did not return an empty array' );
+ }
+
+ /**
+ * @ticket 56299
+ *
+ * @covers get_attribute_names_with_prefix
+ */
+ public function test_get_attribute_names_with_prefix_returns_matching_attribute_names_in_lowercase() {
+ $p = new WP_HTML_Tag_Processor( 'Test
' );
+ $p->next_tag();
+ $this->assertSame(
+ array( 'data-enabled', 'data-test-id' ),
+ $p->get_attribute_names_with_prefix( 'data-' )
+ );
+ }
+
+ /**
+ * @ticket 56299
+ *
+ * @covers set_attribute
+ * @covers get_updated_html
+ * @covers get_attribute_names_with_prefix
+ */
+ public function test_get_attribute_names_with_prefix_returns_attribute_added_by_set_attribute() {
+ $p = new WP_HTML_Tag_Processor( 'Test
' );
+ $p->next_tag();
+ $p->set_attribute( 'data-test-id', '14' );
+ $this->assertSame(
+ 'Test
',
+ $p->get_updated_html(),
+ "Updated HTML doesn't include attribute added via set_attribute"
+ );
+ $this->assertSame(
+ array( 'data-test-id', 'data-foo' ),
+ $p->get_attribute_names_with_prefix( 'data-' ),
+ "Accessing attribute names doesn't find attribute added via set_attribute"
+ );
+ }
+
/**
* @ticket 56299
*