diff --git a/src/wp-includes/html-api/class-wp-html-processor.php b/src/wp-includes/html-api/class-wp-html-processor.php index 6513db35c1243..712df2fb9bf31 100644 --- a/src/wp-includes/html-api/class-wp-html-processor.php +++ b/src/wp-includes/html-api/class-wp-html-processor.php @@ -660,6 +660,57 @@ public function get_unsupported_exception() { return $this->unsupported_exception; } + /** + * Progress through a document pausing on tags matching the provided CSS selector string. + * + * Example: + * + * $processor = WP_HTML_Processor::create_fragment( + * '
', '*', 5 ), + 'escaped * type selector' => array( '
In quirks mode, ID matching is case-insensitive.', '#id', 2 ), + 'quirks mode class' => array( '
In quirks mode, class matching is case-insensitive.', '.c', 2 ), + 'no-quirks mode ID' => array( '
In no-quirks mode, ID matching is case-sensitive.', '#id', 1 ), + 'no-quirks mode class' => array( '
In no-quirks mode, class matching is case-sensitive.', '.c', 1 ),
+ 'any descendant' => array( ' ', 'section *', 4 ),
+ 'any child matches all children' => array( ' ', 'section > *', 2 ),
+
+ 'multiple complex selectors' => array( ' ', 'section > div p > i', 1 ),
+
+ // Per Selectors-4, the substring matchers ^= $= *= match nothing when the value
+ // is empty. ~= also matches nothing: an empty string is never a list item.
+ 'empty value ^= matches nothing' => array( '', '[x^=""]', 0 ),
+ 'empty value $= matches nothing' => array( '', '[x$=""]', 0 ),
+ 'empty value *= matches nothing' => array( '', '[x*=""]', 0 ),
+ 'empty value ~= matches nothing' => array( '', '[x~=""]', 0 ),
+ 'empty value ^= i matches nothing' => array( '', '[x^="" i]', 0 ),
+ 'empty value = matches empty' => array( '', '[x=""]', 1 ),
+ 'empty value |= matches empty or hyphen-prefixed' => array( ' ', '[att=val]', 2 ),
+ 'attribute quoted value' => array( ' ', '[att="::"]', 2 ),
+ 'attribute case insensitive' => array( ' ', '[att="VAL"i]', 2 ),
+ 'attribute case sensitive mod' => array( ' ', '[att="val"s]', 2 ),
+
+ 'attribute one of' => array( ' ', '[att~="b"]', 3 ),
+ 'attribute one of insensitive' => array( ' ', '[att|="special"]', 2 ),
+ 'attribute with-hyphen insensitive' => array( ' ', '[att|="special" i]', 2 ),
+ 'attribute with-hyphen sensitive mod' => array( ' ', '[att|="special"s]', 1 ),
+
+ 'attribute prefixed' => array( ' ', '[att^="p"]', 2 ),
+ 'attribute prefixed insensitive' => array( ' ', '[att^="p"i]', 1 ),
+ 'attribute prefixed sensitive mod' => array( ' ', '[att^="p"s]', 1 ),
+
+ 'attribute suffixed' => array( ' ', '[att$="x"]', 2 ),
+ 'attribute suffixed insensitive' => array( ' ', '[att$="x"i]', 1 ),
+ 'attribute suffixed sensitive mod' => array( ' ', '[att$="x"s]', 1 ),
+
+ 'attribute contains' => array( ' ', '[att*="x"]', 2 ),
+ 'attribute contains insensitive' => array( ' ', '[att*="x"i]', 1 ),
+ 'attribute contains sensitive mod' => array( ' ', '[att*="x"s]', 1 ),
+
+ /*
+ * An escaped trailing whitespace code point is part of the ident,
+ * not trailing whitespace: `.foo\ ` is the class `foo ` (with a
+ * space). Class attribute values are whitespace-separated token
+ * lists, so such a class can never match. It must NOT be confused
+ * with a backslash at the end of input, which decodes to U+FFFD.
+ */
+ 'escaped space at end' => array( "', '[x|=""]', 2 ),
+
+ /*
+ * HTML's case-insensitive attribute value list applies to
+ * "an HTML element in an HTML document": a foreign element with
+ * the same attribute name keeps case-sensitive matching.
+ * ( Chromium applies the list to foreign elements as well,
+ * diverging from the HTML specification here. )
+ *
+ * https://html.spec.whatwg.org/multipage/semantics-other.html#case-sensitivity-of-selectors
+ */
+ 'HTML-namespace-only attribute case-insensitivity' => array( '', '[type=TEXT]', 1 ),
+ );
+ }
+
+ /**
+ * @ticket 62653
+ *
+ * @expectedIncorrectUsage WP_HTML_Processor::select
+ *
+ * @dataProvider data_invalid_selectors
+ */
+ public function test_invalid_selector( string $selector ) {
+ $processor = WP_HTML_Processor::create_fragment( 'irrelevant' );
+ $this->assertFalse( $processor->select( $selector ) );
+ }
+
+ /**
+ * Data provider.
+ *
+ * @return array
+ */
+ public static function data_invalid_selectors(): array {
+ return array(
+ 'invalid selector' => array( '[invalid!selector]' ),
+
+ // The class selectors below are not allowed in non-final position.
+ 'unsupported child selector' => array( '.parent > .child' ),
+ 'unsupported descendant selector' => array( '.ancestor .descendant' ),
+
+ // Unsupported combinators
+ 'unsupported next sibling selector' => array( 'p + p' ),
+ 'unsupported subsequent sibling selector' => array( 'p ~ p' ),
+ );
+ }
+}
diff --git a/tests/phpunit/tests/html-api/wpHtmlTagProcessor-select.php b/tests/phpunit/tests/html-api/wpHtmlTagProcessor-select.php
new file mode 100644
index 0000000000000..dafa64aba7ec5
--- /dev/null
+++ b/tests/phpunit/tests/html-api/wpHtmlTagProcessor-select.php
@@ -0,0 +1,221 @@
+' );
+ $this->assertFalse( $processor->select( 'div' ) );
+ }
+
+ /**
+ * @ticket 62653
+ *
+ * @dataProvider data_selectors
+ */
+ public function test_select( string $html, string $selector, int $match_count ) {
+ $processor = new WP_HTML_Tag_Processor( $html );
+ $count = 0;
+ while ( $processor->select( $selector ) ) {
+ $this->assertTrue(
+ $processor->get_attribute( 'match' ),
+ "Matched unexpected tag {$processor->get_tag()}"
+ );
+ ++$count;
+ }
+ $this->assertSame( $match_count, $count, 'Did not match expected number of tags.' );
+ }
+
+ /**
+ * Data provider.
+ *
+ * @return array
+ */
+ public static function data_selectors(): array {
+ return array(
+ 'simple type' => array( '