PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3
Jetpack – WP Security, Backup, Speed, & Growth v16.3
16.3 16.3-beta 16.3-a.5 16.3-a.7 16.3-a.3 16.3-a.1 16.2 16.2-beta 12.0.3 12.1.3 12.2.3 12.3.2 12.4.2 12.5.2 12.6.4 12.7.3 12.8.3 12.9.5 13.0.2 13.1.5 13.2.4 13.3.3 13.4.5 13.5.2 13.6.2 All 508 releases
jetpack / jetpack_vendor / automattic / block-delimiter / src / class-block-delimiter.php

class-block-delimiter.php in Jetpack – WP Security, Backup, Speed, & Growth 16.3, at jetpack_vendor/automattic/block-delimiter/src/class-block-delimiter.php

901 lines 29.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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