xonack

wp-gutenberg

0
0
# Install this skill:
npx skills add xonack/wp-gutenberg-claude-skill --skill "wp-gutenberg"

Install specific skill from multi-skill repository

# Description

Complete Gutenberg block development reference covering registration, edit/save components, dynamic blocks, InnerBlocks, variations, patterns, supports, build pipeline, transforms, testing, and performance optimization.

# SKILL.md


name: wp-gutenberg
description: Complete Gutenberg block development reference covering registration, edit/save components, dynamic blocks, InnerBlocks, variations, patterns, supports, build pipeline, transforms, testing, and performance optimization.
tools:
- Read
- Write
- Edit
- Bash
- Grep
- Glob


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

Lock Effect
false No 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' );

# Supported AI Coding Agents

This skill is compatible with the SKILL.md standard and works with all major AI coding agents:

Learn more about the SKILL.md standard and how to use these skills with your preferred AI coding agent.