diff --git a/README.md b/README.md
index 5361bed6..8d0ab062 100644
--- a/README.md
+++ b/README.md
@@ -3,7 +3,7 @@ wp-cli/entity-command
Manage WordPress comments, menus, options, posts, sites, terms, and users.
-[](https://github.com/wp-cli/entity-command/actions/workflows/testing.yml)
+[](https://github.com/wp-cli/entity-command/actions/workflows/testing.yml) [](https://codecov.io/gh/wp-cli/entity-command/tree/main)
Quick links: [Using](#using) | [Installing](#installing) | [Contributing](#contributing) | [Support](#support)
@@ -458,7 +458,7 @@ wp comment meta delete [] [] [--all]
Get meta field value.
~~~
-wp comment meta get [--format=]
+wp comment meta get [--single] [--format=]
~~~
**OPTIONS**
@@ -469,6 +469,9 @@ wp comment meta get [--format=]
The name of the meta field to get.
+ [--single]
+ Whether to return a single value.
+
[--format=]
Get value in a particular format.
---
@@ -1444,7 +1447,7 @@ wp network meta delete [] [] [--all]
Get meta field value.
~~~
-wp network meta get [--format=]
+wp network meta get [--single] [--format=]
~~~
**OPTIONS**
@@ -1455,6 +1458,9 @@ wp network meta get [--format=]
The name of the meta field to get.
+ [--single]
+ Whether to return a single value.
+
[--format=]
Get value in a particular format.
---
@@ -2459,6 +2465,11 @@ wp post get [--field=] [--fields=] [--format=]
# Save the post content to a file
$ wp post get 123 --field=content > file.txt
+ # Get the block version of a post (1 = has blocks, 0 = no blocks)
+ # Requires WordPress 5.0+.
+ $ wp post get 123 --field=block_version
+ 1
+
### wp post list
@@ -2686,7 +2697,7 @@ wp post meta delete [] [] [--all]
Get meta field value.
~~~
-wp post meta get [--format=]
+wp post meta get [--single] [--format=]
~~~
**OPTIONS**
@@ -2697,6 +2708,9 @@ wp post meta get [--format=]
The name of the meta field to get.
+ [--single]
+ Whether to return a single value.
+
[--format=]
Get value in a particular format.
---
@@ -3195,6 +3209,936 @@ wp post url-to-id
+### wp post has-blocks
+
+Checks if a post contains any blocks.
+
+~~~
+wp post has-blocks
+~~~
+
+Exits with return code 0 if the post contains blocks,
+or return code 1 if it does not.
+
+**OPTIONS**
+
+
+ The ID of the post to check.
+
+**EXAMPLES**
+
+ # Check if post contains blocks.
+ $ wp post has-blocks 123
+ Success: Post 123 contains blocks.
+
+ # Check a classic (non-block) post.
+ $ wp post has-blocks 456
+ Error: Post 456 does not contain blocks.
+
+ # Use in a shell conditional.
+ $ if wp post has-blocks 123 2>/dev/null; then
+ > echo "Post uses blocks"
+ > fi
+
+
+
+### wp post has-block
+
+Checks if a post contains a specific block type.
+
+~~~
+wp post has-block
+~~~
+
+Exits with return code 0 if the post contains the specified block,
+or return code 1 if it does not.
+
+**OPTIONS**
+
+
+ The ID of the post to check.
+
+
+ The block type name to check for (e.g., 'core/paragraph').
+
+**EXAMPLES**
+
+ # Check if post contains a paragraph block.
+ $ wp post has-block 123 core/paragraph
+ Success: Post 123 contains block 'core/paragraph'.
+
+ # Check for a heading block.
+ $ wp post has-block 123 core/heading
+ Success: Post 123 contains block 'core/heading'.
+
+ # Check for a block that doesn't exist.
+ $ wp post has-block 123 core/gallery
+ Error: Post 123 does not contain block 'core/gallery'.
+
+ # Check for a custom block from a plugin.
+ $ wp post has-block 123 my-plugin/custom-block
+
+
+
+### wp post block
+
+Manages blocks within post content.
+
+~~~
+wp post block
+~~~
+
+Provides commands for inspecting, manipulating, and managing
+Gutenberg blocks in post content.
+
+**EXAMPLES**
+
+ # List all blocks in a post.
+ $ wp post block list 123
+ +------------------+-------+
+ | blockName | count |
+ +------------------+-------+
+ | core/paragraph | 2 |
+ | core/heading | 1 |
+ +------------------+-------+
+
+ # Parse blocks in a post to JSON.
+ $ wp post block parse 123 --format=json
+
+ # Insert a paragraph block.
+ $ wp post block insert 123 core/paragraph --content="Hello World"
+
+
+
+
+
+### wp post block clone
+
+Clones a block within a post.
+
+~~~
+wp post block clone [--position=] [--porcelain]
+~~~
+
+Duplicates an existing block and inserts it at a specified position.
+
+**OPTIONS**
+
+
+ The ID of the post.
+
+
+ Index of the block to clone (0-indexed).
+
+ [--position=]
+ Where to insert the cloned block. Accepts 'after', 'before', 'start', 'end', or a numeric index.
+ ---
+ default: after
+ ---
+
+ [--porcelain]
+ Output just the new block index.
+
+**EXAMPLES**
+
+ # Clone a block and insert immediately after it (default).
+ $ wp post block clone 123 2
+ Success: Cloned block to index 3 in post 123.
+
+ # Clone the first block and insert immediately before it.
+ $ wp post block clone 123 0 --position=before
+ Success: Cloned block to index 0 in post 123.
+
+ # Clone a block and insert at the end of the post.
+ $ wp post block clone 123 0 --position=end
+ Success: Cloned block to index 5 in post 123.
+
+ # Clone a block and insert at the start of the post.
+ $ wp post block clone 123 3 --position=start
+ Success: Cloned block to index 0 in post 123.
+
+ # Clone and get just the new block index for scripting.
+ $ wp post block clone 123 1 --porcelain
+ 2
+
+ # Duplicate the hero section (first block) at the end for a footer.
+ $ wp post block clone 123 0 --position=end
+ Success: Cloned block to index 10 in post 123.
+
+
+
+### wp post block count
+
+Counts blocks across multiple posts.
+
+~~~
+wp post block count [...] [--block=] [--post-type=] [--post-status=] [--format=]
+~~~
+
+Analyzes block usage across posts for site-wide reporting.
+
+**OPTIONS**
+
+ [...]
+ Optional post IDs. If not specified, queries all posts.
+
+ [--block=]
+ Only count specific block type.
+
+ [--post-type=]
+ Limit to specific post type(s). Comma-separated.
+ ---
+ default: post,page
+ ---
+
+ [--post-status=]
+ Post status to include.
+ ---
+ default: publish
+ ---
+
+ [--format=]
+ Output format.
+ ---
+ default: table
+ options:
+ - table
+ - json
+ - csv
+ - yaml
+ - count
+ ---
+
+**EXAMPLES**
+
+ # Count all blocks across published posts and pages.
+ $ wp post block count
+ +------------------+-------+-------+
+ | blockName | count | posts |
+ +------------------+-------+-------+
+ | core/paragraph | 1542 | 234 |
+ | core/heading | 523 | 198 |
+ | core/image | 312 | 156 |
+ +------------------+-------+-------+
+
+ # Count blocks in specific posts only.
+ $ wp post block count 123 456 789
+ +------------------+-------+-------+
+ | blockName | count | posts |
+ +------------------+-------+-------+
+ | core/paragraph | 8 | 3 |
+ | core/heading | 3 | 2 |
+ +------------------+-------+-------+
+
+ # Count only paragraph blocks across the site.
+ $ wp post block count --block=core/paragraph --format=count
+ 1542
+
+ # Count blocks in a custom post type.
+ $ wp post block count --post-type=product
+
+ # Count blocks in multiple post types.
+ $ wp post block count --post-type=post,page,product
+
+ # Count blocks including drafts.
+ $ wp post block count --post-status=draft
+
+ # Get count as JSON for further processing.
+ $ wp post block count --format=json
+ [{"blockName":"core/paragraph","count":1542,"posts":234}]
+
+ # Get total number of unique block types used.
+ $ wp post block count --format=count
+ 15
+
+
+
+### wp post block export
+
+Exports block content to a file.
+
+~~~
+wp post block export [--file=] [--format=] [--raw]
+~~~
+
+Exports blocks from a post to a file for backup or migration.
+
+**OPTIONS**
+
+
+ The ID of the post to export blocks from.
+
+ [--file=]
+ Output file path. If not specified, outputs to STDOUT.
+
+ [--format=]
+ Export format.
+ ---
+ default: json
+ options:
+ - json
+ - yaml
+ - html
+ ---
+
+ [--raw]
+ Include innerHTML in JSON/YAML output.
+
+**EXAMPLES**
+
+ # Export blocks to a JSON file for backup.
+ $ wp post block export 123 --file=blocks.json
+ Success: Exported 5 blocks to blocks.json
+
+ # Export blocks to STDOUT as JSON.
+ $ wp post block export 123
+ {
+ "version": "1.0",
+ "generator": "wp-cli/entity-command",
+ "post_id": 123,
+ "exported_at": "2024-12-10T12:00:00+00:00",
+ "blocks": [...]
+ }
+
+ # Export as YAML format.
+ $ wp post block export 123 --format=yaml
+ version: "1.0"
+ generator: wp-cli/entity-command
+ blocks:
+ - blockName: core/paragraph
+ attrs: []
+
+ # Export rendered HTML (final output, not block structure).
+ $ wp post block export 123 --format=html --file=content.html
+ Success: Exported 5 blocks to content.html
+
+ # Export with raw innerHTML included for complete backup.
+ $ wp post block export 123 --raw --file=blocks-full.json
+ Success: Exported 5 blocks to blocks-full.json
+
+ # Pipe export to another command.
+ $ wp post block export 123 | jq '.blocks[].blockName'
+
+
+
+### wp post block extract
+
+Extracts data from blocks.
+
+~~~
+wp post block extract [--block=] [--index=] [--attr=] [--content] [--format=]
+~~~
+
+Extracts specific attribute values or content from blocks for scripting.
+
+**OPTIONS**
+
+
+ The ID of the post.
+
+ [--block=]
+ Filter by block type.
+
+ [--index=]
+ Get from specific block index.
+
+ [--attr=]
+ Extract specific attribute value.
+
+ [--content]
+ Extract innerHTML content.
+
+ [--format=]
+ Output format.
+ ---
+ default: json
+ options:
+ - json
+ - yaml
+ - csv
+ - ids
+ ---
+
+**EXAMPLES**
+
+ # Extract all image IDs from the post (one per line).
+ $ wp post block extract 123 --block=core/image --attr=id --format=ids
+ 456
+ 789
+ 1024
+
+ # Extract all image URLs as JSON array.
+ $ wp post block extract 123 --block=core/image --attr=url --format=json
+ ["https://example.com/img1.jpg","https://example.com/img2.jpg"]
+
+ # Extract text content from all headings.
+ $ wp post block extract 123 --block=core/heading --content --format=ids
+ Introduction
+ Getting Started
+ Conclusion
+
+ # Get the heading level from the first block.
+ $ wp post block extract 123 --index=0 --attr=level --format=ids
+ 2
+
+ # Extract all heading levels as CSV.
+ $ wp post block extract 123 --block=core/heading --attr=level --format=csv
+ 2,3,3,2
+
+ # Extract paragraph content as YAML.
+ $ wp post block extract 123 --block=core/paragraph --content --format=yaml
+ - "First paragraph text"
+ - "Second paragraph text"
+
+ # Get all button URLs for link checking.
+ $ wp post block extract 123 --block=core/button --attr=url --format=ids
+ https://example.com/signup
+ https://example.com/learn-more
+
+ # Extract cover block image IDs for media audit.
+ $ wp post block extract 123 --block=core/cover --attr=id --format=json
+
+
+
+### wp post block get
+
+Gets a single block by index.
+
+~~~
+wp post block get [--raw] [--format=]
+~~~
+
+Retrieves the full structure of a block at the specified position.
+
+**OPTIONS**
+
+
+ The ID of the post.
+
+
+ The block index (0-indexed).
+
+ [--raw]
+ Include innerHTML in output.
+
+ [--format=]
+ Render output in a particular format.
+ ---
+ default: json
+ options:
+ - json
+ - yaml
+ ---
+
+**EXAMPLES**
+
+ # Get the first block in a post.
+ $ wp post block get 123 0
+ {
+ "blockName": "core/paragraph",
+ "attrs": {},
+ "innerBlocks": []
+ }
+
+ # Get the third block (index 2) with attributes.
+ $ wp post block get 123 2
+ {
+ "blockName": "core/heading",
+ "attrs": {
+ "level": 2
+ },
+ "innerBlocks": []
+ }
+
+ # Get block as YAML format.
+ $ wp post block get 123 1 --format=yaml
+ blockName: core/image
+ attrs:
+ id: 456
+ sizeSlug: large
+ innerBlocks: []
+
+ # Get block with raw HTML content included.
+ $ wp post block get 123 0 --raw
+ {
+ "blockName": "core/paragraph",
+ "attrs": {},
+ "innerBlocks": [],
+ "innerHTML": "Hello World
",
+ "innerContent": ["Hello World
"]
+ }
+
+
+
+### wp post block import
+
+Imports blocks from a file into a post.
+
+~~~
+wp post block import [--file=] [--position=] [--replace] [--porcelain]
+~~~
+
+Imports blocks from a JSON or YAML file into a post's content.
+
+**OPTIONS**
+
+
+ The ID of the post to import blocks into.
+
+ [--file=]
+ Input file path. If not specified, reads from STDIN.
+
+ [--position=]
+ Where to insert imported blocks. Accepts 'start', 'end', or a numeric index.
+ ---
+ default: end
+ ---
+
+ [--replace]
+ Replace all existing blocks instead of appending.
+
+ [--porcelain]
+ Output just the number of blocks imported.
+
+**EXAMPLES**
+
+ # Import blocks from a JSON file, append to end of post.
+ $ wp post block import 123 --file=blocks.json
+ Success: Imported 5 blocks into post 123.
+
+ # Import blocks at the beginning of the post.
+ $ wp post block import 123 --file=blocks.json --position=start
+ Success: Imported 5 blocks into post 123.
+
+ # Replace all existing content with imported blocks.
+ $ wp post block import 123 --file=blocks.json --replace
+ Success: Imported 5 blocks into post 123.
+
+ # Import from STDIN (piped from another command).
+ $ cat blocks.json | wp post block import 123
+ Success: Imported 5 blocks into post 123.
+
+ # Copy blocks from one post to another.
+ $ wp post block export 123 | wp post block import 456
+ Success: Imported 5 blocks into post 456.
+
+ # Import YAML format.
+ $ wp post block import 123 --file=blocks.yaml
+ Success: Imported 3 blocks into post 123.
+
+ # Get just the count of imported blocks for scripting.
+ $ wp post block import 123 --file=blocks.json --porcelain
+ 5
+
+
+
+### wp post block insert
+
+Inserts a block into a post at a specified position.
+
+~~~
+wp post block insert [--content=] [--attrs=] [--position=] [--porcelain]
+~~~
+
+Adds a new block to the post content. By default, the block is
+appended to the end of the post.
+
+**OPTIONS**
+
+
+ The ID of the post to modify.
+
+
+ The block type name (e.g., 'core/paragraph').
+
+ [--content=]
+ The inner content/HTML for the block.
+
+ [--attrs=]
+ Block attributes as JSON.
+
+ [--position=]
+ Position to insert the block (0-indexed). Use 'start' or 'end'.
+ ---
+ default: end
+ ---
+
+ [--porcelain]
+ Output just the post ID.
+
+**EXAMPLES**
+
+ # Insert a paragraph block at the end of the post.
+ $ wp post block insert 123 core/paragraph --content="Hello World"
+ Success: Inserted block into post 123.
+
+ # Insert a level-2 heading at the start.
+ $ wp post block insert 123 core/heading --content="My Title" --attrs='{"level":2}' --position=start
+ Success: Inserted block into post 123.
+
+ # Insert an image block at position 2.
+ $ wp post block insert 123 core/image --attrs='{"id":456,"url":"https://example.com/image.jpg"}' --position=2
+
+ # Insert a separator block.
+ $ wp post block insert 123 core/separator
+
+
+
+### wp post block list
+
+Lists blocks in a post with counts.
+
+~~~
+wp post block list [--nested] [--format=]
+~~~
+
+Displays a summary of block types used in the post and how many
+times each block type appears.
+
+**OPTIONS**
+
+
+ The ID of the post to analyze.
+
+ [--nested]
+ Include nested/inner blocks in the list.
+
+ [--format=]
+ Render output in a particular format.
+ ---
+ default: table
+ options:
+ - table
+ - csv
+ - json
+ - yaml
+ - count
+ ---
+
+**EXAMPLES**
+
+ # List blocks with counts.
+ $ wp post block list 123
+ +------------------+-------+
+ | blockName | count |
+ +------------------+-------+
+ | core/paragraph | 5 |
+ | core/heading | 2 |
+ | core/image | 1 |
+ +------------------+-------+
+
+ # List blocks as JSON.
+ $ wp post block list 123 --format=json
+ [{"blockName":"core/paragraph","count":5}]
+
+ # Include nested blocks (e.g., blocks inside columns or groups).
+ $ wp post block list 123 --nested
+
+ # Get the number of unique block types.
+ $ wp post block list 123 --format=count
+ 3
+
+
+
+### wp post block move
+
+Moves a block from one position to another.
+
+~~~
+wp post block move [--porcelain]
+~~~
+
+Reorders blocks within the post by moving a block from one index to another.
+
+**OPTIONS**
+
+
+ The ID of the post.
+
+
+ Current block index (0-indexed).
+
+
+ Target position index (0-indexed).
+
+ [--porcelain]
+ Output just the post ID.
+
+**EXAMPLES**
+
+ # Move the first block to the third position.
+ $ wp post block move 123 0 2
+ Success: Moved block from index 0 to index 2 in post 123.
+
+ # Move the last block (index 4) to the beginning.
+ $ wp post block move 123 4 0
+ Success: Moved block from index 4 to index 0 in post 123.
+
+ # Move a heading block from position 3 to position 1.
+ $ wp post block move 123 3 1
+ Success: Moved block from index 3 to index 1 in post 123.
+
+ # Move block and get post ID for scripting.
+ $ wp post block move 123 2 0 --porcelain
+ 123
+
+
+
+### wp post block parse
+
+Parses and displays the block structure of a post.
+
+~~~
+wp post block parse [--raw] [--format=]
+~~~
+
+Outputs the parsed block structure as JSON or YAML. By default,
+innerHTML is stripped from the output for readability.
+
+**OPTIONS**
+
+
+ The ID of the post to parse.
+
+ [--raw]
+ Include raw innerHTML in output.
+
+ [--format=]
+ Render output in a particular format.
+ ---
+ default: json
+ options:
+ - json
+ - yaml
+ ---
+
+**EXAMPLES**
+
+ # Parse blocks to JSON.
+ $ wp post block parse 123
+ [
+ {
+ "blockName": "core/paragraph",
+ "attrs": {}
+ }
+ ]
+
+ # Parse blocks to YAML format.
+ $ wp post block parse 123 --format=yaml
+ -
+ blockName: core/paragraph
+ attrs: { }
+
+ # Parse blocks including raw HTML content.
+ $ wp post block parse 123 --raw
+
+
+
+### wp post block remove
+
+Removes blocks from a post by name or index.
+
+~~~
+wp post block remove [] [--index=] [--all] [--porcelain]
+~~~
+
+Removes one or more blocks from the post content. Blocks can be
+removed by their type name or by their position index.
+
+**OPTIONS**
+
+
+ The ID of the post to modify.
+
+ []
+ The block type name to remove (e.g., 'core/paragraph').
+
+ [--index=]
+ Remove block at specific index (0-indexed). Can be comma-separated for multiple indices.
+
+ [--all]
+ Remove all blocks of the specified type.
+
+ [--porcelain]
+ Output just the number of blocks removed.
+
+**EXAMPLES**
+
+ # Remove the first block (index 0).
+ $ wp post block remove 123 --index=0
+ Success: Removed 1 block from post 123.
+
+ # Remove the first paragraph block found.
+ $ wp post block remove 123 core/paragraph
+ Success: Removed 1 block from post 123.
+
+ # Remove all paragraph blocks.
+ $ wp post block remove 123 core/paragraph --all
+ Success: Removed 5 blocks from post 123.
+
+ # Remove blocks at multiple indices.
+ $ wp post block remove 123 --index=0,2,4
+ Success: Removed 3 blocks from post 123.
+
+ # Remove all image blocks and get count.
+ $ wp post block remove 123 core/image --all --porcelain
+ 2
+
+
+
+### wp post block replace
+
+Replaces blocks in a post.
+
+~~~
+wp post block replace [--attrs=] [--content=] [--all] [--porcelain]
+~~~
+
+Replaces blocks of one type with blocks of another type. Can also
+be used to update block attributes without changing the block type.
+
+**OPTIONS**
+
+
+ The ID of the post to modify.
+
+
+ The block type name to replace.
+
+
+ The new block type name.
+
+ [--attrs=]
+ New block attributes as JSON.
+
+ [--content=]
+ New block content. Use '{content}' to preserve original content.
+
+ [--all]
+ Replace all matching blocks. By default, only the first match is replaced.
+
+ [--porcelain]
+ Output just the number of blocks replaced.
+
+**EXAMPLES**
+
+ # Replace the first paragraph block with a heading.
+ $ wp post block replace 123 core/paragraph core/heading
+ Success: Replaced 1 block in post 123.
+
+ # Replace all paragraphs with preformatted blocks, keeping content.
+ $ wp post block replace 123 core/paragraph core/preformatted --content='{content}' --all
+ Success: Replaced 3 blocks in post 123.
+
+ # Change all h2 headings to h3.
+ $ wp post block replace 123 core/heading core/heading --attrs='{"level":3}' --all
+
+ # Replace and get count for scripting.
+ $ wp post block replace 123 core/quote core/pullquote --all --porcelain
+ 2
+
+
+
+### wp post block render
+
+Renders blocks from a post to HTML.
+
+~~~
+wp post block render [--block=]
+~~~
+
+Outputs the rendered HTML of blocks in a post. This uses WordPress's
+block rendering system to produce the final HTML output.
+
+**OPTIONS**
+
+
+ The ID of the post to render.
+
+ [--block=]
+ Only render blocks of this type.
+
+**EXAMPLES**
+
+ # Render all blocks to HTML.
+ $ wp post block render 123
+ Hello World
+ My Heading
+
+ # Render only paragraph blocks.
+ $ wp post block render 123 --block=core/paragraph
+ Hello World
+
+ # Render only heading blocks.
+ $ wp post block render 123 --block=core/heading
+
+
+
+### wp post block update
+
+Updates a block's attributes or content by index.
+
+~~~
+wp post block update [--attrs=] [--content=] [--replace-attrs] [--porcelain]
+~~~
+
+Modifies a specific block without changing its type. For blocks where
+attributes are reflected in HTML (like heading levels), the HTML is
+automatically updated to match the new attributes.
+
+**OPTIONS**
+
+
+ The ID of the post.
+
+
+ The block index to update (0-indexed).
+
+ [--attrs=]
+ Block attributes as JSON. Merges with existing attributes by default.
+
+ [--content=]
+ New innerHTML content for the block.
+
+ [--replace-attrs]
+ Replace all attributes instead of merging.
+
+ [--porcelain]
+ Output just the post ID.
+
+**EXAMPLES**
+
+ # Change a heading from h2 to h3.
+ $ wp post block update 123 0 --attrs='{"level":3}'
+ Success: Updated block at index 0 in post 123.
+
+ # Add alignment to an existing paragraph (merges with existing attrs).
+ $ wp post block update 123 1 --attrs='{"align":"center"}'
+ Success: Updated block at index 1 in post 123.
+
+ # Update the text content of a paragraph block.
+ $ wp post block update 123 2 --content="Updated paragraph text
"
+ Success: Updated block at index 2 in post 123.
+
+ # Update both attributes and content at once.
+ $ wp post block update 123 0 --attrs='{"level":2}' --content="New Heading
"
+ Success: Updated block at index 0 in post 123.
+
+ # Replace all attributes instead of merging (removes existing attrs).
+ $ wp post block update 123 0 --attrs='{"level":4}' --replace-attrs
+ Success: Updated block at index 0 in post 123.
+
+ # Get just the post ID for scripting.
+ $ wp post block update 123 0 --attrs='{"level":2}' --porcelain
+ 123
+
+ # Use custom HTML sync logic via the wp_cli_post_block_update_html filter.
+ # Use WP_CLI::add_wp_hook() in a file loaded with --require.
+ $ wp post block update 123 0 --attrs='{"url":"https://example.com"}' --require=my-sync-filters.php
+ Success: Updated block at index 0 in post 123.
+
+
+
### wp post-type
Retrieves details on the site's registered post types.
@@ -3796,7 +4740,7 @@ wp site meta delete [] [] [--all]
Get meta field value.
~~~
-wp site meta get [--format=]
+wp site meta get [--single] [--format=]
~~~
**OPTIONS**
@@ -3807,6 +4751,9 @@ wp site meta get [--format=]
The name of the meta field to get.
+ [--single]
+ Whether to return a single value.
+
[--format=]
Get value in a particular format.
---
@@ -4710,7 +5657,7 @@ wp term meta delete [] [] [--all]
Get meta field value.
~~~
-wp term meta get [--format=]
+wp term meta get [--single] [--format=]
~~~
**OPTIONS**
@@ -4721,6 +5668,9 @@ wp term meta get [--format=]
The name of the meta field to get.
+ [--single]
+ Whether to return a single value.
+
[--format=]
Get value in a particular format.
---
@@ -6100,7 +7050,7 @@ wp user meta update [--format=]
Removes a user's capability.
~~~
-wp user remove-cap
+wp user remove-cap [--force]
~~~
**OPTIONS**
@@ -6111,6 +7061,9 @@ wp user remove-cap
The capability to be removed.
+ [--force]
+ Forcefully remove a capability.
+
**EXAMPLES**
$ wp user remove-cap 11 publish_newsletters
@@ -6122,6 +7075,9 @@ wp user remove-cap
$ wp user remove-cap 11 nonexistent_cap
Error: No such 'nonexistent_cap' cap for supervisor (11).
+ $ wp user remove-cap 11 publish_newsletters --force
+ Success: Removed 'publish_newsletters' cap for supervisor (11).
+
### wp user remove-role
@@ -6477,6 +7433,8 @@ Lists signups.
wp user signup list [--=] [--field=] [--fields=] [--format=] [--per_page=]
~~~
+**OPTIONS**
+
[--=]
Filter the list by a specific field.
diff --git a/codecov.yml b/codecov.yml
new file mode 100644
index 00000000..b9042b3a
--- /dev/null
+++ b/codecov.yml
@@ -0,0 +1,2 @@
+ignore:
+ - "src/Compat/**/*"
diff --git a/composer.json b/composer.json
index 21d853c5..3a30474f 100644
--- a/composer.json
+++ b/composer.json
@@ -120,6 +120,23 @@
"post term set",
"post update",
"post url-to-id",
+ "post has-blocks",
+ "post has-block",
+ "post block",
+ "post block clone",
+ "post block count",
+ "post block export",
+ "post block extract",
+ "post block get",
+ "post block import",
+ "post block insert",
+ "post block list",
+ "post block move",
+ "post block parse",
+ "post block remove",
+ "post block replace",
+ "post block render",
+ "post block update",
"post-type",
"post-type get",
"post-type list",
@@ -220,6 +237,11 @@
"classmap": [
"src/"
],
+ "exclude-from-classmap": [
+ "src/Compat/WP_HTML_Span.php",
+ "src/Compat/WP_Block_Processor.php",
+ "src/Compat/polyfills.php"
+ ],
"files": [
"entity-command.php"
]
@@ -234,7 +256,6 @@
"phpstan": "run-phpstan-tests",
"phpunit": "run-php-unit-tests",
"phpcbf": "run-phpcbf-cleanup",
- "phpstan": "run-phpstan-tests",
"prepare-tests": "install-package-tests",
"test": [
"@lint",
diff --git a/entity-command.php b/entity-command.php
index 6cb54ff3..0cecf202 100644
--- a/entity-command.php
+++ b/entity-command.php
@@ -11,6 +11,11 @@
require_once $wpcli_entity_autoloader;
}
+// Load the BlockProcessorLoader class (but don't call load() yet).
+// The polyfills will be loaded on-demand by Block_Processor_Helper
+// when needed, ensuring WordPress classes take precedence if available.
+require_once __DIR__ . '/src/Compat/BlockProcessorLoader.php';
+
WP_CLI::add_command( 'comment', 'Comment_Command' );
WP_CLI::add_command( 'comment meta', 'Comment_Meta_Command' );
WP_CLI::add_command( 'menu', 'Menu_Command' );
@@ -29,6 +34,17 @@
);
WP_CLI::add_command( 'option', 'Option_Command' );
WP_CLI::add_command( 'post', 'Post_Command' );
+WP_CLI::add_command(
+ 'post block',
+ 'Post_Block_Command',
+ array(
+ 'before_invoke' => function () {
+ if ( Utils\wp_version_compare( '5.0', '<' ) ) {
+ WP_CLI::error( 'Requires WordPress 5.0 or greater.' );
+ }
+ },
+ )
+);
WP_CLI::add_command( 'post meta', 'Post_Meta_Command' );
WP_CLI::add_command( 'post term', 'Post_Term_Command' );
WP_CLI::add_command( 'post-type', 'Post_Type_Command' );
diff --git a/features/post-block.feature b/features/post-block.feature
new file mode 100644
index 00000000..bfcfc9ca
--- /dev/null
+++ b/features/post-block.feature
@@ -0,0 +1,1824 @@
+Feature: Manage blocks in post content
+
+ @require-wp-5.0
+ Scenario: Check if a post has blocks
+ Given a WP install
+ When I run `wp post create --post_title='Block Post' --post_content='Hello
' --porcelain`
+ Then save STDOUT as {POST_ID}
+
+ When I run `wp post has-blocks {POST_ID}`
+ Then STDOUT should contain:
+ """
+ Success: Post {POST_ID} contains blocks.
+ """
+
+ When I run `wp post create --post_title='Classic Post' --post_content='Hello classic
' --porcelain`
+ Then save STDOUT as {CLASSIC_ID}
+
+ When I try `wp post has-blocks {CLASSIC_ID}`
+ Then STDERR should contain:
+ """
+ Error: Post {CLASSIC_ID} does not contain blocks.
+ """
+ And the return code should be 1
+
+ @require-wp-5.0
+ Scenario: Check if a post has a specific block
+ Given a WP install
+ When I run `wp post create --post_title='Block Post' --post_content='Hello
Title
' --porcelain`
+ Then save STDOUT as {POST_ID}
+
+ When I run `wp post has-block {POST_ID} core/paragraph`
+ Then STDOUT should contain:
+ """
+ Success: Post {POST_ID} contains block 'core/paragraph'.
+ """
+
+ When I run `wp post has-block {POST_ID} core/heading`
+ Then STDOUT should contain:
+ """
+ Success: Post {POST_ID} contains block 'core/heading'.
+ """
+
+ When I try `wp post has-block {POST_ID} core/image`
+ Then STDERR should contain:
+ """
+ Error: Post {POST_ID} does not contain block 'core/image'.
+ """
+ And the return code should be 1
+
+ @require-wp-5.0
+ Scenario: Parse blocks in a post
+ Given a WP install
+ When I run `wp post create --post_title='Block Post' --post_content='Hello
' --porcelain`
+ Then save STDOUT as {POST_ID}
+
+ When I run `wp post block parse {POST_ID}`
+ Then STDOUT should contain:
+ """
+ "blockName": "core/paragraph"
+ """
+ And STDOUT should contain:
+ """
+ "align": "center"
+ """
+
+ When I run `wp post block parse {POST_ID} --format=yaml`
+ Then STDOUT should contain:
+ """
+ blockName: core/paragraph
+ """
+
+ When I run `wp post block parse {POST_ID} --raw`
+ Then STDOUT should contain:
+ """
+ innerHTML
+ """
+
+ @require-wp-5.0
+ Scenario: List blocks in a post
+ Given a WP install
+ When I run `wp post create --post_title='Block Post' --post_content='One
Two
Title
' --porcelain`
+ Then save STDOUT as {POST_ID}
+
+ When I run `wp post block list {POST_ID}`
+ Then STDOUT should be a table containing rows:
+ | blockName | count |
+ | core/paragraph | 2 |
+ | core/heading | 1 |
+
+ When I run `wp post block list {POST_ID} --format=json`
+ Then STDOUT should be JSON containing:
+ """
+ [{"blockName":"core/paragraph","count":2}]
+ """
+
+ When I run `wp post block list {POST_ID} --format=count`
+ Then STDOUT should be:
+ """
+ 2
+ """
+
+ @require-wp-5.0
+ Scenario: List nested blocks
+ Given a WP install
+ When I run `wp post create --post_title='Nested Blocks' --post_content='Nested
' --porcelain`
+ Then save STDOUT as {POST_ID}
+
+ When I run `wp post block list {POST_ID}`
+ Then STDOUT should be a table containing rows:
+ | blockName | count |
+ | core/group | 1 |
+ And STDOUT should not contain:
+ """
+ core/paragraph
+ """
+
+ When I run `wp post block list {POST_ID} --nested`
+ Then STDOUT should be a table containing rows:
+ | blockName | count |
+ | core/group | 1 |
+ | core/paragraph | 1 |
+
+ @require-wp-5.0
+ Scenario: Render blocks to HTML
+ Given a WP install
+ When I run `wp post create --post_title='Block Post' --post_content='Hello World
Title
' --porcelain`
+ Then save STDOUT as {POST_ID}
+
+ When I run `wp post block render {POST_ID}`
+ Then STDOUT should contain:
+ """
+ Hello World
+ """
+ And STDOUT should contain:
+ """
+
+ """
+
+ When I run `wp post block render {POST_ID} --block=core/paragraph`
+ Then STDOUT should contain:
+ """
+
Hello World
+ """
+ And STDOUT should not contain:
+ """
+ Title
+ """
+
+ @require-wp-5.0
+ Scenario: Insert a block into a post
+ Given a WP install
+ When I run `wp post create --post_title='Block Post' --post_content='First
' --porcelain`
+ Then save STDOUT as {POST_ID}
+
+ When I run `wp post block insert {POST_ID} core/paragraph --content="Added at end"`
+ Then STDOUT should contain:
+ """
+ Success: Inserted block into post {POST_ID}.
+ """
+
+ When I run `wp post get {POST_ID} --field=post_content`
+ Then STDOUT should contain:
+ """
+ Added at end
+ """
+
+ When I run `wp post block insert {POST_ID} core/heading --content="Title" --position=start`
+ Then STDOUT should contain:
+ """
+ Success: Inserted block into post {POST_ID}.
+ """
+
+ When I run `wp post block list {POST_ID}`
+ Then STDOUT should be a table containing rows:
+ | blockName | count |
+ | core/paragraph | 2 |
+ | core/heading | 1 |
+
+ @require-wp-5.0
+ Scenario: Insert a block with attributes
+ Given a WP install
+ When I run `wp post create --post_title='Block Post' --post_content='Test
' --porcelain`
+ Then save STDOUT as {POST_ID}
+
+ When I run `wp post block insert {POST_ID} core/heading --content="Title" --attrs='{"level":3}'`
+ Then STDOUT should contain:
+ """
+ Success: Inserted block into post {POST_ID}.
+ """
+
+ When I run `wp post block parse {POST_ID}`
+ Then STDOUT should contain:
+ """
+ "level": 3
+ """
+
+ @require-wp-5.0
+ Scenario: Remove a block by index
+ Given a WP install
+ When I run `wp post create --post_title='Block Post' --post_content='First
Second
Third
' --porcelain`
+ Then save STDOUT as {POST_ID}
+
+ When I run `wp post block remove {POST_ID} --index=1`
+ Then STDOUT should contain:
+ """
+ Success: Removed 1 block from post {POST_ID}.
+ """
+
+ When I run `wp post get {POST_ID} --field=post_content`
+ Then STDOUT should contain:
+ """
+ First
+ """
+ And STDOUT should contain:
+ """
+ Third
+ """
+ And STDOUT should not contain:
+ """
+ Second
+ """
+
+ @require-wp-5.0
+ Scenario: Remove multiple blocks by indices
+ Given a WP install
+ When I run `wp post create --post_title='Block Post' --post_content='First
Second
Third
' --porcelain`
+ Then save STDOUT as {POST_ID}
+
+ When I run `wp post block remove {POST_ID} --index=0,2`
+ Then STDOUT should contain:
+ """
+ Success: Removed 2 blocks from post {POST_ID}.
+ """
+
+ When I run `wp post get {POST_ID} --field=post_content`
+ Then STDOUT should contain:
+ """
+ Second
+ """
+ And STDOUT should not contain:
+ """
+ First
+ """
+ And STDOUT should not contain:
+ """
+ Third
+ """
+
+ @require-wp-5.0
+ Scenario: Remove blocks by name
+ Given a WP install
+ When I run `wp post create --post_title='Block Post' --post_content='Para 1
Heading
Para 2
' --porcelain`
+ Then save STDOUT as {POST_ID}
+
+ When I run `wp post block remove {POST_ID} core/paragraph`
+ Then STDOUT should contain:
+ """
+ Success: Removed 1 block from post {POST_ID}.
+ """
+
+ When I run `wp post block list {POST_ID}`
+ Then STDOUT should be a table containing rows:
+ | blockName | count |
+ | core/paragraph | 1 |
+ | core/heading | 1 |
+
+ @require-wp-5.0
+ Scenario: Remove all blocks of a type
+ Given a WP install
+ When I run `wp post create --post_title='Block Post' --post_content='Para 1
Heading
Para 2
' --porcelain`
+ Then save STDOUT as {POST_ID}
+
+ When I run `wp post block remove {POST_ID} core/paragraph --all`
+ Then STDOUT should contain:
+ """
+ Success: Removed 2 blocks from post {POST_ID}.
+ """
+
+ When I run `wp post block list {POST_ID}`
+ Then STDOUT should be a table containing rows:
+ | blockName | count |
+ | core/heading | 1 |
+ And STDOUT should not contain:
+ """
+ core/paragraph
+ """
+
+ @require-wp-5.0
+ Scenario: Replace blocks
+ Given a WP install
+ When I run `wp post create --post_title='Block Post' --post_content='Content
' --porcelain`
+ Then save STDOUT as {POST_ID}
+
+ When I run `wp post block replace {POST_ID} core/paragraph core/heading`
+ Then STDOUT should contain:
+ """
+ Success: Replaced 1 block in post {POST_ID}.
+ """
+
+ When I run `wp post has-block {POST_ID} core/heading`
+ Then STDOUT should contain:
+ """
+ Success: Post {POST_ID} contains block 'core/heading'.
+ """
+
+ When I try `wp post has-block {POST_ID} core/paragraph`
+ Then the return code should be 1
+
+ @require-wp-5.0
+ Scenario: Replace all blocks of a type
+ Given a WP install
+ When I run `wp post create --post_title='Block Post' --post_content='Para 1
Para 2
' --porcelain`
+ Then save STDOUT as {POST_ID}
+
+ When I run `wp post block replace {POST_ID} core/paragraph core/verse --all`
+ Then STDOUT should contain:
+ """
+ Success: Replaced 2 blocks in post {POST_ID}.
+ """
+
+ When I run `wp post block list {POST_ID}`
+ Then STDOUT should be a table containing rows:
+ | blockName | count |
+ | core/verse | 2 |
+ And STDOUT should not contain:
+ """
+ core/paragraph
+ """
+
+ @require-wp-5.0
+ Scenario: Replace block with new attributes
+ Given a WP install
+ When I run `wp post create --post_title='Block Post' --post_content='Title
' --porcelain`
+ Then save STDOUT as {POST_ID}
+
+ When I run `wp post block replace {POST_ID} core/heading core/heading --attrs='{"level":4}'`
+ Then STDOUT should contain:
+ """
+ Success: Replaced 1 block in post {POST_ID}.
+ """
+
+ When I run `wp post block parse {POST_ID}`
+ Then STDOUT should contain:
+ """
+ "level": 4
+ """
+
+ @require-wp-5.0
+ Scenario: Error handling for invalid post
+ Given a WP install
+
+ When I try `wp post has-blocks 999999`
+ Then STDERR should contain:
+ """
+ Could not find the post
+ """
+ And the return code should be 1
+
+ When I try `wp post block list 999999`
+ Then STDERR should contain:
+ """
+ Could not find the post
+ """
+ And the return code should be 1
+
+ @require-wp-5.0
+ Scenario: Error handling for remove without block name or index
+ Given a WP install
+ When I run `wp post create --post_title='Block Post' --post_content='Test
' --porcelain`
+ Then save STDOUT as {POST_ID}
+
+ When I try `wp post block remove {POST_ID}`
+ Then STDERR should contain:
+ """
+ Error: You must specify either a block name or --index.
+ """
+ And the return code should be 1
+
+ @require-wp-5.0
+ Scenario: Porcelain output for insert
+ Given a WP install
+ When I run `wp post create --post_title='Block Post' --post_content='Test
' --porcelain`
+ Then save STDOUT as {POST_ID}
+
+ When I run `wp post block insert {POST_ID} core/paragraph --content="New" --porcelain`
+ Then STDOUT should be:
+ """
+ {POST_ID}
+ """
+
+ @require-wp-5.0
+ Scenario: Porcelain output for remove
+ Given a WP install
+ When I run `wp post create --post_title='Block Post' --post_content='Test
' --porcelain`
+ Then save STDOUT as {POST_ID}
+
+ When I run `wp post block remove {POST_ID} --index=0 --porcelain`
+ Then STDOUT should be:
+ """
+ 1
+ """
+
+ @require-wp-5.0
+ Scenario: Porcelain output for replace
+ Given a WP install
+ When I run `wp post create --post_title='Block Post' --post_content='Test
' --porcelain`
+ Then save STDOUT as {POST_ID}
+
+ When I run `wp post block replace {POST_ID} core/paragraph core/heading --porcelain`
+ Then STDOUT should be:
+ """
+ 1
+ """
+
+ @require-wp-5.0
+ Scenario: Get a block by index
+ Given a WP install
+ When I run `wp post create --post_title='Block Post' --post_content='First
Title
' --porcelain`
+ Then save STDOUT as {POST_ID}
+
+ When I run `wp post block get {POST_ID} 0`
+ Then STDOUT should contain:
+ """
+ "blockName": "core/paragraph"
+ """
+ And STDOUT should contain:
+ """
+ "align": "center"
+ """
+
+ When I run `wp post block get {POST_ID} 1`
+ Then STDOUT should contain:
+ """
+ "blockName": "core/heading"
+ """
+ And STDOUT should contain:
+ """
+ "level": 2
+ """
+
+ When I run `wp post block get {POST_ID} 0 --format=yaml`
+ Then STDOUT should contain:
+ """
+ blockName: core/paragraph
+ """
+
+ When I run `wp post block get {POST_ID} 0 --raw`
+ Then STDOUT should contain:
+ """
+ innerHTML
+ """
+
+ @require-wp-5.0
+ Scenario: Error on invalid block index
+ Given a WP install
+ When I run `wp post create --post_title='Block Post' --post_content='Test
' --porcelain`
+ Then save STDOUT as {POST_ID}
+
+ When I try `wp post block get {POST_ID} 5`
+ Then STDERR should contain:
+ """
+ Invalid index: 5
+ """
+ And the return code should be 1
+
+ When I try `wp post block get {POST_ID} -1`
+ Then STDERR should contain:
+ """
+ Invalid index: -1
+ """
+ And the return code should be 1
+
+ @require-wp-5.0
+ Scenario: Update block attributes
+ Given a WP install
+ When I run `wp post create --post_title='Block Post' --post_content='Title
' --porcelain`
+ Then save STDOUT as {POST_ID}
+
+ When I run `wp post block update {POST_ID} 0 --attrs='{"level":3}'`
+ Then STDOUT should contain:
+ """
+ Success: Updated block at index 0 in post {POST_ID}.
+ """
+
+ When I run `wp post block parse {POST_ID}`
+ Then STDOUT should contain:
+ """
+ "level": 3
+ """
+
+ @require-wp-5.0
+ Scenario: Update heading level syncs HTML tag
+ Given a WP install
+ When I run `wp post create --post_title='Block Post' --post_content='Original Title
' --porcelain`
+ Then save STDOUT as {POST_ID}
+
+ When I run `wp post block update {POST_ID} 0 --attrs='{"level":4}'`
+ Then STDOUT should contain:
+ """
+ Success: Updated block at index 0 in post {POST_ID}.
+ """
+
+ # Verify the attribute was updated
+ When I run `wp post block parse {POST_ID}`
+ Then STDOUT should contain:
+ """
+ "level": 4
+ """
+
+ # Verify the HTML tag was updated to match
+ When I run `wp post get {POST_ID} --field=post_content`
+ Then STDOUT should contain:
+ """
+ Original Title
+ """
+ And STDOUT should not contain:
+ """
+
+ """
+
+ @require-wp-5.0
+ Scenario: Update list ordered attribute syncs HTML tag
+ Given a WP install
+ When I run `wp post create --post_title='Block Post' --post_content='' --porcelain`
+ Then save STDOUT as {POST_ID}
+
+ When I run `wp post block update {POST_ID} 0 --attrs='{"ordered":true}'`
+ Then STDOUT should contain:
+ """
+ Success: Updated block at index 0 in post {POST_ID}.
+ """
+
+ # Verify the HTML tag was updated from ul to ol
+ When I run `wp post get {POST_ID} --field=post_content`
+ Then STDOUT should contain:
+ """
+
+ """
+ And STDOUT should contain:
+ """
+
+ """
+ And STDOUT should not contain:
+ """
+
+ """
+
+ @require-wp-5.0
+ Scenario: Update block with custom HTML sync filter via --require
+ Given a WP install
+ And a custom-sync-filter.php file:
+ """
+ ]*)>/',
+ '',
+ $block['innerHTML']
+ );
+ $block['innerContent'] = [ $block['innerHTML'] ];
+ }
+ return $block;
+ }, 10, 3 );
+ """
+ When I run `wp post create --post_title='Block Post' --post_content='
Hello World
' --porcelain`
+ Then save STDOUT as {POST_ID}
+
+ When I run `wp post block update {POST_ID} 0 --attrs='{"customClass":"my-custom-class"}' --require=custom-sync-filter.php`
+ Then STDOUT should contain:
+ """
+ Success: Updated block at index 0 in post {POST_ID}.
+ """
+
+ When I run `wp post get {POST_ID} --field=post_content`
+ Then STDOUT should contain:
+ """
+ Hello World
+ """
+
+ @require-wp-5.0
+ Scenario: Update block content
+ Given a WP install
+ When I run `wp post create --post_title='Block Post' --post_content='Old text
' --porcelain`
+ Then save STDOUT as {POST_ID}
+
+ When I run `wp post block update {POST_ID} 0 --content="New text
"`
+ Then STDOUT should contain:
+ """
+ Success: Updated block at index 0 in post {POST_ID}.
+ """
+
+ When I run `wp post get {POST_ID} --field=post_content`
+ Then STDOUT should contain:
+ """
+ New text
+ """
+ And STDOUT should not contain:
+ """
+ Old text
+ """
+
+ @require-wp-5.0
+ Scenario: Update block with replace-attrs flag
+ Given a WP install
+ When I run `wp post create --post_title='Block Post' --post_content='Title
' --porcelain`
+ Then save STDOUT as {POST_ID}
+
+ When I run `wp post block update {POST_ID} 0 --attrs='{"level":4}' --replace-attrs`
+ Then STDOUT should contain:
+ """
+ Success: Updated block at index 0 in post {POST_ID}.
+ """
+
+ When I run `wp post block parse {POST_ID}`
+ Then STDOUT should contain:
+ """
+ "level": 4
+ """
+ And STDOUT should not contain:
+ """
+ "align"
+ """
+
+ @require-wp-5.0
+ Scenario: Error when no attrs or content provided for update
+ Given a WP install
+ When I run `wp post create --post_title='Block Post' --post_content='Test
' --porcelain`
+ Then save STDOUT as {POST_ID}
+
+ When I try `wp post block update {POST_ID} 0`
+ Then STDERR should contain:
+ """
+ You must specify either --attrs or --content.
+ """
+ And the return code should be 1
+
+ @require-wp-5.0
+ Scenario: Porcelain output for update
+ Given a WP install
+ When I run `wp post create --post_title='Block Post' --post_content='Test
' --porcelain`
+ Then save STDOUT as {POST_ID}
+
+ When I run `wp post block update {POST_ID} 0 --content="New
" --porcelain`
+ Then STDOUT should be:
+ """
+ {POST_ID}
+ """
+
+ @require-wp-5.0
+ Scenario: Move block forward
+ Given a WP install
+ When I run `wp post create --post_title='Block Post' --post_content='First
Second
Third
' --porcelain`
+ Then save STDOUT as {POST_ID}
+
+ When I run `wp post block move {POST_ID} 0 2`
+ Then STDOUT should contain:
+ """
+ Success: Moved block from index 0 to index 2 in post {POST_ID}.
+ """
+
+ When I run `wp post block render {POST_ID}`
+ Then STDOUT should match /Second.*First/s
+
+ @require-wp-5.0
+ Scenario: Move block backward
+ Given a WP install
+ When I run `wp post create --post_title='Block Post' --post_content='First
Second
Third
' --porcelain`
+ Then save STDOUT as {POST_ID}
+
+ When I run `wp post block move {POST_ID} 2 0`
+ Then STDOUT should contain:
+ """
+ Success: Moved block from index 2 to index 0 in post {POST_ID}.
+ """
+
+ When I run `wp post block render {POST_ID}`
+ Then STDOUT should match /Third.*First/s
+
+ @require-wp-5.0
+ Scenario: Move block same index warning
+ Given a WP install
+ When I run `wp post create --post_title='Block Post' --post_content='First
Second
' --porcelain`
+ Then save STDOUT as {POST_ID}
+
+ When I try `wp post block move {POST_ID} 0 0`
+ Then STDERR should contain:
+ """
+ Source and destination indices are the same.
+ """
+ And the return code should be 0
+
+ @require-wp-5.0
+ Scenario: Error on invalid move indices
+ Given a WP install
+ When I run `wp post create --post_title='Block Post' --post_content='First
Second
' --porcelain`
+ Then save STDOUT as {POST_ID}
+
+ When I try `wp post block move {POST_ID} 5 0`
+ Then STDERR should contain:
+ """
+ Invalid from-index: 5
+ """
+ And the return code should be 1
+
+ When I try `wp post block move {POST_ID} 0 10`
+ Then STDERR should contain:
+ """
+ Invalid to-index: 10
+ """
+ And the return code should be 1
+
+ @require-wp-5.0
+ Scenario: Porcelain output for move
+ Given a WP install
+ When I run `wp post create --post_title='Block Post' --post_content='First
Second
' --porcelain`
+ Then save STDOUT as {POST_ID}
+
+ When I run `wp post block move {POST_ID} 0 1 --porcelain`
+ Then STDOUT should be:
+ """
+ {POST_ID}
+ """
+
+ @require-wp-5.0
+ Scenario: Export blocks to STDOUT as JSON
+ Given a WP install
+ When I run `wp post create --post_title='Block Post' --post_content='Test
Title
' --porcelain`
+ Then save STDOUT as {POST_ID}
+
+ When I run `wp post block export {POST_ID}`
+ Then STDOUT should contain:
+ """
+ "version": "1.0"
+ """
+ And STDOUT should contain:
+ """
+ "generator": "wp-cli/entity-command"
+ """
+ And STDOUT should contain:
+ """
+ "blockName": "core/paragraph"
+ """
+
+ @require-wp-5.0
+ Scenario: Export blocks as YAML
+ Given a WP install
+ When I run `wp post create --post_title='Block Post' --post_content='Test
' --porcelain`
+ Then save STDOUT as {POST_ID}
+
+ When I run `wp post block export {POST_ID} --format=yaml`
+ Then STDOUT should contain:
+ """
+ version:
+ """
+ And STDOUT should contain:
+ """
+ generator: wp-cli/entity-command
+ """
+ And STDOUT should contain:
+ """
+ blockName: core/paragraph
+ """
+
+ @require-wp-5.0
+ Scenario: Export blocks as HTML
+ Given a WP install
+ When I run `wp post create --post_title='Block Post' --post_content='Hello World
' --porcelain`
+ Then save STDOUT as {POST_ID}
+
+ When I run `wp post block export {POST_ID} --format=html`
+ Then STDOUT should contain:
+ """
+ Hello World
+ """
+
+ @require-wp-5.0
+ Scenario: Export blocks to file
+ Given a WP install
+ When I run `wp post create --post_title='Block Post' --post_content='Test
' --porcelain`
+ Then save STDOUT as {POST_ID}
+
+ When I run `wp post block export {POST_ID} --file=blocks-export.json`
+ Then STDOUT should contain:
+ """
+ Success: Exported 1 block to blocks-export.json
+ """
+ And the blocks-export.json file should contain:
+ """
+ "blockName": "core/paragraph"
+ """
+
+ @require-wp-5.0
+ Scenario: Import blocks from file
+ Given a WP install
+ And a blocks-import.json file:
+ """
+ {
+ "version": "1.0",
+ "blocks": [
+ {"blockName": "core/paragraph", "attrs": {}, "innerBlocks": [], "innerHTML": "Imported
", "innerContent": ["Imported
"]}
+ ]
+ }
+ """
+ When I run `wp post create --post_title='Block Post' --post_content='Original
' --porcelain`
+ Then save STDOUT as {POST_ID}
+
+ When I run `wp post block import {POST_ID} --file=blocks-import.json`
+ Then STDOUT should contain:
+ """
+ Success: Imported 1 block into post {POST_ID}.
+ """
+
+ When I run `wp post block list {POST_ID}`
+ Then STDOUT should be a table containing rows:
+ | blockName | count |
+ | core/heading | 1 |
+ | core/paragraph | 1 |
+
+ @require-wp-5.0
+ Scenario: Import blocks at start
+ Given a WP install
+ And a blocks-import.json file:
+ """
+ {
+ "blocks": [
+ {"blockName": "core/paragraph", "attrs": {}, "innerBlocks": [], "innerHTML": "First
", "innerContent": ["First
"]}
+ ]
+ }
+ """
+ When I run `wp post create --post_title='Block Post' --post_content='Second
' --porcelain`
+ Then save STDOUT as {POST_ID}
+
+ When I run `wp post block import {POST_ID} --file=blocks-import.json --position=start`
+ Then STDOUT should contain:
+ """
+ Success: Imported 1 block into post {POST_ID}.
+ """
+
+ When I run `wp post block render {POST_ID}`
+ Then STDOUT should match /First.*Second/s
+
+ @require-wp-5.0
+ Scenario: Import blocks with replace
+ Given a WP install
+ And a blocks-import.json file:
+ """
+ {
+ "blocks": [
+ {"blockName": "core/heading", "attrs": {"level": 2}, "innerBlocks": [], "innerHTML": "New Content
", "innerContent": ["New Content
"]}
+ ]
+ }
+ """
+ When I run `wp post create --post_title='Block Post' --post_content='Old
' --porcelain`
+ Then save STDOUT as {POST_ID}
+
+ When I run `wp post block import {POST_ID} --file=blocks-import.json --replace`
+ Then STDOUT should contain:
+ """
+ Success: Imported 1 block into post {POST_ID}.
+ """
+
+ When I run `wp post block list {POST_ID}`
+ Then STDOUT should be a table containing rows:
+ | blockName | count |
+ | core/heading | 1 |
+ And STDOUT should not contain:
+ """
+ core/paragraph
+ """
+
+ @require-wp-5.0
+ Scenario: Import error on missing file
+ Given a WP install
+ When I run `wp post create --post_title='Block Post' --post_content='Test
' --porcelain`
+ Then save STDOUT as {POST_ID}
+
+ When I try `wp post block import {POST_ID} --file=nonexistent.json`
+ Then STDERR should contain:
+ """
+ File not found: nonexistent.json
+ """
+ And the return code should be 1
+
+ @require-wp-5.0
+ Scenario: Porcelain output for import
+ Given a WP install
+ And a blocks-import.json file:
+ """
+ {
+ "blocks": [
+ {"blockName": "core/paragraph", "attrs": {}, "innerBlocks": [], "innerHTML": "One
", "innerContent": ["One
"]},
+ {"blockName": "core/paragraph", "attrs": {}, "innerBlocks": [], "innerHTML": "Two
", "innerContent": ["Two
"]}
+ ]
+ }
+ """
+ When I run `wp post create --post_title='Block Post' --post_content='' --porcelain`
+ Then save STDOUT as {POST_ID}
+
+ When I run `wp post block import {POST_ID} --file=blocks-import.json --porcelain`
+ Then STDOUT should be:
+ """
+ 2
+ """
+
+ @require-wp-5.0
+ Scenario: Count blocks across posts
+ Given a WP install
+ When I run `wp post create --post_title='Post 1' --post_content='Test
Test2
' --post_status=publish --porcelain`
+ Then save STDOUT as {POST_1}
+
+ When I run `wp post create --post_title='Post 2' --post_content='Test
Title
' --post_status=publish --porcelain`
+ Then save STDOUT as {POST_2}
+
+ When I run `wp post block count {POST_1} {POST_2}`
+ Then STDOUT should be a table containing rows:
+ | blockName | count | posts |
+ | core/paragraph | 3 | 2 |
+ | core/heading | 1 | 1 |
+
+ @require-wp-5.0
+ Scenario: Count specific block type
+ Given a WP install
+ When I run `wp post create --post_title='Post 1' --post_content='Test
Test2
' --post_status=publish --porcelain`
+ Then save STDOUT as {POST_1}
+
+ When I run `wp post block count {POST_1} --block=core/paragraph --format=count`
+ Then STDOUT should be:
+ """
+ 2
+ """
+
+ @require-wp-5.0
+ Scenario: Count unique block types
+ Given a WP install
+ When I run `wp post create --post_title='Post 1' --post_content='Test
Title
' --post_status=publish --porcelain`
+ Then save STDOUT as {POST_ID}
+
+ When I run `wp post block count {POST_ID} --format=count`
+ Then STDOUT should be:
+ """
+ 2
+ """
+
+ @require-wp-5.0
+ Scenario: Clone block with default position (after)
+ Given a WP install
+ When I run `wp post create --post_title='Block Post' --post_content='First
Second
' --porcelain`
+ Then save STDOUT as {POST_ID}
+
+ When I run `wp post block clone {POST_ID} 0`
+ Then STDOUT should contain:
+ """
+ Success: Cloned block to index 1 in post {POST_ID}.
+ """
+
+ When I run `wp post block list {POST_ID}`
+ Then STDOUT should be a table containing rows:
+ | blockName | count |
+ | core/paragraph | 3 |
+
+ @require-wp-5.0
+ Scenario: Clone block to end
+ Given a WP install
+ When I run `wp post create --post_title='Block Post' --post_content='First
Title
' --porcelain`
+ Then save STDOUT as {POST_ID}
+
+ When I run `wp post block clone {POST_ID} 0 --position=end`
+ Then STDOUT should contain:
+ """
+ Success: Cloned block to index 2 in post {POST_ID}.
+ """
+
+ When I run `wp post block render {POST_ID}`
+ Then STDOUT should match /First.*Title.*First/s
+
+ @require-wp-5.0
+ Scenario: Clone block to start
+ Given a WP install
+ When I run `wp post create --post_title='Block Post' --post_content='First
Title
' --porcelain`
+ Then save STDOUT as {POST_ID}
+
+ When I run `wp post block clone {POST_ID} 1 --position=start`
+ Then STDOUT should contain:
+ """
+ Success: Cloned block to index 0 in post {POST_ID}.
+ """
+
+ When I run `wp post block render {POST_ID}`
+ Then STDOUT should match /Title.*First.*Title/s
+
+ @require-wp-5.0
+ Scenario: Porcelain output for clone
+ Given a WP install
+ When I run `wp post create --post_title='Block Post' --post_content='Test
' --porcelain`
+ Then save STDOUT as {POST_ID}
+
+ When I run `wp post block clone {POST_ID} 0 --porcelain`
+ Then STDOUT should be:
+ """
+ 1
+ """
+
+ @require-wp-5.0
+ Scenario: Error on invalid clone index
+ Given a WP install
+ When I run `wp post create --post_title='Block Post' --post_content='Test
' --porcelain`
+ Then save STDOUT as {POST_ID}
+
+ When I try `wp post block clone {POST_ID} 5`
+ Then STDERR should contain:
+ """
+ Invalid source-index: 5
+ """
+ And the return code should be 1
+
+ @require-wp-5.0
+ Scenario: Extract attribute values
+ Given a WP install
+ When I run `wp post create --post_title='Block Post' --post_content='Title 1
Title 2
' --porcelain`
+ Then save STDOUT as {POST_ID}
+
+ When I run `wp post block extract {POST_ID} --block=core/heading --attr=level --format=ids`
+ Then STDOUT should contain:
+ """
+ 2
+ """
+ And STDOUT should contain:
+ """
+ 3
+ """
+
+ @require-wp-5.0
+ Scenario: Extract attribute from specific index
+ Given a WP install
+ When I run `wp post create --post_title='Block Post' --post_content='Title
' --porcelain`
+ Then save STDOUT as {POST_ID}
+
+ When I run `wp post block extract {POST_ID} --index=0 --attr=level --format=ids`
+ Then STDOUT should be:
+ """
+ 2
+ """
+
+ @require-wp-5.0
+ Scenario: Extract content from blocks
+ Given a WP install
+ When I run `wp post create --post_title='Block Post' --post_content='Hello World
' --porcelain`
+ Then save STDOUT as {POST_ID}
+
+ When I run `wp post block extract {POST_ID} --block=core/paragraph --content --format=ids`
+ Then STDOUT should contain:
+ """
+ Hello World
+ """
+
+ @require-wp-5.0
+ Scenario: Extract error when no attr or content specified
+ Given a WP install
+ When I run `wp post create --post_title='Block Post' --post_content='Test
' --porcelain`
+ Then save STDOUT as {POST_ID}
+
+ When I try `wp post block extract {POST_ID}`
+ Then STDERR should contain:
+ """
+ You must specify either --attr or --content.
+ """
+ And the return code should be 1
+
+ # ============================================================================
+ # Phase 3: Extended Test Coverage - P0 (Critical) Tests
+ # ============================================================================
+
+ @require-wp-5.0
+ Scenario: Check for nested block inside group
+ Given a WP install
+ When I run `wp post create --post_title='Nested' --post_content='Nested para
' --porcelain`
+ Then save STDOUT as {POST_ID}
+
+ # Should find the nested paragraph
+ When I run `wp post has-block {POST_ID} core/paragraph`
+ Then STDOUT should contain:
+ """
+ Success: Post {POST_ID} contains block 'core/paragraph'.
+ """
+
+ # Should also find the container
+ When I run `wp post has-block {POST_ID} core/group`
+ Then STDOUT should contain:
+ """
+ Success: Post {POST_ID} contains block 'core/group'.
+ """
+
+ @require-wp-5.0
+ Scenario: Partial block name does not match
+ Given a WP install
+ When I run `wp post create --post_title='Test' --post_content='Test
' --porcelain`
+ Then save STDOUT as {POST_ID}
+
+ # "core/para" should NOT match "core/paragraph"
+ When I try `wp post has-block {POST_ID} core/para`
+ Then STDERR should contain:
+ """
+ does not contain block 'core/para'
+ """
+ And the return code should be 1
+
+ @require-wp-5.0
+ Scenario: Parse post with classic content (no blocks)
+ Given a WP install
+ When I run `wp post create --post_title='Classic' --post_content='Just HTML, no blocks
' --porcelain`
+ Then save STDOUT as {POST_ID}
+
+ When I run `wp post block parse {POST_ID}`
+ Then STDOUT should contain:
+ """
+ "blockName": null
+ """
+
+ @require-wp-5.0
+ Scenario: Render dynamic block
+ Given a WP install
+ When I run `wp post create --post_title='Dynamic' --post_content='' --porcelain`
+ Then save STDOUT as {POST_ID}
+
+ When I run `wp post block render {POST_ID}`
+ # Dynamic blocks render at runtime - output depends on site content
+ Then STDOUT should not be empty
+
+ @require-wp-5.0
+ Scenario: Insert block at specific numeric position
+ Given a WP install
+ When I run `wp post create --post_title='Test' --post_content='First
Third
' --porcelain`
+ Then save STDOUT as {POST_ID}
+
+ When I run `wp post block insert {POST_ID} core/paragraph --content="Second" --position=1`
+ Then STDOUT should contain:
+ """
+ Success: Inserted block into post {POST_ID}.
+ """
+
+ When I run `wp post block render {POST_ID}`
+ Then STDOUT should match /First.*Second.*Third/s
+
+ @require-wp-5.0
+ Scenario: Remove all blocks from post
+ Given a WP install
+ When I run `wp post create --post_title='Test' --post_content='Only block
' --porcelain`
+ Then save STDOUT as {POST_ID}
+
+ When I run `wp post block remove {POST_ID} --index=0`
+ Then STDOUT should contain:
+ """
+ Success: Removed 1 block from post {POST_ID}.
+ """
+
+ When I run `wp post block list {POST_ID} --format=count`
+ Then STDOUT should be:
+ """
+ 0
+ """
+
+ @require-wp-5.0
+ Scenario: Replace when no matches found
+ Given a WP install
+ When I run `wp post create --post_title='Test' --post_content='Test
' --porcelain`
+ Then save STDOUT as {POST_ID}
+
+ When I try `wp post block replace {POST_ID} core/image core/heading`
+ Then STDERR should contain:
+ """
+ No blocks of type 'core/image' were found
+ """
+ And the return code should be 0
+
+ @require-wp-5.0
+ Scenario: Update with invalid attrs JSON
+ Given a WP install
+ When I run `wp post create --post_title='Test' --post_content='Title
' --porcelain`
+ Then save STDOUT as {POST_ID}
+
+ When I try `wp post block update {POST_ID} 0 --attrs='{not valid json'`
+ Then STDERR should contain:
+ """
+ Invalid JSON
+ """
+ And the return code should be 1
+
+ @require-wp-5.0
+ Scenario: Import invalid JSON file
+ Given a WP install
+ And a bad-import.json file:
+ """
+ {not valid json
+ """
+ When I run `wp post create --post_title='Test' --post_content='' --porcelain`
+ Then save STDOUT as {POST_ID}
+
+ When I try `wp post block import {POST_ID} --file=bad-import.json`
+ Then STDERR should contain:
+ """
+ Invalid block structure
+ """
+ And the return code should be 1
+
+ @require-wp-5.0
+ Scenario: Count blocks filtered by post type
+ Given a WP install
+ When I run `wp post create --post_title='Post' --post_type=post --post_content='Post
' --post_status=publish --porcelain`
+ Then save STDOUT as {POST_ID}
+
+ When I run `wp post create --post_title='Page' --post_type=page --post_content='Page
' --post_status=publish --porcelain`
+ Then save STDOUT as {PAGE_ID}
+
+ When I run `wp post block count {POST_ID} --post-type=post`
+ Then STDOUT should be a table containing rows:
+ | blockName | count | posts |
+ | core/paragraph | 1 | 1 |
+
+ When I run `wp post block count {PAGE_ID} --post-type=page`
+ Then STDOUT should be a table containing rows:
+ | blockName | count | posts |
+ | core/heading | 1 | 1 |
+
+ @require-wp-5.0
+ Scenario: Count blocks filtered by post status
+ Given a WP install
+ When I run `wp post create --post_title='Published' --post_content='Pub
' --post_status=publish --porcelain`
+ Then save STDOUT as {PUB_ID}
+
+ When I run `wp post create --post_title='Draft' --post_content='Draft
' --post_status=draft --porcelain`
+ Then save STDOUT as {DRAFT_ID}
+
+ When I run `wp post block count {DRAFT_ID} --post-status=draft`
+ Then STDOUT should be a table containing rows:
+ | blockName | count | posts |
+ | core/heading | 1 | 1 |
+
+ # ============================================================================
+ # Phase 3: Extended Test Coverage - P1 (High) Tests
+ # ============================================================================
+
+ @require-wp-5.0
+ Scenario: Post with mixed block and freeform content
+ Given a WP install
+ # Content with blocks and freeform text in between
+ When I run `wp post create --post_title='Mixed' --post_content='Block
Some freeform text
Title
' --porcelain`
+ Then save STDOUT as {POST_ID}
+
+ When I run `wp post has-blocks {POST_ID}`
+ Then STDOUT should contain:
+ """
+ Success: Post {POST_ID} contains blocks.
+ """
+
+ @require-wp-5.0
+ Scenario: Empty post has no blocks
+ Given a WP install
+ When I run `wp post create --post_title='Empty' --post_content='' --porcelain`
+ Then save STDOUT as {POST_ID}
+
+ When I try `wp post has-blocks {POST_ID}`
+ Then STDERR should contain:
+ """
+ does not contain blocks
+ """
+ And the return code should be 1
+
+ @require-wp-5.0
+ Scenario: Parse deeply nested blocks
+ Given a WP install
+ When I run `wp post create --post_title='Deep' --post_content='Deep
' --porcelain`
+ Then save STDOUT as {POST_ID}
+
+ When I run `wp post block parse {POST_ID}`
+ Then STDOUT should contain:
+ """
+ "blockName": "core/group"
+ """
+ And STDOUT should contain:
+ """
+ "blockName": "core/columns"
+ """
+ And STDOUT should contain:
+ """
+ "blockName": "core/paragraph"
+ """
+
+ @require-wp-5.0
+ Scenario: List blocks on post with no blocks
+ Given a WP install
+ When I run `wp post create --post_title='Classic' --post_content='No blocks
' --porcelain`
+ Then save STDOUT as {POST_ID}
+
+ When I run `wp post block list {POST_ID} --format=count`
+ Then STDOUT should be:
+ """
+ 0
+ """
+
+ @require-wp-5.0
+ Scenario: Render nested blocks
+ Given a WP install
+ When I run `wp post create --post_title='Nested' --post_content='Inner
' --porcelain`
+ Then save STDOUT as {POST_ID}
+
+ When I run `wp post block render {POST_ID}`
+ Then STDOUT should contain:
+ """
+ Inner
+ """
+
+ @require-wp-5.0
+ Scenario: Insert self-closing block
+ Given a WP install
+ When I run `wp post create --post_title='Test' --post_content='Before
' --porcelain`
+ Then save STDOUT as {POST_ID}
+
+ When I run `wp post block insert {POST_ID} core/separator`
+ Then STDOUT should contain:
+ """
+ Success: Inserted block into post {POST_ID}.
+ """
+
+ When I run `wp post has-block {POST_ID} core/separator`
+ Then STDOUT should contain:
+ """
+ Success: Post {POST_ID} contains block 'core/separator'.
+ """
+
+ @require-wp-5.0
+ Scenario: Insert block into empty post
+ Given a WP install
+ When I run `wp post create --post_title='Empty' --post_content='' --porcelain`
+ Then save STDOUT as {POST_ID}
+
+ When I run `wp post block insert {POST_ID} core/paragraph --content="First block"`
+ Then STDOUT should contain:
+ """
+ Success: Inserted block into post {POST_ID}.
+ """
+
+ When I run `wp post block list {POST_ID} --format=count`
+ Then STDOUT should be:
+ """
+ 1
+ """
+
+ @require-wp-5.0
+ Scenario: Remove with out of bounds index
+ Given a WP install
+ When I run `wp post create --post_title='Test' --post_content='Test
' --porcelain`
+ Then save STDOUT as {POST_ID}
+
+ When I try `wp post block remove {POST_ID} --index=100`
+ Then STDERR should contain:
+ """
+ Invalid index: 100
+ """
+ And the return code should be 1
+
+ @require-wp-5.0
+ Scenario: Remove with negative index
+ Given a WP install
+ When I run `wp post create --post_title='Test' --post_content='Test
' --porcelain`
+ Then save STDOUT as {POST_ID}
+
+ When I try `wp post block remove {POST_ID} --index=-1`
+ Then STDERR should contain:
+ """
+ Invalid index: -1
+ """
+ And the return code should be 1
+
+ @require-wp-5.0
+ Scenario: Remove container block removes children
+ Given a WP install
+ When I run `wp post create --post_title='Test' --post_content='Nested
' --porcelain`
+ Then save STDOUT as {POST_ID}
+
+ When I run `wp post block remove {POST_ID} --index=0`
+ Then STDOUT should contain:
+ """
+ Success: Removed 1 block from post {POST_ID}.
+ """
+
+ When I run `wp post block list {POST_ID} --format=count`
+ Then STDOUT should be:
+ """
+ 0
+ """
+
+ @require-wp-5.0
+ Scenario: Remove block by index
+ Given a WP install
+ When I run `wp post create --post_title='Three Blocks' --post_content='First
Second
Third
' --porcelain`
+ Then save STDOUT as {POST_ID}
+
+ # Index 1 should be "Second" block
+ When I run `wp post block remove {POST_ID} --index=1`
+ Then STDOUT should contain:
+ """
+ Success: Removed 1 block from post {POST_ID}.
+ """
+
+ When I run `wp post get {POST_ID} --field=post_content`
+ Then STDOUT should contain:
+ """
+ First
+ """
+ And STDOUT should contain:
+ """
+ Third
+ """
+ And STDOUT should not contain:
+ """
+ Second
+ """
+
+ @require-wp-5.0
+ Scenario: Remove multiple blocks by indices
+ Given a WP install
+ When I run `wp post create --post_title='Three Blocks' --post_content='First
Second
Third
' --porcelain`
+ Then save STDOUT as {POST_ID}
+
+ # Indices 0 and 2 should be "First" and "Third"
+ When I run `wp post block remove {POST_ID} --index=0,2`
+ Then STDOUT should contain:
+ """
+ Success: Removed 2 blocks from post {POST_ID}.
+ """
+
+ When I run `wp post get {POST_ID} --field=post_content`
+ Then STDOUT should contain:
+ """
+ Second
+ """
+ And STDOUT should not contain:
+ """
+ First
+ """
+ And STDOUT should not contain:
+ """
+ Third
+ """
+
+ @require-wp-5.0
+ Scenario: Replace block preserves content
+ Given a WP install
+ When I run `wp post create --post_title='Test' --post_content='Keep this text
' --porcelain`
+ Then save STDOUT as {POST_ID}
+
+ When I run `wp post block replace {POST_ID} core/paragraph core/verse`
+ Then STDOUT should contain:
+ """
+ Success: Replaced 1 block in post {POST_ID}.
+ """
+
+ When I run `wp post get {POST_ID} --field=post_content`
+ Then STDOUT should contain:
+ """
+ Keep this text
+ """
+
+ @require-wp-5.0
+ Scenario: Get nested block shows inner blocks
+ Given a WP install
+ When I run `wp post create --post_title='Test' --post_content='Inner
' --porcelain`
+ Then save STDOUT as {POST_ID}
+
+ When I run `wp post block get {POST_ID} 0`
+ Then STDOUT should contain:
+ """
+ "blockName": "core/group"
+ """
+ And STDOUT should contain:
+ """
+ "innerBlocks"
+ """
+
+ @require-wp-5.0
+ Scenario: Update both attrs and content
+ Given a WP install
+ When I run `wp post create --post_title='Test' --post_content='Old Title
' --porcelain`
+ Then save STDOUT as {POST_ID}
+
+ When I run `wp post block update {POST_ID} 0 --attrs='{"level":3}' --content="New Title
"`
+ Then STDOUT should contain:
+ """
+ Success: Updated block at index 0 in post {POST_ID}.
+ """
+
+ When I run `wp post block parse {POST_ID}`
+ Then STDOUT should contain:
+ """
+ "level": 3
+ """
+
+ When I run `wp post get {POST_ID} --field=post_content`
+ Then STDOUT should contain:
+ """
+ New Title
+ """
+
+ @require-wp-5.0
+ Scenario: Move in single block post
+ Given a WP install
+ When I run `wp post create --post_title='Test' --post_content='Only
' --porcelain`
+ Then save STDOUT as {POST_ID}
+
+ When I try `wp post block move {POST_ID} 0 1`
+ Then STDERR should contain:
+ """
+ Invalid to-index: 1
+ """
+ And the return code should be 1
+
+ @require-wp-5.0
+ Scenario: Export with --raw includes innerHTML
+ Given a WP install
+ When I run `wp post create --post_title='Test' --post_content='Test content
' --porcelain`
+ Then save STDOUT as {POST_ID}
+
+ When I run `wp post block export {POST_ID} --raw`
+ Then STDOUT should contain:
+ """
+ "innerHTML"
+ """
+ And STDOUT should contain:
+ """
+ Test content
+ """
+
+ @require-wp-5.0
+ Scenario: Export post with no blocks
+ Given a WP install
+ When I run `wp post create --post_title='Classic' --post_content='No blocks
' --porcelain`
+ Then save STDOUT as {POST_ID}
+
+ When I run `wp post block export {POST_ID}`
+ Then STDOUT should contain:
+ """
+ "blocks":
+ """
+
+ @require-wp-5.0
+ Scenario: Import at specific numeric position
+ Given a WP install
+ And a blocks-import-pos.json file:
+ """
+ {"blocks":[{"blockName":"core/heading","attrs":{"level":2},"innerBlocks":[],"innerHTML":"Middle
","innerContent":["Middle
"]}]}
+ """
+ When I run `wp post create --post_title='Test' --post_content='First
Last
' --porcelain`
+ Then save STDOUT as {POST_ID}
+
+ When I run `wp post block import {POST_ID} --file=blocks-import-pos.json --position=1`
+ Then STDOUT should contain:
+ """
+ Success: Imported 1 block into post {POST_ID}.
+ """
+
+ When I run `wp post block render {POST_ID}`
+ Then STDOUT should match /First.*Middle.*Last/s
+
+ @require-wp-5.0
+ Scenario: Clone block to specific numeric position
+ Given a WP install
+ When I run `wp post create --post_title='Test' --post_content='First
Second
Third
' --porcelain`
+ Then save STDOUT as {POST_ID}
+
+ # Clone first block to position 2 (between Second and Third)
+ When I run `wp post block clone {POST_ID} 0 --position=2`
+ Then STDOUT should contain:
+ """
+ Success: Cloned block to index 2 in post {POST_ID}.
+ """
+
+ When I run `wp post block render {POST_ID}`
+ Then STDOUT should match /First.*Second.*First.*Third/s
+
+ @require-wp-5.0
+ Scenario: Clone nested block preserves children
+ Given a WP install
+ When I run `wp post create --post_title='Test' --post_content='Inner
' --porcelain`
+ Then save STDOUT as {POST_ID}
+
+ When I run `wp post block clone {POST_ID} 0`
+ Then STDOUT should contain:
+ """
+ Success: Cloned block to index 1 in post {POST_ID}.
+ """
+
+ When I run `wp post block list {POST_ID} --nested`
+ Then STDOUT should be a table containing rows:
+ | blockName | count |
+ | core/group | 2 |
+ | core/paragraph | 2 |
+
+ @require-wp-5.0
+ Scenario: Extract non-existent attribute
+ Given a WP install
+ When I run `wp post create --post_title='Test' --post_content='Test
' --porcelain`
+ Then save STDOUT as {POST_ID}
+
+ When I try `wp post block extract {POST_ID} --block=core/paragraph --attr=nonexistent --format=ids`
+ Then STDERR should contain:
+ """
+ No values found
+ """
+
+ @require-wp-5.0
+ Scenario: Extract from non-existent block type
+ Given a WP install
+ When I run `wp post create --post_title='Test' --post_content='Test
' --porcelain`
+ Then save STDOUT as {POST_ID}
+
+ When I try `wp post block extract {POST_ID} --block=core/image --attr=id --format=ids`
+ Then STDERR should contain:
+ """
+ No matching blocks
+ """
+
+ # ============================================================================
+ # Phase 3: Extended Test Coverage - P2 (Medium) Tests
+ # ============================================================================
+
+ @require-wp-5.0
+ Scenario: Parse empty post content
+ Given a WP install
+ When I run `wp post create --post_title='Empty' --post_content='' --porcelain`
+ Then save STDOUT as {POST_ID}
+
+ When I run `wp post block parse {POST_ID}`
+ Then STDOUT should be:
+ """
+ []
+ """
+
+ @require-wp-5.0
+ Scenario: List blocks in CSV and YAML formats
+ Given a WP install
+ When I run `wp post create --post_title='Test' --post_content='Test
' --porcelain`
+ Then save STDOUT as {POST_ID}
+
+ When I run `wp post block list {POST_ID} --format=csv`
+ Then STDOUT should contain:
+ """
+ blockName,count
+ """
+ And STDOUT should contain:
+ """
+ core/paragraph,1
+ """
+
+ When I run `wp post block list {POST_ID} --format=yaml`
+ Then STDOUT should contain:
+ """
+ blockName: core/paragraph
+ """
+
+ @require-wp-5.0
+ Scenario: List with --nested counts all nesting levels
+ Given a WP install
+ When I run `wp post create --post_title='Deep' --post_content='Deep
' --porcelain`
+ Then save STDOUT as {POST_ID}
+
+ When I run `wp post block list {POST_ID} --nested`
+ Then STDOUT should be a table containing rows:
+ | blockName | count |
+ | core/group | 2 |
+ | core/paragraph | 1 |
+
+ @require-wp-5.0
+ Scenario: Render unknown block type
+ Given a WP install
+ When I run `wp post create --post_title='Unknown' --post_content='Content
' --porcelain`
+ Then save STDOUT as {POST_ID}
+
+ When I run `wp post block render {POST_ID}`
+ # Unknown blocks render their innerHTML as-is
+ Then STDOUT should contain:
+ """
+ Content
+ """
+
+ @require-wp-5.0
+ Scenario: Insert with invalid attrs JSON
+ Given a WP install
+ When I run `wp post create --post_title='Test' --post_content='' --porcelain`
+ Then save STDOUT as {POST_ID}
+
+ When I try `wp post block insert {POST_ID} core/heading --attrs='{invalid json'`
+ Then STDERR should contain:
+ """
+ Invalid JSON
+ """
+ And the return code should be 1
+
+ @require-wp-5.0
+ Scenario: Replace with invalid attrs JSON
+ Given a WP install
+ When I run `wp post create --post_title='Test' --post_content='Test
' --porcelain`
+ Then save STDOUT as {POST_ID}
+
+ When I try `wp post block replace {POST_ID} core/paragraph core/heading --attrs='{broken'`
+ Then STDERR should contain:
+ """
+ Invalid JSON
+ """
+ And the return code should be 1
+
+ @require-wp-5.0
+ Scenario: Move with negative indices
+ Given a WP install
+ When I run `wp post create --post_title='Test' --post_content='First
Second
' --porcelain`
+ Then save STDOUT as {POST_ID}
+
+ When I try `wp post block move {POST_ID} -1 0`
+ Then STDERR should contain:
+ """
+ Invalid from-index: -1
+ """
+ And the return code should be 1
+
+ @require-wp-5.0
+ Scenario: Count blocks in various formats
+ Given a WP install
+ When I run `wp post create --post_title='Test' --post_content='Test
' --post_status=publish --porcelain`
+ Then save STDOUT as {POST_ID}
+
+ When I run `wp post block count {POST_ID} --format=json`
+ Then STDOUT should be JSON containing:
+ """
+ [{"blockName":"core/paragraph","count":1,"posts":1}]
+ """
+
+ When I run `wp post block count {POST_ID} --format=csv`
+ Then STDOUT should contain:
+ """
+ blockName,count,posts
+ """
+
+ When I run `wp post block count {POST_ID} --format=yaml`
+ Then STDOUT should contain:
+ """
+ blockName: core/paragraph
+ """
+
+ @require-wp-5.0
+ Scenario: Import empty blocks array
+ Given a WP install
+ And a empty-blocks.json file:
+ """
+ {"version":"1.0","blocks":[]}
+ """
+ When I run `wp post create --post_title='Test' --post_content='Existing
' --porcelain`
+ Then save STDOUT as {POST_ID}
+
+ When I run `wp post block import {POST_ID} --file=empty-blocks.json`
+ Then STDOUT should contain:
+ """
+ Success: Imported 0 blocks into post {POST_ID}.
+ """
+
+ @require-wp-5.0
+ Scenario: Extract attribute in various formats
+ Given a WP install
+ When I run `wp post create --post_title='Test' --post_content='One
Two
' --porcelain`
+ Then save STDOUT as {POST_ID}
+
+ When I run `wp post block extract {POST_ID} --block=core/heading --attr=level --format=json`
+ Then STDOUT should be JSON containing:
+ """
+ [2,3]
+ """
+
+ When I run `wp post block extract {POST_ID} --block=core/heading --attr=level --format=csv`
+ Then STDOUT should contain:
+ """
+ 2,3
+ """
+
+ @require-wp-5.0
+ Scenario: Extract with both block and index filters
+ Given a WP install
+ When I run `wp post create --post_title='Test' --post_content='Para
Title
' --porcelain`
+ Then save STDOUT as {POST_ID}
+
+ # --index=1 is the heading, --block filter should match
+ When I run `wp post block extract {POST_ID} --index=1 --block=core/heading --attr=level --format=ids`
+ Then STDOUT should be:
+ """
+ 2
+ """
+
+ # ============================================================================
+ # Phase 3: STDIN Import Test (requires wp-cli-tests update)
+ # ============================================================================
+
+ @require-wp-5.0 @broken
+ Scenario: Import blocks from STDIN
+ Given a WP install
+ And a blocks-stdin.json file:
+ """
+ {"blocks":[{"blockName":"core/paragraph","attrs":{},"innerBlocks":[],"innerHTML":"From STDIN
","innerContent":["From STDIN
"]}]}
+ """
+ When I run `wp post create --post_title='STDIN Test' --post_content='' --porcelain`
+ Then save STDOUT as {POST_ID}
+
+ When I run `wp post block import {POST_ID}` with STDIN from 'blocks-stdin.json'
+ Then STDOUT should contain:
+ """
+ Success: Imported 1 block into post {POST_ID}.
+ """
+
+ When I run `wp post get {POST_ID} --field=post_content`
+ Then STDOUT should contain:
+ """
+ From STDIN
+ """
diff --git a/features/post.feature b/features/post.feature
index 858668bf..0f8a08b0 100644
--- a/features/post.feature
+++ b/features/post.feature
@@ -509,3 +509,39 @@ Feature: Manage WordPress posts
"""
2005-01-24T09:52:00.000Z
"""
+
+ @require-wp-5.0
+ Scenario: Get block_version field for post with blocks
+ When I run `wp post create --post_title='Block Post' --post_content='Hello block world
' --porcelain`
+ Then STDOUT should be a number
+ And save STDOUT as {POST_ID}
+
+ When I run `wp post get {POST_ID} --field=block_version`
+ Then STDOUT should be:
+ """
+ 1
+ """
+
+ @require-wp-5.0
+ Scenario: Get block_version field for post without blocks
+ When I run `wp post create --post_title='Classic Post' --post_content='Just plain HTML
' --porcelain`
+ Then STDOUT should be a number
+ And save STDOUT as {POST_ID}
+
+ When I run `wp post get {POST_ID} --field=block_version`
+ Then STDOUT should be:
+ """
+ 0
+ """
+
+ @require-wp-5.0
+ Scenario: Get block_version field included in default output
+ When I run `wp post create --post_title='Test Post' --post_content='Title
' --porcelain`
+ Then STDOUT should be a number
+ And save STDOUT as {POST_ID}
+
+ When I run `wp post get {POST_ID} --format=json`
+ Then STDOUT should be JSON containing:
+ """
+ {"block_version":1}
+ """
diff --git a/phpcs.xml.dist b/phpcs.xml.dist
index 2df6f26d..0df76141 100644
--- a/phpcs.xml.dist
+++ b/phpcs.xml.dist
@@ -47,6 +47,7 @@
+
@@ -64,7 +65,7 @@
*/src/Network_Meta_Command\.php$
*/src/Network_Namespace\.php$
*/src/Option_Command\.php$
- */src/Post(_Meta|_Term|_Type)?_Command\.php$
+ */src/Post(_Block|_Meta|_Term|_Type)?_Command\.php$
*/src/Signup_Command\.php$
*/src/Site(_Meta|_Option)?_Command\.php$
*/src/Term(_Meta)?_Command\.php$
@@ -76,4 +77,47 @@
*/src/WP_CLI/CommandWithDBObject\.php$
+
+
+
+
+ */src/Compat/WP_Block_Processor\.php$
+ */src/Compat/WP_HTML_Span\.php$
+
+
+
+
+ */src/Compat/*
+ */tests/Compat/PolyfillsTest\.php$
+
+
+ */src/Compat/*
+ */tests/Compat/PolyfillsTest\.php$
+
+
+ */src/Compat/*
+ */tests/Compat/PolyfillsTest\.php$
+
+
+
+
+ */src/Compat/WP_Block_Processor\.php$
+
+
+
+
+ */src/Compat/WP_Block_Processor\.php$
+
+
+
+
+ */tests/bootstrap\.php$
+
+
diff --git a/phpstan.neon.dist b/phpstan.neon.dist
index 46911b20..1ebe3bd3 100644
--- a/phpstan.neon.dist
+++ b/phpstan.neon.dist
@@ -3,6 +3,12 @@ parameters:
paths:
- src
- entity-command.php
+ excludePaths:
+ # Polyfill files are copies of WordPress core code with GOTO control flow
+ # that PHPStan cannot analyze correctly
+ - src/Compat/WP_Block_Processor.php
+ - src/Compat/WP_HTML_Span.php
+ - src/Compat/polyfills.php
scanDirectories:
- vendor/wp-cli/wp-cli/php
scanFiles:
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
new file mode 100644
index 00000000..c419b306
--- /dev/null
+++ b/phpunit.xml.dist
@@ -0,0 +1,26 @@
+
+
+
+ tests
+
+
+
+
+
+ src
+
+
+
diff --git a/src/Block_HTML_Sync_Filters.php b/src/Block_HTML_Sync_Filters.php
new file mode 100644
index 00000000..bcc86679
--- /dev/null
+++ b/src/Block_HTML_Sync_Filters.php
@@ -0,0 +1,135 @@
+ to .
+ *
+ * @param array $block The block structure.
+ * @param array $new_attrs The newly applied attributes.
+ * @param string $block_name The block type name.
+ * @return array The block with synchronized HTML.
+ */
+ public static function sync_heading_level( $block, $new_attrs, $block_name ) {
+ if ( 'core/heading' !== $block_name ) {
+ return $block;
+ }
+
+ if ( ! isset( $new_attrs['level'] ) ) {
+ return $block;
+ }
+
+ $new_level = (int) $new_attrs['level'];
+ if ( $new_level < 1 || $new_level > 6 ) {
+ return $block;
+ }
+
+ $inner_html = $block['innerHTML'] ?? '';
+ if ( empty( $inner_html ) ) {
+ return $block;
+ }
+
+ // Replace opening and closing heading tags.
+ // Pattern matches through with optional attributes.
+ $updated_html = preg_replace(
+ '/]*)?>/',
+ "",
+ $inner_html
+ );
+ $updated_html = preg_replace(
+ '/<\/h[1-6]>/',
+ "",
+ $updated_html
+ );
+
+ if ( null !== $updated_html && $updated_html !== $inner_html ) {
+ $block['innerHTML'] = $updated_html;
+ $block['innerContent'] = [ $updated_html ];
+ }
+
+ return $block;
+ }
+
+ /**
+ * Synchronizes list HTML tag with the ordered attribute.
+ *
+ * When a list's ordered attribute changes, this updates the HTML
+ * from to or vice versa.
+ *
+ * @param array $block The block structure.
+ * @param array $new_attrs The newly applied attributes.
+ * @param string $block_name The block type name.
+ * @return array The block with synchronized HTML.
+ */
+ public static function sync_list_type( $block, $new_attrs, $block_name ) {
+ if ( 'core/list' !== $block_name ) {
+ return $block;
+ }
+
+ if ( ! isset( $new_attrs['ordered'] ) ) {
+ return $block;
+ }
+
+ $inner_html = $block['innerHTML'] ?? '';
+ if ( empty( $inner_html ) ) {
+ return $block;
+ }
+
+ $is_ordered = (bool) $new_attrs['ordered'];
+ $new_tag = $is_ordered ? 'ol' : 'ul';
+ $old_tag = $is_ordered ? 'ul' : 'ol';
+
+ // Replace opening and closing list tags.
+ $updated_html = preg_replace(
+ "/<{$old_tag}(\s[^>]*)?>/",
+ "<{$new_tag}$1>",
+ $inner_html
+ );
+ $updated_html = preg_replace(
+ "/<\/{$old_tag}>/",
+ "{$new_tag}>",
+ $updated_html
+ );
+
+ if ( null !== $updated_html && $updated_html !== $inner_html ) {
+ $block['innerHTML'] = $updated_html;
+ $block['innerContent'] = [ $updated_html ];
+ }
+
+ return $block;
+ }
+}
diff --git a/src/Block_Processor_Helper.php b/src/Block_Processor_Helper.php
new file mode 100644
index 00000000..57e968d1
--- /dev/null
+++ b/src/Block_Processor_Helper.php
@@ -0,0 +1,448 @@
+next_block() ) {
+ // Only process top-level blocks (depth 1).
+ if ( 1 !== $processor->get_depth() ) {
+ continue;
+ }
+
+ $delimiter_type = $processor->get_delimiter_type();
+
+ // Handle void blocks specially - don't use extract which causes issues.
+ if ( WP_Block_Processor::VOID === $delimiter_type ) {
+ $blocks[] = [
+ 'blockName' => $processor->get_block_type(),
+ 'attrs' => $processor->allocate_and_return_parsed_attributes() ?? [],
+ 'innerBlocks' => [],
+ 'innerHTML' => '',
+ 'innerContent' => [],
+ ];
+ } elseif ( WP_Block_Processor::OPENER === $delimiter_type ) {
+ // For opener blocks, extract_full_block_and_advance works correctly.
+ $block = $processor->extract_full_block_and_advance();
+ if ( null !== $block ) {
+ $blocks[] = $block;
+ }
+ }
+ }
+
+ return $blocks;
+ }
+
+ /**
+ * Gets a block at a specific index.
+ *
+ * Uses streaming to find the block without parsing the entire document.
+ *
+ * @param string $content Block content to search.
+ * @param int $target_index The 0-based index of the block to get.
+ * @param bool $skip_freeform Whether to skip freeform HTML blocks (default true).
+ * @return array|null The block structure or null if not found.
+ */
+ public static function get_at_index( string $content, int $target_index, bool $skip_freeform = true ): ?array {
+ if ( '' === $content || $target_index < 0 ) {
+ return null;
+ }
+
+ self::ensure_loaded();
+ $processor = new WP_Block_Processor( $content );
+ $current_index = 0;
+
+ while ( $processor->next_block() ) {
+ // Only consider top-level blocks (depth 1).
+ if ( 1 !== $processor->get_depth() ) {
+ continue;
+ }
+
+ // Skip freeform content unless requested.
+ if ( null === $processor->get_block_type() ) {
+ if ( $skip_freeform ) {
+ continue;
+ }
+ }
+
+ if ( $current_index === $target_index ) {
+ $delimiter_type = $processor->get_delimiter_type();
+
+ // Handle void blocks specially.
+ if ( WP_Block_Processor::VOID === $delimiter_type ) {
+ return [
+ 'blockName' => $processor->get_block_type(),
+ 'attrs' => $processor->allocate_and_return_parsed_attributes() ?? [],
+ 'innerBlocks' => [],
+ 'innerHTML' => '',
+ 'innerContent' => [],
+ ];
+ }
+
+ return $processor->extract_full_block_and_advance();
+ }
+
+ ++$current_index;
+ }
+
+ return null;
+ }
+
+ /**
+ * Counts blocks by type using streaming.
+ *
+ * @param string $content Block content to analyze.
+ * @param bool $nested Whether to include nested blocks in count.
+ * @return array Associative array of block type => count.
+ */
+ public static function count_by_type( string $content, bool $nested = false ): array {
+ if ( '' === $content ) {
+ return [];
+ }
+
+ self::ensure_loaded();
+ $processor = new WP_Block_Processor( $content );
+ $counts = [];
+
+ while ( $processor->next_block() ) {
+ $block_type = $processor->get_block_type();
+
+ // Skip freeform HTML.
+ if ( null === $block_type ) {
+ continue;
+ }
+
+ // If not counting nested, only count top-level blocks.
+ if ( ! $nested && $processor->get_depth() > 1 ) {
+ continue;
+ }
+
+ if ( ! isset( $counts[ $block_type ] ) ) {
+ $counts[ $block_type ] = 0;
+ }
+
+ ++$counts[ $block_type ];
+ }
+
+ return $counts;
+ }
+
+ /**
+ * Checks if a specific block type exists in content.
+ *
+ * Uses streaming for early exit on first match.
+ *
+ * @param string $content Block content to search.
+ * @param string $block_type Block type to find (e.g., 'core/paragraph' or 'paragraph').
+ * @return bool True if block type exists, false otherwise.
+ */
+ public static function has_block( string $content, string $block_type ): bool {
+ if ( '' === $content ) {
+ return false;
+ }
+
+ self::ensure_loaded();
+ $processor = new WP_Block_Processor( $content );
+
+ // Use the processor's built-in type filtering for efficiency.
+ return $processor->next_block( $block_type );
+ }
+
+ /**
+ * Gets the total count of blocks in content.
+ *
+ * @param string $content Block content to count.
+ * @param bool $nested Whether to include nested blocks.
+ * @param bool $skip_freeform Whether to skip freeform HTML blocks.
+ * @return int Total number of blocks.
+ */
+ public static function get_block_count( string $content, bool $nested = false, bool $skip_freeform = true ): int {
+ if ( '' === $content ) {
+ return 0;
+ }
+
+ self::ensure_loaded();
+ $processor = new WP_Block_Processor( $content );
+ $count = 0;
+
+ while ( $processor->next_block() ) {
+ $block_type = $processor->get_block_type();
+
+ // Skip freeform HTML if requested.
+ if ( $skip_freeform && null === $block_type ) {
+ continue;
+ }
+
+ // If not counting nested, only count top-level blocks.
+ if ( ! $nested && $processor->get_depth() > 1 ) {
+ continue;
+ }
+
+ ++$count;
+ }
+
+ return $count;
+ }
+
+ /**
+ * Extracts blocks matching a filter condition.
+ *
+ * @param string $content Block content to search.
+ * @param callable $predicate Function that receives block type and attributes, returns bool.
+ * @param int $limit Maximum number of blocks to return (0 = unlimited).
+ * @return array Array of matching block structures.
+ */
+ public static function extract_matching( string $content, callable $predicate, int $limit = 0 ): array {
+ if ( '' === $content ) {
+ return [];
+ }
+
+ self::ensure_loaded();
+ $processor = new WP_Block_Processor( $content );
+ $blocks = [];
+
+ while ( $processor->next_block() ) {
+ $block_type = $processor->get_block_type();
+
+ // Skip freeform content for matching.
+ if ( null === $block_type ) {
+ continue;
+ }
+
+ // Only check top-level blocks.
+ if ( $processor->get_depth() > 1 ) {
+ continue;
+ }
+
+ $attrs = $processor->allocate_and_return_parsed_attributes() ?? [];
+
+ if ( $predicate( $block_type, $attrs ) ) {
+ $delimiter_type = $processor->get_delimiter_type();
+
+ // Handle void blocks specially.
+ if ( WP_Block_Processor::VOID === $delimiter_type ) {
+ $block = [
+ 'blockName' => $block_type,
+ 'attrs' => $attrs,
+ 'innerBlocks' => [],
+ 'innerHTML' => '',
+ 'innerContent' => [],
+ ];
+ } else {
+ $block = $processor->extract_full_block_and_advance();
+ }
+
+ if ( null !== $block ) {
+ $blocks[] = $block;
+
+ if ( $limit > 0 && count( $blocks ) >= $limit ) {
+ break;
+ }
+ }
+ }
+ }
+
+ return $blocks;
+ }
+
+ /**
+ * Gets block span (position) information by index.
+ *
+ * Returns the byte offset and length of a block at a given index.
+ * Useful for string splice operations.
+ *
+ * @param string $content Block content to search.
+ * @param int $target_index The 0-based index of the block.
+ * @return array|null Array with 'start' and 'end' keys, or null if not found.
+ */
+ public static function get_block_span( string $content, int $target_index ): ?array {
+ if ( '' === $content || $target_index < 0 ) {
+ return null;
+ }
+
+ self::ensure_loaded();
+ $processor = new WP_Block_Processor( $content );
+ $current_index = 0;
+
+ while ( $processor->next_block() ) {
+ $block_type = $processor->get_block_type();
+
+ // Skip freeform content.
+ if ( null === $block_type ) {
+ continue;
+ }
+
+ // Only consider top-level blocks.
+ if ( $processor->get_depth() > 1 ) {
+ continue;
+ }
+
+ if ( $current_index === $target_index ) {
+ $start_span = $processor->get_span();
+ if ( null === $start_span ) {
+ return null;
+ }
+
+ $start = $start_span->start;
+ $delimiter_type = $processor->get_delimiter_type();
+
+ // For void blocks, the span is just the delimiter.
+ if ( WP_Block_Processor::VOID === $delimiter_type ) {
+ return [
+ 'start' => $start,
+ 'end' => $start + $start_span->length,
+ ];
+ }
+
+ // For opener blocks, extract to find the end.
+ $processor->extract_full_block_and_advance();
+
+ // After extract, we're positioned at the closer (or next token).
+ // The span now points to the closer delimiter.
+ $end_span = $processor->get_span();
+ if ( null !== $end_span ) {
+ // End position is after the closer delimiter.
+ $end = $end_span->start + $end_span->length;
+ } else {
+ $end = strlen( $content );
+ }
+
+ return [
+ 'start' => $start,
+ 'end' => $end,
+ ];
+ }
+
+ ++$current_index;
+ }
+
+ return null;
+ }
+
+ /**
+ * Lists all block types present in content.
+ *
+ * Returns a simple array of unique block type names found.
+ *
+ * @param string $content Block content to analyze.
+ * @param bool $nested Whether to include nested block types.
+ * @return array Array of unique block type names.
+ */
+ public static function get_block_types( string $content, bool $nested = false ): array {
+ $counts = self::count_by_type( $content, $nested );
+ return array_keys( $counts );
+ }
+
+ /**
+ * Checks if content contains any blocks.
+ *
+ * @param string $content Block content to check.
+ * @return bool True if content contains at least one block.
+ */
+ public static function has_blocks( string $content ): bool {
+ if ( '' === $content ) {
+ return false;
+ }
+
+ self::ensure_loaded();
+ $processor = new WP_Block_Processor( $content );
+
+ while ( $processor->next_block() ) {
+ // Skip freeform HTML.
+ if ( null !== $processor->get_block_type() ) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Strips innerHTML and innerContent from blocks recursively.
+ *
+ * @param array $blocks Array of blocks.
+ * @return array Blocks with innerHTML stripped.
+ */
+ public static function strip_inner_html( array $blocks ): array {
+ return array_map(
+ function ( $block ) {
+ unset( $block['innerHTML'] );
+ unset( $block['innerContent'] );
+ if ( ! empty( $block['innerBlocks'] ) ) {
+ $block['innerBlocks'] = self::strip_inner_html( $block['innerBlocks'] );
+ }
+ return $block;
+ },
+ $blocks
+ );
+ }
+
+ /**
+ * Filters blocks to only those with non-null blockName.
+ *
+ * Removes freeform/whitespace blocks from array.
+ *
+ * @param array $blocks Array of blocks.
+ * @return array Filtered blocks with re-indexed keys.
+ */
+ public static function filter_empty_blocks( array $blocks ): array {
+ return array_values(
+ array_filter(
+ $blocks,
+ function ( $block ) {
+ return ! empty( $block['blockName'] );
+ }
+ )
+ );
+ }
+}
diff --git a/src/Compat/BlockProcessorLoader.php b/src/Compat/BlockProcessorLoader.php
new file mode 100644
index 00000000..8eeb90f8
--- /dev/null
+++ b/src/Compat/BlockProcessorLoader.php
@@ -0,0 +1,88 @@
+ *Note!* If a fully-parsed block tree of a document is necessary, including
+ * > all the parsed JSON attributes, nested blocks, and HTML, consider
+ * > using {@see \parse_blocks()} instead which will parse the document
+ * > in one swift pass.
+ *
+ * For typical usage, jump first to the methods {@see self::next_block()},
+ * {@see self::next_delimiter()}, or {@see self::next_token()}.
+ *
+ * ### Values
+ *
+ * As a lower-level interface than {@see parse_blocks()} this class follows
+ * different performance-focused values:
+ *
+ * - Minimize allocations so that documents of any size may be processed
+ * on a fixed or marginal amount of memory.
+ * - Make hidden costs explicit so that calling code only has to pay the
+ * performance penalty for features it needs.
+ * - Operate with a streaming and re-entrant design to make it possible
+ * to operate on chunks of a document and to resume after pausing.
+ *
+ * This means that some operations might appear more cumbersome than one
+ * might expect. This design tradeoff opens up opportunity to wrap this in
+ * a convenience class to add higher-level functionality.
+ *
+ * ## Concepts
+ *
+ * All text documents can be considered a block document containing a combination
+ * of “freeform HTML” and explicit block structure. Block structure forms through
+ * special HTML comments called _delimiters_ which include a block type and,
+ * optionally, block attributes encoded as a JSON object payload.
+ *
+ * This processor is designed to scan through a block document from delimiter to
+ * delimiter, tracking how the delimiters impact the structure of the document.
+ * Spans of HTML appear between delimiters. If these spans exist at the top level
+ * of the document, meaning there is no containing block around them, they are
+ * considered freeform HTML content. If, however, they appear _inside_ block
+ * structure they are interpreted as `innerHTML` for the containing block.
+ *
+ * ### Tokens and scanning
+ *
+ * As the processor scans through a document is reports information about the token
+ * on which is pauses. Tokens represent spans of text in the input comprising block
+ * delimiters and spans of HTML.
+ *
+ * - {@see self::next_token()} visits every contiguous subspan of text in the
+ * input document. This includes all explicit block comment delimiters and spans
+ * of HTML content (whether freeform or inner HTML).
+ * - {@see self::next_delimiter()} visits every explicit block comment delimiter
+ * unless passed a block type which covers freeform HTML content. In these cases
+ * it will stop at top-level spans of HTML and report a `null` block type.
+ * - {@see self::next_block()} visits every block delimiter which _opens_ a block.
+ * This includes opening block delimiters as well as void block delimiters. With
+ * the same exception as above for freeform HTML block types, this will visit
+ * top-level spans of HTML content.
+ *
+ * When matched on a particular token, the following methods provide structural
+ * and textual information about it:
+ *
+ * - {@see self::get_delimiter_type()} reports whether the delimiter is an opener,
+ * a closer, or if it represents a whole void block.
+ * - {@see self::get_block_type()} reports the fully-qualified block type which
+ * the delimiter represents.
+ * - {@see self::get_printable_block_type()} reports the fully-qualified block type,
+ * but returns `core/freeform` instead of `null` for top-level freeform HTML content.
+ * - {@see self::is_block_type()} indicates if the delimiter represents a block of
+ * the given block type, or wildcard or pseudo-block type described below.
+ * - {@see self::opens_block()} indicates if the delimiter opens a block of one
+ * of the provided block types. Opening, void, and top-level freeform HTML content
+ * all open blocks.
+ * - {@see static::get_attributes()} is currently reserved for a future streaming
+ * JSON parser class.
+ * - {@see self::allocate_and_return_parsed_attributes()} extracts the JSON attributes
+ * for delimiters which open blocks and return the fully-parsed attributes as an
+ * associative array. {@see static::get_last_json_error()} for when this fails.
+ * - {@see self::is_html()} indicates if the token is a span of HTML which might
+ * be top-level freeform content or a block’s inner HTML.
+ * - {@see self::get_html_content()} returns the span of HTML.
+ * - {@see self::get_span()} for the byte offset and length into the input document
+ * representing the token.
+ *
+ * It’s possible for the processor to fail to scan forward if the input document ends
+ * in a proper prefix of an explicit block comment delimiter. For example, if the input
+ * ends in `` delimiter it will return `core/group` as the
+ * block type.
+ *
+ * There are two special block types that change the behavior of the processor:
+ *
+ * - The wildcard `*` represents _any block_. In addition to matching all block types,
+ * it also represents top-level freeform HTML whose block type is reported as `null`.
+ *
+ * - The `core/freeform` block type is a pseudo-block type which explicitly matches
+ * top-level freeform HTML.
+ *
+ * These special block types can be passed into any method which searches for blocks.
+ *
+ * There is one additional special block type which may be returned from
+ * {@see self::get_printable_block_type()}. This is the `#innerHTML` type, which
+ * indicates that the HTML span on which the processor is paused is inner HTML for
+ * a containing block.
+ *
+ * ### Spans of HTML
+ *
+ * Non-block content plays a complicated role in processing block documents. This
+ * processor exposes tools to help work with these spans of HTML.
+ *
+ * - {@see self::is_html()} indicates if the processor is paused at a span of
+ * HTML but does not differentiate between top-level freeform content and inner HTML.
+ * - {@see self::is_non_whitespace_html()} indicates not only if the processor
+ * is paused at a span of HTML, but also whether that span incorporates more than
+ * whitespace characters. Because block serialization often inserts newlines between
+ * block comment delimiters, this is useful for distinguishing “real” freeform
+ * content from purely aesthetic syntax.
+ * - {@see self::is_block_type()} matches top-level freeform HTML content when
+ * provided one of the special block types described above.
+ *
+ * ### Block structure
+ *
+ * As the processor traverses block delimiters it maintains a stack of which blocks are
+ * open at the given place in the document where it’s paused. This stack represents the
+ * block structure of a document and is used to determine where blocks end, which blocks
+ * represent inner blocks, whether a span of HTML is top-level freeform content, and
+ * more. Investigate the stack with {@see self::get_breadcrumbs()}, which returns an
+ * array of block types starting at the outermost-open block and descending to the
+ * currently-visited block.
+ *
+ * Unlike {@parse_blocks()}, spans of HTML appear in this structure as the special
+ * reported block type `#html`. Such a span represents inner HTML for a block if the
+ * depth reported by {@see self::get_depth()} is greater than one.
+ *
+ * It will generally not be necessary to inspect the stack of open blocks, though
+ * depth may be important for finding where blocks end. When visiting a block opener,
+ * the depth will have been increased before pausing; in contrast the depth is
+ * decremented before visiting a closer. This makes the following an easy way to
+ * determine if a block is still open.
+ *
+ * Example:
+ *
+ * $depth = $processor->get_depth();
+ * while ( $processor->next_token() && $processor->get_depth() > $depth ) {
+ * continue
+ * }
+ * // Processor is now paused at the token immediately following the closed block.
+ *
+ * #### Extracting blocks
+ *
+ * A unique feature of this processor is the ability to return the same output as
+ * {@see \parse_blocks()} would produce, but for a subset of the input document.
+ * For example, it’s possible to extract an image block, manipulate that parsed
+ * block, and re-serialize it into the original document. It’s possible to do so
+ * while skipping over the parse of the rest of the document.
+ *
+ * {@see self::extract_full_block_and_advance()} will scan forward from the current block opener
+ * and build the parsed block structure until the current block is closed. It will
+ * include all inner HTML and inner blocks, and parse all of the inner blocks. It
+ * can be used to extract a block at any depth in the document, helpful for operating
+ * on blocks within nested structure.
+ *
+ * Example:
+ *
+ * if ( ! $processor->next_block( 'gallery' ) ) {
+ * return $post_content;
+ * }
+ *
+ * $gallery_at = $processor->get_span()->start;
+ * $gallery_block = $processor->extract_full_block_and_advance();
+ * $after_gallery = $processor->get_span()->start;
+ * return (
+ * substr( $post_content, 0, $gallery_at ) .
+ * serialize_block( modify_gallery( $gallery_block ) .
+ * substr( $post_content, $after_gallery )
+ * );
+ *
+ * #### Handling of malformed structure
+ *
+ * There are situations where closing block delimiters appear for which no open block
+ * exists, or where a document ends before a block is closed, or where a closing block
+ * delimiter appears but references a different block type than the most-recently
+ * opened block does. In all of these cases, the stack of open blocks should mirror
+ * the behavior in {@see \parse_blocks()}.
+ *
+ * Unlike {@see \parse_blocks()}, however, this processor can still operate on the
+ * invalid block delimiters. It provides a few functions which can be used for building
+ * custom and non-spec-compliant error handling.
+ *
+ * - {@see self::has_closing_flag()} indicates if the block delimiter contains the
+ * closing flag at the end. Some invalid block delimiters might contain both the
+ * void and closing flag, in which case {@see self::get_delimiter_type()} will
+ * report that it’s a void block.
+ * - {@see static::get_last_error()} indicates if the processor reached an invalid
+ * block closing. Depending on the context, {@see \parse_blocks()} might instead
+ * ignore the token or treat it as freeform HTML content.
+ *
+ * ## Static helpers
+ *
+ * This class provides helpers for performing semantic block-related operations.
+ *
+ * - {@see self::normalize_block_type()} takes a block type with or without the
+ * implicit `core` namespace and returns a fully-qualified block type.
+ * - {@see self::are_equal_block_types()} indicates if two spans across one or
+ * more input texts represent the same fully-qualified block type.
+ *
+ * ## Subclassing
+ *
+ * This processor is designed to accurately parse a block document. Therefore, many
+ * of its methods are not meant for subclassing. However, overall this class supports
+ * building higher-level convenience classes which may choose to subclass it. For those
+ * classes, avoid re-implementing methods except for the list below. Instead, create
+ * new names representing the higher-level concepts being introduced. For example, instead
+ * of creating a new method named `next_block()` which only advances to blocks of a given
+ * kind, consider creating a new method named something like `next_layout_block()` which
+ * won’t interfere with the base class method.
+ *
+ * - {@see static::get_last_error()} may be reimplemented to report new errors in the subclass
+ * which aren’t intrinsic to block parsing.
+ * - {@see static::get_attributes()} may be reimplemented to provide a streaming interface
+ * to reading and modifying a block’s JSON attributes. It should be fast and memory efficient.
+ * - {@see static::get_last_json_error()} may be reimplemented to report new errors introduced
+ * with a reimplementation of {@see static::get_attributes()}.
+ *
+ * @since 6.9.0
+ */
+class WP_Block_Processor {
+ /**
+ * Indicates if the last operation failed, otherwise
+ * will be `null` for success.
+ *
+ * @since 6.9.0
+ *
+ * @var string|null
+ */
+ private $last_error = null;
+
+ /**
+ * Indicates failures from decoding JSON attributes.
+ *
+ * @since 6.9.0
+ *
+ * @see \json_last_error()
+ *
+ * @var int
+ */
+ private $last_json_error = JSON_ERROR_NONE;
+
+ /**
+ * Source text provided to processor.
+ *
+ * @since 6.9.0
+ *
+ * @var string
+ */
+ protected $source_text;
+
+ /**
+ * Byte offset into source text where a matched delimiter starts.
+ *
+ * Example:
+ *
+ * 5 10 15 20 25 30 35 40 45 50
+ *
+ * ╰─ Starts at byte offset 17.
+ *
+ * @since 6.9.0
+ *
+ * @var int
+ */
+ private $matched_delimiter_at = 0;
+
+ /**
+ * Byte length of full span of a matched delimiter.
+ *
+ * Example:
+ *
+ * 5 10 15 20 25 30 35 40 45 50
+ *
+ * ╰───────────────╯
+ * 17 bytes long.
+ *
+ * @since 6.9.0
+ *
+ * @var int
+ */
+ private $matched_delimiter_length = 0;
+
+ /**
+ * First byte offset into source text following any previously-matched delimiter.
+ * Used to indicate where an HTML span starts.
+ *
+ * Example:
+ *
+ * 5 10 15 20 25 30 35 40 45 50 55
+ * Content
<⃨!⃨-⃨-⃨ ⃨/⃨w⃨p⃨:⃨p⃨a⃨r⃨a⃨g⃨r⃨a⃨p⃨h⃨ ⃨-⃨-⃨>⃨
+ * │ ╰─ This delimiter was matched, and after matching,
+ * │ revealed the preceding HTML span.
+ * │
+ * ╰─ The first byte offset after the previous matched delimiter
+ * is 21. Because the matched delimiter starts at 55, which is after
+ * this, a span of HTML must exist between these boundaries.
+ *
+ * @since 6.9.0
+ *
+ * @var int
+ */
+ private $after_previous_delimiter = 0;
+
+ /**
+ * Byte offset where namespace span begins.
+ *
+ * When no namespace is present, this will be the same as the starting
+ * byte offset for the block name.
+ *
+ * Example:
+ *
+ *
+ * │ ╰─ Name starts here.
+ * ╰─ Namespace starts here.
+ *
+ *
+ * ├─ The namespace would start here but is implied as “core.”
+ * ╰─ The name starts here.
+ *
+ * @since 6.9.0
+ *
+ * @var int
+ */
+ private $namespace_at = 0;
+
+ /**
+ * Byte offset where block name span begins.
+ *
+ * When no namespace is present, this will be the same as the starting
+ * byte offset for the block namespace.
+ *
+ * Example:
+ *
+ *
+ * │ ╰─ Name starts here.
+ * ╰─ Namespace starts here.
+ *
+ *
+ * ├─ The namespace would start here but is implied as “core.”
+ * ╰─ The name starts here.
+ *
+ * @since 6.9.0
+ *
+ * @var int
+ */
+ private $name_at = 0;
+
+ /**
+ * Byte length of block name span.
+ *
+ * Example:
+ *
+ * 5 10 15 20 25
+ *
+ * ╰─────╯
+ * 7 bytes long.
+ *
+ * @since 6.9.0
+ *
+ * @var int
+ */
+ private $name_length = 0;
+
+ /**
+ * Whether the delimiter contains the block-closing flag.
+ *
+ * This may be erroneous if present within a void block,
+ * therefore the {@see self::has_closing_flag()} can be used by
+ * calling code to perform custom error-handling.
+ *
+ * @since 6.9.0
+ *
+ * @var bool
+ */
+ private $has_closing_flag = false;
+
+ /**
+ * Byte offset where JSON attributes span begins.
+ *
+ * Example:
+ *
+ * 5 10 15 20 25 30 35 40
+ *
+ * ╰─ Starts at byte offset 18.
+ *
+ * @since 6.9.0
+ *
+ * @var int
+ */
+ private $json_at;
+
+ /**
+ * Byte length of JSON attributes span, or 0 if none are present.
+ *
+ * Example:
+ *
+ * 5 10 15 20 25 30 35 40
+ *
+ * ╰───────────────╯
+ * 17 bytes long.
+ *
+ * @since 6.9.0
+ *
+ * @var int
+ */
+ private $json_length = 0;
+
+ /**
+ * Internal parser state, differentiating whether the instance is currently matched,
+ * on an implicit freeform node, in error, or ready to begin parsing.
+ *
+ * @see self::READY
+ * @see self::MATCHED
+ * @see self::HTML_SPAN
+ * @see self::INCOMPLETE_INPUT
+ * @see self::COMPLETE
+ *
+ * @since 6.9.0
+ *
+ * @var string
+ */
+ protected $state = self::READY;
+
+ /**
+ * Indicates what kind of block comment delimiter was matched.
+ *
+ * One of:
+ *
+ * - {@see self::OPENER} If the delimiter is opening a block.
+ * - {@see self::CLOSER} If the delimiter is closing an open block.
+ * - {@see self::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 syntax error and probably
+ * the block ought to close an open block of the same name, if one is open.
+ *
+ * @since 6.9.0
+ *
+ * @var string
+ */
+ private $type;
+
+ /**
+ * Whether the last-matched delimiter acts like a void block and should be
+ * popped from the stack of open blocks as soon as the parser advances.
+ *
+ * This applies to void block delimiters and to HTML spans.
+ *
+ * @since 6.9.0
+ *
+ * @var bool
+ */
+ private $was_void = false;
+
+ /**
+ * For every open block, in hierarchical order, this stores the byte offset
+ * into the source text where the block type starts, including for HTML spans.
+ *
+ * To avoid allocating and normalizing block names when they aren’t requested,
+ * the stack of open blocks is stored as the byte offsets and byte lengths of
+ * each open block’s block type. This allows for minimal tracking and quick
+ * reading or comparison of block types when requested.
+ *
+ * @since 6.9.0
+ *
+ * @see self::$open_blocks_length
+ *
+ * @var int[]
+ */
+ private $open_blocks_at = array();
+
+ /**
+ * For every open block, in hierarchical order, this stores the byte length
+ * of the block’s block type in the source text. For HTML spans this is 0.
+ *
+ * @since 6.9.0
+ *
+ * @see self::$open_blocks_at
+ *
+ * @var int[]
+ */
+ private $open_blocks_length = array();
+
+ /**
+ * Indicates which operation should apply to the stack of open blocks after
+ * processing any pending spans of HTML.
+ *
+ * Since HTML spans are discovered after matching block delimiters, those
+ * delimiters need to defer modifying the stack of open blocks. This value,
+ * if set, indicates what operation should be applied. The properties
+ * associated with token boundaries still point to the delimiters even
+ * when processing HTML spans, so there’s no need to track them independently.
+ *
+ * @var 'push'|'void'|'pop'|null
+ */
+ private $next_stack_op = null;
+
+ /**
+ * Creates a new block processor.
+ *
+ * Example:
+ *
+ * $processor = new WP_Block_Processor( $post_content );
+ * if ( $processor->next_block( 'core/image' ) ) {
+ * echo "Found an image!\n";
+ * }
+ *
+ * @see self::next_block() to advance to the start of the next block (skips closers).
+ * @see self::next_delimiter() to advance to the next explicit block delimiter.
+ * @see self::next_token() to advance to the next block delimiter or HTML span.
+ *
+ * @since 6.9.0
+ *
+ * @param string $source_text Input document potentially containing block content.
+ */
+ public function __construct( string $source_text ) {
+ $this->source_text = $source_text;
+ }
+
+ /**
+ * Advance to the next block delimiter which opens a block, indicating if one was found.
+ *
+ * Delimiters which open blocks include opening and void block delimiters. To visit
+ * freeform HTML content, pass the wildcard “*” as the block type.
+ *
+ * Use this function to walk through the blocks in a document, pausing where they open.
+ *
+ * Example blocks:
+ *
+ * // The first delimiter opens the paragraph block.
+ * <⃨!⃨-⃨-⃨ ⃨w⃨p⃨:⃨p⃨a⃨r⃨a⃨g⃨r⃨a⃨p⃨h⃨ ⃨-⃨-⃨>⃨Content
+ *
+ * // The void block is the first opener in this sequence of closers.
+ * <⃨!⃨-⃨-⃨ ⃨w⃨p⃨:⃨s⃨p⃨a⃨c⃨e⃨r⃨ ⃨{⃨"⃨h⃨e⃨i⃨g⃨h⃨t⃨"⃨:⃨"⃨2⃨0⃨0⃨p⃨x⃨"⃨}⃨ ⃨/⃨-⃨-⃨>⃨
+ *
+ * // If, however, `*` is provided as the block type, freeform content is matched.
+ * <⃨h⃨2⃨>⃨M⃨y⃨ ⃨s⃨y⃨n⃨o⃨p⃨s⃨i⃨s⃨<⃨/⃨h⃨2⃨>⃨\⃨n⃨
+ *
+ * // Inner HTML is never freeform content, and will not be matched even with the wildcard.
+ *
<⃨!⃨-⃨-⃨ ⃨w⃨p⃨:⃨p⃨a⃨r⃨a⃨g⃨r⃨a⃨p⃨h⃨ ⃨-⃨>⃨
+ *
+ * Example:
+ *
+ * // Find all textual ranges of image block opening delimiters.
+ * $images = array();
+ * $processor = new WP_Block_Processor( $html );
+ * while ( $processor->next_block( 'core/image' ) ) {
+ * $images[] = $processor->get_span();
+ * }
+ *
+ * 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 = [];
+ * $block_type = '*';
+ * $processor = new WP_Block_Processor( $html );
+ * while ( $processor->next_block( $block_type ) {
+ * // Stop wasting time visiting freeform blocks after one has been found.
+ * if (
+ * '*' === $block_type &&
+ * null === $processor->get_block_type() &&
+ * $processor->is_non_whitespace_html()
+ * ) {
+ * $block_type = null;
+ * $seen_block_types['core/freeform'] = true;
+ * continue;
+ * }
+ *
+ * $seen_block_types[ $processor->get_block_type() ] = true;
+ * }
+ *
+ * @since 6.9.0
+ *
+ * @see self::next_delimiter() to advance to the next explicit block delimiter.
+ * @see self::next_token() to advance to the next block delimiter or HTML span.
+ *
+ * @param string|null $block_type Optional. If provided, advance until a block of this type is found.
+ * Default is to stop at any block regardless of its type.
+ * @return bool Whether an opening delimiter for a block was found.
+ */
+ public function next_block( ?string $block_type = null ): bool {
+ while ( $this->next_delimiter( $block_type ) ) {
+ if ( self::CLOSER !== $this->get_delimiter_type() ) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Advance to the next block delimiter in a document, indicating if one was found.
+ *
+ * Delimiters may include invalid JSON. This parser does not attempt to parse the
+ * JSON attributes until requested; when invalid, the attributes will be null. This
+ * matches the behavior of {@see \parse_blocks()}. To visit freeform HTML content,
+ * pass the wildcard “*” as the block type.
+ *
+ * Use this function to walk through the block delimiters in a document.
+ *
+ * Example delimiters:
+ *
+ *
+ *
+ *
+ *
+ * // If the wildcard `*` is provided as the block type, freeform content is matched.
+ * <⃨h⃨2⃨>⃨M⃨y⃨ ⃨s⃨y⃨n⃨o⃨p⃨s⃨i⃨s⃨<⃨/⃨h⃨2⃨>⃨\⃨n⃨
+ *
+ * // Inner HTML is never freeform content, and will not be matched even with the wildcard.
+ * ...<⃨!⃨-⃨-⃨ ⃨/⃨w⃨p⃨:⃨l⃨i⃨s⃨t⃨ ⃨-⃨-⃨>⃨
+ *
+ * Example:
+ *
+ * $html = '\n';
+ * $processor = new WP_Block_Processor( $html );
+ * while ( $processor->next_delimiter() {
+ * // Runs twice, seeing both void blocks of type “core/void.”
+ * }
+ *
+ * $processor = new WP_Block_Processor( $html );
+ * while ( $processor->next_delimiter( '*' ) ) {
+ * // Runs thrice, seeing the void block, the newline span, and the void block.
+ * }
+ *
+ * @since 6.9.0
+ *
+ * @param string|null $block_name Optional. Keep searching until a block of this name is found.
+ * Defaults to visit every block regardless of type.
+ * @return bool Whether a block delimiter was matched.
+ */
+ public function next_delimiter( ?string $block_name = null ): bool {
+ if ( ! isset( $block_name ) ) {
+ while ( $this->next_token() ) {
+ if ( ! $this->is_html() ) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ while ( $this->next_token() ) {
+ if ( $this->is_block_type( $block_name ) ) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Advance to the next block delimiter or HTML span in a document, indicating if one was found.
+ *
+ * This function steps through every syntactic chunk in a document. This includes explicit
+ * block comment delimiters, freeform non-block content, and inner HTML segments.
+ *
+ * Example tokens:
+ *
+ *
+ *
+ *
+ *
Normal HTML content
+ * Plaintext content too!
+ *
+ * Example:
+ *
+ * // Find span containing wrapping HTML element surrounding inner blocks.
+ * $processor = new WP_Block_Processor( $html );
+ * if ( ! $processor->next_block( 'gallery' ) ) {
+ * return null;
+ * }
+ *
+ * $containing_span = null;
+ * while ( $processor->next_token() && $processor->is_html() ) {
+ * $containing_span = $processor->get_span();
+ * }
+ *
+ * This method will visit all HTML spans including those forming freeform non-block
+ * content as well as those which are part of a block’s inner HTML.
+ *
+ * @since 6.9.0
+ *
+ * @return bool Whether a token was matched or the end of the document was reached without finding any.
+ */
+ public function next_token(): bool {
+ if ( $this->last_error || self::COMPLETE === $this->state || self::INCOMPLETE_INPUT === $this->state ) {
+ return false;
+ }
+
+ // Void tokens automatically pop off the stack of open blocks.
+ if ( $this->was_void ) {
+ array_pop( $this->open_blocks_at );
+ array_pop( $this->open_blocks_length );
+ $this->was_void = false;
+ }
+
+ $text = $this->source_text;
+ $end = strlen( $text );
+
+ /*
+ * Because HTML spans are inferred after finding the next delimiter, it means that
+ * the parser must transition out of that HTML state and reuse the token boundaries
+ * it found after the HTML span. If those boundaries are before the end of the
+ * document it implies that a real delimiter was found; otherwise this must be the
+ * terminating HTML span and the parsing is complete.
+ */
+ if ( self::HTML_SPAN === $this->state ) {
+ if ( $this->matched_delimiter_at >= $end ) {
+ $this->state = self::COMPLETE;
+ return false;
+ }
+
+ switch ( $this->next_stack_op ) {
+ case 'void':
+ $this->was_void = true;
+ $this->open_blocks_at[] = $this->namespace_at;
+ $this->open_blocks_length[] = $this->name_at + $this->name_length - $this->namespace_at;
+ break;
+
+ case 'push':
+ $this->open_blocks_at[] = $this->namespace_at;
+ $this->open_blocks_length[] = $this->name_at + $this->name_length - $this->namespace_at;
+ break;
+
+ case 'pop':
+ array_pop( $this->open_blocks_at );
+ array_pop( $this->open_blocks_length );
+ break;
+ }
+
+ $this->next_stack_op = null;
+ $this->state = self::MATCHED;
+ return true;
+ }
+
+ $this->state = self::READY;
+ $after_prev_delimiter = $this->matched_delimiter_at + $this->matched_delimiter_length;
+ $at = $after_prev_delimiter;
+
+ while ( $at < $end ) {
+ /*
+ * Find the next possible start of a delimiter.
+ *
+ * This follows the behavior in the official block parser, which segments a post
+ * by the block comment delimiters. It is possible for an HTML attribute to contain
+ * what looks like a block comment delimiter but which is actually an HTML attribute
+ * value. In such a case, the parser here will break apart the HTML and create the
+ * block boundary inside the HTML attribute. In other words, the block parser
+ * isolates sections of HTML from each other, even if that leads to malformed markup.
+ *
+ * For a more robust parse, scan through the document with the HTML API and parse
+ * comments once they are matched to see if they are also block delimiters. In
+ * practice, this nuance has not caused any known problems since developing blocks.
+ *
+ * <⃨!⃨-⃨-⃨ /wp:core/paragraph {"dropCap":true} /-->
+ */
+ $comment_opening_at = strpos( $text, '
+ $opening_whitespace_at = $comment_opening_at + 4;
+ if ( $opening_whitespace_at >= $end ) {
+ goto incomplete;
+ }
+
+ $opening_whitespace_length = strspn( $text, " \t\f\r\n", $opening_whitespace_at );
+
+ /*
+ * The `wp` prefix cannot come before this point, but it may come after it
+ * depending on the presence of the closer. This is detected next.
+ */
+ $wp_prefix_at = $opening_whitespace_at + $opening_whitespace_length;
+ if ( $wp_prefix_at >= $end ) {
+ goto incomplete;
+ }
+
+ if ( 0 === $opening_whitespace_length ) {
+ $at = $this->find_html_comment_end( $comment_opening_at, $end );
+ continue;
+ }
+
+ //
+ $has_closer = false;
+ if ( '/' === $text[ $wp_prefix_at ] ) {
+ $has_closer = true;
+ ++$wp_prefix_at;
+ }
+
+ //
+ if ( $wp_prefix_at < $end && 0 !== substr_compare( $text, 'wp:', $wp_prefix_at, 3 ) ) {
+ if (
+ ( $wp_prefix_at + 2 >= $end && str_ends_with( $text, 'wp' ) ) ||
+ ( $wp_prefix_at + 1 >= $end && str_ends_with( $text, 'w' ) )
+ ) {
+ goto incomplete;
+ }
+
+ $at = $this->find_html_comment_end( $comment_opening_at, $end );
+ continue;
+ }
+
+ /*
+ * If the block contains no namespace, this will end up masquerading with
+ * the block name. It’s easier to first detect the span and then determine
+ * if it’s a namespace of a name.
+ *
+ *
+ */
+ $namespace_at = $wp_prefix_at + 3;
+ if ( $namespace_at >= $end ) {
+ goto incomplete;
+ }
+
+ $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 ) {
+ goto incomplete;
+ }
+
+ //
+ $has_separator = '/' === $text[ $separator_at ];
+ if ( $has_separator ) {
+ $name_at = $separator_at + 1;
+
+ if ( $name_at >= $end ) {
+ goto incomplete;
+ }
+
+ //
+ $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;
+ }
+
+ if ( $name_at + $name_length >= $end ) {
+ goto incomplete;
+ }
+
+ /*
+ * For this next section of the delimiter, it could be the JSON attributes
+ * or it could be the end of the comment. Assume that the JSON is there and
+ * update if it’s not.
+ */
+
+ //
+ $after_name_whitespace_at = $name_at + $name_length;
+ $after_name_whitespace_length = strspn( $text, " \t\f\r\n", $after_name_whitespace_at );
+ $json_at = $after_name_whitespace_at + $after_name_whitespace_length;
+
+ if ( $json_at >= $end ) {
+ goto incomplete;
+ }
+
+ if ( 0 === $after_name_whitespace_length ) {
+ $at = $this->find_html_comment_end( $comment_opening_at, $end );
+ continue;
+ }
+
+ //
+ $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 is not allowed to contain the HTML comment terminator.
+ *
+ * This also matches the behavior in the official block parser,
+ * even though it allows for matching invalid JSON content.
+ *
+ * ', $json_at );
+ if ( false === $comment_closing_at ) {
+ goto incomplete;
+ }
+
+ //
+ 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.
+ * Otherwise it’s a normal HTML comment.
+ */
+ if ( ! $has_json ) {
+ if ( $after_name_whitespace_at + $after_name_whitespace_length === $comment_closing_at - $void_flag_length ) {
+ // This must be a block delimiter!
+ $this->state = self::MATCHED;
+ break;
+ }
+
+ $at = $this->find_html_comment_end( $comment_opening_at, $end );
+ continue;
+ }
+
+ /*
+ * There's JSON, so attempt to find its boundary.
+ *
+ * @todo It’s likely faster to scan forward instead of in reverse.
+ *
+ *
+ */
+ $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;
+ }
+ }
+
+ /*
+ * This covers cases where there is no terminating “}” or where
+ * mandatory whitespace is missing.
+ */
+ 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!
+ $this->state = self::MATCHED;
+ break;
+ }
+
+ // The end of the document was reached without a match.
+ if ( self::MATCHED !== $this->state ) {
+ $this->state = self::COMPLETE;
+ return false;
+ }
+
+ /*
+ * From this point forward, a delimiter has been matched. There
+ * might also be an HTML span that appears before the delimiter.
+ */
+
+ $this->after_previous_delimiter = $after_prev_delimiter;
+
+ $this->matched_delimiter_at = $comment_opening_at;
+ $this->matched_delimiter_length = $comment_closing_at + 3 - $comment_opening_at;
+
+ $this->namespace_at = $namespace_at;
+ $this->name_at = $name_at;
+ $this->name_length = $name_length;
+
+ $this->json_at = $json_at;
+ $this->json_length = $json_length;
+
+ /*
+ * When delimiters contain both the void flag and the closing flag
+ * they shall be interpreted as void blocks, per the spec parser.
+ */
+ if ( $has_void_flag ) {
+ $this->type = self::VOID;
+ $this->next_stack_op = 'void';
+ } elseif ( $has_closer ) {
+ $this->type = self::CLOSER;
+ $this->next_stack_op = 'pop';
+
+ /*
+ * @todo Check if the name matches and bail according to the spec parser.
+ * The default parser doesn’t examine the names.
+ */
+ } else {
+ $this->type = self::OPENER;
+ $this->next_stack_op = 'push';
+ }
+
+ $this->has_closing_flag = $has_closer;
+
+ // HTML spans are visited before the delimiter that follows them.
+ if ( $comment_opening_at > $after_prev_delimiter ) {
+ $this->state = self::HTML_SPAN;
+ $this->open_blocks_at[] = $after_prev_delimiter;
+ $this->open_blocks_length[] = 0;
+ $this->was_void = true;
+
+ return true;
+ }
+
+ // If there were no HTML spans then flush the enqueued stack operations immediately.
+ switch ( $this->next_stack_op ) {
+ case 'void':
+ $this->was_void = true;
+ $this->open_blocks_at[] = $namespace_at;
+ $this->open_blocks_length[] = $name_at + $name_length - $namespace_at;
+ break;
+
+ case 'push':
+ $this->open_blocks_at[] = $namespace_at;
+ $this->open_blocks_length[] = $name_at + $name_length - $namespace_at;
+ break;
+
+ case 'pop':
+ array_pop( $this->open_blocks_at );
+ array_pop( $this->open_blocks_length );
+ break;
+ }
+
+ $this->next_stack_op = null;
+
+ return true;
+
+ incomplete:
+ $this->state = self::COMPLETE;
+ $this->last_error = self::INCOMPLETE_INPUT;
+ return false;
+ }
+
+ /**
+ * Returns an array containing the names of the currently-open blocks, in order
+ * from outermost to innermost, with HTML spans indicated as “#html”.
+ *
+ * Example:
+ *
+ * // Freeform HTML content is an HTML span.
+ * $processor = new WP_Block_Processor( 'Just text' );
+ * $processor->next_token();
+ * array( '#text' ) === $processor->get_breadcrumbs();
+ *
+ * $processor = new WP_Block_Processor( '' );
+ * $processor->next_token();
+ * array( 'core/a' ) === $processor->get_breadcrumbs();
+ * $processor->next_token();
+ * array( 'core/a', 'core/b' ) === $processor->get_breadcrumbs();
+ * $processor->next_token();
+ * // Void blocks are only open while visiting them.
+ * array( 'core/a', 'core/b', 'core/c' ) === $processor->get_breadcrumbs();
+ * $processor->next_token();
+ * // Blocks are closed before visiting their closing delimiter.
+ * array( 'core/a' ) === $processor->get_breadcrumbs();
+ * $processor->next_token();
+ * array() === $processor->get_breadcrumbs();
+ *
+ * // Inner HTML is also an HTML span.
+ * $processor = new WP_Block_Processor( 'Inner HTML' );
+ * $processor->next_token();
+ * $processor->next_token();
+ * array( 'core/a', '#html' ) === $processor->get_breadcrumbs();
+ *
+ * @since 6.9.0
+ *
+ * @return string[]
+ */
+ public function get_breadcrumbs(): array {
+ $breadcrumbs = array_fill( 0, count( $this->open_blocks_at ), null );
+
+ /*
+ * Since HTML spans can only be at the very end, set the normalized block name for
+ * each open element and then work backwards after creating the array. This allows
+ * for the elimination of a conditional on each iteration of the loop.
+ */
+ foreach ( $this->open_blocks_at as $i => $at ) {
+ $block_type = substr( $this->source_text, $at, $this->open_blocks_length[ $i ] );
+ $breadcrumbs[ $i ] = self::normalize_block_type( $block_type );
+ }
+
+ if ( isset( $i ) && 0 === $this->open_blocks_length[ $i ] ) {
+ $breadcrumbs[ $i ] = '#html';
+ }
+
+ return $breadcrumbs;
+ }
+
+ /**
+ * Returns the depth of the open blocks where the processor is currently matched.
+ *
+ * Depth increases before visiting openers and void blocks and decreases before
+ * visiting closers. HTML spans behave like void blocks.
+ *
+ * @since 6.9.0
+ *
+ * @return int
+ */
+ public function get_depth(): int {
+ return count( $this->open_blocks_at );
+ }
+
+ /**
+ * Extracts a block object, and all inner content, starting at a matched opening
+ * block delimiter, or at a matched top-level HTML span as freeform HTML content.
+ *
+ * Use this function to extract some blocks within a document, but not all. For example,
+ * one might want to find image galleries, parse them, modify them, and then reserialize
+ * them in place.
+ *
+ * Once this function returns, the parser will be matched on token following the close
+ * of the given block.
+ *
+ * The return type of this method is compatible with the return of {@see \parse_blocks()}.
+ *
+ * Example:
+ *
+ * $processor = new WP_Block_Processor( $post_content );
+ * if ( ! $processor->next_block( 'gallery' ) ) {
+ * return $post_content;
+ * }
+ *
+ * $gallery_at = $processor->get_span()->start;
+ * $gallery = $processor->extract_full_block_and_advance();
+ * $ends_before = $processor->get_span();
+ * $ends_before = $ends_before->start ?? strlen( $post_content );
+ *
+ * $new_gallery = update_gallery( $gallery );
+ * $new_gallery = serialize_block( $new_gallery );
+ *
+ * return (
+ * substr( $post_content, 0, $gallery_at ) .
+ * $new_gallery .
+ * substr( $post_content, $ends_before )
+ * );
+ *
+ * @since 6.9.0
+ *
+ * @return array[]|null {
+ * Array of block structures.
+ *
+ * @type array ...$0 {
+ * An associative array of a single parsed block object. See WP_Block_Parser_Block.
+ *
+ * @type string|null $blockName Name of block.
+ * @type array $attrs Attributes from block comment delimiters.
+ * @type array[] $innerBlocks List of inner blocks. An array of arrays that
+ * have the same structure as this one.
+ * @type string $innerHTML HTML from inside block comment delimiters.
+ * @type array $innerContent List of string fragments and null markers where
+ * inner blocks were found.
+ * }
+ * }
+ */
+ public function extract_full_block_and_advance(): ?array {
+ if ( $this->is_html() ) {
+ $chunk = $this->get_html_content();
+
+ return array(
+ 'blockName' => null,
+ 'attrs' => array(),
+ 'innerBlocks' => array(),
+ 'innerHTML' => $chunk,
+ 'innerContent' => array( $chunk ),
+ );
+ }
+
+ $block = array(
+ 'blockName' => $this->get_block_type(),
+ 'attrs' => $this->allocate_and_return_parsed_attributes() ?? array(),
+ 'innerBlocks' => array(),
+ 'innerHTML' => '',
+ 'innerContent' => array(),
+ );
+
+ $depth = $this->get_depth();
+ while ( $this->next_token() && $this->get_depth() > $depth ) {
+ if ( $this->is_html() ) {
+ $chunk = $this->get_html_content();
+ $block['innerHTML'] .= $chunk;
+ $block['innerContent'][] = $chunk;
+ continue;
+ }
+
+ /**
+ * Inner blocks.
+ *
+ * @todo This is a decent place to call {@link \render_block()}
+ * @todo Use iteration instead of recursion, or at least refactor to tail-call form.
+ */
+ if ( $this->opens_block() ) {
+ $inner_block = $this->extract_full_block_and_advance();
+ $block['innerBlocks'][] = $inner_block;
+ $block['innerContent'][] = null;
+ }
+ }
+
+ return $block;
+ }
+
+ /**
+ * Returns the byte-offset after the ending character of an HTML comment,
+ * assuming the proper starting byte offset.
+ *
+ * @since 6.9.0
+ *
+ * @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 `$search_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 ) {
+ 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.
+ *
+ * @since 6.9.0
+ *
+ * @return string|null Error from last attempt at parsing next block delimiter,
+ * or `null` if last attempt succeeded.
+ */
+ public function get_last_error(): ?string {
+ return $this->last_error;
+ }
+
+ /**
+ * Indicates if the last attempt to parse a block’s JSON attributes failed.
+ *
+ * @see \json_last_error()
+ *
+ * @since 6.9.0
+ *
+ * @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:
+ *
+ * - {@see self::OPENER}
+ * - {@see self::CLOSER}
+ * - {@see self::VOID}
+ * - `null`
+ *
+ * @since 6.9.0
+ *
+ * @return string|null type of the block comment delimiter, if currently matched.
+ */
+ public function get_delimiter_type(): ?string {
+ switch ( $this->state ) {
+ case self::HTML_SPAN:
+ return self::VOID;
+
+ case self::MATCHED:
+ return $this->type;
+
+ default:
+ return null;
+ }
+ }
+
+ /**
+ * Returns whether the delimiter contains the closing flag.
+ *
+ * This should be avoided except in cases of custom error-handling
+ * with block closers containing the void flag. For normative use,
+ * {@see self::get_delimiter_type()}.
+ *
+ * @since 6.9.0
+ *
+ * @return bool Whether the currently-matched block delimiter contains the closing flag.
+ */
+ public function has_closing_flag(): bool {
+ return $this->has_closing_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.
+ *
+ * Since freeform HTML content is non-block content, it has no block type.
+ * Passing the wildcard “*” will, however, return true for all block types,
+ * even the implicit freeform content, though not for spans of inner HTML.
+ *
+ * Example:
+ *
+ * $is_core_paragraph = $processor->is_block_type( 'paragraph' );
+ * $is_core_paragraph = $processor->is_block_type( 'core/paragraph' );
+ * $is_formula = $processor->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 {
+ if ( '*' === $block_type ) {
+ return true;
+ }
+
+ // This is a core/freeform text block, it’s special.
+ if ( $this->is_html() && 0 === ( $this->open_blocks_length[0] ?? null ) ) {
+ return (
+ 'core/freeform' === $block_type ||
+ 'freeform' === $block_type
+ );
+ }
+
+ return $this->are_equal_block_types( $this->source_text, $this->namespace_at, $this->name_at - $this->namespace_at + $this->name_length, $block_type, 0, strlen( $block_type ) );
+ }
+
+ /**
+ * Given two spans of text, indicate if they represent identical block types.
+ *
+ * This function normalizes block types to account for implicit core namespacing.
+ *
+ * Note! This function only returns valid results when the complete block types are
+ * represented in the span offsets and lengths. This means that the full optional
+ * namespace and block name must be represented in the input arguments.
+ *
+ * Example:
+ *
+ * 0 5 10 15 20 25 30 35 40
+ * $text = '';
+ *
+ * true === WP_Block_Processor::are_equal_block_types( $text, 9, 5, $text, 27, 10 );
+ * false === WP_Block_Processor::are_equal_block_types( $text, 9, 5, 'my/block', 0, 8 );
+ *
+ * @since 6.9.0
+ *
+ * @param string $a_text Text in which first block type appears.
+ * @param int $a_at Byte offset into text in which first block type starts.
+ * @param int $a_length Byte length of first block type.
+ * @param string $b_text Text in which second block type appears (may be the same as the first text).
+ * @param int $b_at Byte offset into text in which second block type starts.
+ * @param int $b_length Byte length of second block type.
+ * @return bool Whether the spans of text represent identical block types, normalized for namespacing.
+ */
+ public static function are_equal_block_types( string $a_text, int $a_at, int $a_length, string $b_text, int $b_at, int $b_length ): bool {
+ $a_ns_length = strcspn( $a_text, '/', $a_at, $a_length );
+ $b_ns_length = strcspn( $b_text, '/', $b_at, $b_length );
+
+ $a_has_ns = $a_ns_length !== $a_length;
+ $b_has_ns = $b_ns_length !== $b_length;
+
+ // Both contain namespaces.
+ if ( $a_has_ns && $b_has_ns ) {
+ if ( $a_length !== $b_length ) {
+ return false;
+ }
+
+ $a_block_type = substr( $a_text, $a_at, $a_length );
+
+ return 0 === substr_compare( $b_text, $a_block_type, $b_at, $b_length );
+ }
+
+ if ( $a_has_ns ) {
+ $b_block_type = 'core/' . substr( $b_text, $b_at, $b_length );
+
+ return (
+ strlen( $b_block_type ) === $a_length &&
+ 0 === substr_compare( $a_text, $b_block_type, $a_at, $a_length )
+ );
+ }
+
+ if ( $b_has_ns ) {
+ $a_block_type = 'core/' . substr( $a_text, $a_at, $a_length );
+
+ return (
+ strlen( $a_block_type ) === $b_length &&
+ 0 === substr_compare( $b_text, $a_block_type, $b_at, $b_length )
+ );
+ }
+
+ // Neither contains a namespace.
+ if ( $a_length !== $b_length ) {
+ return false;
+ }
+
+ $a_name = substr( $a_text, $a_at, $a_length );
+
+ return 0 === substr_compare( $b_text, $a_name, $b_at, $b_length );
+ }
+
+ /**
+ * Indicates if the matched delimiter is an opening or void delimiter of the given type,
+ * if a type is provided, otherwise if it opens any block or implicit freeform HTML content.
+ *
+ * This is a helper method to ease handling of code inspecting where blocks start, and for
+ * 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.
+ *
+ * To advance to the start of a block {@see self::next_block()}.
+ *
+ * Example:
+ *
+ * $processor = new WP_Block_Processor( $html );
+ * while ( $processor->next_delimiter() ) {
+ * if ( $processor->opens_block( 'core/code', 'syntaxhighlighter/code' ) ) {
+ * echo "Found code!";
+ * continue;
+ * }
+ *
+ * if ( $processor->opens_block( 'core/image' ) ) {
+ * echo "Found an image!";
+ * continue;
+ * }
+ *
+ * if ( $processor->opens_block() ) {
+ * echo "Found a new block!";
+ * }
+ * }
+ *
+ * @since 6.9.0
+ *
+ * @see self::is_block_type()
+ *
+ * @param string[] $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( string ...$block_type ): bool {
+ // HTML spans only open implicit freeform content at the top level.
+ if ( self::HTML_SPAN === $this->state && 1 !== count( $this->open_blocks_at ) ) {
+ return false;
+ }
+
+ /*
+ * Because HTML spans are discovered after the next delimiter is found,
+ * the delimiter type when visiting HTML spans refers to the type of the
+ * following delimiter. Therefore the HTML case is handled by checking
+ * the state and depth of the stack of open block.
+ */
+ if ( self::CLOSER === $this->type && ! $this->is_html() ) {
+ 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 an HTML span.
+ *
+ * @since 6.9.0
+ *
+ * @see self::is_non_whitespace_html()
+ *
+ * @return bool Whether the processor is matched on an HTML span.
+ */
+ public function is_html(): bool {
+ return self::HTML_SPAN === $this->state;
+ }
+
+ /**
+ * Indicates if the matched delimiter is an HTML span and comprises more
+ * than whitespace characters, i.e. contains real content.
+ *
+ * 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.
+ *
+ * @since 6.9.0
+ *
+ * @see self::is_html()
+ *
+ * @return bool Whether the currently-matched delimiter is an HTML
+ * span containing non-whitespace text.
+ */
+ public function is_non_whitespace_html(): bool {
+ if ( ! $this->is_html() ) {
+ return false;
+ }
+
+ $length = $this->matched_delimiter_at - $this->after_previous_delimiter;
+
+ $whitespace_length = strspn(
+ $this->source_text,
+ " \t\f\r\n",
+ $this->after_previous_delimiter,
+ $length
+ );
+
+ return $whitespace_length !== $length;
+ }
+
+ /**
+ * Returns the string content of a matched HTML span, or `null` otherwise.
+ *
+ * @since 6.9.0
+ *
+ * @return string|null Raw HTML content, or `null` if not currently matched on HTML.
+ */
+ public function get_html_content(): ?string {
+ if ( ! $this->is_html() ) {
+ return null;
+ }
+
+ return substr(
+ $this->source_text,
+ $this->after_previous_delimiter,
+ $this->matched_delimiter_at - $this->after_previous_delimiter
+ );
+ }
+
+ /**
+ * Allocates a substring for the block type and returns the fully-qualified
+ * name, including the namespace, if matched on a delimiter, otherwise `null`.
+ *
+ * This function is like {@see self::get_printable_block_type()} but when
+ * paused on a freeform HTML block, will return `null` instead of “core/freeform”.
+ * The `null` behavior matches what {@see \parse_blocks()} returns but may not
+ * be as useful as having a string value.
+ *
+ * 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 {@see 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' = $processor->get_block_type();
+ *
+ * // Prefer.
+ * $processor->is_block_type( 'core/paragraph' );
+ * $processor->is_block_type( 'paragraph' );
+ * $processor->is_block_type( 'core/freeform' );
+ *
+ * // Freeform HTML content has no block type.
+ * $processor = new WP_Block_Processor( 'non-block content' );
+ * $processor->next_token();
+ * null === $processor->get_block_type();
+ *
+ * @since 6.9.0
+ *
+ * @see self::are_equal_block_types()
+ *
+ * @return string|null Fully-qualified block namespace and type, e.g. "core/paragraph",
+ * if matched on an explicit delimiter, otherwise `null`.
+ */
+ public function get_block_type(): ?string {
+ if (
+ self::READY === $this->state ||
+ self::COMPLETE === $this->state ||
+ self::INCOMPLETE_INPUT === $this->state
+ ) {
+ return null;
+ }
+
+ // This is a core/freeform text block, it’s special.
+ if ( $this->is_html() ) {
+ return null;
+ }
+
+ $block_type = substr( $this->source_text, $this->namespace_at, $this->name_at - $this->namespace_at + $this->name_length );
+ return self::normalize_block_type( $block_type );
+ }
+
+ /**
+ * Allocates a printable substring for the block type and returns the fully-qualified
+ * name, including the namespace, if matched on a delimiter or freeform block, otherwise `null`.
+ *
+ * This function is like {@see self::get_block_type()} but when paused on a freeform
+ * HTML block, will return “core/freeform” instead of `null`. The `null` behavior matches
+ * what {@see \parse_blocks()} returns but may not be as useful as having a string value.
+ *
+ * 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 {@see 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' = $processor->get_printable_block_type();
+ *
+ * // Prefer.
+ * $processor->is_block_type( 'core/paragraph' );
+ * $processor->is_block_type( 'paragraph' );
+ * $processor->is_block_type( 'core/freeform' );
+ *
+ * // Freeform HTML content is given an implicit type.
+ * $processor = new WP_Block_Processor( 'non-block content' );
+ * $processor->next_token();
+ * 'core/freeform' === $processor->get_printable_block_type();
+ *
+ * @since 6.9.0
+ *
+ * @see self::are_equal_block_types()
+ *
+ * @return string|null Fully-qualified block namespace and type, e.g. "core/paragraph",
+ * if matched on an explicit delimiter or freeform block, otherwise `null`.
+ */
+ public function get_printable_block_type(): ?string {
+ if (
+ self::READY === $this->state ||
+ self::COMPLETE === $this->state ||
+ self::INCOMPLETE_INPUT === $this->state
+ ) {
+ return null;
+ }
+
+ // This is a core/freeform text block, it’s special.
+ if ( $this->is_html() ) {
+ return 1 === count( $this->open_blocks_at )
+ ? 'core/freeform'
+ : '#innerHTML';
+ }
+
+ $block_type = substr( $this->source_text, $this->namespace_at, $this->name_at - $this->namespace_at + $this->name_length );
+ return self::normalize_block_type( $block_type );
+ }
+
+ /**
+ * Normalizes a block name to ensure that missing implicit “core” namespaces are present.
+ *
+ * Example:
+ *
+ * 'core/paragraph' === WP_Block_Processor::normalize_block_byte( 'paragraph' );
+ * 'core/paragraph' === WP_Block_Processor::normalize_block_byte( 'core/paragraph' );
+ * 'my/paragraph' === WP_Block_Processor::normalize_block_byte( 'my/paragraph' );
+ *
+ * @since 6.9.0
+ *
+ * @param string $block_type Valid block name, potentially without a namespace.
+ * @return string Fully-qualified block type including namespace.
+ */
+ public static function normalize_block_type( string $block_type ): string {
+ return false === strpos( $block_type, '/' )
+ ? "core/{$block_type}"
+ : $block_type;
+ }
+
+ /**
+ * Returns a lazy wrapper around the block attributes, which can be used
+ * for efficiently interacting with the JSON attributes.
+ *
+ * This stub hints that there should be a lazy interface for parsing
+ * block attributes but doesn’t define it. It serves both as a placeholder
+ * for one to come as well as a guard against implementing an eager
+ * function in its place.
+ *
+ * @throws Exception This function is a stub for subclasses to implement
+ * when providing streaming attribute parsing.
+ *
+ * @since 6.9.0
+ *
+ * @see self::allocate_and_return_parsed_attributes()
+ *
+ * @return never
+ */
+ public function get_attributes() {
+ 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 {@see 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:
+ *
+ * $processor = new WP_Block_Processor( '' );
+ * $processor->next_delimiter();
+ * $memory_hungry_and_slow_attributes = $processor->allocate_and_return_parsed_attributes();
+ * $memory_hungry_and_slow_attributes === array( 'url' => 'https://wordpress.org/favicon.ico' );
+ *
+ * $processor = new WP_Block_Processor( '' );
+ * $processor->next_delimiter();
+ * null = $processor->allocate_and_return_parsed_attributes();
+ * JSON_ERROR_NONE = $processor->get_last_json_error();
+ *
+ * $processor = new WP_Block_Processor( '' );
+ * $processor->next_delimiter();
+ * array() === $processor->allocate_and_return_parsed_attributes();
+ *
+ * $processor = new WP_Block_Processor( '' );
+ * $processor->next_delimiter();
+ * null = $processor->allocate_and_return_parsed_attributes();
+ *
+ * $processor = new WP_Block_Processor( '' );
+ * $processor->next_delimiter();
+ * null = $processor->allocate_and_return_parsed_attributes();
+ * JSON_ERROR_CTRL_CHAR = $processor->get_last_json_error();
+ *
+ * @since 6.9.0
+ *
+ * @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 ( self::CLOSER === $this->type || $this->is_html() || 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`.
+ *
+ * Example:
+ *
+ * $processor = new WP_Block_Processor( '' );
+ * null === $processor->get_span();
+ *
+ * $processor->next_delimiter();
+ * WP_HTML_Span( 0, 17 ) === $processor->get_span();
+ *
+ * @since 6.9.0
+ *
+ * @return WP_HTML_Span|null Span of text in source text spanning matched delimiter.
+ */
+ public function get_span(): ?WP_HTML_Span {
+ switch ( $this->state ) {
+ case self::HTML_SPAN:
+ return new WP_HTML_Span( $this->after_previous_delimiter, $this->matched_delimiter_at - $this->after_previous_delimiter );
+
+ case self::MATCHED:
+ return new WP_HTML_Span( $this->matched_delimiter_at, $this->matched_delimiter_length );
+
+ default:
+ return null;
+ }
+ }
+
+ //
+ // Constant declarations that would otherwise pollute the top of the class.
+ //
+
+ /**
+ * Indicates that the block comment delimiter closes an open block.
+ *
+ * @see self::$type
+ *
+ * @since 6.9.0
+ */
+ const CLOSER = 'closer';
+
+ /**
+ * Indicates that the block comment delimiter opens a block.
+ *
+ * @see self::$type
+ *
+ * @since 6.9.0
+ */
+ const OPENER = 'opener';
+
+ /**
+ * Indicates that the block comment delimiter represents a void block
+ * with no inner content of any kind.
+ *
+ * @see self::$type
+ *
+ * @since 6.9.0
+ */
+ const VOID = 'void';
+
+ /**
+ * Indicates that the processor is ready to start parsing but hasn’t yet begun.
+ *
+ * @see self::$state
+ *
+ * @since 6.9.0
+ */
+ const READY = 'processor-ready';
+
+ /**
+ * Indicates that the processor is matched on an explicit block delimiter.
+ *
+ * @see self::$state
+ *
+ * @since 6.9.0
+ */
+ const MATCHED = 'processor-matched';
+
+ /**
+ * Indicates that the processor is matched on the opening of an implicit freeform delimiter.
+ *
+ * @see self::$state
+ *
+ * @since 6.9.0
+ */
+ const HTML_SPAN = 'processor-html-span';
+
+ /**
+ * Indicates that the parser started parsing a block comment delimiter, but
+ * the input document ended before it could finish. The document was likely truncated.
+ *
+ * @see self::$state
+ *
+ * @since 6.9.0
+ */
+ const INCOMPLETE_INPUT = 'incomplete-input';
+
+ /**
+ * Indicates that the processor has finished parsing and has nothing left to scan.
+ *
+ * @see self::$state
+ *
+ * @since 6.9.0
+ */
+ const COMPLETE = 'processor-complete';
+}
diff --git a/src/Compat/WP_HTML_Span.php b/src/Compat/WP_HTML_Span.php
new file mode 100644
index 00000000..08faa3f9
--- /dev/null
+++ b/src/Compat/WP_HTML_Span.php
@@ -0,0 +1,63 @@
+start = $start;
+ $this->length = $length;
+ }
+}
diff --git a/src/Compat/polyfills.php b/src/Compat/polyfills.php
new file mode 100644
index 00000000..caf684c1
--- /dev/null
+++ b/src/Compat/polyfills.php
@@ -0,0 +1,63 @@
+fetcher = new PostFetcher();
+
+ // Register default HTML sync filters once.
+ if ( ! self::$filters_registered ) {
+ Block_HTML_Sync_Filters::register();
+ self::$filters_registered = true;
+ }
+ }
+
+ /**
+ * Gets a single block by index.
+ *
+ * Retrieves the full structure of a block at the specified position.
+ *
+ * ## OPTIONS
+ *
+ *
+ * : The ID of the post.
+ *
+ *
+ * : The block index (0-indexed).
+ *
+ * [--raw]
+ * : Include innerHTML in output.
+ *
+ * [--format=]
+ * : Render output in a particular format.
+ * ---
+ * default: json
+ * options:
+ * - json
+ * - yaml
+ * ---
+ *
+ * ## EXAMPLES
+ *
+ * # Get the first block in a post.
+ * $ wp post block get 123 0
+ * {
+ * "blockName": "core/paragraph",
+ * "attrs": {},
+ * "innerBlocks": []
+ * }
+ *
+ * # Get the third block (index 2) with attributes.
+ * $ wp post block get 123 2
+ * {
+ * "blockName": "core/heading",
+ * "attrs": {
+ * "level": 2
+ * },
+ * "innerBlocks": []
+ * }
+ *
+ * # Get block as YAML format.
+ * $ wp post block get 123 1 --format=yaml
+ * blockName: core/image
+ * attrs:
+ * id: 456
+ * sizeSlug: large
+ * innerBlocks: []
+ *
+ * # Get block with raw HTML content included.
+ * $ wp post block get 123 0 --raw
+ * {
+ * "blockName": "core/paragraph",
+ * "attrs": {},
+ * "innerBlocks": [],
+ * "innerHTML": "Hello World
",
+ * "innerContent": ["Hello World
"]
+ * }
+ *
+ * @subcommand get
+ */
+ public function get( $args, $assoc_args ) {
+ $post = $this->fetcher->get_check( $args[0] );
+ $index = (int) $args[1];
+
+ // Use streaming helper to get block at index.
+ $block = Block_Processor_Helper::get_at_index( $post->post_content, $index );
+
+ if ( null === $block ) {
+ $block_count = Block_Processor_Helper::get_block_count( $post->post_content );
+ WP_CLI::error( "Invalid index: {$index}. Post has {$block_count} block(s) (0-indexed)." );
+ }
+
+ $include_raw = Utils\get_flag_value( $assoc_args, 'raw', false );
+
+ if ( ! $include_raw ) {
+ $block = Block_Processor_Helper::strip_inner_html( [ $block ] )[0];
+ }
+
+ $format = Utils\get_flag_value( $assoc_args, 'format', 'json' );
+
+ if ( 'yaml' === $format ) {
+ echo Spyc::YAMLDump( $block, 2, 0, true );
+ } else {
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode
+ echo json_encode( $block, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ) . "\n";
+ }
+ }
+
+ /**
+ * Updates a block's attributes or content by index.
+ *
+ * Modifies a specific block without changing its type. For blocks where
+ * attributes are reflected in HTML (like heading levels), the HTML is
+ * automatically updated to match the new attributes.
+ *
+ * ## OPTIONS
+ *
+ *
+ * : The ID of the post.
+ *
+ *
+ * : The block index to update (0-indexed).
+ *
+ * [--attrs=]
+ * : Block attributes as JSON. Merges with existing attributes by default.
+ *
+ * [--content=]
+ * : New innerHTML content for the block.
+ *
+ * [--replace-attrs]
+ * : Replace all attributes instead of merging.
+ *
+ * [--porcelain]
+ * : Output just the post ID.
+ *
+ * ## EXAMPLES
+ *
+ * # Change a heading from h2 to h3.
+ * $ wp post block update 123 0 --attrs='{"level":3}'
+ * Success: Updated block at index 0 in post 123.
+ *
+ * # Add alignment to an existing paragraph (merges with existing attrs).
+ * $ wp post block update 123 1 --attrs='{"align":"center"}'
+ * Success: Updated block at index 1 in post 123.
+ *
+ * # Update the text content of a paragraph block.
+ * $ wp post block update 123 2 --content="Updated paragraph text
"
+ * Success: Updated block at index 2 in post 123.
+ *
+ * # Update both attributes and content at once.
+ * $ wp post block update 123 0 --attrs='{"level":2}' --content="New Heading
"
+ * Success: Updated block at index 0 in post 123.
+ *
+ * # Replace all attributes instead of merging (removes existing attrs).
+ * $ wp post block update 123 0 --attrs='{"level":4}' --replace-attrs
+ * Success: Updated block at index 0 in post 123.
+ *
+ * # Get just the post ID for scripting.
+ * $ wp post block update 123 0 --attrs='{"level":2}' --porcelain
+ * 123
+ *
+ * # Use custom HTML sync logic via the wp_cli_post_block_update_html filter.
+ * # Use WP_CLI::add_wp_hook() in a file loaded with --require.
+ * $ wp post block update 123 0 --attrs='{"url":"https://example.com"}' --require=my-sync-filters.php
+ * Success: Updated block at index 0 in post 123.
+ *
+ * @subcommand update
+ */
+ public function update( $args, $assoc_args ) {
+ $post = $this->fetcher->get_check( $args[0] );
+ $index = (int) $args[1];
+ $blocks = parse_blocks( $post->post_content );
+
+ // Filter out empty blocks but keep track of original indices.
+ $filtered_blocks = [];
+ $index_map = [];
+ foreach ( $blocks as $original_idx => $block ) {
+ if ( ! empty( $block['blockName'] ) ) {
+ $index_map[ count( $filtered_blocks ) ] = $original_idx;
+ $filtered_blocks[] = $block;
+ }
+ }
+
+ if ( $index < 0 || $index >= count( $filtered_blocks ) ) {
+ WP_CLI::error( "Invalid index: {$index}. Post has " . count( $filtered_blocks ) . ' block(s) (0-indexed).' );
+ }
+
+ $original_idx = $index_map[ $index ];
+ $block = $blocks[ $original_idx ];
+
+ $attrs_json = Utils\get_flag_value( $assoc_args, 'attrs', null );
+ $content = Utils\get_flag_value( $assoc_args, 'content', null );
+ $replace_attrs = Utils\get_flag_value( $assoc_args, 'replace-attrs', false );
+
+ if ( null === $attrs_json && null === $content ) {
+ WP_CLI::error( 'You must specify either --attrs or --content.' );
+ }
+
+ if ( null !== $attrs_json ) {
+ $new_attrs = json_decode( $attrs_json, true );
+ if ( ! is_array( $new_attrs ) ) {
+ WP_CLI::error( 'Invalid JSON provided for --attrs. Must be a JSON object.' );
+ }
+
+ if ( $replace_attrs ) {
+ $block['attrs'] = $new_attrs;
+ } else {
+ $block['attrs'] = array_merge(
+ is_array( $block['attrs'] ) ? $block['attrs'] : [],
+ $new_attrs
+ );
+ }
+
+ // Update HTML to reflect attribute changes for known block types.
+ $block = $this->sync_html_with_attrs( $block, $new_attrs );
+ }
+
+ if ( null !== $content ) {
+ $block['innerHTML'] = $content;
+ $block['innerContent'] = [ $content ];
+ }
+
+ $blocks[ $original_idx ] = $block;
+
+ // @phpstan-ignore argument.type
+ $new_content = serialize_blocks( $blocks );
+ $result = wp_update_post(
+ [
+ 'ID' => $post->ID,
+ 'post_content' => $new_content,
+ ],
+ true
+ );
+
+ if ( is_wp_error( $result ) ) {
+ WP_CLI::error( $result->get_error_message() );
+ }
+
+ if ( Utils\get_flag_value( $assoc_args, 'porcelain' ) ) {
+ WP_CLI::line( (string) $post->ID );
+ } else {
+ WP_CLI::success( "Updated block at index {$index} in post {$post->ID}." );
+ }
+ }
+
+ /**
+ * Moves a block from one position to another.
+ *
+ * Reorders blocks within the post by moving a block from one index to another.
+ *
+ * ## OPTIONS
+ *
+ *
+ * : The ID of the post.
+ *
+ *
+ * : Current block index (0-indexed).
+ *
+ *
+ * : Target position index (0-indexed).
+ *
+ * [--porcelain]
+ * : Output just the post ID.
+ *
+ * ## EXAMPLES
+ *
+ * # Move the first block to the third position.
+ * $ wp post block move 123 0 2
+ * Success: Moved block from index 0 to index 2 in post 123.
+ *
+ * # Move the last block (index 4) to the beginning.
+ * $ wp post block move 123 4 0
+ * Success: Moved block from index 4 to index 0 in post 123.
+ *
+ * # Move a heading block from position 3 to position 1.
+ * $ wp post block move 123 3 1
+ * Success: Moved block from index 3 to index 1 in post 123.
+ *
+ * # Move block and get post ID for scripting.
+ * $ wp post block move 123 2 0 --porcelain
+ * 123
+ *
+ * @subcommand move
+ */
+ public function move( $args, $assoc_args ) {
+ $post = $this->fetcher->get_check( $args[0] );
+ $from_index = (int) $args[1];
+ $to_index = (int) $args[2];
+ $blocks = parse_blocks( $post->post_content );
+
+ // Filter out empty blocks but keep track of original indices.
+ $filtered_blocks = [];
+ $index_map = [];
+ foreach ( $blocks as $original_idx => $block ) {
+ if ( ! empty( $block['blockName'] ) ) {
+ $index_map[ count( $filtered_blocks ) ] = $original_idx;
+ $filtered_blocks[] = $block;
+ }
+ }
+
+ $block_count = count( $filtered_blocks );
+
+ if ( $from_index < 0 || $from_index >= $block_count ) {
+ WP_CLI::error( "Invalid from-index: {$from_index}. Post has {$block_count} block(s) (0-indexed)." );
+ }
+
+ if ( $to_index < 0 || $to_index >= $block_count ) {
+ WP_CLI::error( "Invalid to-index: {$to_index}. Post has {$block_count} block(s) (0-indexed)." );
+ }
+
+ if ( $from_index === $to_index ) {
+ WP_CLI::warning( 'Source and destination indices are the same. No changes made.' );
+ return;
+ }
+
+ // Work with the actual blocks array (including whitespace).
+ $original_from = (int) $index_map[ $from_index ];
+ $block_to_move = $blocks[ $original_from ];
+
+ // Remove the block from original position.
+ array_splice( $blocks, $original_from, 1 );
+
+ // Recalculate index map after removal.
+ $new_filtered = [];
+ $new_index_map = [];
+ foreach ( $blocks as $idx => $block ) {
+ if ( ! empty( $block['blockName'] ) ) {
+ $new_index_map[ count( $new_filtered ) ] = (int) $idx;
+ $new_filtered[] = $block;
+ }
+ }
+
+ // Calculate the actual insertion position.
+ if ( $to_index >= count( $new_filtered ) ) {
+ // Insert at end.
+ $insert_pos = count( $blocks );
+ } else {
+ $insert_pos = (int) $new_index_map[ $to_index ];
+ }
+
+ // Insert at new position.
+ array_splice( $blocks, $insert_pos, 0, [ $block_to_move ] );
+
+ $new_content = serialize_blocks( $blocks );
+ $result = wp_update_post(
+ [
+ 'ID' => $post->ID,
+ 'post_content' => $new_content,
+ ],
+ true
+ );
+
+ if ( is_wp_error( $result ) ) {
+ WP_CLI::error( $result->get_error_message() );
+ }
+
+ if ( Utils\get_flag_value( $assoc_args, 'porcelain' ) ) {
+ WP_CLI::line( (string) $post->ID );
+ } else {
+ WP_CLI::success( "Moved block from index {$from_index} to index {$to_index} in post {$post->ID}." );
+ }
+ }
+
+ /**
+ * Exports block content to a file.
+ *
+ * Exports blocks from a post to a file for backup or migration.
+ *
+ * ## OPTIONS
+ *
+ *
+ * : The ID of the post to export blocks from.
+ *
+ * [--file=]
+ * : Output file path. If not specified, outputs to STDOUT.
+ *
+ * [--format=]
+ * : Export format.
+ * ---
+ * default: json
+ * options:
+ * - json
+ * - yaml
+ * - html
+ * ---
+ *
+ * [--raw]
+ * : Include innerHTML in JSON/YAML output.
+ *
+ * ## EXAMPLES
+ *
+ * # Export blocks to a JSON file for backup.
+ * $ wp post block export 123 --file=blocks.json
+ * Success: Exported 5 blocks to blocks.json
+ *
+ * # Export blocks to STDOUT as JSON.
+ * $ wp post block export 123
+ * {
+ * "version": "1.0",
+ * "generator": "wp-cli/entity-command",
+ * "post_id": 123,
+ * "exported_at": "2024-12-10T12:00:00+00:00",
+ * "blocks": [...]
+ * }
+ *
+ * # Export as YAML format.
+ * $ wp post block export 123 --format=yaml
+ * version: "1.0"
+ * generator: wp-cli/entity-command
+ * blocks:
+ * - blockName: core/paragraph
+ * attrs: []
+ *
+ * # Export rendered HTML (final output, not block structure).
+ * $ wp post block export 123 --format=html --file=content.html
+ * Success: Exported 5 blocks to content.html
+ *
+ * # Export with raw innerHTML included for complete backup.
+ * $ wp post block export 123 --raw --file=blocks-full.json
+ * Success: Exported 5 blocks to blocks-full.json
+ *
+ * # Pipe export to another command.
+ * $ wp post block export 123 | jq '.blocks[].blockName'
+ *
+ * @subcommand export
+ */
+ public function export( $args, $assoc_args ) {
+ $post = $this->fetcher->get_check( $args[0] );
+ $file = Utils\get_flag_value( $assoc_args, 'file', null );
+ $format = Utils\get_flag_value( $assoc_args, 'format', 'json' );
+ $include_raw = Utils\get_flag_value( $assoc_args, 'raw', false );
+
+ $blocks = parse_blocks( $post->post_content );
+
+ // Filter out empty blocks.
+ $blocks = array_values(
+ array_filter(
+ $blocks,
+ function ( $block ) {
+ return ! empty( $block['blockName'] );
+ }
+ )
+ );
+
+ $block_count = count( $blocks );
+
+ if ( 'html' === $format ) {
+ $output = '';
+ foreach ( $blocks as $block ) {
+ $output .= render_block( $block );
+ }
+ } else {
+ if ( ! $include_raw ) {
+ $blocks = $this->strip_inner_html( $blocks );
+ }
+
+ $export_data = [
+ 'version' => '1.0',
+ 'generator' => 'wp-cli/entity-command',
+ 'post_id' => $post->ID,
+ 'exported_at' => gmdate( 'c' ),
+ 'blocks' => $blocks,
+ ];
+
+ if ( 'yaml' === $format ) {
+ $output = Spyc::YAMLDump( $export_data, 2, 0, true );
+ } else {
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode
+ $output = json_encode( $export_data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ) . "\n";
+ }
+ }
+
+ if ( null !== $file ) {
+ $dir = dirname( $file );
+ if ( ! empty( $dir ) && ! is_dir( $dir ) ) {
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_mkdir
+ if ( ! mkdir( $dir, 0755, true ) ) {
+ WP_CLI::error( "Could not create directory: {$dir}" );
+ }
+ }
+
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
+ $result = file_put_contents( $file, $output );
+ if ( false === $result ) {
+ WP_CLI::error( "Could not write to file: {$file}" );
+ }
+
+ $block_word = 1 === $block_count ? 'block' : 'blocks';
+ WP_CLI::success( "Exported {$block_count} {$block_word} to {$file}" );
+ } else {
+ echo $output;
+ }
+ }
+
+ /**
+ * Imports blocks from a file into a post.
+ *
+ * Imports blocks from a JSON or YAML file into a post's content.
+ *
+ * ## OPTIONS
+ *
+ *
+ * : The ID of the post to import blocks into.
+ *
+ * [--file=]
+ * : Input file path. If not specified, reads from STDIN.
+ *
+ * [--position=]
+ * : Where to insert imported blocks. Accepts 'start', 'end', or a numeric index.
+ * ---
+ * default: end
+ * ---
+ *
+ * [--replace]
+ * : Replace all existing blocks instead of appending.
+ *
+ * [--porcelain]
+ * : Output just the number of blocks imported.
+ *
+ * ## EXAMPLES
+ *
+ * # Import blocks from a JSON file, append to end of post.
+ * $ wp post block import 123 --file=blocks.json
+ * Success: Imported 5 blocks into post 123.
+ *
+ * # Import blocks at the beginning of the post.
+ * $ wp post block import 123 --file=blocks.json --position=start
+ * Success: Imported 5 blocks into post 123.
+ *
+ * # Replace all existing content with imported blocks.
+ * $ wp post block import 123 --file=blocks.json --replace
+ * Success: Imported 5 blocks into post 123.
+ *
+ * # Import from STDIN (piped from another command).
+ * $ cat blocks.json | wp post block import 123
+ * Success: Imported 5 blocks into post 123.
+ *
+ * # Copy blocks from one post to another.
+ * $ wp post block export 123 | wp post block import 456
+ * Success: Imported 5 blocks into post 456.
+ *
+ * # Import YAML format.
+ * $ wp post block import 123 --file=blocks.yaml
+ * Success: Imported 3 blocks into post 123.
+ *
+ * # Get just the count of imported blocks for scripting.
+ * $ wp post block import 123 --file=blocks.json --porcelain
+ * 5
+ *
+ * @subcommand import
+ */
+ public function import( $args, $assoc_args ) {
+ $post = $this->fetcher->get_check( $args[0] );
+ $file = Utils\get_flag_value( $assoc_args, 'file', null );
+ $position = Utils\get_flag_value( $assoc_args, 'position', 'end' );
+ $replace = Utils\get_flag_value( $assoc_args, 'replace', false );
+
+ if ( null !== $file ) {
+ if ( ! file_exists( $file ) ) {
+ WP_CLI::error( "File not found: {$file}" );
+ }
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
+ $input = file_get_contents( $file );
+ } else {
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
+ $input = file_get_contents( 'php://stdin' );
+ }
+
+ if ( false === $input || '' === trim( $input ) ) {
+ WP_CLI::error( 'No input data provided.' );
+ }
+
+ // Try to parse as JSON first, then YAML.
+ $data = json_decode( $input, true );
+ if ( null === $data ) {
+ $data = Spyc::YAMLLoadString( $input );
+ }
+
+ if ( ! is_array( $data ) ) {
+ WP_CLI::error( 'Invalid input format. Expected JSON or YAML.' );
+ }
+
+ // Handle export format (with metadata wrapper) or plain blocks array.
+ $import_blocks = isset( $data['blocks'] ) ? $data['blocks'] : $data;
+
+ if ( ! is_array( $import_blocks ) ) {
+ WP_CLI::error( 'No blocks found in import data.' );
+ }
+
+ // Validate block structure.
+ foreach ( $import_blocks as $idx => $block ) {
+ if ( ! isset( $block['blockName'] ) ) {
+ WP_CLI::error( "Invalid block structure at index {$idx}: missing blockName." );
+ }
+ }
+
+ $imported_count = count( $import_blocks );
+
+ if ( $replace ) {
+ $blocks = $import_blocks;
+ } else {
+ $blocks = parse_blocks( $post->post_content );
+
+ // Filter out empty blocks.
+ $blocks = array_values(
+ array_filter(
+ $blocks,
+ function ( $block ) {
+ return ! empty( $block['blockName'] );
+ }
+ )
+ );
+
+ if ( 'start' === $position ) {
+ $blocks = array_merge( $import_blocks, $blocks );
+ } elseif ( 'end' === $position ) {
+ $blocks = array_merge( $blocks, $import_blocks );
+ } elseif ( is_numeric( $position ) ) {
+ $pos = (int) $position;
+ if ( $pos < 0 || $pos > count( $blocks ) ) {
+ WP_CLI::error( "Invalid position: {$pos}. Post has " . count( $blocks ) . ' block(s) (0-indexed).' );
+ }
+ array_splice( $blocks, $pos, 0, $import_blocks );
+ } else {
+ $blocks = array_merge( $blocks, $import_blocks );
+ }
+ }
+
+ $new_content = serialize_blocks( $blocks );
+ $result = wp_update_post(
+ [
+ 'ID' => $post->ID,
+ 'post_content' => $new_content,
+ ],
+ true
+ );
+
+ if ( is_wp_error( $result ) ) {
+ WP_CLI::error( $result->get_error_message() );
+ }
+
+ if ( Utils\get_flag_value( $assoc_args, 'porcelain' ) ) {
+ WP_CLI::line( (string) $imported_count );
+ } else {
+ $block_word = 1 === $imported_count ? 'block' : 'blocks';
+ WP_CLI::success( "Imported {$imported_count} {$block_word} into post {$post->ID}." );
+ }
+ }
+
+ /**
+ * Counts blocks across multiple posts.
+ *
+ * Analyzes block usage across posts for site-wide reporting.
+ *
+ * ## OPTIONS
+ *
+ * [...]
+ * : Optional post IDs. If not specified, queries all posts.
+ *
+ * [--block=]
+ * : Only count specific block type.
+ *
+ * [--post-type=]
+ * : Limit to specific post type(s). Comma-separated.
+ * ---
+ * default: post,page
+ * ---
+ *
+ * [--post-status=]
+ * : Post status to include.
+ * ---
+ * default: publish
+ * ---
+ *
+ * [--format=]
+ * : Output format.
+ * ---
+ * default: table
+ * options:
+ * - table
+ * - json
+ * - csv
+ * - yaml
+ * - count
+ * ---
+ *
+ * ## EXAMPLES
+ *
+ * # Count all blocks across published posts and pages.
+ * $ wp post block count
+ * +------------------+-------+-------+
+ * | blockName | count | posts |
+ * +------------------+-------+-------+
+ * | core/paragraph | 1542 | 234 |
+ * | core/heading | 523 | 198 |
+ * | core/image | 312 | 156 |
+ * +------------------+-------+-------+
+ *
+ * # Count blocks in specific posts only.
+ * $ wp post block count 123 456 789
+ * +------------------+-------+-------+
+ * | blockName | count | posts |
+ * +------------------+-------+-------+
+ * | core/paragraph | 8 | 3 |
+ * | core/heading | 3 | 2 |
+ * +------------------+-------+-------+
+ *
+ * # Count only paragraph blocks across the site.
+ * $ wp post block count --block=core/paragraph --format=count
+ * 1542
+ *
+ * # Count blocks in a custom post type.
+ * $ wp post block count --post-type=product
+ *
+ * # Count blocks in multiple post types.
+ * $ wp post block count --post-type=post,page,product
+ *
+ * # Count blocks including drafts.
+ * $ wp post block count --post-status=draft
+ *
+ * # Get count as JSON for further processing.
+ * $ wp post block count --format=json
+ * [{"blockName":"core/paragraph","count":1542,"posts":234}]
+ *
+ * # Get total number of unique block types used.
+ * $ wp post block count --format=count
+ * 15
+ *
+ * @subcommand count
+ */
+ public function count( $args, $assoc_args ) {
+ $block_filter = Utils\get_flag_value( $assoc_args, 'block', null );
+ $post_types = Utils\get_flag_value( $assoc_args, 'post-type', 'post,page' );
+ $post_status = Utils\get_flag_value( $assoc_args, 'post-status', 'publish' );
+ $format = Utils\get_flag_value( $assoc_args, 'format', 'table' );
+
+ if ( ! empty( $args ) ) {
+ $post_ids = array_map( 'intval', $args );
+ } else {
+ $query_args = [
+ 'post_type' => explode( ',', $post_types ),
+ 'post_status' => $post_status,
+ 'posts_per_page' => -1,
+ 'fields' => 'ids',
+ ];
+
+ $post_ids = get_posts( $query_args );
+ }
+
+ if ( empty( $post_ids ) ) {
+ WP_CLI::warning( 'No posts found matching criteria.' );
+ return;
+ }
+
+ $block_counts = [];
+ $post_counts = [];
+
+ foreach ( $post_ids as $post_id ) {
+ $post = get_post( $post_id );
+ if ( ! $post || ! has_blocks( $post->post_content ) ) {
+ continue;
+ }
+
+ $blocks = parse_blocks( $post->post_content );
+ $this->aggregate_block_counts( $blocks, $block_counts, $post_counts, $post_id, $block_filter );
+ }
+
+ if ( empty( $block_counts ) ) {
+ WP_CLI::warning( 'No blocks found in queried posts.' );
+ return;
+ }
+
+ // Sort by count descending.
+ arsort( $block_counts );
+
+ // Handle single block filter with count format.
+ if ( null !== $block_filter && 'count' === $format ) {
+ $count = isset( $block_counts[ $block_filter ] ) ? $block_counts[ $block_filter ] : 0;
+ WP_CLI::line( (string) $count );
+ return;
+ }
+
+ $items = [];
+ foreach ( $block_counts as $block_name => $count ) {
+ $items[] = [
+ 'blockName' => $block_name,
+ 'count' => $count,
+ 'posts' => isset( $post_counts[ $block_name ] ) ? count( $post_counts[ $block_name ] ) : 0,
+ ];
+ }
+
+ if ( 'count' === $format ) {
+ WP_CLI::line( (string) count( $items ) );
+ return;
+ }
+
+ $formatter = new Formatter( $assoc_args, [ 'blockName', 'count', 'posts' ] );
+ $formatter->display_items( $items );
+ }
+
+ /**
+ * Clones a block within a post.
+ *
+ * Duplicates an existing block and inserts it at a specified position.
+ *
+ * ## OPTIONS
+ *
+ *
+ * : The ID of the post.
+ *
+ *
+ * : Index of the block to clone (0-indexed).
+ *
+ * [--position=]
+ * : Where to insert the cloned block. Accepts 'after', 'before', 'start', 'end', or a numeric index.
+ * ---
+ * default: after
+ * ---
+ *
+ * [--porcelain]
+ * : Output just the new block index.
+ *
+ * ## EXAMPLES
+ *
+ * # Clone a block and insert immediately after it (default).
+ * $ wp post block clone 123 2
+ * Success: Cloned block to index 3 in post 123.
+ *
+ * # Clone the first block and insert immediately before it.
+ * $ wp post block clone 123 0 --position=before
+ * Success: Cloned block to index 0 in post 123.
+ *
+ * # Clone a block and insert at the end of the post.
+ * $ wp post block clone 123 0 --position=end
+ * Success: Cloned block to index 5 in post 123.
+ *
+ * # Clone a block and insert at the start of the post.
+ * $ wp post block clone 123 3 --position=start
+ * Success: Cloned block to index 0 in post 123.
+ *
+ * # Clone and get just the new block index for scripting.
+ * $ wp post block clone 123 1 --porcelain
+ * 2
+ *
+ * # Duplicate the hero section (first block) at the end for a footer.
+ * $ wp post block clone 123 0 --position=end
+ * Success: Cloned block to index 10 in post 123.
+ *
+ * @subcommand clone
+ */
+ public function clone_block( $args, $assoc_args ) {
+ $post = $this->fetcher->get_check( $args[0] );
+ $source_index = (int) $args[1];
+ $position = Utils\get_flag_value( $assoc_args, 'position', 'after' );
+ $blocks = parse_blocks( $post->post_content );
+
+ // Filter out empty blocks but keep track of original indices.
+ $filtered_blocks = [];
+ $index_map = [];
+ foreach ( $blocks as $original_idx => $block ) {
+ if ( ! empty( $block['blockName'] ) ) {
+ $index_map[ count( $filtered_blocks ) ] = $original_idx;
+ $filtered_blocks[] = $block;
+ }
+ }
+
+ $block_count = count( $filtered_blocks );
+
+ if ( $source_index < 0 || $source_index >= $block_count ) {
+ WP_CLI::error( "Invalid source-index: {$source_index}. Post has {$block_count} block(s) (0-indexed)." );
+ }
+
+ $original_idx = (int) $index_map[ $source_index ];
+ $cloned_block = $this->deep_copy_block( $blocks[ $original_idx ] );
+
+ // Calculate insertion position.
+ if ( is_numeric( $position ) ) {
+ $new_index = (int) $position;
+ if ( $new_index < 0 || $new_index > $block_count ) {
+ WP_CLI::error( "Invalid position: {$new_index}. Must be between 0 and {$block_count}." );
+ }
+ // Map the filtered index to original index for insertion.
+ if ( $new_index >= $block_count ) {
+ $insert_pos = count( $blocks );
+ } else {
+ $insert_pos = $index_map[ $new_index ];
+ }
+ } else {
+ switch ( $position ) {
+ case 'before':
+ $insert_pos = $original_idx;
+ $new_index = $source_index;
+ break;
+ case 'after':
+ $insert_pos = $original_idx + 1;
+ $new_index = $source_index + 1;
+ break;
+ case 'start':
+ $insert_pos = 0;
+ $new_index = 0;
+ break;
+ case 'end':
+ default:
+ $insert_pos = count( $blocks );
+ $new_index = $block_count;
+ break;
+ }
+ }
+
+ array_splice( $blocks, (int) $insert_pos, 0, [ $cloned_block ] );
+
+ // @phpstan-ignore argument.type
+ $new_content = serialize_blocks( $blocks );
+
+ $result = wp_update_post(
+ [
+ 'ID' => $post->ID,
+ 'post_content' => $new_content,
+ ],
+ true
+ );
+
+ if ( is_wp_error( $result ) ) {
+ WP_CLI::error( $result->get_error_message() );
+ }
+
+ if ( Utils\get_flag_value( $assoc_args, 'porcelain' ) ) {
+ WP_CLI::line( (string) $new_index );
+ } else {
+ WP_CLI::success( "Cloned block to index {$new_index} in post {$post->ID}." );
+ }
+ }
+
+ /**
+ * Extracts data from blocks.
+ *
+ * Extracts specific attribute values or content from blocks for scripting.
+ *
+ * ## OPTIONS
+ *
+ *
+ * : The ID of the post.
+ *
+ * [--block=]
+ * : Filter by block type.
+ *
+ * [--index=]
+ * : Get from specific block index.
+ *
+ * [--attr=]
+ * : Extract specific attribute value.
+ *
+ * [--content]
+ * : Extract innerHTML content.
+ *
+ * [--format=]
+ * : Output format.
+ * ---
+ * default: json
+ * options:
+ * - json
+ * - yaml
+ * - csv
+ * - ids
+ * ---
+ *
+ * ## EXAMPLES
+ *
+ * # Extract all image IDs from the post (one per line).
+ * $ wp post block extract 123 --block=core/image --attr=id --format=ids
+ * 456
+ * 789
+ * 1024
+ *
+ * # Extract all image URLs as JSON array.
+ * $ wp post block extract 123 --block=core/image --attr=url --format=json
+ * ["https://example.com/img1.jpg","https://example.com/img2.jpg"]
+ *
+ * # Extract text content from all headings.
+ * $ wp post block extract 123 --block=core/heading --content --format=ids
+ * Introduction
+ * Getting Started
+ * Conclusion
+ *
+ * # Get the heading level from the first block.
+ * $ wp post block extract 123 --index=0 --attr=level --format=ids
+ * 2
+ *
+ * # Extract all heading levels as CSV.
+ * $ wp post block extract 123 --block=core/heading --attr=level --format=csv
+ * 2,3,3,2
+ *
+ * # Extract paragraph content as YAML.
+ * $ wp post block extract 123 --block=core/paragraph --content --format=yaml
+ * - "First paragraph text"
+ * - "Second paragraph text"
+ *
+ * # Get all button URLs for link checking.
+ * $ wp post block extract 123 --block=core/button --attr=url --format=ids
+ * https://example.com/signup
+ * https://example.com/learn-more
+ *
+ * # Extract cover block image IDs for media audit.
+ * $ wp post block extract 123 --block=core/cover --attr=id --format=json
+ *
+ * @subcommand extract
+ */
+ public function extract( $args, $assoc_args ) {
+ $post = $this->fetcher->get_check( $args[0] );
+ $block_filter = Utils\get_flag_value( $assoc_args, 'block', null );
+ $index = Utils\get_flag_value( $assoc_args, 'index', null );
+ $attr = Utils\get_flag_value( $assoc_args, 'attr', null );
+ $get_content = Utils\get_flag_value( $assoc_args, 'content', false );
+ $format = Utils\get_flag_value( $assoc_args, 'format', 'json' );
+
+ if ( null === $attr && ! $get_content ) {
+ WP_CLI::error( 'You must specify either --attr or --content.' );
+ }
+
+ $blocks = parse_blocks( $post->post_content );
+
+ // Filter out empty blocks.
+ $blocks = array_values(
+ array_filter(
+ $blocks,
+ function ( $block ) {
+ return ! empty( $block['blockName'] );
+ }
+ )
+ );
+
+ // Filter by index.
+ if ( null !== $index ) {
+ $index = (int) $index;
+ if ( $index < 0 || $index >= count( $blocks ) ) {
+ WP_CLI::error( "Invalid index: {$index}. Post has " . count( $blocks ) . ' block(s) (0-indexed).' );
+ }
+ $blocks = [ $blocks[ $index ] ];
+ }
+
+ // Filter by block type.
+ if ( null !== $block_filter ) {
+ $blocks = array_filter(
+ $blocks,
+ function ( $block ) use ( $block_filter ) {
+ return $block['blockName'] === $block_filter;
+ }
+ );
+ }
+
+ if ( empty( $blocks ) ) {
+ WP_CLI::warning( 'No matching blocks found.' );
+ return;
+ }
+
+ // Extract values.
+ $values = [];
+ foreach ( $blocks as $block ) {
+ if ( $get_content ) {
+ $content = isset( $block['innerHTML'] ) ? $block['innerHTML'] : '';
+ // Strip HTML tags for cleaner output.
+ $values[] = trim( wp_strip_all_tags( $content ) );
+ } elseif ( null !== $attr ) {
+ if ( isset( $block['attrs'][ $attr ] ) ) {
+ $values[] = $block['attrs'][ $attr ];
+ }
+ }
+ }
+
+ if ( empty( $values ) ) {
+ WP_CLI::warning( 'No values found for extraction criteria.' );
+ return;
+ }
+
+ // Output based on format.
+ switch ( $format ) {
+ case 'ids':
+ foreach ( $values as $value ) {
+ WP_CLI::line( (string) $value );
+ }
+ break;
+ case 'csv':
+ WP_CLI::line( implode( ',', array_map( 'strval', $values ) ) );
+ break;
+ case 'yaml':
+ echo Spyc::YAMLDump( $values, 2, 0, true );
+ break;
+ case 'json':
+ default:
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode
+ echo json_encode( $values, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ) . "\n";
+ break;
+ }
+ }
+
+ /**
+ * Parses and displays the block structure of a post.
+ *
+ * Outputs the parsed block structure as JSON or YAML. By default,
+ * innerHTML is stripped from the output for readability.
+ *
+ * ## OPTIONS
+ *
+ *
+ * : The ID of the post to parse.
+ *
+ * [--raw]
+ * : Include raw innerHTML in output.
+ *
+ * [--format=]
+ * : Render output in a particular format.
+ * ---
+ * default: json
+ * options:
+ * - json
+ * - yaml
+ * ---
+ *
+ * ## EXAMPLES
+ *
+ * # Parse blocks to JSON.
+ * $ wp post block parse 123
+ * [
+ * {
+ * "blockName": "core/paragraph",
+ * "attrs": {}
+ * }
+ * ]
+ *
+ * # Parse blocks to YAML format.
+ * $ wp post block parse 123 --format=yaml
+ * -
+ * blockName: core/paragraph
+ * attrs: { }
+ *
+ * # Parse blocks including raw HTML content.
+ * $ wp post block parse 123 --raw
+ *
+ * @subcommand parse
+ */
+ public function parse( $args, $assoc_args ) {
+ $post = $this->fetcher->get_check( $args[0] );
+ $blocks = parse_blocks( $post->post_content );
+
+ $include_raw = Utils\get_flag_value( $assoc_args, 'raw', false );
+
+ if ( ! $include_raw ) {
+ $blocks = $this->strip_inner_html( $blocks );
+ }
+
+ $format = Utils\get_flag_value( $assoc_args, 'format', 'json' );
+
+ if ( 'yaml' === $format ) {
+ echo Spyc::YAMLDump( $blocks, 2, 0, true );
+ } else {
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode
+ echo json_encode( $blocks, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ) . "\n";
+ }
+ }
+
+ /**
+ * Renders blocks from a post to HTML.
+ *
+ * Outputs the rendered HTML of blocks in a post. This uses WordPress's
+ * block rendering system to produce the final HTML output.
+ *
+ * ## OPTIONS
+ *
+ *
+ * : The ID of the post to render.
+ *
+ * [--block=]
+ * : Only render blocks of this type.
+ *
+ * ## EXAMPLES
+ *
+ * # Render all blocks to HTML.
+ * $ wp post block render 123
+ * Hello World
+ * My Heading
+ *
+ * # Render only paragraph blocks.
+ * $ wp post block render 123 --block=core/paragraph
+ * Hello World
+ *
+ * # Render only heading blocks.
+ * $ wp post block render 123 --block=core/heading
+ *
+ * @subcommand render
+ */
+ public function render( $args, $assoc_args ) {
+ $post = $this->fetcher->get_check( $args[0] );
+ $block_name = Utils\get_flag_value( $assoc_args, 'block', null );
+ $blocks = parse_blocks( $post->post_content );
+ $output_html = '';
+
+ foreach ( $blocks as $block ) {
+ if ( null !== $block_name && $block['blockName'] !== $block_name ) {
+ continue;
+ }
+ $output_html .= render_block( $block );
+ }
+
+ echo $output_html;
+ }
+
+ /**
+ * Lists blocks in a post with counts.
+ *
+ * Displays a summary of block types used in the post and how many
+ * times each block type appears.
+ *
+ * ## OPTIONS
+ *
+ *
+ * : The ID of the post to analyze.
+ *
+ * [--nested]
+ * : Include nested/inner blocks in the list.
+ *
+ * [--format=]
+ * : Render output in a particular format.
+ * ---
+ * default: table
+ * options:
+ * - table
+ * - csv
+ * - json
+ * - yaml
+ * - count
+ * ---
+ *
+ * ## EXAMPLES
+ *
+ * # List blocks with counts.
+ * $ wp post block list 123
+ * +------------------+-------+
+ * | blockName | count |
+ * +------------------+-------+
+ * | core/paragraph | 5 |
+ * | core/heading | 2 |
+ * | core/image | 1 |
+ * +------------------+-------+
+ *
+ * # List blocks as JSON.
+ * $ wp post block list 123 --format=json
+ * [{"blockName":"core/paragraph","count":5}]
+ *
+ * # Include nested blocks (e.g., blocks inside columns or groups).
+ * $ wp post block list 123 --nested
+ *
+ * # Get the number of unique block types.
+ * $ wp post block list 123 --format=count
+ * 3
+ *
+ * @subcommand list
+ */
+ public function list_( $args, $assoc_args ) {
+ $post = $this->fetcher->get_check( $args[0] );
+ $include_nested = Utils\get_flag_value( $assoc_args, 'nested', false );
+
+ $block_counts = Block_Processor_Helper::count_by_type( $post->post_content, $include_nested );
+
+ $items = [];
+ foreach ( $block_counts as $block_name => $count ) {
+ $items[] = [
+ 'blockName' => $block_name,
+ 'count' => $count,
+ ];
+ }
+
+ $format = Utils\get_flag_value( $assoc_args, 'format', 'table' );
+
+ if ( 'count' === $format ) {
+ WP_CLI::line( (string) count( $items ) );
+ return;
+ }
+
+ $formatter = new Formatter( $assoc_args, [ 'blockName', 'count' ] );
+ $formatter->display_items( $items );
+ }
+
+ /**
+ * Inserts a block into a post at a specified position.
+ *
+ * Adds a new block to the post content. By default, the block is
+ * appended to the end of the post.
+ *
+ * ## OPTIONS
+ *
+ *
+ * : The ID of the post to modify.
+ *
+ *
+ * : The block type name (e.g., 'core/paragraph').
+ *
+ * [--content=]
+ * : The inner content/HTML for the block.
+ *
+ * [--attrs=]
+ * : Block attributes as JSON.
+ *
+ * [--position=]
+ * : Position to insert the block (0-indexed). Use 'start' or 'end'.
+ * ---
+ * default: end
+ * ---
+ *
+ * [--porcelain]
+ * : Output just the post ID.
+ *
+ * ## EXAMPLES
+ *
+ * # Insert a paragraph block at the end of the post.
+ * $ wp post block insert 123 core/paragraph --content="Hello World"
+ * Success: Inserted block into post 123.
+ *
+ * # Insert a level-2 heading at the start.
+ * $ wp post block insert 123 core/heading --content="My Title" --attrs='{"level":2}' --position=start
+ * Success: Inserted block into post 123.
+ *
+ * # Insert an image block at position 2.
+ * $ wp post block insert 123 core/image --attrs='{"id":456,"url":"https://example.com/image.jpg"}' --position=2
+ *
+ * # Insert a separator block.
+ * $ wp post block insert 123 core/separator
+ *
+ * @subcommand insert
+ */
+ public function insert( $args, $assoc_args ) {
+ $post = $this->fetcher->get_check( $args[0] );
+ $block_name = $args[1];
+ $content = Utils\get_flag_value( $assoc_args, 'content', '' );
+ $attrs_json = Utils\get_flag_value( $assoc_args, 'attrs', '{}' );
+ $position = Utils\get_flag_value( $assoc_args, 'position', 'end' );
+
+ $attrs = json_decode( $attrs_json, true );
+ if ( null === $attrs && '{}' !== $attrs_json ) {
+ WP_CLI::error( 'Invalid JSON provided for --attrs.' );
+ }
+ if ( ! is_array( $attrs ) ) {
+ $attrs = [];
+ }
+
+ $blocks = parse_blocks( $post->post_content );
+
+ $new_block = $this->create_block( $block_name, $attrs, $content );
+
+ if ( 'start' === $position ) {
+ array_unshift( $blocks, $new_block );
+ } elseif ( 'end' === $position ) {
+ $blocks[] = $new_block;
+ } else {
+ $pos = (int) $position;
+ if ( $pos < 0 || $pos > count( $blocks ) ) {
+ WP_CLI::error( "Invalid position: {$position}. Must be between 0 and " . count( $blocks ) . '.' );
+ }
+ array_splice( $blocks, $pos, 0, [ $new_block ] );
+ }
+
+ // @phpstan-ignore argument.type
+ $new_content = serialize_blocks( $blocks );
+ $result = wp_update_post(
+ [
+ 'ID' => $post->ID,
+ 'post_content' => $new_content,
+ ],
+ true
+ );
+
+ if ( is_wp_error( $result ) ) {
+ WP_CLI::error( $result->get_error_message() );
+ }
+
+ if ( Utils\get_flag_value( $assoc_args, 'porcelain' ) ) {
+ WP_CLI::line( (string) $post->ID );
+ } else {
+ WP_CLI::success( "Inserted block into post {$post->ID}." );
+ }
+ }
+
+ /**
+ * Removes blocks from a post by name or index.
+ *
+ * Removes one or more blocks from the post content. Blocks can be
+ * removed by their type name or by their position index.
+ *
+ * ## OPTIONS
+ *
+ *
+ * : The ID of the post to modify.
+ *
+ * []
+ * : The block type name to remove (e.g., 'core/paragraph').
+ *
+ * [--index=]
+ * : Remove block at specific index (0-indexed). Can be comma-separated for multiple indices.
+ *
+ * [--all]
+ * : Remove all blocks of the specified type.
+ *
+ * [--porcelain]
+ * : Output just the number of blocks removed.
+ *
+ * ## EXAMPLES
+ *
+ * # Remove the first block (index 0).
+ * $ wp post block remove 123 --index=0
+ * Success: Removed 1 block from post 123.
+ *
+ * # Remove the first paragraph block found.
+ * $ wp post block remove 123 core/paragraph
+ * Success: Removed 1 block from post 123.
+ *
+ * # Remove all paragraph blocks.
+ * $ wp post block remove 123 core/paragraph --all
+ * Success: Removed 5 blocks from post 123.
+ *
+ * # Remove blocks at multiple indices.
+ * $ wp post block remove 123 --index=0,2,4
+ * Success: Removed 3 blocks from post 123.
+ *
+ * # Remove all image blocks and get count.
+ * $ wp post block remove 123 core/image --all --porcelain
+ * 2
+ *
+ * @subcommand remove
+ */
+ public function remove( $args, $assoc_args ) {
+ $post = $this->fetcher->get_check( $args[0] );
+ $block_name = isset( $args[1] ) ? $args[1] : null;
+ $indices = Utils\get_flag_value( $assoc_args, 'index', null );
+ $remove_all = Utils\get_flag_value( $assoc_args, 'all', false );
+
+ if ( null === $block_name && null === $indices ) {
+ WP_CLI::error( 'You must specify either a block name or --index.' );
+ }
+
+ // Use Block_Processor_Helper for consistent parsing across WP versions.
+ $filtered_blocks = Block_Processor_Helper::parse_all( $post->post_content );
+
+ // For serialization, we need the full parse_blocks output including whitespace blocks.
+ $blocks = parse_blocks( $post->post_content );
+
+ // Build index map from filtered to original indices.
+ $index_map = [];
+ $filtered_idx = 0;
+ foreach ( $blocks as $original_idx => $block ) {
+ if ( ! empty( $block['blockName'] ) ) {
+ $index_map[ $filtered_idx ] = $original_idx;
+ ++$filtered_idx;
+ }
+ }
+
+ $removed_count = 0;
+
+ if ( null !== $indices ) {
+ $index_array = array_map( 'intval', explode( ',', $indices ) );
+
+ // Validate all indices first.
+ foreach ( $index_array as $idx ) {
+ if ( $idx < 0 || $idx >= count( $filtered_blocks ) ) {
+ WP_CLI::error( "Invalid index: {$idx}. Post has " . count( $filtered_blocks ) . ' block(s) (0-indexed).' );
+ }
+ }
+
+ // Map to original indices and sort in reverse order to remove from end first.
+ $original_indices = array_map(
+ function ( $idx ) use ( $index_map ) {
+ return $index_map[ $idx ];
+ },
+ $index_array
+ );
+ rsort( $original_indices );
+
+ foreach ( $original_indices as $original_idx ) {
+ array_splice( $blocks, (int) $original_idx, 1 );
+ ++$removed_count;
+ }
+ } elseif ( $remove_all && null !== $block_name ) {
+ $new_blocks = [];
+ foreach ( $blocks as $block ) {
+ if ( $block['blockName'] === $block_name ) {
+ ++$removed_count;
+ } else {
+ $new_blocks[] = $block;
+ }
+ }
+ $blocks = $new_blocks;
+ } elseif ( null !== $block_name ) {
+ foreach ( $blocks as $idx => $block ) {
+ if ( $block['blockName'] === $block_name ) {
+ array_splice( $blocks, (int) $idx, 1 );
+ ++$removed_count;
+ break;
+ }
+ }
+ }
+
+ if ( 0 === $removed_count ) {
+ WP_CLI::warning( 'No blocks were removed.' );
+ return;
+ }
+
+ $new_content = serialize_blocks( $blocks );
+ $result = wp_update_post(
+ [
+ 'ID' => $post->ID,
+ 'post_content' => $new_content,
+ ],
+ true
+ );
+
+ if ( is_wp_error( $result ) ) {
+ WP_CLI::error( $result->get_error_message() );
+ }
+
+ if ( Utils\get_flag_value( $assoc_args, 'porcelain' ) ) {
+ WP_CLI::line( (string) $removed_count );
+ } else {
+ $block_word = 1 === $removed_count ? 'block' : 'blocks';
+ WP_CLI::success( "Removed {$removed_count} {$block_word} from post {$post->ID}." );
+ }
+ }
+
+ /**
+ * Replaces blocks in a post.
+ *
+ * Replaces blocks of one type with blocks of another type. Can also
+ * be used to update block attributes without changing the block type.
+ *
+ * ## OPTIONS
+ *
+ *
+ * : The ID of the post to modify.
+ *
+ *
+ * : The block type name to replace.
+ *
+ *
+ * : The new block type name.
+ *
+ * [--attrs=]
+ * : New block attributes as JSON.
+ *
+ * [--content=]
+ * : New block content. Use '{content}' to preserve original content.
+ *
+ * [--all]
+ * : Replace all matching blocks. By default, only the first match is replaced.
+ *
+ * [--porcelain]
+ * : Output just the number of blocks replaced.
+ *
+ * ## EXAMPLES
+ *
+ * # Replace the first paragraph block with a heading.
+ * $ wp post block replace 123 core/paragraph core/heading
+ * Success: Replaced 1 block in post 123.
+ *
+ * # Replace all paragraphs with preformatted blocks, keeping content.
+ * $ wp post block replace 123 core/paragraph core/preformatted --content='{content}' --all
+ * Success: Replaced 3 blocks in post 123.
+ *
+ * # Change all h2 headings to h3.
+ * $ wp post block replace 123 core/heading core/heading --attrs='{"level":3}' --all
+ *
+ * # Replace and get count for scripting.
+ * $ wp post block replace 123 core/quote core/pullquote --all --porcelain
+ * 2
+ *
+ * @subcommand replace
+ */
+ public function replace( $args, $assoc_args ) {
+ $post = $this->fetcher->get_check( $args[0] );
+ $old_block_name = $args[1];
+ $new_block_name = $args[2];
+ $attrs_json = Utils\get_flag_value( $assoc_args, 'attrs', null );
+ $content = Utils\get_flag_value( $assoc_args, 'content', null );
+ $replace_all = Utils\get_flag_value( $assoc_args, 'all', false );
+
+ $new_attrs = null;
+ if ( null !== $attrs_json ) {
+ $new_attrs = json_decode( $attrs_json, true );
+ if ( null === $new_attrs ) {
+ WP_CLI::error( 'Invalid JSON provided for --attrs.' );
+ }
+ }
+
+ $blocks = parse_blocks( $post->post_content );
+ $replaced_count = 0;
+
+ foreach ( $blocks as $idx => $block ) {
+ if ( $block['blockName'] !== $old_block_name ) {
+ continue;
+ }
+
+ $block_attrs = is_array( $new_attrs ) ? $new_attrs : ( is_array( $block['attrs'] ) ? $block['attrs'] : [] );
+ $block_content = $content;
+
+ if ( null === $block_content || '{content}' === $block_content ) {
+ $block_content = $block['innerHTML'];
+ }
+
+ $blocks[ $idx ] = $this->create_block( $new_block_name, $block_attrs, (string) $block_content );
+ ++$replaced_count;
+
+ if ( ! $replace_all ) {
+ break;
+ }
+ }
+
+ if ( 0 === $replaced_count ) {
+ WP_CLI::warning( "No blocks of type '{$old_block_name}' were found." );
+ return;
+ }
+
+ // @phpstan-ignore argument.type
+ $new_content = serialize_blocks( $blocks );
+ $result = wp_update_post(
+ [
+ 'ID' => $post->ID,
+ 'post_content' => $new_content,
+ ],
+ true
+ );
+
+ if ( is_wp_error( $result ) ) {
+ WP_CLI::error( $result->get_error_message() );
+ }
+
+ if ( Utils\get_flag_value( $assoc_args, 'porcelain' ) ) {
+ WP_CLI::line( (string) $replaced_count );
+ } else {
+ $block_word = 1 === $replaced_count ? 'block' : 'blocks';
+ WP_CLI::success( "Replaced {$replaced_count} {$block_word} in post {$post->ID}." );
+ }
+ }
+
+ /**
+ * Strips innerHTML and innerContent from blocks recursively.
+ *
+ * @param array $blocks Array of blocks.
+ * @return array Blocks with innerHTML stripped.
+ */
+ private function strip_inner_html( $blocks ) {
+ return array_map(
+ function ( $block ) {
+ unset( $block['innerHTML'] );
+ unset( $block['innerContent'] );
+ if ( ! empty( $block['innerBlocks'] ) ) {
+ $block['innerBlocks'] = $this->strip_inner_html( $block['innerBlocks'] );
+ }
+ return $block;
+ },
+ $blocks
+ );
+ }
+
+ /**
+ * Creates a block array structure.
+ *
+ * @param string $block_name Block name.
+ * @param array $attrs Block attributes.
+ * @param string $content Block content.
+ * @return array Block structure.
+ */
+ private function create_block( $block_name, $attrs, $content = '' ) {
+ $inner_html = $content;
+
+ if ( ! empty( $content ) && ! preg_match( '/^', $content ) ) {
+ $inner_html = "{$content}
";
+ }
+
+ return [
+ 'blockName' => $block_name,
+ 'attrs' => $attrs ?: [],
+ 'innerBlocks' => [],
+ 'innerHTML' => $inner_html,
+ 'innerContent' => [ $inner_html ],
+ ];
+ }
+
+ /**
+ * Aggregates block counts across posts.
+ *
+ * @param array $blocks Array of blocks.
+ * @param array $block_counts Reference to block counts.
+ * @param array $post_counts Reference to post counts per block type.
+ * @param int $post_id Current post ID.
+ * @param string|null $block_filter Optional filter for specific block type.
+ */
+ private function aggregate_block_counts( $blocks, &$block_counts, &$post_counts, $post_id, $block_filter = null ) {
+ foreach ( $blocks as $block ) {
+ if ( empty( $block['blockName'] ) ) {
+ continue;
+ }
+
+ $block_name = $block['blockName'];
+
+ if ( null !== $block_filter && $block_name !== $block_filter ) {
+ // Still recurse into inner blocks in case they match.
+ if ( ! empty( $block['innerBlocks'] ) ) {
+ $this->aggregate_block_counts( $block['innerBlocks'], $block_counts, $post_counts, $post_id, $block_filter );
+ }
+ continue;
+ }
+
+ if ( ! isset( $block_counts[ $block_name ] ) ) {
+ $block_counts[ $block_name ] = 0;
+ $post_counts[ $block_name ] = [];
+ }
+ ++$block_counts[ $block_name ];
+ $post_counts[ $block_name ][ $post_id ] = true;
+
+ // Recurse into inner blocks.
+ if ( ! empty( $block['innerBlocks'] ) ) {
+ $this->aggregate_block_counts( $block['innerBlocks'], $block_counts, $post_counts, $post_id, $block_filter );
+ }
+ }
+ }
+
+ /**
+ * Deep copies a block structure.
+ *
+ * @param array $block Block to copy.
+ * @return array Copied block.
+ */
+ private function deep_copy_block( $block ) {
+ $copy = $block;
+
+ if ( ! empty( $copy['innerBlocks'] ) ) {
+ $copy['innerBlocks'] = array_map( [ $this, 'deep_copy_block' ], $copy['innerBlocks'] );
+ }
+
+ return $copy;
+ }
+
+ /**
+ * Synchronizes block HTML with updated attributes.
+ *
+ * Applies the 'wp_cli_post_block_update_html' filter to allow handlers
+ * to update block HTML when attributes change.
+ *
+ * Custom handlers can be added via --require (using WP_CLI::add_wp_hook())
+ * or in plugins/themes (using add_filter()):
+ *
+ * WP_CLI::add_wp_hook( 'wp_cli_post_block_update_html', function( $block, $new_attrs, $block_name ) {
+ * if ( 'core/button' === $block_name && isset( $new_attrs['url'] ) ) {
+ * // Update href in the HTML...
+ * }
+ * return $block;
+ * }, 10, 3 );
+ *
+ * @see \WP_CLI\Entity\Block_HTML_Sync_Filters Default filter implementations.
+ *
+ * @param array $block The block structure.
+ * @param array $new_attrs The newly applied attributes.
+ * @return array The block with synchronized HTML.
+ */
+ private function sync_html_with_attrs( $block, $new_attrs ) {
+ $block_name = $block['blockName'] ?? '';
+
+ /**
+ * Filters a block after attribute updates to sync HTML with attributes.
+ *
+ * Allows handlers to update block HTML when attributes change.
+ *
+ * @since 3.0.0
+ *
+ * @see \WP_CLI\Entity\Block_HTML_Sync_Filters Default filter implementations.
+ *
+ * @param array $block The block structure with updated attrs.
+ * @param array $new_attrs The newly applied attributes.
+ * @param string $block_name The block type name (e.g., 'core/heading').
+ */
+ return apply_filters( 'wp_cli_post_block_update_html', $block, $new_attrs, $block_name );
+ }
+}
diff --git a/src/Post_Command.php b/src/Post_Command.php
index b8dd833d..0572718a 100644
--- a/src/Post_Command.php
+++ b/src/Post_Command.php
@@ -1,6 +1,7 @@
file.txt
+ *
+ * # Get the block version of a post (1 = has blocks, 0 = no blocks)
+ * # Requires WordPress 5.0+.
+ * $ wp post get 123 --field=block_version
+ * 1
*/
public function get( $args, $assoc_args ) {
$post = $this->fetcher->get_check( $args[0] );
@@ -434,6 +440,10 @@ public function get( $args, $assoc_args ) {
$post_arr['url'] = get_permalink( $post->ID );
}
+ if ( function_exists( 'block_version' ) ) {
+ $post_arr['block_version'] = block_version( $post->post_content );
+ }
+
if ( empty( $assoc_args['fields'] ) ) {
$assoc_args['fields'] = array_keys( $post_arr );
}
@@ -1059,6 +1069,88 @@ public function exists( $args ) {
}
}
+ /**
+ * Checks if a post contains any blocks.
+ *
+ * Exits with return code 0 if the post contains blocks,
+ * or return code 1 if it does not.
+ *
+ * ## OPTIONS
+ *
+ *
+ * : The ID of the post to check.
+ *
+ * ## EXAMPLES
+ *
+ * # Check if post contains blocks.
+ * $ wp post has-blocks 123
+ * Success: Post 123 contains blocks.
+ *
+ * # Check a classic (non-block) post.
+ * $ wp post has-blocks 456
+ * Error: Post 456 does not contain blocks.
+ *
+ * # Use in a shell conditional.
+ * $ if wp post has-blocks 123 2>/dev/null; then
+ * > echo "Post uses blocks"
+ * > fi
+ *
+ * @subcommand has-blocks
+ */
+ public function has_blocks( $args, $assoc_args ) {
+ $post = $this->fetcher->get_check( $args[0] );
+
+ if ( Block_Processor_Helper::has_blocks( $post->post_content ) ) {
+ WP_CLI::success( "Post {$post->ID} contains blocks." );
+ } else {
+ WP_CLI::error( "Post {$post->ID} does not contain blocks." );
+ }
+ }
+
+ /**
+ * Checks if a post contains a specific block type.
+ *
+ * Exits with return code 0 if the post contains the specified block,
+ * or return code 1 if it does not.
+ *
+ * ## OPTIONS
+ *
+ *
+ * : The ID of the post to check.
+ *
+ *
+ * : The block type name to check for (e.g., 'core/paragraph').
+ *
+ * ## EXAMPLES
+ *
+ * # Check if post contains a paragraph block.
+ * $ wp post has-block 123 core/paragraph
+ * Success: Post 123 contains block 'core/paragraph'.
+ *
+ * # Check for a heading block.
+ * $ wp post has-block 123 core/heading
+ * Success: Post 123 contains block 'core/heading'.
+ *
+ * # Check for a block that doesn't exist.
+ * $ wp post has-block 123 core/gallery
+ * Error: Post 123 does not contain block 'core/gallery'.
+ *
+ * # Check for a custom block from a plugin.
+ * $ wp post has-block 123 my-plugin/custom-block
+ *
+ * @subcommand has-block
+ */
+ public function has_block( $args, $assoc_args ) {
+ $post = $this->fetcher->get_check( $args[0] );
+ $block_name = $args[1];
+
+ if ( has_block( $block_name, $post ) ) {
+ WP_CLI::success( "Post {$post->ID} contains block '{$block_name}'." );
+ } else {
+ WP_CLI::error( "Post {$post->ID} does not contain block '{$block_name}'." );
+ }
+ }
+
/**
* Convert a date-time string with a hyphen separator to a space separator.
*
diff --git a/tests/Block_Processor_HelperTest.php b/tests/Block_Processor_HelperTest.php
new file mode 100644
index 00000000..10ef7ce7
--- /dev/null
+++ b/tests/Block_Processor_HelperTest.php
@@ -0,0 +1,1115 @@
+sample_content = implode(
+ '',
+ [
+ 'First paragraph
',
+ 'My Heading
',
+ 'Second paragraph
',
+ '',
+ ]
+ );
+ }
+
+ // =========================================================================
+ // Tests for parse_all()
+ // =========================================================================
+
+ /**
+ * Test parse_all returns correct structure for simple content.
+ */
+ public function test_parse_all_returns_correct_structure() {
+ $content = 'Hello
';
+ $blocks = Block_Processor_Helper::parse_all( $content );
+
+ $this->assertCount( 1, $blocks );
+ $this->assertSame( 'core/paragraph', $blocks[0]['blockName'] );
+ $this->assertArrayHasKey( 'attrs', $blocks[0] );
+ $this->assertArrayHasKey( 'innerBlocks', $blocks[0] );
+ $this->assertArrayHasKey( 'innerHTML', $blocks[0] );
+ $this->assertArrayHasKey( 'innerContent', $blocks[0] );
+ }
+
+ /**
+ * Test parse_all handles multiple blocks.
+ */
+ public function test_parse_all_handles_multiple_blocks() {
+ $blocks = Block_Processor_Helper::parse_all( $this->sample_content );
+
+ $this->assertCount( 4, $blocks );
+ $this->assertSame( 'core/paragraph', $blocks[0]['blockName'] );
+ $this->assertSame( 'core/heading', $blocks[1]['blockName'] );
+ $this->assertSame( 'core/paragraph', $blocks[2]['blockName'] );
+ $this->assertSame( 'core/image', $blocks[3]['blockName'] );
+ }
+
+ /**
+ * Test parse_all handles nested blocks.
+ */
+ public function test_parse_all_handles_nested_blocks() {
+ $content = '';
+ $blocks = Block_Processor_Helper::parse_all( $content );
+
+ $this->assertCount( 1, $blocks );
+ $this->assertSame( 'core/group', $blocks[0]['blockName'] );
+ $this->assertCount( 1, $blocks[0]['innerBlocks'] );
+ $this->assertSame( 'core/paragraph', $blocks[0]['innerBlocks'][0]['blockName'] );
+ }
+
+ /**
+ * Test parse_all handles void blocks.
+ */
+ public function test_parse_all_handles_void_blocks() {
+ $content = '';
+ $blocks = Block_Processor_Helper::parse_all( $content );
+
+ $this->assertCount( 2, $blocks );
+ $this->assertSame( 'core/separator', $blocks[0]['blockName'] );
+ $this->assertSame( 'core/spacer', $blocks[1]['blockName'] );
+ }
+
+ /**
+ * Test parse_all returns empty array for empty content.
+ */
+ public function test_parse_all_returns_empty_for_empty_content() {
+ $blocks = Block_Processor_Helper::parse_all( '' );
+
+ $this->assertSame( [], $blocks );
+ }
+
+ /**
+ * Test parse_all parses attributes correctly.
+ */
+ public function test_parse_all_parses_attributes() {
+ $content = 'Title
';
+ $blocks = Block_Processor_Helper::parse_all( $content );
+
+ $this->assertCount( 1, $blocks );
+ $this->assertSame( 3, $blocks[0]['attrs']['level'] );
+ $this->assertSame( 'center', $blocks[0]['attrs']['textAlign'] );
+ }
+
+ // =========================================================================
+ // Tests for get_at_index()
+ // =========================================================================
+
+ /**
+ * Test get_at_index returns correct block.
+ */
+ public function test_get_at_index_returns_correct_block() {
+ $block = Block_Processor_Helper::get_at_index( $this->sample_content, 1 );
+
+ $this->assertNotNull( $block );
+ $this->assertSame( 'core/heading', $block['blockName'] );
+ }
+
+ /**
+ * Test get_at_index returns first block at index 0.
+ */
+ public function test_get_at_index_returns_first_block() {
+ $block = Block_Processor_Helper::get_at_index( $this->sample_content, 0 );
+
+ $this->assertNotNull( $block );
+ $this->assertSame( 'core/paragraph', $block['blockName'] );
+ }
+
+ /**
+ * Test get_at_index returns last block.
+ */
+ public function test_get_at_index_returns_last_block() {
+ $block = Block_Processor_Helper::get_at_index( $this->sample_content, 3 );
+
+ $this->assertNotNull( $block );
+ $this->assertSame( 'core/image', $block['blockName'] );
+ }
+
+ /**
+ * Test get_at_index returns null for invalid index.
+ */
+ public function test_get_at_index_returns_null_for_invalid_index() {
+ $block = Block_Processor_Helper::get_at_index( $this->sample_content, 10 );
+
+ $this->assertNull( $block );
+ }
+
+ /**
+ * Test get_at_index returns null for negative index.
+ */
+ public function test_get_at_index_returns_null_for_negative_index() {
+ $block = Block_Processor_Helper::get_at_index( $this->sample_content, -1 );
+
+ $this->assertNull( $block );
+ }
+
+ /**
+ * Test get_at_index returns null for empty content.
+ */
+ public function test_get_at_index_returns_null_for_empty_content() {
+ $block = Block_Processor_Helper::get_at_index( '', 0 );
+
+ $this->assertNull( $block );
+ }
+
+ // =========================================================================
+ // Tests for count_by_type()
+ // =========================================================================
+
+ /**
+ * Test count_by_type returns correct counts.
+ */
+ public function test_count_by_type_returns_correct_counts() {
+ $counts = Block_Processor_Helper::count_by_type( $this->sample_content );
+
+ $this->assertSame( 2, $counts['core/paragraph'] );
+ $this->assertSame( 1, $counts['core/heading'] );
+ $this->assertSame( 1, $counts['core/image'] );
+ }
+
+ /**
+ * Test count_by_type with nested blocks excluded by default.
+ */
+ public function test_count_by_type_excludes_nested_by_default() {
+ $content = 'Inner
';
+ $counts = Block_Processor_Helper::count_by_type( $content );
+
+ $this->assertSame( 1, $counts['core/group'] );
+ $this->assertArrayNotHasKey( 'core/paragraph', $counts );
+ }
+
+ /**
+ * Test count_by_type with nested blocks included.
+ */
+ public function test_count_by_type_includes_nested_when_requested() {
+ $content = 'Inner
';
+ $counts = Block_Processor_Helper::count_by_type( $content, true );
+
+ $this->assertSame( 1, $counts['core/group'] );
+ $this->assertSame( 1, $counts['core/paragraph'] );
+ }
+
+ /**
+ * Test count_by_type returns empty array for empty content.
+ */
+ public function test_count_by_type_returns_empty_for_empty_content() {
+ $counts = Block_Processor_Helper::count_by_type( '' );
+
+ $this->assertSame( [], $counts );
+ }
+
+ /**
+ * Test count_by_type returns empty array for plain HTML.
+ */
+ public function test_count_by_type_returns_empty_for_plain_html() {
+ $counts = Block_Processor_Helper::count_by_type( 'Just HTML
' );
+
+ $this->assertSame( [], $counts );
+ }
+
+ // =========================================================================
+ // Tests for has_block()
+ // =========================================================================
+
+ /**
+ * Test has_block returns true when block exists.
+ */
+ public function test_has_block_returns_true_when_exists() {
+ $this->assertTrue( Block_Processor_Helper::has_block( $this->sample_content, 'core/heading' ) );
+ }
+
+ /**
+ * Test has_block works with shorthand block names.
+ */
+ public function test_has_block_works_with_shorthand() {
+ $this->assertTrue( Block_Processor_Helper::has_block( $this->sample_content, 'heading' ) );
+ $this->assertTrue( Block_Processor_Helper::has_block( $this->sample_content, 'paragraph' ) );
+ }
+
+ /**
+ * Test has_block returns false when block doesn't exist.
+ */
+ public function test_has_block_returns_false_when_missing() {
+ $this->assertFalse( Block_Processor_Helper::has_block( $this->sample_content, 'core/quote' ) );
+ }
+
+ /**
+ * Test has_block returns false for empty content.
+ */
+ public function test_has_block_returns_false_for_empty_content() {
+ $this->assertFalse( Block_Processor_Helper::has_block( '', 'paragraph' ) );
+ }
+
+ /**
+ * Test has_block finds nested blocks.
+ */
+ public function test_has_block_finds_nested_blocks() {
+ $content = 'Inner
';
+
+ $this->assertTrue( Block_Processor_Helper::has_block( $content, 'paragraph' ) );
+ }
+
+ // =========================================================================
+ // Tests for get_block_count()
+ // =========================================================================
+
+ /**
+ * Test get_block_count returns correct count.
+ */
+ public function test_get_block_count_returns_correct_count() {
+ $count = Block_Processor_Helper::get_block_count( $this->sample_content );
+
+ $this->assertSame( 4, $count );
+ }
+
+ /**
+ * Test get_block_count excludes nested by default.
+ */
+ public function test_get_block_count_excludes_nested_by_default() {
+ $content = 'Inner
';
+ $count = Block_Processor_Helper::get_block_count( $content );
+
+ $this->assertSame( 1, $count );
+ }
+
+ /**
+ * Test get_block_count includes nested when requested.
+ */
+ public function test_get_block_count_includes_nested_when_requested() {
+ $content = 'Inner
';
+ $count = Block_Processor_Helper::get_block_count( $content, true );
+
+ $this->assertSame( 2, $count );
+ }
+
+ /**
+ * Test get_block_count returns 0 for empty content.
+ */
+ public function test_get_block_count_returns_zero_for_empty() {
+ $count = Block_Processor_Helper::get_block_count( '' );
+
+ $this->assertSame( 0, $count );
+ }
+
+ // =========================================================================
+ // Tests for has_blocks()
+ // =========================================================================
+
+ /**
+ * Test has_blocks returns true when blocks exist.
+ */
+ public function test_has_blocks_returns_true_when_exists() {
+ $this->assertTrue( Block_Processor_Helper::has_blocks( $this->sample_content ) );
+ }
+
+ /**
+ * Test has_blocks returns false for empty content.
+ */
+ public function test_has_blocks_returns_false_for_empty() {
+ $this->assertFalse( Block_Processor_Helper::has_blocks( '' ) );
+ }
+
+ /**
+ * Test has_blocks returns false for plain HTML.
+ */
+ public function test_has_blocks_returns_false_for_plain_html() {
+ $this->assertFalse( Block_Processor_Helper::has_blocks( 'Just HTML
' ) );
+ }
+
+ // =========================================================================
+ // Tests for get_block_types()
+ // =========================================================================
+
+ /**
+ * Test get_block_types returns unique types.
+ */
+ public function test_get_block_types_returns_unique_types() {
+ $types = Block_Processor_Helper::get_block_types( $this->sample_content );
+
+ $this->assertContains( 'core/paragraph', $types );
+ $this->assertContains( 'core/heading', $types );
+ $this->assertContains( 'core/image', $types );
+ $this->assertCount( 3, $types );
+ }
+
+ /**
+ * Test get_block_types returns empty array for empty content.
+ */
+ public function test_get_block_types_returns_empty_for_empty() {
+ $types = Block_Processor_Helper::get_block_types( '' );
+
+ $this->assertSame( [], $types );
+ }
+
+ // =========================================================================
+ // Tests for extract_matching()
+ // =========================================================================
+
+ /**
+ * Test extract_matching finds blocks by type.
+ */
+ public function test_extract_matching_finds_blocks_by_type() {
+ $blocks = Block_Processor_Helper::extract_matching(
+ $this->sample_content,
+ // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed -- Callback signature requires both params.
+ function ( $type, $attrs ) {
+ return 'core/paragraph' === $type;
+ }
+ );
+
+ $this->assertCount( 2, $blocks );
+ $this->assertSame( 'core/paragraph', $blocks[0]['blockName'] );
+ $this->assertSame( 'core/paragraph', $blocks[1]['blockName'] );
+ }
+
+ /**
+ * Test extract_matching finds blocks by attribute.
+ */
+ public function test_extract_matching_finds_blocks_by_attribute() {
+ $blocks = Block_Processor_Helper::extract_matching(
+ $this->sample_content,
+ function ( $type, $attrs ) {
+ return isset( $attrs['level'] ) && 2 === $attrs['level'];
+ }
+ );
+
+ $this->assertCount( 1, $blocks );
+ $this->assertSame( 'core/heading', $blocks[0]['blockName'] );
+ }
+
+ /**
+ * Test extract_matching respects limit.
+ */
+ public function test_extract_matching_respects_limit() {
+ $blocks = Block_Processor_Helper::extract_matching(
+ $this->sample_content,
+ // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed -- Callback signature requires both params.
+ function ( $type, $attrs ) {
+ return 'core/paragraph' === $type;
+ },
+ 1
+ );
+
+ $this->assertCount( 1, $blocks );
+ }
+
+ /**
+ * Test extract_matching returns empty for no matches.
+ */
+ public function test_extract_matching_returns_empty_for_no_matches() {
+ $blocks = Block_Processor_Helper::extract_matching(
+ $this->sample_content,
+ // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed -- Callback signature requires both params.
+ function ( $type, $attrs ) {
+ return 'core/quote' === $type;
+ }
+ );
+
+ $this->assertSame( [], $blocks );
+ }
+
+ // =========================================================================
+ // Tests for get_block_span()
+ // =========================================================================
+
+ /**
+ * Test get_block_span returns correct offsets.
+ */
+ public function test_get_block_span_returns_correct_offsets() {
+ $content = 'Test
';
+ $span = Block_Processor_Helper::get_block_span( $content, 0 );
+
+ $this->assertNotNull( $span );
+ $this->assertArrayHasKey( 'start', $span );
+ $this->assertArrayHasKey( 'end', $span );
+ $this->assertSame( 0, $span['start'] );
+ $this->assertSame( strlen( $content ), $span['end'] );
+ }
+
+ /**
+ * Test get_block_span returns null for invalid index.
+ */
+ public function test_get_block_span_returns_null_for_invalid_index() {
+ $span = Block_Processor_Helper::get_block_span( $this->sample_content, 100 );
+
+ $this->assertNull( $span );
+ }
+
+ /**
+ * Test get_block_span returns null for empty content.
+ */
+ public function test_get_block_span_returns_null_for_empty() {
+ $span = Block_Processor_Helper::get_block_span( '', 0 );
+
+ $this->assertNull( $span );
+ }
+
+ // =========================================================================
+ // Tests for strip_inner_html()
+ // =========================================================================
+
+ /**
+ * Test strip_inner_html removes innerHTML.
+ */
+ public function test_strip_inner_html_removes_content() {
+ $blocks = [
+ [
+ 'blockName' => 'core/paragraph',
+ 'attrs' => [],
+ 'innerBlocks' => [],
+ 'innerHTML' => 'Test
',
+ 'innerContent' => [ 'Test
' ],
+ ],
+ ];
+
+ $stripped = Block_Processor_Helper::strip_inner_html( $blocks );
+
+ $this->assertArrayNotHasKey( 'innerHTML', $stripped[0] );
+ $this->assertArrayNotHasKey( 'innerContent', $stripped[0] );
+ $this->assertSame( 'core/paragraph', $stripped[0]['blockName'] );
+ }
+
+ /**
+ * Test strip_inner_html works recursively on nested blocks.
+ */
+ public function test_strip_inner_html_works_recursively() {
+ $blocks = [
+ [
+ 'blockName' => 'core/group',
+ 'attrs' => [],
+ 'innerHTML' => '',
+ 'innerContent' => [ '', '
' ],
+ 'innerBlocks' => [
+ [
+ 'blockName' => 'core/paragraph',
+ 'attrs' => [],
+ 'innerHTML' => 'Inner
',
+ 'innerContent' => [ 'Inner
' ],
+ 'innerBlocks' => [],
+ ],
+ ],
+ ],
+ ];
+
+ $stripped = Block_Processor_Helper::strip_inner_html( $blocks );
+
+ $this->assertArrayNotHasKey( 'innerHTML', $stripped[0] );
+ $this->assertArrayNotHasKey( 'innerHTML', $stripped[0]['innerBlocks'][0] );
+ }
+
+ // =========================================================================
+ // Tests for filter_empty_blocks()
+ // =========================================================================
+
+ /**
+ * Test filter_empty_blocks removes null blockName entries.
+ */
+ public function test_filter_empty_blocks_removes_empty() {
+ $blocks = [
+ [
+ 'blockName' => 'core/paragraph',
+ 'attrs' => [],
+ ],
+ [
+ 'blockName' => null,
+ 'attrs' => [],
+ ],
+ [
+ 'blockName' => 'core/heading',
+ 'attrs' => [],
+ ],
+ [
+ 'blockName' => '',
+ 'attrs' => [],
+ ],
+ ];
+
+ $filtered = Block_Processor_Helper::filter_empty_blocks( $blocks );
+
+ $this->assertCount( 2, $filtered );
+ $this->assertSame( 'core/paragraph', $filtered[0]['blockName'] );
+ $this->assertSame( 'core/heading', $filtered[1]['blockName'] );
+ }
+
+ /**
+ * Test filter_empty_blocks re-indexes array.
+ */
+ public function test_filter_empty_blocks_reindexes() {
+ $blocks = [
+ 0 => [ 'blockName' => null ],
+ 1 => [
+ 'blockName' => 'core/paragraph',
+ 'attrs' => [],
+ ],
+ ];
+
+ $filtered = Block_Processor_Helper::filter_empty_blocks( $blocks );
+
+ $this->assertArrayHasKey( 0, $filtered );
+ $this->assertArrayNotHasKey( 1, $filtered );
+ }
+
+ // =========================================================================
+ // Edge case tests
+ // =========================================================================
+
+ /**
+ * Test handling of deeply nested blocks.
+ */
+ public function test_deeply_nested_blocks() {
+ $content = 'Deep
';
+
+ $blocks = Block_Processor_Helper::parse_all( $content );
+
+ $this->assertCount( 1, $blocks );
+ $this->assertSame( 'core/group', $blocks[0]['blockName'] );
+
+ // Navigate to deepest block.
+ $inner = $blocks[0]['innerBlocks'][0]['innerBlocks'][0]['innerBlocks'][0];
+ $this->assertSame( 'core/paragraph', $inner['blockName'] );
+ }
+
+ /**
+ * Test handling of blocks with complex JSON attributes.
+ */
+ public function test_complex_json_attributes() {
+ $content = '';
+
+ $blocks = Block_Processor_Helper::parse_all( $content );
+
+ $this->assertCount( 1, $blocks );
+ $this->assertSame( [ 1, 2, 3 ], $blocks[0]['attrs']['ids'] );
+ $this->assertSame( 3, $blocks[0]['attrs']['columns'] );
+ $this->assertSame( 'media', $blocks[0]['attrs']['linkTo'] );
+ $this->assertSame( [ 'key' => 'value' ], $blocks[0]['attrs']['nested'] );
+ }
+
+ /**
+ * Test handling of custom namespaced blocks.
+ */
+ public function test_custom_namespace_blocks() {
+ $content = '';
+
+ $blocks = Block_Processor_Helper::parse_all( $content );
+
+ $this->assertCount( 1, $blocks );
+ $this->assertSame( 'my-plugin/custom-block', $blocks[0]['blockName'] );
+ $this->assertSame( 'value', $blocks[0]['attrs']['option'] );
+ }
+
+ // =========================================================================
+ // Serialization Round-Trip Tests (Task 1)
+ // =========================================================================
+
+ /**
+ * Normalize block structure for comparison (remove keys that may differ).
+ *
+ * @param array $blocks Blocks to normalize.
+ * @return array Normalized blocks.
+ */
+ private function normalize_for_comparison( array $blocks ) {
+ return array_map(
+ function ( $block ) {
+ return [
+ 'blockName' => $block['blockName'],
+ 'attrs' => $block['attrs'] ?? [],
+ 'innerBlocks' => isset( $block['innerBlocks'] )
+ ? $this->normalize_for_comparison( $block['innerBlocks'] )
+ : [],
+ ];
+ },
+ $blocks
+ );
+ }
+
+ /**
+ * Serialize an array of blocks to string (test helper).
+ *
+ * @param array $blocks Blocks to serialize.
+ * @return string Serialized content.
+ */
+ private function serialize_blocks_for_test( array $blocks ) {
+ $output = '';
+ foreach ( $blocks as $block ) {
+ $output .= $this->serialize_block_for_test( $block );
+ }
+ return $output;
+ }
+
+ /**
+ * Serialize a single block to string (test helper).
+ *
+ * @param array $block Block to serialize.
+ * @return string Serialized content.
+ */
+ private function serialize_block_for_test( array $block ) {
+ $block_name = $block['blockName'] ?? null;
+
+ if ( empty( $block_name ) ) {
+ return $block['innerHTML'] ?? '';
+ }
+
+ $name = $block_name;
+ if ( 0 === strpos( $name, 'core/' ) ) {
+ $name = substr( $name, 5 );
+ }
+
+ $attrs = '';
+ if ( ! empty( $block['attrs'] ) ) {
+ $attrs = ' ' . json_encode( $block['attrs'], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE );
+ }
+
+ if ( empty( $block['innerContent'] ) ) {
+ return "";
+ }
+
+ $output = "";
+ $inner_index = 0;
+ foreach ( $block['innerContent'] as $chunk ) {
+ if ( null === $chunk ) {
+ if ( isset( $block['innerBlocks'][ $inner_index ] ) ) {
+ $output .= $this->serialize_block_for_test( $block['innerBlocks'][ $inner_index ] );
+ ++$inner_index;
+ }
+ } else {
+ $output .= $chunk;
+ }
+ }
+ $output .= "";
+
+ return $output;
+ }
+
+ /**
+ * Test that parsed blocks can be serialized and re-parsed consistently.
+ */
+ public function test_serialization_roundtrip_simple_paragraph() {
+ $original = 'Test
';
+ $parsed = Block_Processor_Helper::parse_all( $original );
+ $serialized = $this->serialize_blocks_for_test( $parsed );
+ $reparsed = Block_Processor_Helper::parse_all( $serialized );
+
+ $this->assertEquals(
+ $this->normalize_for_comparison( $parsed ),
+ $this->normalize_for_comparison( $reparsed )
+ );
+ }
+
+ /**
+ * Test serialization round-trip with attributes.
+ */
+ public function test_serialization_roundtrip_with_attributes() {
+ $original = 'Title
';
+ $parsed = Block_Processor_Helper::parse_all( $original );
+ $serialized = $this->serialize_blocks_for_test( $parsed );
+ $reparsed = Block_Processor_Helper::parse_all( $serialized );
+
+ $this->assertEquals(
+ $this->normalize_for_comparison( $parsed ),
+ $this->normalize_for_comparison( $reparsed )
+ );
+ }
+
+ /**
+ * Test serialization round-trip with nested blocks.
+ */
+ public function test_serialization_roundtrip_nested_blocks() {
+ $original = '';
+ $parsed = Block_Processor_Helper::parse_all( $original );
+ $serialized = $this->serialize_blocks_for_test( $parsed );
+ $reparsed = Block_Processor_Helper::parse_all( $serialized );
+
+ $this->assertEquals(
+ $this->normalize_for_comparison( $parsed ),
+ $this->normalize_for_comparison( $reparsed )
+ );
+ }
+
+ /**
+ * Test serialization round-trip with void blocks.
+ */
+ public function test_serialization_roundtrip_void_blocks() {
+ $original = '';
+ $parsed = Block_Processor_Helper::parse_all( $original );
+ $serialized = $this->serialize_blocks_for_test( $parsed );
+ $reparsed = Block_Processor_Helper::parse_all( $serialized );
+
+ $this->assertEquals(
+ $this->normalize_for_comparison( $parsed ),
+ $this->normalize_for_comparison( $reparsed )
+ );
+ }
+
+ /**
+ * Test serialization round-trip with multiple blocks.
+ */
+ public function test_serialization_roundtrip_multiple_blocks() {
+ $original = 'One
Two
Three
';
+ $parsed = Block_Processor_Helper::parse_all( $original );
+ $serialized = $this->serialize_blocks_for_test( $parsed );
+ $reparsed = Block_Processor_Helper::parse_all( $serialized );
+
+ $this->assertEquals(
+ $this->normalize_for_comparison( $parsed ),
+ $this->normalize_for_comparison( $reparsed )
+ );
+ }
+
+ // =========================================================================
+ // Malformed Content Handling Tests (Task 2)
+ // =========================================================================
+
+ /**
+ * Test handling of unclosed block (missing closer).
+ */
+ public function test_parse_all_handles_unclosed_block() {
+ $content = 'No closing tag';
+ $blocks = Block_Processor_Helper::parse_all( $content );
+
+ // Should not throw, may return empty or partial.
+ $this->assertIsArray( $blocks );
+ }
+
+ /**
+ * Test handling of orphaned closer (no opener).
+ */
+ public function test_parse_all_handles_orphaned_closer() {
+ $content = '
Some text
';
+ $blocks = Block_Processor_Helper::parse_all( $content );
+
+ $this->assertIsArray( $blocks );
+ }
+
+ /**
+ * Test handling of mismatched block types.
+ */
+ public function test_parse_all_handles_mismatched_blocks() {
+ $content = 'Text
';
+ $blocks = Block_Processor_Helper::parse_all( $content );
+
+ $this->assertIsArray( $blocks );
+ }
+
+ /**
+ * Test handling of invalid JSON in attributes.
+ */
+ public function test_parse_all_handles_invalid_json_attrs() {
+ $content = 'Test
';
+ $blocks = Block_Processor_Helper::parse_all( $content );
+
+ $this->assertIsArray( $blocks );
+ }
+
+ /**
+ * Test handling of truncated block comment.
+ */
+ public function test_parse_all_handles_truncated_comment() {
+ $content = 'Test
';
+ $blocks = Block_Processor_Helper::parse_all( $content );
+
+ $this->assertIsArray( $blocks );
+ }
+
+ /**
+ * Test handling of block with only whitespace content.
+ */
+ public function test_parse_all_handles_whitespace_only_content() {
+ $content = " \n\n ";
+ $blocks = Block_Processor_Helper::parse_all( $content );
+
+ $this->assertCount( 1, $blocks );
+ $this->assertSame( 'core/paragraph', $blocks[0]['blockName'] );
+ }
+
+ /**
+ * Test handling of deeply nested unclosed blocks.
+ */
+ public function test_parse_all_handles_nested_unclosed() {
+ $content = 'Unclosed nesting';
+ $blocks = Block_Processor_Helper::parse_all( $content );
+
+ $this->assertIsArray( $blocks );
+ }
+
+ // =========================================================================
+ // Unicode and Special Character Tests (Task 3)
+ // =========================================================================
+
+ /**
+ * Test handling of emoji in attributes.
+ */
+ public function test_parse_all_handles_emoji_in_attrs() {
+ $content = '
Test
';
+ $blocks = Block_Processor_Helper::parse_all( $content );
+
+ $this->assertCount( 1, $blocks );
+ $this->assertSame( '🎉🚀💻', $blocks[0]['attrs']['emoji'] );
+ }
+
+ /**
+ * Test handling of Chinese characters in attributes.
+ */
+ public function test_parse_all_handles_chinese_chars() {
+ $content = '中文测试
';
+ $blocks = Block_Processor_Helper::parse_all( $content );
+
+ $this->assertCount( 1, $blocks );
+ $this->assertSame( '中文测试', $blocks[0]['attrs']['text'] );
+ }
+
+ /**
+ * Test handling of RTL characters (Arabic/Hebrew).
+ */
+ public function test_parse_all_handles_rtl_chars() {
+ $content = 'مرحبا بالعالم
';
+ $blocks = Block_Processor_Helper::parse_all( $content );
+
+ $this->assertCount( 1, $blocks );
+ $this->assertSame( 'مرحبا بالعالم', $blocks[0]['attrs']['text'] );
+ }
+
+ /**
+ * Test handling of escaped quotes in attributes.
+ */
+ public function test_parse_all_handles_escaped_quotes() {
+ $content = 'Test
';
+ $blocks = Block_Processor_Helper::parse_all( $content );
+
+ $this->assertCount( 1, $blocks );
+ $this->assertSame( 'He said "hello"', $blocks[0]['attrs']['text'] );
+ }
+
+ /**
+ * Test handling of newlines in attributes.
+ */
+ public function test_parse_all_handles_newlines_in_attrs() {
+ $content = 'Test
';
+ $blocks = Block_Processor_Helper::parse_all( $content );
+
+ $this->assertCount( 1, $blocks );
+ $this->assertStringContainsString( "\n", $blocks[0]['attrs']['text'] );
+ }
+
+ /**
+ * Test handling of HTML entities in content.
+ */
+ public function test_parse_all_handles_html_entities() {
+ $content = '<script>alert("xss")</script>
';
+ $blocks = Block_Processor_Helper::parse_all( $content );
+
+ $this->assertCount( 1, $blocks );
+ $this->assertStringContainsString( '<script>', $blocks[0]['innerHTML'] );
+ }
+
+ /**
+ * Test handling of null bytes (should be stripped or handled).
+ */
+ public function test_parse_all_handles_null_bytes() {
+ $content = "Test\x00with\x00nulls
";
+ $blocks = Block_Processor_Helper::parse_all( $content );
+
+ $this->assertIsArray( $blocks );
+ }
+
+ // =========================================================================
+ // Deep Nesting Tests (Task 4)
+ // =========================================================================
+
+ /**
+ * Test handling of 10-level deep nesting.
+ */
+ public function test_parse_all_handles_10_level_nesting() {
+ $depth = 10;
+ $content = str_repeat( '', $depth );
+ $content .= '
Deep content
';
+ $content .= str_repeat( '
', $depth );
+
+ $blocks = Block_Processor_Helper::parse_all( $content );
+
+ $this->assertCount( 1, $blocks );
+ $this->assertSame( 'core/group', $blocks[0]['blockName'] );
+
+ // Navigate to deepest block.
+ $current = $blocks[0];
+ for ( $i = 1; $i < $depth; $i++ ) {
+ $this->assertNotEmpty( $current['innerBlocks'] );
+ $current = $current['innerBlocks'][0];
+ }
+ $this->assertSame( 'core/paragraph', $current['innerBlocks'][0]['blockName'] );
+ }
+
+ /**
+ * Test handling of 50-level deep nesting (stress test).
+ */
+ public function test_parse_all_handles_50_level_nesting() {
+ $depth = 50;
+ $content = str_repeat( '', $depth );
+ $content .= 'Very deep
';
+ $content .= str_repeat( '', $depth );
+
+ $blocks = Block_Processor_Helper::parse_all( $content );
+
+ $this->assertCount( 1, $blocks );
+ }
+
+ /**
+ * Test handling of wide nesting (many siblings at each level).
+ */
+ public function test_parse_all_handles_wide_nesting() {
+ $siblings = 20;
+ $content = '';
+ for ( $i = 0; $i < $siblings; $i++ ) {
+ $content .= "
Paragraph {$i}
";
+ }
+ $content .= '
';
+
+ $blocks = Block_Processor_Helper::parse_all( $content );
+
+ $this->assertCount( 1, $blocks );
+ $this->assertCount( $siblings, $blocks[0]['innerBlocks'] );
+ }
+
+ /**
+ * Test handling of mixed nesting patterns.
+ */
+ public function test_parse_all_handles_mixed_nesting() {
+ $content = '';
+ $content .= '
';
+ $content .= '
';
+ $content .= '
Col1
';
+ $content .= '
';
+ $content .= '
';
+ $content .= '
';
+ $content .= '
Col2
';
+ $content .= '';
+ $content .= '
';
+
+ $blocks = Block_Processor_Helper::parse_all( $content );
+
+ $this->assertCount( 1, $blocks );
+ $this->assertSame( 'core/columns', $blocks[0]['blockName'] );
+ $this->assertCount( 2, $blocks[0]['innerBlocks'] ); // 2 columns.
+ }
+
+ // =========================================================================
+ // Freeform Content Handling Tests (Task 8)
+ // =========================================================================
+
+ /**
+ * Test that parse_all skips pure freeform HTML content.
+ */
+ public function test_parse_all_skips_freeform_html() {
+ $content = 'Just HTML, no block markers
';
+ $blocks = Block_Processor_Helper::parse_all( $content );
+
+ $this->assertCount( 0, $blocks );
+ }
+
+ /**
+ * Test that parse_all skips freeform content between blocks.
+ */
+ public function test_parse_all_skips_freeform_between_blocks() {
+ $content = 'Block 1
';
+ $content .= 'Freeform between
';
+ $content .= 'Block 2
';
+
+ $blocks = Block_Processor_Helper::parse_all( $content );
+
+ $this->assertCount( 2, $blocks );
+ $this->assertSame( 'core/paragraph', $blocks[0]['blockName'] );
+ $this->assertSame( 'core/paragraph', $blocks[1]['blockName'] );
+ }
+
+ /**
+ * Test that parse_all skips freeform content before first block.
+ */
+ public function test_parse_all_skips_leading_freeform() {
+ $content = 'Leading freeform
Block
';
+ $blocks = Block_Processor_Helper::parse_all( $content );
+
+ $this->assertCount( 1, $blocks );
+ $this->assertSame( 'core/paragraph', $blocks[0]['blockName'] );
+ }
+
+ /**
+ * Test that parse_all skips freeform content after last block.
+ */
+ public function test_parse_all_skips_trailing_freeform() {
+ $content = 'Block
Trailing freeform
';
+ $blocks = Block_Processor_Helper::parse_all( $content );
+
+ $this->assertCount( 1, $blocks );
+ $this->assertSame( 'core/paragraph', $blocks[0]['blockName'] );
+ }
+
+ /**
+ * Document: filter_empty_blocks removes freeform blocks from array.
+ */
+ public function test_filter_empty_blocks_removes_freeform() {
+ $blocks = [
+ [
+ 'blockName' => null,
+ 'innerHTML' => 'Freeform
',
+ ],
+ [
+ 'blockName' => 'core/paragraph',
+ 'attrs' => [],
+ ],
+ [
+ 'blockName' => null,
+ 'innerHTML' => 'Whitespace',
+ ],
+ [
+ 'blockName' => 'core/heading',
+ 'attrs' => [],
+ ],
+ ];
+
+ $filtered = Block_Processor_Helper::filter_empty_blocks( $blocks );
+
+ $this->assertCount( 2, $filtered );
+ $this->assertSame( 'core/paragraph', $filtered[0]['blockName'] );
+ $this->assertSame( 'core/heading', $filtered[1]['blockName'] );
+ }
+}
diff --git a/tests/Compat/PolyfillsTest.php b/tests/Compat/PolyfillsTest.php
new file mode 100644
index 00000000..a4ed6427
--- /dev/null
+++ b/tests/Compat/PolyfillsTest.php
@@ -0,0 +1,127 @@
+assertTrue( str_ends_with( 'hello world', 'world' ) );
+ $this->assertTrue( str_ends_with( 'hello world', 'd' ) );
+ $this->assertTrue( str_ends_with( 'hello world', 'hello world' ) );
+ }
+
+ /**
+ * Test str_ends_with with non-matching suffix.
+ */
+ public function test_str_ends_with_returns_false_for_non_matching_suffix() {
+ $this->assertFalse( str_ends_with( 'hello world', 'hello' ) );
+ $this->assertFalse( str_ends_with( 'hello world', 'World' ) ); // Case sensitive.
+ $this->assertFalse( str_ends_with( 'hello world', 'xyz' ) );
+ }
+
+ /**
+ * Test str_ends_with with empty needle.
+ */
+ public function test_str_ends_with_returns_true_for_empty_needle() {
+ $this->assertTrue( str_ends_with( 'hello world', '' ) );
+ $this->assertTrue( str_ends_with( '', '' ) );
+ }
+
+ /**
+ * Test str_ends_with with empty haystack.
+ */
+ public function test_str_ends_with_handles_empty_haystack() {
+ $this->assertFalse( str_ends_with( '', 'hello' ) );
+ }
+
+ /**
+ * Test str_ends_with with needle longer than haystack.
+ */
+ public function test_str_ends_with_handles_needle_longer_than_haystack() {
+ $this->assertFalse( str_ends_with( 'hi', 'hello world' ) );
+ }
+
+ /**
+ * Test str_ends_with with special characters.
+ */
+ public function test_str_ends_with_handles_special_characters() {
+ $this->assertTrue( str_ends_with( '', '-->' ) );
+ $this->assertTrue( str_ends_with( '', '' ) );
+ $this->assertTrue( str_ends_with( 'testassertTrue( str_ends_with( 'test<', '<' ) );
+ }
+
+ /**
+ * Test str_starts_with with matching prefix.
+ */
+ public function test_str_starts_with_returns_true_for_matching_prefix() {
+ $this->assertTrue( str_starts_with( 'hello world', 'hello' ) );
+ $this->assertTrue( str_starts_with( 'hello world', 'h' ) );
+ $this->assertTrue( str_starts_with( 'hello world', 'hello world' ) );
+ }
+
+ /**
+ * Test str_starts_with with non-matching prefix.
+ */
+ public function test_str_starts_with_returns_false_for_non_matching_prefix() {
+ $this->assertFalse( str_starts_with( 'hello world', 'world' ) );
+ $this->assertFalse( str_starts_with( 'hello world', 'Hello' ) ); // Case sensitive.
+ }
+
+ /**
+ * Test str_starts_with with empty needle.
+ */
+ public function test_str_starts_with_returns_true_for_empty_needle() {
+ $this->assertTrue( str_starts_with( 'hello world', '' ) );
+ $this->assertTrue( str_starts_with( '', '' ) );
+ }
+
+ /**
+ * Test str_contains with matching substring.
+ */
+ public function test_str_contains_returns_true_for_matching_substring() {
+ $this->assertTrue( str_contains( 'hello world', 'world' ) );
+ $this->assertTrue( str_contains( 'hello world', 'hello' ) );
+ $this->assertTrue( str_contains( 'hello world', 'o w' ) );
+ $this->assertTrue( str_contains( 'hello world', 'hello world' ) );
+ }
+
+ /**
+ * Test str_contains with non-matching substring.
+ */
+ public function test_str_contains_returns_false_for_non_matching_substring() {
+ $this->assertFalse( str_contains( 'hello world', 'xyz' ) );
+ $this->assertFalse( str_contains( 'hello world', 'World' ) ); // Case sensitive.
+ }
+
+ /**
+ * Test str_contains with empty needle.
+ */
+ public function test_str_contains_returns_true_for_empty_needle() {
+ $this->assertTrue( str_contains( 'hello world', '' ) );
+ $this->assertTrue( str_contains( '', '' ) );
+ }
+
+ /**
+ * Test str_contains with empty haystack.
+ */
+ public function test_str_contains_handles_empty_haystack() {
+ $this->assertFalse( str_contains( '', 'hello' ) );
+ }
+}
diff --git a/tests/Compat/WP_Block_ProcessorTest.php b/tests/Compat/WP_Block_ProcessorTest.php
new file mode 100644
index 00000000..c5d70731
--- /dev/null
+++ b/tests/Compat/WP_Block_ProcessorTest.php
@@ -0,0 +1,621 @@
+Test
';
+ $processor = new WP_Block_Processor( $content );
+
+ $this->assertTrue( $processor->next_block() );
+ $this->assertSame( 'core/paragraph', $processor->get_block_type() );
+ }
+
+ /**
+ * Test next_block finds void blocks.
+ */
+ public function test_next_block_finds_void_block() {
+ $content = '';
+ $processor = new WP_Block_Processor( $content );
+
+ $this->assertTrue( $processor->next_block() );
+ $this->assertSame( 'core/spacer', $processor->get_block_type() );
+ $this->assertSame( WP_Block_Processor::VOID, $processor->get_delimiter_type() );
+ }
+
+ /**
+ * Test next_block with specific block type filter.
+ */
+ public function test_next_block_with_type_filter() {
+ $content = 'Test
Title
';
+ $processor = new WP_Block_Processor( $content );
+
+ $this->assertTrue( $processor->next_block( 'heading' ) );
+ $this->assertSame( 'core/heading', $processor->get_block_type() );
+ }
+
+ /**
+ * Test next_block returns false when no blocks found.
+ */
+ public function test_next_block_returns_false_for_no_blocks() {
+ $content = 'Just regular HTML
';
+ $processor = new WP_Block_Processor( $content );
+
+ $this->assertFalse( $processor->next_block() );
+ }
+
+ /**
+ * Test next_block skips closers.
+ */
+ public function test_next_block_skips_closers() {
+ $content = 'Test
Title
';
+ $processor = new WP_Block_Processor( $content );
+
+ // First block is paragraph.
+ $this->assertTrue( $processor->next_block() );
+ $this->assertSame( 'core/paragraph', $processor->get_block_type() );
+
+ // Second block is heading (skips the paragraph closer).
+ $this->assertTrue( $processor->next_block() );
+ $this->assertSame( 'core/heading', $processor->get_block_type() );
+
+ // No more blocks.
+ $this->assertFalse( $processor->next_block() );
+ }
+
+ /**
+ * Test get_block_type returns null for freeform content.
+ */
+ public function test_get_block_type_returns_null_for_freeform() {
+ $content = 'Just text';
+ $processor = new WP_Block_Processor( $content );
+
+ $processor->next_token();
+
+ $this->assertNull( $processor->get_block_type() );
+ $this->assertTrue( $processor->is_html() );
+ }
+
+ /**
+ * Test get_block_type normalizes implicit core namespace.
+ */
+ public function test_get_block_type_normalizes_namespace() {
+ $content = 'Test
';
+ $processor = new WP_Block_Processor( $content );
+
+ $processor->next_block();
+
+ $this->assertSame( 'core/paragraph', $processor->get_block_type() );
+ }
+
+ /**
+ * Test get_block_type preserves custom namespace.
+ */
+ public function test_get_block_type_preserves_custom_namespace() {
+ $content = '';
+ $processor = new WP_Block_Processor( $content );
+
+ $processor->next_block();
+
+ $this->assertSame( 'my-plugin/custom-block', $processor->get_block_type() );
+ }
+
+ /**
+ * Test extract_full_block_and_advance returns correct structure.
+ */
+ public function test_extract_full_block_returns_correct_structure() {
+ $content = 'Hello World
';
+ $processor = new WP_Block_Processor( $content );
+
+ $processor->next_block();
+ $block = $processor->extract_full_block_and_advance();
+
+ $this->assertIsArray( $block );
+ $this->assertSame( 'core/paragraph', $block['blockName'] );
+ $this->assertIsArray( $block['attrs'] );
+ $this->assertIsArray( $block['innerBlocks'] );
+ $this->assertArrayHasKey( 'innerHTML', $block );
+ $this->assertArrayHasKey( 'innerContent', $block );
+ $this->assertSame( 'Hello World
', $block['innerHTML'] );
+ }
+
+ /**
+ * Test extract_full_block_and_advance handles nested blocks.
+ */
+ public function test_extract_full_block_handles_nested_blocks() {
+ $content = '';
+ $processor = new WP_Block_Processor( $content );
+
+ $processor->next_block();
+ $block = $processor->extract_full_block_and_advance();
+
+ $this->assertSame( 'core/group', $block['blockName'] );
+ $this->assertCount( 1, $block['innerBlocks'] );
+ $this->assertSame( 'core/paragraph', $block['innerBlocks'][0]['blockName'] );
+ }
+
+ /**
+ * Test allocate_and_return_parsed_attributes parses JSON correctly.
+ */
+ public function test_allocate_and_return_parsed_attributes_parses_json() {
+ $content = 'Title
';
+ $processor = new WP_Block_Processor( $content );
+
+ $processor->next_block();
+ $attrs = $processor->allocate_and_return_parsed_attributes();
+
+ $this->assertIsArray( $attrs );
+ $this->assertSame( 2, $attrs['level'] );
+ $this->assertSame( 'center', $attrs['textAlign'] );
+ }
+
+ /**
+ * Test allocate_and_return_parsed_attributes returns null for void blocks without attrs.
+ */
+ public function test_allocate_and_return_parsed_attributes_returns_null_without_json() {
+ $content = '';
+ $processor = new WP_Block_Processor( $content );
+
+ $processor->next_block();
+ $attrs = $processor->allocate_and_return_parsed_attributes();
+
+ $this->assertNull( $attrs );
+ }
+
+ /**
+ * Test get_span returns correct byte offsets.
+ */
+ public function test_get_span_returns_correct_offsets() {
+ $content = 'BeforeTest
';
+ $processor = new WP_Block_Processor( $content );
+
+ $processor->next_block();
+ $span = $processor->get_span();
+
+ $this->assertInstanceOf( WP_HTML_Span::class, $span );
+ $this->assertSame( 6, $span->start ); // After "Before".
+ $this->assertGreaterThan( 0, $span->length );
+ }
+
+ /**
+ * Test get_depth tracks nesting correctly.
+ */
+ public function test_get_depth_tracks_nesting() {
+ $content = 'Inner
';
+ $processor = new WP_Block_Processor( $content );
+
+ // Before anything.
+ $this->assertSame( 0, $processor->get_depth() );
+
+ // After entering group.
+ $processor->next_block();
+ $this->assertSame( 1, $processor->get_depth() );
+
+ // After entering paragraph.
+ $processor->next_block();
+ $this->assertSame( 2, $processor->get_depth() );
+ }
+
+ /**
+ * Test get_breadcrumbs returns open block hierarchy.
+ */
+ public function test_get_breadcrumbs_returns_hierarchy() {
+ $content = 'Test
';
+ $processor = new WP_Block_Processor( $content );
+
+ $processor->next_block(); // group
+ $processor->next_block(); // columns
+ $processor->next_block(); // column
+ $processor->next_block(); // paragraph
+
+ $breadcrumbs = $processor->get_breadcrumbs();
+
+ $this->assertSame(
+ array( 'core/group', 'core/columns', 'core/column', 'core/paragraph' ),
+ $breadcrumbs
+ );
+ }
+
+ /**
+ * Test is_block_type with wildcard.
+ */
+ public function test_is_block_type_with_wildcard() {
+ $content = 'Test
';
+ $processor = new WP_Block_Processor( $content );
+
+ $processor->next_block();
+
+ $this->assertTrue( $processor->is_block_type( '*' ) );
+ }
+
+ /**
+ * Test is_block_type with shorthand name.
+ */
+ public function test_is_block_type_with_shorthand() {
+ $content = 'Test
';
+ $processor = new WP_Block_Processor( $content );
+
+ $processor->next_block();
+
+ $this->assertTrue( $processor->is_block_type( 'paragraph' ) );
+ $this->assertTrue( $processor->is_block_type( 'core/paragraph' ) );
+ }
+
+ /**
+ * Test opens_block detects block openers.
+ */
+ public function test_opens_block_detects_openers() {
+ $content = 'Test
';
+ $processor = new WP_Block_Processor( $content );
+
+ $processor->next_block();
+
+ $this->assertTrue( $processor->opens_block() );
+ $this->assertTrue( $processor->opens_block( 'paragraph' ) );
+ $this->assertFalse( $processor->opens_block( 'heading' ) );
+ }
+
+ /**
+ * Test get_delimiter_type returns correct types.
+ */
+ public function test_get_delimiter_type_returns_correct_types() {
+ $content = 'Test
';
+ $processor = new WP_Block_Processor( $content );
+
+ $processor->next_delimiter();
+ $this->assertSame( WP_Block_Processor::OPENER, $processor->get_delimiter_type() );
+
+ $processor->next_delimiter();
+ $this->assertSame( WP_Block_Processor::CLOSER, $processor->get_delimiter_type() );
+ }
+
+ /**
+ * Test normalize_block_type static method.
+ */
+ public function test_normalize_block_type() {
+ $this->assertSame( 'core/paragraph', WP_Block_Processor::normalize_block_type( 'paragraph' ) );
+ $this->assertSame( 'core/paragraph', WP_Block_Processor::normalize_block_type( 'core/paragraph' ) );
+ $this->assertSame( 'my/block', WP_Block_Processor::normalize_block_type( 'my/block' ) );
+ }
+
+ /**
+ * Test empty content handling.
+ */
+ public function test_empty_content_handling() {
+ $processor = new WP_Block_Processor( '' );
+
+ $this->assertFalse( $processor->next_block() );
+ $this->assertFalse( $processor->next_token() );
+ }
+
+ /**
+ * Test multiple blocks iteration.
+ */
+ public function test_multiple_blocks_iteration() {
+ $content = 'One
Two
Three
';
+ $processor = new WP_Block_Processor( $content );
+
+ $count = 0;
+ while ( $processor->next_block() ) {
+ ++$count;
+ }
+
+ $this->assertSame( 3, $count );
+ }
+
+ /**
+ * Test deeply nested blocks.
+ */
+ public function test_deeply_nested_blocks() {
+ $content = 'Deep
';
+ $processor = new WP_Block_Processor( $content );
+
+ // Navigate to the deepest block.
+ $processor->next_block(); // First group.
+ $processor->next_block(); // Second group.
+ $processor->next_block(); // Third group.
+ $processor->next_block(); // Paragraph.
+
+ $this->assertSame( 4, $processor->get_depth() );
+ $this->assertSame( 'core/paragraph', $processor->get_block_type() );
+ }
+
+ /**
+ * Test freeform HTML content detection.
+ */
+ public function test_freeform_html_content() {
+ $content = 'Freeform HTML
Block
';
+ $processor = new WP_Block_Processor( $content );
+
+ // First token should be HTML.
+ $processor->next_token();
+ $this->assertTrue( $processor->is_html() );
+
+ // Second token should be the paragraph opener.
+ $processor->next_token();
+ $this->assertFalse( $processor->is_html() );
+ $this->assertSame( 'core/paragraph', $processor->get_block_type() );
+ }
+
+ /**
+ * Test is_non_whitespace_html distinguishes content.
+ */
+ public function test_is_non_whitespace_html() {
+ $content = "\n\nTest
\n\n";
+ $processor = new WP_Block_Processor( $content );
+
+ // First token is whitespace HTML.
+ $processor->next_token();
+ $this->assertTrue( $processor->is_html() );
+ $this->assertFalse( $processor->is_non_whitespace_html() );
+ }
+
+ /**
+ * Test get_html_content returns correct content.
+ */
+ public function test_get_html_content() {
+ $content = 'Freeform
Block
';
+ $processor = new WP_Block_Processor( $content );
+
+ $processor->next_token();
+ $this->assertSame( 'Freeform
', $processor->get_html_content() );
+ }
+
+ // =========================================================================
+ // Additional Polyfill Verification Tests (Task 9)
+ // =========================================================================
+
+ /**
+ * Test next_block with type parameter filters correctly.
+ */
+ public function test_next_block_type_filter_with_namespace() {
+ $content = 'Skip
';
+ $content .= 'Find
';
+
+ $processor = new WP_Block_Processor( $content );
+
+ // Should skip paragraph and find heading.
+ $this->assertTrue( $processor->next_block( 'core/heading' ) );
+ $this->assertSame( 'core/heading', $processor->get_block_type() );
+ }
+
+ /**
+ * Test next_block with shorthand type name.
+ */
+ public function test_next_block_type_filter_shorthand() {
+ $content = 'Skip
';
+ $content .= 'Find
';
+
+ $processor = new WP_Block_Processor( $content );
+
+ // Shorthand should also work.
+ $this->assertTrue( $processor->next_block( 'heading' ) );
+ $this->assertSame( 'core/heading', $processor->get_block_type() );
+ }
+
+ /**
+ * Test get_breadcrumbs returns correct path.
+ */
+ public function test_get_breadcrumbs_nested() {
+ $content = 'Deep
';
+
+ $processor = new WP_Block_Processor( $content );
+
+ // Navigate to paragraph.
+ while ( $processor->next_block() ) {
+ if ( 'core/paragraph' === $processor->get_block_type() ) {
+ break;
+ }
+ }
+
+ $breadcrumbs = $processor->get_breadcrumbs();
+
+ $this->assertContains( 'core/group', $breadcrumbs );
+ $this->assertContains( 'core/columns', $breadcrumbs );
+ $this->assertContains( 'core/column', $breadcrumbs );
+ }
+
+ /**
+ * Test is_block_type with custom namespace.
+ */
+ public function test_is_block_type_custom_namespace() {
+ $content = '';
+ $processor = new WP_Block_Processor( $content );
+ $processor->next_block();
+
+ $this->assertTrue( $processor->is_block_type( 'my-plugin/custom-block' ) );
+ $this->assertFalse( $processor->is_block_type( 'other-plugin/custom-block' ) );
+ $this->assertFalse( $processor->is_block_type( 'my-plugin/other-block' ) );
+ }
+
+ /**
+ * Test extract_full_block_and_advance returns correct structure.
+ */
+ public function test_extract_full_block_structure() {
+ $content = 'Test
';
+
+ $processor = new WP_Block_Processor( $content );
+ $processor->next_block();
+
+ $block = $processor->extract_full_block_and_advance();
+
+ $this->assertArrayHasKey( 'blockName', $block );
+ $this->assertArrayHasKey( 'attrs', $block );
+ $this->assertArrayHasKey( 'innerBlocks', $block );
+ $this->assertArrayHasKey( 'innerHTML', $block );
+ $this->assertArrayHasKey( 'innerContent', $block );
+
+ $this->assertSame( 'core/paragraph', $block['blockName'] );
+ $this->assertSame( 'center', $block['attrs']['align'] );
+ $this->assertEmpty( $block['innerBlocks'] );
+ $this->assertStringContainsString( 'Test', $block['innerHTML'] );
+ }
+
+ /**
+ * Test extract_full_block_and_advance with nested blocks.
+ */
+ public function test_extract_full_block_with_nested() {
+ $content = '';
+
+ $processor = new WP_Block_Processor( $content );
+ $processor->next_block();
+
+ $block = $processor->extract_full_block_and_advance();
+
+ $this->assertSame( 'core/group', $block['blockName'] );
+ $this->assertCount( 1, $block['innerBlocks'] );
+ $this->assertSame( 'core/paragraph', $block['innerBlocks'][0]['blockName'] );
+ }
+
+ /**
+ * Test handling of complex nested attributes.
+ */
+ public function test_complex_nested_attributes() {
+ $content = '';
+
+ $processor = new WP_Block_Processor( $content );
+ $processor->next_block();
+
+ $attrs = $processor->allocate_and_return_parsed_attributes();
+
+ $this->assertSame( [ 1, 2, 3 ], $attrs['ids'] );
+ $this->assertSame( true, $attrs['nested']['deep']['value'] );
+ }
+
+ /**
+ * Test that opener detection works correctly.
+ */
+ public function test_opens_block_with_specific_type() {
+ $content = 'Title
';
+
+ $processor = new WP_Block_Processor( $content );
+ $processor->next_block();
+
+ $this->assertTrue( $processor->opens_block() );
+ $this->assertTrue( $processor->opens_block( 'heading' ) );
+ $this->assertTrue( $processor->opens_block( 'core/heading' ) );
+ $this->assertFalse( $processor->opens_block( 'paragraph' ) );
+ }
+
+ /**
+ * Test multiple sequential blocks with extraction.
+ */
+ public function test_multiple_blocks_extraction() {
+ $content = 'One
';
+ $content .= 'Two
';
+ $content .= 'Three
';
+
+ $processor = new WP_Block_Processor( $content );
+ $blocks = [];
+
+ while ( $processor->next_block() ) {
+ $blocks[] = $processor->extract_full_block_and_advance();
+ }
+
+ $this->assertCount( 3, $blocks );
+ $this->assertSame( 'core/paragraph', $blocks[0]['blockName'] );
+ $this->assertSame( 'core/heading', $blocks[1]['blockName'] );
+ $this->assertSame( 'core/paragraph', $blocks[2]['blockName'] );
+ }
+
+ /**
+ * Test depth tracking with multiple nested levels.
+ */
+ public function test_depth_tracking_complex() {
+ $content = 'Test
';
+
+ $processor = new WP_Block_Processor( $content );
+ $max_depth = 0;
+
+ while ( $processor->next_block() ) {
+ $depth = $processor->get_depth();
+ if ( $depth > $max_depth ) {
+ $max_depth = $depth;
+ }
+ }
+
+ $this->assertSame( 4, $max_depth );
+ }
+
+ /**
+ * Test void block with complex attributes.
+ */
+ public function test_void_block_with_complex_attrs() {
+ $content = '';
+
+ $processor = new WP_Block_Processor( $content );
+ $processor->next_block();
+
+ $this->assertSame( WP_Block_Processor::VOID, $processor->get_delimiter_type() );
+
+ $attrs = $processor->allocate_and_return_parsed_attributes();
+
+ $this->assertSame( 123, $attrs['id'] );
+ $this->assertSame( 'large', $attrs['sizeSlug'] );
+ $this->assertSame( 'media', $attrs['linkDestination'] );
+ $this->assertSame( 'is-style-rounded', $attrs['className'] );
+ }
+
+ /**
+ * Test handling of Unicode in block content.
+ */
+ public function test_unicode_content_handling() {
+ $content = '日本語テスト
';
+
+ $processor = new WP_Block_Processor( $content );
+ $processor->next_block();
+
+ $attrs = $processor->allocate_and_return_parsed_attributes();
+
+ $this->assertSame( '日本語テスト', $attrs['text'] );
+ }
+
+ /**
+ * Test span offsets for block in middle of content.
+ */
+ public function test_span_offset_middle_block() {
+ $prefix = 'Some text before ';
+ $block = 'Test
';
+ $suffix = ' some text after';
+ $content = $prefix . $block . $suffix;
+
+ $processor = new WP_Block_Processor( $content );
+ $processor->next_block();
+
+ $span = $processor->get_span();
+
+ $this->assertSame( strlen( $prefix ), $span->start );
+ }
+
+ /**
+ * Test is_non_whitespace_html with actual content.
+ */
+ public function test_is_non_whitespace_html_with_content() {
+ $content = 'Real content
Block
';
+
+ $processor = new WP_Block_Processor( $content );
+ $processor->next_token();
+
+ $this->assertTrue( $processor->is_html() );
+ $this->assertTrue( $processor->is_non_whitespace_html() );
+ }
+}
diff --git a/tests/Compat/WP_HTML_SpanTest.php b/tests/Compat/WP_HTML_SpanTest.php
new file mode 100644
index 00000000..8bf8b7d2
--- /dev/null
+++ b/tests/Compat/WP_HTML_SpanTest.php
@@ -0,0 +1,73 @@
+assertSame( 10, $span->start );
+ $this->assertSame( 25, $span->length );
+ }
+
+ /**
+ * Test constructor with zero values.
+ */
+ public function test_constructor_with_zero_values() {
+ $span = new WP_HTML_Span( 0, 0 );
+
+ $this->assertSame( 0, $span->start );
+ $this->assertSame( 0, $span->length );
+ }
+
+ /**
+ * Test constructor with large values.
+ */
+ public function test_constructor_with_large_values() {
+ $span = new WP_HTML_Span( 1000000, 5000000 );
+
+ $this->assertSame( 1000000, $span->start );
+ $this->assertSame( 5000000, $span->length );
+ }
+
+ /**
+ * Test properties are public and accessible.
+ */
+ public function test_properties_are_public() {
+ $span = new WP_HTML_Span( 5, 10 );
+
+ // Properties should be directly accessible.
+ $span->start = 20;
+ $span->length = 30;
+
+ $this->assertSame( 20, $span->start );
+ $this->assertSame( 30, $span->length );
+ }
+
+ /**
+ * Test use case: extracting substring from document.
+ */
+ public function test_use_case_extract_substring() {
+ $document = 'Hello World
';
+ $span = new WP_HTML_Span( 21, 18 ); // "Hello World
"
+
+ $extracted = substr( $document, $span->start, $span->length );
+
+ $this->assertSame( 'Hello World
', $extracted );
+ }
+}
diff --git a/tests/bootstrap.php b/tests/bootstrap.php
new file mode 100644
index 00000000..8a813c5f
--- /dev/null
+++ b/tests/bootstrap.php
@@ -0,0 +1,25 @@
+