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 6513db35c1243..615316f12c363 100644
--- a/src/wp-includes/html-api/class-wp-html-processor.php
+++ b/src/wp-includes/html-api/class-wp-html-processor.php
@@ -1402,6 +1402,15 @@ public function serialize_token(): string {
$html .= "";
break;
+ /**
+ * Processing instructions are serialized as `"" target " " data "?>"`.
+ *
+ * @link https://html.spec.whatwg.org/multipage/parsing.html#serialising-html-fragments
+ */
+ case '#processing-instruction':
+ $html .= "{$this->get_tag()} {$this->get_modifiable_text()}?>";
+ break;
+
case '#cdata-section':
$html .= "get_modifiable_text()}]]>";
break;
@@ -1562,10 +1571,12 @@ private function step_initial(): bool {
/*
* > A comment token
+ * > A processing instruction token
*/
case '#comment':
case '#funky-comment':
case '#presumptuous-tag':
+ case '#processing-instruction':
$this->insert_html_element( $this->state->current_token );
return true;
@@ -1628,10 +1639,12 @@ private function step_before_html(): bool {
/*
* > A comment token
+ * > A processing instruction token
*/
case '#comment':
case '#funky-comment':
case '#presumptuous-tag':
+ case '#processing-instruction':
$this->insert_html_element( $this->state->current_token );
return true;
@@ -1733,10 +1746,12 @@ private function step_before_head(): bool {
/*
* > A comment token
+ * > A processing instruction token
*/
case '#comment':
case '#funky-comment':
case '#presumptuous-tag':
+ case '#processing-instruction':
$this->insert_html_element( $this->state->current_token );
return true;
@@ -1832,10 +1847,12 @@ private function step_in_head(): bool {
/*
* > A comment token
+ * > A processing instruction token
*/
case '#comment':
case '#funky-comment':
case '#presumptuous-tag':
+ case '#processing-instruction':
$this->insert_html_element( $this->state->current_token );
return true;
@@ -2158,10 +2175,12 @@ private function step_after_head(): bool {
/*
* > A comment token
+ * > A processing instruction token
*/
case '#comment':
case '#funky-comment':
case '#presumptuous-tag':
+ case '#processing-instruction':
$this->insert_html_element( $this->state->current_token );
return true;
@@ -2318,6 +2337,7 @@ private function step_in_body(): bool {
case '#comment':
case '#funky-comment':
case '#presumptuous-tag':
+ case '#processing-instruction':
$this->insert_html_element( $this->state->current_token );
return true;
@@ -3400,10 +3420,12 @@ private function step_in_table(): bool {
/*
* > A comment token
+ * > A processing instruction token
*/
case '#comment':
case '#funky-comment':
case '#presumptuous-tag':
+ case '#processing-instruction':
$this->insert_html_element( $this->state->current_token );
return true;
@@ -3721,10 +3743,12 @@ private function step_in_column_group(): bool {
/*
* > A comment token
+ * > A processing instruction token
*/
case '#comment':
case '#funky-comment':
case '#presumptuous-tag':
+ case '#processing-instruction':
$this->insert_html_element( $this->state->current_token );
return true;
@@ -4153,10 +4177,12 @@ private function step_in_select(): bool {
/*
* > A comment token
+ * > A processing instruction token
*/
case '#comment':
case '#funky-comment':
case '#presumptuous-tag':
+ case '#processing-instruction':
$this->insert_html_element( $this->state->current_token );
return true;
@@ -4378,12 +4404,14 @@ private function step_in_template(): bool {
/*
* > A character token
* > A comment token
+ * > A processing instruction token
* > A DOCTYPE token
*/
case '#text':
case '#comment':
case '#funky-comment':
case '#presumptuous-tag':
+ case '#processing-instruction':
case 'html':
return $this->step_in_body();
@@ -4519,10 +4547,12 @@ private function step_after_body(): bool {
/*
* > A comment token
+ * > A processing instruction token
*/
case '#comment':
case '#funky-comment':
case '#presumptuous-tag':
+ case '#processing-instruction':
$this->bail( 'Content outside of BODY is unsupported.' );
break;
@@ -4612,10 +4642,12 @@ private function step_in_frameset(): bool {
/*
* > A comment token
+ * > A processing instruction token
*/
case '#comment':
case '#funky-comment':
case '#presumptuous-tag':
+ case '#processing-instruction':
$this->insert_html_element( $this->state->current_token );
return true;
@@ -4732,10 +4764,12 @@ private function step_after_frameset(): bool {
/*
* > A comment token
+ * > A processing instruction token
*/
case '#comment':
case '#funky-comment':
case '#presumptuous-tag':
+ case '#processing-instruction':
$this->insert_html_element( $this->state->current_token );
return true;
@@ -4802,10 +4836,12 @@ private function step_after_after_body(): bool {
switch ( $op ) {
/*
* > A comment token
+ * > A processing instruction token
*/
case '#comment':
case '#funky-comment':
case '#presumptuous-tag':
+ case '#processing-instruction':
$this->bail( 'Content outside of HTML is unsupported.' );
break;
@@ -4866,10 +4902,12 @@ private function step_after_after_frameset(): bool {
switch ( $op ) {
/*
* > A comment token
+ * > A processing instruction token
*/
case '#comment':
case '#funky-comment':
case '#presumptuous-tag':
+ case '#processing-instruction':
$this->bail( 'Content outside of HTML is unsupported.' );
break;
@@ -4989,10 +5027,12 @@ private function step_in_foreign_content(): bool {
/*
* > A comment token
+ * > A processing instruction token
*/
case '#comment':
case '#funky-comment':
case '#presumptuous-tag':
+ case '#processing-instruction':
$this->insert_foreign_element( $this->state->current_token, false );
return true;
@@ -5306,8 +5346,12 @@ public function get_tag(): ?string {
/*
* > A start tag whose tag name is "image"
* > Change the token's tag name to "img" and reprocess it. (Don't ask.)
+ *
+ * This only applies to tags; a processing instruction target or a
+ * comment which looks like a processing instruction may also report
+ * a tag name and must not be rewritten.
*/
- return ( 'IMAGE' === $tag_name && 'html' === $this->get_namespace() )
+ return ( 'IMAGE' === $tag_name && 'html' === $this->get_namespace() && '#tag' === $this->get_token_type() )
? 'IMG'
: $tag_name;
}
@@ -5376,8 +5420,11 @@ public function get_token_name(): ?string {
* - `#doctype` when matched on a DOCTYPE declaration.
* - `#presumptuous-tag` when matched on an empty tag closer.
* - `#funky-comment` when matched on a funky comment.
+ * - `#processing-instruction` when matched on a processing instruction.
*
* @since 6.6.0 Subclassed for the HTML Processor.
+ * @since 7.1.0 Recognize processing instructions according to an HTML
+ * specification update.
*
* @return string|null What kind of token is matched, or null.
*/
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 a3dac33fc55ca..8e125ba461890 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
@@ -316,7 +316,7 @@
* invalid. The text for these nodes is the text that a browser would transform into
* an HTML comment when parsing. E.g. for `%post_author>` the text is `%post_author`.
* - `DOCTYPE` declarations like `` which have no closing tag.
- * - XML Processing instruction nodes like `` (with restrictions [2]).
+ * - Processing instruction nodes like `` (with restrictions [2]).
* - The empty end tag `>` which is ignored in the browser and DOM.
*
* [1]: There are no CDATA sections in HTML. When encountering `` cannot exist within the token
- * since Processing Instructions do not exist within HTML and their syntax transforms
- * into a bogus comment in the DOM.
+ * [2]: HTML recognizes processing instructions whose target starts with an ASCII letter
+ * or `_` and continues with ASCII alphanumerics, `-`, or `_`. The reserved `xml`
+ * and `xml-stylesheet` targets, as well as XML-valid targets with characters
+ * outside this set, transform into bogus comments in the DOM instead. Processing
+ * instructions exhibit the same constraint as CDATA sections, in that `>` cannot
+ * exist within the token since the processing instruction ends at the first `>`.
*
* ## Design and limitations
*
@@ -482,17 +482,18 @@ class WP_HTML_Tag_Processor {
/**
* Specifies mode of operation of the parser at any given time.
*
- * | State | Meaning |
- * | ----------------|----------------------------------------------------------------------|
- * | *Ready* | The parser is ready to run. |
- * | *Complete* | There is nothing left to parse. |
- * | *Incomplete* | The HTML ended in the middle of a token; nothing more can be parsed. |
- * | *Matched tag* | Found an HTML tag; it's possible to modify its attributes. |
- * | *Text node* | Found a #text node; this is plaintext and modifiable. |
- * | *CDATA node* | Found a CDATA section; this is modifiable. |
- * | *Comment* | Found a comment or bogus comment; this is modifiable. |
- * | *Presumptuous* | Found an empty tag closer: `>`. |
- * | *Funky comment* | Found a tag closer with an invalid tag name; this is modifiable. |
+ * | State | Meaning |
+ * |--------------------------|----------------------------------------------------------------------|
+ * | *Ready* | The parser is ready to run. |
+ * | *Complete* | There is nothing left to parse. |
+ * | *Incomplete* | The HTML ended in the middle of a token; nothing more can be parsed. |
+ * | *Matched tag* | Found an HTML tag; it's possible to modify its attributes. |
+ * | *Text node* | Found a #text node; this is plaintext and modifiable. |
+ * | *CDATA node* | Found a CDATA section; this is modifiable. |
+ * | *Comment* | Found a comment or bogus comment; this is modifiable. |
+ * | *Presumptuous* | Found an empty tag closer: `>`. |
+ * | *Funky comment* | Found a tag closer with an invalid tag name; this is modifiable. |
+ * | *Processing instruction* | Found a processing instruction, e.g. ``. |
*
* @since 6.5.0
*
@@ -506,6 +507,7 @@ class WP_HTML_Tag_Processor {
* @see WP_HTML_Tag_Processor::STATE_DOCTYPE
* @see WP_HTML_Tag_Processor::STATE_PRESUMPTUOUS_TAG
* @see WP_HTML_Tag_Processor::STATE_FUNKY_COMMENT
+ * @see WP_HTML_Tag_Processor::STATE_PROCESSING_INSTRUCTION
*
* @var string
*/
@@ -933,10 +935,11 @@ public function next_tag( $query = null ): bool {
* - a text node - the plaintext inside tags.
* - an HTML comment.
* - a DOCTYPE declaration.
- * - a processing instruction, e.g. ``.
+ * - an HTML processing instruction, e.g. ``.
*
* @since 6.5.0
* @since 6.7.0 Recognizes CDATA sections within foreign content.
+ * @since 7.1.0 Recognizes processing instructions.
*
* @return bool Whether a token was parsed.
*/
@@ -2027,9 +2030,18 @@ private function parse_next_tag(): bool {
return true;
}
- /*
- * `` transitions to a bogus comment state – skip to the nearest >
- * See https://html.spec.whatwg.org/multipage/parsing.html#tag-open-state
+ /**
+ * `` transitions to the processing instruction open state.
+ *
+ * A processing instruction whose target starts with an ASCII letter or `_`,
+ * continues with ASCII alphanumerics, `-`, or `_`, and is not an ASCII
+ * case-insensitive match for `xml` or `xml-stylesheet` produces a processing
+ * instruction node. Anything else transitions to the bogus comment state.
+ *
+ * Both forms end at the nearest `>`; a processing instruction cannot
+ * contain one in the HTML syntax.
+ *
+ * @link https://html.spec.whatwg.org/multipage/parsing.html#processing-instruction-open-state
*/
if ( ! $this->is_closing_tag && '?' === $html[ $at + 1 ] ) {
$closer_at = strpos( $html, '>', $at + 2 );
@@ -2039,6 +2051,56 @@ private function parse_next_tag(): bool {
return false;
}
+ $target_at = $at + 2;
+ $target_length = 0;
+ $first_char = $html[ $target_at ];
+ if (
+ ( 'a' <= $first_char && 'z' >= $first_char ) ||
+ ( 'A' <= $first_char && 'Z' >= $first_char ) ||
+ '_' === $first_char
+ ) {
+ $target_length = 1 + strspn( $html, 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-', $target_at + 1 );
+ }
+
+ /*
+ * In the processing instruction target state, only whitespace, `?`,
+ * or `>` may terminate the target; any other character converts the
+ * token into a bogus comment.
+ *
+ * The `xml` and `xml-stylesheet` targets are reserved and disallowed;
+ * they also convert the token into a bogus comment.
+ */
+ $is_valid_pi = (
+ 0 !== $target_length &&
+ false !== strpos( " \t\f\r\n?>", $html[ $target_at + $target_length ] ) &&
+ ! ( 3 === $target_length && 0 === substr_compare( $html, 'xml', $target_at, 3, true ) ) &&
+ ! ( 14 === $target_length && 0 === substr_compare( $html, 'xml-stylesheet', $target_at, 14, true ) )
+ );
+
+ if ( $is_valid_pi ) {
+ /*
+ * The processing instruction data starts after any whitespace
+ * following the target and ends at the `>`. When the token is
+ * closed by `?>`, that final `?` is not part of the data.
+ */
+ $data_at = $target_at + $target_length;
+ $data_at += strspn( $html, " \t\f\r\n", $data_at );
+
+ $data_length = $closer_at - $data_at;
+ if ( $data_length > 0 && '?' === $html[ $closer_at - 1 ] ) {
+ --$data_length;
+ }
+
+ $this->parser_state = self::STATE_PROCESSING_INSTRUCTION;
+ $this->tag_name_starts_at = $target_at;
+ $this->tag_name_length = $target_length;
+ $this->token_length = $closer_at + 1 - $this->token_starts_at;
+ $this->text_starts_at = $data_at;
+ $this->text_length = $data_length;
+ $this->bytes_already_parsed = $closer_at + 1;
+ return true;
+ }
+
$this->parser_state = self::STATE_COMMENT;
$this->comment_type = self::COMMENT_AS_INVALID_HTML;
$this->token_length = $closer_at + 1 - $this->token_starts_at;
@@ -2047,20 +2109,15 @@ private function parse_next_tag(): bool {
$this->bytes_already_parsed = $closer_at + 1;
/*
- * Identify a Processing Instruction node were HTML to have them.
- *
- * This section must occur after identifying the bogus comment end
- * because in an HTML parser it will span to the nearest `>`, even
- * if there's no `?>` as would be required in an XML document. It
- * is therefore not possible to parse a Processing Instruction node
- * containing a `>` in the HTML syntax.
+ * Identify an XML-like Processing Instruction node.
*
- * XML allows for more target names, but this code only identifies
- * those with ASCII-representable target names. This means that it
- * may identify some Processing Instruction nodes as bogus comments,
- * but it will not misinterpret the HTML structure. By limiting the
- * identification to these target names the Tag Processor can avoid
- * the need to start parsing UTF-8 sequences.
+ * HTML and XML processing instructions have different parsing rules.
+ * The HTML API recognizes XML-like processing instructions that are
+ * _not_ HTML processing instructions. The HTML standard transforms
+ * them to "bogus comments," represented by the HTML API as comments
+ * with the `COMMENT_AS_PI_NODE_LOOKALIKE` type. This includes the
+ * special targets `xml` and `xml-stylesheet` which are reserved
+ * targets not allowed in HTML processing instructions.
*
* > NameStartChar ::= ":" | [A-Z] | "_" | [a-z] | [#xC0-#xD6] | [#xD8-#xF6] | [#xF8-#x2FF] |
* [#x370-#x37D] | [#x37F-#x1FFF] | [#x200C-#x200D] | [#x2070-#x218F] |
@@ -2068,9 +2125,6 @@ private function parse_next_tag(): bool {
* [#x10000-#xEFFFF]
* > NameChar ::= NameStartChar | "-" | "." | [0-9] | #xB7 | [#x0300-#x036F] | [#x203F-#x2040]
*
- * @todo Processing instruction nodes in SGML may contain any kind of markup. XML defines a
- * special case with `` syntax, but the `?` is part of the bogus comment.
- *
* @see https://www.w3.org/TR/2006/REC-xml11-20060816/#NT-PITarget
*/
if ( $this->token_length >= 5 && '?' === $html[ $closer_at - 1 ] ) {
@@ -2961,6 +3015,14 @@ public function get_tag(): ?string {
return strtoupper( $tag_name );
}
+ /*
+ * Processing instruction targets are case-sensitive
+ * and returned as they appear in the input HTML.
+ */
+ if ( self::STATE_PROCESSING_INSTRUCTION === $this->parser_state ) {
+ return $tag_name;
+ }
+
if (
self::STATE_COMMENT === $this->parser_state &&
self::COMMENT_AS_PI_NODE_LOOKALIKE === $this->get_comment_type()
@@ -3437,10 +3499,13 @@ public function is_tag_closer(): bool {
* - `#doctype` when matched on a DOCTYPE declaration.
* - `#presumptuous-tag` when matched on an empty tag closer.
* - `#funky-comment` when matched on a funky comment.
+ * - `#processing-instruction` when matched on a processing instruction.
*
* @since 6.5.0
+ * @since 7.1.0 Recognizes processing instructions.
*
* @return string|null What kind of token is matched, or null.
+ * @phpstan-return '#tag'|'#text'|'#cdata-section'|'#comment'|'#doctype'|'#presumptuous-tag'|'#funky-comment'|'#processing-instruction'|null
*/
public function get_token_type(): ?string {
switch ( $this->parser_state ) {
@@ -3497,6 +3562,9 @@ public function get_token_name(): ?string {
case self::STATE_FUNKY_COMMENT:
return '#funky-comment';
+
+ case self::STATE_PROCESSING_INSTRUCTION:
+ return '#processing-instruction';
}
return null;
@@ -3537,7 +3605,7 @@ public function get_comment_type(): ?string {
*
* This differs from {@see ::get_modifiable_text()} in that certain comment
* types in the HTML API cannot allow their entire comment text content to
- * be modified. Namely, "bogus comments" of the form ``
+ * be modified. Namely, "bogus comments" of the form ``
* will create a comment whose text content starts with `?`. Note that if
* that character were modified, it would be possible to change the node
* type.
@@ -3706,6 +3774,19 @@ public function get_modifiable_text(): string {
? $this->lexical_updates['modifiable text']->text
: substr( $this->html, $this->text_starts_at, $this->text_length );
+ /*
+ * An enqueued processing instruction update holds normalized raw
+ * syntax spanning from the end of the target through the end of
+ * the token: a separating space, the data, and the `?>` closer.
+ * The data is found by skipping the leading whitespace and
+ * dropping the two bytes of the closer.
+ *
+ * @see WP_HTML_Tag_Processor::set_modifiable_text()
+ */
+ if ( $has_enqueued_update && self::STATE_PROCESSING_INSTRUCTION === $this->parser_state ) {
+ $text = substr( $text, strspn( $text, " \t\f\r\n" ), -2 );
+ }
+
/*
* Pre-processing the input stream would normally happen before
* any parsing is done, but deferring it means it's possible to
@@ -3719,12 +3800,13 @@ public function get_modifiable_text(): string {
$text = str_replace( "\r\n", "\n", $text );
$text = str_replace( "\r", "\n", $text );
- // Comment data is not decoded.
+ // Comment and processing instruction data is not decoded.
if (
self::STATE_CDATA_NODE === $this->parser_state ||
self::STATE_COMMENT === $this->parser_state ||
self::STATE_DOCTYPE === $this->parser_state ||
- self::STATE_FUNKY_COMMENT === $this->parser_state
+ self::STATE_FUNKY_COMMENT === $this->parser_state ||
+ self::STATE_PROCESSING_INSTRUCTION === $this->parser_state
) {
return str_replace( "\x00", "\u{FFFD}", $text );
}
@@ -3793,7 +3875,11 @@ public function get_modifiable_text(): string {
* that escaping strings like `` won’t break the script; in these
* cases, updates will be rejected and it’s up to calling code to perform
* language-specific escaping or workarounds. Similarly, it will not allow
- * setting content into a comment which would prematurely terminate the comment.
+ * setting content into a comment which would prematurely terminate the comment,
+ * or processing instruction data which cannot be represented: data containing
+ * a `>`, which would prematurely terminate the processing instruction, or data
+ * with leading whitespace, which is indistinguishable from the whitespace
+ * separating the data from its target.
*
* Example:
*
@@ -3831,6 +3917,7 @@ public function get_modifiable_text(): string {
*
* @since 6.7.0
* @since 6.9.0 Escapes all character references instead of trying to avoid double-escaping.
+ * @since 7.1.0 Supports setting processing instruction data.
*
* @param string $plaintext_content New text content to represent in the matched token.
* @return bool Whether the text was able to update.
@@ -3862,6 +3949,11 @@ public function set_modifiable_text( string $plaintext_content ): bool {
) {
// Check if the text could close the comment.
if ( 1 === preg_match( '/--!?>/', $plaintext_content ) ) {
+ _doing_it_wrong(
+ __METHOD__,
+ __( 'Comment text cannot contain a comment closer.' ),
+ '7.1.0'
+ );
return false;
}
@@ -3874,6 +3966,55 @@ public function set_modifiable_text( string $plaintext_content ): bool {
return true;
}
+ // Processing instruction data is not encoded.
+ if ( self::STATE_PROCESSING_INSTRUCTION === $this->parser_state ) {
+ /*
+ * A processing instruction ends at the first `>` in its
+ * raw syntax: data containing one cannot be represented.
+ */
+ if ( str_contains( $plaintext_content, '>' ) ) {
+ _doing_it_wrong(
+ __METHOD__,
+ __( 'Processing instruction data cannot contain ">".' ),
+ '7.1.0'
+ );
+ return false;
+ }
+
+ /*
+ * All whitespace between the target and the data is skipped when
+ * parsing: data with leading whitespace cannot be represented.
+ */
+ if ( 0 !== strspn( $plaintext_content, " \t\f\r\n" ) ) {
+ _doing_it_wrong(
+ __METHOD__,
+ __( 'Processing instruction data cannot start with whitespace. Try ltrim( $plaintext_content, " \t\f\r\n" ).' ),
+ '7.1.0'
+ );
+ return false;
+ }
+
+ /**
+ * A single replacement spans from the end of the target through
+ * the end of the token, normalizing the raw syntax for that
+ * region into a fixed form: a separating space, the data, and
+ * the `?>` closer.
+ *
+ * {@see self::get_modifiable_text()} performs necessary parsing to
+ * return the correct processing instruction data based
+ * on the modifiable text lexical update.
+ */
+ $data_at = $this->tag_name_starts_at + $this->tag_name_length;
+
+ $this->lexical_updates['modifiable text'] = new WP_HTML_Text_Replacement(
+ $data_at,
+ $this->token_starts_at + $this->token_length - $data_at,
+ " {$plaintext_content}?>"
+ );
+
+ return true;
+ }
+
/*
* The rest of this function handles modifiable text for special "atomic" HTML elements.
* Only tags in the HTML namespace should be processed.
@@ -3882,6 +4023,11 @@ public function set_modifiable_text( string $plaintext_content ): bool {
self::STATE_MATCHED_TAG !== $this->parser_state ||
'html' !== $this->get_namespace()
) {
+ _doing_it_wrong(
+ __METHOD__,
+ __( 'This token does not support setting modifiable text.' ),
+ '7.1.0'
+ );
return false;
}
@@ -3915,6 +4061,11 @@ public function set_modifiable_text( string $plaintext_content ): bool {
false !== stripos( $plaintext_content, ' cannot be printed inside a script tag
* the `example/example` type is an unknown type with no known escaping rules.
diff --git a/tests/phpunit/tests/html-api/wpHtmlProcessor-serialize.php b/tests/phpunit/tests/html-api/wpHtmlProcessor-serialize.php
index e332ec12a0a91..74f4a650f5afb 100644
--- a/tests/phpunit/tests/html-api/wpHtmlProcessor-serialize.php
+++ b/tests/phpunit/tests/html-api/wpHtmlProcessor-serialize.php
@@ -382,12 +382,71 @@ public function data_bogus_comments() {
'CDATA look-alike' => array( '' ),
'Immediately-closed markup instruction' => array( '' ),
'Warning Symbol' => array( '' ),
- 'PHP block look-alike' => array( '<', '?php foo(); ?', '>' ),
+ 'PHP short echo tag' => array( '<', '?= "Hello" ?', '>' ),
'Funky comment' => array( '', '%display-name', '>' ),
'XML Processing Instruction look-alike' => array( '<', '?xml foo ', '>' ),
);
}
+ /**
+ * Ensures that processing instructions are serialized in their normative form.
+ *
+ * Note that the serialized form separates the target from the data with a
+ * single space and always terminates with `?>`, regardless of the original
+ * syntax. The closer's `?` is dropped on parse, so this form represents any
+ * data, including data ending in `?`.
+ *
+ * @ticket 61530
+ *
+ * @dataProvider data_processing_instructions
+ *
+ * @param string $html Input containing a processing instruction.
+ * @param string $expected Normative serialization of the input.
+ */
+ public function test_serializes_processing_instructions( string $html, string $expected ): void {
+ $this->assertSame(
+ WP_HTML_Processor::normalize( $html ),
+ $expected,
+ 'Should have serialized the processing instruction in its normative form.'
+ );
+ }
+
+ /**
+ * Ensures that normalizing an already-normalized processing instruction does not change it.
+ *
+ * @ticket 61530
+ *
+ * @dataProvider data_processing_instructions
+ *
+ * @param string $html Input containing a processing instruction.
+ * @param string $expected Normative serialization of the input.
+ */
+ public function test_processing_instruction_normalization_is_idempotent( string $html, string $expected ): void {
+ $this->assertSame(
+ $expected,
+ WP_HTML_Processor::normalize( $expected ),
+ 'Normalizing an already-normalized processing instruction should not change it.'
+ );
+ }
+
+ /**
+ * Data provider.
+ *
+ * @return array
+ */
+ public function data_processing_instructions(): array {
+ return array(
+ 'PHP block' => array( '', '' ),
+ 'Unclosed PHP block' => array( '', '' ),
+ 'Empty data' => array( '', '' ),
+ 'Whitespace-only data' => array( '', '' ),
+ 'Data ending in question mark' => array( '', '' ),
+ 'Data of a lone question mark' => array( '', '' ),
+ 'Data with question mark runs' => array( '', '' ),
+ 'Question mark then whitespace' => array( '', '' ),
+ );
+ }
+
/**
* Ensures that NULL bytes are properly handled.
*
diff --git a/tests/phpunit/tests/html-api/wpHtmlProcessor.php b/tests/phpunit/tests/html-api/wpHtmlProcessor.php
index a978422c20098..f3b051ca3639e 100644
--- a/tests/phpunit/tests/html-api/wpHtmlProcessor.php
+++ b/tests/phpunit/tests/html-api/wpHtmlProcessor.php
@@ -303,16 +303,18 @@ public function test_expects_closer_expects_no_closer_for_self_contained_tokens(
/**
* Data provider.
*
- * @return array[]
+ * @return array
*/
- public static function data_self_contained_node_tokens() {
+ public static function data_self_contained_node_tokens(): array {
$self_contained_nodes = array(
- 'Normative comment' => array( '' ),
- 'Comment with invalid closing' => array( '' ),
- 'CDATA Section lookalike' => array( '' ),
- 'Processing Instruction lookalike' => array( '' ),
- 'Funky comment' => array( '/wp:post-meta key=isbn>' ),
- 'Text node' => array( 'Trombone' ),
+ 'Normative comment' => array( '' ),
+ 'Comment with invalid closing' => array( '' ),
+ 'CDATA Section lookalike' => array( '' ),
+ 'Processing Instruction' => array( '' ),
+ 'Bogus PI-lookalike xml comment' => array( '' ),
+ 'Bogus comment' => array( '🔥?>' ),
+ 'Funky comment' => array( '/wp:post-meta key=isbn>' ),
+ 'Text node' => array( 'Trombone' ),
);
foreach ( self::data_void_tags_not_ignored_in_body() as $tag_name => $_name ) {
diff --git a/tests/phpunit/tests/html-api/wpHtmlProcessorComments.php b/tests/phpunit/tests/html-api/wpHtmlProcessorComments.php
index 4ea17f2318b51..79dd8c204c111 100644
--- a/tests/phpunit/tests/html-api/wpHtmlProcessorComments.php
+++ b/tests/phpunit/tests/html-api/wpHtmlProcessorComments.php
@@ -32,17 +32,20 @@ public function test_comment_processing( $html, $expected_comment_type, $expecte
/**
* Data provider.
*
- * @return array[]
+ * @return array
*/
- public static function data_comments() {
+ public static function data_comments(): array {
return array(
- 'Normative comment' => array( '', WP_HTML_Processor::COMMENT_AS_HTML_COMMENT, ' A comment. ' ),
- 'Abruptly closed comment' => array( '', WP_HTML_Processor::COMMENT_AS_ABRUPTLY_CLOSED_COMMENT, '' ),
- 'Invalid HTML comment !' => array( '', WP_HTML_Processor::COMMENT_AS_INVALID_HTML, ' Bang opener ' ),
- 'Invalid HTML comment ?' => array( ' Question opener >', WP_HTML_Processor::COMMENT_AS_INVALID_HTML, ' Question opener ' ),
- 'CDATA comment' => array( '', WP_HTML_Processor::COMMENT_AS_CDATA_LOOKALIKE, ' cdata body ' ),
- 'Processing instruction comment' => array( '', WP_HTML_Processor::COMMENT_AS_PI_NODE_LOOKALIKE, ' Instruction body. ', 'pi-target' ),
- 'Processing instruction php' => array( '', WP_HTML_Processor::COMMENT_AS_PI_NODE_LOOKALIKE, ' const HTML_COMMENT = true; ', 'php' ),
+ 'Normative comment' => array( '', WP_HTML_Processor::COMMENT_AS_HTML_COMMENT, ' A comment. ' ),
+ 'Abruptly closed comment' => array( '', WP_HTML_Processor::COMMENT_AS_ABRUPTLY_CLOSED_COMMENT, '' ),
+ 'Invalid HTML comment !' => array( '', WP_HTML_Processor::COMMENT_AS_INVALID_HTML, ' Bang opener ' ),
+ 'Invalid HTML comment ?' => array( ' Question opener >', WP_HTML_Processor::COMMENT_AS_INVALID_HTML, ' Question opener ' ),
+ 'CDATA comment' => array( '', WP_HTML_Processor::COMMENT_AS_CDATA_LOOKALIKE, ' cdata body ' ),
+ 'Processing instruction xml' => array( '', WP_HTML_Processor::COMMENT_AS_PI_NODE_LOOKALIKE, ' version="1.0" ', 'xml' ),
+ 'Processing instruction xml-stylesheet' => array( '', WP_HTML_Processor::COMMENT_AS_PI_NODE_LOOKALIKE, ' href="a.css" ', 'xml-stylesheet' ),
+ 'Processing instruction XML-valid target' => array( '', WP_HTML_Processor::COMMENT_AS_PI_NODE_LOOKALIKE, ' const HTML_COMMENT = true; ', 'wp.bit' ),
+ 'Processing instruction bad target' => array( '$var Not a target. ?>', WP_HTML_Processor::COMMENT_AS_INVALID_HTML, '$var Not a target. ?' ),
+ 'PHP short echo tag' => array( '= "Hello" ?>', WP_HTML_Processor::COMMENT_AS_INVALID_HTML, '= "Hello" ?' ),
);
}
diff --git a/tests/phpunit/tests/html-api/wpHtmlProcessorModifiableText.php b/tests/phpunit/tests/html-api/wpHtmlProcessorModifiableText.php
index 5d093ae05dd07..3072a46aa91d0 100644
--- a/tests/phpunit/tests/html-api/wpHtmlProcessorModifiableText.php
+++ b/tests/phpunit/tests/html-api/wpHtmlProcessorModifiableText.php
@@ -77,6 +77,8 @@ public static function data_modifiable_text_special_textarea(): array {
*
* @ticket 64751
* @dataProvider data_set_modifiable_fails_non_atomic_tags
+ *
+ * @expectedIncorrectUsage WP_HTML_Tag_Processor::set_modifiable_text
*/
public function test_set_modifiable_fails_non_atomic_tags(
string $html,
diff --git a/tests/phpunit/tests/html-api/wpHtmlProcessorProcessingInstructions.php b/tests/phpunit/tests/html-api/wpHtmlProcessorProcessingInstructions.php
new file mode 100644
index 0000000000000..d0d111cf913c6
--- /dev/null
+++ b/tests/phpunit/tests/html-api/wpHtmlProcessorProcessingInstructions.php
@@ -0,0 +1,226 @@
+next_token();
+
+ $this->assertSame( '#processing-instruction', $processor->get_token_name() );
+ $this->assertSame( '#processing-instruction', $processor->get_token_type() );
+ $this->assertSame( $expected_target, $processor->get_tag() );
+ $this->assertSame( $expected_data, $processor->get_modifiable_text() );
+ $this->assertNull( $processor->get_comment_type() );
+ }
+
+ /**
+ * Data provider.
+ *
+ * The whitespace following the target is not part of the data, but all
+ * further text through the closing `>` is, excepting a final `?` when
+ * the processing instruction is closed by `?>`.
+ *
+ * @return array
+ */
+ public static function data_processing_instructions(): array {
+ return array(
+ 'Basic' => array( '', 'pi-target', 'Instruction body. ' ),
+ 'PHP block' => array( '', 'php', 'const HTML_COMMENT = true; ' ),
+ 'No data' => array( '', 'something', '' ),
+ 'No data, questionable close' => array( '', 'something', '' ),
+ 'Unclosed data' => array( '', 'something', 'good' ),
+ 'Data with spaces' => array( '', 'something', 'else is good' ),
+ 'Whitespace after target' => array( "", 'hey', 'there' ),
+ 'Tab terminates target' => array( "", 'hey', 'there=1' ),
+ 'Question mark in data' => array( '', 'hey', '?there' ),
+ 'Question mark data run' => array( '', 'something', '? ' ),
+ 'Data ending in question mark' => array( '', 'wp-bit', 'smile?' ),
+ 'Ends at first closer' => array( ' ?>', 't', 'd ' ),
+ 'Case preserved in target' => array( '', 'all-KINDS-of-CaSeS', 'data' ),
+ 'Underscore target' => array( '', '_x132', 'data' ),
+ 'Single-character target' => array( '', 'x', 'data' ),
+ 'Digits and hyphens in target' => array( '', 'r2-d2', 'data' ),
+ 'XML lookalike target prefix' => array( '', 'xml2', 'data' ),
+ 'IMAGE target not rewritten' => array( '', 'IMAGE', 'data' ),
+ );
+ }
+
+ /**
+ * Ensures that invalid or reserved processing instruction targets
+ * produce comments instead of processing instruction nodes.
+ *
+ * The comment cases are more fully covered in the comment processing tests.
+ *
+ * @ticket 61530
+ *
+ * @dataProvider data_invalid_targets
+ *
+ * @param string $html Input whose processing-instruction-like syntax has an invalid target.
+ */
+ public function test_invalid_targets_become_comments( string $html ): void {
+ $processor = WP_HTML_Processor::create_fragment( $html );
+ $processor->next_token();
+
+ $this->assertSame( '#comment', $processor->get_token_name() );
+ }
+
+ /**
+ * Data provider.
+ *
+ * @return array
+ */
+ public static function data_invalid_targets(): array {
+ return array(
+ 'Reserved xml target' => array( '' ),
+ 'Reserved xml-stylesheet target' => array( '' ),
+ 'Reserved target, cased' => array( '' ),
+ 'Leading digit' => array( '1st-place data?>' ),
+ 'Leading hyphen' => array( '-prefix data?>' ),
+ 'Colon in target' => array( '' ),
+ 'Dot in target' => array( '' ),
+ 'Non-ASCII target' => array( '٥-star data?>' ),
+ 'Empty target' => array( '>' ),
+ 'Whitespace target' => array( ' >' ),
+ 'PHP short echo tag' => array( '= "Hello" ?>' ),
+ );
+ }
+
+ /**
+ * Ensures that a document ending inside a processing instruction
+ * pauses the processor at an incomplete token.
+ *
+ * @ticket 61530
+ *
+ * @dataProvider data_incomplete_processing_instructions
+ *
+ * @param string $html Input ending inside a processing instruction.
+ */
+ public function test_incomplete_processing_instruction( string $html ): void {
+ $processor = WP_HTML_Processor::create_full_parser( $html );
+
+ $this->assertFalse( $processor->next_token() );
+ $this->assertTrue( $processor->paused_at_incomplete_token() );
+ }
+
+ /**
+ * Data provider.
+ *
+ * @return array
+ */
+ public static function data_incomplete_processing_instructions(): array {
+ return array(
+ 'Bare opener' => array( '' ),
+ 'Target only' => array( ' array( ' array( ' array( ' array( '# comment' ),
+ );
+ }
+
+ /**
+ * Ensures that processing instructions are found in every part of a document.
+ *
+ * In every insertion mode, a processing instruction token is handled
+ * like a comment token: it's inserted in place and does not influence
+ * the surrounding structure.
+ *
+ * @ticket 61530
+ *
+ * @dataProvider data_processing_instruction_placement
+ *
+ * @param string $html Input containing a processing instruction somewhere.
+ * @param string $breadcrumbs Breadcrumbs of the processing instruction, joined by " > ".
+ */
+ public function test_processing_instruction_placement( string $html, string $breadcrumbs ): void {
+ $processor = WP_HTML_Processor::create_full_parser( $html );
+
+ while ( $processor->next_token() ) {
+ if ( '#processing-instruction' === $processor->get_token_type() ) {
+ $this->assertSame(
+ $breadcrumbs,
+ implode( ' > ', $processor->get_breadcrumbs() ),
+ 'Found the processing instruction in the wrong location.'
+ );
+ return;
+ }
+ }
+
+ $this->fail( 'Failed to find the processing instruction in the document.' );
+ }
+
+ /**
+ * Data provider.
+ *
+ * @return array
+ */
+ public static function data_processing_instruction_placement(): array {
+ return array(
+ 'Before HTML' => array( '', '#processing-instruction' ),
+ 'In HEAD' => array( '', 'HTML > HEAD > #processing-instruction' ),
+ 'After HEAD' => array( '', 'HTML > #processing-instruction' ),
+ 'In BODY' => array( '', 'HTML > BODY > DIV > #processing-instruction' ),
+ 'In TABLE' => array( '
', 'HTML > BODY > TABLE > TBODY > TR > #processing-instruction' ),
+ 'In TEMPLATE' => array( '', 'HTML > BODY > TEMPLATE > #processing-instruction' ),
+ 'In foreign content' => array( '', 'HTML > BODY > SVG > #processing-instruction' ),
+ );
+ }
+
+ /**
+ * Ensures that processing-instruction-like syntax inside raw text
+ * elements is not parsed as a processing instruction.
+ *
+ * @ticket 61530
+ *
+ * @dataProvider data_raw_text_elements
+ *
+ * @param string $html Input containing processing-instruction-like syntax in a raw text element.
+ */
+ public function test_no_processing_instruction_in_raw_text( string $html ): void {
+ $processor = WP_HTML_Processor::create_fragment( $html );
+
+ while ( $processor->next_token() ) {
+ $this->assertNotSame(
+ '#processing-instruction',
+ $processor->get_token_type(),
+ 'Should not have found a processing instruction inside a raw text element.'
+ );
+ }
+ }
+
+ /**
+ * Data provider.
+ *
+ * @return array
+ */
+ public static function data_raw_text_elements(): array {
+ return array(
+ 'SCRIPT' => array( '' ),
+ 'STYLE' => array( '' ),
+ 'TEXTAREA' => array( '' ),
+ 'TITLE' => array( '' ),
+ );
+ }
+}
diff --git a/tests/phpunit/tests/html-api/wpHtmlProcessorWebPlatformTests.php b/tests/phpunit/tests/html-api/wpHtmlProcessorWebPlatformTests.php
index 7aa26f5ecc01a..58ca90a857da0 100644
--- a/tests/phpunit/tests/html-api/wpHtmlProcessorWebPlatformTests.php
+++ b/tests/phpunit/tests/html-api/wpHtmlProcessorWebPlatformTests.php
@@ -37,9 +37,6 @@ class Tests_HtmlApi_WebPlatformTests extends WP_UnitTestCase {
'tests2/line0686' => 'Unimplemented: This parser does not add missing attributes to existing HTML or BODY tags.',
'tests2/line0697' => 'Unimplemented: This parser does not add missing attributes to existing HTML or BODY tags.',
'tests2/line0709' => 'Unimplemented: This parser does not add missing attributes to existing HTML or BODY tags.',
- 'tests1/line0601' => 'Unimplemented: This parser treats processing instructions as comments.',
- 'tests1/line0640' => 'Unimplemented: This parser treats processing instructions as comments.',
- 'html5test-com/line0129' => 'Unimplemented: This parser treats processing instructions as comments.',
'menuitem-element/line0161' => 'Unimplemented: This parser does not support customizable SELECT element content.',
'tests9/line0048' => 'Unimplemented: This parser does not support customizable SELECT element content.',
'tests9/line0059' => 'Unimplemented: This parser does not support customizable SELECT element content.',
@@ -60,16 +57,6 @@ class Tests_HtmlApi_WebPlatformTests extends WP_UnitTestCase {
'webkit02/line0706' => 'Unimplemented: This parser does not support customizable SELECT element content.',
'webkit02/line0732' => 'Unimplemented: This parser does not support customizable SELECT element content.',
'webkit02/line0748' => 'Unimplemented: This parser does not support customizable SELECT element content.',
-
- 'tests1/line0602' => 'Unimplemented: Updated Processing Instruction parsing.',
- 'tests1/line0641' => 'Unimplemented: Updated Processing Instruction parsing.',
- );
-
- /**
- * Skip test files that exercise parser behavior unsupported by the HTML API.
- */
- const SKIP_TEST_PREFIXES = array(
- 'processing-instructions/' => 'Unimplemented: Updated Processing Instruction parsing.',
);
/**
@@ -178,12 +165,6 @@ private static function should_skip_test( ?string $test_context_element, string
return true;
}
- foreach ( array_keys( self::SKIP_TEST_PREFIXES ) as $test_prefix ) {
- if ( str_starts_with( $test_name, $test_prefix ) ) {
- return true;
- }
- }
-
return false;
}
@@ -344,6 +325,15 @@ static function ( $a, $b ) {
$output .= str_repeat( self::TREE_INDENT, $indent_level ) . "\n";
break;
+ case '#processing-instruction':
+ /*
+ * Processing instructions must be "" then the target then,
+ * unless the data is empty, a space and the data, and finally "?>".
+ */
+ $pi_data = $processor->get_modifiable_text();
+ $output .= str_repeat( self::TREE_INDENT, $indent_level ) . "{$processor->get_tag()} {$pi_data}?>\n";
+ break;
+
case '#comment':
// Comments must be "<" then "!-- " then the data then " -->".
$output .= str_repeat( self::TREE_INDENT, $indent_level ) . "\n";
@@ -474,14 +464,6 @@ public static function parse_web_platform_test_file( $filename ) {
* the tree of the parsed DOM. Each node must be represented by a single line. Each line
* must start with "| ", followed by two spaces per parent node that the node has before
* the root document node.
- *
- * - Element nodes must be represented by a "<" then the tag name string ">", and all the attributes must be given, sorted lexicographically by UTF-16 code unit according to their attribute name string, on subsequent lines, as if they were children of the element node.
- * - Attribute nodes must have the attribute name string, then an "=" sign, then the attribute value in double quotes (").
- * - Text nodes must be the string, in double quotes. Newlines aren't escaped.
- * - Comments must be "<" then "!-- " then the data then " -->".
- * - DOCTYPEs must be "".
- * - Processing instructions must be "", then the target, then a space, then the data and then ">". (The HTML parser cannot emit processing instructions, but scripts can, and the WebVTT to DOM rules can emit them.)
- * - Template contents are represented by the string "content" with the children below it.
*/
case 'document':
if ( '|' === $line[0] ) {
diff --git a/tests/phpunit/tests/html-api/wpHtmlTagProcessor-token-scanning.php b/tests/phpunit/tests/html-api/wpHtmlTagProcessor-token-scanning.php
index de61377e21d55..e6c77024848dd 100644
--- a/tests/phpunit/tests/html-api/wpHtmlTagProcessor-token-scanning.php
+++ b/tests/phpunit/tests/html-api/wpHtmlTagProcessor-token-scanning.php
@@ -579,6 +579,7 @@ public function test_empty_cdata_in_foreign_content() {
* @ticket 60170
*
* @since 6.5.0
+ * @since 7.1.0 Processing instructions produce their own token type.
*
* @covers WP_HTML_Tag_Processor::next_token
*/
@@ -586,6 +587,49 @@ public function test_basic_assertion_processing_instruction() {
$processor = new WP_HTML_Tag_Processor( '' );
$processor->next_token();
+ $this->assertSame(
+ '#processing-instruction',
+ $processor->get_token_name(),
+ "Should have found processing instruction token but found {$processor->get_token_name()} instead."
+ );
+
+ $this->assertNull(
+ $processor->get_comment_type(),
+ 'Should not have detected a comment type on a non-comment token.'
+ );
+
+ $this->assertSame(
+ 'wp-bit',
+ $processor->get_tag(),
+ "Should have found PI target as tag name but found {$processor->get_tag()} instead."
+ );
+
+ $this->assertNull(
+ $processor->get_attribute( 'type' ),
+ 'Should not have been able to query attributes on non-element token.'
+ );
+
+ $this->assertSame(
+ '{"just": "kidding"}',
+ $processor->get_modifiable_text(),
+ 'Found incorrect modifiable text.'
+ );
+ }
+
+ /**
+ * Ensures that Processing Instruction nodes with reserved XML targets
+ * are properly parsed as bogus comments.
+ *
+ * @ticket 60170
+ *
+ * @since 7.1.0
+ *
+ * @covers WP_HTML_Tag_Processor::next_token
+ */
+ public function test_basic_assertion_xml_processing_instruction(): void {
+ $processor = new WP_HTML_Tag_Processor( '' );
+ $processor->next_token();
+
$this->assertSame(
'#comment',
$processor->get_token_name(),
@@ -599,21 +643,27 @@ public function test_basic_assertion_processing_instruction() {
);
$this->assertSame(
- 'wp-bit',
+ 'xml',
$processor->get_tag(),
"Should have found PI target as tag name but found {$processor->get_tag()} instead."
);
$this->assertNull(
- $processor->get_attribute( 'type' ),
+ $processor->get_attribute( 'version' ),
'Should not have been able to query attributes on non-element token.'
);
$this->assertSame(
- ' {"just": "kidding"}',
+ ' version="1.0"',
$processor->get_modifiable_text(),
'Found incorrect modifiable text.'
);
+
+ $this->assertSame(
+ '?xml version="1.0"?',
+ $processor->get_full_comment_text(),
+ 'Found incorrect full comment text.'
+ );
}
/**
diff --git a/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php b/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php
index 88762ddbb60c4..84d90a84190fc 100644
--- a/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php
+++ b/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php
@@ -657,7 +657,8 @@ public static function data_html_nth_token_substring() {
'Abruptly closed comment' => array( '', 1, '' ),
'Empty comment' => array( '', 1, '' ),
'Funky comment' => array( '', 1, '' ),
- 'PI lookalike comment' => array( '', 1, '' ),
+ 'Processing instruction' => array( '', 1, '' ),
+ 'PI lookalike comment' => array( '', 1, '' ),
'CDATA lookalike comment' => array( '', 1, '' ),
);
}
diff --git a/tests/phpunit/tests/html-api/wpHtmlTagProcessorModifiableText.php b/tests/phpunit/tests/html-api/wpHtmlTagProcessorModifiableText.php
index 4a09403b7b23e..35b5ed88baec0 100644
--- a/tests/phpunit/tests/html-api/wpHtmlTagProcessorModifiableText.php
+++ b/tests/phpunit/tests/html-api/wpHtmlTagProcessorModifiableText.php
@@ -349,6 +349,8 @@ public function test_get_modifiable_text_ignores_newlines_after_seeking() {
*
* @dataProvider data_tokens_not_supporting_modifiable_text_updates
*
+ * @expectedIncorrectUsage WP_HTML_Tag_Processor::set_modifiable_text
+ *
* @param string $html Contains HTML with a token not supporting modifiable text updates.
* @param int $advance_n_tokens Count of times to run `next_token()` before reaching target node.
*/
@@ -428,6 +430,8 @@ public static function data_tokens_with_basic_modifiable_text_updates() {
return array(
'Text node (start)' => array( 'Text', 1, 'Blubber', 'Blubber' ),
'Text node (middle)' => array( 'Bold move', 2, 'yo', 'yo' ),
+ 'PI node' => array( 'beforeafter', 2, 'other', 'beforeafter' ),
+ 'PI node (no separator)' => array( '', 1, '{"just": "kidding"}', '' ),
'Text node (end)' => array( 'of a dog', 2, 'of a cat', 'of a cat' ),
'Encoded text node' => array( 'birds and dogs', 2, ' & ', '<birds> & <dogs>' ),
'SCRIPT tag' => array( 'beforeafter', 2, 'const img = " & ";', 'beforeafter' ),
@@ -441,6 +445,134 @@ public static function data_tokens_with_basic_modifiable_text_updates() {
);
}
+ /**
+ * Ensures that processing instruction data updates re-parse to the value which was set.
+ *
+ * The processing instruction syntax cannot represent every data value verbatim:
+ * a separating space must be added when the data would abut the target, and
+ * the update always writes the `?>` form of the closer, whose `?` is dropped
+ * when parsing, so that data ending in `?` remains intact.
+ *
+ * @ticket 61530
+ *
+ * @dataProvider data_processing_instruction_data_updates
+ *
+ * @param string $html Contains a processing instruction as its first token.
+ * @param string $new_data Data to set on the processing instruction.
+ * @param string $expected Expected document after the update.
+ */
+ public function test_sets_processing_instruction_data( string $html, string $new_data, string $expected ): void {
+ $processor = new WP_HTML_Tag_Processor( $html );
+ $processor->next_token();
+
+ $this->assertSame(
+ '#processing-instruction',
+ $processor->get_token_name(),
+ 'Should have found a processing instruction: check test setup.'
+ );
+
+ $target = $processor->get_tag();
+
+ $this->assertTrue(
+ $processor->set_modifiable_text( $new_data ),
+ 'Should have set the processing instruction data.'
+ );
+
+ $this->assertSame(
+ $new_data,
+ $processor->get_modifiable_text(),
+ 'Should have read back the enqueued data before flushing the update.'
+ );
+
+ $this->assertSame(
+ $expected,
+ $processor->get_updated_html(),
+ 'Should have updated the document as expected.'
+ );
+
+ $this->assertSame(
+ $new_data,
+ $processor->get_modifiable_text(),
+ 'Should have read back the data after flushing the update.'
+ );
+
+ $this->assertSame(
+ $target,
+ $processor->get_tag(),
+ 'Should not have changed the processing instruction target.'
+ );
+
+ $reparsed = new WP_HTML_Tag_Processor( $expected );
+ $reparsed->next_token();
+
+ $this->assertSame(
+ '#processing-instruction',
+ $reparsed->get_token_name(),
+ 'Should have found a processing instruction when re-parsing the updated document.'
+ );
+
+ $this->assertSame(
+ $target,
+ $reparsed->get_tag(),
+ 'Should have preserved the target when re-parsing the updated document.'
+ );
+
+ $this->assertSame(
+ $new_data,
+ $reparsed->get_modifiable_text(),
+ 'Should have found the set data when re-parsing the updated document.'
+ );
+ }
+
+ /**
+ * Data provider.
+ *
+ * @return array
+ */
+ public static function data_processing_instruction_data_updates(): array {
+ return array(
+ 'Replace data' => array( '', 'after', '' ),
+ 'Bare closer rewritten' => array( '', 'after', '' ),
+ 'Separator inserted' => array( '', '{"just": "kidding"}', '' ),
+ 'Separator inserted (bare closer)' => array( '', 'data', '' ),
+ 'Data abutting target' => array( '', 'x', '' ),
+ 'Data ending in ?' => array( '', 'd?', '' ),
+ 'Data ending in ? (?> closer)' => array( '', 'd?', '' ),
+ 'Data of only ?' => array( '', '?', '' ),
+ 'Emptied (?> closer)' => array( '', '', '' ),
+ 'Emptied (bare closer)' => array( '', '', '' ),
+ 'Empty data set on empty data' => array( '', '', '' ),
+ 'Whitespace run normalized' => array( "", 'new', '' ),
+ );
+ }
+
+ /**
+ * Ensures that repeated processing instruction data updates replace
+ * each other instead of accumulating syntax adjustments.
+ *
+ * @ticket 61530
+ */
+ public function test_replaces_previous_processing_instruction_data_update(): void {
+ $processor = new WP_HTML_Tag_Processor( '' );
+ $processor->next_token();
+
+ $this->assertTrue(
+ $processor->set_modifiable_text( 'first?' ),
+ 'Should have set the initial processing instruction data.'
+ );
+
+ $this->assertTrue(
+ $processor->set_modifiable_text( 'second' ),
+ 'Should have replaced the pending processing instruction data.'
+ );
+
+ $this->assertSame(
+ '',
+ $processor->get_updated_html(),
+ 'Should have applied only the last update to the document.'
+ );
+ }
+
/**
* Ensures that updates with potentially-compromising values aren't accepted.
*
@@ -453,6 +585,8 @@ public static function data_tokens_with_basic_modifiable_text_updates() {
*
* @dataProvider data_unallowed_modifiable_text_updates
*
+ * @expectedIncorrectUsage WP_HTML_Tag_Processor::set_modifiable_text
+ *
* @param string $html_with_nonempty_modifiable_text Will be used to find the test element.
* @param string $invalid_update Update containing possibly-compromising text.
*/
@@ -490,6 +624,10 @@ public static function data_unallowed_modifiable_text_updates() {
return array(
'Comment with -->' => array( '', 'Comments end in -->' ),
'Comment with --!>' => array( '', 'Invalid but legitimate comments end in --!>' ),
+ 'PI with >' => array( '', 'Processing instructions end at the first >' ),
+ 'PI with leading space' => array( '', ' leading whitespace is skipped after the target' ),
+ 'PI with leading tab' => array( '', "\tleading whitespace is skipped after the target" ),
+ 'PI with only whitespace' => array( '', ' ' ),
'Non-JS SCRIPT with ', '