Get the FREE Ultimate OpenClaw Setup Guide →

wp-gutenberg

Scanned
npx machina-cli add skill xonack/wp-gutenberg-claude-skill/wp-gutenberg --openclaw
Files (1)
SKILL.md
20.2 KB

Gutenberg Block Development

1. Block Registration

block.json is the canonical registration method since WordPress 5.8+. It enables automatic asset enqueue, server-side discovery, and block directory compatibility.

block.json Required Fields

{
  "apiVersion": 3,
  "$schema": "https://schemas.wp.org/trunk/block.json",
  "name": "zentratec/hero-banner",
  "version": "1.0.0",
  "title": "Hero Banner",
  "category": "design",
  "icon": "cover-image",
  "description": "A full-width hero banner with heading, text, and CTA.",
  "textdomain": "zentratec",
  "editorScript": "file:./index.js",
  "editorStyle": "file:./index.css",
  "style": "file:./style-index.css",
  "viewScript": "file:./view.js"
}

block.json Optional Fields

{
  "attributes": {
    "heading": { "type": "string", "default": "" },
    "mediaId": { "type": "number" },
    "mediaUrl": { "type": "string", "source": "attribute", "selector": "img", "attribute": "src" }
  },
  "supports": { "align": ["wide", "full"], "color": { "background": true, "text": true } },
  "styles": [
    { "name": "default", "label": "Default", "isDefault": true },
    { "name": "dark", "label": "Dark Overlay" }
  ],
  "variations": [],
  "example": {
    "attributes": { "heading": "Welcome to Zentratec" }
  },
  "parent": ["zentratec/section"],
  "ancestor": ["core/group"],
  "keywords": ["banner", "hero", "cta"]
}

PHP Registration

// plugin.php or functions.php
function zentratec_register_blocks(): void {
    register_block_type( __DIR__ . '/build/hero-banner' );
}
add_action( 'init', 'zentratec_register_blocks' );

Register multiple blocks from a shared build directory:

function zentratec_register_all_blocks(): void {
    $blocks = glob( __DIR__ . '/build/blocks/*/block.json' );
    foreach ( $blocks as $block_json ) {
        register_block_type( dirname( $block_json ) );
    }
}
add_action( 'init', 'zentratec_register_all_blocks' );

2. Block Edit Component

The Edit component renders in the editor. useBlockProps() is mandatory -- it provides block wrapper attributes (id, className, data-attributes).

import { useBlockProps, InspectorControls, BlockControls, RichText, MediaUpload, MediaUploadCheck } from '@wordpress/block-editor';
import { PanelBody, ToolbarGroup, ToolbarButton, RangeControl, ToggleControl, SelectControl } from '@wordpress/components';
import { __ } from '@wordpress/i18n';

export default function Edit( { attributes, setAttributes } ) {
    const { heading, mediaUrl, mediaId, overlayOpacity, showCta } = attributes;
    const blockProps = useBlockProps( { className: 'zentratec-hero' } );

    return (
        <>
            <BlockControls>
                <ToolbarGroup>
                    <MediaUploadCheck>
                        <MediaUpload
                            onSelect={ ( media ) => setAttributes( { mediaId: media.id, mediaUrl: media.url } ) }
                            allowedTypes={ [ 'image' ] }
                            value={ mediaId }
                            render={ ( { open } ) => (
                                <ToolbarButton onClick={ open } icon="format-image" label={ __( 'Edit Image', 'zentratec' ) } />
                            ) }
                        />
                    </MediaUploadCheck>
                </ToolbarGroup>
            </BlockControls>

            <InspectorControls>
                <PanelBody title={ __( 'Hero Settings', 'zentratec' ) }>
                    <RangeControl
                        label={ __( 'Overlay Opacity', 'zentratec' ) }
                        value={ overlayOpacity }
                        onChange={ ( val ) => setAttributes( { overlayOpacity: val } ) }
                        min={ 0 } max={ 100 } step={ 5 }
                    />
                    <ToggleControl
                        label={ __( 'Show CTA Button', 'zentratec' ) }
                        checked={ showCta }
                        onChange={ ( val ) => setAttributes( { showCta: val } ) }
                    />
                </PanelBody>
            </InspectorControls>

            <div { ...blockProps }>
                { mediaUrl && <img src={ mediaUrl } alt="" /> }
                <RichText
                    tagName="h1"
                    value={ heading }
                    onChange={ ( val ) => setAttributes( { heading: val } ) }
                    placeholder={ __( 'Enter heading...', 'zentratec' ) }
                />
            </div>
        </>
    );
}

