Make WordPress Core

Changeset 57264


Ignore:
Timestamp:
01/10/2024 02:03:57 PM (3 years ago)
Author:
dmsnell
Message:

HTML API: Add support for list elements.

Adds support for the following HTML elements to the HTML Processor:

  • LI, OL, UL.
  • DD, DL, DT.

Previously, these elements were not supported and the HTML Processor would bail when encountering them.
With this patch it will proceed to parse an HTML document when encountering those tags as long as other normal conditions don't cause it to bail (such as complicated format reconstruction).

Props audrasjb, jonsurrell, bernhard-reiter.
Fixes #60215.

Location:
trunk
Files:
1 added
8 edited

Legend:

Unmodified
Added
Removed
  • trunk/phpcs.xml.dist

    r57017 r57264  
    251251        </rule>
    252252
     253        <!-- Exclude forbidding goto in the HTML Processor, which mimics algorithms that are written
     254             this way in the HTML specification, and these particular algorithms are complex and
     255             highly imperative. Avoiding the goto introduces a number of risks that could make it
     256             more difficult to maintain the relationship to the standard, lead to subtle differences
     257             in the parsing, and distance the code from its standard. -->
     258        <rule ref="Generic.PHP.DiscourageGoto.Found">
     259                <exclude-pattern>/wp-includes/html-api/class-wp-html-processor\.php</exclude-pattern>
     260        </rule>
     261
    253262        <!-- Exclude sample config from modernization to prevent breaking CI workflows based on WP-CLI scaffold.
    254263                 See: https://core.trac.wordpress.org/ticket/48082#comment:16 -->
  • trunk/src/wp-includes/html-api/class-wp-html-open-elements.php

    r57186 r57264  
    130130
    131131                        if ( in_array( $node->node_name, $termination_list, true ) ) {
    132                                 return true;
     132                                return false;
    133133                        }
    134134                }
    … …  
    167167         *
    168168         * @since 6.4.0
     169         * @since 6.5.0 Implemented: no longer throws on every invocation.
    169170         *
    170171         * @see https://html.spec.whatwg.org/#has-an-element-in-list-item-scope
    171          *
    172          * @throws WP_HTML_Unsupported_Exception Always until this function is implemented.
    173172         *
    174173         * @param string $tag_name Name of tag to check.
    … …  
    176175         */
    177176        public function has_element_in_list_item_scope( $tag_name ) {
    178                 throw new WP_HTML_Unsupported_Exception( 'Cannot process elements depending on list item scope.' );
    179 
    180                 return false; // The linter requires this unreachable code until the function is implemented and can return.
     177                return $this->has_element_in_specific_scope(
     178                        $tag_name,
     179                        array(
     180                                // There are more elements that belong here which aren't currently supported.
     181                                'OL',
     182                                'UL',
     183                        )
     184                );
    181185        }
    182186
    … …  
    376380         *
    377381         * @since 6.4.0
    378          */
    379         public function walk_up() {
     382         * @since 6.5.0 Accepts $above_this_node to start traversal above a given node, if it exists.
     383         *
     384         * @param ?WP_HTML_Token $above_this_node Start traversing above this node, if provided and if the node exists.
     385         */
     386        public function walk_up( $above_this_node = null ) {
     387                $has_found_node = null === $above_this_node;
     388
    380389                for ( $i = count( $this->stack ) - 1; $i >= 0; $i-- ) {
    381                         yield $this->stack[ $i ];
     390                        $node = $this->stack[ $i ];
     391
     392                        if ( ! $has_found_node ) {
     393                                $has_found_node = $node === $above_this_node;
     394                                continue;
     395                        }
     396
     397                        yield $node;
    382398                }
    383399        }
  • trunk/src/wp-includes/html-api/class-wp-html-processor.php

    r57248 r57264  
    106106 *  - Heading elements: H1, H2, H3, H4, H5, H6, HGROUP.
    107107 *  - Links: A.
    108  *  - Lists: DL.
     108 *  - Lists: DD, DL, DT, LI, OL, LI.
    109109 *  - Media elements: AUDIO, CANVAS, FIGCAPTION, FIGURE, IMG, MAP, PICTURE, VIDEO.
    110110 *  - Paragraph: P.
    … …  
    649649                        case '+MENU':
    650650                        case '+NAV':
     651                        case '+OL':
    651652                        case '+P':
    652653                        case '+SEARCH':
    653654                        case '+SECTION':
    654655                        case '+SUMMARY':
     656                        case '+UL':
    655657                                if ( $this->state->stack_of_open_elements->has_p_in_button_scope() ) {
    656658                                        $this->close_a_p_element();
    … …  
    686688                        case '-MENU':
    687689                        case '-NAV':
     690                        case '-OL':
    688691                        case '-SEARCH':
    689692                        case '-SECTION':
    690693                        case '-SUMMARY':
     694                        case '-UL':
    691695                                if ( ! $this->state->stack_of_open_elements->has_element_in_scope( $tag_name ) ) {
    692696                                        // @todo Report parse error.
    … …  
    754758
    755759                                $this->state->stack_of_open_elements->pop_until( '(internal: H1 through H6 - do not use)' );
     760                                return true;
     761
     762                        /*
     763                         * > A start tag whose tag name is "li"
     764                         * > A start tag whose tag name is one of: "dd", "dt"
     765                         */
     766                        case '+DD':
     767                        case '+DT':
     768                        case '+LI':
     769                                $this->state->frameset_ok = false;
     770                                $node                     = $this->state->stack_of_open_elements->current_node();
     771                                $is_li                    = 'LI' === $tag_name;
     772
     773                                in_body_list_loop:
     774                                /*
     775                                 * The logic for LI and DT/DD is the same except for one point: LI elements _only_
     776                                 * close other LI elements, but a DT or DD element closes _any_ open DT or DD element.
     777                                 */
     778                                if ( $is_li ? 'LI' === $node->node_name : ( 'DD' === $node->node_name || 'DT' === $node->node_name ) ) {
     779                                        $node_name = $is_li ? 'LI' : $node->node_name;
     780                                        $this->generate_implied_end_tags( $node_name );
     781                                        if ( $node_name !== $this->state->stack_of_open_elements->current_node()->node_name ) {
     782                                                // @todo Indicate a parse error once it's possible. This error does not impact the logic here.
     783                                        }
     784
     785                                        $this->state->stack_of_open_elements->pop_until( $node_name );
     786                                        goto in_body_list_done;
     787                                }
     788
     789                                if (
     790                                        'ADDRESS' !== $node->node_name &&
     791                                        'DIV' !== $node->node_name &&
     792                                        'P' !== $node->node_name &&
     793                                        $this->is_special( $node->node_name )
     794                                ) {
     795                                        /*
     796                                         * > If node is in the special category, but is not an address, div,
     797                                         * > or p element, then jump to the step labeled done below.
     798                                         */
     799                                        goto in_body_list_done;
     800                                } else {
     801                                        /*
     802                                         * > Otherwise, set node to the previous entry in the stack of open elements
     803                                         * > and return to the step labeled loop.
     804                                         */
     805                                        foreach ( $this->state->stack_of_open_elements->walk_up( $node ) as $item ) {
     806                                                $node = $item;
     807                                                break;
     808                                        }
     809                                        goto in_body_list_loop;
     810                                }
     811
     812                                in_body_list_done:
     813                                if ( $this->state->stack_of_open_elements->has_p_in_button_scope() ) {
     814                                        $this->close_a_p_element();
     815                                }
     816
     817                                $this->insert_html_element( $this->state->current_token );
     818                                return true;
     819
     820                        /*
     821                         * > An end tag whose tag name is "li"
     822                         * > An end tag whose tag name is one of: "dd", "dt"
     823                         */
     824                        case '-DD':
     825                        case '-DT':
     826                        case '-LI':
     827                                if (
     828                                        /*
     829                                         * An end tag whose tag name is "li":
     830                                         * If the stack of open elements does not have an li element in list item scope,
     831                                         * then this is a parse error; ignore the token.
     832                                         */
     833                                        (
     834                                                'LI' === $tag_name &&
     835                                                ! $this->state->stack_of_open_elements->has_element_in_list_item_scope( 'LI' )
     836                                        ) ||
     837                                        /*
     838                                         * An end tag whose tag name is one of: "dd", "dt":
     839                                         * If the stack of open elements does not have an element in scope that is an
     840                                         * HTML element with the same tag name as that of the token, then this is a
     841                                         * parse error; ignore the token.
     842                                         */
     843                                        (
     844                                                'LI' !== $tag_name &&
     845                                                ! $this->state->stack_of_open_elements->has_element_in_scope( $tag_name )
     846                                        )
     847                                ) {
     848                                        /*
     849                                         * This is a parse error, ignore the token.
     850                                         *
     851                                         * @todo Indicate a parse error once it's possible.
     852                                         */
     853                                        return $this->step();
     854                                }
     855
     856                                $this->generate_implied_end_tags( $tag_name );
     857
     858                                if ( $tag_name !== $this->state->stack_of_open_elements->current_node()->node_name ) {
     859                                        // @todo Indicate a parse error once it's possible. This error does not impact the logic here.
     860                                }
     861
     862                                $this->state->stack_of_open_elements->pop_until( $tag_name );
    756863                                return true;
    757864
    … …  
    12241331        private function generate_implied_end_tags( $except_for_this_element = null ) {
    12251332                $elements_with_implied_end_tags = array(
     1333                        'DD',
     1334                        'DT',
     1335                        'LI',
    12261336                        'P',
    12271337                );
    … …  
    12491359        private function generate_implied_end_tags_thoroughly() {
    12501360                $elements_with_implied_end_tags = array(
     1361                        'DD',
     1362                        'DT',
     1363                        'LI',
    12511364                        'P',
    12521365                );
  • trunk/tests/phpunit/tests/html-api/wpHtmlProcessor.php

    r57248 r57264  
    169169                        'COL'       => array( 'COL' ),
    170170                        'COLGROUP'  => array( 'COLGROUP' ),
    171                         'DD'        => array( 'DD' ),
    172                         'DT'        => array( 'DT' ),
    173171                        'EMBED'     => array( 'EMBED' ),
    174172                        'FORM'      => array( 'FORM' ),
    … …  
    181179                        'INPUT'     => array( 'INPUT' ),
    182180                        'KEYGEN'    => array( 'KEYGEN' ),
    183                         'LI'        => array( 'LI' ),
    184181                        'LINK'      => array( 'LINK' ),
    185182                        'LISTING'   => array( 'LISTING' ),
    … …  
    192189                        'NOSCRIPT'  => array( 'NOSCRIPT' ),
    193190                        'OBJECT'    => array( 'OBJECT' ),
    194                         'OL'        => array( 'OL' ),
    195191                        'OPTGROUP'  => array( 'OPTGROUP' ),
    196192                        'OPTION'    => array( 'OPTION' ),
    … …  
    219215                        'TR'        => array( 'TR' ),
    220216                        'TRACK'     => array( 'TRACK' ),
    221                         'UL'        => array( 'UL' ),
    222217                        'WBR'       => array( 'WBR' ),
    223218                        'XMP'       => array( 'XMP' ),
  • trunk/tests/phpunit/tests/html-api/wpHtmlProcessorBreadcrumbs.php

    r57248 r57264  
    3939                        'A',
    4040                        'ABBR',
    41                         'ACRONYM', // Neutralized
     41                        'ACRONYM', // Neutralized.
    4242                        'ADDRESS',
    4343                        'ARTICLE',
    … …  
    4848                        'BDO',
    4949                        'BIG',
    50                         'BLINK', // Deprecated
     50                        'BLINK', // Deprecated.
    5151                        'BUTTON',
    5252                        'CANVAS',
    53                         'CENTER', // Neutralized
     53                        'CENTER', // Neutralized.
    5454                        'CITE',
    5555                        'CODE',
    5656                        'DATA',
     57                        'DD',
    5758                        'DATALIST',
    5859                        'DFN',
    … …  
    6364                        'DIV',
    6465                        'DL',
     66                        'DT',
    6567                        'EM',
    6668                        'FIELDSET',
    … …  
    8082                        'IMG',
    8183                        'INS',
     84                        'LI',
    8285                        'ISINDEX', // Deprecated
    8386                        'KBD',
    … …  
    9295                        'NAV',
    9396                        'NEXTID', // Deprecated
     97                        'OL',
    9498                        'OUTPUT',
    9599                        'P',
    … …  
    113117                        'TT',
    114118                        'U',
     119                        'UL',
    115120                        'VAR',
    116121                        'VIDEO',
    … …  
    157162        public function data_unsupported_elements() {
    158163                $unsupported_elements = array(
    159                         'APPLET', // Deprecated
     164                        'APPLET', // Deprecated.
    160165                        'AREA',
    161166                        'BASE',
    … …  
    166171                        'COL',
    167172                        'COLGROUP',
    168                         'DD',
    169                         'DT',
    170173                        'EMBED',
    171174                        'FORM',
    … …  
    177180                        'IFRAME',
    178181                        'INPUT',
    179                         'KEYGEN', // Deprecated; void
    180                         'LI',
     182                        'KEYGEN', // Deprecated; void.
    181183                        'LINK',
    182184                        'LISTING', // Deprecated, use PRE instead.
    183                         'MARQUEE', // Deprecated
     185                        'MARQUEE', // Deprecated.
    184186                        'MATH',
    185187                        'META',
    186                         'NOBR', // Neutralized
    187                         'NOEMBED', // Neutralized
    188                         'NOFRAMES', // Neutralized
     188                        'NOBR', // Neutralized.
     189                        'NOEMBED', // Neutralized.
     190                        'NOFRAMES', // Neutralized.
    189191                        'NOSCRIPT',
    190192                        'OBJECT',
    191                         'OL',
    192193                        'OPTGROUP',
    193194                        'OPTION',
    194                         'PLAINTEXT', // Neutralized
     195                        'PLAINTEXT', // Neutralized.
    195196                        'PRE',
    196                         'RB', // Neutralized
     197                        'RB', // Neutralized.
    197198                        'RP',
    198199                        'RT',
    199                         'RTC', // Neutralized
     200                        'RTC', // Neutralized.
    200201                        'SCRIPT',
    201202                        'SELECT',
    … …  
    214215                        'TR',
    215216                        'TRACK',
    216                         'UL',
    217217                        'WBR',
    218218                        'XMP', // Deprecated, use PRE instead.
    … …  
    349349                        'MAIN inside MAIN inside SPAN'          => array( '<span><main><main target>', array( 'HTML', 'BODY', 'SPAN', 'MAIN', 'MAIN' ), 1 ),
    350350                        'MAIN next to unclosed P'               => array( '<p><main target>', array( 'HTML', 'BODY', 'MAIN' ), 1 ),
     351                        'LI after unclosed LI'                  => array( '<li>one<li>two<li target>three', array( 'HTML', 'BODY', 'LI' ), 3 ),
     352                        'LI in UL in LI'                        => array( '<ul><li>one<ul><li target>two', array( 'HTML', 'BODY', 'UL', 'LI', 'UL', 'LI' ), 1 ),
     353                        'DD and DT mutually close, LI self-closes (dt 2)' => array( '<dd><dd><dt><dt target><dd><li><li>', array( 'HTML', 'BODY', 'DT' ), 2 ),
     354                        'DD and DT mutually close, LI self-closes (dd 3)' => array( '<dd><dd><dt><dt><dd target><li><li>', array( 'HTML', 'BODY', 'DD' ), 3 ),
     355                        'DD and DT mutually close, LI self-closes (li 1)' => array( '<dd><dd><dt><dt><dd><li target><li>', array( 'HTML', 'BODY', 'DD', 'LI' ), 1 ),
     356                        'DD and DT mutually close, LI self-closes (li 2)' => array( '<dd><dd><dt><dt><dd><li><li target>', array( 'HTML', 'BODY', 'DD', 'LI' ), 2 ),
    351357
    352358                        // H1 - H6 close out _any_ H1 - H6 when encountering _any_ of H1 - H6, making this section surprising.
  • trunk/tests/phpunit/tests/html-api/wpHtmlProcessorSemanticRules.php

    r57244 r57264  
    226226
    227227        /**
     228         * Verifies that H1 through H6 elements close an open P element.
     229         *
     230         * @ticket 60215
     231         *
     232         * @dataProvider data_heading_elements
     233         *
     234         * @param string $tag_name Name of H1 - H6 element under test.
     235         */
     236        public function test_in_body_heading_element_closes_open_p_tag( $tag_name ) {
     237                $processor = WP_HTML_Processor::create_fragment(
     238                        "<p>Open<{$tag_name}>Closed P</{$tag_name}><img></p>"
     239                );
     240
     241                $processor->next_tag( $tag_name );
     242                $this->assertSame(
     243                        array( 'HTML', 'BODY', $tag_name ),
     244                        $processor->get_breadcrumbs(),
     245                        "Expected {$tag_name} to be a direct child of the BODY, having closed the open P element."
     246                );
     247
     248                $processor->next_tag( 'IMG' );
     249                $this->assertSame(
     250                        array( 'HTML', 'BODY', 'IMG' ),
     251                        $processor->get_breadcrumbs(),
     252                        'Expected IMG to be a direct child of BODY, having closed the open P element.'
     253                );
     254        }
     255
     256        /**
     257         * Data provider.
     258         *
     259         * @return array[].
     260         */
     261        public function data_heading_elements() {
     262                return array(
     263                        'H1' => array( 'H1' ),
     264                        'H2' => array( 'H2' ),
     265                        'H3' => array( 'H3' ),
     266                        'H4' => array( 'H4' ),
     267                        'H5' => array( 'H5' ),
     268                        'H6' => array( 'H5' ),
     269                );
     270        }
     271
     272        /**
     273         * Verifies that H1 through H6 elements close an open H1 through H6 element.
     274         *
     275         * @ticket 60215
     276         *
     277         * @dataProvider data_heading_combinations
     278         *
     279         * @param string $first_heading  H1 - H6 element appearing (unclosed) before the second.
     280         * @param string $second_heading H1 - H6 element appearing after the first.
     281         */
     282        public function test_in_body_heading_element_closes_other_heading_elements( $first_heading, $second_heading ) {
     283                $processor = WP_HTML_Processor::create_fragment(
     284                        "<div><{$first_heading} first> then <{$second_heading} second> and end </{$second_heading}><img></{$first_heading}></div>"
     285                );
     286
     287                while ( $processor->next_tag() && null === $processor->get_attribute( 'second' ) ) {
     288                        continue;
     289                }
     290
     291                $this->assertTrue(
     292                        $processor->get_attribute( 'second' ),
     293                        "Failed to find expected {$second_heading} tag."
     294                );
     295
     296                $this->assertSame(
     297                        array( 'HTML', 'BODY', 'DIV', $second_heading ),
     298                        $processor->get_breadcrumbs(),
     299                        "Expected {$second_heading} to be a direct child of the DIV, having closed the open {$first_heading} element."
     300                );
     301
     302                $processor->next_tag( 'IMG' );
     303                $this->assertSame(
     304                        array( 'HTML', 'BODY', 'DIV', 'IMG' ),
     305                        $processor->get_breadcrumbs(),
     306                        "Expected IMG to be a direct child of DIV, having closed the open {$first_heading} element."
     307                );
     308        }
     309
     310        /**
     311         * Data provider.
     312         *
     313         * @return array[]
     314         */
     315        public function data_heading_combinations() {
     316                $headings = array( 'H1', 'H2', 'H3', 'H4', 'H5', 'H6' );
     317
     318                $combinations = array();
     319
     320                // Create all unique pairs of H1 - H6 elements.
     321                foreach ( $headings as $first_tag ) {
     322                        foreach ( $headings as $second_tag ) {
     323                                $combinations[ "{$first_tag} then {$second_tag}" ] = array( $first_tag, $second_tag );
     324                        }
     325                }
     326
     327                return $combinations;
     328        }
     329
     330        /**
    228331         * Verifies that when "in body" and encountering "any other end tag"
    229332         * that the HTML processor ignores the end tag if there's a special
  • trunk/tests/phpunit/tests/html-api/wpHtmlSupportRequiredHtmlProcessor.php

    r56790 r57264  
    5959         */
    6060        public function test_generate_implied_end_tags_needs_support() {
    61                 $this->ensure_support_is_added_everywhere( 'DD' );
    62                 $this->ensure_support_is_added_everywhere( 'DT' );
    63                 $this->ensure_support_is_added_everywhere( 'LI' );
    6461                $this->ensure_support_is_added_everywhere( 'OPTGROUP' );
    6562                $this->ensure_support_is_added_everywhere( 'OPTION' );
    … …  
    8380                $this->ensure_support_is_added_everywhere( 'CAPTION' );
    8481                $this->ensure_support_is_added_everywhere( 'COLGROUP' );
    85                 $this->ensure_support_is_added_everywhere( 'DD' );
    86                 $this->ensure_support_is_added_everywhere( 'DT' );
    87                 $this->ensure_support_is_added_everywhere( 'LI' );
    8882                $this->ensure_support_is_added_everywhere( 'OPTGROUP' );
    8983                $this->ensure_support_is_added_everywhere( 'OPTION' );
  • trunk/tests/phpunit/tests/html-api/wpHtmlSupportRequiredOpenElements.php

    r57248 r57264  
    121121                 */
    122122                $this->ensure_support_is_added_everywhere( 'SVG' );
    123 
    124                 // These elements are specific to list item scope.
    125                 $this->ensure_support_is_added_everywhere( 'OL' );
    126                 $this->ensure_support_is_added_everywhere( 'UL' );
    127 
    128                 // This element is the only element that depends on list item scope.
    129                 $this->ensure_support_is_added_everywhere( 'LI' );
    130123        }
    131124
Note: See TracChangeset for help on using the changeset viewer.