Skip to content

Latest commit

 

History

History
409 lines (294 loc) · 17.7 KB

blocks.md

File metadata and controls

409 lines (294 loc) · 17.7 KB

Gutenberg Blocks

Pollora provides a complete system for building custom Gutenberg blocks with Vite and JSX. Blocks can live in themes, plugins, or Laravel modules — the registration works identically across all three.

How It Works

The BlockRegistrar service scans a directory for subdirectories containing block.json, then:

  1. Creates a dedicated {parent}.blocks asset container with no basePath for direct Vite manifest resolution
  2. Pre-registers each file:./ asset (scripts and styles) via wp_register_script / wp_register_style with the Vite-compiled URLs
  3. Adds type="module" and crossorigin attributes for Vite scripts
  4. Calls register_block_type() — WordPress finds the pre-registered handles and skips its own resolution

Quick Start

1. Scaffold a Block

php artisan pollora:make:block hero-banner --theme

This creates all the files in resources/views/blocks/hero-banner/ and bootstraps the Vite infrastructure on first use (vite.config.js patching, npm dependencies). No service provider is written: Pollora registers the blocks of every theme, plugin and module itself (see Registration).

2. Build

cd themes/your-theme
npm install
npm run build   # or npm run dev for HMR

3. Done

The block appears in the Gutenberg inserter. No manual register_block_type() needed.

Block Structure

Each block lives in its own directory under resources/views/blocks/, next to the other Blade views:

resources/views/blocks/hero-banner/
├── block.json         # WordPress block metadata
├── render.blade.php   # Server-side render (default)
├── index.jsx          # Entry point — registers the block
├── edit.jsx           # Editor component — shows what the page will show
├── save.jsx           # Frontend save (static blocks only, see --static)
├── editor.css         # Editor-only styles
├── style.css          # Shared styles (editor + frontend)
└── view.js            # Frontend-only script (optional)

Blocks are dynamic by default: render.blade.php renders them on each request, so their markup is not stored in post_content. Changing the markup updates every existing block instead of triggering the editor's "This block contains unexpected or invalid content" error.

view.js keeps its WordPress meaning — the frontend script — and the server template is render.blade.php.

block.json

Standard WordPress block metadata. Asset fields use file:./ references — Pollora resolves them through Vite:

{
    "$schema": "https://schemas.wp.org/trunk/block.json",
    "apiVersion": 3,
    "name": "my-theme/hero-banner",
    "title": "Hero Banner",
    "category": "design",
    "icon": "cover-image",
    "textdomain": "my-theme",
    "editorScript": "file:./index.jsx",
    "editorStyle": "file:./editor.css",
    "style": "file:./style.css",
    "viewScript": "file:./view.js",
    "render": "file:./render.blade.php"
}

index.jsx

Entry point that registers the block with WordPress:

import { registerBlockType } from '@wordpress/blocks';
import Edit from './edit';
import metadata from './block.json';
import './editor.css';
import './style.css';

registerBlockType(metadata.name, {
    edit: Edit,
    save: window.pollora.blocks.save, // The inner blocks, if any: render.blade.php renders the rest
});

A static block (--static) imports save from ./save and passes it instead.

window.pollora.blocks is the framework's editor runtime. Pollora loads it with every block that has a render template (the editor script handle pollora-block-editor); there is nothing to install. Its save stores the block's inner blocks in the post and nothing else — for a block without inner blocks it is the same as save: () => null.

edit.jsx

The editor shows what the page will show. For a dynamic block, the generated edit.jsx asks the server to render render.blade.php for the block's current attributes, and shows the result — with the block's inner blocks editable where the template writes <InnerBlocks />:

import metadata from './block.json';

export default window.pollora.blocks.bladeEdit(metadata);

The preview is fetched from the core block-renderer REST route, again 200 ms after the last attribute change. A static block's edit.jsx mirrors the markup save.jsx writes; a static block made with --inner-blocks uses Gutenberg's InnerBlocks directly.

Registration

