Make WordPress Core

Changeset 57264


Ignore:
Timestamp:
01/10/2024 02:03:57 PM (4 months 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.