| 1 |
<?php |
| 2 |
/** |
| 3 |
* Efficiently scan through block structure in document without parsing |
| 4 |
* the entire block tree and all of its JSON attributes into memory. |
| 5 |
* |
| 6 |
* @package automattic/block-delimiter |
| 7 |
*/ |
| 8 |
|
| 9 |
declare( strict_types = 1 ); |
| 10 |
|
| 11 |
namespace Automattic; |
| 12 |
|
| 13 |
use Exception; |
| 14 |
use WP_HTML_Span; |
| 15 |
|
| 16 |
/** |
| 17 |
* Class for efficiently scanning through block structure in a document |
| 18 |
* without parsing the entire block tree and JSON attributes into memory. |
| 19 |
* |
| 20 |
* This class follows design values of the HTML API: |
| 21 |
* - minimize allocations and strive for zero memory overhead |
| 22 |
* - make costs explicit; pay only for what you need |
| 23 |
* - follow a streaming, re-entrant design for pausing and aborting |
| 24 |
* |
| 25 |
* For usage, jump straight to {@see self::next_delimiter}. |
| 26 |
*/ |
| 27 |
class Block_Scanner { |
| 28 |
/** |
| 29 |
* Indicates if the last operation failed, otherwise |
| 30 |
* will be `null` for success. |
| 31 |
* |
| 32 |
* @var string|null |
| 33 |
*/ |
| 34 |
private $last_error = null; |
| 35 |
|
| 36 |
/** |
| 37 |
* Indicates failures from decoding JSON attributes. |
| 38 |
* |
| 39 |
* @var int |
| 40 |
*/ |
| 41 |
private $last_json_error = JSON_ERROR_NONE; |
| 42 |
|
| 43 |
/** |
| 44 |
* Holds a reference to the original source text from which to |
| 45 |
* extract the parsed spans of the delimiter. |
| 46 |
* |
| 47 |
* @var string |
| 48 |
*/ |
| 49 |
private $source_text; |
| 50 |
|
| 51 |
/** |
| 52 |
* Byte offset into source text where entire delimiter begins. |
| 53 |
* |
| 54 |
* @var int |
| 55 |
*/ |
| 56 |
private $delimiter_at = 0; |
| 57 |
|
| 58 |
/** |
| 59 |
* Byte length of full span of delimiter. |
| 60 |
* |
| 61 |
* @var int |
| 62 |
*/ |
| 63 |
private $delimiter_length = 0; |
| 64 |
|
| 65 |
/** |
| 66 |
* Byte offset where namespace span begins. |
| 67 |
* |
| 68 |
* @var int |
| 69 |
*/ |
| 70 |
private $namespace_at = 0; |
| 71 |
|
| 72 |
/** |
| 73 |
* Byte length of namespace span, or `0` if implicitly in the "core" namespace. |
| 74 |
* |
| 75 |
* @var int |
| 76 |
*/ |
| 77 |
private $namespace_length = 0; |
| 78 |
|
| 79 |
/** |
| 80 |
* Byte offset where block name span begins. |
| 81 |
* |
| 82 |
* @var int |
| 83 |
*/ |
| 84 |
private $name_at = 0; |
| 85 |
|
| 86 |
/** |
| 87 |
* Byte length of block name span. |
| 88 |
* |
| 89 |
* @var int |
| 90 |
*/ |
| 91 |
private $name_length = 0; |
| 92 |
|
| 93 |
/** |
| 94 |
* Whether the delimiter contains the block self-closing flag. |
| 95 |
* |
| 96 |
* This may be erroneous if present within a block closer, |
| 97 |
* therefore the {@see self::has_void_flag} can be used by |
| 98 |
* calling code to perform appropriate error-handling. |
| 99 |
* |
| 100 |
* @var bool |
| 101 |
*/ |
| 102 |
private $has_void_flag = false; |
| 103 |
|
| 104 |
/** |
| 105 |
* Byte offset where JSON attributes span begins. |
| 106 |
* |
| 107 |
* @var int |
| 108 |
*/ |
| 109 |
private $json_at; |
| 110 |
|
| 111 |
/** |
| 112 |
* Byte length of JSON attributes span, or `0` if none are present. |
| 113 |
* |
| 114 |
* @var int |
| 115 |
*/ |
| 116 |
private $json_length; |
| 117 |
|
| 118 |
/** |
| 119 |
* Indicates what kind of block comment delimiter this represents. |
| 120 |
* |
| 121 |
* One of: |
| 122 |
* |
| 123 |
* - `static::OPENER` If the delimiter is opening a block. |
| 124 |
* - `static::CLOSER` If the delimiter is closing an open block. |
| 125 |
* - `static::VOID` If the delimiter represents a void block with no inner content. |
| 126 |
* |
| 127 |
* If a parsed comment delimiter contains both the closing and the void |
| 128 |
* flags then it will be interpreted as a void block to match the behavior |
| 129 |
* of the official block parser, however, this is a mistake and probably |
| 130 |
* the block ought to close an open block of the same name, if one is open. |
| 131 |
* |
| 132 |
* @var string |
| 133 |
*/ |
| 134 |
private $type; |
| 135 |
|
| 136 |
/** |
| 137 |
* Creates a new block scanner. |
| 138 |
* |
| 139 |
* Example: |
| 140 |
* |
| 141 |
* $scanner = Block_Scanner::create( $html ); |
| 142 |
* while ( $scanner->next_delimiter() ) { |
| 143 |
* if ( $scanner->opens_block( 'core/image' ) ) { |
| 144 |
* echo "Found an image!\n"; |
| 145 |
* } |
| 146 |
* } |
| 147 |
* |
| 148 |
* This function is currently a stub so that future improvements can add configuration |
| 149 |
* options and reject creation, which cannot occur directly inside class constructors. |
| 150 |
* |
| 151 |
* @see self::next_delimiter |
| 152 |
* |
| 153 |
* @param string $source_text Input document potentially containing block content. |
| 154 |
* @return ?self Created block scanner, if successfully created. |
| 155 |
*/ |
| 156 |
public static function create( string $source_text ): ?self { |
| 157 |
return new self( $source_text ); |
| 158 |
} |
| 159 |
|
| 160 |
/** |
| 161 |
* Scan to the next block delimiter in a document, indicating if one was found. |
| 162 |
* |
| 163 |
* Block comment delimiters must be valid HTML comments and may contain JSON. |
| 164 |
* This search does not determine, however, if the JSON is valid. |
| 165 |
* |
| 166 |
* Example delimiters: |
| 167 |
* |
| 168 |
* `<!-- wp:paragraph {"dropCap": true} -->` |
| 169 |
* `<!-- wp:separator /-->` |
| 170 |
* `<!-- /wp:paragraph -->` |
| 171 |
* |
| 172 |
* In the case that a block comment delimiter contains both the void indicator and |
| 173 |
* also the closing indicator, it will be treated as a void block. |
| 174 |
* |
| 175 |
* Example: |
| 176 |
* |
| 177 |
* // Find all image block opening delimiters. |
| 178 |
* $images = array(); |
| 179 |
* $scanner = Block_Scanner::create( $html ); |
| 180 |
* while ( $scanner->next_delimiter() ) { |
| 181 |
* if ( $scanner->opens_block( 'core/image' ) ) { |
| 182 |
* $images[] = $scanner->get_span(); |
| 183 |
* } |
| 184 |
* } |
| 185 |
* |
| 186 |
* Not all blocks have explicit delimiters. Non-block content at the top-level of |
| 187 |
* a document (so-called “HTML soup”) forms implicit blocks containing neither a |
| 188 |
* block name nor block attributes. Because this content often comprises only |
| 189 |
* HTML whitespace and adds undo performance burden, it is skipped by default. |
| 190 |
* To scan the implicit freeform blocks, pass the `$freeform_blocks` argument. |
| 191 |
* |
| 192 |
* Example: |
| 193 |
* |
| 194 |
* $html = '<!-- wp:void /-->\n<!-- wp:void /-->'; |
| 195 |
* $blocks = [ |
| 196 |
* [ 'blockName' => 'core/void' ], |
| 197 |
* [ 'blockName' => null ], |
| 198 |
* [ 'blockName' => 'core/void' ], |
| 199 |
* ]; |
| 200 |
* $scanner = Block_Scanner::create( $html ); |
| 201 |
* while ( $scanner->next_delimiter( freeform_blocks: 'visit' ) { |
| 202 |
* ... |
| 203 |
* } |
| 204 |
* |
| 205 |
* In some cases it may be useful to conditionally visit the implicit freeform |
| 206 |
* blocks, such as when determining if a post contains freeform content that |
| 207 |
* isn’t purely whitespace. |
| 208 |
* |
| 209 |
* Example: |
| 210 |
* |
| 211 |
* $seen_block_types = []; |
| 212 |
* $freeform_blocks = 'visit'; |
| 213 |
* $scanner = Block_Scanner::create( $html ); |
| 214 |
* while ( $scanner->next_delimiter( freeform_blocks: $freeform_blocks ) { |
| 215 |
* if ( ! $scanner->opens_block() ) { |
| 216 |
* continue; |
| 217 |
* } |
| 218 |
* |
| 219 |
* // Stop wasting time visiting freeform blocks after one has been found. |
| 220 |
* if ('visit' === $freeform_blocks ) { |
| 221 |
* if ( $scanner->is_non_whitespace_freeform() ) { |
| 222 |
* $freeform_blocks = 'skip'; |
| 223 |
* $seen_block_types['core/freeform'] = true; |
| 224 |
* } |
| 225 |
* continue; |
| 226 |
* } |
| 227 |
* |
| 228 |
* $seen_block_types[ $scanner->get_block_type() ] = true; |
| 229 |
* } |
| 230 |
* |
| 231 |
* @param string $freeform_blocks Optional. Pass `visit` to match freeform HTML content |
| 232 |
* not surrounded by block delimiters. Defaults to `skip`. |
| 233 |
* @return bool Whether a block delimiter was matched. |
| 234 |
*/ |
| 235 |
public function next_delimiter( string $freeform_blocks = 'skip' ): bool { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable |
| 236 |
if ( $this->last_error ) { |
| 237 |
return false; |
| 238 |
} |
| 239 |
|
| 240 |
$text = $this->source_text; |
| 241 |
$end = strlen( $text ); |
| 242 |
$at = $this->delimiter_at + $this->delimiter_length; |
| 243 |
$found_one = false; |
| 244 |
|
| 245 |
while ( $at < $end ) { |
| 246 |
/* |
| 247 |
* Find the next possible opening. |
| 248 |
* |
| 249 |
* This follows the behavior in the official block parser, which treats a post |
| 250 |
* as a list of blocks with nested HTML. If HTML comment syntax appears within |
| 251 |
* an HTML attribute value, SCRIPT or STYLE element, or in other select places, |
| 252 |
* which it can do inside of HTML, then the block parsing may break. |
| 253 |
* |
| 254 |
* For a more robust parse scan through the document with the HTML API. In |
| 255 |
* practice, this has not been a problem in the entire history of blocks. |
| 256 |
*/ |
| 257 |
$comment_opening_at = strpos( $text, '<!--', $at ); |
| 258 |
if ( false === $comment_opening_at ) { |
| 259 |
return false; |
| 260 |
} |
| 261 |
|
| 262 |
$opening_whitespace_at = $comment_opening_at + 4; |
| 263 |
$opening_whitespace_length = strspn( $text, " \t\f\r\n", $opening_whitespace_at ); |
| 264 |
if ( 0 === $opening_whitespace_length ) { |
| 265 |
$at = $this->find_html_comment_end( $comment_opening_at, $end ); |
| 266 |
continue; |
| 267 |
} |
| 268 |
|
| 269 |
$wp_prefix_at = $opening_whitespace_at + $opening_whitespace_length; |
| 270 |
if ( $wp_prefix_at >= $end ) { |
| 271 |
$this->last_error = self::INCOMPLETE_INPUT; |
| 272 |
return false; |
| 273 |
} |
| 274 |
|
| 275 |
$has_closer = false; |
| 276 |
if ( '/' === $text[ $wp_prefix_at ] ) { |
| 277 |
$has_closer = true; |
| 278 |
++$wp_prefix_at; |
| 279 |
} |
| 280 |
|
| 281 |
if ( 0 !== substr_compare( $text, 'wp:', $wp_prefix_at, 3 ) ) { |
| 282 |
$at = $this->find_html_comment_end( $comment_opening_at, $end ); |
| 283 |
continue; |
| 284 |
} |
| 285 |
|
| 286 |
$namespace_at = $wp_prefix_at + 3; |
| 287 |
if ( $namespace_at >= $end ) { |
| 288 |
$this->last_error = self::INCOMPLETE_INPUT; |
| 289 |
return false; |
| 290 |
} |
| 291 |
|
| 292 |
$start_of_namespace = $text[ $namespace_at ]; |
| 293 |
|
| 294 |
// The namespace must start with a-z. |
| 295 |
if ( 'a' > $start_of_namespace || 'z' < $start_of_namespace ) { |
| 296 |
$at = $this->find_html_comment_end( $comment_opening_at, $end ); |
| 297 |
continue; |
| 298 |
} |
| 299 |
|
| 300 |
$namespace_length = 1 + strspn( $text, 'abcdefghijklmnopqrstuvwxyz0123456789-_', $namespace_at + 1 ); |
| 301 |
$separator_at = $namespace_at + $namespace_length; |
| 302 |
if ( $separator_at >= $end ) { |
| 303 |
$this->last_error = self::INCOMPLETE_INPUT; |
| 304 |
return false; |
| 305 |
} |
| 306 |
|
| 307 |
$has_separator = '/' === $text[ $separator_at ]; |
| 308 |
if ( $has_separator ) { |
| 309 |
$name_at = $separator_at + 1; |
| 310 |
$start_of_name = $text[ $name_at ]; |
| 311 |
if ( 'a' > $start_of_name || 'z' < $start_of_name ) { |
| 312 |
$at = $this->find_html_comment_end( $comment_opening_at, $end ); |
| 313 |
continue; |
| 314 |
} |
| 315 |
|
| 316 |
$name_length = 1 + strspn( $text, 'abcdefghijklmnopqrstuvwxyz0123456789-_', $name_at + 1 ); |
| 317 |
} else { |
| 318 |
$name_at = $namespace_at; |
| 319 |
$name_length = $namespace_length; |
| 320 |
$namespace_length = 0; |
| 321 |
} |
| 322 |
|
| 323 |
$after_name_whitespace_at = $name_at + $name_length; |
| 324 |
$after_name_whitespace_length = strspn( $text, " \t\f\r\n", $after_name_whitespace_at ); |
| 325 |
if ( 0 === $after_name_whitespace_length ) { |
| 326 |
$at = $this->find_html_comment_end( $comment_opening_at, $end ); |
| 327 |
continue; |
| 328 |
} |
| 329 |
|
| 330 |
$json_at = $after_name_whitespace_at + $after_name_whitespace_length; |
| 331 |
if ( $json_at >= $end ) { |
| 332 |
$this->last_error = self::INCOMPLETE_INPUT; |
| 333 |
return false; |
| 334 |
} |
| 335 |
$has_json = '{' === $text[ $json_at ]; |
| 336 |
$json_length = 0; |
| 337 |
|
| 338 |
/* |
| 339 |
* For the final span of the delimiter it's most efficient to find the end |
| 340 |
* of the HTML comment and work backwards. This prevents complicated parsing |
| 341 |
* inside the JSON span, which cannot contain the HTML comment terminator. |
| 342 |
* |
| 343 |
* This also matches the behavior in the official block parser, though it |
| 344 |
* allows for matching invalid JSON content. |
| 345 |
*/ |
| 346 |
$comment_closing_at = strpos( $text, '-->', $json_at ); |
| 347 |
if ( false === $comment_closing_at ) { |
| 348 |
$this->last_error = self::INCOMPLETE_INPUT; |
| 349 |
return false; |
| 350 |
} |
| 351 |
|
| 352 |
/* |
| 353 |
* It looks like this logic leaves an error in here, when the position |
| 354 |
* overlaps the JSON or block name. However, for neither of those is it |
| 355 |
* possible to parse a valid block if that last overlapping character |
| 356 |
* is the void flag. This, therefore, will be valid regardless of how |
| 357 |
* the rest of the comment delimiter is written. |
| 358 |
*/ |
| 359 |
if ( '/' === $text[ $comment_closing_at - 1 ] ) { |
| 360 |
$has_void_flag = true; |
| 361 |
$void_flag_length = 1; |
| 362 |
} else { |
| 363 |
$has_void_flag = false; |
| 364 |
$void_flag_length = 0; |
| 365 |
} |
| 366 |
|
| 367 |
/* |
| 368 |
* If there's no JSON, then the span of text after the name |
| 369 |
* until the comment closing must be completely whitespace. |
| 370 |
*/ |
| 371 |
if ( ! $has_json ) { |
| 372 |
$max_whitespace_length = $comment_closing_at - $json_at - $void_flag_length; |
| 373 |
|
| 374 |
// This shouldn't be possible, but it can't be allowed regardless. |
| 375 |
if ( $max_whitespace_length < 0 ) { |
| 376 |
$at = $this->find_html_comment_end( $comment_opening_at, $end ); |
| 377 |
continue; |
| 378 |
} |
| 379 |
|
| 380 |
$closing_whitespace_length = strspn( $text, " \t\f\r\n", $json_at, $comment_closing_at - $json_at - $void_flag_length ); |
| 381 |
if ( 0 === $after_name_whitespace_length + $closing_whitespace_length ) { |
| 382 |
$at = $this->find_html_comment_end( $comment_opening_at, $end ); |
| 383 |
continue; |
| 384 |
} |
| 385 |
|
| 386 |
// This must be a block delimiter! |
| 387 |
$found_one = true; |
| 388 |
break; |
| 389 |
} |
| 390 |
|
| 391 |
// There's no JSON, so attempt to find its boundary. |
| 392 |
$after_json_whitespace_length = 0; |
| 393 |
for ( $char_at = $comment_closing_at - $void_flag_length - 1; $char_at > $json_at; $char_at-- ) { |
| 394 |
$char = $text[ $char_at ]; |
| 395 |
|
| 396 |
switch ( $char ) { |
| 397 |
case ' ': |
| 398 |
case "\t": |
| 399 |
case "\f": |
| 400 |
case "\r": |
| 401 |
case "\n": |
| 402 |
++$after_json_whitespace_length; |
| 403 |
continue 2; |
| 404 |
|
| 405 |
case '}': |
| 406 |
$json_length = $char_at - $json_at + 1; |
| 407 |
break 2; |
| 408 |
|
| 409 |
default: |
| 410 |
++$at; |
| 411 |
continue 3; |
| 412 |
} |
| 413 |
} |
| 414 |
|
| 415 |
if ( 0 === $json_length || 0 === $after_json_whitespace_length ) { |
| 416 |
$at = $this->find_html_comment_end( $comment_opening_at, $end ); |
| 417 |
continue; |
| 418 |
} |
| 419 |
|
| 420 |
// This must be a block delimiter! |
| 421 |
$found_one = true; |
| 422 |
break; |
| 423 |
} |
| 424 |
|
| 425 |
if ( ! $found_one ) { |
| 426 |
return false; |
| 427 |
} |
| 428 |
|
| 429 |
$this->delimiter_at = $comment_opening_at; |
| 430 |
$this->delimiter_length = $comment_closing_at + 3 - $comment_opening_at; |
| 431 |
|
| 432 |
$this->namespace_at = $namespace_at; |
| 433 |
$this->namespace_length = $namespace_length; |
| 434 |
|
| 435 |
$this->name_at = $name_at; |
| 436 |
$this->name_length = $name_length; |
| 437 |
|
| 438 |
$this->json_at = $json_at; |
| 439 |
$this->json_length = $json_length; |
| 440 |
|
| 441 |
$this->type = $has_closer |
| 442 |
? static::CLOSER |
| 443 |
: ( $has_void_flag ? static::VOID : static::OPENER ); |
| 444 |
|
| 445 |
$this->has_void_flag = $has_void_flag; |
| 446 |
|
| 447 |
return true; |
| 448 |
} |
| 449 |
|
| 450 |
/** |
| 451 |
* Constructor function. |
| 452 |
* |
| 453 |
* @param string $source_text Input document potentially containing block content. |
| 454 |
*/ |
| 455 |
private function __construct( string $source_text ) { |
| 456 |
$this->source_text = $source_text; |
| 457 |
} |
| 458 |
|
| 459 |
/** |
| 460 |
* Returns the byte-offset after the ending character of an HTML comment, |
| 461 |
* assuming the proper starting byte offset. |
| 462 |
* |
| 463 |
* @param int $comment_starting_at Where the HTML comment started, the leading `<`. |
| 464 |
* @param int $search_end Last offset in which to search, for limiting search span. |
| 465 |
* @return int Offset after the current HTML comment ends, or `$end` if no end was found. |
| 466 |
*/ |
| 467 |
private function find_html_comment_end( int $comment_starting_at, int $search_end ): int { |
| 468 |
$text = $this->source_text; |
| 469 |
|
| 470 |
// Find span-of-dashes comments which look like `<!----->`. |
| 471 |
$span_of_dashes = strspn( $text, '-', $comment_starting_at + 2 ); |
| 472 |
if ( |
| 473 |
$comment_starting_at + 2 + $span_of_dashes < $search_end && |
| 474 |
'>' === $text[ $comment_starting_at + 2 + $span_of_dashes ] |
| 475 |
) { |
| 476 |
return $comment_starting_at + $span_of_dashes + 1; |
| 477 |
} |
| 478 |
|
| 479 |
// Otherwise, there are other characters inside the comment, find the first `-->` or `--!>`. |
| 480 |
$now_at = $comment_starting_at + 4; |
| 481 |
while ( $now_at < $search_end ) { |
| 482 |
$dashes_at = strpos( $text, '--', $now_at ); |
| 483 |
if ( false === $dashes_at ) { |
| 484 |
$this->last_error = self::INCOMPLETE_INPUT; |
| 485 |
return $search_end; |
| 486 |
} |
| 487 |
|
| 488 |
$closer_must_be_at = $dashes_at + 2 + strspn( $text, '-', $dashes_at + 2 ); |
| 489 |
if ( $closer_must_be_at < $search_end && '!' === $text[ $closer_must_be_at ] ) { |
| 490 |
$closer_must_be_at++; |
| 491 |
} |
| 492 |
|
| 493 |
if ( $closer_must_be_at < $search_end && '>' === $text[ $closer_must_be_at ] ) { |
| 494 |
return $closer_must_be_at + 1; |
| 495 |
} |
| 496 |
|
| 497 |
$now_at++; |
| 498 |
} |
| 499 |
|
| 500 |
return $search_end; |
| 501 |
} |
| 502 |
|
| 503 |
/** |
| 504 |
* Indicates if the last attempt to parse a block comment delimiter |
| 505 |
* failed, if set, otherwise `null` if the last attempt succeeded. |
| 506 |
* |
| 507 |
* @return string|null |
| 508 |
*/ |
| 509 |
public function get_last_error() { |
| 510 |
return $this->last_error; |
| 511 |
} |
| 512 |
|
| 513 |
/** |
| 514 |
* Indicates if the last attempt to parse a block’s JSON attributes failed. |
| 515 |
* |
| 516 |
* @see JSON_ERROR_NONE, JSON_ERROR_DEPTH, etc… |
| 517 |
* |
| 518 |
* @return int JSON_ERROR_ code from last attempt to parse block JSON attributes. |
| 519 |
*/ |
| 520 |
public function get_last_json_error(): int { |
| 521 |
return $this->last_json_error; |
| 522 |
} |
| 523 |
|
| 524 |
/** |
| 525 |
* Returns the type of the block comment delimiter. |
| 526 |
* |
| 527 |
* One of: |
| 528 |
* |
| 529 |
* - `static::OPENER` |
| 530 |
* - `static::CLOSER` |
| 531 |
* - `static::VOID` |
| 532 |
* |
| 533 |
* @return string type of the block comment delimiter. |
| 534 |
*/ |
| 535 |
public function get_delimiter_type(): string { |
| 536 |
return $this->type; |
| 537 |
} |
| 538 |
|
| 539 |
/** |
| 540 |
* Returns whether the delimiter contains the void flag. |
| 541 |
* |
| 542 |
* This should be avoided except in cases of handling errors with |
| 543 |
* block closers containing the void flag. For normative use, |
| 544 |
* {@see self::get_delimiter_type}. |
| 545 |
* |
| 546 |
* @return bool |
| 547 |
*/ |
| 548 |
public function has_void_flag(): bool { |
| 549 |
return $this->has_void_flag; |
| 550 |
} |
| 551 |
|
| 552 |
/** |
| 553 |
* Indicates if the block delimiter represents a block of the given type. |
| 554 |
* |
| 555 |
* Since the "core" namespace may be implicit, it's allowable to pass |
| 556 |
* either the fully-qualified block type with namespace and block name |
| 557 |
* as well as the shorthand version only containing the block name, if |
| 558 |
* the desired block is in the "core" namespace. |
| 559 |
* |
| 560 |
* Example: |
| 561 |
* |
| 562 |
* $is_core_paragraph = $scanner->is_block_type( 'paragraph' ); |
| 563 |
* $is_core_paragraph = $scanner->is_block_type( 'core/paragraph' ); |
| 564 |
* $is_formula = $scanner->is_block_type( 'math-block/formula' ); |
| 565 |
* |
| 566 |
* @param string $block_type Block type name for the desired block. |
| 567 |
* E.g. "paragraph", "core/paragraph", "math-blocks/formula". |
| 568 |
* @return bool Whether this delimiter represents a block of the given type. |
| 569 |
*/ |
| 570 |
public function is_block_type( string $block_type ): bool { |
| 571 |
// This is a core/freeform text block, it’s special. |
| 572 |
if ( 0 === $this->name_length ) { |
| 573 |
return 'core/freeform' === $block_type || 'freeform' === $block_type; |
| 574 |
} |
| 575 |
|
| 576 |
$slash_at = strpos( $block_type, '/' ); |
| 577 |
if ( false === $slash_at ) { |
| 578 |
$namespace = 'core'; |
| 579 |
$block_name = $block_type; |
| 580 |
} else { |
| 581 |
// @todo Get lengths but avoid the allocation, use substr_compare below. |
| 582 |
$namespace = substr( $block_type, 0, $slash_at ); |
| 583 |
$block_name = substr( $block_type, $slash_at + 1 ); |
| 584 |
} |
| 585 |
|
| 586 |
// Only the 'core' namespace is allowed to be omitted. |
| 587 |
if ( 0 === $this->namespace_length && 'core' !== $namespace ) { |
| 588 |
return false; |
| 589 |
} |
| 590 |
|
| 591 |
// If given an explicit namespace, they must match. |
| 592 |
if ( |
| 593 |
0 !== $this->namespace_length && ( |
| 594 |
strlen( $namespace ) !== $this->namespace_length || |
| 595 |
0 !== substr_compare( $this->source_text, $namespace, $this->namespace_at, $this->namespace_length ) |
| 596 |
) |
| 597 |
) { |
| 598 |
return false; |
| 599 |
} |
| 600 |
|
| 601 |
// The block name must match. |
| 602 |
return ( |
| 603 |
strlen( $block_name ) === $this->name_length && |
| 604 |
0 === substr_compare( $this->source_text, $block_name, $this->name_at, $this->name_length ) |
| 605 |
); |
| 606 |
} |
| 607 |
|
| 608 |
/** |
| 609 |
* Indicates if the matched delimiter is an opening or void delimiter |
| 610 |
* (i.e. it opens the block) of the given type, if a type is provided. |
| 611 |
* |
| 612 |
* This is a helper method to ease handling of code inspecting where |
| 613 |
* blocks start, and of checking if the blocks are of a given type. |
| 614 |
* The function is variadic to allow for checking if the delimiter |
| 615 |
* opens one of many possible block types. |
| 616 |
* |
| 617 |
* Example: |
| 618 |
* |
| 619 |
* $scanner = Block_Scanner::create( $html ); |
| 620 |
* while ( $scanner->next_delimiter() ) { |
| 621 |
* if ( $scanner->opens_block( 'core/code', 'syntaxhighlighter/code' ) ) { |
| 622 |
* echo "Found code!"; |
| 623 |
* continue; |
| 624 |
* } |
| 625 |
* |
| 626 |
* if ( $scanner->opens_block( 'core/image' ) ) { |
| 627 |
* echo "Found an image!"; |
| 628 |
* continue; |
| 629 |
* } |
| 630 |
* |
| 631 |
* if ( $scanner->opens_block() ) { |
| 632 |
* echo "Found a new block!"; |
| 633 |
* } |
| 634 |
* } |
| 635 |
* |
| 636 |
* @see self::is_block_type |
| 637 |
* |
| 638 |
* @param string|null ...$block_type Optional. Is the matched block type one of these? |
| 639 |
* If none are provided, will not test block type. |
| 640 |
* @return bool Whether the matched block delimiter opens a block, and whether it |
| 641 |
* opens a block of one of the given block types, if provided. |
| 642 |
*/ |
| 643 |
public function opens_block( ...$block_type ): bool { |
| 644 |
if ( static::CLOSER === $this->type ) { |
| 645 |
return false; |
| 646 |
} |
| 647 |
|
| 648 |
if ( count( $block_type ) === 0 ) { |
| 649 |
return true; |
| 650 |
} |
| 651 |
|
| 652 |
foreach ( $block_type as $block ) { |
| 653 |
if ( $this->is_block_type( $block ) ) { |
| 654 |
return true; |
| 655 |
} |
| 656 |
} |
| 657 |
|
| 658 |
return false; |
| 659 |
} |
| 660 |
|
| 661 |
/** |
| 662 |
* Indicates if the matched delimiter is implied due to top-level |
| 663 |
* non-block content in the post. |
| 664 |
* |
| 665 |
* @see self::is_non_whitespace_freeform |
| 666 |
* |
| 667 |
* @return bool Whether or not the matched delimiter is implied as `core/freeform`. |
| 668 |
*/ |
| 669 |
public function is_freeform(): bool { |
| 670 |
return 0 === $this->name_length; |
| 671 |
} |
| 672 |
|
| 673 |
/** |
| 674 |
* Indicates if the matched delimiter is implicit and surrounding |
| 675 |
* top-level non-block content that contains non-whitespace text. |
| 676 |
* |
| 677 |
* Many block serializers introduce newlines between block delimiters, |
| 678 |
* so the presence of top-level non-block content does not imply that |
| 679 |
* there are “real” freeform HTML blocks. Checking if there is content |
| 680 |
* beyond whitespace is a more certain check, such as for determining |
| 681 |
* whether to load CSS for the freeform or fallback block type. |
| 682 |
* |
| 683 |
* @see self::is_freeform |
| 684 |
* |
| 685 |
* @return bool |
| 686 |
*/ |
| 687 |
public function is_non_whitespace_freeform(): bool { |
| 688 |
if ( 0 !== $this->name_length ) { |
| 689 |
return false; |
| 690 |
} |
| 691 |
|
| 692 |
// For now, return false as this method is not yet fully implemented. |
| 693 |
// @todo Implement logic to check if freeform content contains non-whitespace text. |
| 694 |
return false; |
| 695 |
} |
| 696 |
|
| 697 |
/** |
| 698 |
* Allocates a substring for the block type and returns the |
| 699 |
* fully-qualified name, including the namespace. |
| 700 |
* |
| 701 |
* This function allocates a substring for the given block type. This |
| 702 |
* allocation will be small and likely fine in most cases, but it's |
| 703 |
* preferable to call {@link self::is_block_type} if only needing |
| 704 |
* to know whether the delimiter is for a given block type, as that |
| 705 |
* function is more efficient for this purpose and avoids the allocation. |
| 706 |
* |
| 707 |
* Example: |
| 708 |
* |
| 709 |
* // Avoid. |
| 710 |
* 'core/paragraph' = $scanner->get_block_type(); |
| 711 |
* |
| 712 |
* // Prefer. |
| 713 |
* $scanner->is_block_type( 'core/paragraph' ); |
| 714 |
* $scanner->is_block_type( 'paragraph' ); |
| 715 |
* |
| 716 |
* @return string Fully-qualified block namespace and type, e.g. "core/paragraph". |
| 717 |
*/ |
| 718 |
public function get_block_type(): string { |
| 719 |
// This is a core/freeform text block, it’s special. |
| 720 |
if ( 0 === $this->name_length ) { |
| 721 |
return 'core/freeform'; |
| 722 |
} |
| 723 |
|
| 724 |
// This is implicitly in the "core" namespace. |
| 725 |
if ( 0 === $this->namespace_length ) { |
| 726 |
$block_name = substr( $this->source_text, $this->name_at, $this->name_length ); |
| 727 |
return "core/{$block_name}"; |
| 728 |
} |
| 729 |
|
| 730 |
return substr( $this->source_text, $this->namespace_at, $this->namespace_length + $this->name_length + 1 ); |
| 731 |
} |
| 732 |
|
| 733 |
/** |
| 734 |
* Returns a lazy wrapper around the block attributes, which can be used |
| 735 |
* for efficiently interacting with the JSON attributes. |
| 736 |
* |
| 737 |
* @throws Exception This function is not yet implemented. |
| 738 |
* |
| 739 |
* @todo Create a lazy JSON wrapper so specific attributes can be |
| 740 |
* efficiently queried without parsing everything and loading |
| 741 |
* the entire object into memory. |
| 742 |
* @todo After realistic benchmarking, see if JsonStreamingParser\Parser |
| 743 |
* could be used — it would need to be fast enough for the reduction |
| 744 |
* in memory use to be worth it, compared to {@see \json_decode}. |
| 745 |
* |
| 746 |
* @see \JsonStreamingParser\Parser |
| 747 |
* |
| 748 |
* @return never |
| 749 |
*/ |
| 750 |
public function get_attributes(): void { |
| 751 |
throw new Exception( 'Lazy attribute parsing not yet supported' ); |
| 752 |
} |
| 753 |
|
| 754 |
/** |
| 755 |
* Attempts to parse and return the entire JSON attributes from the delimiter, |
| 756 |
* allocating memory and processing the JSON span in the process. |
| 757 |
* |
| 758 |
* This does not return any parsed attributes for a closing block delimiter |
| 759 |
* even if there is a span of JSON content; this JSON is a parsing error. |
| 760 |
* |
| 761 |
* Consider calling {@link self::get_attributes} instead if it's not |
| 762 |
* necessary to read all the attributes at the same time, as that provides |
| 763 |
* a more efficient mechanism for typical use cases. |
| 764 |
* |
| 765 |
* Since the JSON span inside the comment delimiter may not be valid JSON, |
| 766 |
* this function will return `null` if it cannot parse the span and set the |
| 767 |
* {@see self::get_last_json_error} to the appropriate JSON_ERROR_ constant. |
| 768 |
* |
| 769 |
* If the delimiter contains no JSON span, it will also return `null`, |
| 770 |
* but the last error will be set to {@see JSON_ERROR_NONE}. |
| 771 |
* |
| 772 |
* Example: |
| 773 |
* |
| 774 |
* $scanner = Block_Scanner::create( '<!-- wp:image {"url": "https://wordpress.org/favicon.ico"} -->' ); |
| 775 |
* $scanner->next_delimiter(); |
| 776 |
* $memory_hungry_and_slow_attributes = $scanner->allocate_and_return_parsed_attributes(); |
| 777 |
* $memory_hungry_and_slow_attributes === array( 'url' => 'https://wordpress.org/favicon.ico' ); |
| 778 |
* |
| 779 |
* $scanner = Block_Scanner::create( '<!-- /wp:image {"url": "https://wordpress.org/favicon.ico"} -->' ); |
| 780 |
* $scanner->next_delimiter(); |
| 781 |
* null = $scanner->allocate_and_return_parsed_attributes(); |
| 782 |
* JSON_ERROR_NONE = $scanner->get_last_json_error(); |
| 783 |
* |
| 784 |
* $scanner = Block_Scanner::create( '<!-- wp:separator {} /-->' ); |
| 785 |
* $scanner->next_delimiter(); |
| 786 |
* array() === $scanner->allocate_and_return_parsed_attributes(); |
| 787 |
* |
| 788 |
* $scanner = Block_Scanner::create( '<!-- wp:separator /-->' ); |
| 789 |
* $scanner->next_delimiter(); |
| 790 |
* null = $scanner->allocate_and_return_parsed_attributes(); |
| 791 |
* |
| 792 |
* $scanner = Block_Scanner::create( '<!-- wp:image {"url} -->' ); |
| 793 |
* $scanner->next_delimiter(); |
| 794 |
* null = $scanner->allocate_and_return_parsed_attributes(); |
| 795 |
* JSON_ERROR_CTRL_CHAR = $scanner->get_last_json_error(); |
| 796 |
* |
| 797 |
* @return array|null Parsed JSON attributes, if present and valid, otherwise `null`. |
| 798 |
*/ |
| 799 |
public function allocate_and_return_parsed_attributes(): ?array { |
| 800 |
$this->last_json_error = JSON_ERROR_NONE; |
| 801 |
|
| 802 |
if ( static::CLOSER === $this->type ) { |
| 803 |
return null; |
| 804 |
} |
| 805 |
|
| 806 |
if ( 0 === $this->json_length ) { |
| 807 |
return null; |
| 808 |
} |
| 809 |
|
| 810 |
$json_span = substr( $this->source_text, $this->json_at, $this->json_length ); |
| 811 |
$parsed = json_decode( $json_span, null, 512, JSON_OBJECT_AS_ARRAY | JSON_INVALID_UTF8_SUBSTITUTE ); |
| 812 |
|
| 813 |
$last_error = json_last_error(); |
| 814 |
$this->last_json_error = $last_error; |
| 815 |
|
| 816 |
return ( JSON_ERROR_NONE === $last_error && is_array( $parsed ) ) |
| 817 |
? $parsed |
| 818 |
: null; |
| 819 |
} |
| 820 |
|
| 821 |
/** |
| 822 |
* Returns the span representing the currently-matched delimiter, |
| 823 |
* if matched, otherwise `null`. |
| 824 |
* |
| 825 |
* Note that for freeform blocks this will return a span of length |
| 826 |
* zero, since there is no explicit block delimiter. |
| 827 |
* |
| 828 |
* Example: |
| 829 |
* |
| 830 |
* $scanner = Block_Scanner::create( '<!-- wp:void /-->' ); |
| 831 |
* null === $scanner->get_span(); |
| 832 |
* |
| 833 |
* $scanner->next_delimiter(); |
| 834 |
* WP_HTML_Span( 0, 17 ) === $scanner->get_span(); |
| 835 |
* |
| 836 |
* @return WP_HTML_Span|null Span of text in source text spanning matched delimiter. |
| 837 |
*/ |
| 838 |
public function get_span(): ?WP_HTML_Span { |
| 839 |
return new WP_HTML_Span( $this->delimiter_at, $this->delimiter_length ); |
| 840 |
} |
| 841 |
|
| 842 |
// Debugging methods not meant for production use. |
| 843 |
|
| 844 |
/** |
| 845 |
* Prints a debugging message showing the structure of the parsed delimiter. |
| 846 |
* |
| 847 |
* This is not meant to be used in production! |
| 848 |
* |
| 849 |
* @access private |
| 850 |
*/ |
| 851 |
public function debug_print_structure(): void { |
| 852 |
$c = ( ! defined( 'STDOUT' ) || posix_isatty( STDOUT ) ) |
| 853 |
? function ( $color = null ) { return $color; } // phpcs:ignore |
| 854 |
: function ( $color ) { return ''; }; // phpcs:ignore |
| 855 |
|
| 856 |
if ( $this->is_block_type( 'core/freeform' ) ) { |
| 857 |
$closer = static::CLOSER === $this->get_delimiter_type() ? '/' : ''; |
| 858 |
echo "{$c( "\e[90m" )}<!-- "; // phpcs:ignore |
| 859 |
echo "{$c( "\e[0;31m" )}{$closer}"; // phpcs:ignore |
| 860 |
echo "{$c("\e[90m" )}wp:"; // phpcs:ignore |
| 861 |
echo "{$c( "\e[0;34m" )}freeform"; // phpcs:ignore |
| 862 |
echo "{$c( "\e[0;36m" )} {$c("\e[90m")}-->\n"; // phpcs:ignore |
| 863 |
return; |
| 864 |
} |
| 865 |
|
| 866 |
$namespace = substr( $this->source_text, $this->namespace_at, $this->namespace_length ); |
| 867 |
$slash = 0 === $this->namespace_length ? '' : '/'; |
| 868 |
$block_name = substr( $this->source_text, $this->name_at, $this->name_length ); |
| 869 |
$closer = static::CLOSER === $this->type ? '/' : ''; |
| 870 |
$json = substr( $this->source_text, $this->json_at, $this->json_length ); |
| 871 |
|
| 872 |
$opener_whitespace_at = $this->delimiter_at + 4; |
| 873 |
$opener_whitespace_length = $this->namespace_at - 3 - $opener_whitespace_at - ( static::CLOSER === $this->type ? 1 : 0 ); |
| 874 |
|
| 875 |
$after_name_whitespace_at = $this->name_at + $this->name_length; |
| 876 |
$after_name_whitespace_length = $this->json_at - $after_name_whitespace_at; |
| 877 |
|
| 878 |
$closing_whitespace_at = $this->json_at + $this->json_length; |
| 879 |
$closing_whitespace_length = $this->delimiter_at + $this->delimiter_length - 3 - $closing_whitespace_at; |
| 880 |
|
| 881 |
if ( '/' === $this->source_text[ $this->delimiter_at + $this->delimiter_length - 4 ] ) { |
| 882 |
$void_flag = '/'; |
| 883 |
--$closing_whitespace_length; |
| 884 |
} else { |
| 885 |
$void_flag = ''; |
| 886 |
} |
| 887 |
|
| 888 |
$w = function ( $whitespace ) use ( $c ) { |
| 889 |
return $c( "\e[2;90m" ) . str_replace( array( ' ', "\t", "\f", "\r", "\n" ), array( '␣', '␉', '␌', '␍', '' ), $whitespace ); |
| 890 |
}; |
| 891 |
|
| 892 |
echo "{$c( "\e[90m" )}<!--"; // phpcs:ignore |
| 893 |
echo $w( substr( $this->source_text, $opener_whitespace_at, $opener_whitespace_length ) ); // phpcs:ignore |
| 894 |
echo "{$c( "\e[0;31m" )}{$closer}"; // phpcs:ignore |
| 895 |
echo "{$c("\e[90m" )}wp:{$c( "\e[2;34m" )}{$namespace}"; // phpcs:ignore |
| 896 |
echo "{$c( "\e[2;90m" )}{$slash}"; // phpcs:ignore |
| 897 |
echo "{$c( "\e[0;34m" )}{$block_name}"; // phpcs:ignore |
| 898 |
echo $w( substr( $this->source_text, $after_name_whitespace_at, $after_name_whitespace_length ) ); // phpcs:ignore |
| 899 |
echo "{$c("\e[0;2;32m" )}{$json}"; // phpcs:ignore |
| 900 |
echo $w( substr( $this->source_text, $closing_whitespace_at, $closing_whitespace_length ) ); // phpcs:ignore |
| 901 |
echo "{$c( "\e[0;36m" )}{$void_flag}{$c("\e[90m")}-->\n"; // phpcs:ignore |
| 902 |
} |
| 903 |
|
| 904 |
// Constant declarations that would otherwise pollute the top of the class. |
| 905 |
|
| 906 |
/** |
| 907 |
* Indicates that the block comment delimiter closes an open block. |
| 908 |
*/ |
| 909 |
const CLOSER = 'closer'; |
| 910 |
|
| 911 |
/** |
| 912 |
* Indicates that the parser started parsing a block comment delimiter, but |
| 913 |
* the input document ended before it could finish. The document was likely truncated. |
| 914 |
*/ |
| 915 |
const INCOMPLETE_INPUT = 'incomplete-input'; |
| 916 |
|
| 917 |
/** |
| 918 |
* Indicates that the block comment delimiter opens a block. |
| 919 |
*/ |
| 920 |
const OPENER = 'opener'; |
| 921 |
|
| 922 |
/** |
| 923 |
* Indicates that the parser has not yet attempted to parse a block comment delimiter. |
| 924 |
*/ |
| 925 |
const UNINITIALIZED = 'uninitialized'; |
| 926 |
|
| 927 |
/** |
| 928 |
* Indicates that the block comment delimiter represents a void block |
| 929 |
* with no inner content of any kind. |
| 930 |
*/ |
| 931 |
const VOID = 'void'; |
| 932 |
} |
| 933 |
|