From 232e8989bb7b036dfa34c0872c3b14a22449e868 Mon Sep 17 00:00:00 2001 From: sgillot Date: Thu, 17 Sep 2026 21:20:04 +0200 Subject: [PATCH 1/2] Add WP-CLI migration from beapi/anchor blocks to blockparty anchors Co-authored-by: Cursor --- MIGRATION.md | 92 ++++++ blockparty-anchors.php | 6 + .../Cli/MigrateFromAnchorBlockCommand.php | 262 ++++++++++++++++++ includes/Migration/AnchorBlockMigrator.php | 97 +++++++ 4 files changed, 457 insertions(+) create mode 100644 MIGRATION.md create mode 100644 includes/Cli/MigrateFromAnchorBlockCommand.php create mode 100644 includes/Migration/AnchorBlockMigrator.php diff --git a/MIGRATION.md b/MIGRATION.md new file mode 100644 index 0000000..4524f7c --- /dev/null +++ b/MIGRATION.md @@ -0,0 +1,92 @@ +# Migrating from `beapi/anchor` to `blockparty-anchors` + +This document describes how to move from the old BeAPI anchor blocks to `beapi/blockparty-anchors`. + +## Package swap + +```bash +composer require beapi/blockparty-anchors +``` + +Activate `blockparty-anchors` and deactivate the previous anchor plugin. + +## Block model changes + +| Old | New | +|---|---| +| `beapi/anchor` | `blockparty/anchor` | +| `beapi/anchor-list` | `blockparty/anchors-list` | +| CSS `.wp-block-beapi-anchor` | CSS `.wp-block-blockparty-anchor` | +| CSS `.wp-block-beapi-anchor-list` | CSS `.wp-block-blockparty-anchors-list` | + +Attributes (`title`, `slug`, `className`, and any extra attrs) are kept as-is. + +Old blocks are typically void / self-closing. After migration they are serialized as empty self-closing `blockparty/*` comments: + +``` + + +``` + +becomes: + +``` + + +``` + +## Content migration (WP-CLI) + +After activating `blockparty-anchors`: + +```bash +# Preview (current site) +wp blockparty-anchors migrate-from-anchor-block --dry-run + +# Apply on the current site +wp blockparty-anchors migrate-from-anchor-block + +# Limit post types +wp blockparty-anchors migrate-from-anchor-block --post-type=post,page +``` + +The command always targets **one site only**. On multisite, repeat it yourself with `--url=` for each site: + +```bash +wp blockparty-anchors migrate-from-anchor-block --url=https://example.com/ +wp blockparty-anchors migrate-from-anchor-block --url=https://example.com/site-2/ +``` + +The command: + +1. Scans `post_content` for `beapi/anchor` (covers `beapi/anchor` and `beapi/anchor-list`) +2. Parses blocks, converts markup recursively (including nested group/columns), serializes back +3. Updates the post with `wp_slash()` on content (required so `\u002d` escapes for `--` in block comments are not stripped by `wp_update_post()`) +4. Logs migrated / skipped counts + +If `wp_update_post()` fails with `invalid_page_template` (orphaned `_wp_page_template` meta), the command does not abort. It updates `post_content` only via `$wpdb->update()` (unslashed), then calls `clean_post_cache()`. Those posts are logged as `[update-fallback]`. + +### Revisions + +Only the scanned post types are migrated (public REST post types + `wp_block` by default). **Revisions are not included**, so parent posts are updated while historical revisions (and trash) can still contain the old `beapi/anchor` / `beapi/anchor-list` markup. That is expected. To migrate revisions as well, pass them explicitly, for example: + +```bash +wp blockparty-anchors migrate-from-anchor-block --post-type=revision +``` + +### Duplicate Post (Yoast) + +If [Yoast Duplicate Post](https://wordpress.org/plugins/duplicate-post/) is active, updating a Rewrite & Republish **copy** via `wp_update_post()` (especially without `--user=`) can call `wp_die( 'You are not allowed to republish this post.' )` and abort the whole WP-CLI run. + +Preferred workaround: skip that plugin for the migration only (global WP-CLI flag): + +```bash +wp --skip-plugins=duplicate-post blockparty-anchors migrate-from-anchor-block --url=https://example.com/ +``` + +## Front-end checklist + +- Re-register custom block styles on `blockparty/anchor` / `blockparty/anchors-list` +- Update theme SCSS selectors from `.wp-block-beapi-anchor` / `.wp-block-beapi-anchor-list` to `.wp-block-blockparty-anchor` / `.wp-block-blockparty-anchors-list` +- Update block patterns / allowlists +- Smoke-test in-page links, the anchors list, and nested anchors inside groups/columns diff --git a/blockparty-anchors.php b/blockparty-anchors.php index b0a764a..9722cd6 100644 --- a/blockparty-anchors.php +++ b/blockparty-anchors.php @@ -16,6 +16,8 @@ namespace Blockparty\Anchors; +use Blockparty\Anchors\Cli\MigrateFromAnchorBlockCommand; + if ( ! defined( 'ABSPATH' ) ) { exit; // Exit if accessed directly. } @@ -69,3 +71,7 @@ function init() { } add_action( 'init', __NAMESPACE__ . '\\init', 0 ); + +if ( defined( 'WP_CLI' ) && WP_CLI ) { + \WP_CLI::add_command( 'blockparty-anchors migrate-from-anchor-block', MigrateFromAnchorBlockCommand::class ); +} diff --git a/includes/Cli/MigrateFromAnchorBlockCommand.php b/includes/Cli/MigrateFromAnchorBlockCommand.php new file mode 100644 index 0000000..d874076 --- /dev/null +++ b/includes/Cli/MigrateFromAnchorBlockCommand.php @@ -0,0 +1,262 @@ +] + * : Comma-separated list of post types. Default: any public post type that shows in the REST API, plus wp_block. + * + * [--posts-per-page=] + * : Batch size. Default: 100. + * + * ## EXAMPLES + * + * wp blockparty-anchors migrate-from-anchor-block --dry-run + * wp blockparty-anchors migrate-from-anchor-block --post-type=post,page + * wp blockparty-anchors migrate-from-anchor-block --url=https://example.com/ + * + * @subcommand migrate-from-anchor-block + * + * @param array $args Positional arguments. + * @param array $assoc_args Associative arguments. + * @return void + */ + public function __invoke( $args, $assoc_args ): void { + $dry_run = (bool) WP_CLI\Utils\get_flag_value( $assoc_args, 'dry-run', false ); + $posts_per_page = (int) ( $assoc_args['posts-per-page'] ?? 100 ); + $post_types = $this->resolve_post_types( $assoc_args['post-type'] ?? '' ); + + if ( empty( $post_types ) ) { + WP_CLI::error( 'No post types to migrate.' ); + } + + WP_CLI::log( + sprintf( + 'Migrating site %1$d (%2$s)%3$s — post types: %4$s', + get_current_blog_id(), + home_url( '/' ), + $dry_run ? ' [dry-run]' : '', + implode( ', ', $post_types ) + ) + ); + + $migrator = new AnchorBlockMigrator(); + $posts_updated = 0; + $posts_scanned = 0; + $page = 1; + $max_pages = 1; + $total_migrated = 0; + $total_skipped = 0; + + while ( $page <= $max_pages ) { + $query = new WP_Query( + [ + 'post_type' => $post_types, + 'post_status' => 'any', + 'posts_per_page' => $posts_per_page, + 'paged' => $page, + 'orderby' => 'ID', + 'order' => 'ASC', + 'ignore_sticky_posts' => true, + 'no_found_rows' => false, + 'update_post_meta_cache' => false, + 'update_post_term_cache' => false, + ] + ); + + $max_pages = (int) $query->max_num_pages; + + if ( ! $query->have_posts() ) { + break; + } + + foreach ( $query->posts as $post ) { + ++$posts_scanned; + + if ( ! is_string( $post->post_content ) || '' === $post->post_content ) { + continue; + } + + if ( false === strpos( $post->post_content, 'beapi/anchor' ) ) { + continue; + } + + $before_migrated = $migrator->migrated; + $before_skipped = $migrator->skipped; + $new_content = $migrator->migrate_content( $post->post_content ); + + $delta_migrated = $migrator->migrated - $before_migrated; + $delta_skipped = $migrator->skipped - $before_skipped; + $total_migrated += $delta_migrated; + $total_skipped += $delta_skipped; + + if ( null === $new_content ) { + continue; + } + + ++$posts_updated; + + WP_CLI::log( + sprintf( + '%1$s post %2$d (%3$s) — blocks migrated: %4$d, skipped: %5$d', + $dry_run ? '[dry-run]' : '[update]', + (int) $post->ID, + $post->post_type, + $delta_migrated, + $delta_skipped + ) + ); + + if ( ! $dry_run ) { + $this->persist_post_content( (int) $post->ID, $post->post_type, $new_content, $delta_migrated, $delta_skipped ); + } + } + + ++$page; + wp_reset_postdata(); + } + + WP_CLI::success( + sprintf( + 'Done. Posts scanned: %1$d, posts %2$s: %3$d, anchor blocks migrated: %4$d, skipped: %5$d.', + $posts_scanned, + $dry_run ? 'that would update' : 'updated', + $posts_updated, + $total_migrated, + $total_skipped + ) + ); + } + + /** + * Save migrated content, with a content-only fallback for orphaned page templates. + * + * @param int $post_id Post ID. + * @param string $post_type Post type. + * @param string $new_content Unslashed post content. + * @param int $delta_migrated Blocks migrated for this post. + * @param int $delta_skipped Blocks skipped for this post. + * @return void + */ + private function persist_post_content( int $post_id, string $post_type, string $new_content, int $delta_migrated, int $delta_skipped ): void { + // wp_update_post() expects slashed data; without wp_slash(), + // block comment escapes like \u002d (for "--") become bare "u002d". + $updated = wp_update_post( + [ + 'ID' => $post_id, + 'post_content' => wp_slash( $new_content ), + ], + true + ); + + if ( ! is_wp_error( $updated ) ) { + return; + } + + if ( 'invalid_page_template' === $updated->get_error_code() ) { + $fallback_ok = $this->update_post_content_fallback( $post_id, $new_content ); + + if ( $fallback_ok ) { + WP_CLI::log( + sprintf( + '[update-fallback] post %1$d (%2$s) — blocks migrated: %3$d, skipped: %4$d', + $post_id, + $post_type, + $delta_migrated, + $delta_skipped + ) + ); + return; + } + } + + WP_CLI::warning( + sprintf( + 'Failed to update post %1$d: %2$s', + $post_id, + $updated->get_error_message() + ) + ); + } + + /** + * Update post_content only, bypassing page template validation. + * + * @param int $post_id Post ID. + * @param string $new_content Unslashed post content. + * @return bool + */ + private function update_post_content_fallback( int $post_id, string $new_content ): bool { + global $wpdb; + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Content-only fallback when wp_update_post() rejects an orphaned _wp_page_template. + $result = $wpdb->update( + $wpdb->posts, + [ + 'post_content' => $new_content, + ], + [ + 'ID' => $post_id, + ], + [ '%s' ], + [ '%d' ] + ); + + if ( false === $result ) { + return false; + } + + clean_post_cache( $post_id ); + + return true; + } + + /** + * Resolve target post types. + * + * @param string $raw Comma-separated post types from CLI. + * @return string[] + */ + private function resolve_post_types( string $raw ): array { + if ( '' !== trim( $raw ) ) { + $types = array_filter( array_map( 'trim', explode( ',', $raw ) ) ); + + return array_values( $types ); + } + + $types = get_post_types( + [ + 'public' => true, + 'show_in_rest' => true, + ], + 'names' + ); + + $types[] = 'wp_block'; + $types = array_unique( array_values( $types ) ); + + return $types; + } +} diff --git a/includes/Migration/AnchorBlockMigrator.php b/includes/Migration/AnchorBlockMigrator.php new file mode 100644 index 0000000..064d88c --- /dev/null +++ b/includes/Migration/AnchorBlockMigrator.php @@ -0,0 +1,97 @@ +migrate_blocks( parse_blocks( $content ) ) ); + + return $new === $content ? null : $new; + } + + /** + * Walk every block. Replace old anchor blocks. Recurse into groups/columns/etc. + * + * @param array $blocks Parsed blocks. + * @return array + */ + private function migrate_blocks( array $blocks ): array { + $out = []; + + foreach ( $blocks as $block ) { + $name = $block['blockName'] ?? ''; + + if ( 'beapi/anchor' === $name ) { + $out[] = $this->convert_block( $block, 'blockparty/anchor' ); + continue; + } + + if ( 'beapi/anchor-list' === $name ) { + $out[] = $this->convert_block( $block, 'blockparty/anchors-list' ); + continue; + } + + if ( ! empty( $block['innerBlocks'] ) ) { + $block['innerBlocks'] = $this->migrate_blocks( $block['innerBlocks'] ); + } + + $out[] = $block; + } + + return $out; + } + + /** + * Convert one old block to a self-closing blockparty block, keeping attributes. + * + * @param array $block Old parsed block. + * @param string $new_name New block name. + * @return array + */ + private function convert_block( array $block, string $new_name ): array { + ++$this->migrated; + + $attrs = is_array( $block['attrs'] ?? null ) ? $block['attrs'] : []; + + return [ + 'blockName' => $new_name, + 'attrs' => $attrs, + 'innerBlocks' => [], + 'innerHTML' => '', + 'innerContent' => [], + ]; + } +} From 0421419eef0f9ea7b3e09740387ab6d15eff2200 Mon Sep 17 00:00:00 2001 From: sgillot Date: Thu, 17 Sep 2026 21:34:33 +0200 Subject: [PATCH 2/2] Count WP-CLI migration totals only after a successful post update Co-authored-by: Cursor --- .../Cli/MigrateFromAnchorBlockCommand.php | 55 ++++++++----------- 1 file changed, 23 insertions(+), 32 deletions(-) diff --git a/includes/Cli/MigrateFromAnchorBlockCommand.php b/includes/Cli/MigrateFromAnchorBlockCommand.php index d874076..b699ce2 100644 --- a/includes/Cli/MigrateFromAnchorBlockCommand.php +++ b/includes/Cli/MigrateFromAnchorBlockCommand.php @@ -107,31 +107,36 @@ public function __invoke( $args, $assoc_args ): void { $before_skipped = $migrator->skipped; $new_content = $migrator->migrate_content( $post->post_content ); - $delta_migrated = $migrator->migrated - $before_migrated; - $delta_skipped = $migrator->skipped - $before_skipped; - $total_migrated += $delta_migrated; - $total_skipped += $delta_skipped; + $delta_migrated = $migrator->migrated - $before_migrated; + $delta_skipped = $migrator->skipped - $before_skipped; if ( null === $new_content ) { continue; } + $label = '[dry-run]'; + if ( ! $dry_run ) { + $status = $this->persist_post_content( (int) $post->ID, $new_content ); + if ( '' === $status ) { + continue; + } + $label = '[' . $status . ']'; + } + + $total_migrated += $delta_migrated; + $total_skipped += $delta_skipped; ++$posts_updated; WP_CLI::log( sprintf( '%1$s post %2$d (%3$s) — blocks migrated: %4$d, skipped: %5$d', - $dry_run ? '[dry-run]' : '[update]', + $label, (int) $post->ID, $post->post_type, $delta_migrated, $delta_skipped ) ); - - if ( ! $dry_run ) { - $this->persist_post_content( (int) $post->ID, $post->post_type, $new_content, $delta_migrated, $delta_skipped ); - } } ++$page; @@ -153,14 +158,11 @@ public function __invoke( $args, $assoc_args ): void { /** * Save migrated content, with a content-only fallback for orphaned page templates. * - * @param int $post_id Post ID. - * @param string $post_type Post type. - * @param string $new_content Unslashed post content. - * @param int $delta_migrated Blocks migrated for this post. - * @param int $delta_skipped Blocks skipped for this post. - * @return void + * @param int $post_id Post ID. + * @param string $new_content Unslashed post content. + * @return string Empty on failure, `update` or `update-fallback` on success. */ - private function persist_post_content( int $post_id, string $post_type, string $new_content, int $delta_migrated, int $delta_skipped ): void { + private function persist_post_content( int $post_id, string $new_content ): string { // wp_update_post() expects slashed data; without wp_slash(), // block comment escapes like \u002d (for "--") become bare "u002d". $updated = wp_update_post( @@ -172,24 +174,11 @@ private function persist_post_content( int $post_id, string $post_type, string $ ); if ( ! is_wp_error( $updated ) ) { - return; + return 'update'; } - if ( 'invalid_page_template' === $updated->get_error_code() ) { - $fallback_ok = $this->update_post_content_fallback( $post_id, $new_content ); - - if ( $fallback_ok ) { - WP_CLI::log( - sprintf( - '[update-fallback] post %1$d (%2$s) — blocks migrated: %3$d, skipped: %4$d', - $post_id, - $post_type, - $delta_migrated, - $delta_skipped - ) - ); - return; - } + if ( 'invalid_page_template' === $updated->get_error_code() && $this->update_post_content_fallback( $post_id, $new_content ) ) { + return 'update-fallback'; } WP_CLI::warning( @@ -199,6 +188,8 @@ private function persist_post_content( int $post_id, string $post_type, string $ $updated->get_error_message() ) ); + + return ''; } /**