WP Tuts Hub

WordPress Tutorials, Themes and Plugins Hub
Advertisement

Browse

  • Home
  • Categories
    • WordPress & AI
    • WordPress Security
    • WordPress Troubleshooting
    • WordPress Tips and Tricks
    • SSH (Secure Shell)
    • WordPress Insights
    • WordPress Common Facts
  • Series Tutorials
    • Advanced WordPress Topic
  • Privacy Policy
  • Terms of Use
  • Contact Me
  • About WP Tuts Hub
Home/WordPress & AI/Validate AI-Generated Gutenberg Block Markup Before Insert: parse_blocks, Allowlist and Fallback
WordPress & AI
November 10, 2026

Validate AI-Generated Gutenberg Block Markup Before Insert: parse_blocks, Allowlist and Fallback

Validate AI-Generated Gutenberg Block Markup Before Insert: parse_blocks, Allowlist and Fallback

The editor throws This block contains unexpected or invalid content the moment a model’s response is pasted into a post — or worse, the block saves without complaint and the front end renders a wall of escaped <div> tags where a two-column layout should be. The REST request returned 200, the JSON parsed, and nothing in debug.log explains how a core/columns block turned into a paragraph of raw HTML.

That gap between “the request succeeded” and “the markup is valid” is where most LLM-to-Gutenberg integrations break. What follows diagnoses the failure, builds a validator around parse_blocks(), and hardens it so the same class of markup cannot reach post_content again.

You need PHP 7.4 or newer, WordPress 6.2 or newer, and a plugin or theme where you can register a REST route. The editor side uses @wordpress/api-fetch and @wordpress/blocks, both already bundled with the block editor, so no extra build tooling is required.

The symptom: a 200 response that still breaks the block editor

A typical failure looks like this. The model returns something close to valid block markup, your endpoint stores it, and the editor shows one of three things:

  • A red Block validation failed notice on a block that looks correct in the source.
  • A Classic or HTML block containing the entire response, delimiters and all.
  • Nothing at all — the post saves, but the front end prints escaped angle brackets.

All three trace back to the same cause: the markup was never parsed into a block tree, so nothing checked whether the block names exist, whether the attributes match the registered schema, or whether freeform HTML was sitting between delimiters. parse_blocks() is a tokeniser, not a validator. It will happily return a block named core/does-not-exist with arbitrary attributes, and it will return raw HTML as a nameless block with a populated innerHTML.

There is a second layer people miss. The editor’s own Block validation failed message comes from the JavaScript save() function of each block type, comparing the saved markup to what the block would generate. That check runs in the browser, after the content is already in the database. Server-side validation is not a replacement for it — it is the gate that stops bad markup from ever reaching the point where the browser check matters.

Reproduce the failure with a single parse_blocks() call

Before writing any validation logic, confirm what the parser actually returns for a bad response. Drop this into a scratch plugin or a wp eval call and inspect the output:

<?php
$markup = '<div class="hero">Intro text</div>
<!-- wp:core/columns {"columns":2} -->
<!-- wp:core/column -->
<!-- wp:core/paragraph -->
<p>Hello</p>
<!-- /wp:core/paragraph -->
<!-- /wp:core/column -->
<!-- /wp:core/columns -->';

$blocks = parse_blocks( $markup );

foreach ( $blocks as $block ) {
    printf(
        "name=%s innerHTML=%s\n",
        var_export( $block['blockName'], true ),
        substr( trim( (string) $block['innerHTML'] ), 0, 40 )
    );
}

The first entry has blockName === null and an innerHTML of <div class="hero">Intro text</div>. That is the freeform HTML the model invented. The second entry is core/columns with an attribute the real block does not accept in that shape. Neither of these is an error as far as parse_blocks() is concerned — which is precisely why the failure surfaces later, in the editor, with no useful trace.

Running it on WordPress 7.1.3 with PHP 8.2 prints:

name=NULL innerHTML=<div class="hero">Intro text</div>
name='core/columns' innerHTML=

The core/columns entry has an empty innerHTML because its content lives in innerBlocks, so checking the top-level HTML alone tells you nothing about the nested blocks.

Walk the block tree and collect every name and every freeform chunk

Validation needs two lists: the block names present anywhere in the tree (including nested innerBlocks), and every nameless node that carries HTML. A recursive walker gives you both in one pass and keeps the logic reusable for the REST endpoint and the save-time guard.

