diff --git a/src/wp-admin/includes/template.php b/src/wp-admin/includes/template.php index e667400f3322f..1d4a8e8da1f5c 100644 --- a/src/wp-admin/includes/template.php +++ b/src/wp-admin/includes/template.php @@ -2540,20 +2540,20 @@ function compression_test() { * * @see get_submit_button() * - * @param string $text Optional. The text of the button. Defaults to 'Save Changes'. + * @param string $text The text of the button (defaults to 'Save Changes') * @param string $type Optional. The type and CSS class(es) of the button. Core values * include 'primary', 'small', and 'large'. Default 'primary'. - * @param string $name Optional. The HTML name of the submit button. If no `id` attribute - * is given in the `$other_attributes` parameter, `$name` will be used - * as the button's `id`. Default 'submit'. - * @param bool $wrap Optional. True if the output button should be wrapped in a paragraph tag, - * false otherwise. Default true. - * @param array|string $other_attributes Optional. Other attributes that should be output with the button, - * mapping attributes to their values, e.g. `array( 'id' => 'search-submit' )`. - * These key/value attribute pairs will be output as `attribute="value"`, - * where attribute is the key. Attributes can also be provided as a string, - * e.g. `id="search-submit"`, though the array format is generally preferred. - * Default null. + * @param string $name The HTML name of the submit button. Defaults to "submit". If no + * id attribute is given in $other_attributes below, $name will be + * used as the button's id. + * @param bool $wrap True if the output button should be wrapped in a paragraph tag, + * false otherwise. Defaults to true. + * @param array|string $other_attributes Other attributes that should be output with the button, mapping + * attributes to their values, such as setting tabindex to 1, etc. + * These key/value attribute pairs will be output as attribute="value", + * where attribute is the key. Other attributes can also be provided + * as a string such as 'tabindex="1"', though the array format is + * preferred. Default null. */ function submit_button( $text = null, $type = 'primary', $name = 'submit', $wrap = true, $other_attributes = null ) { echo get_submit_button( $text, $type, $name, $wrap, $other_attributes ); @@ -2564,20 +2564,20 @@ function submit_button( $text = null, $type = 'primary', $name = 'submit', $wrap * * @since 3.1.0 * - * @param string $text Optional. The text of the button. Defaults to 'Save Changes'. + * @param string $text Optional. The text of the button. Default 'Save Changes'. * @param string $type Optional. The type and CSS class(es) of the button. Core values * include 'primary', 'small', and 'large'. Default 'primary large'. - * @param string $name Optional. The HTML name of the submit button. If no `id` attribute - * is given in the `$other_attributes` parameter, `$name` will be used - * as the button's `id`. Default 'submit'. - * @param bool $wrap Optional. True if the output button should be wrapped in a paragraph tag, - * false otherwise. Default true. + * @param string $name Optional. The HTML name of the submit button. Defaults to "submit". + * If no id attribute is given in $other_attributes below, `$name` will + * be used as the button's id. Default 'submit'. + * @param bool $wrap Optional. True if the output button should be wrapped in a paragraph + * tag, false otherwise. Default true. * @param array|string $other_attributes Optional. Other attributes that should be output with the button, - * mapping attributes to their values, e.g. `array( 'id' => 'search-submit' )`. - * These key/value attribute pairs will be output as `attribute="value"`, - * where attribute is the key. Attributes can also be provided as a string, - * e.g. `id="search-submit"`, though the array format is generally preferred. - * Default empty string. + * mapping attributes to their values, such as `array( 'tabindex' => '1' )`. + * These attributes will be output as `attribute="value"`, such as + * `tabindex="1"`. Other attributes can also be provided as a string such + * as `tabindex="1"`, though the array format is typically cleaner. + * Default empty. * @return string Submit button HTML. */ function get_submit_button( $text = '', $type = 'primary large', $name = 'submit', $wrap = true, $other_attributes = '' ) { diff --git a/src/wp-includes/html-api/class-wp-html-open-elements.php b/src/wp-includes/html-api/class-wp-html-open-elements.php index fe5625545b0ac..4fb1cc2e8de40 100644 --- a/src/wp-includes/html-api/class-wp-html-open-elements.php +++ b/src/wp-includes/html-api/class-wp-html-open-elements.php @@ -37,6 +37,17 @@ class WP_HTML_Open_Elements { */ public $stack = array(); + /** + * Holds functions added to be called after popping an element off the stack. + * + * Listeners are passed the WP_HTML_Token for the item that was removed. + * + * @since 6.4.0 + * + * @var array + */ + private $after_pop_listeners = array(); + /** * Whether a P element is in button scope currently. * @@ -428,5 +439,39 @@ public function after_element_pop( $item ) { $this->has_p_in_button_scope = $this->has_element_in_button_scope( 'P' ); break; } + + // Call any listeners that are registered. + foreach ( $this->after_pop_listeners as $listener ) { + call_user_func( $listener, $item ); + } + } + + /** + * Creates a context in which a given listener is called after + * popping an element off of the stack of open elements. + * + * It's unlikely that you will need this function. It exists + * to aid an optimization in the `WP_HTML_Processor` and the + * strange form of calling a generator inside a `foreach` + * loop ensures that proper cleanup of the listener occurs. + * + * Example: + * + * $did_close = false; + * $closed_a_p = function ( $item ) use ( &$did_close ) { $did_close = 'P' === $item->node_name; }; + * foreach ( $stack_of_open_elements->with_pop_listener( $closed_a_p ) ) { + * while ( ! $did_close && $processor->next_tag() ) { + * // This loop executes until _any_ P element is closed. + * } + * } + * + * @since 6.4.0 + * + * @param callable $listener Called with the WP_HTML_Token for the item that was popped off of the stack. + */ + public function with_pop_listener( $listener ) { + $this->after_pop_listeners[] = $listener; + yield; + array_pop( $this->after_pop_listeners ); } } diff --git a/src/wp-includes/html-api/class-wp-html-processor.php b/src/wp-includes/html-api/class-wp-html-processor.php index b8e1093054726..cf409f63a7565 100644 --- a/src/wp-includes/html-api/class-wp-html-processor.php +++ b/src/wp-includes/html-api/class-wp-html-processor.php @@ -21,6 +21,7 @@ * and useful for the following operations: * * - Querying based on nested HTML structure. + * - Reading and writing the raw HTML markup inside or around a tag. * * Eventually the HTML Processor will also support: * - Wrapping a tag in surrounding HTML. @@ -473,11 +474,350 @@ public function matches_breadcrumbs( $breadcrumbs ) { return false; } + /** + * Returns the raw HTML markup inside a matched tag. + * + * "Markup" differs from inner HTML in that it returns the raw HTML inside the matched tag. + * This means that it's possible this returns HTML without matching tags, or with HTML attributes + * serialized differently than a DOM API would return, or with non-decoded character references. + * + * Note that when called on void or self-closing foreign elements this will always return an + * empty string. This is to maintain correspondance with the DOM API. + * + * Example: + * + * $processor = WP_HTML_Processor::createFragment( '

→ Inside P tags

' ); + * $processor->next_tag( 'P' ); + * $html = $procesor->get_raw_inner_markup(); + * $html === '→ Inside P tags'; + * + * @since 6.4.0 + * + * @throws Exception When unable to allocate a bookmark for internal tracking of the open tag. + * + * @return string|null The raw inner markup if available, else NULL. + */ + public function get_raw_inner_markup() { + if ( null === $this->get_tag() ) { + return null; + } + + $this->set_bookmark( 'opener' ); + $found_tag = $this->step_until_tag_is_closed(); + $this->set_bookmark( 'closer' ); + + if ( $found_tag ) { + $inner_markup = $this->substr_bookmarks( 'after', 'opener', 'before', 'closer' ); + } else { + // If there's no closing tag then the inner markup continues to the end of the document. + $inner_markup = $this->substr_bookmark( 'after', 'opener' ); + } + + $this->seek( 'opener' ); + $this->release_bookmark( 'opener' ); + $this->release_bookmark( 'closer' ); + + return $inner_markup; + } + + /** + * Returns the raw HTML markup around a matched tag, including the tag itself. + * + * "Markup" differs from outer HTML in that it returns the raw HTML around the matched tag. + * This means that it's possible this returns HTML without matching tags, or with HTML attributes + * serialized differently than a DOM API would return, or with non-decoded character references. + * + * Example: + * + * $processor = WP_HTML_Processor::createFragment( '

→ Inside P tags

' ); + * $processor->next_tag( 'P' ); + * $html = $processor->get_raw_outer_markup(); + * $html === '

→ Inside P tags'; + * + * @throws Exception When unable to allocate a bookmark for internal tracking of the open tag. + * + * @since 6.4.0 + * + * @return string|null The raw outer markup if available, else NULL. + */ + public function get_raw_outer_markup() { + if ( null === $this->get_tag() ) { + return null; + } + + $this->set_bookmark( 'opener' ); + $start_tag = $this->current_token->node_name; + $found_tag = $this->step_until_tag_is_closed(); + $this->set_bookmark( 'closer' ); + + if ( $found_tag ) { + $did_close = $this->get_tag() === $start_tag && $this->is_tag_closer(); + $end_position = $did_close ? 'after' : 'before'; + $outer_markup = $this->substr_bookmarks( 'before', 'opener', $end_position, 'closer' ); + } else { + // If there's no closing tag then the outer markup continues to the end of the document. + $outer_markup = $this->substr_bookmark( 'before', 'opener' ); + } + + $this->seek( 'opener' ); + $this->release_bookmark( 'opener' ); + $this->release_bookmark( 'closer' ); + + return $outer_markup; + } + + /** + * Replaces the raw HTML of the currently-matched tag's inner markup with new HTML. + * This replaces the content between the tag opener and tag closer. + * + * Example: + * + * // Set inner text. + * $processor = WP_HTML_Processor::createFragment( '

Carrots are .

' ); + * if ( $processor->next_tag( 'EM' ) ) { + * $processor->set_raw_inner_markup( 'orange' ); + * } + * $html = $processor->get_updated_html(); + * $html === '

Carrots are orange.

'; + * + * // Set inner content with a tag. + * $processor = WP_HTML_Processor::createFragment( '

Carrots are .

' ); + * if ( $processor->next_tag( 'EM' ) ) { + * $processor->set_raw_inner_markup( 'orange colored' ); + * } + * $html = $processor->get_updated_html(); + * $html === '

Carrots are orange colored.

'; + * + * Warning! Calling this function directly can lead to malformed HTML output! + * This function operates on raw HTML and performs no escaping or semantic verification. This + * is left to the caller to ensure that any update doesn't break or change the rest of the HTML. + * + * One could imagine including a closing tag for the element whose raw inner markup is being set + * and this closing tag would close the open element and abandon what was previously its children. + * + * Example: + * + * // Warning! This would close the first DIV and create a new one. + * $processor = WP_HTML_Processor::createFragment( '
One giant squirrel.
' ); + * if ( $processor->next_tag( 'SPAN' ) ) { + * $processor->set_raw_inner_markup( ' hehehe' ); + * } + * $html = $processor->get_updated_html(); + * $html === '
One
hehehe squirrel.'; + * + * It's obvious here that the closing DIV tag will end the first DIV element. The + * rest of the markup looks like plain text and some unexpected closing tags. This + * HTML represents the DOM on the right in the following diagram. The "squirrel" + * text segment was a child of the DIV before the change but has become a sibling + * after setting the raw inner markup. + * + * This is not produced. This is produced in the browser. + * BODY BODY + * └─ DIV ├─ DIV + * ├─ #text "One " │ └─ #text "One " + * ├─ SPAN │ └─ SPAN (empty) + * │ └─ #text " hehehe" └─ #text " hehehe squirrel." + * └─ #text " squirrel." + * + * This applies beyond basic markup errors because of the way the semantic rules of HTML + * apply to specific elements. + * + * Example: + * + * // Warning! This would close the first paragraph and create a new one. + * $processor = WP_HTML_Processor::createFragment( '

Carrots are .

' ); + * if ( $processor->next_tag( 'EM' ) ) { + * $processor->set_raw_inner_markup( '

healthy snacks

' ); + * } + * $html = $processor->get_updated_html(); + * $html === '

Carrots are

healthy snacks

.

'; + * + * It may look like the P element is inside the EM element, producing a DOM structure as on + * the left of the following diagram, but it will in fact produce the one on the right. This + * means that updating raw inner markup can "bleed" into the outer elements in the HTML + * document and traversing the tags after setting raw inner markup may be different from + * how it was before making the change. + * + * This is not produced. This is produced in the browser. + * BODY BODY + * └─ P ├─ P + * ├─ SPAN │ ├─ SPAN + * │ └─ #text "Carrots" │ │ └─ #text "Carrots" + * ├─ #text " are " │ ├─ #text " are " + * ├─ EM │ └─ EM (empty) + * │ └─ P ├─ P + * │ └─ #text "healthy snacks" │ └─ EM + * └─ #text "." │ └─ #text "healthy snacks" + * └─ #text "." + * + * @throws Exception When unable to set bookmark for internal tracking. + * + * @since 6.4.0 + * + * @param string $raw_markup Already-escaped and verified HTML to set inside the currently-open tag. + * @return bool|null Whether the contents were updated. + */ + public function set_raw_inner_markup( $raw_markup ) { + if ( null === $this->get_tag() ) { + return null; + } + + $this->set_bookmark( 'opener' ); + $start_tag = $this->current_token->node_name; + + if ( self::is_void( $start_tag ) ) { + $this->release_bookmark( 'opener' ); + return true; + } + + $found_tag = $this->step_until_tag_is_closed(); + $this->set_bookmark( 'closer' ); + + if ( $found_tag ) { + $this->replace_using_bookmarks( $raw_markup, 'after', 'opener', 'before', 'closer' ); + } else { + // If there's no closing tag then the inner markup continues to the end of the document. + $this->replace_using_bookmark( $raw_markup, 'after', 'opener' ); + } + + $this->seek( 'opener' ); + $this->release_bookmark( 'opener' ); + $this->release_bookmark( 'closer' ); + return true; + } + + /** + * Replaces the raw HTML of the currently-matched tag with new HTML. + * This replaces the entire contents of the tag including the tag itself. + * + * Example: + * + * + * // Replace tag and all its children with text. + * $processor = WP_HTML_Processor::createFragment( '

Carrots are good for the eyes.

' ); + * if ( $processor->next_tag( 'EM' ) ) { + * $processor->set_raw_outer_markup( 'orange' ); + * } + * $html = $processor->get_updated_html(); + * $html === '

Carrots are orange.

'; + * + * // Set outer content with a tag. + * $processor = WP_HTML_Processor::createFragment( '

Carrots are .

' ); + * if ( $processor->next_tag( 'EM' ) ) { + * $processor->set_raw_outer_markup( 'orange colored' ); + * } + * $html = $processor->get_updated_html(); + * $html === '

Carrots are orange colored.

'; + * + * Warning! Calling this function directly can lead to malformed HTML output! + * This function operates on raw HTML and performs no escaping or semantic verification. This + * is left to the caller to ensure that any update doesn't break or change the rest of the HTML. + * + * One could imagine including a closing tag that closes the parent tag for the element whose + * raw outer markup is being set and this closing tag would close the open parent element and + * abandon what was previously its children. + * + * Example: + * + * // Warning! This would close the first DIV and create a new one. + * $processor = WP_HTML_Processor::createFragment( '
One giant squirrel.
' ); + * if ( $processor->next_tag( 'SPAN' ) ) { + * $processor->set_raw_outer_markup( ' hehehe' ); + * } + * $html = $processor->get_updated_html(); + * $html === '
One
hehehe squirrel.'; + * + * It's obvious here that the closing DIV tag will end the first DIV element. The + * rest of the markup looks like plain text and an unexpected closing DIV tag. This + * HTML represents the DOM on the right in the following diagram. The "squirrel" + * text segment was a child of the DIV before the change but has become a sibling + * after setting the raw outer markup. + * + * This is not produced. This is produced in the browser. + * BODY BODY + * └─ DIV ├─ DIV + * └─ #text "One hehehe squirrel." │ └─ #text "One " + * └─ #text " hehehe squirrel." + * + * This applies beyond basic markup errors because of the way the semantic rules of HTML + * apply to specific elements. + * + * Example: + * + * // Warning! This would close the first paragraph and create a new one. + * $processor = WP_HTML_Processor::createFragment( '

Carrots are .

' ); + * if ( $processor->next_tag( 'EM' ) ) { + * $processor->set_raw_outer_markup( '

healthy snacks

' ); + * } + * $html = $processor->get_updated_html(); + * $html === '

Carrots are

healthy snacks

.

'; + * + * It may look like the P element is inside the first P element, producing a DOM structure as + * on the left of the following diagram, but it will in fact produce the one on the right. + * This means that updating raw outer markup can "bleed" into the outer elements in the HTML + * document and traversing the tags after setting raw outer markup may be different from how + * it was before making the change. + * + * This is not produced. This is produced in the browser. + * BODY BODY + * └─ P ├─ P + * ├─ SPAN │ ├─ SPAN + * │ └─ #text "Carrots" │ │ └─ #text "Carrots" + * ├─ #text " are " │ └─ #text " are " + * ├─ P ├─ P + * │ └─ #text "healthy snacks" │ └─ #text "healthy snacks" + * └─ #text "." └─ #text "." + * + * @throws Exception When unable to set bookmark for internal tracking. + * + * @since 6.4.0 + * + * @param string $raw_markup Already-escaped and verified HTML to set around the currently-open tag. + * @return bool|null Whether the contents were updated. + */ + public function set_raw_outer_markup( $raw_markup ) { + if ( null === $this->get_tag() ) { + return null; + } + + $this->set_bookmark( 'opener' ); + $start_tag = $this->current_token->node_name; + + if ( self::is_void( $start_tag ) ) { + $this->replace_using_bookmarks( $raw_markup, 'before', 'opener', 'after', 'opener' ); + $this->release_bookmark( 'opener' ); + return true; + } + + $found_tag = $this->step_until_tag_is_closed(); + $this->set_bookmark( 'closer' ); + + if ( $found_tag ) { + $did_close = $this->get_tag() === $start_tag && $this->is_tag_closer(); + $end_position = $did_close ? 'after' : 'before'; + $this->replace_using_bookmarks( $raw_markup, 'before', 'opener', $end_position, 'closer' ); + } else { + // If there's no closing tag then the outer markup continues to the end of the document. + $this->replace_using_bookmark( $raw_markup, 'before', 'opener' ); + } + + $this->seek( 'opener' ); + $this->release_bookmark( 'opener' ); + $this->release_bookmark( 'closer' ); + return true; + } + /** * Steps through the HTML document and stop at the next tag, if any. * + * It is unlikely you will want to use this function. It is public + * for special circumstances, mostly for testing, and instead it + * would probably be better to call WP_HTML_Processor::next_tag(). + * * @since 6.4.0 * + * @access private + * * @throws Exception When unable to allocate a bookmark for the next token in the input HTML document. * * @see self::PROCESS_NEXT_NODE @@ -502,7 +842,7 @@ public function step( $node_to_process = self::PROCESS_NEXT_NODE ) { * When moving on to the next node, therefore, if the bottom-most element * on the stack is a void element, it must be closed. * - * @TODO: Once self-closing foreign elements and BGSOUND are supported, + * @todo: Once self-closing foreign elements and BGSOUND are supported, * they must also be implicitly closed here too. BGSOUND is * special since it's only self-closing if the self-closing flag * is provided in the opening tag, otherwise it expects a tag closer. @@ -512,12 +852,9 @@ public function step( $node_to_process = self::PROCESS_NEXT_NODE ) { $this->state->stack_of_open_elements->pop(); } - parent::next_tag( self::VISIT_EVERYTHING ); - } - - // Finish stepping when there are no more tokens in the document. - if ( null === $this->get_tag() ) { - return false; + if ( ! parent::next_tag( self::VISIT_EVERYTHING ) ) { + return false; + } } $this->state->current_token = new WP_HTML_Token( @@ -555,7 +892,7 @@ public function step( $node_to_process = self::PROCESS_NEXT_NODE ) { * to avoid creating the array copy on tag iteration. If this is done, it would likely * be more useful to walk up the stack when yielding instead of starting at the top. * - * Example + * Example: * * $processor = WP_HTML_Processor::create_fragment( '

' ); * $processor->next_tag( 'IMG' ); @@ -800,6 +1137,152 @@ private function bookmark_tag() { return "{$this->bookmark_counter}"; } + /** + * Steps through the HTML document until the current open tag is closed. + * + * Tags don't always close with a closing tag. In many situations an open element + * will be closed implicitly by some semantic rule. For example: void elements are + * implicitly closed immediately after they are opened; an opening P tag inside an + * already-opened P element will close the previous P element and insert a new one. + * It's because of these complicated rules that this function exists. + * + * @since 6.4.0 + * + * @throws Exception When unable to allocate bookmark for internal tracking. + * + * @return bool|null true if a closing tag was found, false if not, and null if not starting at a matched tag. + */ + private function step_until_tag_is_closed() { + if ( null === $this->get_tag() ) { + return null; + } + + /** @var WP_HTML_Token $start Reference to the opening tag at the time this function was called. */ + $start = $this->current_token; + + /** @var bool $keep_searching Whether to continue scanning for a point where the opening tag is closed. */ + $keep_searching = true; + + /** + * Sets a flag indicating that the starting tag has been closed once + * it's popped from the stack of open elements. This is a listener function. + * + * This function optimizes checking if the original opening element is still + * on the stack of open elements. That element will no longer be open once + * it is popped from the stack of open elements. + * + * @since 6.4.0 + * + * @see WP_HTML_Open_Elements::with_pop_listener() + * + * @param WP_HTML_Token $node Node that was popped. + */ + $tag_is_closed = function ( $node ) use ( &$keep_searching, $start ) { + if ( $node === $start ) { + $keep_searching = false; + } + }; + + /* + * Normally, when stepping into each new elements, it would be required to walk up the + * stack of open elements and look to see if the starting tag is still open, if it's + * on the stack. By listening for elements that are popped from the stack, however, it's + * possible to know if the starting tag has been closed without anything more than a + * constant boolean access, as the listener is called for each tag that's closed. + * + * The use of the `foreach` here creates a context which ensures that the listener is + * properly removed and cleaned up without having to manually remove it. + */ + foreach ( $this->state->stack_of_open_elements->with_pop_listener( $tag_is_closed ) as $_ ) { + + /* + * Find where the tag is closed by stepping forward until it's no longer + * on the stack of open elements. Note that the listener above will + * modify `$keep_searching` even though it looks from here like it + * never changes. + */ + do { + $found_tag = $this->step(); + } while ( $found_tag && $keep_searching ); + } + + return $found_tag; + } + + /** + * Replaces content in the HTML document from a bookmark to the end of the document. + * + * @since 6.4.0 + * + * @param string $html New HTML to insert into document. + * @param string $start_position "before" to clip before bookmark, "after" to clip after. + * @param string $start_bookmark_name Bookmark name at which to start clipping. + */ + private function replace_using_bookmark( $html, $start_position, $start_bookmark_name ) { + $start_bookmark = $this->bookmarks[ "_{$start_bookmark_name}" ]; + $start_offset = 'before' === $start_position ? $start_bookmark->start : $start_bookmark->end + 1; + + $this->lexical_updates[] = new WP_HTML_Text_Replacement( $start_offset, strlen( $this->html ), $html ); + $this->apply_attributes_updates(); + } + + /** + * Replaces content in the HTML document from one bookmark to another. + * + * @since 6.4.0 + * + * @param string $html New HTML to insert into document. + * @param string $start_position "before" to clip before bookmark, "after" to clip after. + * @param string $start_bookmark_name Bookmark name at which to start clipping. + * @param string $end_position "before" to clip before bookmark, "after" to clip after. + * @param string $end_bookmark_name Bookmark name at which to end clipping. + */ + private function replace_using_bookmarks( $html, $start_position, $start_bookmark_name, $end_position, $end_bookmark_name ) { + $start_bookmark = $this->bookmarks[ "_{$start_bookmark_name}" ]; + $end_bookmark = $this->bookmarks[ "_{$end_bookmark_name}" ]; + $start_offset = 'before' === $start_position ? $start_bookmark->start : $start_bookmark->end + 1; + $end_offset = 'before' === $end_position ? $end_bookmark->start : $end_bookmark->end + 1; + + $this->lexical_updates[] = new WP_HTML_Text_Replacement( $start_offset, $end_offset, $html ); + $this->apply_attributes_updates(); + } + + /** + * Returns a substring of the input HTML document from a bookmark until the end. + * + * @since 6.4.0 + * + * @param string $start_position "before" to clip before bookmark, "after" to clip after. + * @param string $start_bookmark_name Bookmark name at which to start clipping. + * @return string Clipped substring of input HTMl document. + */ + private function substr_bookmark( $start_position, $start_bookmark_name ) { + $start_bookmark = $this->bookmarks[ "_{$start_bookmark_name}" ]; + $start_offset = 'before' === $start_position ? $start_bookmark->start : $start_bookmark->end + 1; + + return substr( $this->html, $start_offset ); + } + + /** + * Returns a substring of the input HTML document delimited by bookmarks. + * + * @since 6.4.0 + * + * @param string $start_position "before" to clip before bookmark, "after" to clip after. + * @param string $start_bookmark_name Bookmark name at which to start clipping. + * @param string $end_position "before" to clip before bookmark, "after" to clip after. + * @param string $end_bookmark_name Bookmark name at which to end clipping. + * @return string Clipped substring of input HTMl document. + */ + private function substr_bookmarks( $start_position, $start_bookmark_name, $end_position, $end_bookmark_name ) { + $start_bookmark = $this->bookmarks[ "_{$start_bookmark_name}" ]; + $end_bookmark = $this->bookmarks[ "_{$end_bookmark_name}" ]; + $start_offset = 'before' === $start_position ? $start_bookmark->start : $start_bookmark->end + 1; + $end_offset = 'before' === $end_position ? $end_bookmark->start : $end_bookmark->end + 1; + + return substr( $this->html, $start_offset, $end_offset - $start_offset ); + } + /* * HTML semantic overrides for Tag Processor */ diff --git a/src/wp-includes/html-api/class-wp-html-tag-processor.php b/src/wp-includes/html-api/class-wp-html-tag-processor.php index 19cca778ea6ee..3ad836137a6fb 100644 --- a/src/wp-includes/html-api/class-wp-html-tag-processor.php +++ b/src/wp-includes/html-api/class-wp-html-tag-processor.php @@ -1584,7 +1584,7 @@ private function class_name_updates_to_attributes_updates() { * @param int $shift_this_point Accumulate and return shift for this position. * @return int How many bytes the given pointer moved in response to the updates. */ - private function apply_attributes_updates( $shift_this_point = 0 ) { + protected function apply_attributes_updates( $shift_this_point = 0 ) { if ( ! count( $this->lexical_updates ) ) { return 0; } diff --git a/tests/phpunit/tests/html-api/wpHtmlProcessor.php b/tests/phpunit/tests/html-api/wpHtmlProcessor.php index 37e3aa5de87fb..a9af5d790fc53 100644 --- a/tests/phpunit/tests/html-api/wpHtmlProcessor.php +++ b/tests/phpunit/tests/html-api/wpHtmlProcessor.php @@ -91,8 +91,6 @@ public function test_stops_processing_after_unsupported_elements() { * * @covers WP_HTML_Processor::next_tag * @covers WP_HTML_Processor::seek - * - * @throws WP_HTML_Unsupported_Exception */ public function test_clear_to_navigate_after_seeking() { $p = WP_HTML_Processor::create_fragment( '

' ); diff --git a/tests/phpunit/tests/html-api/wpHtmlProcessorGetInnerMarkup.php b/tests/phpunit/tests/html-api/wpHtmlProcessorGetInnerMarkup.php new file mode 100644 index 0000000000000..922f3c18c84fe --- /dev/null +++ b/tests/phpunit/tests/html-api/wpHtmlProcessorGetInnerMarkup.php @@ -0,0 +1,126 @@ +
' ); + + $this->assertNull( $p->get_raw_inner_markup() ); + + $this->assertFalse( $p->next_tag( 'BUTTON' ), "Should not have found a BUTTON tag but stopped at {$p->get_tag()}." ); + $this->assertNull( $p->get_raw_inner_markup() ); + } + + /** + * @ticket {TICKET_NUMBER} + * + * @covers WP_HTML_Processor::get_raw_inner_markup + * + * @dataProvider data_html_with_inner_markup + * + * @since 6.4.0 + * + * @param string $html_with_target_node HTML containing a node with the `target` attribute set. + * @param string $expected_inner_markup Inner markup of target node. + */ + public function test_returns_appropriate_inner_markup( $html_with_target_node, $expected_inner_markup ) { + $p = WP_HTML_Processor::createFragment( $html_with_target_node ); + + while ( $p->next_tag() && null === $p->get_attribute( 'target' ) ) { + continue; + } + + $this->assertSame( $expected_inner_markup, $p->get_raw_inner_markup(), 'Failed to return appropriate inner markup.' ); + } + + /** + * Data provider. + * + * @return array[] + */ + public function data_html_with_inner_markup() { + $data = array( + 'Void elements' => array( '', '' ), + 'Empty elements' => array( '
', '' ), + 'Element containing only text' => array( '
inside
', 'inside' ), + 'Element with nested tags' => array( '
inside the div
', 'inside the div' ), + 'Unclosed element' => array( '
This is all inside the DIV', 'This is all inside the DIV' ), + 'Unclosed elements' => array( '
Inside P tags
', 'Inside P tags' ), + 'Partially-closed element' => array( '
This is all inside the DIVall inside the DIV array( '

Inside the P

Outside the P

', 'Inside the P' ), + ); + + $inner_html = <<This is inside the Match

+

+
+
+ +
Look at the picture photograph.
+
+
+HTML; + + $html = << +

This is not in the match. +

This is another paragraph not in the match. +

+
{$inner_html}
+
+

This is also note in the match.

+
+HTML; + + $data['Complicated inner nesting'] = array( $html, $inner_html ); + + return $data; + } + + /** + * Ensures that the cursor isn't moved when getting the inner markup. + * It should remain at the tag opener from where it was called. + * + * @ticket {TICKET_NUMBER} + * + * @covers WP_HTML_Processor::get_raw_inner_markup + * + * @since 6.4.0 + */ + public function test_preserves_cursor() { + $p = WP_HTML_Processor::createFragment( '

The cursor should not move unexpectedly.

' ); + + while ( $p->next_tag() && null === $p->get_attribute( 'target' ) ) { + continue; + } + + $this->assertSame( + 'The cursor should not move unexpectedly.', + $p->get_raw_inner_markup(), + 'Failed to return appropriate inner markup.' + ); + + $this->assertSame( 'SPAN', $p->get_tag(), "Should have remained on SPAN, but found {$p->get_tag()} instead." ); + $this->assertFalse( $p->is_tag_closer(), 'Should have remained on SPAN opening tag, but stopped at closing tag instead.' ); + + $p->next_tag(); + $this->assertNotNull( $p->get_attribute( 'inner-target' ), "Expected to move to inner CODE element, but found {$p->get_tag()} instead." ); + } +} diff --git a/tests/phpunit/tests/html-api/wpHtmlProcessorGetOuterMarkup.php b/tests/phpunit/tests/html-api/wpHtmlProcessorGetOuterMarkup.php new file mode 100644 index 0000000000000..156966018c990 --- /dev/null +++ b/tests/phpunit/tests/html-api/wpHtmlProcessorGetOuterMarkup.php @@ -0,0 +1,128 @@ +
' ); + + $this->assertNull( $p->get_raw_outer_markup() ); + + $this->assertFalse( $p->next_tag( 'BUTTON' ), "Should not have found a BUTTON tag but stopped at {$p->get_tag()}." ); + $this->assertNull( $p->get_raw_outer_markup() ); + } + + /** + * @ticket {TICKET_NUMBER} + * + * @covers WP_HTML_Processor::get_raw_outer_markup + * + * @dataProvider data_html_with_outer_markup + * + * @since 6.4.0 + * + * @param string $html_with_target_node HTML containing a node with the `target` attribute set. + * @param string $expected_outer_markup Outer markup of target node. + */ + public function test_returns_appropriate_outer_markup( $html_with_target_node, $expected_outer_markup ) { + $p = WP_HTML_Processor::createFragment( $html_with_target_node ); + + while ( $p->next_tag() && null === $p->get_attribute( 'target' ) ) { + continue; + } + + $this->assertSame( $expected_outer_markup, $p->get_raw_outer_markup(), 'Failed to return appropriate outer markup.' ); + } + + /** + * Data provider. + * + * @return array[] + */ + public function data_html_with_outer_markup() { + $data = array( + 'Void elements' => array( '', '' ), + 'Empty elements' => array( '
', '
' ), + 'Element containing only text' => array( '
inside
', '
inside
' ), + 'Element with nested tags' => array( '
inside the div
', '
inside the div
' ), + 'Unclosed element' => array( '
This is all inside the DIV', '
This is all inside the DIV' ), + 'Unclosed elements' => array( '

Inside P tags

', '

Inside P tags' ), + 'Partially-closed element' => array( '

This is all inside the DIVThis is all inside the DIV array( '

Inside the P

Outside the P

', '

Inside the P' ), + ); + + $inner_html = <<This is inside the Match

+

+
+
+ +
Look at the picture photograph.
+
+
+HTML; + + $html = << +

This is not in the match. +

This is another paragraph not in the match. +

+
{$inner_html}
+
+

This is also note in the match.

+
+HTML; + + $data['Complicated inner nesting'] = array( $html, "
{$inner_html}
" ); + + return $data; + } + + /** + * Ensures that the cursor isn't moved when getting the outer markup. + * It should remain at the tag opener from where it was called. + * + * @ticket {TICKET_NUMBER} + * + * @covers WP_HTML_Processor::get_raw_outer_markup + * + * @since 6.4.0 + */ + public function test_preserves_cursor() { + $p = WP_HTML_Processor::createFragment( '

The cursor should not move unexpectedly.

' ); + + while ( $p->next_tag() && null === $p->get_attribute( 'target' ) ) { + continue; + } + + $this->assertSame( + 'The cursor should not move unexpectedly.', + $p->get_raw_outer_markup(), + 'Failed to return appropriate outer markup.' + ); + + $this->assertSame( 'SPAN', $p->get_tag(), "Should have remained on SPAN, but found {$p->get_tag()} instead." ); + $this->assertFalse( $p->is_tag_closer(), 'Should have remained on SPAN opening tag, but stopped at closing tag instead.' ); + + $p->next_tag(); + $this->assertNotNull( $p->get_attribute( 'inner-target' ), "Expected to move to inner CODE element, but found {$p->get_tag()} instead." ); + } +} diff --git a/tests/phpunit/tests/html-api/wpHtmlProcessorSetInnerMarkup.php b/tests/phpunit/tests/html-api/wpHtmlProcessorSetInnerMarkup.php new file mode 100644 index 0000000000000..02d1546e27494 --- /dev/null +++ b/tests/phpunit/tests/html-api/wpHtmlProcessorSetInnerMarkup.php @@ -0,0 +1,131 @@ +next_tag() && null === $p->get_attribute( 'target' ) ) { + continue; + } + + $this->assertTrue( $p->set_raw_inner_markup( $new_markup ), 'Failed to set inner markup.' ); + $this->assertSame( $expected_output, $p->get_updated_html(), 'Failed to appropriately set inner markup.' ); + } + + /** + * Data provider. + * + * @return array[] + */ + public function data_html_with_inner_markup_changes() { + $data = array( + 'Void element' => array( '', '', '' ), + 'Void element inside text' => array( 'beforeafter', '', 'beforeafter' ), + 'Void element inside another element' => array( '

Look at this graph.

', '', '

Look at this graph.

' ), + 'Empty elements' => array( '
', '', '
' ), + 'Element with nested tags' => array( '
inside the div
', '', '
' ), + 'Element inside another element' => array( '
inside the div
', '', '
inside div
' ), + 'Unclosed element' => array( '
This is all inside the DIV', '', '
' ), + 'Unclosed nested element' => array( '

One thought

And another', '', '

And another' ), + 'Partially-closed element' => array( '

This is all inside the DIV' ), + 'Implicitly-closed element' => array( '

Inside the P

Outside the P

', '', '

Outside the P

' ), + + 'Text markup' => array( '', 'Today is the best day to start.', 'Today is the best day to start.' ), + 'Text with ampersand (raw)' => array( '', 'Today & yesterday are the best days to start.', 'Today & yesterday are the best days to start.' ), + 'Text with tag (raw)' => array( '', 'Yesterday was the best day to start.', 'Yesterday was the best day to start.' ), + 'Text with unclosed tag (raw)' => array( '', 'Yesterday was the best day to start.', 'Yesterday was the best day to start.' ), + 'Text with ending tag (raw)' => array( '', 'Here is no
', 'Here is no
' ), + 'Text with scope-creating tag (raw)' => array( '', '

Start

Finish

Repeat', '

Start

Finish

Repeat' ), + 'Text with scope-ending tag (raw)' => array( '', 'Sneaky closing No more span.', 'Sneaky closing No more span.' ), + ); + + $inner_html = <<This is inside the Match

+

+
+
+ +
Look at the picture photograph.
+
+
+HTML; + + $prefix = << +

This is not in the match. +

This is another paragraph not in the match. +

+
+HTML; + + /* + * Removing the indent on this first line keeps the test easy to reason about, + * otherwise extra indents appear after removing the inner content, because + * that indentation before and after is whitespace and not part of the tag. + */ + $suffix = << +
+

This is also note in the match.

+
+HTML; + + $data['Complicated inner nesting'] = array( $prefix . $inner_html . $suffix, '', $prefix . $suffix ); + + return $data; + } + + /** + * Ensures that the cursor isn't moved when setting the inner markup. It should + * remain at the same location as the tag opener where it was called. + * + * @ticket {TICKET_NUMBER} + * + * @covers WP_HTML_Processor::set_raw_inner_markup + * + * @since 6.4.0 + */ + public function test_preserves_cursor() { + $p = WP_HTML_Processor::createFragment( '

The cursor should not move unexpectedly.

' ); + + while ( $p->next_tag() && null === $p->get_attribute( 'target' ) ) { + continue; + } + + $this->assertTrue( $p->set_raw_inner_markup( '' ) ); + $this->assertSame( + '

The should not move unexpectedly.

', + $p->get_updated_html(), + 'Failed to replace appropriate inner markup.' + ); + + $this->assertSame( 'CODE', $p->get_tag(), "Should have remained on CODE, but found {$p->get_tag()} instead." ); + + $p->next_tag(); + $this->assertNotNull( $p->get_attribute( 'next-target' ), "Expected to move to inserted IMG element, but found {$p->get_tag()} instead." ); + } +} diff --git a/tests/phpunit/tests/html-api/wpHtmlProcessorSetOuterMarkup.php b/tests/phpunit/tests/html-api/wpHtmlProcessorSetOuterMarkup.php new file mode 100644 index 0000000000000..b5e729703920a --- /dev/null +++ b/tests/phpunit/tests/html-api/wpHtmlProcessorSetOuterMarkup.php @@ -0,0 +1,131 @@ +next_tag() && null === $p->get_attribute( 'target' ) ) { + continue; + } + + $this->assertTrue( $p->set_raw_outer_markup( $new_markup ), 'Failed to set outer markup.' ); + $this->assertSame( $expected_output, $p->get_updated_html(), 'Failed to appropriately set outer markup.' ); + } + + /** + * Data provider. + * + * @return array[] + */ + public function data_html_with_outer_markup_changes() { + $data = array( + 'Void element' => array( '', '', '' ), + 'Void element inside text' => array( 'beforeafter', '', 'beforeafter' ), + 'Void element inside another element' => array( '

Look at this graph.

', '', '

Look at this graph.

' ), + 'Empty elements' => array( '
', '', '' ), + 'Element with nested tags' => array( '
inside the div
', '', '' ), + 'Element inside another element' => array( '
inside the div
', '', '
inside div
' ), + 'Unclosed element' => array( '
This is all inside the DIV', '', '' ), + 'Unclosed nested element' => array( '

One thought

And another', '', '

And another' ), + 'Partially-closed element' => array( '

This is all inside the DIV array( '

Inside the P

Outside the P

', '', '
Outside the P

' ), + + 'Text markup' => array( '', 'Today is the best day to start.', 'Today is the best day to start.' ), + 'Text with ampersand (raw)' => array( '', 'Today & yesterday are the best days to start.', 'Today & yesterday are the best days to start.' ), + 'Text with tag (raw)' => array( '', 'Yesterday was the best day to start.', 'Yesterday was the best day to start.' ), + 'Text with unclosed tag (raw)' => array( '', 'Yesterday was the best day to start.', 'Yesterday was the best day to start.' ), + 'Text with ending tag (raw)' => array( '', 'Here is no
', 'Here is no
' ), + 'Text with scope-creating tag (raw)' => array( '', '

Start

Finish

Repeat', '

Start

Finish

Repeat' ), + 'Text with scope-ending tag (raw)' => array( '', 'Sneaky closing No more span.', 'Sneaky closing No more span.' ), + ); + + /* + * Removing the indent on this variable keeps the test easy to reason about, + * otherwise extra indents appear after removing the inner content, because + * that indentation before and after is whitespace and not part of the tag. + */ + $inner_html = << +

This is inside the Match

+

+
+
+ +
Look at the picture photograph.
+
+
+
+HTML; + + $prefix = << +

This is not in the match. +

This is another paragraph not in the match. +

+HTML; + + $suffix = << +

This is also note in the match.

+
+HTML; + + $data['Complicated inner nesting'] = array( $prefix . $inner_html . $suffix, '', $prefix . $suffix ); + + return $data; + } + + /** + * Ensures that the cursor isn't moved when setting the outer markup. It should + * remain at the same location as the tag opener where it was called. + * + * @ticket {TICKET_NUMBER} + * + * @covers WP_HTML_Processor::set_raw_outer_markup + * + * @since 6.4.0 + */ + public function test_preserves_cursor() { + $p = WP_HTML_Processor::createFragment( '

The cursor should not move unexpectedly.

' ); + + while ( $p->next_tag() && null === $p->get_attribute( 'target' ) ) { + continue; + } + + $this->assertTrue( $p->set_raw_outer_markup( '' ) ); + $this->assertSame( + '

The should not move unexpectedly.

', + $p->get_updated_html(), + 'Failed to replace appropriate outer markup.' + ); + + $this->assertSame( 'IMG', $p->get_tag(), "Should have remained on IMG, but found {$p->get_tag()} instead." ); + + $p->next_tag(); + $this->assertNotNull( $p->get_attribute( 'next-target' ), "Expected to move to following EM element, but found {$p->get_tag()} instead." ); + } +}