case '+DIV': case '+DL': case '+FIELDSET': case '+FIGCAPTION': case '+FIGURE': case '+FOOTER': case '+HEADER': case '+HGROUP': 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(); } $this->insert_html_element( $this->state->current_token ); return true; /* * > An end tag whose tag name is one of: "address", "article", "aside", "blockquote", * > "button", "center", "details", "dialog", "dir", "div", "dl", "fieldset", * > "figcaption", "figure", "footer", "header", "hgroup", "listing", "main", * > "menu", "nav", "ol", "pre", "search", "section", "summary", "ul" */ case '-ADDRESS': case '-ARTICLE': case '-ASIDE': case '-BLOCKQUOTE': case '-BUTTON': case '-CENTER': case '-DETAILS': case '-DIALOG': case '-DIR': case '-DIV': case '-DL': case '-FIELDSET': case '-FIGCAPTION': case '-FIGURE': case '-FOOTER': case '-HEADER': case '-HGROUP': case '-LISTING': case '-MAIN': case '-MENU': case '-NAV': case '-OL': case '-PRE': case '-SEARCH': case '-SECTION': case '-SUMMARY': case '-UL': if ( ! $this->state->stack_of_open_elements->has_element_in_scope( $token_name ) ) { // @todo Report parse error. // Ignore the token. return $this->step(); } $this->generate_implied_end_tags(); if ( $this->state->stack_of_open_elements->current_node()->node_name !== $token_name ) { // @todo Record parse error: this error doesn't impact parsing. } $this->state->stack_of_open_elements->pop_until( $token_name ); return true; /* * > A start tag whose tag name is one of: "h1", "h2", "h3", "h4", "h5", "h6" */ case '+H1': case '+H2': case '+H3': case '+H4': case '+H5': case '+H6': if ( $this->state->stack_of_open_elements->has_p_in_button_scope() ) { $this->close_a_p_element(); } if ( in_array( $this->state->stack_of_open_elements->current_node()->node_name, array( 'H1', 'H2', 'H3', 'H4', 'H5', 'H6' ), true ) ) { // @todo Indicate a parse error once it's possible. $this->state->stack_of_open_elements->pop(); } $this->insert_html_element( $this->state->current_token ); return true; /* * > A start tag whose tag name is one of: "pre", "listing" */ case '+PRE': case '+LISTING': 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 ); $this->state->frameset_ok = false; return true; /* * > An end tag whose tag name is one of: "h1", "h2", "h3", "h4", "h5", "h6" */ case '-H1': case '-H2': case '-H3': case '-H4': case '-H5': case '-H6': if ( ! $this->state->stack_of_open_elements->has_element_in_scope( '(internal: H1 through H6 - do not use)' ) ) { /* * 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(); if ( $this->state->stack_of_open_elements->current_node()->node_name !== $token_name ) { // @todo Record parse error: this error doesn't impact parsing. } $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(); $is_li = 'LI' === $token_name; in_body_list_loop: /* * The logic for LI and DT/DD is the same except for one point: LI elements _only_ * close other LI elements, but a DT or DD element closes _any_ open DT or DD element. */ if ( $is_li ? 'LI' === $node->node_name : ( 'DD' === $node->node_name || 'DT' === $node->node_name ) ) { $node_name = $is_li ? 'LI' : $node->node_name; $this->generate_implied_end_tags( $node_name ); if ( $node_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( $node_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 ( /* * An end tag whose tag name is "li": * If the stack of open elements does not have an li element in list item scope, * then this is a parse error; ignore the token. */ ( 'LI' === $token_name && ! $this->state->stack_of_open_elements->has_element_in_list_item_scope( 'LI' ) ) || /* * An end tag whose tag name is one of: "dd", "dt": * If the stack of open elements does not have an element in scope that is an * HTML element with the same tag name as that of the token, then this is a * parse error; ignore the token. */ ( 'LI' !== $token_name && ! $this->state->stack_of_open_elements->has_element_in_scope( $token_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( $token_name ); if ( $token_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( $token_name ); return true; /* * > An end tag whose tag name is "p" */ case '-P': if ( ! $this->state->stack_of_open_elements->has_p_in_button_scope() ) { $this->insert_html_element( $this->state->current_token ); } $this->close_a_p_element(); return true; // > A start tag whose tag name is "a" case '+A': foreach ( $this->state->active_formatting_elements->walk_up() as $item ) { switch ( $item->node_name ) { case 'marker': break; case 'A': $this->run_adoption_agency_algorithm(); $this->state->active_formatting_elements->remove_node( $item ); $this->state->stack_of_open_elements->remove_node( $item ); break; } } $this->reconstruct_active_formatting_elements(); $this->insert_html_element( $this->state->current_token ); $this->state->active_formatting_elements->push( $this->state->current_token ); return true; /* * > A start tag whose tag name is one of: "b", "big", "code", "em", "font", "i", * > "s", "small", "strike", "strong", "tt", "u" */ case '+B': case '+BIG': case '+CODE': case '+EM': case '+FONT': case '+I': case '+S': case '+SMALL': case '+STRIKE': case '+STRONG': case '+TT': case '+U': $this->reconstruct_active_formatting_elements(); $this->insert_html_element( $this->state->current_token ); $this->state->active_formatting_elements->push( $this->state->current_token ); return true; /* * > An end tag whose tag name is one of: "a", "b", "big", "code", "em", "font", "i", * > "nobr", "s", "small", "strike", "strong", "tt", "u" */ case '-A': case '-B': case '-BIG': case '-CODE': case '-EM': case '-FONT': case '-I': case '-S': case '-SMALL': case '-STRIKE': case '-STRONG': case '-TT': case '-U': $this->run_adoption_agency_algorithm(); return true; /* * > An end tag whose tag name is "br" * > Parse error. Drop the attributes from the token, and act as described in the next * > entry; i.e. act as if this was a "br" start tag token with no attributes, rather * > than the end tag token that it actually is. */ case '-BR': $this->last_error = self::ERROR_UNSUPPORTED; throw new WP_HTML_Unsupported_Exception( 'Closing BR tags require unimplemented special handling.' ); /* * > A start tag whose tag name is one of: "area", "br", "embed", "img", "keygen", "wbr" */ case '+AREA': case '+BR': case '+EMBED': case '+IMG': case '+KEYGEN': case '+WBR': $this->reconstruct_active_formatting_elements(); $this->insert_html_element( $this->state->current_token ); $this->state->frameset_ok = false; return true; /* * > A start tag whose tag name is "input" */ case '+INPUT': $this->reconstruct_active_formatting_elements(); $this->insert_html_element( $this->state->current_token ); $type_attribute = $this->get_attribute( 'type' ); /* * > If the token does not have an attribute with the name "type", or if it does, * > but that attribute's value is not an ASCII case-insensitive match for the * > string "hidden", then: set the frameset-ok flag to "not ok". */ if ( ! is_string( $type_attribute ) || 'hidden' !== strtolower( $type_attribute ) ) { $this->state->frameset_ok = false; } return true; /* * > A start tag whose tag name is "hr" */ case '+HR': 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 ); $this->state->frameset_ok = false; return true; /* * > A start tag whose tag name is one of: "param", "source", "track" */ case '+PARAM': case '+SOURCE': case '+TRACK': $this->insert_html_element( $this->state->current_token ); return true; } /* * These tags require special handling in the 'in body' insertion mode * but that handling hasn't yet been implemented. * * As the rules for each tag are implemented, the corresponding tag * name should be removed from this list. An accompanying test should * help ensure this list is maintained. * * @see Tests_HtmlApi_WpHtmlProcessor::test_step_in_body_fails_on_unsupported_tags * * Since this switch structure throws a WP_HTML_Unsupported_Exception, it's * possible to handle "any other start tag" and "any other end tag" below, * as that guarantees execution doesn't proceed for the unimplemented tags. * * @see https://html.spec.whatwg.org/multipage/parsing.html#parsing-main-inbody */ switch ( $token_name ) { case 'APPLET': case 'BASE': case 'BASEFONT': case 'BGSOUND': case 'BODY': case 'CAPTION': case 'COL': case 'COLGROUP': case 'FORM': case 'FRAME': case 'FRAMESET': case 'HEAD': case 'HTML': case 'IFRAME': case 'LINK': case 'MARQUEE': case 'MATH': case 'META': case 'NOBR': case 'NOEMBED': case 'NOFRAMES': case 'NOSCRIPT': case 'OBJECT': case 'OPTGROUP': case 'OPTION': case 'PLAINTEXT': case 'RB': case 'RP': case 'RT': case 'RTC': case 'SARCASM': case 'SCRIPT': case 'SELECT': case 'STYLE': case 'SVG': case 'TABLE': case 'TBODY': case 'TD': case 'TEMPLATE': case 'TEXTAREA': case 'TFOOT': case 'TH': case 'THEAD': case 'TITLE': case 'TR': case 'XMP': $this->last_error = self::ERROR_UNSUPPORTED; throw new WP_HTML_Unsupported_Exception( "Cannot process {$token_name} element." ); } if ( ! $this->is_tag_closer() ) { /* * > Any other start tag */ $this->reconstruct_active_formatting_elements(); $this->insert_html_element( $this->state->current_token ); return true; } else { /* * > Any other end tag */ /* * Find the corresponding tag opener in the stack of open elements, if * it exists before reaching a special element, which provides a kind * of boundary in the stack. For example, a `` should not * close anything beyond its containing `P` or `DIV` element. */ foreach ( $this->state->stack_of_open_elements->walk_up() as $node ) { if ( $token_name === $node->node_name ) { break; } if ( self::is_special( $node->node_name ) ) { // This is a parse error, ignore the token. return $this->step(); } } $this->generate_implied_end_tags( $token_name ); if ( $node !== $this->state->stack_of_open_elements->current_node() ) { // @todo Record parse error: this error doesn't impact parsing. } foreach ( $this->state->stack_of_open_elements->walk_up() as $item ) { $this->state->stack_of_open_elements->pop(); if ( $node === $item ) { return true; } } } } /* * Internal helpers */ /** * Creates a new bookmark for the currently-matched token and returns the generated name. * * @since 6.4.0 * @since 6.5.0 Renamed from bookmark_tag() to bookmark_token(). * * @throws Exception When unable to allocate requested bookmark. * * @return string|false Name of created bookmark, or false if unable to create. */ private function bookmark_token() { if ( ! parent::set_bookmark( ++$this->bookmark_counter ) ) { $this->last_error = self::ERROR_EXCEEDED_MAX_BOOKMARKS; throw new Exception( 'could not allocate bookmark' ); } return "{$this->bookmark_counter}"; } /* * HTML semantic overrides for Tag Processor */ /** * Returns the uppercase name of the matched tag. * * The semantic rules for HTML specify that certain tags be reprocessed * with a different tag name. Because of this, the tag name presented * by the HTML Processor may differ from the one reported by the HTML * Tag Processor, which doesn't apply these semantic rules. * * Example: * * $processor = new WP_HTML_Tag_Processor( '
Test
' ); * $processor->next_tag() === true; * $processor->get_tag() === 'DIV'; * * $processor->next_tag() === false; * $processor->get_tag() === null; * * @since 6.4.0 * * @return string|null Name of currently matched tag in input HTML, or `null` if none found. */ public function get_tag() { if ( null !== $this->last_error ) { return null; } $tag_name = parent::get_tag(); switch ( $tag_name ) { case 'IMAGE': /* * > A start tag whose tag name is "image" * > Change the token's tag name to "img" and reprocess it. (Don't ask.) */ return 'IMG'; default: return $tag_name; } } /** * Removes a bookmark that is no longer needed. * * Releasing a bookmark frees up the small * performance overhead it requires. * * @since 6.4.0 * * @param string $bookmark_name Name of the bookmark to remove. * @return bool Whether the bookmark already existed before removal. */ public function release_bookmark( $bookmark_name ) { return parent::release_bookmark( "_{$bookmark_name}" ); } /** * Moves the internal cursor in the HTML Processor to a given bookmark's location. * * Be careful! Seeking backwards to a previous location resets the parser to the * start of the document and reparses the entire contents up until it finds the * sought-after bookmarked location. * * In order to prevent accidental infinite loops, there's a * maximum limit on the number of times seek() can be called. * * @throws Exception When unable to allocate a bookmark for the next token in the input HTML document. * * @since 6.4.0 * * @param string $bookmark_name Jump to the place in the document identified by this bookmark name. * @return bool Whether the internal cursor was successfully moved to the bookmark's location. */ public function seek( $bookmark_name ) { // Flush any pending updates to the document before beginning. $this->get_updated_html(); $actual_bookmark_name = "_{$bookmark_name}"; $processor_started_at = $this->state->current_token ? $this->bookmarks[ $this->state->current_token->bookmark_name ]->start : 0; $bookmark_starts_at = $this->bookmarks[ $actual_bookmark_name ]->start; $direction = $bookmark_starts_at > $processor_started_at ? 'forward' : 'backward'; /* * If seeking backwards, it's possible that the sought-after bookmark exists within an element * which has been closed before the current cursor; in other words, it has already been removed * from the stack of open elements. This means that it's insufficient to simply pop off elements * from the stack of open elements which appear after the bookmarked location and then jump to * that location, as the elements which were open before won't be re-opened. * * In order to maintain consistency, the HTML Processor rewinds to the start of the document * and reparses everything until it finds the sought-after bookmark. * * There are potentially better ways to do this: cache the parser state for each bookmark and * restore it when seeking; store an immutable and idempotent register of where elements open * and close. * * If caching the parser state it will be essential to properly maintain the cached stack of * open elements and active formatting elements when modifying the document. This could be a * tedious and time-consuming process as well, and so for now will not be performed. * * It may be possible to track bookmarks for where elements open and close, and in doing so * be able to quickly recalculate breadcrumbs for any element in the document. It may even * be possible to remove the stack of open elements and compute it on the fly this way. * If doing this, the parser would need to track the opening and closing locations for all * tokens in the breadcrumb path for any and all bookmarks. By utilizing bookmarks themselves * this list could be automatically maintained while modifying the document. Finding the * breadcrumbs would then amount to traversing that list from the start until the token * being inspected. Once an element closes, if there are no bookmarks pointing to locations * within that element, then all of these locations may be forgotten to save on memory use * and computation time. */ if ( 'backward' === $direction ) { /* * Instead of clearing the parser state and starting fresh, calling the stack methods * maintains the proper flags in the parser. */ foreach ( $this->state->stack_of_open_elements->walk_up() as $item ) { if ( 'context-node' === $item->bookmark_name ) { break; } $this->state->stack_of_open_elements->remove_node( $item ); } foreach ( $this->state->active_formatting_elements->walk_up() as $item ) { if ( 'context-node' === $item->bookmark_name ) { break; } $this->state->active_formatting_elements->remove_node( $item ); } parent::seek( 'context-node' ); $this->state->insertion_mode = WP_HTML_Processor_State::INSERTION_MODE_IN_BODY; $this->state->frameset_ok = true; } // When moving forwards, reparse the document until reaching the same location as the original bookmark. if ( $bookmark_starts_at === $this->bookmarks[ $this->state->current_token->bookmark_name ]->start ) { return true; } while ( $this->step() ) { if ( $bookmark_starts_at === $this->bookmarks[ $this->state->current_token->bookmark_name ]->start ) { return true; } } return false; } /** * Sets a bookmark in the HTML document. * * Bookmarks represent specific places or tokens in the HTML * document, such as a tag opener or closer. When applying * edits to a document, such as setting an attribute, the * text offsets of that token may shift; the bookmark is * kept updated with those shifts and remains stable unless * the entire span of text in which the token sits is removed. * * Release bookmarks when they are no longer needed. * * Example: * *

Surprising fact you may not know!

* ^ ^ * \-|-- this `H2` opener bookmark tracks the token * *

Surprising fact you may no… * ^ ^ * \-|-- it shifts with edits * * Bookmarks provide the ability to seek to a previously-scanned * place in the HTML document. This avoids the need to re-scan * the entire document. * * Example: * * * ^^^^ * want to note this last item * * $p = new WP_HTML_Tag_Processor( $html ); * $in_list = false; * while ( $p->next_tag( array( 'tag_closers' => $in_list ? 'visit' : 'skip' ) ) ) { * if ( 'UL' === $p->get_tag() ) { * if ( $p->is_tag_closer() ) { * $in_list = false; * $p->set_bookmark( 'resume' ); * if ( $p->seek( 'last-li' ) ) { * $p->add_class( 'last-li' ); * } * $p->seek( 'resume' ); * $p->release_bookmark( 'last-li' ); * $p->release_bookmark( 'resume' ); * } else { * $in_list = true; * } * } * * if ( 'LI' === $p->get_tag() ) { * $p->set_bookmark( 'last-li' ); * } * } * * Bookmarks intentionally hide the internal string offsets * to which they refer. They are maintained internally as * updates are applied to the HTML document and therefore * retain their "position" - the location to which they * originally pointed. The inability to use bookmarks with * functions like `substr` is therefore intentional to guard * against accidentally breaking the HTML. * * Because bookmarks allocate memory and require processing * for every applied update, they are limited and require * a name. They should not be created with programmatically-made * names, such as "li_{$index}" with some loop. As a general * rule they should only be created with string-literal names * like "start-of-section" or "last-paragraph". * * Bookmarks are a powerful tool to enable complicated behavior. * Consider double-checking that you need this tool if you are * reaching for it, as inappropriate use could lead to broken * HTML structure or unwanted processing overhead. * * @since 6.4.0 * * @param string $bookmark_name Identifies this particular bookmark. * @return bool Whether the bookmark was successfully created. */ public function set_bookmark( $bookmark_name ) { return parent::set_bookmark( "_{$bookmark_name}" ); } /** * Checks whether a bookmark with the given name exists. * * @since 6.5.0 * * @param string $bookmark_name Name to identify a bookmark that potentially exists. * @return bool Whether that bookmark exists. */ public function has_bookmark( $bookmark_name ) { return parent::has_bookmark( "_{$bookmark_name}" ); } /* * HTML Parsing Algorithms */ /** * Closes a P element. * * @since 6.4.0 * * @throws WP_HTML_Unsupported_Exception When encountering unsupported HTML input. * * @see https://html.spec.whatwg.org/#close-a-p-element */ private function close_a_p_element() { $this->generate_implied_end_tags( 'P' ); $this->state->stack_of_open_elements->pop_until( 'P' ); } /** * Closes elements that have implied end tags. * * @since 6.4.0 * * @see https://html.spec.whatwg.org/#generate-implied-end-tags * * @param string|null $except_for_this_element Perform as if this element doesn't exist in the stack of open elements. */ private function generate_implied_end_tags( $except_for_this_element = null ) { $elements_with_implied_end_tags = array( 'DD', 'DT', 'LI', 'P', ); $current_node = $this->state->stack_of_open_elements->current_node(); while ( $current_node && $current_node->node_name !== $except_for_this_element && in_array( $this->state->stack_of_open_elements->current_node(), $elements_with_implied_end_tags, true ) ) { $this->state->stack_of_open_elements->pop(); } } /** * Closes elements that have implied end tags, thoroughly. * * See the HTML specification for an explanation why this is * different from generating end tags in the normal sense. * * @since 6.4.0 * * @see WP_HTML_Processor::generate_implied_end_tags * @see https://html.spec.whatwg.org/#generate-implied-end-tags */ private function generate_implied_end_tags_thoroughly() { $elements_with_implied_end_tags = array( 'DD', 'DT', 'LI', 'P', ); while ( in_array( $this->state->stack_of_open_elements->current_node(), $elements_with_implied_end_tags, true ) ) { $this->state->stack_of_open_elements->pop(); } } /** * Reconstructs the active formatting elements. * * > This has the effect of reopening all the formatting elements that were opened * > in the current body, cell, or caption (whichever is youngest) that haven't * > been explicitly closed. * * @since 6.4.0 * * @throws WP_HTML_Unsupported_Exception When encountering unsupported HTML input. * * @see https://html.spec.whatwg.org/#reconstruct-the-active-formatting-elements * * @return bool Whether any formatting elements needed to be reconstructed. */ private function reconstruct_active_formatting_elements() { /* * > If there are no entries in the list of active formatting elements, then there is nothing * > to reconstruct; stop this algorithm. */ if ( 0 === $this->state->active_formatting_elements->count() ) { return false; } $last_entry = $this->state->active_formatting_elements->current_node(); if ( /* * > If the last (most recently added) entry in the list of active formatting elements is a marker; * > stop this algorithm. */ 'marker' === $last_entry->node_name || /* * > If the last (most recently added) entry in the list of active formatting elements is an * > element that is in the stack of open elements, then there is nothing to reconstruct; * > stop this algorithm. */ $this->state->stack_of_open_elements->contains_node( $last_entry ) ) { return false; } $this->last_error = self::ERROR_UNSUPPORTED; throw new WP_HTML_Unsupported_Exception( 'Cannot reconstruct active formatting elements when advancing and rewinding is required.' ); } /** * Runs the adoption agency algorithm. * * @since 6.4.0 * * @throws WP_HTML_Unsupported_Exception When encountering unsupported HTML input. * * @see https://html.spec.whatwg.org/#adoption-agency-algorithm */ private function run_adoption_agency_algorithm() { $budget = 1000; $subject = $this->get_tag(); $current_node = $this->state->stack_of_open_elements->current_node(); if ( // > If the current node is an HTML element whose tag name is subject $current_node && $subject === $current_node->node_name && // > the current node is not in the list of active formatting elements ! $this->state->active_formatting_elements->contains_node( $current_node ) ) { $this->state->stack_of_open_elements->pop(); return; } $outer_loop_counter = 0; while ( $budget-- > 0 ) { if ( $outer_loop_counter++ >= 8 ) { return; } /* * > Let formatting element be the last element in the list of active formatting elements that: * > - is between the end of the list and the last marker in the list, * > if any, or the start of the list otherwise, * > - and has the tag name subject. */ $formatting_element = null; foreach ( $this->state->active_formatting_elements->walk_up() as $item ) { if ( 'marker' === $item->node_name ) { break; } if ( $subject === $item->node_name ) { $formatting_element = $item; break; } } // > If there is no such element, then return and instead act as described in the "any other end tag" entry above. if ( null === $formatting_element ) { $this->last_error = self::ERROR_UNSUPPORTED; throw new WP_HTML_Unsupported_Exception( 'Cannot run adoption agency when "any other end tag" is required.' ); } // > If formatting element is not in the stack of open elements, then this is a parse error; remove the element from the list, and return. if ( ! $this->state->stack_of_open_elements->contains_node( $formatting_element ) ) { $this->state->active_formatting_elements->remove_node( $formatting_element ); return; } // > If formatting element is in the stack of open elements, but the element is not in scope, then this is a parse error; return. if ( ! $this->state->stack_of_open_elements->has_element_in_scope( $formatting_element->node_name ) ) { return; } /* * > Let furthest block be the topmost node in the stack of open elements that is lower in the stack * > than formatting element, and is an element in the special category. There might not be one. */ $is_above_formatting_element = true; $furthest_block = null; foreach ( $this->state->stack_of_open_elements->walk_down() as $item ) { if ( $is_above_formatting_element && $formatting_element->bookmark_name !== $item->bookmark_name ) { continue; } if ( $is_above_formatting_element ) { $is_above_formatting_element = false; continue; } if ( self::is_special( $item->node_name ) ) { $furthest_block = $item; break; } } /* * > If there is no furthest block, then the UA must first pop all the nodes from the bottom of the * > stack of open elements, from the current node up to and including formatting element, then * > remove formatting element from the list of active formatting elements, and finally return. */ if ( null === $furthest_block ) { foreach ( $this->state->stack_of_open_elements->walk_up() as $item ) { $this->state->stack_of_open_elements->pop(); if ( $formatting_element->bookmark_name === $item->bookmark_name ) { $this->state->active_formatting_elements->remove_node( $formatting_element ); return; } } } $this->last_error = self::ERROR_UNSUPPORTED; throw new WP_HTML_Unsupported_Exception( 'Cannot extract common ancestor in adoption agency algorithm.' ); } $this->last_error = self::ERROR_UNSUPPORTED; throw new WP_HTML_Unsupported_Exception( 'Cannot run adoption agency when looping required.' ); } /** * Inserts an HTML element on the stack of open elements. * * @since 6.4.0 * * @see https://html.spec.whatwg.org/#insert-a-foreign-element * * @param WP_HTML_Token $token Name of bookmark pointing to element in original input HTML. */ private function insert_html_element( $token ) { $this->state->stack_of_open_elements->push( $token ); } /* * HTML Specification Helpers */ /** * Returns whether an element of a given name is in the HTML special category. * * @since 6.4.0 * * @see https://html.spec.whatwg.org/#special * * @param string $tag_name Name of element to check. * @return bool Whether the element of the given name is in the special category. */ public static function is_special( $tag_name ) { $tag_name = strtoupper( $tag_name ); return ( 'ADDRESS' === $tag_name || 'APPLET' === $tag_name || 'AREA' === $tag_name || 'ARTICLE' === $tag_name || 'ASIDE' === $tag_name || 'BASE' === $tag_name || 'BASEFONT' === $tag_name || 'BGSOUND' === $tag_name || 'BLOCKQUOTE' === $tag_name || 'BODY' === $tag_name || 'BR' === $tag_name || 'BUTTON' === $tag_name || 'CAPTION' === $tag_name || 'CENTER' === $tag_name || 'COL' === $tag_name || 'COLGROUP' === $tag_name || 'DD' === $tag_name || 'DETAILS' === $tag_name || 'DIR' === $tag_name || 'DIV' === $tag_name || 'DL' === $tag_name || 'DT' === $tag_name || 'EMBED' === $tag_name || 'FIELDSET' === $tag_name || 'FIGCAPTION' === $tag_name || 'FIGURE' === $tag_name || 'FOOTER' === $tag_name || 'FORM' === $tag_name || 'FRAME' === $tag_name || 'FRAMESET' === $tag_name || 'H1' === $tag_name || 'H2' === $tag_name || 'H3' === $tag_name || 'H4' === $tag_name || 'H5' === $tag_name || 'H6' === $tag_name || 'HEAD' === $tag_name || 'HEADER' === $tag_name || 'HGROUP' === $tag_name || 'HR' === $tag_name || 'HTML' === $tag_name || 'IFRAME' === $tag_name || 'IMG' === $tag_name || 'INPUT' === $tag_name || 'KEYGEN' === $tag_name || 'LI' === $tag_name || 'LINK' === $tag_name || 'LISTING' === $tag_name || 'MAIN' === $tag_name || 'MARQUEE' === $tag_name || 'MENU' === $tag_name || 'META' === $tag_name || 'NAV' === $tag_name || 'NOEMBED' === $tag_name || 'NOFRAMES' === $tag_name || 'NOSCRIPT' === $tag_name || 'OBJECT' === $tag_name || 'OL' === $tag_name || 'P' === $tag_name || 'PARAM' === $tag_name || 'PLAINTEXT' === $tag_name || 'PRE' === $tag_name || 'SCRIPT' === $tag_name || 'SEARCH' === $tag_name || 'SECTION' === $tag_name || 'SELECT' === $tag_name || 'SOURCE' === $tag_name || 'STYLE' === $tag_name || 'SUMMARY' === $tag_name || 'TABLE' === $tag_name || 'TBODY' === $tag_name || 'TD' === $tag_name || 'TEMPLATE' === $tag_name || 'TEXTAREA' === $tag_name || 'TFOOT' === $tag_name || 'TH' === $tag_name || 'THEAD' === $tag_name || 'TITLE' === $tag_name || 'TR' === $tag_name || 'TRACK' === $tag_name || 'UL' === $tag_name || 'WBR' === $tag_name || 'XMP' === $tag_name || // MathML. 'MI' === $tag_name || 'MO' === $tag_name || 'MN' === $tag_name || 'MS' === $tag_name || 'MTEXT' === $tag_name || 'ANNOTATION-XML' === $tag_name || // SVG. 'FOREIGNOBJECT' === $tag_name || 'DESC' === $tag_name || 'TITLE' === $tag_name ); } /** * Returns whether a given element is an HTML Void Element * * > area, base, br, col, embed, hr, img, input, link, meta, source, track, wbr * * @since 6.4.0 * * @see https://html.spec.whatwg.org/#void-elements * * @param string $tag_name Name of HTML tag to check. * @return bool Whether the given tag is an HTML Void Element. */ public static function is_void( $tag_name ) { $tag_name = strtoupper( $tag_name ); return ( 'AREA' === $tag_name || 'BASE' === $tag_name || 'BASEFONT' === $tag_name || // Obsolete but still treated as void. 'BGSOUND' === $tag_name || // Obsolete but still treated as void. 'BR' === $tag_name || 'COL' === $tag_name || 'EMBED' === $tag_name || 'FRAME' === $tag_name || 'HR' === $tag_name || 'IMG' === $tag_name || 'INPUT' === $tag_name || 'KEYGEN' === $tag_name || // Obsolete but still treated as void. 'LINK' === $tag_name || 'META' === $tag_name || 'PARAM' === $tag_name || // Obsolete but still treated as void. 'SOURCE' === $tag_name || 'TRACK' === $tag_name || 'WBR' === $tag_name ); } /* * Constants that would pollute the top of the class if they were found there. */ /** * Indicates that the next HTML token should be parsed and processed. * * @since 6.4.0 * * @var string */ const PROCESS_NEXT_NODE = 'process-next-node'; /** * Indicates that the current HTML token should be reprocessed in the newly-selected insertion mode. * * @since 6.4.0 * * @var string */ const REPROCESS_CURRENT_NODE = 'reprocess-current-node'; /** * Indicates that the current HTML token should be processed without advancing the parser. * * @since 6.5.0 * * @var string */ const PROCESS_CURRENT_NODE = 'process-current-node'; /** * Indicates that the parser encountered unsupported markup and has bailed. * * @since 6.4.0 * * @var string */ const ERROR_UNSUPPORTED = 'unsupported'; /** * Indicates that the parser encountered more HTML tokens than it * was able to process and has bailed. * * @since 6.4.0 * * @var string */ const ERROR_EXCEEDED_MAX_BOOKMARKS = 'exceeded-max-bookmarks'; /** * Unlock code that must be passed into the constructor to create this class. * * This class extends the WP_HTML_Tag_Processor, which has a public class * constructor. Therefore, it's not possible to have a private constructor here. * * This unlock code is used to ensure that anyone calling the constructor is * doing so with a full understanding that it's intended to be a private API. * * @access private */ const CONSTRUCTOR_UNLOCK_CODE = 'Use WP_HTML_Processor::create_fragment() instead of calling the class constructor directly.'; } ng $url, bool $enabled = true ): bool { $url = $this->prepare_url_for_upsert( $url ); $existing_path = $this->get_by_url( $url ); // No need to go any further if the URL is already listed and nothing has changed. if ( $existing_path && $existing_path->get_url() === $url && $enabled === $existing_path->is_enabled() ) { return true; } global $wpdb; $fields = array( 'url' => $url, 'enabled' => (int) $enabled, ); if ( false === $wpdb->update( $this->get_table(), $fields, array( 'url_id' => $id ) ) ) { throw new ApprovedDirectoriesException( __( 'URL could not be updated (probable database error).', 'woocommerce' ), ApprovedDirectoriesException::DB_ERROR ); } return true; } /** * Indicates if the specified URL is already an approved directory. * * @param string $url The URL to check. * * @return bool */ public function approved_directory_exists( string $url ): bool { return (bool) $this->get_by_url( $url ); } /** * Returns the path identified by $id, or false if it does not exist. * * @param int $id The ID of the rule we are looking for. * * @return StoredUrl|false */ public function get_by_id( int $id ) { global $wpdb; $table = $this->get_table(); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared $result = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$table} WHERE url_id = %d", array( $id ) ) ); if ( ! $result ) { return false; } return new StoredUrl( $result->url_id, $result->url, $result->enabled ); } /** * Returns the path identified by $url, or false if it does not exist. * * @param string $url The URL of the rule we are looking for. * * @return StoredUrl|false */ public function get_by_url( string $url ) { global $wpdb; $table = $this->get_table(); $url = trailingslashit( $url ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared $result = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$table} WHERE url = %s", array( $url ) ) ); if ( ! $result ) { return false; } return new StoredUrl( $result->url_id, $result->url, $result->enabled ); } /** * Indicates if the URL is within an approved directory. The approved directory must be enabled * (it is possible for individual approved directories to be disabled). * * For instance, for 'https://storage.king/12345/ebook.pdf' to be valid then 'https://storage.king/12345' * would need to be within our register. * * If the provided URL is a filepath it can be passed in without the 'file://' scheme. * * @throws URLException If the provided URL is badly formed. * * @param string $download_url The URL to check. * * @return bool */ public function is_valid_path( string $download_url ): bool { global $wpdb; $parent_directories = array(); foreach ( ( new URL( $this->normalize_url( $download_url ) ) )->get_all_parent_urls() as $parent ) { $parent_directories[] = "'" . esc_sql( $parent ) . "'"; } if ( empty( $parent_directories ) ) { return false; } $parent_directories = join( ',', $parent_directories ); $table = $this->get_table(); // Look for a rule that matches the start of the download URL being tested. Since rules describe parent // directories, we also ensure it ends with a trailing slash. // // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared $matches = (int) $wpdb->get_var( " SELECT COUNT(*) FROM {$table} WHERE enabled = 1 AND url IN ( {$parent_directories} ) " ); // phpcs:enable return $matches > 0; } /** * Used when a URL string is prepared before potentially adding it to the database. * * It will be normalized and trailing-slashed; a length check will also be performed. * * @throws ApprovedDirectoriesException If the operation could not be performed. * @throws URLException If the URL was invalid. * * @param string $url The string URL to be normalized and trailing-slashed. * * @return string */ private function prepare_url_for_upsert( string $url ): string { $url = trailingslashit( $this->normalize_url( $url ) ); if ( mb_strlen( $url ) > 256 ) { throw new ApprovedDirectoriesException( __( 'Approved directory URLs cannot be longer than 256 characters.', 'woocommerce' ), ApprovedDirectoriesException::INVALID_URL ); } return $url; } /** * Normalizes the provided URL, by trimming whitespace per normal PHP conventions * and removing any trailing slashes. If it lacks a scheme, the file scheme is * assumed and prepended. * * @throws URLException If the URL is badly formed. * * @param string $url The URL to be normalized. * * @return string */ private function normalize_url( string $url ): string { $url = untrailingslashit( trim( $url ) ); return ( new URL( $url ) )->get_url(); } /** * Lists currently approved directories. * * Returned array will have the following structure: * * [ * 'total_urls' => 12345, * 'total_pages' => 123, * 'urls' => [], # StoredUrl[] * ] * * @param array $args { * Controls pagination and ordering. * * @type null|bool $enabled Controls if only enabled (true), disabled (false) or all rules (null) should be listed. * @type string $order Ordering ('ASC' for ascending, 'DESC' for descending). * @type string $order_by Field to order by (one of 'url_id' or 'url'). * @type int $page The page of results to retrieve. * @type int $per_page The number of results to retrieve per page. * @type string $search Term to search for. * } * * @return array */ public function list( array $args ): array { global $wpdb; $args = array_merge( array( 'enabled' => null, 'order' => 'ASC', 'order_by' => 'url', 'page' => 1, 'per_page' => 20, 'search' => '', ), $args ); $table = $this->get_table(); $paths = array(); $order = in_array( $args['order'], array( 'ASC', 'DESC' ), true ) ? $args['order'] : 'ASC'; $order_by = in_array( $args['order_by'], array( 'url_id', 'url' ), true ) ? $args['order_by'] : 'url'; $page = absint( $args['page'] ); $per_page = absint( $args['per_page'] ); $enabled = is_bool( $args['enabled'] ) ? $args['enabled'] : null; $search = '%' . $wpdb->esc_like( sanitize_text_field( $args['search'] ) ) . '%'; if ( $page < 1 ) { $page = 1; } if ( $per_page < 1 ) { $per_page = 1; } $where = array(); $where_sql = ''; if ( ! empty( $search ) ) { $where[] = $wpdb->prepare( 'url LIKE %s', $search ); } if ( is_bool( $enabled ) ) { $where[] = 'enabled = ' . (int) $enabled; } if ( ! empty( $where ) ) { $where_sql = 'WHERE ' . join( ' AND ', $where ); } // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared $results = $wpdb->get_results( $wpdb->prepare( " SELECT url_id, url, enabled FROM {$table} {$where_sql} ORDER BY {$order_by} {$order} LIMIT %d, %d ", ( $page - 1 ) * $per_page, $per_page ) ); $total_rows = (int) $wpdb->get_var( "SELECT COUNT( * ) FROM {$table} {$where_sql}" ); // phpcs:enable foreach ( $results as $single_result ) { $paths[] = new StoredUrl( $single_result->url_id, $single_result->url, $single_result->enabled ); } return array( 'total_urls' => $total_rows, 'total_pages' => (int) ceil( $total_rows / $per_page ), 'approved_directories' => $paths, ); } /** * Delete the approved directory identitied by the supplied ID. * * @param int $id The ID of the rule to be deleted. * * @return bool */ public function delete_by_id( int $id ): bool { global $wpdb; $table = $this->get_table(); return (bool) $wpdb->delete( $table, array( 'url_id' => $id ) ); } /** * Delete the entirev approved directory list. * * @return bool */ public function delete_all(): bool { global $wpdb; $table = $this->get_table(); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared return (bool) $wpdb->query( "DELETE FROM $table" ); } /** * Enable the approved directory identitied by the supplied ID. * * @param int $id The ID of the rule to be deleted. * * @return bool */ public function enable_by_id( int $id ): bool { global $wpdb; $table = $this->get_table(); return (bool) $wpdb->update( $table, array( 'enabled' => 1 ), array( 'url_id' => $id ) ); } /** * Disable the approved directory identitied by the supplied ID. * * @param int $id The ID of the rule to be deleted. * * @return bool */ public function disable_by_id( int $id ): bool { global $wpdb; $table = $this->get_table(); return (bool) $wpdb->update( $table, array( 'enabled' => 0 ), array( 'url_id' => $id ) ); } /** * Enables all Approved Download Directory rules in a single operation. * * @return bool */ public function enable_all(): bool { global $wpdb; $table = $this->get_table(); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared return (bool) $wpdb->query( "UPDATE {$table} SET enabled = 1" ); } /** * Disables all Approved Download Directory rules in a single operation. * * @return bool */ public function disable_all(): bool { global $wpdb; $table = $this->get_table(); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared return (bool) $wpdb->query( "UPDATE {$table} SET enabled = 0" ); } /** * Indicates the number of approved directories that are enabled (or disabled, if optional * param $enabled is set to false). * * @param bool $enabled Controls whether enabled or disabled directory rules are counted. * * @return int */ public function count( bool $enabled = true ): int { global $wpdb; $table = $this->get_table(); return (int) $wpdb->get_var( $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared "SELECT COUNT(*) FROM {$table} WHERE enabled = %d", $enabled ? 1 : 0 ) ); } }