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. + '"\'>& =' . + // Control characters. + '\x{00}-\x{1F}' . + // HTML noncharacters. + '\x{FDD0}-\x{FDEF}' . + '\x{FFFE}\x{FFFF}\x{1FFFE}\x{1FFFF}\x{2FFFE}\x{2FFFF}\x{3FFFE}\x{3FFFF}' . + '\x{4FFFE}\x{4FFFF}\x{5FFFE}\x{5FFFF}\x{6FFFE}\x{6FFFF}\x{7FFFE}\x{7FFFF}' . + '\x{8FFFE}\x{8FFFF}\x{9FFFE}\x{9FFFF}\x{AFFFE}\x{AFFFF}\x{BFFFE}\x{BFFFF}' . + '\x{CFFFE}\x{CFFFF}\x{DFFFE}\x{DFFFF}\x{EFFFE}\x{EFFFF}\x{FFFFE}\x{FFFFF}' . + '\x{10FFFE}\x{10FFFF}' . + ']~Ssu', + $s + ); + + return $unallowed_characters_match ? null : $s; + } +} diff --git a/lib/experimental/html/class-wp-html-processor.php b/lib/experimental/html/class-wp-html-processor.php new file mode 100644 index 00000000000000..6fa236aa94a336 --- /dev/null +++ b/lib/experimental/html/class-wp-html-processor.php @@ -0,0 +1,215 @@ +` 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_name}>";
+ }
+
+ $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 @@
+
+
It's a post!
The antics of ants with antlers
+
+ Ants
+ with antlers can be funny.
+
+
+
Stuff
+ +
Just some quirky content
', + array( + 'content' => array( + 'type' => 'string', + 'source' => 'html', + 'selector' => 'p' + ) + ) + ), + + array( + array( + 'attributes' => array( 'content' => 'Cows are clever.
+Cows eat grass.
+Pilot aeroplanes
Drive race cars
Captain ships
This concludes our discussion of cows.
+Oxen are strong.
+Cows are clever.
+Cows eat grass.
+Pilot aeroplanes
Drive race cars
Captain ships
This concludes our discussion of cows.
+Oxen are strong.
+Cows are clever.
+Cows eat grass.
+Pilot aeroplanes
Drive race cars
Captain ships
Chew cud
Moo
This concludes our discussion of cows.
+Oxen are strong.
+