How to Reconstruct a Broken Shortcode’s Expected Attributes From Production Content
The ticket arrives mid-morning. A publishing team has migrated from Classic Editor to the Block Editor, and their custom [callout] shortcode—scattered across roughly 400 published posts—now renders without styling. The editor preview shows raw shortcode text or bare HTML. The frontend shows a <div class="callout"> with no inline styles applied. The team’s first instinct is to patch the shortcode handler. That instinct is wrong, and following it creates silent debt across the post table.
Shortcode deprecation during an editor migration is not a UX problem. It is a schema and rendering-contract problem. The wp_posts.post_content column stores shortcode markup as raw text, and the rendering contract—that a given shortcode string produces a specific HTML output with specific styles—depends on a filter stack, a style enqueuing path, and an editor isolation model that all change when you switch from Classic Editor to Block Editor. Treating the symptom as a styling bug means you patch [callout] today, [pullquote] next week, and [staff_directory] the week after, each time adding a conditional or a CSS override without ever auditing what the shortcode was actually contracted to produce.
The Symptom: Correct in Classic, Unstyled in Block Context
Here is the concrete failure mode. A custom [callout type="warning" title="Heads up"] shortcode was registered years ago by a developer who left no documentation. The handler produces a <div class="callout callout-warning"> with a title bar and body content. In Classic Editor, the shortcode renders inline during the_content filtration, and the plugin’s wp_enqueue_style call in wp_enqueue_scripts loads the CSS file site-wide. Everything works because Classic Editor’s rendering context and the frontend’s rendering context share the same stylesheet pipeline.
That same discipline applies to title and framing decisions: before publishing, editors need a way to test a heading promises the same thing the article actually delivers, which is where a novel title generator that fits the project can function as a planning aid rather than a substitute for domain evidence.
In the Block Editor, two things break. First, the editor renders inside an iframe with its own isolated styles, and the wp_enqueue_scripts hook does not fire inside that iframe. Second, when the block parser encounters [callout] inside a paragraph block’s content, it passes the shortcode through do_shortcode_tag during render_block, but the shortcode’s <style> dependency was enqueued on wp_enqueue_scripts, which is not the editor iframe’s enqueue path. The shortcode handler executes, produces its <div>, but the CSS never loads in the editor context. On the frontend, if the post is saved as a Classic block or the shortcode sits inside a paragraph block, render_block does invoke do_shortcode, but the style enqueue may still fail if the plugin’s enqueue callback checks is_singular() or similar conditional logic that doesn’t account for block-rendered content appearing in non-singular contexts.
The result: editors see unstyled callouts, lose trust in the editor preview, and start asking for a rollback to Classic Editor. The ops lead sees inconsistent frontend rendering and starts receiving screenshots from readers. Nobody can articulate why it broke because the original shortcode registration lives in a functions.php include file with no comments, and the rendering contract was never documented.
The Trace: From do_shortcode_tag Through render_block to the Schema
To understand why the patch-as-you-go approach fails, you need to trace the shortcode’s rendering path through three layers: the do_shortcode_tag filter stack, the render_block pipeline, and the wp_posts.post_content schema.
The do_shortcode_tag filter fires for each shortcode WordPress encounters during do_shortcode(). When a shortcode sits inside block content, render_block calls do_shortcode on the block’s rendered HTML as part of the render_block filter chain. The shortcode handler executes and returns its HTML. So far, so good—the shortcode does render. The problem is not that the shortcode fails to execute. The problem is that the shortcode’s rendering contract included a stylesheet dependency that was enforced through a hook (wp_enqueue_scripts) that runs in a different context than the block rendering pipeline.
This is a rendering-contract failure, not a logic failure. The shortcode handler’s PHP logic is correct. The HTML it produces is correct. But the contract—”this shortcode produces styled output”—is violated because the style delivery mechanism is decoupled from the rendering mechanism. In Classic Editor, this decoupling was masked because the_content filter and wp_enqueue_scripts both ran in the same request context. In Block Editor, the iframe isolation breaks that assumption.
As Google’s SRE team frames it in their chapter on Site reliability engineering, silent failures in a content store require postmortem-style root-cause analysis, not surface-level patches. Their chapter on data integrity—”What You Read Is What You Wrote”—directly parallels the post_content schema guarantee: if a shortcode string in post_content produced styled output in one editor context and unstyled output in another, the schema’s rendering contract is broken, and patching individual shortcodes treats the symptom without addressing the contract violation.
The evidence for this point is grounded in NIST, which keeps the article’s claims tied to outside reference material rather than product framing.
The schema-level issue is this: wp_posts.post_content stores shortcode strings as raw text. There is no schema enforcement of shortcode attributes, no validation of attribute values, and no record of which shortcode attributes were available when a given post was written. When you migrate to the Block Editor, you inherit every shortcode instance ever written into that column, with no metadata about what the shortcode handler expected at the time of authoring. A [callout type="warning"] from 2019 may have been written when the handler accepted type values of info, warning, and error. A [callout type="critical"] from 2021 may have been written after someone added critical to the handler. The handler may have since been modified to remove critical. The post_content has no way to tell you this.
Reverse-Engineering the Shortcode’s Attribute Schema From Production Content
Before you can rebuild the shortcode as a block, you need to know what attributes it actually accepts—not what the current handler says it accepts, but what attributes exist in production post_content. The handler may have been modified over the years, but the post_content is the ground truth of what was actually written.
Run this WP-CLI command to extract every shortcode instance from published posts:
wp db query "SELECT ID, post_content FROM wp_posts WHERE post_type = 'post' AND post_status = 'publish' AND post_content LIKE '%[callout%'" --skip-column-names | grep -oP '\[callout[^\]]*\]' | sort | uniq -c | sort -rn
This gives you a frequency-sorted list of every [callout] shortcode variant in production. You will see something like:
187 [callout type="warning" title="Heads up"]
142 [callout type="info"]
63 [callout type="error" title="Error"]
12 [callout type="critical"]
8 [callout]
Immediately, you know four things: type is an attribute, title is an attribute, critical was used in production (even if the current handler no longer supports it), and some posts use the shortcode with no attributes at all. This is your attribute schema, derived from production data rather than from a handler that may have drifted.
Now extract the full attribute set with a more thorough scan:
wp db query "SELECT post_content FROM wp_posts WHERE post_content LIKE '%[callout%' AND post_status = 'publish'" --skip-column-names | grep -oP '(\w+)="[^"]*"' | sort | uniq -c | sort -rn
This reveals every attribute name and value combination across all shortcode instances. You may discover attributes the original developer never documented: icon, dismissable, link. These are the attributes your dynamic block must support. Without this audit, you would build a block that handles type and title, migrate the content, and silently drop icon and link attributes from 40 posts.
This audit discipline matters beyond shortcode migrations. The same production-data-first approach applies when you register custom block variations and need to document them for a publishing team: you name and describe each variation in block.json the same way an editor might use a novel title generator to standardize how a series of custom blocks appears in the inserter. The principle is identical—derive your schema from what actually exists, not from what a stale handler or a vague memory says should exist.
Building the Dynamic Block That Wraps the Same Rendering Logic
Once you have the attribute schema from production, build a dynamic block that reproduces the shortcode’s rendering contract. A dynamic block renders server-side via a PHP callback, which means you can reuse the shortcode’s existing HTML generation logic rather than rewriting it in React.
Register the block with the attributes you discovered:
register_block_type( 'myplugin/callout', [
'render_callback' => 'myplugin_render_callout_block',
'attributes' => [
'type' => [
'type' => 'string',
'default' => 'info',
],
'title' => [
'type' => 'string',
'default' => '',
],
'icon' => [
'type' => 'string',
'default' => '',
],
'dismissable' => [
'type' => 'boolean',
'default' => false,
],
'link' => [
'type' => 'string',
'default' => '',
],
'content' => [
'type' => 'string',
'default' => '',
],
],
] );
The render callback wraps the existing shortcode logic:
function myplugin_render_callout_block( $attributes, $content ) {
// Reuse the existing shortcode handler's HTML generation
$type = $attributes['type'] ?? 'info';
$title = $attributes['title'] ?? '';
$icon = $attributes['icon'] ?? '';
$dismiss = $attributes['dismissable'] ?? false;
$link = $attributes['link'] ?? '';
$classes = [ 'callout', "callout-{$type}" ];
if ( $dismiss ) {
$classes[] = 'callout-dismissable';
}
$html = '<div class="' . implode( ' ', $classes ) . '">';
if ( $title ) {
$html .= '<div class="callout-title">' . esc_html( $title ) . '</div>';
}
$html .= '<div class="callout-body">' . $content . '</div>';
$html .= '</div>';
return $html;
}
The critical difference from the shortcode approach is the stylesheet. Instead of enqueuing the CSS via wp_enqueue_scripts, enqueue it via enqueue_block_assets, which fires in both the editor iframe and the frontend:
add_action( 'enqueue_block_assets', function() {
wp_enqueue_style(
'myplugin-callout',
plugins_url( 'css/callout.css', __FILE__ ),
[],
'1.0.0'
);
} );
This fixes the rendering-contract violation. The stylesheet now loads in both the editor and the frontend because enqueue_block_assets fires in both contexts. The block’s render callback produces the same HTML the shortcode produced. The contract is restored.
The WP-CLI Migration: Converting Shortcode Instances to Block Markup
With the dynamic block registered and the stylesheet correctly enqueued, the remaining task is converting existing shortcode instances in post_content to block markup. This is a schema migration, not a content edit, and it should be treated with the same rigor as any database migration: identify the scope, execute against a staging copy, and verify before/after output with diffable evidence.
Here is the migration script. It reads each post containing [callout], converts the shortcode to block markup, and saves the post. It logs every conversion for verification.
// migration-callout.php
// Run with: wp eval-file migration-callout.php
global $wpdb;
$posts = $wpdb->get_results(
"SELECT ID, post_content FROM {$wpdb->posts}
WHERE post_content LIKE '%[callout%'
AND post_status = 'publish'
AND post_type = 'post'"
);
$log = [];
foreach ( $posts as $post ) {
$content = $post->post_content;
$original = $content;
// Match [callout] with optional attributes and content
$pattern = '/\[callout([^\]]*)\](?:([^\[]*)\[\/callout\])?/';
$content = preg_replace_callback( $pattern, function( $matches ) {
$attr_string = trim( $matches[1] );
$inner = $matches[2] ?? '';
// Parse attributes
$attrs = [
'type' => 'info',
'title' => '',
'icon' => '',
'dismissable' => false,
'link' => '',
];
if ( preg_match_all( '/(\w+)="([^"]*)"/', $attr_string, $attr_matches, PREG_SET_ORDER ) ) {
foreach ( $attr_matches as $m ) {
$key = $m[1];
$val = $m[2];
if ( isset( $attrs[ $key ] ) ) {
$attrs[ $key ] = $val;
}
}
}
// Build block markup
$block_attrs = [];
foreach ( $attrs as $k => $v ) {
if ( is_bool( $v ) ) {
$block_attrs[] = '"' . $k . '":' . ( $v ? 'true' : 'false' );
} else {
$block_attrs[] = '"' . $k . '":"' . esc_js( $v ) . '"';
}
}
$block = '<!-- wp:myplugin/callout {' . implode( ',', $block_attrs ) . '} -->';
$block .= PHP_EOL . $inner;
$block .= PHP_EOL . '<!-- /wp:myplugin/callout -->';
return $block;
}, $content );
if ( $content !== $original ) {
wp_update_post( [
'ID' => $post->ID,
'post_content' => $content,
] );
$log[] = sprintf(
'Post %d: converted %d shortcode(s)',
$post->ID,
preg_match_all( '/\[callout/', $original )
);
}
}
file_put_contents( WP_CONTENT_DIR . '/migration-log.txt', implode( PHP_EOL, $log ) );
WP_CLI::success( sprintf( 'Converted %d posts', count( $log ) ) );
Run this on a staging copy first. Always. Then verify.
Verifying the Migration With Query Monitor Evidence
After running the migration, you need before/after evidence that the rendering contract is intact. This is not a matter of eyeballing a few posts. You need to verify that every converted post produces the same HTML output it produced before the migration, and that the stylesheet loads in both editor and frontend contexts.
Before the migration, on a staging copy of production, capture the rendered HTML of every post containing [callout]:
wp db query "SELECT ID FROM wp_posts WHERE post_content LIKE '%[callout%' AND post_status = 'publish'" --skip-column-names | while read id; do
wp eval "echo get_post_field( 'post_content', $id );" > "/tmp/pre-migration-{$id}.html"
done
Wait—get_post_field returns raw content. You need filtered content:
wp eval "echo apply_filters( 'the_content', get_post_field( 'post_content', $id ) );" > "/tmp/pre-migration-{$id}.html"
After the migration, capture the same output and diff:
for id in $(wp db query "SELECT ID FROM wp_posts WHERE post_content LIKE '%wp:myplugin/callout%' AND post_status = 'publish'" --skip-column-names); do
wp eval "echo apply_filters( 'the_content', get_post_field( 'post_content', $id ) );" > "/tmp/post-migration-{$id}.html"
diff "/tmp/pre-migration-{$id}.html" "/tmp/post-migration-{$id}.html" > "/tmp/diff-{$id}.txt"
done
If the diffs are empty, the rendering contract is intact: the block produces the same HTML the shortcode produced. If the diffs show differences—missing classes, different attribute values, escaped versus unescaped content—you have a rendering-contract violation that you must fix before pushing to production.
Open Query Monitor on a converted post in the editor and confirm that myplugin-callout appears in the asset queue. Open Query Monitor on the frontend and confirm the same. If the stylesheet appears in one context but not the other, your enqueue_block_assets callback has a conditional that needs removing.
Why the Patch-As-You-Go Approach Creates Silent Debt
The alternative to this migration is the approach most teams take: leave the shortcodes in post_content, add a CSS override to make them look right in the block editor, and move on. This works for a week. Then someone files a ticket about [pullquote]. Then [staff_directory]. Each patch adds a CSS rule or a conditional enqueue, and none of them address the underlying contract violation: shortcode rendering depends on a hook (wp_enqueue_scripts) that does not fire in the block editor iframe.
The debt accumulates in three places. First, in the theme’s style.css or a custom admin CSS file, where override rules pile up with no documentation of which shortcode they address. Second, in post_content, where shortcode strings persist as legacy markup that no new editor understands how to insert or modify. Third, in the plugin’s PHP, where conditional enqueue logic grows more complex with each patch, checking is_admin(), is_block_editor(), get_current_screen(), and eventually becoming unreadable.
The migration approach eliminates all three. The block markup in post_content is native block syntax that the editor understands. The stylesheet is enqueued via enqueue_block_assets, which fires in both contexts without conditionals. The plugin’s PHP contains a single render callback with no editor-context branching. The debt is paid down, not accrued.
The Rendering Contract Is the Schema
The lesson from this failure mode is that post_content is not just a text column. It is a rendering contract: a promise that a given string, when filtered through the_content, produces a specific HTML output with specific style dependencies. Shortcodes encode that contract in a syntax that depends on a hook stack and an enqueue path. When you change the editor context, you change the hook stack and the enqueue path, and the contract breaks.
Rebuilding the shortcode as a dynamic block is not a cosmetic improvement. It is a schema migration that restores the rendering contract in a context-appropriate way. The block markup is self-describing—its attributes are declared in block.json, its render callback is a PHP function with a known signature, and its stylesheet is enqueued on a hook that fires in every rendering context. The shortcode was none of these things. It was a regex match in a text column with an enqueue dependency on a hook that no longer fires where the content renders.
When you encounter this failure mode—and if you manage a WordPress site that has been alive for more than three years, you will—run the attribute audit first. Read the production post_content to learn what the shortcode was actually contracted to do, not what the current handler says it does. Build the dynamic block from that schema. Migrate with a script that logs every conversion. Verify with diffs. The migration takes longer than a CSS override. But the CSS override is a patch on a contract violation, and the migration is a fix for the contract itself.