From 27d5c9c3dd31930e1d5a92f6c01152b68c83383a Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Mon, 14 Sep 2026 14:18:59 +0000 Subject: [PATCH] Add `wp block convert` and `wp block conversion-support` Add two commands that expose the server-side block conversion from WordPress/gutenberg PR #82013: - `wp block convert [] [--file=]` converts HTML to block markup with `gutenberg_html_to_block_markup()`, reading the HTML from the argument, a file, or STDIN. - `wp block conversion-support [--format=]` lists the blocks a conversion can produce with `gutenberg_get_block_conversion_support()`, one row per block with `converts`, `conditional` or `declines`. Both commands fail with a clear error when the conversion functions are not available. PHPStan gets stubs for those functions so static analysis can type-check the calls. The feature file has two scenarios that run in CI against a plain install and four scenarios tagged @broken that need a Gutenberg build from the pull request, with instructions on how to run them locally. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01YZNpaiVywfzpc8Hpm6dMa8 --- .distignore | 1 + README.md | 123 ++++++++ block-command.php | 5 + composer.json | 2 + features/block-convert.feature | 162 +++++++++++ phpcs.xml.dist | 2 + phpstan.neon.dist | 1 + phpstan/gutenberg-block-conversion-stubs.php | 32 +++ src/Block_Conversion_Command.php | 279 +++++++++++++++++++ 9 files changed, 607 insertions(+) create mode 100644 features/block-convert.feature create mode 100644 phpstan/gutenberg-block-conversion-stubs.php create mode 100644 src/Block_Conversion_Command.php diff --git a/.distignore b/.distignore index 2f9c78d..a217931 100644 --- a/.distignore +++ b/.distignore @@ -10,6 +10,7 @@ phpcs.xml.dist phpunit.xml.dist bin/ features/ +phpstan/ utils/ *.zip *.tar.gz diff --git a/README.md b/README.md index 102bd84..de9bbc3 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,129 @@ wp block +### wp block convert + +Converts HTML to block markup. + +~~~ +wp block convert [] [--file=] +~~~ + +Markup that no block claims is kept verbatim inside a Custom HTML block. +Input that already contains block delimiters is parsed as blocks rather +than converted again. + +The result is printed as-is and is not sanitized. Run it through the +usual filters, for example by saving it with `wp post update`, before +storing it as post content. + +Requires an active Gutenberg build from +https://github.com/WordPress/gutenberg/pull/82013. + +**OPTIONS** + + [] + The HTML to convert. Reads from --file or STDIN when omitted. + + [--file=] + Read the HTML from a file instead of the argument. + +**EXAMPLES** + + # Convert HTML passed as an argument + $ wp block convert '

Title

Text

' + +

Title

+ + + +

Text

