diff --git a/phpcs.xml.dist b/phpcs.xml.dist index 3defbc290a6b2..b177cf1f7a990 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -224,7 +224,7 @@ ############################################################################# SELECTIVE EXCLUSIONS Exclude specific files for specific sniffs and/or exclude sub-groups in sniffs. - + These exclusions are listed ordered by alphabetic sniff name. ############################################################################# --> @@ -250,6 +250,15 @@ /wp-tests-config-sample\.php + + + /wp-includes/html-api/class-wp-html-processor\.php + + diff --git a/src/wp-includes/html-api/class-wp-html-open-elements.php b/src/wp-includes/html-api/class-wp-html-open-elements.php index 55c4d3a663c5b..02536b56fb7d2 100644 --- a/src/wp-includes/html-api/class-wp-html-open-elements.php +++ b/src/wp-includes/html-api/class-wp-html-open-elements.php @@ -166,18 +166,22 @@ public function has_element_in_scope( $tag_name ) { * Returns whether a particular element is in list item scope. * * @since 6.4.0 + * @since 6.5.0 Implemented: no longer throws on every invocation. * * @see https://html.spec.whatwg.org/#has-an-element-in-list-item-scope * - * @throws WP_HTML_Unsupported_Exception Always until this function is implemented. - * * @param string $tag_name Name of tag to check. * @return bool Whether given element is in scope. */ public function has_element_in_list_item_scope( $tag_name ) { - throw new WP_HTML_Unsupported_Exception( 'Cannot process elements depending on list item scope.' ); - - return false; // The linter requires this unreachable code until the function is implemented and can return. + return $this->has_element_in_specific_scope( + $tag_name, + array( + // There are more elements that belong here which aren't currently supported. + 'OL', + 'UL', + ) + ); } /** @@ -375,10 +379,22 @@ public function walk_down() { * see WP_HTML_Open_Elements::walk_down(). * * @since 6.4.0 + * @since 6.5.0 Accepts $above_this_node to start traversal above a given node, if it exists. + * + * @param ?WP_HTML_Token $above_this_node Start traversing above this node, if provided and if the node exists. */ - public function walk_up() { + public function walk_up( $above_this_node = null ) { + $has_found_node = null === $above_this_node; + for ( $i = count( $this->stack ) - 1; $i >= 0; $i-- ) { - yield $this->stack[ $i ]; + $node = $this->stack[ $i ]; + + if ( ! $has_found_node ) { + $has_found_node = $node === $above_this_node; + continue; + } + + yield $node; } } 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 14dfc3aa869f0..f74e9f99c0179 100644 --- a/src/wp-includes/html-api/class-wp-html-processor.php +++ b/src/wp-includes/html-api/class-wp-html-processor.php @@ -644,10 +644,12 @@ private function step_in_body() { case '+MAIN': case '+MENU': case '+NAV': + case '+OL': case '+P': case '+SEARCH': case '+SECTION': case '+SUMMARY': + case '+UL': if ( $this->state->stack_of_open_elements->has_p_in_button_scope() ) { $this->close_a_p_element(); } @@ -681,9 +683,11 @@ private function step_in_body() { case '-MAIN': case '-MENU': case '-NAV': + case '-OL': case '-SEARCH': case '-SECTION': case '-SUMMARY': + case '-UL': if ( ! $this->state->stack_of_open_elements->has_element_in_scope( $tag_name ) ) { // @TODO: Report parse error. // Ignore the token. @@ -751,6 +755,92 @@ private function step_in_body() { $this->state->stack_of_open_elements->pop_until( '(internal: H1 through H6 - do not use)' ); return true; + /* + * > A start tag whose tag name is "li" + * > A start tag whose tag name is one of: "dd", "dt" + */ + case '+DD': + case '+DT': + case '+LI': + $this->state->frameset_ok = false; + $node = $this->state->stack_of_open_elements->current_node(); + + in_body_list_loop: + if ( $tag_name === $node->node_name ) { + $this->generate_implied_end_tags(); + if ( $tag_name !== $this->state->stack_of_open_elements->current_node()->node_name ) { + // @TODO: Indicate a parse error once it's possible. This error does not impact the logic here. + } + + $this->state->stack_of_open_elements->pop_until( $tag_name ); + goto in_body_list_done; + } + + if ( + 'ADDRESS' !== $node->node_name && + 'DIV' !== $node->node_name && + 'P' !== $node->node_name && + $this->is_special( $node->node_name ) + ) { + /* + * > If node is in the special category, but is not an address, div, + * > or p element, then jump to the step labeled done below. + */ + goto in_body_list_done; + } else { + /* + * > Otherwise, set node to the previous entry in the stack of open elements + * > and return to the step labeled loop. + */ + foreach ( $this->state->stack_of_open_elements->walk_up( $node ) as $item ) { + $node = $item; + break; + } + goto in_body_list_loop; + } + + in_body_list_done: + if ( $this->state->stack_of_open_elements->has_p_in_button_scope() ) { + $this->close_a_p_element(); + } + + $this->insert_html_element( $this->state->current_token ); + return true; + + /* + * > An end tag whose tag name is "li" + * > An end tag whose tag name is one of: "dd", "dt" + */ + case '-DD': + case '-DT': + case '-LI': + if ( + ( + 'LI' === $tag_name && + ! $this->state->stack_of_open_elements->has_element_in_list_item_scope( 'LI' ) + ) || + ( + 'LI' !== $tag_name && + ! $this->state->stack_of_open_elements->has_element_in_scope( $tag_name ) + ) + ) { + /* + * This is a parse error, ignore the token. + * + * @TODO: Indicate a parse error once it's possible. + */ + return $this->step(); + } + + $this->generate_implied_end_tags( $tag_name ); + + if ( $tag_name !== $this->state->stack_of_open_elements->current_node()->node_name ) { + // @TODO: Indicate a parse error once it's possible. This error does not impact the logic here. + } + + $this->state->stack_of_open_elements->pop_until( $tag_name ); + return true; + /* * > An end tag whose tag name is "p" */ @@ -1128,6 +1218,9 @@ private function close_a_p_element() { */ private function generate_implied_end_tags( $except_for_this_element = null ) { $elements_with_implied_end_tags = array( + 'DD', + 'DT', + 'LI', 'P', ); @@ -1153,6 +1246,9 @@ private function generate_implied_end_tags( $except_for_this_element = null ) { */ private function generate_implied_end_tags_thoroughly() { $elements_with_implied_end_tags = array( + 'DD', + 'DT', + 'LI', 'P', ); diff --git a/tests/phpunit/tests/html-api/wpHtmlProcessorBreadcrumbs.php b/tests/phpunit/tests/html-api/wpHtmlProcessorBreadcrumbs.php index 2fd852e434412..c52b9d384d6b9 100644 --- a/tests/phpunit/tests/html-api/wpHtmlProcessorBreadcrumbs.php +++ b/tests/phpunit/tests/html-api/wpHtmlProcessorBreadcrumbs.php @@ -43,13 +43,15 @@ public function data_single_tag_of_supported_elements() { 'B', 'BIG', 'BUTTON', - 'CENTER', // Neutralized + 'CENTER', // Neutralized. 'CODE', + 'DD', 'DETAILS', 'DIALOG', 'DIR', 'DIV', 'DL', + 'DT', 'EM', 'FIELDSET', 'FIGCAPTION', @@ -66,9 +68,11 @@ public function data_single_tag_of_supported_elements() { 'HGROUP', 'I', 'IMG', + 'LI', 'MAIN', 'MENU', 'NAV', + 'OL', 'P', 'SEARCH', 'SECTION', @@ -79,6 +83,7 @@ public function data_single_tag_of_supported_elements() { 'SUMMARY', 'TT', 'U', + 'UL', ); $data = array(); @@ -122,15 +127,15 @@ public function test_fails_when_encountering_unsupported_tag( $html ) { public function data_unsupported_elements() { $unsupported_elements = array( 'ABBR', - 'ACRONYM', // Neutralized - 'APPLET', // Deprecated + 'ACRONYM', // Neutralized. + 'APPLET', // Deprecated. 'AREA', 'AUDIO', 'BASE', 'BDI', 'BDO', 'BGSOUND', // Deprecated; self-closing if self-closing flag provided, otherwise normal. - 'BLINK', // Deprecated + 'BLINK', // Deprecated. 'BODY', 'BR', 'CANVAS', @@ -140,10 +145,8 @@ public function data_unsupported_elements() { 'COLGROUP', 'DATA', 'DATALIST', - 'DD', 'DEL', 'DEFN', - 'DT', 'EMBED', 'FORM', 'FRAME', @@ -154,47 +157,45 @@ public function data_unsupported_elements() { 'IFRAME', 'INPUT', 'INS', - 'ISINDEX', // Deprecated + 'ISINDEX', // Deprecated. 'KBD', - 'KEYGEN', // Deprecated; void + 'KEYGEN', // Deprecated; void. 'LABEL', 'LEGEND', - 'LI', 'LINK', 'LISTING', // Deprecated, use PRE instead. 'MAP', 'MARK', - 'MARQUEE', // Deprecated + 'MARQUEE', // Deprecated. 'MATH', 'META', 'METER', - 'MULTICOL', // Deprecated - 'NEXTID', // Deprecated - 'NOBR', // Neutralized - 'NOEMBED', // Neutralized - 'NOFRAMES', // Neutralized + 'MULTICOL', // Deprecated. + 'NEXTID', // Deprecated. + 'NOBR', // Neutralized. + 'NOEMBED', // Neutralized. + 'NOFRAMES', // Neutralized. 'NOSCRIPT', 'OBJECT', - 'OL', 'OPTGROUP', 'OPTION', 'OUTPUT', 'PICTURE', - 'PLAINTEXT', // Neutralized + 'PLAINTEXT', // Neutralized. 'PRE', 'PROGRESS', 'Q', - 'RB', // Neutralized + 'RB', // Neutralized. 'RP', 'RT', - 'RTC', // Neutralized + 'RTC', // Neutralized. 'RUBY', 'SAMP', 'SCRIPT', 'SELECT', 'SLOT', 'SOURCE', - 'SPACER', // Deprecated + 'SPACER', // Deprecated. 'STYLE', 'SUB', 'SUP', @@ -211,7 +212,6 @@ public function data_unsupported_elements() { 'TITLE', 'TR', 'TRACK', - 'UL', 'VAR', 'VIDEO', 'WBR', @@ -360,6 +360,8 @@ public function data_html_target_with_breadcrumbs() { 'H4 inside H2' => array( '