3. Block Save Component

The Save component outputs static HTML stored in the database. useBlockProps.save() mirrors the editor wrapper.

Static Save

import { useBlockProps, RichText } from '@wordpress/block-editor';

export default function Save( { attributes } ) {
    const { heading, mediaUrl } = attributes;
    const blockProps = useBlockProps.save( { className: 'zentratec-hero' } );

    return (
        <div { ...blockProps }>
            { mediaUrl && <img src={ mediaUrl } alt="" /> }
            <RichText.Content tagName="h1" value={ heading } />
        </div>
    );
}

Dynamic Save (return null)

When server-side rendering handles output, save returns null. This avoids block validation errors when the markup changes.

export default function Save() {
    return null;
}

When to use static vs dynamic:

  • Static: Simple blocks with fixed markup. Faster rendering, no PHP overhead per page load.
  • Dynamic: Blocks that query posts, display user-specific data, or have markup that evolves across plugin versions.

4. Dynamic Blocks

render_callback in PHP

register_block_type( __DIR__ . '/build/recent-posts', [
    'render_callback' => 'zentratec_render_recent_posts',
] );

function zentratec_render_recent_posts( array $attributes, string $content, WP_Block $block ): string {
    $count = $attributes['count'] ?? 3;
    $posts = get_posts( [
        'numberposts' => $count,
        'post_status' => 'publish',
    ] );

    if ( empty( $posts ) ) {
        return '<p>' . esc_html__( 'No posts found.', 'zentratec' ) . '</p>';
    }

    $wrapper = get_block_wrapper_attributes( [ 'class' => 'zentratec-recent-posts' ] );
    $output  = "<div {$wrapper}><ul>";

    foreach ( $posts as $post ) {
        $output .= sprintf(
            '<li><a href="%s">%s</a></li>',
            esc_url( get_permalink( $post ) ),
            esc_html( $post->post_title )
        );
    }

    return $output . '</ul></div>';
}

render in block.json (WP 6.1+)

{
  "render": "file:./render.php"
}
<?php
// render.php -- $attributes, $content, and $block are available automatically
$wrapper = get_block_wrapper_attributes();
$heading = esc_html( $attributes['heading'] ?? '' );
?>
<div <?php echo $wrapper; ?>>
    <h2><?php echo $heading; ?></h2>
    <?php echo $content; ?>
</div>

5. InnerBlocks

InnerBlocks allow nesting blocks inside your custom block.

import { useBlockProps, useInnerBlocksProps, InnerBlocks } from '@wordpress/block-editor';

const TEMPLATE = [
    [ 'core/heading', { level: 2, placeholder: 'Section Title' } ],
    [ 'core/paragraph', { placeholder: 'Section content...' } ],
];

const ALLOWED_BLOCKS = [ 'core/heading', 'core/paragraph', 'core/image', 'core/buttons' ];

export default function Edit() {
    const blockProps = useBlockProps();
    const innerBlocksProps = useInnerBlocksProps( blockProps, {
        template: TEMPLATE,
        templateLock: 'insert',       // 'all' | 'insert' | 'contentOnly' | false
        allowedBlocks: ALLOWED_BLOCKS,
        orientation: 'vertical',      // 'vertical' | 'horizontal'
        renderAppender: InnerBlocks.ButtonBlockAppender,
    } );

    return <div { ...innerBlocksProps } />;
}

export function Save() {
    const blockProps = useBlockProps.save();
    const innerBlocksProps = useInnerBlocksProps.save( blockProps );
    return <div { ...innerBlocksProps } />;
}

Template Lock Modes

LockEffect
falseNo restrictions. Users add/remove/move freely.
'insert'Cannot add or remove blocks. Can reorder and edit content.
'all'Cannot add, remove, or reorder. Content editing only.
'contentOnly'Hides block structure. Only content (text, media) is editable.

Parent/Child Relationships

In the child block's block.json:

{ "parent": [ "zentratec/section" ] }

The child only appears in the inserter when inside the parent.


6. Block Variations

Variations create pre-configured versions of an existing block.

In block.json

{
  "variations": [
    {
      "name": "testimonial",
      "title": "Testimonial",
      "description": "A quote styled as a testimonial.",
      "icon": "format-quote",
      "isDefault": false,
      "attributes": { "style": "testimonial", "showAvatar": true },
      "scope": [ "inserter", "block", "transform" ],
      "isActive": [ "style" ]
    }
  ]
}

JS Registration

