Create Gutenberg blocks in Pollora themes, plugins and modules with Vite, JSX/TSX, Blade rendering and Tailwind CSS.
php artisan boost:add-skill pollora/nectar
Save this content to: .claude/skills/pollora-blocks/SKILL.md
# Pollora Block Development
## When to use this skill
Use this skill when creating, configuring, or customizing WordPress Gutenberg blocks within a Pollora theme, plugin or module.
## Generating a Block
```bash
php artisan pollora:make:block hero-banner --theme
```
Options:
- `--theme[=NAME]` — Create in a theme (default: the active theme)
- `--plugin=NAME` — Create in a plugin
- `--static` — A static block saved in `post_content` (`save.jsx`, no `render.blade.php`)
- `--inner-blocks` — Add inner blocks: `<InnerBlocks />` in `render.blade.php` (Gutenberg's `InnerBlocks` in `edit.jsx`/`save.jsx` with `--static`)
- `--namespace=NS`, `--title=TITLE`, `--category=CAT`, `--icon=ICON`, `--no-view-script`, `--force`
- `--dynamic` is deprecated: blocks are dynamic by default
On the first block, the command patches `vite.config.js` (block entries, `wordpressPlugin()`, Blade-only full reloads) and adds the npm dependencies. It refuses a theme or plugin with no `package.json` or no `vite.config.js`.
## Block Structure
```
resources/views/blocks/hero-banner/
├── block.json # WordPress block metadata
├── render.blade.php # Server-side render (default)
├── index.jsx # Entry point & registration
├── edit.jsx # Editor component — shows what the page will show
├── save.jsx # Static blocks only (--static)
├── editor.css # Editor-only styles
├── style.css # Shared frontend + editor styles
└── 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 "This block contains unexpected or invalid content".
## block.json
```json
{
"$schema": "https://schemas.wp.org/trunk/block.json",
"apiVersion": 3,
"name": "my-theme/hero-banner",
"version": "1.0.0",
"title": "Hero Banner",
"category": "theme",
"icon": "cover-image",
"supports": {
"html": false,
"align": ["wide", "full"]
},
"attributes": {
"heading": { "type": "string", "default": "" },
"ctaText": { "type": "string", "default": "Learn More" },
"ctaUrl": { "type": "string", "default": "#" }
},
"textdomain": "my-theme",
"editorScript": "file:./index.jsx",
"editorStyle": "file:./editor.css",
"style": "file:./style.css",
"viewScript": "file:./view.js",
"render": "file:./render.blade.php"
}
```
## Editor Component (edit.jsx) and save
A generated dynamic block is edited through the framework's editor runtime, `window.pollora.blocks`, which Pollora loads with every block that has a `render` template (handle `pollora-block-editor`). Nothing to install or import:
```jsx
// edit.jsx
import metadata from './block.json';
export default window.pollora.blocks.bladeEdit(metadata);
// index.jsx
registerBlockType(metadata.name, {
edit: Edit,
save: window.pollora.blocks.save, // stores the inner blocks, nothing else
});
```
`bladeEdit` previews `render.blade.php` through the core block-renderer REST route (again 200 ms after an attribute change) and makes its `<InnerBlocks />` editable in place. Framework v13.32.0-beta.8 and earlier generate `ServerSideRender` and `save: () => null` instead — check `edit.jsx` before assuming.
## Rendering with Blade (render.blade.php)
The template receives `$attributes` (array), `$content` (inner blocks HTML), `$block` (`WP_Block`) and `$isPreview` (true while it renders for the editor's preview). Blade components work as in any view.
```blade
<section {!! get_block_wrapper_attributes(['class' => 'py-16']) !!}>
<h1 class="text-3xl font-bold">{{ $attributes['heading'] ?? '' }}</h1>
<a href="{{ esc_url_raw($attributes['ctaUrl'] ?? '#') }}" class="btn">
{{ $attributes['ctaText'] ?? '' }}
</a>
</section>
```
- `{{ }}` escapes; use `{!! !!}` only for `get_block_wrapper_attributes()` and `$content`.
- For URLs use `esc_url_raw()` inside `{{ }}` — `esc_url()` would be encoded twice.
- The render file must stay inside the block directory, or the block renders nothing and a warning is logged.
- A plain `render.php` still works, with the same variables.
## Inner Blocks: `<InnerBlocks />`
Write `<InnerBlocks />` in the template where the inner blocks go (ACF-style). In the editor they are edited right there; on the page they replace the tag, inside a `div` with the tag's `class` (default `pollora-inner-blocks`) — the same `div` the editor renders.
```blade
<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…']]]) }}"
templateLock="false"
/>
</article>
```
- Options are Gutenberg's inner blocks options as attributes: `allowedBlocks`, `template`, `templateLock`, `orientation`, `defaultBlock`, `directInsert`… JSON for arrays/objects (write it with `{{ json_encode(...) }}`, which escapes quotes and `>`), `"true"`/`"false"` for booleans.
- One list per block: only the first tag gets the inner blocks; further tags are dropped.
- The inner blocks are saved in `post_content`; the rest of the block is not, so template changes reach existing blocks.
- In the preview, `$content` is empty and the tag stays for the editor; `<script>` and `on…` attributes are left out. Use `$isPreview` for editor-only hints.
- Requires Pollora 13.34 (the runtime above). Do not use `ServerSideRender` or a hand-written `InnerBlocks` edit for a Blade block with inner blocks.
## Registration
There is nothing to write. Pollora registers the blocks of every active theme, plugin and module on WordPress `init`: whatever holds a `resources/views/blocks` directory (or the deprecated `resources/blocks`).
- Do **not** create a `BlocksServiceProvider` or call `register_block_type()`: a provider boots after `init` over HTTP and not at all for REST requests, so its blocks would exist in WP-CLI only.
- A `BlocksServiceProvider` left by an older `pollora:make:block` is harmless and can be deleted.
- Assets resolve through the module's asset container: `theme`, `plugin.{slug}` or `module.{slug}`.
## Tailwind CSS in Blocks
### Frontend + Editor Styles (style.css)
```css
@import "tailwindcss" source(".");
.wp-block-my-theme-hero-banner {
@apply relative py-24 px-8 rounded-xl overflow-hidden;
}
```
### Editor-Only Styles (editor.css)
```css
@reference "tailwindcss";
.wp-block-my-theme-hero-banner {
@apply border-2 border-dashed min-h-[300px];
}
```
## Vite Configuration for Blocks
`pollora:make:block` writes this on first use:
```js
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;
// input: [..., ...Object.values(blockEntries)]
// plugins: [..., ...(hasBlocks ? [wordpressPlugin()] : [])]
// refresh: Blade files only, so block JSX hot-reloads:
// [...refreshPaths.filter((p) => p !== 'resources/views/**'), 'resources/views/**/*.blade.php']
```
## Migrating from `resources/blocks`
The old directory is still registered, with a deprecation notice in the log, until Pollora v15.
1. `git mv resources/blocks resources/views/blocks`
2. Delete `app/Providers/BlocksServiceProvider.php` if present
3. Update the `vite.config.js` globs and full reloads (running `pollora:make:block` does it)
4. Optionally convert a static block: add `"render": "file:./render.blade.php"`, move the markup of `save.jsx` into it, set `save: window.pollora.blocks.save`
## Important Notes
- Block names follow the pattern `{namespace}/{block-slug}` (e.g., `my-theme/hero-banner`)
- Use `@import "tailwindcss" source(".")` in `style.css`, `@reference "tailwindcss"` in `editor.css`
- Custom `BlockRegistrarInterface` implementations: both methods take an optional `?string $basePath = null` (since v13.32)
Use this skill when creating, configuring, or customizing WordPress Gutenberg blocks within a Pollora theme, plugin or module.
php artisan pollora:make:block hero-banner --theme
Options:
--theme[=NAME] — Create in a theme (default: the active theme)--plugin=NAME — Create in a plugin--static — A static block saved in post_content (save.jsx, no render.blade.php)--inner-blocks — Add inner blocks: <InnerBlocks /> in render.blade.php (Gutenberg's InnerBlocks in edit.jsx/save.jsx with --static)--namespace=NS, --title=TITLE, --category=CAT, --icon=ICON, --no-view-script, --force--dynamic is deprecated: blocks are dynamic by defaultOn the first block, the command patches vite.config.js (block entries, wordpressPlugin(), Blade-only full reloads) and adds the npm dependencies. It refuses a theme or plugin with no package.json or no vite.config.js.
resources/views/blocks/hero-banner/
├── block.json # WordPress block metadata
├── render.blade.php # Server-side render (default)
├── index.jsx # Entry point & registration
├── edit.jsx # Editor component — shows what the page will show
├── save.jsx # Static blocks only (--static)
├── editor.css # Editor-only styles
├── style.css # Shared frontend + editor styles
└── 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 "This block contains unexpected or invalid content".
{
"$schema": "https://schemas.wp.org/trunk/block.json",
"apiVersion": 3,
"name": "my-theme/hero-banner",
"version": "1.0.0",
"title": "Hero Banner",
"category": "theme",
"icon": "cover-image",
"supports": {
"html": false,
"align": ["wide", "full"]
},
"attributes": {
"heading": { "type": "string", "default": "" },
"ctaText": { "type": "string", "default": "Learn More" },
"ctaUrl": { "type": "string", "default": "#" }
},
"textdomain": "my-theme",
"editorScript": "file:./index.jsx",
"editorStyle": "file:./editor.css",
"style": "file:./style.css",
"viewScript": "file:./view.js",
"render": "file:./render.blade.php"
}
A generated dynamic block is edited through the framework's editor runtime, window.pollora.blocks, which Pollora loads with every block that has a render template (handle pollora-block-editor). Nothing to install or import:
// edit.jsx
import metadata from './block.json';
export default window.pollora.blocks.bladeEdit(metadata);
// index.jsx
registerBlockType(metadata.name, {
edit: Edit,
save: window.pollora.blocks.save, // stores the inner blocks, nothing else
});
bladeEdit previews render.blade.php through the core block-renderer REST route (again 200 ms after an attribute change) and makes its <InnerBlocks /> editable in place. Framework v13.32.0-beta.8 and earlier generate ServerSideRender and save: () => null instead — check edit.jsx before assuming.
The template receives $attributes (array), $content (inner blocks HTML), $block (WP_Block) and $isPreview (true while it renders for the editor's preview). Blade components work as in any view.
<section {!! get_block_wrapper_attributes(['class' => 'py-16']) !!}>
<h1 class="text-3xl font-bold">{{ $attributes['heading'] ?? '' }}</h1>
<a href="{{ esc_url_raw($attributes['ctaUrl'] ?? '#') }}" class="btn">
{{ $attributes['ctaText'] ?? '' }}
</a>
</section>
{{ }} escapes; use {!! !!} only for get_block_wrapper_attributes() and $content.esc_url_raw() inside {{ }} — esc_url() would be encoded twice.render.php still works, with the same variables.<InnerBlocks />Write <InnerBlocks /> in the template where the inner blocks go (ACF-style). In the editor they are edited right there; on the page they replace the tag, inside a div with the tag's class (default pollora-inner-blocks) — the same div the editor renders.
<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…']]]) }}"
templateLock="false"
/>
</article>
allowedBlocks, template, templateLock, orientation, defaultBlock, directInsert… JSON for arrays/objects (write it with {{ json_encode(...) }}, which escapes quotes and >), "true"/"false" for booleans.post_content; the rest of the block is not, so template changes reach existing blocks.$content is empty and the tag stays for the editor; <script> and on… attributes are left out. Use $isPreview for editor-only hints.ServerSideRender or a hand-written InnerBlocks edit for a Blade block with inner blocks.There is nothing to write. Pollora registers the blocks of every active theme, plugin and module on WordPress init: whatever holds a resources/views/blocks directory (or the deprecated resources/blocks).
BlocksServiceProvider or call register_block_type(): a provider boots after init over HTTP and not at all for REST requests, so its blocks would exist in WP-CLI only.BlocksServiceProvider left by an older pollora:make:block is harmless and can be deleted.theme, plugin.{slug} or module.{slug}.@import "tailwindcss" source(".");
.wp-block-my-theme-hero-banner {
@apply relative py-24 px-8 rounded-xl overflow-hidden;
}
@reference "tailwindcss";
.wp-block-my-theme-hero-banner {
@apply border-2 border-dashed min-h-[300px];
}
pollora:make:block writes this on first use:
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;
// input: [..., ...Object.values(blockEntries)]
// plugins: [..., ...(hasBlocks ? [wordpressPlugin()] : [])]
// refresh: Blade files only, so block JSX hot-reloads:
// [...refreshPaths.filter((p) => p !== 'resources/views/**'), 'resources/views/**/*.blade.php']
resources/blocksThe old directory is still registered, with a deprecation notice in the log, until Pollora v15.
git mv resources/blocks resources/views/blocksapp/Providers/BlocksServiceProvider.php if presentvite.config.js globs and full reloads (running pollora:make:block does it)"render": "file:./render.blade.php", move the markup of save.jsx into it, set save: window.pollora.blocks.save{namespace}/{block-slug} (e.g., my-theme/hero-banner)@import "tailwindcss" source(".") in style.css, @reference "tailwindcss" in editor.cssBlockRegistrarInterface implementations: both methods take an optional ?string $basePath = null (since v13.32)How can I help you explore Laravel packages today?