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..379d7069d3b22 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 "?>"`.
+ *
+ * @see 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..69c65dae7bc16 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. ``.
+ * - a 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.
*/
@@ -2028,8 +2031,17 @@ private function parse_next_tag(): bool {
}
/*
- * `` 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.
+ *
+ * See 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 instrcutions have different parsing rules.
+ * The HTML API recognizes XML-like processing instructions that are
+ * _not_ HTML processing instrcution. 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 instrcutions.
*
* > 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 What kind of token is matched, or 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,26 @@ 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 raw syntax
+ * spanning from the start of the data through the end of the
+ * token. Find the data within it by applying the same rules
+ * used when parsing the document: whitespace following the
+ * target is skipped, the data ends before the closing `>`,
+ * and a `?` directly before that `>` belongs to the `?>`
+ * form of the closer.
+ *
+ * @see WP_HTML_Tag_Processor::set_modifiable_text()
+ */
+ if ( $has_enqueued_update && self::STATE_PROCESSING_INSTRUCTION === $this->parser_state ) {
+ $data_at = strspn( $text, " \t\f\r\n" );
+ $data_end = strlen( $text ) - 1;
+ if ( $data_end - 1 >= $data_at && '?' === $text[ $data_end - 1 ] ) {
+ --$data_end;
+ }
+ $text = substr( $text, $data_at, $data_end - $data_at );
+ }
+
/*
* Pre-processing the input stream would normally happen before
* any parsing is done, but deferring it means it's possible to
@@ -3719,12 +3807,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 +3882,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 +3924,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.
@@ -3874,6 +3968,66 @@ 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, '>' ) ) {
+ 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" ) ) {
+ return false;
+ }
+
+ $token_end = $this->token_starts_at + $this->token_length;
+
+ /**
+ * A single replacement spans from the start of the data through
+ * the end of the token, holding the raw syntax for that region:
+ * an optional separating space, the data, and the `?>` closer.
+ * The raw syntax may differ from the data it holds:
+ *
+ * - When no whitespace separates the target from its data, e.g.
+ * in ``, a space is prefixed so that the new data
+ * doesn't merge into and extend the target name.
+ *
+ * - The closer is always written in its `?>` form, whose `?` is
+ * dropped when parsing. This represents any data, including
+ * data ending in `?`: the data `d?` is written as `d??>`.
+ *
+ * `get_modifiable_text()` finds the data within the raw syntax
+ * of a pending update by applying these same rules.
+ *
+ * For example, setting `data?` on `` replaces the
+ * final `>` with ` data??>`:
+ *
+ *
+ * ┬
+ * └─ replace `>` with ` data??>`
+ *
+ * producing ``, which holds the data `data?`.
+ */
+ $needs_separator = (
+ '' !== $plaintext_content &&
+ $this->text_starts_at === $this->tag_name_starts_at + $this->tag_name_length
+ );
+
+ $this->lexical_updates['modifiable text'] = new WP_HTML_Text_Replacement(
+ $this->text_starts_at,
+ $token_end - $this->text_starts_at,
+ ( $needs_separator ? ' ' : '' ) . $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.
@@ -4992,6 +5146,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.
+ *
+ * @see 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 +5207,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 +5233,7 @@ public function get_doctype_info(): ?WP_HTML_Doctype_Info {
*
* Example:
*
- *
+ * = nothing special ?>
*
*
* @since 6.5.0
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 "", the target, a space, the data, "?", and ">".
+ $output .= str_repeat( $tree_indent, $indent_level ) . "{$processor->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/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..86d8c8957885a 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 PI-lookalike 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/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( '