initial
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.3.8] - 2026-06-08
|
||||
### Changed
|
||||
- Internal updates.
|
||||
|
||||
## [0.3.7] - 2026-05-19
|
||||
### Changed
|
||||
- Internal updates.
|
||||
|
||||
## [0.3.6] - 2026-05-04
|
||||
### Changed
|
||||
- Internal: No longer require automattic/jetpack-changelogger as a per-project dev dependency. [#48225]
|
||||
|
||||
## [0.3.5] - 2026-02-10
|
||||
### Changed
|
||||
- Internal updates.
|
||||
|
||||
## [0.3.4] - 2025-09-01
|
||||
### Changed
|
||||
- Internal updates.
|
||||
|
||||
## [0.3.3] - 2025-08-18
|
||||
### Changed
|
||||
- Internal updates.
|
||||
|
||||
## [0.3.2] - 2025-08-04
|
||||
### Changed
|
||||
- Internal updates.
|
||||
|
||||
## [0.3.1] - 2025-07-21
|
||||
### Changed
|
||||
- Scanner: Revert changes to returned parsed attributes. [#44365]
|
||||
|
||||
## [0.3.0] - 2025-07-14
|
||||
### Added
|
||||
- Add Block_Scanner class as a replacement to Block_Delimiter. [#44158]
|
||||
|
||||
## [0.2.1] - 2025-06-23
|
||||
### Changed
|
||||
- Performance improvement: Extract anonymous function into static method. [#43984]
|
||||
|
||||
## [0.2.0] - 2025-06-09
|
||||
### Changed
|
||||
- Update PHPCS and Phan configuration to be more permissive, and update main class accordingly. [#43683]
|
||||
|
||||
## 0.1.0 - 2025-05-28
|
||||
### Added
|
||||
- Initial release.
|
||||
|
||||
[0.3.8]: https://github.com/Automattic/block-delimiter/compare/v0.3.7...v0.3.8
|
||||
[0.3.7]: https://github.com/Automattic/block-delimiter/compare/v0.3.6...v0.3.7
|
||||
[0.3.6]: https://github.com/Automattic/block-delimiter/compare/v0.3.5...v0.3.6
|
||||
[0.3.5]: https://github.com/Automattic/block-delimiter/compare/v0.3.4...v0.3.5
|
||||
[0.3.4]: https://github.com/Automattic/block-delimiter/compare/v0.3.3...v0.3.4
|
||||
[0.3.3]: https://github.com/Automattic/block-delimiter/compare/v0.3.2...v0.3.3
|
||||
[0.3.2]: https://github.com/Automattic/block-delimiter/compare/v0.3.1...v0.3.2
|
||||
[0.3.1]: https://github.com/Automattic/block-delimiter/compare/v0.3.0...v0.3.1
|
||||
[0.3.0]: https://github.com/Automattic/block-delimiter/compare/v0.2.1...v0.3.0
|
||||
[0.2.1]: https://github.com/Automattic/block-delimiter/compare/v0.2.0...v0.2.1
|
||||
[0.2.0]: https://github.com/Automattic/block-delimiter/compare/v0.1.0...v0.2.0
|
||||
+900
@@ -0,0 +1,900 @@
|
||||
<?php
|
||||
/**
|
||||
* Efficiently working with block structure.
|
||||
*
|
||||
* @package automattic/block-delimiter
|
||||
*/
|
||||
|
||||
declare( strict_types = 1 );
|
||||
|
||||
namespace Automattic;
|
||||
|
||||
use Exception;
|
||||
|
||||
/**
|
||||
* Class for efficiently working with block structure.
|
||||
*
|
||||
* This class follows design values of the HTML API:
|
||||
* - minimize allocations and strive for zero memory overhead
|
||||
* - make costs explicit; pay only for what you need
|
||||
* - follow a streaming, re-entrant design for pausing and aborting
|
||||
*
|
||||
* For usage, jump straight to {@see static::next_delimiter}.
|
||||
*/
|
||||
class Block_Delimiter {
|
||||
/**
|
||||
* Indicates if the last operation failed, otherwise
|
||||
* will be `null` for success.
|
||||
*
|
||||
* @var string|null
|
||||
*/
|
||||
private static $last_error = self::UNINITIALIZED;
|
||||
|
||||
/**
|
||||
* Indicates failures from decoding JSON attributes.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
private $last_json_error = JSON_ERROR_NONE;
|
||||
|
||||
/**
|
||||
* Holds a reference to the original source text from which to
|
||||
* extract the parsed spans of the delimiter.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private $source_text;
|
||||
|
||||
/**
|
||||
* Byte offset into source text where entire delimiter begins.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
private $delimiter_at;
|
||||
|
||||
/**
|
||||
* Byte length of full span of delimiter.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
private $delimiter_length;
|
||||
|
||||
/**
|
||||
* Byte offset where namespace span begins.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
private $namespace_at;
|
||||
|
||||
/**
|
||||
* Byte length of namespace span, or `0` if implicitly in the "core" namespace.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
private $namespace_length;
|
||||
|
||||
/**
|
||||
* Byte offset where block name span begins.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
private $name_at;
|
||||
|
||||
/**
|
||||
* Byte length of block name span.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
private $name_length;
|
||||
|
||||
/**
|
||||
* Whether the delimiter contains the block self-closing flag.
|
||||
*
|
||||
* This may be erroneous if present within a block closer,
|
||||
* therefore the {@see self::has_void_flag} can be used by
|
||||
* calling code to perform appropriate error-handling.
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
private $has_void_flag = false;
|
||||
|
||||
/**
|
||||
* Byte offset where JSON attributes span begins.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
private $json_at;
|
||||
|
||||
/**
|
||||
* Byte length of JSON attributes span, or `0` if none are present.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
private $json_length;
|
||||
|
||||
/**
|
||||
* Indicates what kind of block comment delimiter this represents.
|
||||
*
|
||||
* One of:
|
||||
*
|
||||
* - `static::OPENER` If the delimiter is opening a block.
|
||||
* - `static::CLOSER` If the delimiter is closing an open block.
|
||||
* - `static::VOID` If the delimiter represents a void block with no inner content.
|
||||
*
|
||||
* If a parsed comment delimiter contains both the closing and the void
|
||||
* flags then it will be interpreted as a void block to match the behavior
|
||||
* of the official block parser, however, this is a mistake and probably
|
||||
* the block ought to close an open block of the same name, if one is open.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private $type;
|
||||
|
||||
/**
|
||||
* Finds the next block delimiter in a text document and returns a parsed
|
||||
* block delimiter info record if it parses, otherwise returns `null`.
|
||||
*
|
||||
* Block comment delimiters must be valid HTML comments and may contain JSON.
|
||||
* This search does not determine, however, if the JSON is valid.
|
||||
*
|
||||
* Example delimiters:
|
||||
*
|
||||
* `<!-- wp:paragraph {"dropCap": true} -->`
|
||||
* `<!-- wp:separator /-->`
|
||||
* `<!-- /wp:paragraph -->`
|
||||
*
|
||||
* In the case that a block comment delimiter contains both the void indicator and
|
||||
* also the closing indicator, it will be treated as a void block.
|
||||
*
|
||||
* Example:
|
||||
*
|
||||
* // Find all image block opening delimiters.
|
||||
* $at = 0;
|
||||
* $end = strlen( $html );
|
||||
* $images = array();
|
||||
* while ( $at < $end ) {
|
||||
* $delimiter = Block_Delimiter::next_delimiter( $html, $at, $next_at, $next_length );
|
||||
* if ( ! isset( $delimiter ) ) {
|
||||
* break;
|
||||
* }
|
||||
*
|
||||
* if (
|
||||
* Block_Delimiter::OPENER === $delimiter->get_delimiter_type() &&
|
||||
* $delimiter->is_block_type( 'core/image' )
|
||||
* ) {
|
||||
* $images[] = $delimiter;
|
||||
* }
|
||||
*
|
||||
* $at = $next_at + $next_length;
|
||||
* }
|
||||
*
|
||||
* @param string $text Input document possibly containing block comment delimiters.
|
||||
* @param int $starting_byte_offset Where in the input document to begin searching.
|
||||
* @param int|null $match_byte_offset Optional. When provided, will be set to the byte offset in
|
||||
* the input document where the delimiter was found, if one
|
||||
* is found, otherwise not set.
|
||||
* @param int|null $match_byte_length Optional. When provided, will be set to the byte length of
|
||||
* the matched delimiter if one is found, otherwise not set.
|
||||
* @return Block_Delimiter|null Parsed block delimiter info record if found, otherwise `null`.
|
||||
*/
|
||||
public static function next_delimiter( string $text, int $starting_byte_offset, ?int &$match_byte_offset = null, ?int &$match_byte_length = null ): ?Block_Delimiter {
|
||||
$end = strlen( $text );
|
||||
$at = $starting_byte_offset;
|
||||
$delimiter = null;
|
||||
static::$last_error = null;
|
||||
|
||||
while ( $at < $end ) {
|
||||
/*
|
||||
* Find the next possible opening.
|
||||
*
|
||||
* This follows the behavior in the official block parser, which treats a post
|
||||
* as a list of blocks with nested HTML. If HTML comment syntax appears within
|
||||
* an HTML attribute value, SCRIPT or STYLE element, or in other select places,
|
||||
* which it can do inside of HTML, then the block parsing may break.
|
||||
*
|
||||
* For a more robust parse scan through the document with the HTML API. In
|
||||
* practice, this has not been a problem in the entire history of blocks.
|
||||
*/
|
||||
$comment_opening_at = strpos( $text, '<!--', $at );
|
||||
if ( false === $comment_opening_at ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$opening_whitespace_at = $comment_opening_at + 4;
|
||||
$opening_whitespace_length = strspn( $text, " \t\f\r\n", $opening_whitespace_at );
|
||||
if ( 0 === $opening_whitespace_length ) {
|
||||
$at = self::find_html_comment_end( $text, $comment_opening_at, $end );
|
||||
continue;
|
||||
}
|
||||
|
||||
$wp_prefix_at = $opening_whitespace_at + $opening_whitespace_length;
|
||||
if ( $wp_prefix_at >= $end ) {
|
||||
static::$last_error = self::INCOMPLETE_INPUT;
|
||||
return null;
|
||||
}
|
||||
|
||||
$has_closer = false;
|
||||
if ( '/' === $text[ $wp_prefix_at ] ) {
|
||||
$has_closer = true;
|
||||
++$wp_prefix_at;
|
||||
}
|
||||
|
||||
if ( 0 !== substr_compare( $text, 'wp:', $wp_prefix_at, 3 ) ) {
|
||||
$at = self::find_html_comment_end( $text, $comment_opening_at, $end );
|
||||
continue;
|
||||
}
|
||||
|
||||
$namespace_at = $wp_prefix_at + 3;
|
||||
if ( $namespace_at >= $end ) {
|
||||
static::$last_error = self::INCOMPLETE_INPUT;
|
||||
return null;
|
||||
}
|
||||
|
||||
$start_of_namespace = $text[ $namespace_at ];
|
||||
|
||||
// The namespace must start with a-z.
|
||||
if ( 'a' > $start_of_namespace || 'z' < $start_of_namespace ) {
|
||||
$at = self::find_html_comment_end( $text, $comment_opening_at, $end );
|
||||
continue;
|
||||
}
|
||||
|
||||
$namespace_length = 1 + strspn( $text, 'abcdefghijklmnopqrstuvwxyz0123456789-_', $namespace_at + 1 );
|
||||
$separator_at = $namespace_at + $namespace_length;
|
||||
if ( $separator_at >= $end ) {
|
||||
static::$last_error = self::INCOMPLETE_INPUT;
|
||||
return null;
|
||||
}
|
||||
|
||||
$has_separator = '/' === $text[ $separator_at ];
|
||||
if ( $has_separator ) {
|
||||
$name_at = $separator_at + 1;
|
||||
$start_of_name = $text[ $name_at ];
|
||||
if ( 'a' > $start_of_name || 'z' < $start_of_name ) {
|
||||
$at = self::find_html_comment_end( $text, $comment_opening_at, $end );
|
||||
continue;
|
||||
}
|
||||
|
||||
$name_length = 1 + strspn( $text, 'abcdefghijklmnopqrstuvwxyz0123456789-_', $name_at + 1 );
|
||||
} else {
|
||||
$name_at = $namespace_at;
|
||||
$name_length = $namespace_length;
|
||||
$namespace_length = 0;
|
||||
}
|
||||
|
||||
$after_name_whitespace_at = $name_at + $name_length;
|
||||
$after_name_whitespace_length = strspn( $text, " \t\f\r\n", $after_name_whitespace_at );
|
||||
if ( 0 === $after_name_whitespace_length ) {
|
||||
$at = self::find_html_comment_end( $text, $comment_opening_at, $end );
|
||||
continue;
|
||||
}
|
||||
|
||||
$json_at = $after_name_whitespace_at + $after_name_whitespace_length;
|
||||
if ( $json_at >= $end ) {
|
||||
static::$last_error = self::INCOMPLETE_INPUT;
|
||||
return null;
|
||||
}
|
||||
$has_json = '{' === $text[ $json_at ];
|
||||
$json_length = 0;
|
||||
|
||||
/*
|
||||
* For the final span of the delimiter it's most efficient to find the end
|
||||
* of the HTML comment and work backwards. This prevents complicated parsing
|
||||
* inside the JSON span, which cannot contain the HTML comment terminator.
|
||||
*
|
||||
* This also matches the behavior in the official block parser, though it
|
||||
* allows for matching invalid JSON content.
|
||||
*/
|
||||
$comment_closing_at = strpos( $text, '-->', $json_at );
|
||||
if ( false === $comment_closing_at ) {
|
||||
static::$last_error = self::INCOMPLETE_INPUT;
|
||||
return null;
|
||||
}
|
||||
|
||||
/*
|
||||
* It looks like this logic leaves an error in here, when the position
|
||||
* overlaps the JSON or block name. However, for neither of those is it
|
||||
* possible to parse a valid block if that last overlapping character
|
||||
* is the void flag. This, therefore, will be valid regardless of how
|
||||
* the rest of the comment delimiter is written.
|
||||
*/
|
||||
if ( '/' === $text[ $comment_closing_at - 1 ] ) {
|
||||
$has_void_flag = true;
|
||||
$void_flag_length = 1;
|
||||
} else {
|
||||
$has_void_flag = false;
|
||||
$void_flag_length = 0;
|
||||
}
|
||||
|
||||
/*
|
||||
* If there's no JSON, then the span of text after the name
|
||||
* until the comment closing must be completely whitespace.
|
||||
*/
|
||||
if ( ! $has_json ) {
|
||||
$max_whitespace_length = $comment_closing_at - $json_at - $void_flag_length;
|
||||
|
||||
// This shouldn't be possible, but it can't be allowed regardless.
|
||||
if ( $max_whitespace_length < 0 ) {
|
||||
$at = self::find_html_comment_end( $text, $comment_opening_at, $end );
|
||||
continue;
|
||||
}
|
||||
|
||||
$closing_whitespace_length = strspn( $text, " \t\f\r\n", $json_at, $comment_closing_at - $json_at - $void_flag_length );
|
||||
if ( 0 === $after_name_whitespace_length + $closing_whitespace_length ) {
|
||||
$at = self::find_html_comment_end( $text, $comment_opening_at, $end );
|
||||
continue;
|
||||
}
|
||||
|
||||
// This must be a block delimiter!
|
||||
$delimiter = new static();
|
||||
break;
|
||||
}
|
||||
|
||||
// There's no JSON, so attempt to find its boundary.
|
||||
$after_json_whitespace_length = 0;
|
||||
for ( $char_at = $comment_closing_at - $void_flag_length - 1; $char_at > $json_at; $char_at-- ) {
|
||||
$char = $text[ $char_at ];
|
||||
|
||||
switch ( $char ) {
|
||||
case ' ':
|
||||
case "\t":
|
||||
case "\f":
|
||||
case "\r":
|
||||
case "\n":
|
||||
++$after_json_whitespace_length;
|
||||
continue 2;
|
||||
|
||||
case '}':
|
||||
$json_length = $char_at - $json_at + 1;
|
||||
break 2;
|
||||
|
||||
default:
|
||||
++$at;
|
||||
continue 3;
|
||||
}
|
||||
}
|
||||
|
||||
if ( 0 === $json_length || 0 === $after_json_whitespace_length ) {
|
||||
$at = self::find_html_comment_end( $text, $comment_opening_at, $end );
|
||||
continue;
|
||||
}
|
||||
|
||||
// This must be a block delimiter!
|
||||
$delimiter = new static();
|
||||
break;
|
||||
}
|
||||
|
||||
if ( null === $delimiter ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$delimiter->source_text = $text;
|
||||
|
||||
$delimiter->delimiter_at = $comment_opening_at;
|
||||
$delimiter->delimiter_length = $comment_closing_at + 3 - $comment_opening_at;
|
||||
|
||||
$delimiter->namespace_at = $namespace_at;
|
||||
$delimiter->namespace_length = $namespace_length;
|
||||
|
||||
$delimiter->name_at = $name_at;
|
||||
$delimiter->name_length = $name_length;
|
||||
|
||||
$delimiter->json_at = $json_at;
|
||||
$delimiter->json_length = $json_length;
|
||||
|
||||
$delimiter->type = $has_closer
|
||||
? static::CLOSER
|
||||
: ( $has_void_flag ? static::VOID : static::OPENER );
|
||||
|
||||
$delimiter->has_void_flag = $has_void_flag;
|
||||
|
||||
$match_byte_offset = $delimiter->delimiter_at;
|
||||
$match_byte_length = $delimiter->delimiter_length;
|
||||
|
||||
return $delimiter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generator function to traverse block delimiters in a text and also
|
||||
* yield the position information for the delimiter as it scans.
|
||||
*
|
||||
* Example:
|
||||
*
|
||||
* foreach ( Block_Delimiter::scan_delimiters( $post_content ) as $where => $delimiter ) {
|
||||
* echo "Found a {$delimiter->allocate_and_return_block_type()} at {$where[0]} (length is {$where[1]} bytes)\n";
|
||||
* }
|
||||
*
|
||||
* @param string $text Document potentially containing blocks.
|
||||
* @param ?string $freeform_blocks Optional. 'visit' to visit virtual block delimiters for freeform content.
|
||||
* @return \Generator Visits each block delimiter and provides [ at, length ] => delimiter.
|
||||
*/
|
||||
public static function scan_delimiters( string $text, ?string $freeform_blocks = 'skip' ): \Generator {
|
||||
$at = 0;
|
||||
$depth = 0;
|
||||
|
||||
/*
|
||||
* Although `phpcs` confidently asserts that `$match_at` and `$match_length`
|
||||
* are undefined, it is not aware enough to realize that they are set by the
|
||||
* call to `next_delimiter` and so it’s necessary to alter the code so it
|
||||
* doesn’t get confused and reject valid code.
|
||||
*/
|
||||
$match_at = 0;
|
||||
$match_length = 0;
|
||||
|
||||
while ( null !== ( $delimiter = self::next_delimiter( $text, $at, $match_at, $match_length ) ) ) {
|
||||
// Handle top-level text as freeform blocks
|
||||
if ( 0 === $depth && $match_at > $at && 'visit' === $freeform_blocks ) {
|
||||
list( $text_opener, $text_closer ) = static::freeform_pair( $text, $at, $match_at - $at );
|
||||
|
||||
++$depth;
|
||||
yield [ $at, 0 ] => $text_opener;
|
||||
|
||||
--$depth;
|
||||
yield [ $match_at, 0 ] => $text_closer;
|
||||
}
|
||||
|
||||
$delimiter_type = $delimiter->get_delimiter_type();
|
||||
|
||||
switch ( $delimiter_type ) {
|
||||
case static::OPENER:
|
||||
case static::VOID:
|
||||
++$depth;
|
||||
break;
|
||||
|
||||
case static::CLOSER:
|
||||
--$depth;
|
||||
break;
|
||||
}
|
||||
|
||||
yield [ $match_at, $match_length ] => $delimiter;
|
||||
|
||||
if ( static::VOID === $delimiter_type ) {
|
||||
--$depth;
|
||||
}
|
||||
|
||||
$at = $match_at + $match_length;
|
||||
}
|
||||
|
||||
$end = strlen( $text );
|
||||
if ( 'visit' === $freeform_blocks && $at < $end ) {
|
||||
list( $text_opener, $text_closer ) = static::freeform_pair( $text, $at, $end - $at );
|
||||
|
||||
++$depth;
|
||||
yield [ $at, 0 ] => $text_opener;
|
||||
|
||||
--$depth;
|
||||
yield [ $end, 0 ] => $text_closer;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructor function.
|
||||
*/
|
||||
private function __construct() {
|
||||
// This is not to be called from the outside.
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the byte-offset after the ending character of an HTML comment,
|
||||
* assuming the proper starting byte offset.
|
||||
*
|
||||
* @since 0.2.1
|
||||
*
|
||||
* @param string $text Document in which to search for HTML comment end.
|
||||
* @param int $comment_starting_at Where the HTML comment started, the leading `<`.
|
||||
* @param int $search_end Last offset in which to search, for limiting search span.
|
||||
* @return int Offset after the current HTML comment ends, or `$end` if no end was found.
|
||||
*/
|
||||
private static function find_html_comment_end( string $text, int $comment_starting_at, int $search_end ): int {
|
||||
// Find span-of-dashes comments which look like `<!----->`.
|
||||
$span_of_dashes = strspn( $text, '-', $comment_starting_at + 2 );
|
||||
if (
|
||||
$comment_starting_at + 2 + $span_of_dashes < $search_end &&
|
||||
'>' === $text[ $comment_starting_at + 2 + $span_of_dashes ]
|
||||
) {
|
||||
return $comment_starting_at + $span_of_dashes + 1;
|
||||
}
|
||||
|
||||
// Otherwise, there are other characters inside the comment, find the first `-->` or `--!>`.
|
||||
$now_at = $comment_starting_at + 4;
|
||||
while ( $now_at < $search_end ) {
|
||||
$dashes_at = strpos( $text, '--', $now_at );
|
||||
if ( false === $dashes_at ) {
|
||||
static::$last_error = self::INCOMPLETE_INPUT;
|
||||
return $search_end;
|
||||
}
|
||||
|
||||
$closer_must_be_at = $dashes_at + 2 + strspn( $text, '-', $dashes_at + 2 );
|
||||
if ( $closer_must_be_at < $search_end && '!' === $text[ $closer_must_be_at ] ) {
|
||||
$closer_must_be_at++;
|
||||
}
|
||||
|
||||
if ( $closer_must_be_at < $search_end && '>' === $text[ $closer_must_be_at ] ) {
|
||||
return $closer_must_be_at + 1;
|
||||
}
|
||||
|
||||
$now_at++;
|
||||
}
|
||||
|
||||
return $search_end;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a pair of delimiters for freeform text content
|
||||
* since there are no delimiters in a document for them.
|
||||
*
|
||||
* @param string $text Source document.
|
||||
* @param int $at Where the text region starts (byte offset).
|
||||
* @param int $length How long the text region spans (byte count).
|
||||
* @return static[] Opening and closing block delimiters for the text region.
|
||||
*/
|
||||
private static function freeform_pair( string $text, int $at, int $length ): array {
|
||||
$opener = new static();
|
||||
$opener->source_text = $text;
|
||||
$opener->delimiter_at = $at;
|
||||
$opener->delimiter_length = 0;
|
||||
$opener->namespace_at = $at;
|
||||
$opener->namespace_length = 0;
|
||||
$opener->name_at = $at;
|
||||
$opener->name_length = 0;
|
||||
$opener->json_at = $at;
|
||||
$opener->json_length = 0;
|
||||
$opener->type = static::OPENER;
|
||||
$opener->has_void_flag = false;
|
||||
|
||||
$closer = clone $opener;
|
||||
$end_at = $at + $length;
|
||||
$closer->delimiter_at = $end_at;
|
||||
$closer->namespace_at = $end_at;
|
||||
$closer->name_at = $end_at;
|
||||
$closer->json_at = $end_at;
|
||||
$closer->type = static::CLOSER;
|
||||
|
||||
return [ $opener, $closer ];
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates if the last attempt to parse a block comment delimiter
|
||||
* failed, if set, otherwise `null` if the last attempt succeeded.
|
||||
*
|
||||
* @return string|null
|
||||
*/
|
||||
public static function get_last_error() {
|
||||
return static::$last_error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates if the last attempt to parse a block’s JSON attributes failed.
|
||||
*
|
||||
* @see JSON_ERROR_NONE, JSON_ERROR_DEPTH, etc…
|
||||
*
|
||||
* @return int JSON_ERROR_ code from last attempt to parse block JSON attributes.
|
||||
*/
|
||||
public function get_last_json_error(): int {
|
||||
return $this->last_json_error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Allocates a substring from the source text containing the delimiter
|
||||
* and releases the reference to the source text.
|
||||
*
|
||||
* Use this function when the delimiter is holding on to the source
|
||||
* text and preventing it from being freed by PHP. This function incurs
|
||||
* a string allocation; if the source text will be retained anyway then
|
||||
* there's no need to detach as that memory cannot be freed.
|
||||
*
|
||||
* This is a low-level function available for controlling the performance
|
||||
* of sensitive hot-paths. You probably don't need this.
|
||||
*
|
||||
* Example:
|
||||
*
|
||||
* function first_block( $html ) {
|
||||
* return Block_Delimiter::next_delimiter( $really_long_html, 0 );
|
||||
* }
|
||||
*
|
||||
* $delimiter = first_block( $really_long_html_document );
|
||||
* // `$really_long_html_document` is still retained inside `$delimiter`, which could lead to a memory leak.
|
||||
*
|
||||
* $delimiter->allocate_and_detach_from_source_text();
|
||||
* // `$really_long_html_document` is no longer referenced, and its memory may be freed or used for something else.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function allocate_and_detach_from_source_text(): void {
|
||||
$this->source_text = substr( $this->source_text, $this->delimiter_at, $this->delimiter_length );
|
||||
|
||||
$byte_delta = $this->delimiter_at;
|
||||
|
||||
$this->delimiter_at -= $byte_delta;
|
||||
$this->namespace_at -= $byte_delta;
|
||||
$this->name_at -= $byte_delta;
|
||||
$this->json_at -= $byte_delta;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the type of the block comment delimiter.
|
||||
*
|
||||
* One of:
|
||||
*
|
||||
* - `static::OPENER`
|
||||
* - `static::CLOSER`
|
||||
* - `static::VOID`
|
||||
*
|
||||
* @return string type of the block comment delimiter.
|
||||
*/
|
||||
public function get_delimiter_type(): string {
|
||||
return $this->type;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the delimiter contains the void flag.
|
||||
*
|
||||
* This should be avoided except in cases of handling errors with
|
||||
* block closers containing the void flag. For normative use,
|
||||
* {@see self::get_delimiter_type}.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function has_void_flag(): bool {
|
||||
return $this->has_void_flag;
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates if the block delimiter represents a block of the given type.
|
||||
*
|
||||
* Since the "core" namespace may be implicit, it's allowable to pass
|
||||
* either the fully-qualified block type with namespace and block name
|
||||
* as well as the shorthand version only containing the block name, if
|
||||
* the desired block is in the "core" namespace.
|
||||
*
|
||||
* Example:
|
||||
*
|
||||
* $is_core_paragraph = $delimiter->is_block_type( 'paragraph' );
|
||||
* $is_core_paragraph = $delimiter->is_block_type( 'core/paragraph' );
|
||||
* $is_formula = $delimiter->is_block_type( 'math-block/formula' );
|
||||
*
|
||||
* @param string $block_type Block type name for the desired block.
|
||||
* E.g. "paragraph", "core/paragraph", "math-blocks/formula".
|
||||
* @return bool Whether this delimiter represents a block of the given type.
|
||||
*/
|
||||
public function is_block_type( string $block_type ): bool {
|
||||
// This is a core/freeform text block, it’s special.
|
||||
if ( 0 === $this->name_length ) {
|
||||
return 'core/freeform' === $block_type || 'freeform' === $block_type;
|
||||
}
|
||||
|
||||
$slash_at = strpos( $block_type, '/' );
|
||||
if ( false === $slash_at ) {
|
||||
$namespace = 'core';
|
||||
$block_name = $block_type;
|
||||
} else {
|
||||
$namespace = substr( $block_type, 0, $slash_at );
|
||||
$block_name = substr( $block_type, $slash_at + 1 );
|
||||
}
|
||||
|
||||
// Only the 'core' namespace is allowed to be omitted.
|
||||
if ( 0 === $this->namespace_length && 'core' !== $namespace ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If given an explicit namespace, they must match.
|
||||
if (
|
||||
0 !== $this->namespace_length && (
|
||||
strlen( $namespace ) !== $this->namespace_length ||
|
||||
0 !== substr_compare( $this->source_text, $namespace, $this->namespace_at, $this->namespace_length )
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// The block name must match.
|
||||
return (
|
||||
strlen( $block_name ) === $this->name_length &&
|
||||
0 === substr_compare( $this->source_text, $block_name, $this->name_at, $this->name_length )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Allocates a substring for the block type and returns the
|
||||
* fully-qualified name, including the namespace.
|
||||
*
|
||||
* This function allocates a substring for the given block type. This
|
||||
* allocation will be small and likely fine in most cases, but it's
|
||||
* preferable to call {@link static::is_block_type} if only needing
|
||||
* to know whether the delimiter is for a given block type, as that
|
||||
* function is more efficient for this purpose and avoids the allocation.
|
||||
*
|
||||
* Example:
|
||||
*
|
||||
* 'core/paragraph' = $delimiter->allocate_and_return_block_type();
|
||||
*
|
||||
* @return string Fully-qualified block namespace and type, e.g. "core/paragraph".
|
||||
*/
|
||||
public function allocate_and_return_block_type(): string {
|
||||
// This is a core/freeform text block, it’s special.
|
||||
if ( 0 === $this->name_length ) {
|
||||
return 'core/freeform';
|
||||
}
|
||||
|
||||
// This is implicitly in the "core" namespace.
|
||||
if ( 0 === $this->namespace_length ) {
|
||||
$block_name = substr( $this->source_text, $this->name_at, $this->name_length );
|
||||
return "core/{$block_name}";
|
||||
}
|
||||
|
||||
return substr( $this->source_text, $this->namespace_at, $this->namespace_length + $this->name_length + 1 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a lazy wrapper around the block attributes, which can be used
|
||||
* for efficiently interacting with the JSON attributes.
|
||||
*
|
||||
* @throws Exception This function is not yet implemented.
|
||||
*
|
||||
* @todo Create a lazy JSON wrapper so specific attributes can be
|
||||
* efficiently queried without parsing everything and loading
|
||||
* the entire object into memory.
|
||||
* @todo After realistic benchmarking, see if JsonStreamingParser\Parser
|
||||
* could be used — it would need to be fast enough for the reduction
|
||||
* in memory use to be worth it, compared to {@see \json_decode}.
|
||||
*
|
||||
* @see \JsonStreamingParser\Parser
|
||||
*
|
||||
* @return never
|
||||
*/
|
||||
public function get_attributes(): void {
|
||||
throw new Exception( 'Lazy attribute parsing not yet supported' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to parse and return the entire JSON attributes from the delimiter,
|
||||
* allocating memory and processing the JSON span in the process.
|
||||
*
|
||||
* This does not return any parsed attributes for a closing block delimiter
|
||||
* even if there is a span of JSON content; this JSON is a parsing error.
|
||||
*
|
||||
* Consider calling {@link static::get_attributes} instead if it's not
|
||||
* necessary to read all the attributes at the same time, as that provides
|
||||
* a more efficient mechanism for typical use cases.
|
||||
*
|
||||
* Since the JSON span inside the comment delimiter may not be valid JSON,
|
||||
* this function will return `null` if it cannot parse the span and set the
|
||||
* {@see static::get_last_json_error} to the appropriate JSON_ERROR_ constant.
|
||||
*
|
||||
* If the delimiter contains no JSON span, it will also return `null`,
|
||||
* but the last error will be set to {@see JSON_ERROR_NONE}.
|
||||
*
|
||||
* Example:
|
||||
*
|
||||
* $delimiter = Block_Delimiter::next_delimiter( '<!-- wp:image {"url": "https://wordpress.org/favicon.ico"} -->', 0 );
|
||||
* $memory_hungry_and_slow_attributes = $delimiter->allocate_and_return_parsed_attributes();
|
||||
* $memory_hungry_and_slow_attributes === array( 'url' => 'https://wordpress.org/favicon.ico' );
|
||||
*
|
||||
* $delimiter = Block_Delimiter::next_delimiter( '<!-- /wp:image {"url": "https://wordpress.org/favicon.ico"} -->', 0 );
|
||||
* null = $delimiter->allocate_and_return_parsed_attributes();
|
||||
* JSON_ERROR_NONE = $delimiter->get_last_json_error();
|
||||
*
|
||||
* $delimiter = Block_Delimiter::next_delimiter( '<!-- wp:separator {} /-->', 0 );
|
||||
* array() === $delimiter->allocate_and_return_parsed_attributes();
|
||||
*
|
||||
* $delimiter = Block_Delimiter::next_delimiter( '<!-- wp:separator /-->', 0 );
|
||||
* null = $delimiter->allocate_and_return_parsed_attributes();
|
||||
*
|
||||
* $delimiter = Block_Delimiter::next_delimiter( '<!-- wp:image {"url} -->', 0 );
|
||||
* null = $delimiter->allocate_and_return_parsed_attributes();
|
||||
* JSON_ERROR_CTRL_CHAR = $delimiter->get_last_json_error();
|
||||
*
|
||||
* @return array|null Parsed JSON attributes, if present and valid, otherwise `null`.
|
||||
*/
|
||||
public function allocate_and_return_parsed_attributes(): ?array {
|
||||
$this->last_json_error = JSON_ERROR_NONE;
|
||||
|
||||
if ( static::CLOSER === $this->type ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ( 0 === $this->json_length ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$json_span = substr( $this->source_text, $this->json_at, $this->json_length );
|
||||
$parsed = json_decode( $json_span, null, 512, JSON_OBJECT_AS_ARRAY | JSON_INVALID_UTF8_SUBSTITUTE );
|
||||
|
||||
$last_error = json_last_error();
|
||||
$this->last_json_error = $last_error;
|
||||
|
||||
return ( JSON_ERROR_NONE === $last_error && is_array( $parsed ) )
|
||||
? $parsed
|
||||
: null;
|
||||
}
|
||||
|
||||
// Debugging methods not meant for production use.
|
||||
|
||||
/**
|
||||
* Prints a debugging message showing the structure of the parsed delimiter.
|
||||
*
|
||||
* This is not meant to be used in production!
|
||||
*
|
||||
* @access private
|
||||
*/
|
||||
public function debug_print_structure(): void {
|
||||
$c = ( ! defined( 'STDOUT' ) || posix_isatty( STDOUT ) )
|
||||
? function ( $color = null ) { return $color; } // phpcs:ignore
|
||||
: function ( $color ) { return ''; }; // phpcs:ignore
|
||||
|
||||
if ( $this->is_block_type( 'core/freeform' ) ) {
|
||||
$closer = static::CLOSER === $this->get_delimiter_type() ? '/' : '';
|
||||
echo "{$c( "\e[90m" )}<!-- "; // phpcs:ignore
|
||||
echo "{$c( "\e[0;31m" )}{$closer}"; // phpcs:ignore
|
||||
echo "{$c("\e[90m" )}wp:"; // phpcs:ignore
|
||||
echo "{$c( "\e[0;34m" )}freeform"; // phpcs:ignore
|
||||
echo "{$c( "\e[0;36m" )} {$c("\e[90m")}-->\n"; // phpcs:ignore
|
||||
return;
|
||||
}
|
||||
|
||||
$namespace = substr( $this->source_text, $this->namespace_at, $this->namespace_length );
|
||||
$slash = 0 === $this->namespace_length ? '' : '/';
|
||||
$block_name = substr( $this->source_text, $this->name_at, $this->name_length );
|
||||
$closer = static::CLOSER === $this->type ? '/' : '';
|
||||
$json = substr( $this->source_text, $this->json_at, $this->json_length );
|
||||
|
||||
$opener_whitespace_at = $this->delimiter_at + 4;
|
||||
$opener_whitespace_length = $this->namespace_at - 3 - $opener_whitespace_at - ( static::CLOSER === $this->type ? 1 : 0 );
|
||||
|
||||
$after_name_whitespace_at = $this->name_at + $this->name_length;
|
||||
$after_name_whitespace_length = $this->json_at - $after_name_whitespace_at;
|
||||
|
||||
$closing_whitespace_at = $this->json_at + $this->json_length;
|
||||
$closing_whitespace_length = $this->delimiter_at + $this->delimiter_length - 3 - $closing_whitespace_at;
|
||||
|
||||
if ( '/' === $this->source_text[ $this->delimiter_at + $this->delimiter_length - 4 ] ) {
|
||||
$void_flag = '/';
|
||||
--$closing_whitespace_length;
|
||||
} else {
|
||||
$void_flag = '';
|
||||
}
|
||||
|
||||
$w = function ( $whitespace ) use ( $c ) {
|
||||
return $c( "\e[2;90m" ) . str_replace( array( ' ', "\t", "\f", "\r", "\n" ), array( '␣', '␉', '␌', '␍', '' ), $whitespace );
|
||||
};
|
||||
|
||||
echo "{$c( "\e[90m" )}<!--"; // phpcs:ignore
|
||||
echo $w( substr( $this->source_text, $opener_whitespace_at, $opener_whitespace_length ) ); // phpcs:ignore
|
||||
echo "{$c( "\e[0;31m" )}{$closer}"; // phpcs:ignore
|
||||
echo "{$c("\e[90m" )}wp:{$c( "\e[2;34m" )}{$namespace}"; // phpcs:ignore
|
||||
echo "{$c( "\e[2;90m" )}{$slash}"; // phpcs:ignore
|
||||
echo "{$c( "\e[0;34m" )}{$block_name}"; // phpcs:ignore
|
||||
echo $w( substr( $this->source_text, $after_name_whitespace_at, $after_name_whitespace_length ) ); // phpcs:ignore
|
||||
echo "{$c("\e[0;2;32m" )}{$json}"; // phpcs:ignore
|
||||
echo $w( substr( $this->source_text, $closing_whitespace_at, $closing_whitespace_length ) ); // phpcs:ignore
|
||||
echo "{$c( "\e[0;36m" )}{$void_flag}{$c("\e[90m")}-->\n"; // phpcs:ignore
|
||||
}
|
||||
|
||||
// Constant declarations that would otherwise pollute the top of the class.
|
||||
|
||||
/**
|
||||
* Indicates that the block comment delimiter closes an open block.
|
||||
*/
|
||||
const CLOSER = 'closer';
|
||||
|
||||
/**
|
||||
* Indicates that the parser started parsing a block comment delimiter, but
|
||||
* the input document ended before it could finish. The document was likely truncated.
|
||||
*/
|
||||
const INCOMPLETE_INPUT = 'incomplete-input';
|
||||
|
||||
/**
|
||||
* Indicates that the block comment delimiter opens a block.
|
||||
*/
|
||||
const OPENER = 'opener';
|
||||
|
||||
/**
|
||||
* Indicates that the parser has not yet attempted to parse a block comment delimiter.
|
||||
*/
|
||||
const UNINITIALIZED = 'uninitialized';
|
||||
|
||||
/**
|
||||
* Indicates that the block comment delimiter represents a void block
|
||||
* with no inner content of any kind.
|
||||
*/
|
||||
const VOID = 'void';
|
||||
}
|
||||
+932
@@ -0,0 +1,932 @@
|
||||
<?php
|
||||
/**
|
||||
* Efficiently scan through block structure in document without parsing
|
||||
* the entire block tree and all of its JSON attributes into memory.
|
||||
*
|
||||
* @package automattic/block-delimiter
|
||||
*/
|
||||
|
||||
declare( strict_types = 1 );
|
||||
|
||||
namespace Automattic;
|
||||
|
||||
use Exception;
|
||||
use WP_HTML_Span;
|
||||
|
||||
/**
|
||||
* Class for efficiently scanning through block structure in a document
|
||||
* without parsing the entire block tree and JSON attributes into memory.
|
||||
*
|
||||
* This class follows design values of the HTML API:
|
||||
* - minimize allocations and strive for zero memory overhead
|
||||
* - make costs explicit; pay only for what you need
|
||||
* - follow a streaming, re-entrant design for pausing and aborting
|
||||
*
|
||||
* For usage, jump straight to {@see self::next_delimiter}.
|
||||
*/
|
||||
class Block_Scanner {
|
||||
/**
|
||||
* Indicates if the last operation failed, otherwise
|
||||
* will be `null` for success.
|
||||
*
|
||||
* @var string|null
|
||||
*/
|
||||
private $last_error = null;
|
||||
|
||||
/**
|
||||
* Indicates failures from decoding JSON attributes.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
private $last_json_error = JSON_ERROR_NONE;
|
||||
|
||||
/**
|
||||
* Holds a reference to the original source text from which to
|
||||
* extract the parsed spans of the delimiter.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private $source_text;
|
||||
|
||||
/**
|
||||
* Byte offset into source text where entire delimiter begins.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
private $delimiter_at = 0;
|
||||
|
||||
/**
|
||||
* Byte length of full span of delimiter.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
private $delimiter_length = 0;
|
||||
|
||||
/**
|
||||
* Byte offset where namespace span begins.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
private $namespace_at = 0;
|
||||
|
||||
/**
|
||||
* Byte length of namespace span, or `0` if implicitly in the "core" namespace.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
private $namespace_length = 0;
|
||||
|
||||
/**
|
||||
* Byte offset where block name span begins.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
private $name_at = 0;
|
||||
|
||||
/**
|
||||
* Byte length of block name span.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
private $name_length = 0;
|
||||
|
||||
/**
|
||||
* Whether the delimiter contains the block self-closing flag.
|
||||
*
|
||||
* This may be erroneous if present within a block closer,
|
||||
* therefore the {@see self::has_void_flag} can be used by
|
||||
* calling code to perform appropriate error-handling.
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
private $has_void_flag = false;
|
||||
|
||||
/**
|
||||
* Byte offset where JSON attributes span begins.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
private $json_at;
|
||||
|
||||
/**
|
||||
* Byte length of JSON attributes span, or `0` if none are present.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
private $json_length;
|
||||
|
||||
/**
|
||||
* Indicates what kind of block comment delimiter this represents.
|
||||
*
|
||||
* One of:
|
||||
*
|
||||
* - `static::OPENER` If the delimiter is opening a block.
|
||||
* - `static::CLOSER` If the delimiter is closing an open block.
|
||||
* - `static::VOID` If the delimiter represents a void block with no inner content.
|
||||
*
|
||||
* If a parsed comment delimiter contains both the closing and the void
|
||||
* flags then it will be interpreted as a void block to match the behavior
|
||||
* of the official block parser, however, this is a mistake and probably
|
||||
* the block ought to close an open block of the same name, if one is open.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private $type;
|
||||
|
||||
/**
|
||||
* Creates a new block scanner.
|
||||
*
|
||||
* Example:
|
||||
*
|
||||
* $scanner = Block_Scanner::create( $html );
|
||||
* while ( $scanner->next_delimiter() ) {
|
||||
* if ( $scanner->opens_block( 'core/image' ) ) {
|
||||
* echo "Found an image!\n";
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* This function is currently a stub so that future improvements can add configuration
|
||||
* options and reject creation, which cannot occur directly inside class constructors.
|
||||
*
|
||||
* @see self::next_delimiter
|
||||
*
|
||||
* @param string $source_text Input document potentially containing block content.
|
||||
* @return ?self Created block scanner, if successfully created.
|
||||
*/
|
||||
public static function create( string $source_text ): ?self {
|
||||
return new self( $source_text );
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan to the next block delimiter in a document, indicating if one was found.
|
||||
*
|
||||
* Block comment delimiters must be valid HTML comments and may contain JSON.
|
||||
* This search does not determine, however, if the JSON is valid.
|
||||
*
|
||||
* Example delimiters:
|
||||
*
|
||||
* `<!-- wp:paragraph {"dropCap": true} -->`
|
||||
* `<!-- wp:separator /-->`
|
||||
* `<!-- /wp:paragraph -->`
|
||||
*
|
||||
* In the case that a block comment delimiter contains both the void indicator and
|
||||
* also the closing indicator, it will be treated as a void block.
|
||||
*
|
||||
* Example:
|
||||
*
|
||||
* // Find all image block opening delimiters.
|
||||
* $images = array();
|
||||
* $scanner = Block_Scanner::create( $html );
|
||||
* while ( $scanner->next_delimiter() ) {
|
||||
* if ( $scanner->opens_block( 'core/image' ) ) {
|
||||
* $images[] = $scanner->get_span();
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* Not all blocks have explicit delimiters. Non-block content at the top-level of
|
||||
* a document (so-called “HTML soup”) forms implicit blocks containing neither a
|
||||
* block name nor block attributes. Because this content often comprises only
|
||||
* HTML whitespace and adds undo performance burden, it is skipped by default.
|
||||
* To scan the implicit freeform blocks, pass the `$freeform_blocks` argument.
|
||||
*
|
||||
* Example:
|
||||
*
|
||||
* $html = '<!-- wp:void /-->\n<!-- wp:void /-->';
|
||||
* $blocks = [
|
||||
* [ 'blockName' => 'core/void' ],
|
||||
* [ 'blockName' => null ],
|
||||
* [ 'blockName' => 'core/void' ],
|
||||
* ];
|
||||
* $scanner = Block_Scanner::create( $html );
|
||||
* while ( $scanner->next_delimiter( freeform_blocks: 'visit' ) {
|
||||
* ...
|
||||
* }
|
||||
*
|
||||
* In some cases it may be useful to conditionally visit the implicit freeform
|
||||
* blocks, such as when determining if a post contains freeform content that
|
||||
* isn’t purely whitespace.
|
||||
*
|
||||
* Example:
|
||||
*
|
||||
* $seen_block_types = [];
|
||||
* $freeform_blocks = 'visit';
|
||||
* $scanner = Block_Scanner::create( $html );
|
||||
* while ( $scanner->next_delimiter( freeform_blocks: $freeform_blocks ) {
|
||||
* if ( ! $scanner->opens_block() ) {
|
||||
* continue;
|
||||
* }
|
||||
*
|
||||
* // Stop wasting time visiting freeform blocks after one has been found.
|
||||
* if ('visit' === $freeform_blocks ) {
|
||||
* if ( $scanner->is_non_whitespace_freeform() ) {
|
||||
* $freeform_blocks = 'skip';
|
||||
* $seen_block_types['core/freeform'] = true;
|
||||
* }
|
||||
* continue;
|
||||
* }
|
||||
*
|
||||
* $seen_block_types[ $scanner->get_block_type() ] = true;
|
||||
* }
|
||||
*
|
||||
* @param string $freeform_blocks Optional. Pass `visit` to match freeform HTML content
|
||||
* not surrounded by block delimiters. Defaults to `skip`.
|
||||
* @return bool Whether a block delimiter was matched.
|
||||
*/
|
||||
public function next_delimiter( string $freeform_blocks = 'skip' ): bool { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
|
||||
if ( $this->last_error ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$text = $this->source_text;
|
||||
$end = strlen( $text );
|
||||
$at = $this->delimiter_at + $this->delimiter_length;
|
||||
$found_one = false;
|
||||
|
||||
while ( $at < $end ) {
|
||||
/*
|
||||
* Find the next possible opening.
|
||||
*
|
||||
* This follows the behavior in the official block parser, which treats a post
|
||||
* as a list of blocks with nested HTML. If HTML comment syntax appears within
|
||||
* an HTML attribute value, SCRIPT or STYLE element, or in other select places,
|
||||
* which it can do inside of HTML, then the block parsing may break.
|
||||
*
|
||||
* For a more robust parse scan through the document with the HTML API. In
|
||||
* practice, this has not been a problem in the entire history of blocks.
|
||||
*/
|
||||
$comment_opening_at = strpos( $text, '<!--', $at );
|
||||
if ( false === $comment_opening_at ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$opening_whitespace_at = $comment_opening_at + 4;
|
||||
$opening_whitespace_length = strspn( $text, " \t\f\r\n", $opening_whitespace_at );
|
||||
if ( 0 === $opening_whitespace_length ) {
|
||||
$at = $this->find_html_comment_end( $comment_opening_at, $end );
|
||||
continue;
|
||||
}
|
||||
|
||||
$wp_prefix_at = $opening_whitespace_at + $opening_whitespace_length;
|
||||
if ( $wp_prefix_at >= $end ) {
|
||||
$this->last_error = self::INCOMPLETE_INPUT;
|
||||
return false;
|
||||
}
|
||||
|
||||
$has_closer = false;
|
||||
if ( '/' === $text[ $wp_prefix_at ] ) {
|
||||
$has_closer = true;
|
||||
++$wp_prefix_at;
|
||||
}
|
||||
|
||||
if ( 0 !== substr_compare( $text, 'wp:', $wp_prefix_at, 3 ) ) {
|
||||
$at = $this->find_html_comment_end( $comment_opening_at, $end );
|
||||
continue;
|
||||
}
|
||||
|
||||
$namespace_at = $wp_prefix_at + 3;
|
||||
if ( $namespace_at >= $end ) {
|
||||
$this->last_error = self::INCOMPLETE_INPUT;
|
||||
return false;
|
||||
}
|
||||
|
||||
$start_of_namespace = $text[ $namespace_at ];
|
||||
|
||||
// The namespace must start with a-z.
|
||||
if ( 'a' > $start_of_namespace || 'z' < $start_of_namespace ) {
|
||||
$at = $this->find_html_comment_end( $comment_opening_at, $end );
|
||||
continue;
|
||||
}
|
||||
|
||||
$namespace_length = 1 + strspn( $text, 'abcdefghijklmnopqrstuvwxyz0123456789-_', $namespace_at + 1 );
|
||||
$separator_at = $namespace_at + $namespace_length;
|
||||
if ( $separator_at >= $end ) {
|
||||
$this->last_error = self::INCOMPLETE_INPUT;
|
||||
return false;
|
||||
}
|
||||
|
||||
$has_separator = '/' === $text[ $separator_at ];
|
||||
if ( $has_separator ) {
|
||||
$name_at = $separator_at + 1;
|
||||
$start_of_name = $text[ $name_at ];
|
||||
if ( 'a' > $start_of_name || 'z' < $start_of_name ) {
|
||||
$at = $this->find_html_comment_end( $comment_opening_at, $end );
|
||||
continue;
|
||||
}
|
||||
|
||||
$name_length = 1 + strspn( $text, 'abcdefghijklmnopqrstuvwxyz0123456789-_', $name_at + 1 );
|
||||
} else {
|
||||
$name_at = $namespace_at;
|
||||
$name_length = $namespace_length;
|
||||
$namespace_length = 0;
|
||||
}
|
||||
|
||||
$after_name_whitespace_at = $name_at + $name_length;
|
||||
$after_name_whitespace_length = strspn( $text, " \t\f\r\n", $after_name_whitespace_at );
|
||||
if ( 0 === $after_name_whitespace_length ) {
|
||||
$at = $this->find_html_comment_end( $comment_opening_at, $end );
|
||||
continue;
|
||||
}
|
||||
|
||||
$json_at = $after_name_whitespace_at + $after_name_whitespace_length;
|
||||
if ( $json_at >= $end ) {
|
||||
$this->last_error = self::INCOMPLETE_INPUT;
|
||||
return false;
|
||||
}
|
||||
$has_json = '{' === $text[ $json_at ];
|
||||
$json_length = 0;
|
||||
|
||||
/*
|
||||
* For the final span of the delimiter it's most efficient to find the end
|
||||
* of the HTML comment and work backwards. This prevents complicated parsing
|
||||
* inside the JSON span, which cannot contain the HTML comment terminator.
|
||||
*
|
||||
* This also matches the behavior in the official block parser, though it
|
||||
* allows for matching invalid JSON content.
|
||||
*/
|
||||
$comment_closing_at = strpos( $text, '-->', $json_at );
|
||||
if ( false === $comment_closing_at ) {
|
||||
$this->last_error = self::INCOMPLETE_INPUT;
|
||||
return false;
|
||||
}
|
||||
|
||||
/*
|
||||
* It looks like this logic leaves an error in here, when the position
|
||||
* overlaps the JSON or block name. However, for neither of those is it
|
||||
* possible to parse a valid block if that last overlapping character
|
||||
* is the void flag. This, therefore, will be valid regardless of how
|
||||
* the rest of the comment delimiter is written.
|
||||
*/
|
||||
if ( '/' === $text[ $comment_closing_at - 1 ] ) {
|
||||
$has_void_flag = true;
|
||||
$void_flag_length = 1;
|
||||
} else {
|
||||
$has_void_flag = false;
|
||||
$void_flag_length = 0;
|
||||
}
|
||||
|
||||
/*
|
||||
* If there's no JSON, then the span of text after the name
|
||||
* until the comment closing must be completely whitespace.
|
||||
*/
|
||||
if ( ! $has_json ) {
|
||||
$max_whitespace_length = $comment_closing_at - $json_at - $void_flag_length;
|
||||
|
||||
// This shouldn't be possible, but it can't be allowed regardless.
|
||||
if ( $max_whitespace_length < 0 ) {
|
||||
$at = $this->find_html_comment_end( $comment_opening_at, $end );
|
||||
continue;
|
||||
}
|
||||
|
||||
$closing_whitespace_length = strspn( $text, " \t\f\r\n", $json_at, $comment_closing_at - $json_at - $void_flag_length );
|
||||
if ( 0 === $after_name_whitespace_length + $closing_whitespace_length ) {
|
||||
$at = $this->find_html_comment_end( $comment_opening_at, $end );
|
||||
continue;
|
||||
}
|
||||
|
||||
// This must be a block delimiter!
|
||||
$found_one = true;
|
||||
break;
|
||||
}
|
||||
|
||||
// There's no JSON, so attempt to find its boundary.
|
||||
$after_json_whitespace_length = 0;
|
||||
for ( $char_at = $comment_closing_at - $void_flag_length - 1; $char_at > $json_at; $char_at-- ) {
|
||||
$char = $text[ $char_at ];
|
||||
|
||||
switch ( $char ) {
|
||||
case ' ':
|
||||
case "\t":
|
||||
case "\f":
|
||||
case "\r":
|
||||
case "\n":
|
||||
++$after_json_whitespace_length;
|
||||
continue 2;
|
||||
|
||||
case '}':
|
||||
$json_length = $char_at - $json_at + 1;
|
||||
break 2;
|
||||
|
||||
default:
|
||||
++$at;
|
||||
continue 3;
|
||||
}
|
||||
}
|
||||
|
||||
if ( 0 === $json_length || 0 === $after_json_whitespace_length ) {
|
||||
$at = $this->find_html_comment_end( $comment_opening_at, $end );
|
||||
continue;
|
||||
}
|
||||
|
||||
// This must be a block delimiter!
|
||||
$found_one = true;
|
||||
break;
|
||||
}
|
||||
|
||||
if ( ! $found_one ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->delimiter_at = $comment_opening_at;
|
||||
$this->delimiter_length = $comment_closing_at + 3 - $comment_opening_at;
|
||||
|
||||
$this->namespace_at = $namespace_at;
|
||||
$this->namespace_length = $namespace_length;
|
||||
|
||||
$this->name_at = $name_at;
|
||||
$this->name_length = $name_length;
|
||||
|
||||
$this->json_at = $json_at;
|
||||
$this->json_length = $json_length;
|
||||
|
||||
$this->type = $has_closer
|
||||
? static::CLOSER
|
||||
: ( $has_void_flag ? static::VOID : static::OPENER );
|
||||
|
||||
$this->has_void_flag = $has_void_flag;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructor function.
|
||||
*
|
||||
* @param string $source_text Input document potentially containing block content.
|
||||
*/
|
||||
private function __construct( string $source_text ) {
|
||||
$this->source_text = $source_text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the byte-offset after the ending character of an HTML comment,
|
||||
* assuming the proper starting byte offset.
|
||||
*
|
||||
* @param int $comment_starting_at Where the HTML comment started, the leading `<`.
|
||||
* @param int $search_end Last offset in which to search, for limiting search span.
|
||||
* @return int Offset after the current HTML comment ends, or `$end` if no end was found.
|
||||
*/
|
||||
private function find_html_comment_end( int $comment_starting_at, int $search_end ): int {
|
||||
$text = $this->source_text;
|
||||
|
||||
// Find span-of-dashes comments which look like `<!----->`.
|
||||
$span_of_dashes = strspn( $text, '-', $comment_starting_at + 2 );
|
||||
if (
|
||||
$comment_starting_at + 2 + $span_of_dashes < $search_end &&
|
||||
'>' === $text[ $comment_starting_at + 2 + $span_of_dashes ]
|
||||
) {
|
||||
return $comment_starting_at + $span_of_dashes + 1;
|
||||
}
|
||||
|
||||
// Otherwise, there are other characters inside the comment, find the first `-->` or `--!>`.
|
||||
$now_at = $comment_starting_at + 4;
|
||||
while ( $now_at < $search_end ) {
|
||||
$dashes_at = strpos( $text, '--', $now_at );
|
||||
if ( false === $dashes_at ) {
|
||||
$this->last_error = self::INCOMPLETE_INPUT;
|
||||
return $search_end;
|
||||
}
|
||||
|
||||
$closer_must_be_at = $dashes_at + 2 + strspn( $text, '-', $dashes_at + 2 );
|
||||
if ( $closer_must_be_at < $search_end && '!' === $text[ $closer_must_be_at ] ) {
|
||||
$closer_must_be_at++;
|
||||
}
|
||||
|
||||
if ( $closer_must_be_at < $search_end && '>' === $text[ $closer_must_be_at ] ) {
|
||||
return $closer_must_be_at + 1;
|
||||
}
|
||||
|
||||
$now_at++;
|
||||
}
|
||||
|
||||
return $search_end;
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates if the last attempt to parse a block comment delimiter
|
||||
* failed, if set, otherwise `null` if the last attempt succeeded.
|
||||
*
|
||||
* @return string|null
|
||||
*/
|
||||
public function get_last_error() {
|
||||
return $this->last_error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates if the last attempt to parse a block’s JSON attributes failed.
|
||||
*
|
||||
* @see JSON_ERROR_NONE, JSON_ERROR_DEPTH, etc…
|
||||
*
|
||||
* @return int JSON_ERROR_ code from last attempt to parse block JSON attributes.
|
||||
*/
|
||||
public function get_last_json_error(): int {
|
||||
return $this->last_json_error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the type of the block comment delimiter.
|
||||
*
|
||||
* One of:
|
||||
*
|
||||
* - `static::OPENER`
|
||||
* - `static::CLOSER`
|
||||
* - `static::VOID`
|
||||
*
|
||||
* @return string type of the block comment delimiter.
|
||||
*/
|
||||
public function get_delimiter_type(): string {
|
||||
return $this->type;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the delimiter contains the void flag.
|
||||
*
|
||||
* This should be avoided except in cases of handling errors with
|
||||
* block closers containing the void flag. For normative use,
|
||||
* {@see self::get_delimiter_type}.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function has_void_flag(): bool {
|
||||
return $this->has_void_flag;
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates if the block delimiter represents a block of the given type.
|
||||
*
|
||||
* Since the "core" namespace may be implicit, it's allowable to pass
|
||||
* either the fully-qualified block type with namespace and block name
|
||||
* as well as the shorthand version only containing the block name, if
|
||||
* the desired block is in the "core" namespace.
|
||||
*
|
||||
* Example:
|
||||
*
|
||||
* $is_core_paragraph = $scanner->is_block_type( 'paragraph' );
|
||||
* $is_core_paragraph = $scanner->is_block_type( 'core/paragraph' );
|
||||
* $is_formula = $scanner->is_block_type( 'math-block/formula' );
|
||||
*
|
||||
* @param string $block_type Block type name for the desired block.
|
||||
* E.g. "paragraph", "core/paragraph", "math-blocks/formula".
|
||||
* @return bool Whether this delimiter represents a block of the given type.
|
||||
*/
|
||||
public function is_block_type( string $block_type ): bool {
|
||||
// This is a core/freeform text block, it’s special.
|
||||
if ( 0 === $this->name_length ) {
|
||||
return 'core/freeform' === $block_type || 'freeform' === $block_type;
|
||||
}
|
||||
|
||||
$slash_at = strpos( $block_type, '/' );
|
||||
if ( false === $slash_at ) {
|
||||
$namespace = 'core';
|
||||
$block_name = $block_type;
|
||||
} else {
|
||||
// @todo Get lengths but avoid the allocation, use substr_compare below.
|
||||
$namespace = substr( $block_type, 0, $slash_at );
|
||||
$block_name = substr( $block_type, $slash_at + 1 );
|
||||
}
|
||||
|
||||
// Only the 'core' namespace is allowed to be omitted.
|
||||
if ( 0 === $this->namespace_length && 'core' !== $namespace ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If given an explicit namespace, they must match.
|
||||
if (
|
||||
0 !== $this->namespace_length && (
|
||||
strlen( $namespace ) !== $this->namespace_length ||
|
||||
0 !== substr_compare( $this->source_text, $namespace, $this->namespace_at, $this->namespace_length )
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// The block name must match.
|
||||
return (
|
||||
strlen( $block_name ) === $this->name_length &&
|
||||
0 === substr_compare( $this->source_text, $block_name, $this->name_at, $this->name_length )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates if the matched delimiter is an opening or void delimiter
|
||||
* (i.e. it opens the block) of the given type, if a type is provided.
|
||||
*
|
||||
* This is a helper method to ease handling of code inspecting where
|
||||
* blocks start, and of checking if the blocks are of a given type.
|
||||
* The function is variadic to allow for checking if the delimiter
|
||||
* opens one of many possible block types.
|
||||
*
|
||||
* Example:
|
||||
*
|
||||
* $scanner = Block_Scanner::create( $html );
|
||||
* while ( $scanner->next_delimiter() ) {
|
||||
* if ( $scanner->opens_block( 'core/code', 'syntaxhighlighter/code' ) ) {
|
||||
* echo "Found code!";
|
||||
* continue;
|
||||
* }
|
||||
*
|
||||
* if ( $scanner->opens_block( 'core/image' ) ) {
|
||||
* echo "Found an image!";
|
||||
* continue;
|
||||
* }
|
||||
*
|
||||
* if ( $scanner->opens_block() ) {
|
||||
* echo "Found a new block!";
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* @see self::is_block_type
|
||||
*
|
||||
* @param string|null ...$block_type Optional. Is the matched block type one of these?
|
||||
* If none are provided, will not test block type.
|
||||
* @return bool Whether the matched block delimiter opens a block, and whether it
|
||||
* opens a block of one of the given block types, if provided.
|
||||
*/
|
||||
public function opens_block( ...$block_type ): bool {
|
||||
if ( static::CLOSER === $this->type ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ( count( $block_type ) === 0 ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
foreach ( $block_type as $block ) {
|
||||
if ( $this->is_block_type( $block ) ) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates if the matched delimiter is implied due to top-level
|
||||
* non-block content in the post.
|
||||
*
|
||||
* @see self::is_non_whitespace_freeform
|
||||
*
|
||||
* @return bool Whether or not the matched delimiter is implied as `core/freeform`.
|
||||
*/
|
||||
public function is_freeform(): bool {
|
||||
return 0 === $this->name_length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates if the matched delimiter is implicit and surrounding
|
||||
* top-level non-block content that contains non-whitespace text.
|
||||
*
|
||||
* Many block serializers introduce newlines between block delimiters,
|
||||
* so the presence of top-level non-block content does not imply that
|
||||
* there are “real” freeform HTML blocks. Checking if there is content
|
||||
* beyond whitespace is a more certain check, such as for determining
|
||||
* whether to load CSS for the freeform or fallback block type.
|
||||
*
|
||||
* @see self::is_freeform
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function is_non_whitespace_freeform(): bool {
|
||||
if ( 0 !== $this->name_length ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// For now, return false as this method is not yet fully implemented.
|
||||
// @todo Implement logic to check if freeform content contains non-whitespace text.
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Allocates a substring for the block type and returns the
|
||||
* fully-qualified name, including the namespace.
|
||||
*
|
||||
* This function allocates a substring for the given block type. This
|
||||
* allocation will be small and likely fine in most cases, but it's
|
||||
* preferable to call {@link self::is_block_type} if only needing
|
||||
* to know whether the delimiter is for a given block type, as that
|
||||
* function is more efficient for this purpose and avoids the allocation.
|
||||
*
|
||||
* Example:
|
||||
*
|
||||
* // Avoid.
|
||||
* 'core/paragraph' = $scanner->get_block_type();
|
||||
*
|
||||
* // Prefer.
|
||||
* $scanner->is_block_type( 'core/paragraph' );
|
||||
* $scanner->is_block_type( 'paragraph' );
|
||||
*
|
||||
* @return string Fully-qualified block namespace and type, e.g. "core/paragraph".
|
||||
*/
|
||||
public function get_block_type(): string {
|
||||
// This is a core/freeform text block, it’s special.
|
||||
if ( 0 === $this->name_length ) {
|
||||
return 'core/freeform';
|
||||
}
|
||||
|
||||
// This is implicitly in the "core" namespace.
|
||||
if ( 0 === $this->namespace_length ) {
|
||||
$block_name = substr( $this->source_text, $this->name_at, $this->name_length );
|
||||
return "core/{$block_name}";
|
||||
}
|
||||
|
||||
return substr( $this->source_text, $this->namespace_at, $this->namespace_length + $this->name_length + 1 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a lazy wrapper around the block attributes, which can be used
|
||||
* for efficiently interacting with the JSON attributes.
|
||||
*
|
||||
* @throws Exception This function is not yet implemented.
|
||||
*
|
||||
* @todo Create a lazy JSON wrapper so specific attributes can be
|
||||
* efficiently queried without parsing everything and loading
|
||||
* the entire object into memory.
|
||||
* @todo After realistic benchmarking, see if JsonStreamingParser\Parser
|
||||
* could be used — it would need to be fast enough for the reduction
|
||||
* in memory use to be worth it, compared to {@see \json_decode}.
|
||||
*
|
||||
* @see \JsonStreamingParser\Parser
|
||||
*
|
||||
* @return never
|
||||
*/
|
||||
public function get_attributes(): void {
|
||||
throw new Exception( 'Lazy attribute parsing not yet supported' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to parse and return the entire JSON attributes from the delimiter,
|
||||
* allocating memory and processing the JSON span in the process.
|
||||
*
|
||||
* This does not return any parsed attributes for a closing block delimiter
|
||||
* even if there is a span of JSON content; this JSON is a parsing error.
|
||||
*
|
||||
* Consider calling {@link self::get_attributes} instead if it's not
|
||||
* necessary to read all the attributes at the same time, as that provides
|
||||
* a more efficient mechanism for typical use cases.
|
||||
*
|
||||
* Since the JSON span inside the comment delimiter may not be valid JSON,
|
||||
* this function will return `null` if it cannot parse the span and set the
|
||||
* {@see self::get_last_json_error} to the appropriate JSON_ERROR_ constant.
|
||||
*
|
||||
* If the delimiter contains no JSON span, it will also return `null`,
|
||||
* but the last error will be set to {@see JSON_ERROR_NONE}.
|
||||
*
|
||||
* Example:
|
||||
*
|
||||
* $scanner = Block_Scanner::create( '<!-- wp:image {"url": "https://wordpress.org/favicon.ico"} -->' );
|
||||
* $scanner->next_delimiter();
|
||||
* $memory_hungry_and_slow_attributes = $scanner->allocate_and_return_parsed_attributes();
|
||||
* $memory_hungry_and_slow_attributes === array( 'url' => 'https://wordpress.org/favicon.ico' );
|
||||
*
|
||||
* $scanner = Block_Scanner::create( '<!-- /wp:image {"url": "https://wordpress.org/favicon.ico"} -->' );
|
||||
* $scanner->next_delimiter();
|
||||
* null = $scanner->allocate_and_return_parsed_attributes();
|
||||
* JSON_ERROR_NONE = $scanner->get_last_json_error();
|
||||
*
|
||||
* $scanner = Block_Scanner::create( '<!-- wp:separator {} /-->' );
|
||||
* $scanner->next_delimiter();
|
||||
* array() === $scanner->allocate_and_return_parsed_attributes();
|
||||
*
|
||||
* $scanner = Block_Scanner::create( '<!-- wp:separator /-->' );
|
||||
* $scanner->next_delimiter();
|
||||
* null = $scanner->allocate_and_return_parsed_attributes();
|
||||
*
|
||||
* $scanner = Block_Scanner::create( '<!-- wp:image {"url} -->' );
|
||||
* $scanner->next_delimiter();
|
||||
* null = $scanner->allocate_and_return_parsed_attributes();
|
||||
* JSON_ERROR_CTRL_CHAR = $scanner->get_last_json_error();
|
||||
*
|
||||
* @return array|null Parsed JSON attributes, if present and valid, otherwise `null`.
|
||||
*/
|
||||
public function allocate_and_return_parsed_attributes(): ?array {
|
||||
$this->last_json_error = JSON_ERROR_NONE;
|
||||
|
||||
if ( static::CLOSER === $this->type ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ( 0 === $this->json_length ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$json_span = substr( $this->source_text, $this->json_at, $this->json_length );
|
||||
$parsed = json_decode( $json_span, null, 512, JSON_OBJECT_AS_ARRAY | JSON_INVALID_UTF8_SUBSTITUTE );
|
||||
|
||||
$last_error = json_last_error();
|
||||
$this->last_json_error = $last_error;
|
||||
|
||||
return ( JSON_ERROR_NONE === $last_error && is_array( $parsed ) )
|
||||
? $parsed
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the span representing the currently-matched delimiter,
|
||||
* if matched, otherwise `null`.
|
||||
*
|
||||
* Note that for freeform blocks this will return a span of length
|
||||
* zero, since there is no explicit block delimiter.
|
||||
*
|
||||
* Example:
|
||||
*
|
||||
* $scanner = Block_Scanner::create( '<!-- wp:void /-->' );
|
||||
* null === $scanner->get_span();
|
||||
*
|
||||
* $scanner->next_delimiter();
|
||||
* WP_HTML_Span( 0, 17 ) === $scanner->get_span();
|
||||
*
|
||||
* @return WP_HTML_Span|null Span of text in source text spanning matched delimiter.
|
||||
*/
|
||||
public function get_span(): ?WP_HTML_Span {
|
||||
return new WP_HTML_Span( $this->delimiter_at, $this->delimiter_length );
|
||||
}
|
||||
|
||||
// Debugging methods not meant for production use.
|
||||
|
||||
/**
|
||||
* Prints a debugging message showing the structure of the parsed delimiter.
|
||||
*
|
||||
* This is not meant to be used in production!
|
||||
*
|
||||
* @access private
|
||||
*/
|
||||
public function debug_print_structure(): void {
|
||||
$c = ( ! defined( 'STDOUT' ) || posix_isatty( STDOUT ) )
|
||||
? function ( $color = null ) { return $color; } // phpcs:ignore
|
||||
: function ( $color ) { return ''; }; // phpcs:ignore
|
||||
|
||||
if ( $this->is_block_type( 'core/freeform' ) ) {
|
||||
$closer = static::CLOSER === $this->get_delimiter_type() ? '/' : '';
|
||||
echo "{$c( "\e[90m" )}<!-- "; // phpcs:ignore
|
||||
echo "{$c( "\e[0;31m" )}{$closer}"; // phpcs:ignore
|
||||
echo "{$c("\e[90m" )}wp:"; // phpcs:ignore
|
||||
echo "{$c( "\e[0;34m" )}freeform"; // phpcs:ignore
|
||||
echo "{$c( "\e[0;36m" )} {$c("\e[90m")}-->\n"; // phpcs:ignore
|
||||
return;
|
||||
}
|
||||
|
||||
$namespace = substr( $this->source_text, $this->namespace_at, $this->namespace_length );
|
||||
$slash = 0 === $this->namespace_length ? '' : '/';
|
||||
$block_name = substr( $this->source_text, $this->name_at, $this->name_length );
|
||||
$closer = static::CLOSER === $this->type ? '/' : '';
|
||||
$json = substr( $this->source_text, $this->json_at, $this->json_length );
|
||||
|
||||
$opener_whitespace_at = $this->delimiter_at + 4;
|
||||
$opener_whitespace_length = $this->namespace_at - 3 - $opener_whitespace_at - ( static::CLOSER === $this->type ? 1 : 0 );
|
||||
|
||||
$after_name_whitespace_at = $this->name_at + $this->name_length;
|
||||
$after_name_whitespace_length = $this->json_at - $after_name_whitespace_at;
|
||||
|
||||
$closing_whitespace_at = $this->json_at + $this->json_length;
|
||||
$closing_whitespace_length = $this->delimiter_at + $this->delimiter_length - 3 - $closing_whitespace_at;
|
||||
|
||||
if ( '/' === $this->source_text[ $this->delimiter_at + $this->delimiter_length - 4 ] ) {
|
||||
$void_flag = '/';
|
||||
--$closing_whitespace_length;
|
||||
} else {
|
||||
$void_flag = '';
|
||||
}
|
||||
|
||||
$w = function ( $whitespace ) use ( $c ) {
|
||||
return $c( "\e[2;90m" ) . str_replace( array( ' ', "\t", "\f", "\r", "\n" ), array( '␣', '␉', '␌', '␍', '' ), $whitespace );
|
||||
};
|
||||
|
||||
echo "{$c( "\e[90m" )}<!--"; // phpcs:ignore
|
||||
echo $w( substr( $this->source_text, $opener_whitespace_at, $opener_whitespace_length ) ); // phpcs:ignore
|
||||
echo "{$c( "\e[0;31m" )}{$closer}"; // phpcs:ignore
|
||||
echo "{$c("\e[90m" )}wp:{$c( "\e[2;34m" )}{$namespace}"; // phpcs:ignore
|
||||
echo "{$c( "\e[2;90m" )}{$slash}"; // phpcs:ignore
|
||||
echo "{$c( "\e[0;34m" )}{$block_name}"; // phpcs:ignore
|
||||
echo $w( substr( $this->source_text, $after_name_whitespace_at, $after_name_whitespace_length ) ); // phpcs:ignore
|
||||
echo "{$c("\e[0;2;32m" )}{$json}"; // phpcs:ignore
|
||||
echo $w( substr( $this->source_text, $closing_whitespace_at, $closing_whitespace_length ) ); // phpcs:ignore
|
||||
echo "{$c( "\e[0;36m" )}{$void_flag}{$c("\e[90m")}-->\n"; // phpcs:ignore
|
||||
}
|
||||
|
||||
// Constant declarations that would otherwise pollute the top of the class.
|
||||
|
||||
/**
|
||||
* Indicates that the block comment delimiter closes an open block.
|
||||
*/
|
||||
const CLOSER = 'closer';
|
||||
|
||||
/**
|
||||
* Indicates that the parser started parsing a block comment delimiter, but
|
||||
* the input document ended before it could finish. The document was likely truncated.
|
||||
*/
|
||||
const INCOMPLETE_INPUT = 'incomplete-input';
|
||||
|
||||
/**
|
||||
* Indicates that the block comment delimiter opens a block.
|
||||
*/
|
||||
const OPENER = 'opener';
|
||||
|
||||
/**
|
||||
* Indicates that the parser has not yet attempted to parse a block comment delimiter.
|
||||
*/
|
||||
const UNINITIALIZED = 'uninitialized';
|
||||
|
||||
/**
|
||||
* Indicates that the block comment delimiter represents a void block
|
||||
* with no inner content of any kind.
|
||||
*/
|
||||
const VOID = 'void';
|
||||
}
|
||||
Reference in New Issue
Block a user