From 9280759836342a5631b37f4bc40791f1e0e63729 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 14 Sep 2026 13:55:09 +0000 Subject: [PATCH] Add `wp post convert-to-blocks` for server-side block conversion Adds a `post convert-to-blocks ... [--dry-run]` subcommand that turns classic post content into block markup using the server-side conversion from WordPress/gutenberg#82013 (`gutenberg_html_to_block_markup()`). Posts that cannot be found count as failures, posts without content or that already contain blocks are skipped, and the run ends with the usual batch summary. Without that Gutenberg build active, the command fails with a clear error pointing at the pull request. Only the "not available" scenario runs in CI; the scenarios that need the Gutenberg build are tagged @broken with instructions for running them locally. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01ECHX5L8q3jXJTxQqX94rH7 --- README.md | 56 ++++++++++ composer.json | 1 + features/post-convert-to-blocks.feature | 132 ++++++++++++++++++++++++ src/Post_Command.php | 128 +++++++++++++++++++++++ 4 files changed, 317 insertions(+) create mode 100644 features/post-convert-to-blocks.feature diff --git a/README.md b/README.md index 72d9fdf73..533970b24 100644 --- a/README.md +++ b/README.md @@ -4696,6 +4696,62 @@ or return code 1 if it does not. +### wp post convert-to-blocks + +Converts the classic (non-block) content of one or more posts to block markup. + +~~~ +wp post convert-to-blocks ... [--dry-run] +~~~ + +Uses the server-side block conversion provided by the Gutenberg plugin +(see https://github.com/WordPress/gutenberg/pull/82013) to turn classic +HTML content into serialized block markup. Markup that no block claims +is kept verbatim inside a Custom HTML block, so nothing is lost. Posts +that already contain blocks or have no content are skipped. + +The conversion does not sanitize the markup. Saving the converted content +applies the usual kses filtering for the current user context, so a run +without `--user` is filtered as an untrusted author would be. Run the +command with `--user=` to keep markup that requires the +`unfiltered_html` capability, such as iframes or scripts. + +Requires a Gutenberg build that provides `gutenberg_html_to_block_markup()`. + +**OPTIONS** + + ... + One or more IDs of posts to convert. + + [--dry-run] + Preview which posts would be converted, without saving any changes. + +**EXAMPLES** + + # Convert a single post. + $ wp post convert-to-blocks 123 + Converted post 123. + Success: Converted 1 of 1 posts. + + # Convert every post of a post type. + $ wp post list --post_type=post --format=ids | xargs wp post convert-to-blocks + Converted post 123. + Converted post 124. + Warning: Post 125 already contains blocks. + Success: Converted 2 of 3 posts (1 skipped). + + # Preview a conversion without saving. + $ wp post convert-to-blocks 123 --dry-run + Would convert post 123. + Success: Would convert 1 of 1 posts. + + # Run as an administrator to keep markup that requires unfiltered_html. + $ wp post convert-to-blocks 123 --user=admin + Converted post 123. + Success: Converted 1 of 1 posts. + + + ### wp post block Manages blocks within post content. diff --git a/composer.json b/composer.json index fcd353184..2a3265ca2 100644 --- a/composer.json +++ b/composer.json @@ -148,6 +148,7 @@ "post url-to-id", "post has-blocks", "post has-block", + "post convert-to-blocks", "post block", "post block clone", "post block count", diff --git a/features/post-convert-to-blocks.feature b/features/post-convert-to-blocks.feature new file mode 100644 index 000000000..7b1bff0d9 --- /dev/null +++ b/features/post-convert-to-blocks.feature @@ -0,0 +1,132 @@ +Feature: Convert classic post content to blocks + + # The conversion depends on the server-side block conversion from + # https://github.com/WordPress/gutenberg/pull/82013 (branch + # `try/13163-php-block-conversion`), which no released Gutenberg build + # provides yet. The scenarios tagged @broken below need a Gutenberg build + # of that PR. To run them locally, build the plugin ZIP from that branch, + # remove the @broken tags, and add the following step after the + # `Given a WP install` step in each scenario: + # + # And I run `wp plugin install /path/to/gutenberg.zip --activate` + + Scenario: Conversion requires the Gutenberg build with server-side block conversion + Given a WP install + + When I try `wp post convert-to-blocks 1` + 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 + + @broken + Scenario: Convert a classic post to blocks + Given a WP install + + When I run `wp post create --post_title='Classic post' --post_content='