<?php
/**
 * Collect block names and freeform HTML from a parsed tree.
 *
 * @param array $blocks   Output of parse_blocks().
 * @param array $names    Accumulator for block names.
 * @param array $freeform Accumulator for raw HTML chunks.
 * @return array{names: string[], freeform: string[]}
 */
function wptut_collect_blocks( array $blocks, array &$names = array(), array &$freeform = array() ): array {
    foreach ( $blocks as $block ) {
        if ( empty( $block['blockName'] ) ) {
            $html = trim( (string) ( $block['innerHTML'] ?? '' ) );
            if ( '' !== $html ) {
                $freeform[] = $html;
            }
        } else {
            $names[] = $block['blockName'];
        }

        if ( ! empty( $block['innerBlocks'] ) ) {
            wptut_collect_blocks( $block['innerBlocks'], $names, $freeform );
        }
    }

    return array( 'names' => $names, 'freeform' => $freeform );
}

Passing the accumulators by reference matters. A model that nests a paragraph inside a column inside columns produces three levels, and a non-recursive check would only see the top one. The freeform list is the more important output: any non-empty entry is a hard reject, because a nameless block means the model wrote HTML outside the delimiter syntax and the editor will treat it as a Classic block.

Build the allowlist from registered block types, not from a hardcoded guess

An allowlist written by hand drifts the moment a plugin registers a new block or a core block is renamed. Query the registry instead, then intersect with the subset you actually want the model to produce. The registry call also gives you the attribute schema for the next step.

<?php
function wptut_validate_block_tree( string $markup, array $allowed ) {
    $blocks    = parse_blocks( $markup );
    $collected = wptut_collect_blocks( $blocks );

    if ( ! empty( $collected['freeform'] ) ) {
        return new WP_Error(
            'wptut_raw_html',
            __( 'Raw HTML was returned outside a block delimiter.', 'wptut' ),
            array( 'freeform' => $collected['freeform'] )
        );
    }

    $registry = WP_Block_Type_Registry::get_instance();
    $unknown  = array();

    foreach ( array_unique( $collected['names'] ) as $name ) {
        if ( ! in_array( $name, $allowed, true ) || ! $registry->is_registered( $name ) ) {
            $unknown[] = $name;
        }
    }

    if ( ! empty( $unknown ) ) {
        return new WP_Error(
            'wptut_unknown_block',
            __( 'Disallowed or unregistered block names were returned.', 'wptut' ),
            array( 'blocks' => $unknown )
        );
    }

    return wptut_sanitise_attributes( $blocks );
}

Checking is_registered() as well as the allowlist catches a subtle case: a block name that is in your list but whose registering plugin has been deactivated. Without that second check the markup passes validation and then renders as an empty comment on the front end. Returning a WP_Error with structured data — rather than a boolean — lets the caller show the model’s exact offending names in the diff panel.

Sanitise attributes against block.json so the model cannot smuggle markup

Block attributes are declared in block.json with a type and often an enum or default. The registry exposes that schema through $type->attributes. Casting each value to its declared type and running strings through sanitize_text_field() removes the most common injection route: an attribute like content or caption that a block renders without escaping.

<?php
function wptut_sanitise_attributes( array $blocks ): array {
    $registry = WP_Block_Type_Registry::get_instance();
    $clean    = array();

    foreach ( $blocks as $block ) {
        if ( empty( $block['blockName'] ) ) {
            continue;
        }

        $type = $registry->get_registered( $block['blockName'] );
        if ( ! $type ) {
            continue;
        }

        $schema = is_array( $type->attributes ) ? $type->attributes : array();
        $attrs  = array();

        foreach ( $schema as $name => $definition ) {
            if ( ! array_key_exists( $name, (array) $block['attrs'] ) ) {
                continue;
            }

            $value = $block['attrs'][ $name ];

            switch ( $definition['type'] ?? 'string' ) {
                case 'boolean':
                    $attrs[ $name ] = (bool) $value;
                    break;
                case 'integer':
                    $attrs[ $name ] = (int) $value;
                    break;
                case 'number':
                    $attrs[ $name ] = (float) $value;
                    break;
                case 'array':
                case 'object':
                    $attrs[ $name ] = array_map( 'sanitize_text_field', (array) $value );
                    break;
                default:
                    $attrs[ $name ] = sanitize_text_field( (string) $value );
            }
        }

        $clean[] = array(
            'blockName'   => $block['blockName'],
            'attrs'       => $attrs,
            'innerBlocks' => wptut_sanitise_attributes( $block['innerBlocks'] ?? array() ),
            'innerHTML'   => wp_kses_post( $block['innerHTML'] ?? '' ),
        );
    }

    return $clean;
}