Pollora registers the blocks of every active theme, plugin and module by itself: whatever holds a resources/views/blocks directory (or the former resources/blocks) has its blocks registered on WordPress init. There is nothing to write — no service provider, no register_block_type() call.

A service provider of your own could not do this reliably. Over HTTP, WordPress is loaded, and init has fired, before theme and plugin providers boot; a REST request — which the editor and its block previews rely on — is answered before they boot at all. A block registered from a provider existed in WP-CLI only.

A BlocksServiceProvider written by an earlier pollora:make:block is harmless — a block WordPress already holds is skipped — and can be deleted.

Each block's assets resolve through the asset container of the module that ships it:

Module type Container name
Theme theme
Plugin plugin.{slug}
Module module.{slug}

A Laravel module declares its own container in a provider that boots too late for init, so Pollora creates it when it is missing, building from public/build/module/{slug}. The BlockRegistrar automatically creates a {container}.blocks child container with an empty basePath to resolve block assets directly against the Vite manifest.

Asset entry points are resolved relative to the Vite project root — resources/views/blocks/hero-banner/index.jsx — which is both the manifest key and the dev server path. The root is the closest parent directory holding a vite.config.{js,ts,mjs}, or else the directory containing resources/: keep the Vite config at the root of the theme, plugin or module.

Vite Configuration

Block entry points must be registered in vite.config.js. The recommended pattern auto-discovers all block assets:

import { wordpressPlugin } from '@roots/vite-plugin';
import { globSync } from 'glob';