Title

Text

' --porcelain` + Then save STDOUT as {POST_ID} + + When I run `wp post convert-to-blocks {POST_ID}` + Then STDOUT should be: + """ + Converted post {POST_ID}. + Success: Converted 1 of 1 posts. + """ + And STDERR should be empty + + When I run `wp post get {POST_ID} --field=post_content` + Then STDOUT should contain: + """ + + """ + And STDOUT should contain: + """ + + """ + + When I run `wp post has-blocks {POST_ID}` + Then STDOUT should be: + """ + Success: Post {POST_ID} contains blocks. + """ + + @broken + Scenario: Preview a conversion with --dry-run + Given a WP install + + When I run `wp post create --post_title='Classic post' --post_content='

Title

Text

' --porcelain` + Then save STDOUT as {POST_ID} + + When I run `wp post convert-to-blocks {POST_ID} --dry-run` + Then STDOUT should be: + """ + Would convert post {POST_ID}. + Success: Would convert 1 of 1 posts. + """ + And STDERR should be empty + + When I run `wp post get {POST_ID} --field=post_content` + Then STDOUT should be: + """ +

Title

Text

+ """ + + @broken + Scenario: Skip posts that already contain blocks + Given a WP install + + When I run `wp post create --post_title='Block post' --post_content='

Text

' --porcelain` + Then save STDOUT as {POST_ID} + + When I try `wp post convert-to-blocks {POST_ID}` + Then STDERR should be: + """ + Warning: Post {POST_ID} already contains blocks. + """ + And STDOUT should be: + """ + Success: Converted 0 of 1 posts (1 skipped). + """ + And the return code should be 0 + + @broken + Scenario: Skip posts without content + Given a WP install + + When I run `wp post create --post_title='Empty post' --post_content='' --porcelain` + Then save STDOUT as {POST_ID} + + When I try `wp post convert-to-blocks {POST_ID}` + Then STDERR should be: + """ + Warning: Post {POST_ID} has no content to convert. + """ + And STDOUT should be: + """ + Success: Converted 0 of 1 posts (1 skipped). + """ + And the return code should be 0 + + @broken + Scenario: Report missing posts as failures + Given a WP install + + When I run `wp post create --post_title='Classic post' --post_content='

Text

' --porcelain` + Then save STDOUT as {POST_ID} + + When I try `wp post convert-to-blocks {POST_ID} 99999` + Then STDERR should contain: + """ + Warning: Could not find the post with ID 99999. + """ + And STDERR should contain: + """ + Error: Only converted 1 of 2 posts (1 failed). + """ + And STDOUT should be: + """ + Converted post {POST_ID}. + """ + And the return code should be 1 diff --git a/src/Post_Command.php b/src/Post_Command.php index 41f3e1216..202bdab7d 100644 --- a/src/Post_Command.php +++ b/src/Post_Command.php @@ -1443,6 +1443,134 @@ public function has_block( $args, $assoc_args ) { } } + /** + * Converts the classic (non-block) content of one or more posts to block markup. + * + * Uses the server-side block conversion provided by the Gutenberg plugin + * (see https://github.com/WordPress/gutenberg/pull/82013) to turn classic + * HTML content into serialized block markup. Markup that no block claims + * is kept verbatim inside a Custom HTML block, so nothing is lost. Posts + * that already contain blocks or have no content are skipped. + * + * The conversion does not sanitize the markup. Saving the converted content + * applies the usual kses filtering for the current user context, so a run + * without `--user` is filtered as an untrusted author would be. Run the + * command with `--user=` to keep markup that requires the + * `unfiltered_html` capability, such as iframes or scripts. + * + * Requires a Gutenberg build that provides `gutenberg_html_to_block_markup()`. + * + * ## OPTIONS + * + * ... + * : One or more IDs of posts to convert. + * + * [--dry-run] + * : Preview which posts would be converted, without saving any changes. + * + * ## EXAMPLES + * + * # Convert a single post. + * $ wp post convert-to-blocks 123 + * Converted post 123. + * Success: Converted 1 of 1 posts. + * + * # Convert every post of a post type. + * $ wp post list --post_type=post --format=ids | xargs wp post convert-to-blocks + * Converted post 123. + * Converted post 124. + * Warning: Post 125 already contains blocks. + * Success: Converted 2 of 3 posts (1 skipped). + * + * # Preview a conversion without saving. + * $ wp post convert-to-blocks 123 --dry-run + * Would convert post 123. + * Success: Would convert 1 of 1 posts. + * + * # Run as an administrator to keep markup that requires unfiltered_html. + * $ wp post convert-to-blocks 123 --user=admin + * Converted post 123. + * Success: Converted 1 of 1 posts. + * + * @subcommand convert-to-blocks + */ + public function convert_to_blocks( $args, $assoc_args ) { + if ( ! function_exists( 'gutenberg_html_to_block_markup' ) ) { + WP_CLI::error( 'Server-side block conversion is not available. Activate the Gutenberg plugin from https://github.com/WordPress/gutenberg/pull/82013.' ); + } + + $dry_run = (bool) Utils\get_flag_value( $assoc_args, 'dry-run', false ); + + $total = count( $args ); + $successes = 0; + $errors = 0; + $skips = 0; + + foreach ( $args as $post_id ) { + $post = $this->fetcher->get( $post_id ); + + if ( ! $post ) { + WP_CLI::warning( "Could not find the post with ID {$post_id}." ); + ++$errors; + continue; + } + + if ( '' === trim( (string) $post->post_content ) ) { + WP_CLI::warning( "Post {$post->ID} has no content to convert." ); + ++$skips; + continue; + } + + if ( Block_Processor_Helper::has_blocks( $post->post_content ) ) { + WP_CLI::warning( "Post {$post->ID} already contains blocks." ); + ++$skips; + continue; + } + + $markup = gutenberg_html_to_block_markup( $post->post_content ); + + if ( $dry_run ) { + WP_CLI::log( "Would convert post {$post->ID}." ); + ++$successes; + continue; + } + + $result = wp_update_post( + [ + 'ID' => $post->ID, + 'post_content' => $markup, + ], + true + ); + + if ( is_wp_error( $result ) ) { + WP_CLI::warning( "Failed converting post {$post->ID}: " . $result->get_error_message() ); + ++$errors; + continue; + } + + WP_CLI::log( "Converted post {$post->ID}." ); + ++$successes; + } + + if ( ! $dry_run ) { + Utils\report_batch_operation_results( 'post', 'convert', $total, $successes, $errors, $skips ); + return; + } + + $skipped_message = $skips ? " ({$skips} skipped)" : ''; + + if ( $errors ) { + $failed_skipped_message = " ({$errors} failed" . ( $skips ? ", {$skips} skipped" : '' ) . ')'; + if ( $successes ) { + WP_CLI::error( "Would only convert {$successes} of {$total} posts{$failed_skipped_message}." ); + } + WP_CLI::error( "No posts would be converted{$failed_skipped_message}." ); + } + + WP_CLI::success( "Would convert {$successes} of {$total} posts{$skipped_message}." ); + } + /** * Convert a date-time string with a hyphen separator to a space separator. *