diff --git a/lib/experimental/html/class-wp-html-attribute-sourcer.php b/lib/experimental/html/class-wp-html-attribute-sourcer.php new file mode 100644 index 00000000000000..c66ffbf4550c88 --- /dev/null +++ b/lib/experimental/html/class-wp-html-attribute-sourcer.php @@ -0,0 +1,477 @@ + a + * figure a + * figure img + * figure video,figure img + * h1,h2,h3,h4,h5,h6 + * img + * li + * ol,ul + * p + * pre + * tbody tr + * td,th + * tfoot tr + * thead tr + * video + */ + +require_once __DIR__ . '/class-wp-html-processor.php'; + +/* + * @see PHP docs for array_is_list: user-contributed polyfill + */ +if ( ! function_exists( 'array_is_list' ) ) { + function array_is_list( $array ) { + $i = 0; + + foreach ( $array as $k => $v ) { + if ( $k !== $i++ ) { + return false; + } + } + + return true; + } +} + +class WP_HTML_Attribute_Sourcer { + /** + * Attributes definitions, typically from `block.json`. + * + * @see WP_Block_Type_Registry + * + * @var mixed|null + */ + public $attribute_definitions; + + /** + * Source HTML containing embedded attributes. + * + * @var mixed|null + */ + private $html; + + public function __construct( $attribute_definitions = null, $html = null ) { + $this->attribute_definitions = $attribute_definitions; + $this->html = $html; + } + + public function source_attributes() { + $attributes = []; + $unparsed = []; + + foreach ( $this->attribute_definitions as $name => $definition ) { + $sourcer = self::parse_definition( $definition ); + switch ( $sourcer ) { + case null: + case 'not-sourced': + case 'unsupported': + $unparsed[] = $name; + continue 2; + + case 'inner-html': + $attributes[ $name ] = $this->html; + continue 2; + } + + $tags = self::select( $sourcer['selector'], $this->html ); + if ( ! $tags ) { + $attributes[ $name ] = null; + continue; + } + + switch ( $sourcer['type'] ) { + case 'html': + $attributes[ $name ] = $tags->get_content_inside_balanced_tags(); + continue 2; + + case 'attribute': + $attributes[ $name ] = $tags->get_attribute( $sourcer['attribute'] ); + continue 2; + } + } + + return array( + 'attributes' => $attributes, + 'unparsed' => $unparsed + ); + } + + public static function select_match( $tags, $s ) { + if ( ! empty( $s['tag_name'] ) && strtoupper( $s['tag_name'] ) !== $tags->get_tag() ) { + return null; + } + + if ( ! empty( $s['class_names'] ) ) { + $classes = $tags->get_attribute( 'class' ); + if ( null === $classes ) { + return null; + } + + foreach ( $s['class_names'] as $class_name ) { + if ( ! preg_match( "~\b{$class_name}\b~", $classes ) ) { + return null; + } + } + } + + if ( isset( $s['hash'] ) && $s['identifier'] !== $tags->get_attribute( 'id' ) ) { + return null; + } + + if ( isset( $s['has_attribute'] ) && null === $tags->get_attribute( $s['has_attribute'] ) ) { + return null; + } + + return $tags; + } + + /** + * @TODO: This needs to be able to continue to the next match + * Pass in $tags? Pass in a bookmark? + */ + public static function select( $selectors, $html ) { + $selector_index = 0; + + selector_choice: + $tags = new WP_HTML_Processor( $html ); + $outer_state = $tags->new_state(); + + $next = $selectors[ $selector_index ]; + + loop: + while ( $tags->balanced_next( $outer_state ) ) { + if ( ! self::select_match( $tags, $next ) ) { + continue; + } + + if ( ! isset( $next['then'] ) ) { + return $tags; + } + + inner_loop: + $prev = $next; + $next = $next['then']; + + $inner_state = $tags->new_state(); + switch ( $next['combinator'] ) { + /* + * Adjacent sibling must be the immediately-following + * element which shares the same parent. + */ + case '+': + /* + * If we have opened a tag we need to continue scanning past all of its children. + * `balanced_next()` will end up on the closing tag, so if we don't have any + * children, or no closing tag, we need to skip this because `balanced_tag()` + * would end up in those cases on the sibling element. + */ + if ( ! WP_HTML_Processor::is_html_void_element( $tags->get_tag() ) ) { + while ( $tags->balanced_next( $inner_state ) ) { + continue; + } + } + + if ( $tags->balanced_next( $outer_state ) && self::select_match( $tags, $next ) ) { + if ( ! isset( $next['then'] ) ) { + return $tags; + } + goto inner_loop; + } + + $next = $prev; + break; + + // Child combinator + case '>': + $inner_state->match_depth = 1; + // Intentional fallthrough + // Descendant combinator + case ' ': + /* + * This match has to be a child of the matched tag, + * and the matched tag has to be its parent for the + * case of the child combinator. + */ + while ( $tags->balanced_next( $inner_state ) ) { + if ( self::select_match( $tags, $next ) ) { + if ( ! isset( $next['then'] ) ) { + return $tags; + } + + goto inner_loop; + } + } + + $next = $prev; + goto loop; + } + } + + if ( ++$selector_index < count( $selectors ) ) { + goto selector_choice; + } + + return false; + } + + public static function parse_definition( $definition ) { + if ( empty( $definition['source'] ) ) { + return 'not-sourced'; + } + + $source = $definition['source']; + if ( 'html' !== $source && 'attribute' !== $source ) { + return 'unsupported'; + } + + if ( 'attribute' === $source && empty( $definition['selector'] ) ) { + return null; + } + + if ( 'html' === $source && empty( $definition['selector'] ) ) { + return 'inner-html'; + } + + $selectors = self::parse_full_selector( $definition['selector'] ); + if ( null === $selectors ) { + return 'unsupported'; + } + + if ( 'html' === $source ) { + return array( 'type' => 'html', 'selector' => $selectors ); + } + + $attribute = self::parse_attribute( $definition['attribute'] ); + if ( null === $attribute ) { + return null; + } + + return array( 'type' => 'attribute', 'selector' => $selectors, 'attribute' => $attribute ); + } + + public static function parse_full_selector( $s ) { + $selectors = []; + $at = 0; + + while ( $at < strlen( $s ) ) { + $at += strspn( $s, " \f\n\r\t", $at ); + + list( $selector, $next_at ) = self::parse_selector( $s, $at ); + if ( null === $selector ) { + return null; + } + + $selectors[] = $selector; + $at = $next_at; + + if ( $at < strlen( $s ) && ',' !== $s[ $at ] ) { + return null; + } + $at++; + } + + return $selectors; + } + + public static function parse_selector( $s, $at = 0, $selector = [] ) { + $is_first = true; + + while ( $at < strlen( $s ) && ',' !== $s[ $at ] ) { + /* + * Descendant combinators are harder to discover because we + * always have to skip whitespace, but that whitespace could + * be the combinator if we don't approach anything else first. + */ + $ws_length = strspn( $s, " \f\n\r\t", $at ); + $at += $ws_length; + + if ( !$is_first && $ws_length > 0 && 0 === strspn( $s[ $at ], '>+~' ) ) { + $at--; + $s[ $at ] = ' '; + } + $is_first = false; + + switch ( $s[ $at ] ) { + case '>': + case '+': + case '~': + case ' ': + $combinator = $s[ $at ]; + $at++; + $at += strspn( $s, " \f\n\r\t", $at ); + $inner = self::parse_selector( $s, $at ); + if ( null === $inner ) { + return null; + } + list( $inner_selector, $next_at ) = $inner; + $inner_selector['combinator'] = $combinator; + $selector['then'] = $inner_selector; + $at = $next_at; + break; + + case '.': + $at++; + $class_name = self::parse_css_identifier( $s, $at ); + if ( null === $class_name ) { + return null; + } + + if ( ! isset( $selector['class_names'] ) ) { + $selector['class_names'] = array(); + } + $selector['class_names'][] = $class_name; + $at += strlen( $class_name ); + break; + + case '#': + $at++; + // @TODO: Hashes don't have to start with `nmstart` so this might reject valid hash names. + $element_id = self::parse_css_identifier( $s, $at ); + if ( null === $element_id ) { + return null; + } + + $selector['hash'] = $element_id; + $at += strlen( $element_id ); + break; + + case '[': + /* + * Only current support is for checking of presence of attributes + * with a very-limited subset of allowable names, not whether the + * attribute conforms to a given value or is allowed in HTML. + */ + $at++; + $inside_length = strspn( $s, "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-", $at ); + if ( ']' !== $s[ $at + $inside_length ] ) { + return null; + } + + $attribute = substr( $s, $at, $inside_length ); + $at += $inside_length + 1; + + $selector['has_attribute'] = $attribute; + break; + + default: + $tag_name = self::parse_css_identifier( $s, $at ); + if ( null === $tag_name ) { + return null; + } + + $selector['tag_name'] = $tag_name; + $at += strlen( $tag_name ); + } + } + + return [ $selector, $at ]; + } + + /** + * Parses CSS identifier; currently limited to ASCII identifiers. + * + * Example: + * ``` + * 'div' === parse_css_identifier( 'div > img' ); + * ``` + * + * Grammar: + * ``` + * ident -?{nmstart}{nmchar}* + * nmstart [_a-z]|{nonascii}|{escape} + * nmchar [_a-z0-9-]|{nonascii}|{escape} + * nonascii [\240-\377] + * escape {unicode}|\\[^\r\n\f0-9a-f] + * unicode \\{h}{1,6}(\r\n|[ \t\r\n\f])? + * h [0-9a-f] + * ``` + * + * @TODO: Add support for the proper syntax + * + * @see https://www.w3.org/TR/CSS21/grammar.html + * + * @param $s + * @return false|string|null + */ + public static function parse_css_identifier( $s, $at = 0 ) { + $budget = 1000; + $started_at = $at; + + $starting_chars = strspn( $s, '_-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ', $at ); + if ( 0 === $starting_chars ) { + return null; + } + $at += $starting_chars; + + while ( $at < strlen( $s ) && $budget-- > 0 ) { + $chars = strspn( $s, '_-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', $at ); + + if ( 0 === $chars ) { + break; + } + + $at += $chars; + } + + if ( $budget < 0 ) { + return null; + } + + return substr( $s, $started_at, $at - $started_at ); + } + + public static function parse_attribute( $s ) { + $unallowed_characters_match = preg_match( + '~[' . + // Syntax-like characters. + '"\'>&` but not clear to how + * handle `

` given that `` is a formatting element but `

` is + * not, that `

` itself is a special element. + */ + + +class WP_HTML_Processor_Scan_State { + public $budget = 1000; + public $open_tags = array(); + public $match_depth = null; + + public function relative_depth() { + return count( $this->open_tags ); + } +} + + +class WP_HTML_Processor extends WP_HTML_Tag_Processor { + public function new_state() { + $state = new WP_HTML_Processor_Scan_State(); + $tag_name = $this->get_tag(); + + if ( ! self::is_html_void_element( $tag_name ) && ! $this->is_tag_closer() ) { + $state->open_tags[] = $tag_name; + } + + return $state; + } + + public function balanced_next( WP_HTML_Processor_Scan_State $state, $query = null ) { + while ( $this->next_tag( array( 'tag_closers' => 'visit' ) ) && $state->budget-- > 0 ) { + $tag_name = $this->get_tag(); + $is_closer = $this->is_tag_closer(); + $is_void = self::is_html_void_element( $tag_name ); + $type = self::classify_tag_type( $is_closer, $is_void ); + + /* + * Step 1. Update the stack of open tags. + * + * If and when we add more complete HTML parsing support we will also + * need to track the stack of active formats so that we can properly + * handle missing tags and overlapping tags. + */ + + switch ( $type ) { + case 'void': + /* + * Void tags (such as ) can't have children and so we + * won't push or pop them from the stack of open tags. + * + * If and when we support self-closing foreign tags we would + * need to separately track those, but their behavior matches + * this case. The self-closing flag is ignored for HTML5 tags. + */ + if ( 0 === $state->relative_depth() ) { + return false; + } + + break; + + case 'opener': + $state->open_tags[] = $tag_name; + break; + + case 'closer': + $last_tag = array_pop( $state->open_tags ); + + /* + * Currently we can only support fully-normative and balanced HTML5. + * If we encounter anything we don't expect then we will bail. In a + * future update we may perform more careful HTML parsing and unlock + * navigating through non-normative documents. + */ + if ( $last_tag !== $tag_name ) { + return false; + } + + /* + * Step 2. Bail if we've reached the end of the tag in which we started. + */ + if ( 0 === $state->relative_depth() ) { + return false; + } + + break; + } + + /* + * Void elements don't enter the stack, but they do exist in the + * depth hierarchy, so we have to temporarily account for that. + * + * We could have followed the approach in the HTML5 spec by appending + * the void tag to the stack of open tags, and then remember to pop it + * when existing this function, but by tracking it like this we don't + * have to remember to do that. + */ + $depth = $type === 'void' + ? $state->relative_depth() + 1 + : $state->relative_depth(); + + /* + * Step 3. Determine if we have a matching tag. In addition to the query + * we pass along to the underlying tag processor we're going to allow + * specifying the relative depth for a match. For example, a CSS child + * combinator would specify that a match must have a relative depth of 1, + * indicating that it's a direct child of the surrounding element, whereas + * the descendant selector could match at any depth and so sets this to `null`. + * To prevent matching _above_ a tag we rely on the `bail_depth` to stop + * searching once we've exited the tag on which we started, or reach its parent. + */ + + if ( ! isset( $state->match_depth ) || $state->match_depth + 1 === $depth ) { + $this->parse_query( $query ); + if ( $this->matches() ) { + return true; + } + } + } + + return false; + } + + public function get_content_inside_balanced_tags() { + static $start_name = null; + static $end_name = null; + + if ( null === $start_name || array_key_exists( $start_name, $this->bookmarks ) ) { + $rand_id = rand( 1, PHP_INT_MAX ); + $start_name = "start_{$rand_id}"; + } + + if ( null === $end_name || array_key_exists( $end_name, $this->bookmarks ) ) { + $rand_id = rand( 1, PHP_INT_MAX ); + $end_name = "start_{$rand_id}"; + } + + $this->set_bookmark( $start_name ); + + $state = self::new_state(); + while ( $this->balanced_next( $state ) ) { + continue; + } + + $this->set_bookmark( $end_name ); + $content = $this->content_inside_bookmarks( $start_name, $end_name ); + $this->seek( $start_name ); + + $this->release_bookmark( $start_name ); + $this->release_bookmark( $end_name ); + + return $content; + } + + private function content_inside_bookmarks( $start_bookmark, $end_bookmark ) { + if ( ! isset( $this->bookmarks[ $start_bookmark ], $this->bookmarks[ $end_bookmark ] ) ) { + return null; + } + + $start = $this->bookmarks[ $start_bookmark ]; + $end = $this->bookmarks[ $end_bookmark ]; + + return substr( $this->get_updated_html(), $start->end + 1, $end->start - $start->end - 2 ); + } + + /* + * HTML-related Utility Functions + */ + + public static function classify_tag_type( $is_closer, $is_void ) { + if ( $is_void ) { + return 'void'; + } + + return $is_closer ? 'closer' : 'opener'; + } + + /** + * @see https://html.spec.whatwg.org/#elements-2 + */ + public static function is_html_void_element( $tag_name ) { + switch ( $tag_name ) { + case 'AREA': + case 'BASE': + case 'BR': + case 'COL': + case 'EMBED': + case 'HR': + case 'IMG': + case 'INPUT': + case 'LINK': + case 'META': + case 'SOURCE': + case 'TRACK': + case 'WBR': + return true; + + default: + return false; + } + } +} diff --git a/lib/experimental/html/class-wp-html-tag-processor.php b/lib/experimental/html/class-wp-html-tag-processor.php index 0dfa57f30f2aab..72c342dbd02a73 100644 --- a/lib/experimental/html/class-wp-html-tag-processor.php +++ b/lib/experimental/html/class-wp-html-tag-processor.php @@ -20,6 +20,7 @@ * @TODO: Add slow mode to escape character entities in CSS class names? * (This requires a custom decoder since `html_entity_decode()` * doesn't handle attribute character reference decoding rules. + * @TODO: Do we make any indexing assumptions based on only scanning tag openers? $tag_name - 1 vs. ? * * @package WordPress * @subpackage HTML @@ -388,7 +389,7 @@ class WP_HTML_Tag_Processor { * @since 6.2.0 * @var WP_HTML_Span[] */ - private $bookmarks = array(); + protected $bookmarks = array(); const ADD_CLASS = true; const REMOVE_CLASS = false; @@ -1439,6 +1440,26 @@ public function get_tag() { return strtoupper( $tag_name ); } + /** + * Returns a representation of the currently-open tag, for debug purposes. + * + * @since 6.3.0 + * @return string + */ + public function debug_current_token() { + if ( null === $this->tag_name_starts_at ) { + return ''; + } + + if ( $this->is_tag_closer() ) { + $tag_name = substr( $this->html, $this->tag_name_starts_at, $this->tag_name_length ); + return ""; + } + + $tag_starts_at = $this->tag_name_starts_at - 1; + return substr( $this->html, $tag_starts_at, $this->tag_ends_at - $tag_starts_at + 1 ); + } + /** * Indicates if the current tag token is a tag closer. * @@ -1729,7 +1750,7 @@ public function get_updated_html() { * @type string $tag_closers "visit" or "skip": whether to stop on tag closers, e.g. . * } */ - private function parse_query( $query ) { + protected function parse_query( $query ) { if ( null !== $query && $query === $this->last_query ) { return; } @@ -1776,7 +1797,7 @@ private function parse_query( $query ) { * * @return boolean */ - private function matches() { + protected function matches() { if ( $this->is_closing_tag && ! $this->stop_on_tag_closers ) { return false; } diff --git a/lib/experimental/html/index.php b/lib/experimental/html/index.php index a31dbaf48c6b2a..299131052c32fe 100644 --- a/lib/experimental/html/index.php +++ b/lib/experimental/html/index.php @@ -10,3 +10,5 @@ require_once __DIR__ . '/class-wp-html-span.php'; require_once __DIR__ . '/class-wp-html-text-replacement.php'; require_once __DIR__ . '/class-wp-html-tag-processor.php'; +require_once __DIR__ . '/class-wp-html-processor.php'; +require_once __DIR__ . '/class-wp-html-attribute-sourcer.php'; diff --git a/phpunit/html/wp-html-attribute-sourcer-test.php b/phpunit/html/wp-html-attribute-sourcer-test.php new file mode 100644 index 00000000000000..38a274008a9de3 --- /dev/null +++ b/phpunit/html/wp-html-attribute-sourcer-test.php @@ -0,0 +1,517 @@ + +

+ +
+ +
+

The antics of ants with antlers

+

+ + Ants + with antlers can be funny. +

+ +
+ +HTML; + + list( $selectors ) = WP_HTML_Attribute_Sourcer::parse_selector( $selector ); + $this->assertIsArray( $selectors ); + + $found_ids = array(); + if ( $tags = WP_HTML_Attribute_Sourcer::select( [ $selectors ], $html ) ) { + $found_ids[] = $tags->get_attribute( 'id' ); + } + + $this->assertEqualsCanonicalizing( $wanted_ids, $found_ids ); + } + + public function data_ids_and_their_selectors() { + return array( + array( array( 'li-1' ), 'li' ), + array( array(), 'section > p img + em' ), + array( array( 'strong-ants' ), 'section > p img + strong' ), + array( array( 'funny-stuff' ), 'section > p strong + em' ), + array( array( 'page-title' ), 'section h2' ), + array( array( 'post-title' ), 'section > h2' ), + array( array( 'ant-logo' ), '[href]' ), + array( array(), '.non-existent' ), + ); + } + + /** + * @dataProvider data_single_combinators + */ + public function test_sources_single_combinators( $expected, $html, $attributes ) { + $this->assertSame( $expected, ( new WP_HTML_Attribute_Sourcer( $attributes, $html ) )->source_attributes() ); + } + + public function data_single_combinators() { + return array( + array( + array( 'attributes' => array( 'link' => 'docs.html' ), 'unparsed' => array() ), + '
', + array( + 'link' => array( + 'type' => 'string', + 'source' => 'attribute', + 'selector' => 'div a', + 'attribute' => 'href' + ), + ), + ), + + array( + array( 'attributes' => array( 'link' => 'docs.html' ), 'unparsed' => array() ), + '
', + array( + 'link' => array( + 'type' => 'string', + 'source' => 'attribute', + 'selector' => 'div > a', + 'attribute' => 'href' + ), + ), + ), + + array( + array( 'attributes' => array( 'link' => 'docs.html' ), 'unparsed' => array() ), + '
', + array( + 'link' => array( + 'type' => 'string', + 'source' => 'attribute', + 'selector' => 'div + a', + 'attribute' => 'href' + ), + ), + ), + + array( + array( 'attributes' => array( 'link' => null ), 'unparsed' => array() ), + '

', + array( + 'link' => array( + 'type' => 'string', + 'source' => 'attribute', + 'selector' => 'div + a', + 'attribute' => 'href' + ), + ), + ), + ); + } + + /** + * @dataProvider data_nested_combinators + */ + public function test_sources_nested_combinators( $expected, $html, $attributes ) { + $this->assertSame( $expected, ( new WP_HTML_Attribute_Sourcer( $attributes, $html ) )->source_attributes() ); + } + + public function data_nested_combinators() { + return array( + array( + array( 'attributes' => array( 'link' => 'docs.html' ), 'unparsed' => array() ), + '
', + array( + 'link' => array( + 'type' => 'string', + 'source' => 'attribute', + 'selector' => 'section div a', + 'attribute' => 'href' + ), + ), + ), + + array( + array( 'attributes' => array( 'link' => null ), 'unparsed' => array() ), + '
', + array( + 'link' => array( + 'type' => 'string', + 'source' => 'attribute', + 'selector' => 'section div a', + 'attribute' => 'href' + ), + ), + ), + + array( + array( 'attributes' => array( 'link' => 'docs.html' ), 'unparsed' => array() ), + '
', + array( + 'link' => array( + 'type' => 'string', + 'source' => 'attribute', + 'selector' => 'section > div > a', + 'attribute' => 'href' + ), + ), + ), + + array( + array( 'attributes' => array( 'link' => 'docs.html' ), 'unparsed' => array() ), + '
', + array( + 'link' => array( + 'type' => 'string', + 'source' => 'attribute', + 'selector' => 'section > div + a', + 'attribute' => 'href' + ), + ), + ), + ); + } + + /** + * @dataProvider data_skipping_non_matches + */ + public function test_sources_skipping_non_matches( $expected, $html, $attributes ) { + $this->assertSame( $expected, ( new WP_HTML_Attribute_Sourcer( $attributes, $html ) )->source_attributes() ); + } + + public function data_skipping_non_matches() { + return array( + + ); + } + + /** + * @dataProvider data_sourced_attributes + */ + public function test_sources_attributes( $expected, $html, $attributes ) { + $this->assertSame( $expected, ( new WP_HTML_Attribute_Sourcer( $attributes, $html ) )->source_attributes() ); + } + + public function data_sourced_attributes() { + return array( + array( + array( 'attributes' => array( 'link' => 'image' ), 'unparsed' => array() ), + << +
Just another section
+
blah
+

Stuff

+
blarg
+
Still another section
+
image
+
Still another section
+
docs
+ +EOF, + array( + 'link' => array( + 'type' => 'string', + 'source' => 'attribute', + 'selector' => 'main section + div > a[href]', + 'attribute' => 'href', + ), + ), + ), + + array( + array( 'attributes' => array( 'src' => 'image.png' ), 'unparsed' => array() ), + '
', + array( + 'src' => array( + 'type' => 'string', + 'source' => 'attribute', + 'selector' => 'img', + 'attribute' => 'src' + ), + ), + ), + + array( + array( + 'attributes' => array( 'content' => 'Just some quirky content' ), + 'unparsed' => array(), + ), + '

Just some quirky content

', + array( + 'content' => array( + 'type' => 'string', + 'source' => 'html', + 'selector' => 'p' + ) + ) + ), + + array( + array( + 'attributes' => array( 'content' => '
one item
another item
' ), + 'unparsed' => array(), + ), + '
one item
another item
', + array( + 'content' => array( + 'type' => 'string', + 'source' => 'html', + 'selector' => '.wp-block-group' + ) + ) + ), + + array( + array( + 'attributes' => array( 'content' => 'An Important Section' ), + 'unparsed' => array(), + ), + '

An Important Section

', + array( + 'content' => array( + 'type' => 'string', + 'source' => 'html', + 'selector' => 'h1,h2,h3,h4,h5,h6' + ) + ) + ), + + array( + array( + 'attributes' => array( 'url' => 'poster.pdf' ), + 'unparsed' => array(), + ), + 'Some PlaceDownload the posterNo Link', + array( + 'url' => array( + 'type' => 'string', + 'source' => 'attribute', + 'attribute' => 'href', + 'selector' => 'a[download]', + ), + ), + ), + ); + } + + /** + * @dataProvider data_parsed_block_attribute_definitions + */ + public function test_parse_definition( $expected, $input ) { + $this->assertSame( $expected, WP_HTML_Attribute_Sourcer::parse_definition( $input ) ); + } + + public function data_parsed_block_attribute_definitions() { + return array( + array( + 'not-sourced', + array( 'type' => 'string' ), + ), + array( + array( 'type' => 'attribute', 'selector' => array( array( 'tag_name' => 'div', 'then' => array( 'tag_name' => 'img', 'combinator' => '+' ) ) ), 'attribute' => 'src' ), + array( 'type' => 'string', 'source' => 'attribute', 'selector' => 'div + img', 'attribute' => 'src' ), + ), + array( + 'inner-html', + array( 'type' => 'string', 'source' => 'html' ), + ), + array( + array( 'type' => 'html', 'selector' => array( array( 'tag_name' => 'code' ) ) ), + array( 'type' => 'string', 'source' => 'html', 'selector' => 'code' ), + ), + array( + array( 'type' => 'attribute', 'selector' => array( array( 'tag_name' => 'img' ) ), 'attribute' => 'src' ), + array( 'type' => 'string', 'source' => 'attribute', 'selector' => 'img', 'attribute' => 'src' ), + ), + ); + } + + /** + * @dataProvider data_parsed_css_selectors + */ + public function test_parses_css_selector( $expected, $input ) { + $this->assertSame($expected, WP_HTML_Attribute_Sourcer::parse_full_selector( $input ) ); + } + + public function data_parsed_css_selectors() { + return array( + array( array( array( 'tag_name' => 'img' ) ), 'img' ), + array( array( array( 'class_names' => array( 'block-group' ) ) ), '.block-group' ), + array( array( array( 'hash' => 'input-form' ) ), '#input-form' ), + array( + array( + array( + 'tag_name' => 'div', + 'then' => array( + 'class_names' => array( 'important' ), + 'combinator' => '>', + ) + ) + ), + 'div > .important', + ), + array( + array( + array( + 'tag_name' => 'img', + 'then' => array( + 'tag_name' => 'p', + 'combinator' => '+', + ) + ) + ), + 'img + p', + ), + array( + array( + array( + 'tag_name' => 'img', + 'then' => array( + 'tag_name' => 'p', + 'combinator' => '~', + ) + ) + ), + 'img ~ p', + ), + array( + array( + array( + 'tag_name' => 'main', + 'then' => array( + 'tag_name' => 'section', + 'then' => array( + 'class_names' => array( 'title' ), + 'combinator' => '+', + ), + 'combinator' => '>', + ) + ), + array( 'hash' => 'title' ) + ), + 'main > section + .title, #title', + ), + array( + array( + array( + 'tag_name' => 'li', + 'then' => array( + 'tag_name' => 'em', + 'combinator' => ' ', + ) + ) + ), + 'li em', + ), + array( + array( + array( + 'tag_name' => 'main', + 'then' => array( + 'tag_name' => 'section', + 'then' => array( + 'class_names' => array( 'title' ), + 'combinator' => '+', + ), + 'combinator' => '>', + ) + ), + array( + 'hash' => 'title', + 'then' => array( + 'tag_name' => 'h2', + 'then' => array( + 'tag_name' => 'em', + 'then' => array( + 'class_names' => array( 'really' ), + 'combinator' => ' ' + ), + 'combinator' => ' ', + ), + 'combinator' => '~', + ), + ), + array( + 'class_names' => array( + 'another', + 'class', + 'combined', + ), + ), + ), + 'main > section + .title, #title ~ h2 em .really, .another.class.combined', + ) + ); + } + + /** + * @dataProvider data_multi_parsed_css_selectors + */ + public function test_parses_multi_css_selectors( $expected, $input ) { + $this->assertSame( $expected, WP_HTML_Attribute_Sourcer::parse_full_selector( $input ) ); + } + + public function data_multi_parsed_css_selectors() { + return array( + array( + array( + array( 'tag_name' => 'img' ), + array( 'class_names' => array( 'full-width' ) ), + ), + 'img, .full-width' + ), + array( + array( + array( 'tag_name' => 'h1' ), + array( 'tag_name' => 'h2' ), + array( 'tag_name' => 'h3' ), + array( 'tag_name' => 'h4' ), + array( 'tag_name' => 'h5' ), + array( 'tag_name' => 'h6' ), + ), + 'h1,h2,h3,h4,h5,h6' + ) + ); + } + + /** + * @dataProvider data_identifier_from_selector + * @return void + */ + public function test_parses_css_identifier( $expected, $input ) { + $this->assertEquals( $expected, WP_HTML_Attribute_Sourcer::parse_css_identifier( $input ) ); + } + + public function data_identifier_from_selector() { + return array( + array( 'div', 'div > img' ), + array( '-ident', '-ident.class#id' ) + ); + } +} diff --git a/phpunit/html/wp-html-processor-test.php b/phpunit/html/wp-html-processor-test.php new file mode 100644 index 00000000000000..ae9089d761f4f3 --- /dev/null +++ b/phpunit/html/wp-html-processor-test.php @@ -0,0 +1,225 @@ +outside
inside
' ); + + $tags->next_tag( 'div' ); + $state = $tags->new_state(); + $this->assertFalse( $tags->balanced_next( $state, 'img' ) ); + + $this->assertTrue( $tags->next_tag( 'div' ) ); + $state = $tags->new_state(); + $this->assertTrue( $tags->balanced_next( $state, 'img' ) ); + } + + public function test_find_immediate_child_tag() { + $tags = new WP_HTML_Processor( '
' ); + + $tags->next_tag( 'div' ); + $state = $tags->new_state(); + $state->match_depth = 1; + $this->assertFalse( $tags->balanced_next( $state, 'img' ) ); + } + + public function test_find_immediate_child_tag2() { + $tags = new WP_HTML_Processor( '
' ); + + $tags->next_tag( 'div' ); + $state = $tags->new_state(); + $state->match_depth = 1; + $this->assertTrue( $tags->balanced_next( $state, 'img' ), 'Did not find the wanted ' ); + $this->assertTrue( $tags->get_attribute( 'wanted' ), 'Found the wrong ' ); + } + + public function test_find_child_tag() { + $tags = new WP_HTML_Processor( '
' ); + + $tags->next_tag( 'div' ); + $state = $tags->new_state(); + $state->match_depth = 3; + $this->assertTrue( $tags->balanced_next( $state, 'img' ) ); + } + + public function test_flushes_up_to_close_tag_from_deep_within() { + $tags = new WP_HTML_Processor( << +
+

Cows

+
+

Cows are clever.

+

Cows eat grass.

+
+

Things cows can't do

+
    +
  • Pilot aeroplanes

  • +
  • Drive race cars

  • +
  • Captain ships

  • +
+

This concludes our discussion of cows.

+
+
+

Oxen

+
+

Oxen are strong.

+
+
+ +HTML + ); + + $tags->next_tag( 'section' ); + $state = $tags->new_state(); + + // Jump inside this tag + $tags->balanced_next( $state, 'p' ); + $this->assertTrue( $tags->get_attribute( 'start' ) ); + // Then exit the outer section we were scanning. + while ( $tags->balanced_next( $state ) ) { + continue; + } + + $this->assertEquals( 'SECTION', $tags->get_tag() ); + $tags->next_tag( 'p' ); + $this->assertTrue( $tags->get_attribute( 'wanted' ) ); + } + + public function test_can_navigate_with_unique_state_throughout_structure() { + $tags = new WP_HTML_Processor( << +
+

Cows

+
+

Cows are clever.

+

Cows eat grass.

+
+

Things cows can't do

+
    +
  • Pilot aeroplanes

  • +
  • Drive race cars

  • +
  • Captain ships

  • +
+

This concludes our discussion of cows.

+
+
+

Oxen

+
+

Oxen are strong.

+
+
+ +HTML + ); + + $tags->next_tag( 'section' ); + $state = $tags->new_state(); + + // Jump inside this tag + $tags->balanced_next( $state, 'p' ); + $this->assertTrue( $tags->get_attribute( 'start' ) ); + + // Establish a new state/frame for navigating inside the outer structure. + $tags->balanced_next( $state, 'ul' ); + $li_count = 0; + $li_state = $tags->new_state(); + while ( $tags->balanced_next( $li_state, 'li' ) ) { + $li_count++; + } + $this->assertEquals( 3, $li_count ); + + // Ensure that we ended up where we expected. + $this->assertEquals( 'UL', $tags->get_tag() ); + $this->assertTrue( $tags->is_tag_closer() ); + $tags->next_tag(); + $this->assertTrue( $tags->get_attribute( 'inner' ) ); + + // And now flush out the previous stack/frame + while ( $tags->balanced_next( $state ) ) { + continue; + } + + // Ensure that we're back where we want to be after exiting two separate frames. + $this->assertEquals( 'P', $tags->get_tag() ); + $this->assertTrue( $tags->is_tag_closer() ); + $tags->next_tag( 'p' ); + $this->assertTrue( $tags->get_attribute( 'wanted' ) ); + } + + public function test_can_scan_through_tags_at_a_given_depth() { + $tags = new WP_HTML_Processor( << +
+

Cows

+
+

Cows are clever.

+

Cows eat grass.

+
+

Things cows can't do

+
    +
  • Pilot aeroplanes

  • +
  • Drive race cars

  • +
  • Captain ships

  • +
+

Things cows can do

+
    +
  • Chew cud

  • +
  • Moo

  • +
+

This concludes our discussion of cows.

+
+
+

Oxen

+
+

Oxen are strong.

+
+
+ +HTML + ); + + $tags->next_tag( 'section' ); + $state = $tags->new_state(); + $state->match_depth = 3; + + $p3_count = 0; + while ( $tags->balanced_next( $state, 'p' ) ) { + $p3_count++; + } + + // Did we only visit the tags inside section > * > * > p? + $this->assertEquals( 5, $p3_count ); + + $state = $tags->new_state(); + $state->match_depth = 2; + + $p2_count = 0; + while ( $tags->balanced_next( $state, 'p' ) ) { + $p2_count++; + } + + // Did we only visit the tags inside section > * > p? + $this->assertEquals( 1, $p2_count ); + } +} diff --git a/phpunit/html/wp-html-tag-processor-test.php b/phpunit/html/wp-html-tag-processor-test.php index e6b3d4d8071af5..1d91d3a32f53c1 100644 --- a/phpunit/html/wp-html-tag-processor-test.php +++ b/phpunit/html/wp-html-tag-processor-test.php @@ -8,6 +8,15 @@ require_once __DIR__ . '/../../lib/experimental/html/index.php'; +if ( ! function_exists( 'esc_attr' ) ) { + function esc_attr( $s ) { return htmlentities( $s, ENT_QUOTES, null, false ); } +} + +if ( ! class_exists( 'WP_UnitTestCase' ) ) { + class WP_UnitTestCase extends PHPUnit\Framework\TestCase {} +} + + /** * @group html *