const blockEntries = globSync([
    './resources/views/blocks/*/{index,view}.{js,jsx,ts,tsx}',
    './resources/views/blocks/*/{editor,style}.css',
])
    .reduce((acc, file) => {
        acc[file.replace(/^\.\//, '').replace(/\.\w+$/, '')] = file;
        return acc;
    }, {});
const hasBlocks = Object.keys(blockEntries).length > 0;

Then in the Laravel Vite config:

input: ["./resources/assets/app.js", ...Object.values(blockEntries)],

And in plugins:

plugins: [
    laravel(getThemeConfig()),
    ...(hasBlocks ? [wordpressPlugin()] : []),
],

The @roots/vite-plugin provides the wordpressPlugin() which generates editor.deps.json with WordPress script dependencies.

Full reloads

Blocks live under resources/views, so a refresh glob such as resources/views/** — including laravel-vite-plugin's default refreshPaths — would reload the whole page on every block JSX change instead of hot-replacing it. Reload on Blade templates only:

refresh: [
    ...refreshPaths.filter((refreshPath) => refreshPath !== 'resources/views/**'),
    'resources/views/**/*.blade.php',
],

Tailwind CSS in Blocks

Pollora uses Tailwind CSS v4 with automatic source detection. No tailwind.config.js is needed. Tailwind utilities work in blocks via two approaches:

Utility Classes in JSX

Use Tailwind classes directly in your block JSX — they work on both frontend and editor:

// edit.jsx
export default function Edit() {
    const blockProps = useBlockProps();
    return (
        <div {...blockProps}>
            <h2 className="text-3xl font-bold text-white mb-4">
                {__('Hello World', 'my-theme')}
            </h2>
            <p className="text-lg text-indigo-100/80 max-w-2xl mx-auto">
                {__('A description', 'my-theme')}
            </p>
        </div>
    );
}

@apply in Block CSS

Use @apply in style.css and editor.css for component-level styles:

/* style.css — loaded on frontend AND in editor */
@import "tailwindcss" source(".");

.wp-block-my-theme-hero {
    @apply relative py-24 px-8 rounded-xl overflow-hidden;
    background: linear-gradient(135deg,
        theme(--color-indigo-950) 0%,
        theme(--color-violet-900) 100%
    );
}
/* editor.css — loaded only in editor */
@reference "tailwindcss";

.wp-block-my-theme-hero {
    @apply border-2 border-dashed border-black/15 min-h-[300px];
}

Key Directives

Directive Use in Purpose
@import "tailwindcss" source(".") style.css Full Tailwind import, scoped to the block's directory. Generates utility classes from JSX files.
@reference "tailwindcss" editor.css Access to @apply and theme() without generating utilities.
theme(--color-*) Any CSS Access Tailwind theme values as CSS functions.

Why source(".")?

The source(".") parameter tells Tailwind to scan only the block's directory for utility classes, not the entire project. This keeps the generated CSS small while ensuring all classes used in edit.jsx, save.jsx, and index.jsx are available in the editor iframe.

Without source("."), the block's CSS would include utilities from the entire project — much larger than necessary.

pollora:make:block Command

Usage

php artisan pollora:make:block <name> [options]

Arguments

Argument Description
name Block slug in kebab-case (e.g. hero-banner)

Options

Option Description
--theme[=NAME] Create in theme (default: active theme)
--plugin=NAME Create in plugin
--namespace=NS Block namespace (before the /)
--title=TITLE Block title in the inserter
--category=CAT Gutenberg category (default: widgets)
--icon=ICON Dashicon name (default: block-default)
--static Create a static block saved in post_content (save.jsx, no render.blade.php)
--dynamic Deprecated: blocks are dynamic by default
--inner-blocks Add inner blocks: <InnerBlocks /> in render.blade.php (InnerBlocks in edit.jsx/save.jsx with --static)
--no-view-script Skip frontend view script
--force Overwrite existing block

Examples

# Block rendered with Blade in the active theme
php artisan pollora:make:block testimonial --theme --title="Testimonial"

# Static block saved in post content
php artisan pollora:make:block hero-banner --theme --static

# Block with InnerBlocks in a plugin
php artisan pollora:make:block accordion --plugin=my-plugin --inner-blocks

# Custom namespace and category
php artisan pollora:make:block pricing-table --theme --namespace=starter --category=design

First-Run Bootstrap

When creating the first block in a theme or plugin, the command automatically:

  1. Patches vite.config.js with block entry discovery, wordpressPlugin() and Blade-only full reloads
  2. Adds required npm dependencies (@roots/vite-plugin, @wordpress/blocks, etc.)

A block needs a Vite build: the command refuses a theme or plugin that has no package.json or no vite.config.js, and writes nothing. A plugin made with pollora:make:plugin --asset has both.

In a theme or plugin whose blocks are still in resources/blocks, it skips the bootstrap and updates the vite.config.js block entries to build both locations.

Rendering with Blade

render.blade.php receives the block's $attributes (array), $content (inner blocks HTML), $block (WP_Block) and $isPreview (whether it renders for the editor's preview):

<section {!! get_block_wrapper_attributes(['class' => 'py-16']) !!}>
    <h2 class="text-3xl font-bold">{{ $attributes['heading'] ?? '' }}</h2>

    <x-button :href="esc_url_raw($attributes['buttonUrl'] ?? '#')">
        {{ $attributes['buttonText'] ?? __('Learn more', 'my-theme') }}
    </x-button>

    {!! $content !!}
</section>
  • {{ }} escapes; use {!! !!} only for get_block_wrapper_attributes() and $content, which WordPress already escaped.
  • For URLs, filter the protocol with esc_url_raw() and let {{ }} escape: href="{{ esc_url_raw($url) }}". esc_url() already HTML-encodes, so inside {{ }} a & would come out as &amp;#038;.
  • Blade components work as in any view. The block's $attributes array is restored after each <x-…> tag, even though components use their own $attributes bag.
  • Tailwind classes used in the template are picked up as long as your CSS scans resources/views.

A render file must stay inside the block directory: if block.json points outside it, or to a missing file, the block renders nothing and a warning is logged.

A render.php file still works and is included as plain PHP, with the same variables.

Inner blocks in a Blade template

Write <InnerBlocks /> where the block's inner blocks go. In the editor, they are edited right there, inside the rendered template; on the page, they replace the tag:

<article {!! get_block_wrapper_attributes(['class' => 'card']) !!}>
    <h3 class="card__title">{{ $attributes['title'] ?? '' }}</h3>

    <InnerBlocks
        class="card__body"
        allowedBlocks="{{ json_encode(['core/paragraph', 'core/list']) }}"
        template="{{ json_encode([['core/paragraph', ['placeholder' => __('Write the card…', 'my-theme')]]]) }}"
        templateLock="false"
    />
</article>

On the page, the tag becomes the inner blocks inside a div carrying its class:

<article class="wp-block-my-theme-card card">
    <h3 class="card__title">Title</h3>
    <div class="card__body"><p>The inner blocks</p></div>
</article>

The editor renders the same div around the editable blocks, so one stylesheet serves both. Without a class, the wrapper is <div class="pollora-inner-blocks">.

  • Options: the tag takes the options of Gutenberg's inner blocks — allowedBlocks, template, templateLock ("all", "insert", "contentOnly", "false"), orientation, defaultBlock, directInsert, prioritizedInserterBlocks, templateInsertUpdatesSelection. Arrays and objects are JSON, "true" and "false" booleans. Write JSON with {{ json_encode(...) }}: it escapes the quotes and the > the tag's attributes may not hold raw.
  • One per block: Gutenberg keeps a single list of inner blocks per block. The first <InnerBlocks /> gets it; any further tag is dropped, in the editor and on the page.
  • Where it can go: anywhere in the template's markup, at any depth. Both <InnerBlocks /> and <InnerBlocks></InnerBlocks> work.
  • Saved in the post: the inner blocks are stored in post_content inside the block's comment, the rest of the block is not. Changing the template updates every existing block and keeps its inner blocks.
  • $content holds the rendered inner blocks, as before; printing {!! $content !!} yourself still works, without the editor making them editable there.

In the editor

The preview is the template rendered by the server, turned into editor elements: $isPreview is true, $content is empty, and <InnerBlocks /> stays in the HTML for the editor to replace. A few things differ from the page:

  • <script> elements and on… attributes are left out of the preview, so the template's scripts do not run in the editor;
  • the rendered template sits inside the editor's own block wrapper, one div deeper than on the page;
  • use $isPreview to show a placeholder, or skip something heavy, in the editor only:
@if ($isPreview && empty($attributes['image']))
    <p class="card__hint">{{ __('Choose an image in the block settings.', 'my-theme') }}</p>
@endif

Migrating from ACF blocks: the tag, its options and the wrapper follow ACF's <InnerBlocks /> — only the default wrapper class differs (pollora-inner-blocks instead of acf-innerblocks-container).

Migrating from resources/blocks

Blocks used to live in resources/blocks. That directory is still registered, with a deprecation notice in the log, and support ends in Pollora v15.

  1. Move the blocks: git mv resources/blocks resources/views/blocks
  2. Delete app/Providers/BlocksServiceProvider.php if you have one: Pollora registers both locations itself, and a block present in both is taken from resources/views/blocks.
  3. In vite.config.js, glob ./resources/views/blocks/*/… and restrict full reloads to Blade files (see Full reloads). Running pollora:make:block does this for you.
  4. Optionally convert a static block to Blade: add "render": "file:./render.blade.php" to block.json, move the markup of save.jsx into render.blade.php, and set save: window.pollora.blocks.save. Existing content keeps its saved markup until the post is edited, then renders from Blade.

npm Dependencies

Block development requires these packages (added automatically by pollora:make:block):

{
    "devDependencies": {
        "@roots/vite-plugin": "^2.0.0",
        "glob": "^11.0.0",
        "@wordpress/blocks": "^14.0.0",
        "@wordpress/block-editor": "^14.0.0",
        "@wordpress/components": "^29.0.0",
        "@wordpress/element": "^6.0.0",
        "@wordpress/i18n": "^5.0.0"
    }
}

Stubs

Block stubs can be published and customized:

php artisan vendor:publish --tag=pollora-block-stubs

This copies the stubs to stubs/pollora-block/ where you can modify them for your project conventions.