+ + + # Convert the contents of a file + $ wp block convert --file=page.html + + # Convert HTML from STDIN + $ cat page.html | wp block convert + + # Convert a post's content in place + $ wp post get 123 --field=post_content | wp block convert | wp post update 123 - + + + +### wp block conversion-support + +Lists the blocks a server-side conversion can produce. + +~~~ +wp block conversion-support [--support=] [--field=] [--fields=] [--format=] +~~~ + +Reports one row per block. The `support` column is one of: + +* converts: produced from any markup the block matches. +* conditional: produced only from markup the block can save back. +* declines: deliberately not produced by a server-side conversion. + +Requires an active Gutenberg build from +https://github.com/WordPress/gutenberg/pull/82013. + +**OPTIONS** + + [--support=] + Only list blocks with this level of support. + --- + options: + - converts + - conditional + - declines + --- + + [--field=] + Prints the value of a single field for each block. + + [--fields=] + Limit the output to specific fields. + + [--format=] + Render output in a particular format. + --- + default: table + options: + - table + - csv + - json + - count + - yaml + --- + +**AVAILABLE FIELDS** + +These fields will be displayed by default for each block: + +* name +* support + +**EXAMPLES** + + # List what a conversion can produce + $ wp block conversion-support + +----------------+-------------+ + | name | support | + +----------------+-------------+ + | core/heading | converts | + | core/paragraph | converts | + | core/table | declines | + +----------------+-------------+ + + # List only the blocks that decline conversion + $ wp block conversion-support --support=declines --field=name + + # Get the support map as JSON + $ wp block conversion-support --format=json + + + ### wp block type Retrieves details on registered block types. diff --git a/block-command.php b/block-command.php index 83f6d0f..e1cebe8 100644 --- a/block-command.php +++ b/block-command.php @@ -54,3 +54,8 @@ WP_CLI::add_command( 'block binding', WP_CLI\Block\Block_Binding_Command::class, [ 'before_invoke' => $wpcli_block_before_invoke_6_5 ] ); WP_CLI::add_command( 'block template', WP_CLI\Block\Block_Template_Command::class, [ 'before_invoke' => $wpcli_block_before_invoke_5_9 ] ); WP_CLI::add_command( 'block synced-pattern', WP_CLI\Block\Block_Synced_Pattern_Command::class, [ 'before_invoke' => $wpcli_block_before_invoke_5_0 ] ); + +// Server-side block conversion, provided by https://github.com/WordPress/gutenberg/pull/82013. +// The commands check for the conversion functions themselves on top of the version gate. +WP_CLI::add_command( 'block convert', [ WP_CLI\Block\Block_Conversion_Command::class, 'convert' ], [ 'before_invoke' => $wpcli_block_before_invoke_5_0 ] ); +WP_CLI::add_command( 'block conversion-support', [ WP_CLI\Block\Block_Conversion_Command::class, 'conversion_support' ], [ 'before_invoke' => $wpcli_block_before_invoke_5_0 ] ); diff --git a/composer.json b/composer.json index 312ae18..d206e54 100644 --- a/composer.json +++ b/composer.json @@ -29,6 +29,8 @@ "bundled": true, "commands": [ "block", + "block convert", + "block conversion-support", "block type", "block type exists", "block type list", diff --git a/features/block-convert.feature b/features/block-convert.feature new file mode 100644 index 0000000..a8e1fd7 --- /dev/null +++ b/features/block-convert.feature @@ -0,0 +1,162 @@ +Feature: Server-side block conversion commands + + # The conversion functions ship with the Gutenberg build from + # https://github.com/WordPress/gutenberg/pull/82013 and are not part of any + # released WordPress or Gutenberg version yet, so on a plain install every + # command fails with the same error. + + @require-wp-5.0 + Scenario: Convert fails without the server-side block conversion + Given a WP install + + When I try `wp block convert '

Text

'` + Then STDERR should be: + """ + Error: Server-side block conversion is not available. Activate the Gutenberg plugin from https://github.com/WordPress/gutenberg/pull/82013. + """ + And STDOUT should be empty + And the return code should be 1 + + @require-wp-5.0 + Scenario: Conversion support fails without the server-side block conversion + Given a WP install + + When I try `wp block conversion-support` + Then STDERR should be: + """ + Error: Server-side block conversion is not available. Activate the Gutenberg plugin from https://github.com/WordPress/gutenberg/pull/82013. + """ + And STDOUT should be empty + And the return code should be 1 + + # The scenarios below need a Gutenberg build from that pull request, which + # CI cannot install, so they are tagged @broken and `composer behat` skips + # them. To run them locally: + # + # 1. Build the plugin zip from the `try/13163-php-block-conversion` branch + # (`npm run build:plugin-zip` in a Gutenberg checkout). + # 2. Copy it to `gutenberg.zip` in the root of this package. Git ignores it. + # 3. Prepare the test database once with `composer prepare-tests`. + # 4. Run the scenarios by tag, bypassing the @broken exclusion: + # + # vendor/bin/behat --tags=@broken features/block-convert.feature + # + # Each scenario installs the zip with `wp plugin install gutenberg.zip --activate`. + + @broken + Scenario: Convert HTML passed as an argument + Given a WP install + And I run `wp plugin install {PROJECT_DIR}/gutenberg.zip --activate` + + When I run `wp block convert '

Title

Text

'` + Then STDOUT should contain: + """ + + """ + And STDOUT should contain: + """ + + """ + And STDOUT should contain: + """ +

Text

+ """ + And STDERR should be empty + + @broken + Scenario: Convert HTML from STDIN and from a file + Given a WP install + And I run `wp plugin install {PROJECT_DIR}/gutenberg.zip --activate` + And a page.html file: + """ +

Title

+

Text

+ """ + + When I run `cat page.html | wp block convert` + Then STDOUT should contain: + """ + + """ + And STDOUT should contain: + """ + + """ + + When I run `wp block convert --file=page.html` + Then STDOUT should contain: + """ + + """ + And STDOUT should contain: + """ + + """ + + When I try `wp block convert --file=missing.html` + Then STDERR should contain: + """ + Error: File 'missing.html' does not exist or is not readable. + """ + And the return code should be 1 + + When I try `wp block convert '

Text

' --file=page.html` + Then STDERR should contain: + """ + Error: Pass the HTML either as an argument or with --file, not both. + """ + And the return code should be 1 + + When I try `wp block convert < /dev/null` + Then STDERR should contain: + """ + Error: No HTML given. Pass it as an argument, with --file, or on STDIN. + """ + And the return code should be 1 + + @broken + Scenario: Existing block markup is left alone and unknown markup becomes a Custom HTML block + Given a WP install + And I run `wp plugin install {PROJECT_DIR}/gutenberg.zip --activate` + + When I run `wp block convert '

Text

'` + Then STDOUT should be: + """ +