import { registerBlockVariation } from '@wordpress/blocks';

registerBlockVariation( 'core/group', {
    name: 'zentratec-card',
    title: 'Card',
    description: 'A card container with padding and border.',
    icon: 'id-alt',
    attributes: {
        style: { border: { radius: '8px', width: '1px', color: '#e0e0e0' }, spacing: { padding: { top: '24px', right: '24px', bottom: '24px', left: '24px' } } },
        backgroundColor: 'white',
    },
    innerBlocks: [
        [ 'core/heading', { level: 3 } ],
        [ 'core/paragraph' ],
    ],
    scope: [ 'inserter' ],
} );

7. Block Patterns

Patterns are pre-built block layouts users insert from the inserter.

PHP Registration

function zentratec_register_patterns(): void {
    register_block_pattern_category( 'zentratec', [
        'label' => __( 'Zentratec', 'zentratec' ),
    ] );

    register_block_pattern( 'zentratec/cta-section', [
        'title'       => __( 'CTA Section', 'zentratec' ),
        'description' => __( 'A call-to-action with heading and button.', 'zentratec' ),
        'categories'  => [ 'zentratec', 'call-to-action' ],
        'keywords'    => [ 'cta', 'action', 'button' ],
        'blockTypes'  => [ 'core/group' ],
        'content'     => '<!-- wp:group {"align":"full","backgroundColor":"primary","layout":{"type":"constrained"}} -->
            <div class="wp-block-group alignfull has-primary-background-color has-background">
                <!-- wp:heading {"textAlign":"center","textColor":"white"} -->
                <h2 class="has-text-align-center has-white-color has-text-color">Ready to get started?</h2>
                <!-- /wp:heading -->
                <!-- wp:buttons {"layout":{"type":"flex","justifyContent":"center"}} -->
                <div class="wp-block-buttons">
                    <!-- wp:button {"backgroundColor":"white","textColor":"primary"} -->
                    <div class="wp-block-button"><a class="wp-block-button__link has-primary-color has-white-background-color has-text-color has-background">Contact Us</a></div>
                    <!-- /wp:button -->
                </div>
                <!-- /wp:buttons -->
            </div>
            <!-- /wp:group -->',
    ] );
}
add_action( 'init', 'zentratec_register_patterns' );

File-based Patterns (WP 6.0+)

Place .php files in patterns/ within your theme or plugin:

<?php
/**
 * Title: Hero Section
 * Slug: zentratec/hero-section
 * Categories: zentratec, featured
 * Keywords: hero, banner
 * Block Types: core/group
 */
?>
<!-- wp:cover {"dimRatio":50,"minHeight":600} -->
<!-- /wp:cover -->

8. Block Supports

Supports provide design controls without custom code. Declared in block.json:

{
  "supports": {
    "color": {
      "background": true,
      "text": true,
      "gradients": true,
      "link": true
    },
    "typography": {
      "fontSize": true,
      "lineHeight": true,
      "fontFamily": true,
      "fontWeight": true,
      "fontStyle": true,
      "textTransform": true,
      "letterSpacing": true,
      "textDecoration": true
    },
    "spacing": {
      "margin": true,
      "padding": true,
      "blockGap": true
    },
    "dimensions": {
      "minHeight": true
    },
    "border": {
      "color": true,
      "radius": true,
      "style": true,
      "width": true
    },
    "layout": {
      "default": { "type": "constrained" },
      "allowSwitching": true
    },
    "anchor": true,
    "align": [ "wide", "full" ],
    "html": false,
    "className": true,
    "customClassName": true
  }
}

Supports generate CSS custom properties and classes automatically. Use get_block_wrapper_attributes() in dynamic blocks to output them.


9. Build Pipeline

Project Setup

# Scaffold a new block plugin
npx @wordpress/create-block@latest zentratec-blocks --namespace zentratec

# Or add wp-scripts to an existing project
bun add -D @wordpress/scripts

package.json Scripts

{
  "scripts": {
    "build": "wp-scripts build",
    "start": "wp-scripts start",
    "lint:js": "wp-scripts lint-js",
    "lint:css": "wp-scripts lint-style",
    "test:unit": "wp-scripts test-unit-js",
    "test:e2e": "wp-scripts test-e2e"
  }
}

Multi-Block webpack Configuration

// webpack.config.js
const defaultConfig = require( '@wordpress/scripts/config/webpack.config' );
const path = require( 'path' );

