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