Two details carry the security weight. First, attributes not present in the schema are dropped entirely — a model that invents "onclick" or "style" gets nothing back. Second, wp_kses_post() on innerHTML keeps the paragraph and heading tags a block legitimately needs while stripping <script>, event handlers and javascript: URLs. Do not reach for strip_tags() here; it will flatten a core/paragraph into plain text and break the block’s own save() output.

Register a REST endpoint that reports the diff instead of inserting blindly

The endpoint should always return 200 with a structured report, not a 400. A validation failure is an expected outcome of an LLM pipeline, and a non-error status keeps the editor’s apiFetch promise resolving so the sidebar can render the reason and the fallback in one pass.

<?php
add_action( 'rest_api_init', function () {
    register_rest_route( 'wptut/v1', '/validate-blocks', array(
        'methods'             => 'POST',
        'permission_callback' => function () {
            return current_user_can( 'edit_posts' );
        },
        'args' => array(
            'markup' => array( 'required' => true, 'type' => 'string' ),
        ),
        'callback' => function ( WP_REST_Request $request ) {
            $allowed = array(
                'core/paragraph', 'core/heading', 'core/list', 'core/list-item',
                'core/quote', 'core/image', 'core/buttons', 'core/button',
            );

            $markup = (string) $request['markup'];
            $result = wptut_validate_block_tree( $markup, $allowed );

            if ( is_wp_error( $result ) ) {
                return new WP_REST_Response( array(
                    'valid'    => false,
                    'code'     => $result->get_error_code(),
                    'message'  => $result->get_error_message(),
                    'data'     => $result->get_error_data(),
                    'fallback' => wptut_fallback_markup( $markup ),
                ), 200 );
            }

            return new WP_REST_Response( array(
                'valid'  => true,
                'blocks' => $result,
                'diff'   => wptut_diff_markup( $markup, $result ),
            ), 200 );
        },
    ) );
} );

The diff is a line-by-line comparison of the original markup against serialize_blocks() of the cleaned tree. It is deliberately coarse — the point is to show a human which attribute changed, not to produce a merge tool. A simple loop over preg_split( '/\R/', ... ) is enough and avoids pulling in the admin diff renderer.

Fall back to core/paragraph with the model’s text intact

Rejecting outright loses whatever the model got right. Stripping the delimiters, flattening the tags and wrapping the remaining text in a single core/paragraph preserves the prose while guaranteeing the result is a block the editor can render. serialize_blocks() produces the delimiter comments for you, so the fallback is valid Gutenberg markup rather than a hand-built string.

<?php
function wptut_fallback_markup( string $markup ): string {
    $text = preg_replace( '/<!--\s*\/?wp:.*?-->/s', '', $markup );
    $text = wp_strip_all_tags( (string) $text );
    $text = trim( preg_replace( '/\s+/u', ' ', $text ) );

    if ( '' === $text ) {
        return '';
    }

    $html = '<p>' . esc_html( $text ) . '</p>';

    return serialize_blocks( array(
        array(
            'blockName'    => 'core/paragraph',
            'attrs'        => array(),
            'innerBlocks'  => array(),
            'innerHTML'    => $html,
            'innerContent' => array( $html ),
        ),
    ) );
}

Note the innerContent key. serialize_blocks() relies on it to decide where child blocks and text fragments sit; omitting it produces a block that parses back with an empty body. If the fallback also comes back empty — the model returned only markup and no prose — return an empty string and let the caller show “nothing to insert” rather than writing a blank paragraph.

Show the diff in a Gutenberg sidebar before anything touches the post

The sidebar is where the pipeline becomes trustworthy. It calls the endpoint, renders the rejection reason or the diff, and only calls insertBlocks() after a human clicks. parse() from @wordpress/blocks turns the chosen source — validated markup or fallback — into block objects the editor can insert directly.

import { useState } from '@wordpress/element';
import { useDispatch } from '@wordpress/data';
import { store as blockEditorStore } from '@wordpress/block-editor';
import { parse } from '@wordpress/blocks';
import apiFetch from '@wordpress/api-fetch';

