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. -[![Testing](https://github.com/wp-cli/entity-command/actions/workflows/testing.yml/badge.svg)](https://github.com/wp-cli/entity-command/actions/workflows/testing.yml) +[![Testing](https://github.com/wp-cli/entity-command/actions/workflows/testing.yml/badge.svg)](https://github.com/wp-cli/entity-command/actions/workflows/testing.yml) [![Code Coverage](https://codecov.io/gh/wp-cli/entity-command/branch/main/graph/badge.svg)](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='
  • Item 1
  • Item 2
' --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}>/", + "", + $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}

"; + } + + 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 = '

Inner

'; + $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 = '

Inner

'; + $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 = '

Inner

'; + $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 = 'Before

Test

'; + $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\n

Test

\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 = '

Inner

'; + + $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 @@ +