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 `""`. + * + * @link https://html.spec.whatwg.org/multipage/parsing.html#serialising-html-fragments + */ + case '#processing-instruction': + $html .= "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 `` 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; } - /* - * ` - * See https://html.spec.whatwg.org/multipage/parsing.html#tag-open-state + /** + * ``; 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, 'lexical_updates['modifiable text'] = new WP_HTML_Text_Replacement( @@ -3979,6 +4130,11 @@ static function ( $tag_match ) { return true; } + _doing_it_wrong( + __METHOD__, + __( 'Only the SCRIPT, STYLE, TEXTAREA, and TITLE tags support setting modifiable text.' ), + '7.1.0' + ); return false; } @@ -4992,6 +5148,27 @@ public function get_doctype_info(): ?WP_HTML_Doctype_Info { */ const STATE_FUNKY_COMMENT = 'STATE_WP_FUNKY'; + /** + * Indicates that the parser has found a processing instruction + * and it's possible to read its target and data. + * + * Example: + * + * + * + * Processing instructions with an allowable target are parsed + * into processing instruction nodes. The reserved `xml` and + * `xml-stylesheet` targets, and targets with characters outside + * an ASCII-representable subset, are turned into bogus comments. + * + * @link https://html.spec.whatwg.org/multipage/parsing.html#processing-instruction-open-state + * + * @since 7.1.0 + * + * @access private + */ + const STATE_PROCESSING_INSTRUCTION = 'STATE_PROCESSING_INSTRUCTION'; + /** * Indicates that a comment was created when encountering abruptly-closed HTML comment. * @@ -5032,15 +5209,23 @@ public function get_doctype_info(): ?WP_HTML_Doctype_Info { /** * Indicates that a comment would be parsed as a Processing - * Instruction node, were they to exist within HTML. + * Instruction node, were its target allowed within HTML. * * Example: * - * + * + * * - * This is an HTML comment, but it looks like a CDATA node. + * These are HTML comments, but they look like processing + * instructions. HTML parses processing instructions with + * an allowable target into processing instruction nodes, + * but the reserved `xml` and `xml-stylesheet` targets and + * XML-valid targets with characters outside of the allowed + * set become bogus comments instead. * * @since 6.5.0 + * @since 7.1.0 Only applies to reserved and XML-specific target names; + * other processing instructions produce their own token. */ const COMMENT_AS_PI_NODE_LOOKALIKE = 'COMMENT_AS_PI_NODE_LOOKALIKE'; @@ -5050,7 +5235,7 @@ public function get_doctype_info(): ?WP_HTML_Doctype_Info { * * Example: * - * + * * * * @since 6.5.0 diff --git a/src/wp-includes/script-loader.php b/src/wp-includes/script-loader.php index 134d86c26a08a..147c2e8bb7faa 100644 --- a/src/wp-includes/script-loader.php +++ b/src/wp-includes/script-loader.php @@ -3048,11 +3048,6 @@ function wp_get_inline_script_tag( $data, $attributes = array() ) { } if ( ! $processor->set_modifiable_text( $data ) ) { - _doing_it_wrong( - __FUNCTION__, - __( 'Unable to set inline script data.' ), - '7.0.0' - ); return ''; } diff --git a/tests/phpunit/includes/build-visual-html-tree.php b/tests/phpunit/includes/build-visual-html-tree.php index 4fa5bd0abeb69..d874e944ead0c 100644 --- a/tests/phpunit/includes/build-visual-html-tree.php +++ b/tests/phpunit/includes/build-visual-html-tree.php @@ -210,6 +210,11 @@ static function ( $a, $b ) { $text_node .= $text_content; break; + case '#processing-instruction': + // Processing instructions must be "". + $output .= str_repeat( $tree_indent, $indent_level ) . "get_tag()} {$processor->get_modifiable_text()}?>\n"; + break; + case '#funky-comment': // Comments must be "<" then "!-- " then the data then " -->". $output .= str_repeat( $tree_indent, $indent_level ) . "\n"; diff --git a/tests/phpunit/tests/build-visual-html-tree.php b/tests/phpunit/tests/build-visual-html-tree.php index e87cc1d1d257a..528d95a61e256 100644 --- a/tests/phpunit/tests/build-visual-html-tree.php +++ b/tests/phpunit/tests/build-visual-html-tree.php @@ -73,9 +73,21 @@ class="has-custom-classname is-style-default wp-block-separator" TREE; yield 'Text nodes in blocks' => array( $block_markup, $tree_structure ); + + yield 'Processing instruction' => array( + '
beforeafter', + <<<'TREE' +
+ "before" + + "after" + + TREE, + ); } /** + * @ticket 65582 * @ticket 63527 * @ticket 64531 * diff --git a/tests/phpunit/tests/dependencies/wpInlineScriptTag.php b/tests/phpunit/tests/dependencies/wpInlineScriptTag.php index e8fabd7226e84..590872ee3fd6c 100644 --- a/tests/phpunit/tests/dependencies/wpInlineScriptTag.php +++ b/tests/phpunit/tests/dependencies/wpInlineScriptTag.php @@ -169,9 +169,10 @@ public function test_script_tag_repeat_attributes() { * Test failure conditions setting inline script tag contents. * * @ticket 64500 + * + * @expectedIncorrectUsage WP_HTML_Tag_Processor::set_modifiable_text */ public function test_script_tag_dangerous_unescapeable_contents() { - $this->setExpectedIncorrectUsage( 'wp_get_inline_script_tag' ); /* * 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( '' ), '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( '' ), - '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( '' ), + '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( '', 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( '', 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( '', WP_HTML_Processor::COMMENT_AS_INVALID_HTML, '$var Not a target. ?' ), + 'PHP short echo tag' => array( '', 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( '' ), + 'Leading hyphen' => array( '' ), + 'Colon in target' => array( '' ), + 'Dot in target' => array( '' ), + 'Non-ASCII target' => array( '' ), + 'Empty target' => array( '' ), + 'Whitespace target' => array( '' ), + 'PHP short echo tag' => array( '' ), + ); + } + + /** + * 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( ' array( ' array( ' array( ' array( ' array( ' ". + */ + 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 > #processing-instruction' ), + 'In TABLE ROW' => 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( '<?wp-bit?>' ), + ); + } +} 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 "". + */ + $pi_data = $processor->get_modifiable_text(); + $output .= str_repeat( self::TREE_INDENT, $indent_level ) . "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 "". (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 ', '