module.exports = {
    ...defaultConfig,
    entry: {
        'hero-banner/index': path.resolve( __dirname, 'src/blocks/hero-banner/index.js' ),
        'recent-posts/index': path.resolve( __dirname, 'src/blocks/recent-posts/index.js' ),
        'cta-card/index': path.resolve( __dirname, 'src/blocks/cta-card/index.js' ),
    },
    output: {
        ...defaultConfig.output,
        path: path.resolve( __dirname, 'build/blocks' ),
    },
};

wp-scripts build generates *.asset.php files alongside each entry point, listing dependencies and a version hash. These are consumed automatically by register_block_type().


10. Block Transforms

Transforms let users convert blocks between types.

import { createBlock } from '@wordpress/blocks';

const transforms = {
    from: [
        {
            type: 'block',
            blocks: [ 'core/paragraph' ],
            transform: ( { content } ) => createBlock( 'zentratec/callout', { text: content } ),
        },
        {
            type: 'prefix',
            prefix: '!!',
            transform: ( content ) => createBlock( 'zentratec/callout', { text: content } ),
        },
        {
            type: 'enter',
            regExp: /^!!\s?$/,
            transform: () => createBlock( 'zentratec/callout' ),
        },
        {
            type: 'raw',
            isMatch: ( node ) => node.nodeName === 'BLOCKQUOTE' && node.classList.contains( 'callout' ),
            transform: ( node ) => createBlock( 'zentratec/callout', { text: node.textContent } ),
        },
    ],
    to: [
        {
            type: 'block',
            blocks: [ 'core/paragraph' ],
            transform: ( { text } ) => createBlock( 'core/paragraph', { content: text } ),
        },
    ],
};

// Include in registerBlockType or block.json edit config
registerBlockType( 'zentratec/callout', { transforms, edit: Edit, save: Save } );

11. Testing Blocks

Unit Tests (Jest)

// src/blocks/hero-banner/__tests__/edit.test.js
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Edit from '../edit';

// Mock block-editor hooks
jest.mock( '@wordpress/block-editor', () => ( {
    useBlockProps: () => ( { className: 'test-block' } ),
    RichText: ( { value, onChange, placeholder } ) => (
        <input value={ value } onChange={ ( e ) => onChange( e.target.value ) } placeholder={ placeholder } />
    ),
    InspectorControls: ( { children } ) => <div data-testid="inspector">{ children }</div>,
    BlockControls: ( { children } ) => <div data-testid="toolbar">{ children }</div>,
} ) );

describe( 'HeroBanner Edit', () => {
    const defaultProps = {
        attributes: { heading: '', mediaUrl: '', overlayOpacity: 50 },
        setAttributes: jest.fn(),
    };

    it( 'renders heading input with placeholder', () => {
        render( <Edit { ...defaultProps } /> );
        expect( screen.getByPlaceholderText( /enter heading/i ) ).toBeInTheDocument();
    } );

    it( 'calls setAttributes when heading changes', async () => {
        render( <Edit { ...defaultProps } /> );
        await userEvent.type( screen.getByPlaceholderText( /enter heading/i ), 'Hello' );
        expect( defaultProps.setAttributes ).toHaveBeenCalled();
    } );
} );

E2E Tests (Playwright)

// e2e/hero-banner.spec.js
import { test, expect } from '@wordpress/e2e-test-utils-playwright';

test.describe( 'Hero Banner Block', () => {
    test( 'can be inserted and edited', async ( { admin, editor, page } ) => {
        await admin.createNewPost();
        await editor.insertBlock( { name: 'zentratec/hero-banner' } );

        const block = page.locator( '[data-type="zentratec/hero-banner"]' );
        await expect( block ).toBeVisible();

        await block.locator( 'h1[contenteditable]' ).fill( 'Test Heading' );
        await expect( block.locator( 'h1' ) ).toHaveText( 'Test Heading' );
    } );

    test( 'saves and renders on frontend', async ( { admin, editor, page } ) => {
        await admin.createNewPost();
        await editor.insertBlock( { name: 'zentratec/hero-banner', attributes: { heading: 'Live Test' } } );
        const postId = await editor.publishPost();

        await page.goto( `/?p=${ postId }` );
        await expect( page.locator( '.zentratec-hero h1' ) ).toHaveText( 'Live Test' );
    } );
} );

Dynamic Block PHP Output Tests

// tests/php/test-recent-posts-block.php
class Test_Recent_Posts_Block extends WP_UnitTestCase {
    public function test_render_with_posts(): void {
        $this->factory->post->create( [ 'post_title' => 'Test Post' ] );
        $output = zentratec_render_recent_posts( [ 'count' => 1 ], '', new WP_Block( [] ) );

        $this->assertStringContainsString( 'Test Post', $output );
        $this->assertStringContainsString( 'zentratec-recent-posts', $output );
    }