export function AiMarkupPanel( { markup } ) {
    const [ report, setReport ] = useState( null );
    const { insertBlocks } = useDispatch( blockEditorStore );

    async function validate() {
        const response = await apiFetch( {
            path: '/wptut/v1/validate-blocks',
            method: 'POST',
            data: { markup },
        } );
        setReport( response );
    }

    function insert() {
        const source = report.valid ? markup : report.fallback;
        if ( ! source ) {
            return;
        }
        insertBlocks( parse( source ) );
    }

    return (
        <div className="wptut-ai-panel">
            <button onClick={ validate }>Validate markup</button>
            { report && (
                <p className={ report.valid ? 'is-valid' : 'is-invalid' }>
                    { report.valid ? 'All blocks passed.' : report.message }
                </p>
            ) }
            { report && report.diff && report.diff.length > 0 && (
                <ol className="wptut-diff">
                    { report.diff.map( ( row ) => (
                        <li key={ row.line }>
                            <del>{ row.before }</del>
                            <ins>{ row.after }</ins>
                        </li>
                    ) ) }
                </ol>
            ) }
            <button onClick={ insert } disabled={ ! report }>
                Insert { report && report.valid ? 'validated blocks' : 'fallback paragraph' }
            </button>
        </div>
    );
}

Keep the insert button disabled until report exists. That single guard is what stops a fast click from bypassing validation entirely, and it makes the fallback path an explicit choice rather than a silent substitution.

Close the loop: reject at save time as well as at insert time

An editor-side check only covers the path you wrote. A second client, a cron job or a direct wp_insert_post() call can still write unvalidated markup. Add a save-time guard, but gate it behind a flag so it never rewrites hand-authored posts that legitimately use blocks outside your allowlist.

<?php
add_filter( 'wp_insert_post_data', function ( $data, $postarr ) {
    if ( empty( $postarr['ID'] ) || ! get_post_meta( $postarr['ID'], '_wptut_ai_generated', true ) ) {
        return $data;
    }

    $allowed = array( 'core/paragraph', 'core/heading', 'core/list', 'core/list-item', 'core/quote' );
    $result  = wptut_validate_block_tree( (string) $data['post_content'], $allowed );

    if ( is_wp_error( $result ) ) {
        $data['post_content'] = wptut_fallback_markup( (string) $data['post_content'] );
    }

    return $data;
}, 10, 2 );

The meta flag is the whole safety mechanism. Without it, this filter runs on every save in the site and quietly flattens any post containing a block you did not list — including ones a human built by hand in the editor. Set _wptut_ai_generated when the pipeline creates the post, and clear it the first time a human edits the content.

One more hardening step worth taking: log every rejection with the model name, the error code and the offending block names, using error_log() or a custom table. Patterns emerge quickly — a model that keeps emitting core/group when your allowlist stops at core/columns is telling you something about its training data, and the fix is usually a tighter system prompt rather than a wider allowlist.

A useful next experiment is to feed the rejection payload back to the model automatically: take the code and data.blocks from the failed response, append them to the original prompt as a correction instruction, and retry once before falling back. A single retry can fix small markup mistakes, and the paragraph fallback stays as the final safety net.

Share this:

  • Share on X (Opens in new window) X
  • Share on Facebook (Opens in new window) Facebook
  • Share on LinkedIn (Opens in new window) LinkedIn
  • Share on WhatsApp (Opens in new window) WhatsApp
  • Share on Reddit (Opens in new window) Reddit
  • Email a link to a friend (Opens in new window) Email

Related

Previous Article Harden WordPress Application Passwords: Per-Role Availability, Revocation on Role Change and Auth Logging

About the author

Md. Zubaer Ahammed
Md. Zubaer Ahammed

Md. Zubaer Ahammed is a senior full-stack developer building learning platforms and modern web systems with WordPress, Laravel, Python/Django, Vue, and React. More at https://zubaer.com/.

Related Posts

  • Harden WordPress Application Passwords: Per-Role Availability, Revocation on Role Change and Auth Logging Harden WordPress Application Passwords: Per-Role Availability, Revocation on Role Change and Auth Logging
  • Build a Streaming WordPress AI Chat Assistant: SSE Endpoint, Conversation Table and Per-User Rate Limits Build a Streaming WordPress AI Chat Assistant: SSE Endpoint, Conversation Table and Per-User Rate Limits
WP Tuts Hub

WordPress tutorials, themes, and plugins — written for builders.

  • Portfolio
  • Calorie Calculator Pro
  • Contact
  • About
  • Privacy
  • Terms

© 2026 WP Tuts Hub · Md. Zubaer Ahammed