The visual editor saves a Gutenberg-shaped block tree to your model. Renderers are the packages that take that block tree and turn it back into HTML for the public site. V1 ships three:
| Package | Type | Where it runs | Use it when |
|---|---|---|---|
artisanpack-ui/visual-editor-renderer-blade |
Composer (PHP) | Server-side, inside Blade views | Traditional Laravel app, Blade + Livewire, no SPA front-end |
@artisanpack-ui/visual-editor-renderer-react |
npm | Client-side, inside React tree | Inertia+React, headless React front-end |
@artisanpack-ui/visual-editor-renderer-vue |
npm | Client-side, inside Vue tree | Inertia+Vue, headless Vue front-end |
All three resolve a per-block partial/component by block name and fall
through to a placeholder when nothing's registered. Dynamic blocks
render server-side regardless of the client renderer — the Blade renderer
calls DynamicBlock::render() directly, the React and Vue renderers
proxy through /visual-editor/api/blocks/preview.
1. Blade renderer
composer require artisanpack-ui/visual-editor-renderer-blade
<x-ve-blocks :tree="$post->getBlockContent()" />
The <x-ve-blocks> component walks the block tree and renders each block:
- If it's a dynamic block, call the registered
DynamicBlock::render($attributes). - Otherwise, render the partial
visual-editor-renderer-blade::blocks.{namespace}.{name}with$attributesand$innerBlocksHtmlin scope. - If no partial exists, emit an HTML comment placeholder.
Static-block partials live under
packages/visual-editor-renderer-blade/resources/views/blocks/{namespace}/{block}.blade.php.
Host apps override individual partials by publishing the view namespace:
php artisan vendor:publish --tag=visual-editor-blade-views
Then edit
resources/views/vendor/visual-editor-renderer-blade/blocks/artisanpack/callout.blade.php.
Rendering a template
For full-template rendering (with template-part resolution and the
<head> block emitted by global styles):
<x-ve-template :slug="$templateSlug" />
<x-ve-template> looks up the template via TemplateResolver, applies
the fallback chain (theme file → user override → custom), and inlines
template parts. See Templates for hierarchy details.
Template-part and synced-pattern references are recognized under both
their core and forked names (BlockShape::TEMPLATE_PART_NAMES /
BlockShape::PATTERN_REF_NAMES):
| Reference | Names expanded |
|---|---|
| Template part | core/template-part (theme files), artisanpack/template-part (templates saved in the site editor) |
| Synced pattern | core/block, artisanpack/block (older saved templates) |
Since v1.12.1 (#822). Before 1.12.1 only the core names were expanded,
so every part in a template saved from the site editor rendered as an
empty wrapper. The React and Vue inliners follow the same rule. A resolved
template part keeps the block name it was saved with, and its saved
innerBlocks snapshot is always replaced by the live part. A resolved
pattern reference is always output as core/block.
Core query block names
Theme templates written for WordPress use the core/query-* family. The
renderers render these names with the matching artisanpack/* fork output,
so a theme template renders on the front end without first being saved in
the site editor:
| Core name | Renders as |
|---|---|
core/query-pagination |
artisanpack/query-pagination |
core/query-pagination-previous |
artisanpack/query-pagination-previous |
core/query-pagination-numbers |
artisanpack/query-pagination-numbers |
core/query-pagination-next |
artisanpack/query-pagination-next |
core/query-no-results |
artisanpack/query-no-results |
core/query-title |
artisanpack/query-title |
core/query-total |
artisanpack/query-total |
Since v1.14.0 (#856). Earlier versions rendered these names through the
unknown-block wrapper. core/query-total (WordPress 6.8's Query Total) is
rendered since v1.15.2 (#962). The Blade, React and Vue renderers all follow this
mapping.
Tree shapes
<x-ve-blocks :tree> accepts both the editor shape (name /
attributes) and WordPress's parse_blocks() shape (blockName /
attrs). Attributes are read from whichever key the block carries, so
headings in a parse_blocks() tree keep their content and get table of
contents anchors, and breadcrumbs and business-info blocks resolve in
either shape.
Since v1.14.0 (#863). Earlier versions rendered every block in an
attrs-shape tree with empty attributes.
Front-end styles
Mount <x-ve-blocks-styles> once in your layout's <head>:
<head>
<x-ve-blocks-styles :theme-json="$themeJson" />
</head>
It links the bundled block-library CSS and the per-block front-end
stylesheets, and inlines the --wp--preset--* tokens compiled from the
theme.json you pass, the layout baseline, the defaults below and, since
v1.15.0, the compiled global styles. It takes these attributes:
| Attribute | Default | Effect |
|---|---|---|
:theme-json |
null |
The theme.json array to compile into <style data-ve-theme-tokens>. |
:bundle |
true |
false skips the block-library <link>s. |
:interactive |
true |
false skips the accordion / tabs assets. |
asset-base |
/vendor/visual-editor-renderer-blade |
Serve the CSS and scripts from another base URL, such as a CDN. |
:global-styles |
true |
Since v1.15.0. false means this component never prints the global styles itself. See Global styles placement. |
:block-style-variations |
true |
Since v1.15.0. false keeps the theme's styles.blocks.*.variations out of the theme tokens. |
Order. Mount <x-ve-blocks-styles> before the <link> to your
theme's own stylesheet. The global styles print last inside the
component, so the page follows the editor canvas (and WordPress) order:
block library, then global styles, then the theme's style.css. A
style.css rule then beats the theme.json rule it overrides on both
surfaces.
<head>
<x-ve-blocks-styles :theme-json="$themeJson" />
<link rel="stylesheet" href="{{ $themeStylesheetUrl }}">
</head>
Block style variations are emitted once. The global styles already
carry every block style variation's .is-style-* rules. Once the global
styles are on the page (printed by this component, queued for it, or
printed earlier by a block renderer), the component doesn't compile the
theme's variations into the tokens again. There's one case it can't
detect. With :global-styles="false" on a head that renders before the
blocks, the variations are still compiled into the tokens, because the
component can't know the blocks will follow and print them too. Pair it
with :block-style-variations="false" on pages that render blocks.
Global styles placement
Since v1.15.0 (#879). The compiled global styles print once per request
in a <style data-ve-global-styles> tag. With
global_styles_in_head at its
default of true, where they end up depends on what renders first:
- The head renders first (a single-file view with
<x-ve-blocks-styles>above the blocks). The component prints the global styles inline in<head>. - The blocks render first, inside an
@sectionor a component slot (an@extendslayout, or an anonymous or class component layout).<x-ve-blocks>/<x-ve-template>push the tag onto theve-global-stylesBlade stack, and<x-ve-blocks-styles>prints that stack in<head>when the layout renders. - The blocks render first, outside any section or slot, or after a
<x-ve-blocks-styles>has already rendered. Nothing later can move them higher in the document, so they print inline in the body, as in 1.14.
In every case they print exactly once, and a second
<x-ve-blocks-styles> doesn't print the stack again. If the layout has
<x-ve-blocks-styles>, don't add your own @stack('ve-global-styles'),
because the component already prints it. A layout without the component
can print the stack itself:
<head>
@stack('ve-global-styles')
<link rel="stylesheet" href="{{ $themeStylesheetUrl }}">
</head>
:global-styles="false" means the component never claims the emission
itself. In a single-file view, the first <x-ve-blocks> then prints the
global styles in the body. In a layout where the blocks render first,
they have already queued the styles for the head, so the component still
prints the queued stack rather than lose them. To get body placement
there, set global_styles_in_head to false.
Safety net for layouts that never print the stack. The Blade
renderer adds the InjectQueuedGlobalStyles middleware
(ArtisanPackUI\VisualEditorRendererBlade\Http\Middleware\InjectQueuedGlobalStyles)
to the web middleware group. It acts only when blocks queued the
global styles (case 2) and neither <x-ve-blocks-styles> nor a host
@stack('ve-global-styles') printed them. It then inserts the tag:
- inside
<head>, before the first stylesheet (<link rel="stylesheet">or<style>; commented-out ones are ignored), so the theme's own CSS still wins; - otherwise just before
</head>; - with no
</head>, right after the opening<body>tag; - otherwise at the end of the response.
It never prints the styles twice. It does nothing once the stack has been
printed, or when the response already contains the queued tag. It leaves
streamed, binary-file, JSON and redirect responses alone, along with any
response that has a Content-Encoding header or a content type other
than text/html. When it injects, it updates Content-Length. Its state
lives on the request-scoped GlobalStylesPlacement service, so
long-lived workers such as Octane start every request clean.
Limitation. Output that never becomes an HTTP response, such as mail or
Blade::render()into a string, gets no injection. If those views render blocks inside a section or slot without printing the stack, setglobal_styles_in_headtofalseso the styles print inline in the body.
Set global_styles_in_head to false to restore the 1.14 placement:
<x-ve-blocks-styles> never prints the global styles, and the first
<x-ve-blocks> / <x-ve-template> prints them inline in the body.
Tags the Blade renderer prints
Besides its <link>s, <x-ve-blocks-styles> prints
<style data-ve-theme-tokens>, <style data-ve-spacing-presets>,
<style data-ve-default-tokens> and <style data-ve-default-styles>
when each has content, then <style data-ve-responsive-stacking>,
<style data-ve-layout-baseline> and <style data-ve-global-styles>.
Since v1.15.1 (#895), when the breakpoints differ from the defaults,
the Flex Layout token rules print as <style data-ve-flex-layout> in
place of the flex-layout.css link (see
Flex layout). data-ve-default-tokens also
declares WordPress's default shadow presets (#900, see
Box shadows).
Since v1.15.0, the layout baseline also resets the inherited block gap
on every layout container at zero specificity, so a nested Row, Stack,
Grid or Group no longer picks up its parent's Block spacing. Each
container's own spacing reaches its children through its
wp-container-{hash} rule instead, as in WordPress:
:where(body) { --ap-root-block-gap: var(--wp--style--block-gap); }
:where(.is-layout-flow, .is-layout-constrained, .is-layout-flex, .is-layout-grid) { --wp--style--block-gap: var(--ap-root-block-gap); }
:where(.wp-block-columns) { --ap-columns-gap: var(--wp--style--block-gap); }
Set a site-wide --wp--style--block-gap on body or :root (or through
theme.json styles.spacing.blockGap), not on a wrapper element around
<x-ve-blocks>. See Flex layout for the
per-instance rules.
<x-ve-blocks> and <x-ve-template> print the support CSS collected
while their tree renders. Since v1.15.0 that includes duotone: one
<style data-ve-duotone> with the wp-duotone-* rules and one visually
hidden <svg data-ve-duotone> holding the <filter> definitions. Each
filter id is printed once per request, even when a page renders several
trees. Hosts that call render() / renderMarkup() directly get both
from flushSupportAssets().
Theme-less front-end defaults
Since v1.12.1 (#821). A site with no theme now renders close to the
editor canvas. <x-ve-blocks-styles> always declares:
-
Font-size and palette presets — every preset the editor's pickers offer, as
--wp--preset--font-size--*/--wp--preset--color--*properties, plus the.has-{slug}-font-size,.has-{slug}-color,.has-{slug}-background-color, and.has-{slug}-border-colorclasses that point at them. When the theme ships no list, the package defaults are used (small13px,regular16px,medium20px,large28px,huge36px;base-content,base-muted,primary,secondary,accent,success,warning,error), with hostpresetslayered on top. Before 1.12.1 a "Large" pick on a theme-less site savedhas-large-font-sizewith nothing behind it.Since v1.14.1. A palette slug named
text,linkorborderproduces a.has-{slug}-colorclass that is also a block-support marker (has-text-color,has-link-color,has-border-color). A block that picks one of these slugs as its text color also gets an exacthas-{slug}-color-presetclass with its own rule. The.has-{slug}-colorrule remains as a zero-specificity fallback for markup the renderer doesn't build: it skips elements with an inlinecolor:, requireshas-text-colorforlink/border, and thetextfallback is emitted last, so in editor-canvas or saved Gutenberg markup alink/bordertext pick resolves totext. SeePresetRegistry::colorUtilityRules().
The package's own CSS also gives every var(--wp--*) / var(--ap-*) a
fallback value, so a missing token never collapses a declaration.
Baseline stylesheet. On top of the tokens, an optional baseline adds
content typography (system font stack, 16px, line-height 1.6), a heading
scale for .wp-block-heading, and root padding for constrained post
content (with .alignfull children bleeding to the edges). It is scoped
to block output (.wp-block-post-content, .wp-block-heading,
.has-global-padding) rather than body or bare h1–h6, so it can't
restyle a Tailwind host's own chrome, and every rule is wrapped in
:where() so any theme rule wins. The
default_styles config key controls
it:
| Value | Behaviour |
|---|---|
'auto' (default) |
Emitted only when no theme is active — no theme-json is passed and cms-framework reports no active theme. |
true |
Always emitted. |
false |
Never emitted. |
The React and Vue renderers declare the same preset and layout token
defaults through <LayoutBaseline /> (see below). The baseline
stylesheet is Blade-only.
Rendering raw block markup
Since v1.5.5 (#688).
<x-ve-blocks> and <x-ve-template> both start from a block tree. When
what you have is a raw WP block-markup string — a block theme's
templates/*.html or parts/*.html, a .php pattern, a persisted
post_content column — use renderMarkup():
use ArtisanPackUI\VisualEditorRendererBlade\BlockRenderer;
$html = app(BlockRenderer::class)->renderMarkup(
file_get_contents($theme->path('templates/home.html')),
defaultTheme: 'artisanpack-ui',
);
Markup in, HTML out. The call hydrates the markup into a tree, then inlines
template-part and synced-pattern references before walking it — so a
standalone theme template renders its parts as real content rather than
empty wrappers.
The hydration step matters more than it looks. Gutenberg persists most block
text in the saved HTML, not in the delimiter JSON, so a naive
{blockName, attrs} → {name, attributes} key-rename yields a
structurally-correct but completely textless page. Hydration replays each
registered block type's block.json attribute definitions back over the
saved HTML to recover paragraph and heading content, button text and href,
image src/alt/caption, list values, table cells, and everything else
declared with a source. Recovery is registry-driven, so a block that ships
a new sourced attribute is picked up with no extra wiring.
To hydrate without rendering — when you need the tree itself, or want to
pass a $post for full-fidelity rendering — use the hydrator directly:
use ArtisanPackUI\VisualEditor\Support\BlockMarkupHydrator;
$tree = app(BlockMarkupHydrator::class)->hydrate($markup);
<x-ve-blocks :tree="$tree" :post="$post" />
Support CSS for direct renders
Since v1.14.0.
Block supports don't print inline styles. Responsive overrides, states,
animations, gradient borders, box shadows, position, Elements API
colors (Color → Link, heading and button element colors) and, since
v1.15.0, duotone filters are collected
while the tree renders, and <x-ve-blocks> / <x-ve-template> print
them around their output. When you call render() or renderMarkup()
yourself, drain them with flushSupportAssets(). Print css before the
HTML and scripts after it:
$renderer = app(BlockRenderer::class);
$html = $renderer->renderMarkup($markup);
$assets = $renderer->flushSupportAssets();
echo $assets['css'] . $html . $assets['scripts'];
Without it, blocks keep classes such as wp-elements-{hash} with no rules
behind them. That includes Navigation link colors, which printed their own
inline <style> before v1.14.0. Draining also clears the collected rules,
so a later <x-ve-blocks> in the same request doesn't print them again.
Scope. renderMarkup() cannot resolve blocks that need an entity in
scope — post-*, core/query loops, comments, breadcrumbs — because a
string input carries no post. Hydrate to a tree and hand it to
<x-ve-blocks :tree="…" :post="…" /> for those.
Requires cms-framework. The markup parser lives in
artisanpack-ui/cms-framework. Without it, renderMarkup() returns an empty
string and hydrate() returns an empty tree. Gate on
BlockMarkupHydrator::canParseMarkup() if you would rather fail loudly.
hydrateTree(), which takes an already-parsed parse_blocks()-shape array,
works either way.
Security.
$markupmust already be trusted to render. Block partials emit recovered rich-text unescaped — that is what makes a paragraph's<strong>survive the round trip — so hydration is safe over theme files, patterns, and editor-authored content that passed the post editor's authorization, and not over visitor-submitted markup, which it would turn into stored XSS. It is not a sanitizer. This is the same trust boundary Gutenberg draws around block markup. Run untrusted markup through your own sanitizer (e.g.kses()fromartisanpack-ui/security) first.
Registering a custom block renderer
Static blocks: add the partial. Dynamic blocks: register the DynamicBlock
subclass in your service provider:
VisualEditor::registerDynamicBlock(LatestPostsBlock::class);
The renderer picks it up automatically.
2. React renderer
npm install @artisanpack-ui/visual-editor-renderer-react
import { BlockTree, registerBlockRenderer } from '@artisanpack-ui/visual-editor-renderer-react';
import { CalloutBlock } from './blocks/callout';
registerBlockRenderer('artisanpack/callout', CalloutBlock);
export function Post({ blocks }) {
return <BlockTree tree={blocks} />;
}
Each renderer component receives { attributes, innerBlocks, children }:
export function CalloutBlock({ attributes, children }) {
const severity = attributes.severity ?? 'info';
return (
<div className={`ap-callout ap-callout--${severity}`}>
<div className="ap-callout__body">{children}</div>
</div>
);
}
children is the pre-rendered innerBlocks tree — pass it straight into
whatever wrapper the block needs. If you'd rather render innerBlocks
manually, use <BlockTree tree={innerBlocks} />.
Dynamic blocks in React
The React renderer ships a <DynamicBlock> fallback that fetches the
server-rendered HTML from POST /visual-editor/api/blocks/preview and
injects it via dangerouslySetInnerHTML. The fallback fires whenever a
block has no client registration but the server has a DynamicBlock for
that name.
To skip the round-trip, register a client renderer for the dynamic block that produces equivalent HTML from the same attributes. This is a denormalization — keep the two in sync deliberately.
Rendering templates and global styles
import { Template, GlobalStyles } from '@artisanpack-ui/visual-editor-renderer-react';
<>
<GlobalStyles />
<Template slug={templateSlug} />
</>
<GlobalStyles> fetches and emits the CSS from
/visual-editor/api/global-styles/css. Mount it once at the root.
Also mount <LayoutBaseline /> once (React and Vue). Besides the
flow / constrained / flex / grid layout rules, it declares the package
default spacing presets (--wp--preset--spacing--20 … --70) at zero
specificity (:where(:root)) and the navigation block's default item gap
(:where(.wp-block-navigation)), so var:preset|spacing|* picks and
Block spacing resolve even when the theme ships no spacingSizes. Since
v1.12.1 (#821) it also declares the default font-size and palette presets
with their .has-{slug}-* classes, and the content size, wide size, block
gap and root padding tokens — the same defaults as the Blade renderer's
theme-less front-end defaults. Any
:root declaration from <GlobalStyles> — theme, style variation, user
Global Styles or host presets — overrides those defaults.
Styles <BlockTree> prints
Like Blade, the React and Vue renderers don't write support CSS into
inline styles. <BlockTree> collects it while it walks the tree and
prints one tag per kind inside the rendered fragment:
| Tag | Contents |
|---|---|
<style data-ve-layout> |
Per-instance layout rules for wp-container-{hash} classes. |
<style data-ve-responsive> |
Per-breakpoint overrides for ve-r-{hash} classes. |
<style data-ve-effects> |
Effects and Custom CSS for ve-fx-{hash} classes. |
<style data-ve-duotone> + <svg data-ve-duotone> |
Since v1.15.0. Duotone rules for wp-duotone-* classes, plus one visually hidden <svg> with the <filter> definitions. |
Since v1.15.0, the React and Vue renderers also render:
- Background images on Group, Quote, Pullquote, Verse and Post
Content (
style.background.backgroundImageas an object or a string, plus size, position, repeat and attachment), byte for byte with Blade. URLs go throughsafeUrl()and are percent-encoded insideurl('…'). - Duotone on Image, Featured Image, Cover, Avatar, Site Logo and Post
Author (and their
artisanpack/*forks). Custom stops (hex,rgb(),hsl()or named colors),unsetand theme presets all work. - Per-instance layout on every block whose
block.jsondeclaressupports.layout, with the same inline Block spacing (--wp--style--block-gap,row-gap,column-gap) Blade prints.
Three <BlockTree> props control the parts that depend on server config or the request:
| Prop | Type | Default | Notes |
|---|---|---|---|
duotonePresets |
DuotonePresets |
— | The theme's settings.color.duotone, as a flat list or keyed by origin ({ theme, custom, default }). Without it, var:preset|duotone|… picks render no filter; custom stops still work. Also accepted by <Template>. |
customCssAllowUrls |
boolean |
true |
Pass the host's effects.custom_css_allow_urls. When false, Custom CSS that loads a resource (url(, image-set(, image(, src() is dropped, as Blade drops it. <Template> takes the same prop and forwards it. |
currentUrl |
string | null |
— | Absolute URL of the page being rendered (#975). Navigation links / submenus pointing at it get current-menu-item + aria-current="page", ones to a parent path and submenus leading to it get current-menu-ancestor — what Blade derives from the request. Under Inertia: new URL(usePage().url, window.location.origin).href (or the site URL during SSR). Omitted, nothing is marked. Also accepted by <Template>. |
Resolve both on the server and pass them in the page props:
use ArtisanPackUI\VisualEditor\Resources\PresetRegistry;
return Inertia::render('Posts/Show', [
'post' => $post,
'duotonePresets' => PresetRegistry::activeThemeSettings()['color']['duotone'] ?? null,
'customCssAllowUrls' => (bool) config('artisanpack.visual-editor.effects.custom_css_allow_urls', true),
]);
<BlockTree
tree={post.content}
duotonePresets={duotonePresets}
customCssAllowUrls={customCssAllowUrls}
/>
Breakpoints
The responsive (ve-r-*), column-width (ve-w-*) and Flex Layout rules
use the device breakpoints the renderer is configured with. The defaults
match the server's: Tablet up to 1023px and Mobile up to 767px. Since
v1.15.0, hosts that customize breakpoints (config breakpoints or
theme.json) can pass the server's list to setBreakpoints() once at
bootstrap, so the JS output matches Blade:
use ArtisanPackUI\VisualEditor\Responsive\BreakpointRegistry;
// In the Inertia shared props:
'breakpoints' => app(BreakpointRegistry::class)->toArray(),
import { setBreakpoints, getDeviceBreakpoints } from '@artisanpack-ui/visual-editor-renderer-react';
setBreakpoints(page.props.breakpoints);
setBreakpoints() takes two forms:
-
An array, such as
BreakpointRegistry::toArray(). Entries withminWidthPxreplace the legacy (mobile-first) list. Entries withmaxWidthPxandpreviewWidthPxreplace the device list. An array with no device entries keeps the default devices, so existingsetBreakpoints([{ key, minWidthPx }])calls behave as before. -
A map, shaped like
config('artisanpack.visual-editor.breakpoints')and layered over the defaults the way the server layers the config. A number or'Npx'string keeps the family of the entry it overrides, an object naming one width switches family, andnullremoves the entry:setBreakpoints({ mobile: { maxWidthPx: 599, previewWidthPx: 375 } });
Entries the server would reject are dropped: the reserved base key,
keys that aren't a letter or digit followed by letters, digits, _ or -, non-positive or duplicate widths, and
entries with both or neither width. A device preview width wider than its
max-width is clamped to it. getDeviceBreakpoints() returns the active
device list, largest first. The types BreakpointInput, BreakpointMap
and DeviceBreakpointDefinition are exported too.
Since v1.15.1 (#895), once setBreakpoints() moves the breakpoints
off the defaults, <LayoutBaseline /> also prints the Flex Layout token
rules at the new widths (<style data-ve-flex-layout>). Call
setBreakpoints() before the first render, and stop linking the
published flex-layout.css: its default-width rules would still fire.
Navigation overlay
Since v1.13.0 (#804). The React and Vue renderers render
core/navigation with the same responsive overlay as Blade: a menu
button plus a responsive container that shows the menu inline at desktop
widths and as a full-screen drawer below 600px (with the default
overlayMenu: 'mobile'), or always behind the button ('always').
'never' keeps the plain <nav><ul>. The drawer closes on Escape, a
backdrop click or a link click, traps Tab focus while open and returns
focus to the menu button.
Upgrade note. In 1.12 the React and Vue renderers output a plain
<nav><ul>. With the overlay, the menu<ul>sits three levels deeper, inside.wp-block-navigation__responsive-container. Update host CSS that targets.wp-block-navigation > ul.
When the tree contains a navigation with an overlay, <BlockTree> emits
the overlay CSS once, in a <style data-ve-navigation-overlay> tag.
The rules sit in the ve-navigation cascade layer
(@layer ve-navigation { … }), so any unlayered host or theme rule wins
over them regardless of specificity.
To render a navigation overlay template part (the block's overlay
setting) inside the open drawer, pass the part in templateParts with
area: 'navigation-overlay'. A reference to a part in any other area is
ignored, and the drawer falls back to the menu, as it also does when the
part is missing or visibility rules hide all of its blocks:
import { BlockTree, NAVIGATION_OVERLAY_AREA } from '@artisanpack-ui/visual-editor-renderer-react';
<BlockTree
tree={page.content}
templateParts={[
{ slug: 'header', area: 'header', blocks: header.blocks },
// NAVIGATION_OVERLAY_AREA === 'navigation-overlay'
{ slug: 'mobile-overlay', area: NAVIGATION_OVERLAY_AREA, blocks: overlay.blocks },
]}
/>
The overlay part is resolved wherever the navigation ends up, including
inside template parts, synced patterns (when you pass patterns) and
query loops. A part that references itself, directly or through
another part, is skipped instead of recursing.
Since v1.15.2 (#973), the Blade renderer runs the overlay part
through the same passes as the page: nested template parts and synced
patterns are inlined, and a core/navigation inside the part that uses
a menu location (__unstableLocation) or ref renders its menu items,
resolved against the theme the page renders with (<x-ve-blocks default-theme>, <x-ve-template theme>). Give such a navigation
overlayMenu: 'never' so it doesn't open a drawer of its own. The React
and Vue renderers don't look menus up: pass the overlay part's
navigation blocks with their items already resolved, as you do for the
page.
Custom navigation renderers. The renderer resolves the part into an
internal overlay-content block and hands its rendered blocks to the
core/navigation renderer as a named slot, not as children:
- React: the new
slotsprop onBlockRendererProps. The overlay isslots[NAVIGATION_OVERLAY_SLOT]('overlay'). - Vue: the named slot
overlay(slots.overlay?.()).
import {
NAVIGATION_OVERLAY_SLOT,
registerBlockRenderer,
type BlockRendererProps,
} from '@artisanpack-ui/visual-editor-renderer-react';
function MyNavigation({ attributes, children, slots }: BlockRendererProps) {
const overlay = slots?.[NAVIGATION_OVERLAY_SLOT];
return (
<nav className="my-nav">
<ul>{children}</ul>
{overlay !== undefined && <div className="my-nav__drawer">{overlay}</div>}
</nav>
);
}
registerBlockRenderer('core/navigation', MyNavigation);
Both packages export NAVIGATION_OVERLAY_SLOT and
NAVIGATION_OVERLAY_AREA, so you don't have to hard-code either string.
Only the renderer creates the overlay-content block
(artisanpack/navigation-overlay-content); a stored block with that name
is dropped and never rendered.
3. Vue renderer
npm install @artisanpack-ui/visual-editor-renderer-vue
Same registry pattern. Renderer components are Vue SFCs (or defineComponent):
import { defineComponent, h } from 'vue';
import { registerBlockRenderer, BlockTree } from '@artisanpack-ui/visual-editor-renderer-vue';
const CalloutBlock = defineComponent({
props: ['attributes', 'innerBlocks'],
setup(props, { slots }) {
return () => h(
'div',
{ class: `ap-callout ap-callout--${props.attributes.severity ?? 'info'}` },
[h('div', { class: 'ap-callout__body' }, slots.default?.())],
);
},
});
registerBlockRenderer('artisanpack/callout', CalloutBlock);
The <BlockTree> / <Template> / <GlobalStyles> components mirror the
React renderer's API.
That includes the navigation overlay:
templateParts takes the same records (area: 'navigation-overlay' for
overlay parts), and a custom core/navigation renderer receives the
overlay content in the named overlay slot instead of the default slot.
The styles <BlockTree> prints and the
breakpoint setup are the same too. In templates, pass the
1.15 props in kebab case, and import setBreakpoints from the Vue
package:
<BlockTree
:tree="post.content"
:duotone-presets="duotonePresets"
:custom-css-allow-urls="customCssAllowUrls"
/>
4. Which renderer for which stack
- Traditional Laravel (Blade, Livewire, Volt) — Blade renderer only. Dynamic blocks resolve in-process; no extra network round-trips.
- Inertia + React — React renderer on the front-end, Blade renderer optional for SSR. Most apps don't need SSR.
- Inertia + Vue — Vue renderer on the front-end.
- API-driven SPA (no Inertia) — fetch the block tree from
/visual-editor/api/{resource}/{id}/contentand render with the React or Vue package.
See Inertia recipes for end-to-end examples.
5. Renderer parity
The three renderers must produce equivalent HTML for the same block tree.
The package's npm run verify:parity script renders a fixture tree
through all three and diffs the output. Add a fixture entry whenever you
add a custom block that ships partials/components in more than one
renderer.
Things to know when you mix renderers, or render more than one tree on a page:
- Heading anchors are unique per
<BlockTree>. The React and Vue renderers dedupe auto anchors within one tree. Two trees on the same page (for example a header tree and a content tree) can repeat anid. Render them as one tree, or set author anchors on the headings that could collide. Duotone filter ids work the same way: each<BlockTree>prints its own<svg data-ve-duotone>, so ids can repeat across trees. (Blade prints each filter id once per request.) - How auto anchors are built. Since v1.15.0, every renderer, and the
editor's anchor autogeneration, reduces a heading's text to the
characters an
idkeeps ([A-Za-z0-9_:.-], runs of-collapsed) before it checks for uniqueness. Text whose letters all drop out (such as日本語) falls back toheading, thenheading-1,heading-2and so on. Text with no letters or numbers at all (???) gets no anchor. Author-set anchors are kept as they are, including numeric ones such as42. The JS renderers decode only the HTML 4 named entities (plus') when they slug headings, so an HTML5-only entity such as★slugs differently from Blade. - Post-content headings. Since v1.15.0, the React and Vue renderers
treat headings inside a post-content block's
_resolvedContentas Blade does: an existingidis claimed, and a heading without one gets a unique auto anchor. The Table of Contents block's_resolvedItemsare still resolved on the server only. - Breakpoints. Hosts with custom breakpoints must pass
app(BreakpointRegistry::class)->toArray()tosetBreakpoints(), or the JS renderers use the default widths and their media queries won't match Blade's. classNameon every block. Since v1.15.0, Code, Preformatted, Verse, Embed and Accordion title/body carry theirclassNamein React and Vue too, sove-r-*andve-fx-*styling applies to them. On those blocks the JS renderers applyclassName, preset classes and dimensions.- Block supports. Since v1.15.3 (#920), the React and Vue
renderers build colour, background, spacing, border, typography and
dimensions supports for core block wrappers (the Cover overlay
included) with
compileBlockSupports(), a port of Blade'sBlockSupports::compile(), and validate every value before it reaches an inline style. Known remaining gaps: alignment classes (alignwide,has-text-align-*) on most blocks, supports on artisanpack dynamic blocks, and Gallery layout classes. - Post Title level 0. Since v1.15.3 (#994), a Post Title with
level: 0renders a<p>in all three renderers. - Dynamic blocks rendered through
/blocks/previewget only what the block's ownrender()outputs. The Blade renderer merges block supports into a dynamic block's wrapper (seeappliesOwnSupports()); the preview endpoint the React and Vue<DynamicBlock>fallback calls doesn't.
6. Distribution
The three renderer packages live under packages/ in the monorepo and
are split out to:
artisanpack-ui/visual-editor-renderer-blade(Packagist)@artisanpack-ui/visual-editor-renderer-react(npm)@artisanpack-ui/visual-editor-renderer-vue(npm)
V1.0.0 publishes the Blade renderer; React and Vue renderers ship from the dev app via a path/file repository until their first Packagist/npm publish.
See also
- Custom blocks — authoring blocks that need renderers
- Templates — template fallback chain and
core/template-part - Patterns — synced-pattern (
core/block) references - Configuration —
default_styles,global_styles_in_head,effectsandpresets - Global styles — CSS emission contract
- Inertia — embedding the renderers inside Inertia apps