Major

Minor

', array( 'HTML', 'BODY', 'H2', 'SPAN', 'H4' ), 1 ), 'H5 after unclosed H4 inside H2' => array( '

Major

Minor

', array( 'HTML', 'BODY', 'H2', 'SPAN', 'H5' ), 1 ), 'H5 after H4 inside H2' => array( '

Major

Minor

', array( 'HTML', 'BODY', 'H5' ), 1 ), + 'LI after unclosed LI' => array( '
  • one
  • two
  • three', array( 'HTML', 'BODY', 'LI' ), 3 ), + 'LI in UL in LI' => array( '
    • one
      • two', array( 'HTML', 'BODY', 'UL', 'LI', 'UL', 'LI' ), 1 ), ); } diff --git a/tests/phpunit/tests/html-api/wpHtmlProcessorSemanticRules.php b/tests/phpunit/tests/html-api/wpHtmlProcessorSemanticRules.php index 7bd243d8dce9a..479410899f31b 100644 --- a/tests/phpunit/tests/html-api/wpHtmlProcessorSemanticRules.php +++ b/tests/phpunit/tests/html-api/wpHtmlProcessorSemanticRules.php @@ -224,6 +224,107 @@ public function test_in_body_button_with_button_in_scope_as_ancestor() { $this->assertSame( array( 'HTML', 'BODY', 'BUTTON' ), $p->get_breadcrumbs(), 'Failed to produce expected DOM nesting for third button.' ); } + /** + * Verifies that H1 through H6 elements close an open P element. + * + * @ticket {TICKET_NUMBER} + * + * @dataProvider data_heading_elements + * + * @param string $tag_name Name of H1 - H6 element under test. + */ + public function test_in_body_heading_element_closes_open_p_tag( $tag_name ) { + $processor = WP_HTML_Processor::create_fragment( + "

        Open<{$tag_name}>Closed P

        " + ); + + $processor->next_tag( $tag_name ); + $this->assertSame( + array( 'HTML', 'BODY', $tag_name ), + $processor->get_breadcrumbs(), + "Expected {$tag_name} to be a direct child of the BODY, having closed the open P element." + ); + + $processor->next_tag( 'IMG' ); + $this->assertSame( + array( 'HTML', 'BODY', 'IMG' ), + $processor->get_breadcrumbs(), + 'Expected IMG to be a direct child of BODY, having closed the open P element.' + ); + } + + /** + * Data provider. + * + * @return array[]. + */ + public function data_heading_elements() { + return array( + 'H1' => array( 'H1' ), + 'H2' => array( 'H2' ), + 'H3' => array( 'H3' ), + 'H4' => array( 'H4' ), + 'H5' => array( 'H5' ), + 'H6' => array( 'H5' ), + ); + } + + /** + * Verifies that H1 through H6 elements close an open H1 through H6 element. + * + * @dataProvider data_heading_combinations + * + * @param string $first_heading H1 - H6 element appearing (unclosed) before the second. + * @param string $second_heading H1 - H6 element appearing after the first. + */ + public function test_in_body_heading_element_closes_other_heading_elements( $first_heading, $second_heading ) { + $processor = WP_HTML_Processor::create_fragment( + "
        <{$first_heading} first> then <{$second_heading} second> and end
        " + ); + + while ( $processor->next_tag() && null === $processor->get_attribute( 'second' ) ) { + continue; + } + + $this->assertTrue( + $processor->get_attribute( 'second' ), + "Failed to find expected {$second_heading} tag." + ); + + $this->assertSame( + array( 'HTML', 'BODY', 'DIV', $second_heading ), + $processor->get_breadcrumbs(), + "Expected {$second_heading} to be a direct child of the DIV, having closed the open {$first_heading} element." + ); + + $processor->next_tag( 'IMG' ); + $this->assertSame( + array( 'HTML', 'BODY', 'DIV', 'IMG' ), + $processor->get_breadcrumbs(), + "Expected IMG to be a direct child of DIV, having closed the open {$first_heading} element." + ); + } + + /** + * Data provider. + * + * @return array[] + */ + public function data_heading_combinations() { + $headings = array( 'H1', 'H2', 'H3', 'H4', 'H5', 'H6' ); + + $combinations = array(); + + // Create all unique pairs of H1 - H6 elements. + foreach ( $headings as $first_tag ) { + foreach ( $headings as $second_tag ) { + $combinations[ "{$first_tag} then {$second_tag}" ] = array( $first_tag, $second_tag ); + } + } + + return $combinations; + } + /** * Verifies that when "in body" and encountering "any other end tag" * that the HTML processor ignores the end tag if there's a special diff --git a/tests/phpunit/tests/html-api/wpHtmlSupportRequiredHtmlProcessor.php b/tests/phpunit/tests/html-api/wpHtmlSupportRequiredHtmlProcessor.php index b0951b580ce26..2770acb7360df 100644 --- a/tests/phpunit/tests/html-api/wpHtmlSupportRequiredHtmlProcessor.php +++ b/tests/phpunit/tests/html-api/wpHtmlSupportRequiredHtmlProcessor.php @@ -58,9 +58,6 @@ private function ensure_support_is_added_everywhere( $tag_name ) { * @covers WP_HTML_Processor::generate_implied_end_tags */ public function test_generate_implied_end_tags_needs_support() { - $this->ensure_support_is_added_everywhere( 'DD' ); - $this->ensure_support_is_added_everywhere( 'DT' ); - $this->ensure_support_is_added_everywhere( 'LI' ); $this->ensure_support_is_added_everywhere( 'OPTGROUP' ); $this->ensure_support_is_added_everywhere( 'OPTION' ); $this->ensure_support_is_added_everywhere( 'RB' ); @@ -82,9 +79,6 @@ public function test_generate_implied_end_tags_needs_support() { public function test_generate_implied_end_tags_thoroughly_needs_support() { $this->ensure_support_is_added_everywhere( 'CAPTION' ); $this->ensure_support_is_added_everywhere( 'COLGROUP' ); - $this->ensure_support_is_added_everywhere( 'DD' ); - $this->ensure_support_is_added_everywhere( 'DT' ); - $this->ensure_support_is_added_everywhere( 'LI' ); $this->ensure_support_is_added_everywhere( 'OPTGROUP' ); $this->ensure_support_is_added_everywhere( 'OPTION' ); $this->ensure_support_is_added_everywhere( 'RB' ); diff --git a/tests/phpunit/tests/html-api/wpHtmlSupportRequiredOpenElements.php b/tests/phpunit/tests/html-api/wpHtmlSupportRequiredOpenElements.php index 9dbb689df0329..adbe84c9b1314 100644 --- a/tests/phpunit/tests/html-api/wpHtmlSupportRequiredOpenElements.php +++ b/tests/phpunit/tests/html-api/wpHtmlSupportRequiredOpenElements.php @@ -130,13 +130,6 @@ public function test_has_element_in_list_item_scope_needs_support() { $this->ensure_support_is_added_everywhere( 'FOREIGNOBJECT' ); $this->ensure_support_is_added_everywhere( 'DESC' ); $this->ensure_support_is_added_everywhere( 'TITLE' ); - - // These elements are specific to list item scope. - $this->ensure_support_is_added_everywhere( 'OL' ); - $this->ensure_support_is_added_everywhere( 'UL' ); - - // This element is the only element that depends on list item scope. - $this->ensure_support_is_added_everywhere( 'LI' ); } /**