Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 133 additions & 12 deletions src/wp-includes/blocks.php
Original file line number Diff line number Diff line change
Expand Up @@ -1025,6 +1025,30 @@ function insert_hooked_blocks( &$parsed_anchor_block, $relative_position, $hooke
$hooked_block_types = apply_filters( 'hooked_block_types', $hooked_block_types, $relative_position, $anchor_block_type, $context );

$markup = '';

/*
* Nothing below reads or writes anything but the anchor block, so an empty list
* leaves no work to do. Returning here avoids copying the anchor block's attributes
* for the many blocks that have no hooked blocks.
*
* This must run after the `hooked_block_types` filter, which can add a hooked block
* type to an anchor that has none registered.
*/
if ( empty( $hooked_block_types ) ) {
return $markup;
}

// Filters have always received array-shaped attributes.
$filtered_anchor_block = _wp_get_block_hooks_filter_anchor_block( $parsed_anchor_block );

/*
* Read `metadata` through an array cast. On the preserving parse path `"metadata":{}`
* arrives as an empty stdClass, and in PHP an array offset on an object is a fatal
* error, including inside isset() and ??.
*/
$anchor_metadata = (array) ( $parsed_anchor_block['attrs']['metadata'] ?? array() );
$ignored_hooked_blocks = (array) ( $anchor_metadata['ignoredHookedBlocks'] ?? array() );

foreach ( $hooked_block_types as $hooked_block_type ) {
$parsed_hooked_block = array(
'blockName' => $hooked_block_type,
Expand All @@ -1046,7 +1070,7 @@ function insert_hooked_blocks( &$parsed_anchor_block, $relative_position, $hooke
* @param WP_Block_Template|WP_Post|array $context The block template, template part, post object,
* or pattern that the anchor block belongs to.
*/
$parsed_hooked_block = apply_filters( 'hooked_block', $parsed_hooked_block, $hooked_block_type, $relative_position, $parsed_anchor_block, $context );
$parsed_hooked_block = apply_filters( 'hooked_block', $parsed_hooked_block, $hooked_block_type, $relative_position, $filtered_anchor_block, $context );

/**
* Filters the parsed block array for a given hooked block.
Expand All @@ -1062,18 +1086,15 @@ function insert_hooked_blocks( &$parsed_anchor_block, $relative_position, $hooke
* @param WP_Block_Template|WP_Post|array $context The block template, template part, post object,
* or pattern that the anchor block belongs to.
*/
$parsed_hooked_block = apply_filters( "hooked_block_{$hooked_block_type}", $parsed_hooked_block, $hooked_block_type, $relative_position, $parsed_anchor_block, $context );
$parsed_hooked_block = apply_filters( "hooked_block_{$hooked_block_type}", $parsed_hooked_block, $hooked_block_type, $relative_position, $filtered_anchor_block, $context );

if ( null === $parsed_hooked_block ) {
continue;
}

// It's possible that the filter returned a block of a different type, so we explicitly
// look for the original `$hooked_block_type` in the `ignoredHookedBlocks` metadata.
if (
! isset( $parsed_anchor_block['attrs']['metadata']['ignoredHookedBlocks'] ) ||
! in_array( $hooked_block_type, $parsed_anchor_block['attrs']['metadata']['ignoredHookedBlocks'], true )
) {
if ( ! in_array( $hooked_block_type, $ignored_hooked_blocks, true ) ) {
$markup .= serialize_block( $parsed_hooked_block );
}
}
Expand Down Expand Up @@ -1108,6 +1129,9 @@ function set_ignored_hooked_blocks_metadata( &$parsed_anchor_block, $relative_po
return '';
}

// Filters have always received array-shaped attributes.
$filtered_anchor_block = _wp_get_block_hooks_filter_anchor_block( $parsed_anchor_block );

foreach ( $hooked_block_types as $index => $hooked_block_type ) {
$parsed_hooked_block = array(
'blockName' => $hooked_block_type,
Expand All @@ -1117,25 +1141,33 @@ function set_ignored_hooked_blocks_metadata( &$parsed_anchor_block, $relative_po
);

/** This filter is documented in wp-includes/blocks.php */
$parsed_hooked_block = apply_filters( 'hooked_block', $parsed_hooked_block, $hooked_block_type, $relative_position, $parsed_anchor_block, $context );
$parsed_hooked_block = apply_filters( 'hooked_block', $parsed_hooked_block, $hooked_block_type, $relative_position, $filtered_anchor_block, $context );

/** This filter is documented in wp-includes/blocks.php */
$parsed_hooked_block = apply_filters( "hooked_block_{$hooked_block_type}", $parsed_hooked_block, $hooked_block_type, $relative_position, $parsed_anchor_block, $context );
$parsed_hooked_block = apply_filters( "hooked_block_{$hooked_block_type}", $parsed_hooked_block, $hooked_block_type, $relative_position, $filtered_anchor_block, $context );

if ( null === $parsed_hooked_block ) {
unset( $hooked_block_types[ $index ] );
}
}

$previously_ignored_hooked_blocks = $parsed_anchor_block['attrs']['metadata']['ignoredHookedBlocks'] ?? array();
/*
* Rebuild `metadata` through an array cast rather than writing into it in place. On
* the preserving parse path `"metadata":{}` arrives as an empty stdClass, and in PHP
* an array offset on an object is a fatal error, assignment included.
*/
$anchor_metadata = (array) ( $parsed_anchor_block['attrs']['metadata'] ?? array() );
$previously_ignored_hooked_blocks = (array) ( $anchor_metadata['ignoredHookedBlocks'] ?? array() );

$parsed_anchor_block['attrs']['metadata']['ignoredHookedBlocks'] = array_unique(
$anchor_metadata['ignoredHookedBlocks'] = array_unique(
array_merge(
$previously_ignored_hooked_blocks,
$hooked_block_types
)
);

$parsed_anchor_block['attrs']['metadata'] = $anchor_metadata;

// Markup for the hooked blocks has already been created (in `insert_hooked_blocks`).
return '';
}
Expand Down Expand Up @@ -1237,7 +1269,7 @@ function apply_block_hooks_to_content( $content, $context = null, $callback = 'i
};
add_filter( 'hooked_block_types', $suppress_single_instance_blocks, PHP_INT_MAX );
$content = traverse_and_serialize_blocks(
parse_blocks( $content ),
_wp_parse_blocks_preserving_empty_object_attributes( $content ),
$before_block_visitor,
$after_block_visitor
);
Expand Down Expand Up @@ -2123,7 +2155,7 @@ function filter_block_content( $text, $allowed_html = 'post', $allowed_protocols
$text = preg_replace_callback( '%<!--(.*?)--->%', '_filter_block_content_callback', $text );
}

$blocks = parse_blocks( $text );
$blocks = _wp_parse_blocks_preserving_empty_object_attributes( $text );
foreach ( $blocks as $block ) {
$block = filter_block_kses( $block, $allowed_html, $allowed_protocols );
$result .= serialize_block( $block );
Expand Down Expand Up @@ -2559,6 +2591,95 @@ function parse_blocks( $content ) {
return $parser->parse( $content );
}

/**
* Parses blocks while preserving nested empty JSON object attributes.
*
* An attribute written as `{}` decodes to an empty PHP array like any other JSON object,
* and is therefore re-serialized as `[]`. This is only a problem for code that parses
* stored markup and writes it back, so preservation is limited to the two workflows that
* do: the Block Hooks algorithm and block content filtering through KSES.
*
* Nested empty objects are returned as empty stdClass instances. Every other value keeps
* the shape the default parse path produces.
*
* Custom block parsers that do not implement `parse_with_options()` fall back to their
* existing parse behavior.
*
* @since 7.2.0
* @access private
*
* @param string $content Post content.
* @return array[] Array of parsed block objects.
*/
function _wp_parse_blocks_preserving_empty_object_attributes( $content ) {
/** This filter is documented in wp-includes/blocks.php */
$parser_class = apply_filters( 'block_parser_class', 'WP_Block_Parser' );

$parser = new $parser_class();

if ( method_exists( $parser, 'parse_with_options' ) ) {
return $parser->parse_with_options(
$content,
array( 'preserve_empty_object_attributes' => true )
);
}

return $parser->parse( $content );
}

/**
* Converts preserved empty object attributes back to empty arrays.
*
* Used when exposing an internally preserved parsed block to extension points that
* historically received arrays throughout.
*
* @since 7.2.0
* @access private
*
* @param mixed $value Attribute value.
* @return mixed The value with empty objects converted to empty arrays.
*/
function _wp_block_attribute_empty_objects_to_arrays( $value ) {
if ( $value instanceof stdClass ) {
return array();
}

if ( ! is_array( $value ) ) {
return $value;
}

foreach ( $value as $key => $child_value ) {
if ( is_array( $child_value ) || $child_value instanceof stdClass ) {
$value[ $key ] = _wp_block_attribute_empty_objects_to_arrays( $child_value );
}
}

return $value;
}

/**
* Returns a parsed block using the historical all-array attribute shape.
*
* The Block Hooks algorithm parses with empty-object preservation enabled, so an anchor
* block handed to a third-party filter would otherwise expose an empty stdClass where
* that filter has always seen an empty array.
*
* @since 7.2.0
* @access private
*
* @param array $parsed_block A block, in parsed block array format.
* @return array A copy of the block with all-array attributes.
*/
function _wp_get_block_hooks_filter_anchor_block( $parsed_block ) {
if ( empty( $parsed_block['attrs'] ) || ! is_array( $parsed_block['attrs'] ) ) {
return $parsed_block;
}

$parsed_block['attrs'] = _wp_block_attribute_empty_objects_to_arrays( $parsed_block['attrs'] );

return $parsed_block;
}

/**
* Parses dynamic blocks out of `post_content` and re-renders them.
*
Expand Down
118 changes: 117 additions & 1 deletion src/wp-includes/class-wp-block-parser.php
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,14 @@ class WP_Block_Parser {
*/
public $stack;

/**
* Parse options for the current parse
*
* @since 7.2.0
* @var array
*/
protected $options = array();

/**
* Parses a document and returns a list of block structures
*
Expand All @@ -73,6 +81,35 @@ public function parse( $document ) {
return $this->output;
}

/**
* Parses a document with parse options and returns a list of block structures.
*
* Separate from {@see WP_Block_Parser::parse()} to leave that method's signature
* unchanged, since a parser supplied through the `block_parser_class` filter may
* subclass this class and override it. Delegating to `parse()` rather than
* reimplementing it keeps any such override in effect.
*
* @since 7.2.0
*
* @param string $document Input document being parsed.
* @param array $options Optional. Parse options. Supports the
* `preserve_empty_object_attributes` key, which keeps a
* nested empty JSON object attribute as an empty object
* rather than collapsing it to an empty array. Anything
* that is not an array is ignored. Default empty array.
* @return array[]
*/
public function parse_with_options( $document, $options = array() ) {
$this->options = is_array( $options ) ? $options : array();

try {
return $this->parse( $document );
} finally {
// Options apply to a single parse only.
$this->options = array();
}
}

/**
* Processes the next token from the input document
* and returns whether to proceed eating more tokens
Expand Down Expand Up @@ -277,7 +314,7 @@ public function next_token() {
* are associative arrays. If we use `array()` we get a JSON `[]`
*/
$attrs = $has_attrs
? json_decode( $matches['attrs'][0], /* as-associative */ true )
? $this->parse_block_attributes( $matches['attrs'][0] )
: array();

/*
Expand Down Expand Up @@ -387,6 +424,85 @@ public function add_block_from_stack( $end_offset = null ) {

$this->output[] = (array) $stack_top->block;
}

/**
* Decodes a block's attribute JSON.
*
* @since 7.2.0
*
* @param string $json Raw attribute JSON from the block delimiter.
* @return array|null Decoded attributes, or null on invalid JSON.
*/
private function parse_block_attributes( $json ) {
if (
empty( $this->options['preserve_empty_object_attributes'] )
/*
* An attribute string with no `{}` token cannot contain an empty object, so
* there is nothing to preserve and the historical decode is used. Most
* attributes fall in that group, which keeps their cost unchanged. The
* character class covers the whitespace JSON permits between the braces.
*
* A `{}` inside a string value, as in `{"tpl":"{}"}`, only leads to a walk
* that finds nothing to change, so this is an optimization, not a fork.
*/
|| ! preg_match( '/\{[ \t\r\n]*\}/', $json )
) {
// Default (historical) behavior: objects and arrays both decode to arrays.
return json_decode( $json, /* associative */ true );
}

$decoded = json_decode( $json, /* associative */ false );
if ( JSON_ERROR_NONE !== json_last_error() ) {
return null;
}

return self::normalize_block_attributes( $decoded, /* is_attribute_root */ true );
}

/**
* Converts a json_decode(..., false) result into the parsed attribute shape,
* keeping only nested empty objects as objects.
*
* Every other JSON object becomes a PHP array, matching the default parse path. An
* empty object holds no keys and no strings, so nothing downstream needs to read
* into it or sanitize it; a populated object would hide its contents from code that
* walks arrays.
*
* wp_json_encode() emits `{}` for an empty object and `[]` for an empty array, so
* the distinction survives serialization without a marker or a restore step.
*
* @since 7.2.0
*
* @param mixed $value Decoded value (stdClass, array, or scalar).
* @param bool $is_attribute_root Whether $value is the top-level attribute container,
* which always becomes an array so that empty
* attributes keep being dropped on serialization.
* @return mixed The normalized value.
*/
private static function normalize_block_attributes( $value, $is_attribute_root = false ) {
if ( $value instanceof stdClass ) {
$properties = get_object_vars( $value );

if ( ! $is_attribute_root && empty( $properties ) ) {
return $value;
}

$normalized = array();
foreach ( $properties as $key => $child_value ) {
$normalized[ $key ] = self::normalize_block_attributes( $child_value );
}

return $normalized;
}

if ( is_array( $value ) ) {
foreach ( $value as $key => $child_value ) {
$value[ $key ] = self::normalize_block_attributes( $child_value );
}
}

return $value; // Scalars and null pass through unchanged.
}
}

/**
Expand Down
Loading
Loading