    public function test_render_empty_state(): void {
        $output = zentratec_render_recent_posts( [ 'count' => 3 ], '', new WP_Block( [] ) );
        $this->assertStringContainsString( 'No posts found', $output );
    }
}

12. Performance

Conditional Asset Loading

Use viewScript in block.json instead of script. WordPress 6.5+ only enqueues viewScript when the block appears on the page.

{
  "viewScript": "file:./view.js",
  "viewStyle": "file:./style-index.css"
}

Lazy Loading Interactive Scripts (WP 6.5+ Interactivity API)

{
  "viewScriptModule": "file:./view.js",
  "supports": { "interactivity": true }
}

Dynamic Block Caching

function zentratec_render_recent_posts( array $attributes, string $content, WP_Block $block ): string {
    $cache_key  = 'zentratec_recent_' . md5( wp_json_encode( $attributes ) );
    $cache_html = get_transient( $cache_key );

    if ( false !== $cache_html ) {
        return $cache_html;
    }

    // ... build $output ...

    set_transient( $cache_key, $output, HOUR_IN_SECONDS );
    return $output;
}

Invalidate on post publish:

add_action( 'transition_post_status', function ( string $new, string $old ): void {
    if ( $new === 'publish' || $old === 'publish' ) {
        global $wpdb;
        $wpdb->query( "DELETE FROM {$wpdb->options} WHERE option_name LIKE '_transient_zentratec_recent_%'" );
    }
}, 10, 2 );

Avoid Enqueuing Assets When Block Is Not Present

For blocks registered without block.json auto-enqueue, check before loading:

function zentratec_conditionally_enqueue(): void {
    if ( has_block( 'zentratec/hero-banner' ) ) {
        wp_enqueue_style( 'zentratec-hero-style', plugins_url( 'build/hero-banner/style.css', __FILE__ ) );
    }
}
add_action( 'wp_enqueue_scripts', 'zentratec_conditionally_enqueue' );

Source

git clone https://github.com/xonack/wp-gutenberg-claude-skill/blob/main/skills/wp-gutenberg/SKILL.mdView on GitHub

Overview

This skill is a complete reference for building WordPress blocks with Gutenberg. It covers block registration via block.json (API v3), the edit/save components, dynamic blocks, InnerBlocks, variations, patterns, and supports, as well as the build pipeline, transforms, testing, and performance optimization.

How This Skill Works

Blocks are registered using block.json (apiVersion 3), enabling automatic asset enqueue, server-side discovery, and compatibility with block directories. The Edit component renders in the editor using useBlockProps and various editor controls; dynamic blocks, transforms, and InnerBlocks support complex content structures, while patterns, variations, and supports enhance UX and performance.

When to Use It

  • You need a reusable content block for a WordPress theme or site builder.
  • You want dynamic, server-side rendered blocks with transforms and editing capabilities.
  • You are building nested layouts using InnerBlocks and want modular content composition.
  • You aim to offer editor UX enhancements through variations, patterns, and supports.
  • You are registering multiple blocks from a shared build directory to streamline deployment.

Quick Start

  1. Step 1: Create a block.json (API v3) to register the block and enable automatic asset handling.
  2. Step 2: Implement the Edit component using useBlockProps and add controls via InspectorControls and BlockControls.
  3. Step 3: Register blocks (single or multiple) from a shared build directory using PHP (glob) and register_block_type.

Best Practices

  • Use block.json with apiVersion 3 for automatic asset enqueue, server-side discovery, and block directory compatibility.
  • Leverage InnerBlocks, variations, and patterns to create flexible, reusable content blocks.
  • Enable useful supports, styles, and keywords to improve editor UX and block discovery.
  • Test transforms and ensure your build pipeline handles edits and saves reliably.
  • Profile and optimize performance, keeping assets lean and leveraging the build pipeline effectively.

Example Use Cases

  • Hero banner block (zentratec/hero-banner) with editor and style fields for a marketing site.
  • Dynamic block that renders server-side content and supports transforms.
  • Pattern library consisting of multiple block variations for consistent page design.
  • Block set registered from a shared build directory to simplify deployment.
  • Block with InnerBlocks enabling nested content like hero text, media, and CTAs.

Frequently Asked Questions

Add this skill to your agents
Sponsor this space

Reach thousands of developers