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 Ptags
' );
+ * $processor->next_tag( 'P' );
+ * $html = $procesor->get_raw_inner_markup();
+ * $html === '→ Inside Ptags';
+ *
+ * @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( '
';
+ *
+ * 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( '
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( '
';
+ *
+ * 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( '
';
+ *
+ * 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( '
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( '
.';
+ *
+ * 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( '
+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( '
" );
+
+ 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( '
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
+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( '
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 = <<
+
+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( '
',
+ $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." );
+ }
+}