Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 92 additions & 0 deletions MIGRATION.md
Original file line number Diff line number Diff line change
@@ -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:

```
<!-- wp:beapi/anchor {"title":"My section","slug":"my-section"} /-->
<!-- wp:beapi/anchor-list /-->
```

becomes:

```
<!-- wp:blockparty/anchor {"title":"My section","slug":"my-section"} /-->
<!-- wp:blockparty/anchors-list /-->
```

## 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
6 changes: 6 additions & 0 deletions blockparty-anchors.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@

namespace Blockparty\Anchors;

use Blockparty\Anchors\Cli\MigrateFromAnchorBlockCommand;

if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
Expand Down Expand Up @@ -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 );
}
253 changes: 253 additions & 0 deletions includes/Cli/MigrateFromAnchorBlockCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,253 @@
<?php
/**
* WP-CLI command to migrate beapi/anchor content.
*
* @package Blockparty\Anchors
*/

namespace Blockparty\Anchors\Cli;

use Blockparty\Anchors\Migration\AnchorBlockMigrator;
use WP_CLI;
use WP_CLI_Command;
use WP_Query;

/**
* Blockparty Anchors WP-CLI commands.
*/
class MigrateFromAnchorBlockCommand extends WP_CLI_Command {

/**
* Migrate beapi/anchor and beapi/anchor-list blocks to blockparty/anchor and blockparty/anchors-list.
*
* ## OPTIONS
*
* [--dry-run]
* : Report changes without updating the database.
*
* [--post-type=<post-types>]
* : Comma-separated list of post types. Default: any public post type that shows in the REST API, plus wp_block.
*
* [--posts-per-page=<number>]
* : 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;

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',
$label,
(int) $post->ID,
$post->post_type,
$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 $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 $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(
[
'ID' => $post_id,
'post_content' => wp_slash( $new_content ),
],
true
);

if ( ! is_wp_error( $updated ) ) {
return 'update';
}

if ( 'invalid_page_template' === $updated->get_error_code() && $this->update_post_content_fallback( $post_id, $new_content ) ) {
return 'update-fallback';
}

WP_CLI::warning(
sprintf(
'Failed to update post %1$d: %2$s',
$post_id,
$updated->get_error_message()
)
);

return '';
}

/**
* 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;
}
}
Loading
Loading