Text

+ """ + + When I run `wp block convert 'Text'` + Then STDOUT should contain: + """ + + """ + + @broken + Scenario: List the blocks a conversion can produce + Given a WP install + And I run `wp plugin install {PROJECT_DIR}/gutenberg.zip --activate` + + When I run `wp block conversion-support` + Then STDOUT should be a table containing rows: + | name | support | + | core/paragraph | converts | + | core/table | declines | + + When I run `wp block conversion-support --support=declines --field=name` + Then STDOUT should contain: + """ + core/table + """ + And STDOUT should not contain: + """ + core/paragraph + """ + + When I run `wp block conversion-support --format=json` + Then STDOUT should contain: + """ + {"name":"core\/paragraph","support":"converts"} + """ + + When I run `wp block conversion-support --format=count` + Then STDOUT should be a number diff --git a/phpcs.xml.dist b/phpcs.xml.dist index d51808e..22e43c8 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -4,6 +4,8 @@ . + + */phpstan/* diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 8ffb46d..b8c57dd 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -7,6 +7,7 @@ parameters: - vendor/wp-cli/wp-cli/php scanFiles: - vendor/php-stubs/wordpress-stubs/wordpress-stubs.php + - phpstan/gutenberg-block-conversion-stubs.php treatPhpDocTypesAsCertain: false ignoreErrors: # Standard WP-CLI ignores (same as entity-command) diff --git a/phpstan/gutenberg-block-conversion-stubs.php b/phpstan/gutenberg-block-conversion-stubs.php new file mode 100644 index 0000000..852f7a6 --- /dev/null +++ b/phpstan/gutenberg-block-conversion-stubs.php @@ -0,0 +1,32 @@ +> Parsed block arrays, in the shape returned by `parse_blocks()`. + */ +function gutenberg_html_to_blocks( $html ) {} + +/** + * Converts HTML into serialized block markup. + * + * @param string $html HTML to convert. + * @return string Block markup. + */ +function gutenberg_html_to_block_markup( $html ) {} + +/** + * Reports which blocks a server-side conversion can produce. + * + * @return array{converts: string[], conditional: string[], declines: string[]} + */ +function gutenberg_get_block_conversion_support() {} diff --git a/src/Block_Conversion_Command.php b/src/Block_Conversion_Command.php new file mode 100644 index 0000000..7df96ff --- /dev/null +++ b/src/Block_Conversion_Command.php @@ -0,0 +1,279 @@ +Title

Text

' + * + * # List the blocks a conversion can produce + * $ wp block conversion-support + * + * @package wp-cli + */ +class Block_Conversion_Command extends WP_CLI_Command { + + /** + * Pull request that provides the server-side block conversion. + * + * @var string + */ + const GUTENBERG_PR_URL = 'https://github.com/WordPress/gutenberg/pull/82013'; + + /** + * Converts HTML to block markup. + * + * Markup that no block claims is kept verbatim inside a Custom HTML block. + * Input that already contains block delimiters is parsed as blocks rather + * than converted again. + * + * The result is printed as-is and is not sanitized. Run it through the + * usual filters, for example by saving it with `wp post update`, before + * storing it as post content. + * + * Requires an active Gutenberg build from + * https://github.com/WordPress/gutenberg/pull/82013. + * + * ## OPTIONS + * + * [] + * : The HTML to convert. Reads from --file or STDIN when omitted. + * + * [--file=] + * : Read the HTML from a file instead of the argument. + * + * ## EXAMPLES + * + * # Convert HTML passed as an argument + * $ wp block convert '

Title

Text

' + * + *

Title

+ * + * + * + *

Text

+ * + * + * # Convert the contents of a file + * $ wp block convert --file=page.html + * + * # Convert HTML from STDIN + * $ cat page.html | wp block convert + * + * # Convert a post's content in place + * $ wp post get 123 --field=post_content | wp block convert | wp post update 123 - + * + * @param array $args Positional arguments. + * @param array $assoc_args Associative arguments. + */ + public function convert( $args, $assoc_args ) { + $this->check_conversion_available( 'gutenberg_html_to_block_markup' ); + + $html = $this->read_html( $args, $assoc_args ); + + WP_CLI::line( gutenberg_html_to_block_markup( $html ) ); + } + + /** + * Lists the blocks a server-side conversion can produce. + * + * Reports one row per block. The `support` column is one of: + * + * * converts: produced from any markup the block matches. + * * conditional: produced only from markup the block can save back. + * * declines: deliberately not produced by a server-side conversion. + * + * Requires an active Gutenberg build from + * https://github.com/WordPress/gutenberg/pull/82013. + * + * ## OPTIONS + * + * [--support=] + * : Only list blocks with this level of support. + * --- + * options: + * - converts + * - conditional + * - declines + * --- + * + * [--field=] + * : Prints the value of a single field for each block. + * + * [--fields=] + * : Limit the output to specific fields. + * + * [--format=] + * : Render output in a particular format. + * --- + * default: table + * options: + * - table + * - csv + * - json + * - count + * - yaml + * --- + * + * ## AVAILABLE FIELDS + * + * These fields will be displayed by default for each block: + * + * * name + * * support + * + * ## EXAMPLES + * + * # List what a conversion can produce + * $ wp block conversion-support + * +----------------+-------------+ + * | name | support | + * +----------------+-------------+ + * | core/heading | converts | + * | core/paragraph | converts | + * | core/table | declines | + * +----------------+-------------+ + * + * # List only the blocks that decline conversion + * $ wp block conversion-support --support=declines --field=name + * + * # Get the support map as JSON + * $ wp block conversion-support --format=json + * + * @param array $args Positional arguments. + * @param array $assoc_args Associative arguments. + */ + public function conversion_support( $args, $assoc_args ) { + $this->check_conversion_available( 'gutenberg_get_block_conversion_support' ); + + $support = gutenberg_get_block_conversion_support(); + $levels = [ 'converts', 'conditional', 'declines' ]; + + if ( ! empty( $assoc_args['support'] ) ) { + $levels = [ $assoc_args['support'] ]; + unset( $assoc_args['support'] ); + } + + $items = []; + foreach ( $levels as $level ) { + if ( empty( $support[ $level ] ) ) { + continue; + } + foreach ( $support[ $level ] as $name ) { + $items[] = [ + 'name' => $name, + 'support' => $level, + ]; + } + } + + usort( + $items, + static function ( $a, $b ) { + return strcmp( $a['name'], $b['name'] ); + } + ); + + $formatter = new Formatter( $assoc_args, [ 'name', 'support' ], 'block-conversion-support' ); + $formatter->display_items( $items ); + } + + /** + * Fails unless the given conversion function is available. + * + * The conversion functions only exist while a Gutenberg build from the + * pull request that introduces them is active. + * + * @param string $function_name Function the command is about to call. + */ + private function check_conversion_available( $function_name ) { + if ( ! function_exists( $function_name ) ) { + WP_CLI::error( + 'Server-side block conversion is not available. Activate the Gutenberg plugin from ' . self::GUTENBERG_PR_URL . '.' + ); + } + } + + /** + * Reads the HTML to convert from the argument, --file, or STDIN. + * + * @param array $args Positional arguments. + * @param array $assoc_args Associative arguments. + * @return string HTML to convert. + */ + private function read_html( $args, $assoc_args ) { + $has_arg = isset( $args[0] ); + $has_file = isset( $assoc_args['file'] ); + + if ( $has_arg && $has_file ) { + WP_CLI::error( 'Pass the HTML either as an argument or with --file, not both.' ); + } + + if ( $has_arg ) { + $html = $args[0]; + } elseif ( $has_file ) { + $html = $this->read_file( $assoc_args['file'] ); + } else { + $html = $this->read_stdin(); + } + + if ( '' === trim( $html ) ) { + WP_CLI::error( 'No HTML given. Pass it as an argument, with --file, or on STDIN.' ); + } + + return $html; + } + + /** + * Reads a file. + * + * @param string $file Path to the file. + * @return string File contents. + */ + private function read_file( $file ) { + if ( ! is_file( $file ) || ! is_readable( $file ) ) { + WP_CLI::error( "File '{$file}' does not exist or is not readable." ); + } + + $contents = file_get_contents( $file ); + + if ( false === $contents ) { + WP_CLI::error( "Failed to read file '{$file}'." ); + } + + return $contents; + } + + /** + * Reads STDIN. + * + * Treats an interactive terminal as no input rather than waiting for it. + * + * @return string STDIN contents. + */ + private function read_stdin() { + if ( function_exists( 'stream_isatty' ) && stream_isatty( STDIN ) ) { + return ''; + } + + $contents = file_get_contents( 'php://stdin' ); + + if ( false === $contents ) { + WP_CLI::error( 'Failed to read from STDIN.' ); + } + + return $contents; + } +}