This document describes how the Handoff WordPress compiler reads Handoff component data (Handlebars templates and property definitions) and transforms them into WordPress Gutenberg blocks.
- Overview
- Input Format
- Output Format
- Property Type Mappings
- Handlebars-to-JSX Pipeline
- Handlebars-to-JSX Mappings
- Handlebars-to-PHP Mappings
- Helper Expressions
- Field Editing (Inline vs Sidebar)
- Dynamic Arrays
- Styles
- Shared Components
- Pipeline Steps
The compiler converts Handoff design-system components into WordPress Gutenberg blocks. Each Handoff component consists of:
- A Handlebars template (
component.code) defining the HTML structure - A properties schema (
component.properties) defining editable fields and their types - Optional CSS/SASS for styling
The compiler produces a self-contained Gutenberg block with editor UI, server-side rendering, and styles. The editor UI provides both inline editing (on the block canvas) and sidebar controls (in the InspectorControls panel), depending on how fields are used in the template.
interface HandoffComponent {
id: string; // e.g. "hero_article"
title: string; // e.g. "Hero Article"
description: string;
properties: Record<string, HandoffProperty>;
code: string; // Handlebars template
css?: string;
sass?: string;
figma?: string; // Figma URL
preview?: string; // Preview image URL
}interface HandoffProperty {
id: string;
name: string;
type: 'text' | 'richtext' | 'image' | 'link' | 'button' | 'number' |
'boolean' | 'select' | 'array' | 'object' | 'pagination';
description?: string;
default?: any;
rules?: { required?: boolean };
items?: { type: string; properties?: Record<string, HandoffProperty> };
properties?: Record<string, HandoffProperty>;
options?: Array<{ label: string; value: string }>;
}The compiler supports the following Handlebars constructs:
| Construct | Description |
|---|---|
{{properties.fieldName}} |
Output a property value (escaped) |
{{{properties.fieldName}}} |
Output a property value (unescaped / raw HTML) |
{{#each properties.items as |item|}}...{{/each}} |
Loop over an array property |
{{#each this.subArray as |sub|}}...{{/each}} |
Nested loop inside a parent loop |
{{#if properties.fieldName}}...{{/if}} |
Conditional rendering |
{{#if properties.fieldName}}...{{else}}...{{/if}} |
Conditional with else |
{{#if (eq properties.x "value")}}...{{/if}} |
Conditional with helper expression |
{{else if (condition)}} |
Else-if chain |
{{#unless @last}}...{{/unless}} |
Unless last item in loop |
{{#unless @first}}...{{/unless}} |
Unless first item in loop |
{{@index}} |
Current loop index |
{{this.fieldName}} |
Access field on current loop item |
{{@root.properties.fieldName}} |
Access a root-level property from inside any nested scope (e.g. inside {{#each}}) |
{{#if @root.properties.fieldName}}...{{/if}} |
Root-level conditional from inside a loop or nested scope |
{{#if (eq @root.properties.fieldName "value")}}...{{/if}} |
Root-level helper expression from inside a loop or nested scope |
{{#field "path"}}...{{/field}} |
Mark content as inline-editable in the editor |
For each component, the compiler generates 6 files inside a block directory:
| File | Purpose |
|---|---|
block.json |
Block metadata, attributes, and registration config |
index.js |
Editor script — edit() function with InspectorControls and canvas preview |
render.php |
Server-side rendering template |
editor.scss |
Editor-only styles |
style.scss |
Frontend + editor styles |
README.md |
Block documentation |
Registers the block with WordPress. Contains:
name:handoff/{component-id}(kebab-case)attributes: Gutenberg attribute definitions mapped from Handoff propertieseditorScript,editorStyle,style,render: file references__handoff: metadata with Handoff and Figma URLs, plus optional removed-from-compile flags (see below)supports.inserter: set tofalsewhen a block is deprecated (removed from compile output)
Two separate mechanisms apply:
| Mechanism | Trigger | Storage | Editor behavior |
|---|---|---|---|
| Schema deprecation | Handoff property schema changes between compiles | deprecated array in index.js + schema-changelog.json |
Migrates old attribute shapes when a post is opened |
| Removed from compile output | Local block exists but was not in the latest compile --all run (removed from Handoff, import: false, or superseded by a merged group) |
__handoff.removedFromHandoff in block.json |
Hidden from inserter; warning Notice in block editor via build/editor/block-deprecation.js |
Removed-from-compile fields on __handoff:
removedFromHandoff:trueremovedFromHandoffAt: ISO timestamp when markedremovedFromHandoffReason:not-in-compile-output
After npm run compile:all, the compiler reconciles every directory under blocks/ against the slugs compiled in that run. Orphan directories are patched in place (not deleted) so existing post content keeps rendering via render.php.
Registers the block via registerBlockType(). The edit() function renders:
<InspectorControls>with<PanelBody>panels for sidebar editing<BlockControls>with<MediaReplaceFlow>for image toolbar buttons- Editor preview div with transpiled JSX from the Handlebars template
PHP template that extracts block attributes and renders the component server-side. Uses get_block_wrapper_attributes() for the root element.
Each Handoff property type maps to a Gutenberg block attribute, a sidebar control, an inline editing component, and a PHP render expression.
| Handoff Type | Block Attribute | Sidebar Control | Inline Component | PHP Render |
|---|---|---|---|---|
text |
{ type: 'string' } |
TextControl |
RichText (via {{#field}}) |
esc_html() |
richtext |
(none — uses InnerBlocks) | (none) | InnerBlocks (via {{#field}}) |
$content |
number |
{ type: 'number' } |
RangeControl |
(none) | intval() |
boolean |
{ type: 'boolean' } |
ToggleControl |
(none) | direct |
image |
{ type: 'object' } |
MediaUpload |
Image (10up, via {{#field}}) |
esc_url() for src |
link |
{ type: 'object' } |
LinkControl + TextControl |
HandoffLinkField (via {{#field}}) |
esc_url() for url |
button |
{ type: 'object' } |
LinkControl + TextControl + ToggleControl |
HandoffLinkField (via {{#field}}) |
esc_url() for href |
select |
{ type: 'string' } |
SelectControl |
(none) | esc_html() |
array |
{ type: 'array' } |
Repeater (10up) |
(none) | foreach |
object |
{ type: 'object' } |
Nested field controls | (none) | nested access |
pagination |
(none — server-side only) | (none) | (none) | generated from WP_Query |
| Type | Default |
|---|---|
text, select |
'' |
number |
0 |
boolean |
false |
image |
{ src: '', alt: '' } |
link |
{ label: '', url: '', opensInNewTab: false } |
button |
{ label: '', href: '#', target: '', rel: '', disabled: false } |
array |
[] (with first item from items.properties defaults) |
object |
Nested defaults from sub-properties |
The Handlebars template is transformed into React JSX through a multi-stage pipeline:
Template (Handlebars string)
│
▼
preprocessFields()
├─ Finds {{#field "path"}}...{{/field}} blocks
├─ Looks up field type via field-lookup.ts
├─ Creates <editable-field-marker> for text/richtext/image/link/button
└─ Tracks which fields have inline editing
│
▼
cleanTemplate()
├─ Strips <html>/<body> wrappers
├─ Removes {{{style}}}, {{{script}}}
├─ Removes comments
└─ Calls preprocessAttributeConditionals()
│
▼
preprocessBlocks()
├─ {{#each}} → <loop-marker> / <nested-loop-marker>
├─ {{#if}} → <if-marker> / <if-else-marker> / <if-elseif-marker>
└─ {{#unless @first/@last}} → <unless-first/last-marker>
│
▼
parseHTML() (node-html-parser)
│
▼
nodeToJsx()
├─ HTML elements → JSX with converted attributes
├─ Text content → processTextContent() for Handlebars expressions
├─ <a> tags → href stripped (editor-only)
└─ Self-closing tags handled
│
▼
postprocessJsx()
├─ <loop-marker> → {arr && arr.map((item, index) => (...))}
├─ <if-marker> → {condition && (...)}
├─ <if-else-marker> → {condition ? (...) : (...)}
├─ <editable-field-marker> → RichText / Image / InnerBlocks / HandoffLinkField
└─ class= → className=
│
▼
postprocessTemplateLiterals()
└─ Decode base64 template literal markers
│
▼
JSX output string
| Handlebars | JSX |
|---|---|
{{properties.title}} |
{title} |
{{properties.hero.subtitle}} |
{hero?.subtitle} |
{{this.label}} |
{item.label} (in loop) |
{{alias.field}} |
{alias.field} (named loop variable) |
{{{properties.content}}} |
<span dangerouslySetInnerHTML={{ __html: content }} /> |
{{@index}} |
{index} |
{{@first}} |
{index === 0} |
| Handlebars | JSX |
|---|---|
{{#each properties.items as |card|}}...{{/each}} |
{items && items.map((card, index) => (<Fragment key={index}>...</Fragment>))} |
{{#each this.tags as |tag|}}...{{/each}} |
{item.tags && item.tags.map((tag, tagIndex) => (<Fragment key={tagIndex}>...</Fragment>))} |
| Handlebars | JSX |
|---|---|
{{#if properties.showTitle}}...{{/if}} |
{showTitle && (<Fragment>...</Fragment>)} |
{{#if properties.x}}A{{else}}B{{/if}} |
{x ? (<Fragment>A</Fragment>) : (<Fragment>B</Fragment>)} |
{{#unless @last}}...{{/unless}} |
{index < items?.length - 1 && (<Fragment>...</Fragment>)} |
{{#unless @first}}...{{/unless}} |
{index !== 0 && (<Fragment>...</Fragment>)} |
| Handlebars in Attribute | JSX |
|---|---|
href="{{properties.url}}" |
href={url} |
src="{{properties.image.src}}" |
src={image?.src} |
class="card {{properties.type}}" |
className={`card ${type}`} |
style="background: {{properties.bg}}" |
style={{ background: bg }} |
{{#if cond}}class="active"{{/if}} |
Conditional attribute expression |
properties.field_name→fieldName(snake_case to camelCase)- Reserved JS words are prefixed:
class→blockClass,super→blockSuper - Component IDs:
hero_article→ block namehandoff/hero-article
| Handlebars | PHP |
|---|---|
{{properties.title}} |
<?php echo esc_html($title ?? ''); ?> |
{{properties.image.src}} |
<?php echo esc_url($image['src'] ?? ''); ?> |
{{{properties.content}}} |
<?php echo $content; ?> (InnerBlocks content) |
{{this.label}} |
<?php echo esc_html($item['label'] ?? ''); ?> |
{{@index}} |
<?php echo $index; ?> |
| Handlebars | PHP |
|---|---|
{{#each properties.items as |card|}} |
<?php foreach ($items as $index => $item) : ?> |
{{/each}} |
<?php endforeach; ?> |
{{#each this.tags}} |
<?php foreach ($item['tags'] as $subIndex => $subItem) : ?> |
| Handlebars | PHP |
|---|---|
{{#if properties.x}} |
<?php if (!empty($x)) : ?> |
{{else}} |
<?php else : ?> |
{{/if}} |
<?php endif; ?> |
{{#unless @last}} |
<?php if ($index < $_loop_count - 1) : ?> |
{{#unless @first}} |
<?php if ($index > 0) : ?> |
- Text values:
esc_html() - URLs (
href,src):esc_url() - Attribute values:
esc_attr() - Raw HTML / richtext:
$content(InnerBlocks) orwp_kses_post()
$attributes = $attributes ?? [];
$title = $attributes['title'] ?? '';
$image = $attributes['image'] ?? ['src' => '', 'alt' => ''];
$items = $attributes['items'] ?? [];Handlebars helper expressions in conditionals are transpiled to both JSX and PHP.
| Handlebars Helper | JSX | PHP |
|---|---|---|
(eq a "b") |
a === "b" |
($a ?? '') === 'b' |
(ne a "b") |
a !== "b" |
($a ?? '') !== 'b' |
(gt a 5) |
a > 5 |
($a ?? 0) > 5 |
(lt a 5) |
a < 5 |
($a ?? 0) < 5 |
(gte a 5) |
a >= 5 |
($a ?? 0) >= 5 |
(lte a 5) |
a <= 5 |
($a ?? 0) <= 5 |
(and a b) |
(a) && (b) |
(!empty($a)) && (!empty($b)) |
(or a b) |
(a) || (b) |
(!empty($a)) || (!empty($b)) |
(not a) |
!(a) |
empty($a) |
When a template wraps content in {{#field "path"}}...{{/field}}, the compiler enables inline editing for that field on the editor canvas. The decision of whether a field appears in the sidebar or inline is based on this marker:
- Has
{{#field}}→ inline editing on the canvas; removed from the sidebar - No
{{#field}}→ sidebar-only control (standard WordPress editor controls)
| Type | Inline Component | Behavior |
|---|---|---|
text |
<RichText tagName="span"> |
Plain text editing with no formatting |
richtext |
<InnerBlocks> |
Full block editor content (paragraphs, headings, etc.) |
image |
<Image> (10up) |
Click to select/replace media |
link |
<HandoffLinkField> |
RichText for label + Popover/LinkControl for URL |
button |
<HandoffLinkField> |
Same as link, mapping to href/target properties |
For link and button fields, the compiler generates a HandoffLinkField component that mirrors the WordPress core Button block pattern:
- A
<RichText>for the label text (inline contenteditable) - A
<Popover>anchored to the field, containing a<LinkControl>for URL editing - The popover opens on click and only renders when
isSelectedis true - The original Handoff markup (e.g.,
<a>tag with classes) is preserved
These types never have inline equivalents:
number→RangeControlboolean→ToggleControlselect→SelectControlarray→Repeater(10up)object→ Nested field controls
<a>tags have theirhrefattribute stripped in the editor to prevent navigation and allow click-to-edit- Only one
<InnerBlocks>is allowed per block; subsequentrichtextfields become no-ops in the editor BlockControlswithMediaReplaceFlowis always generated for image fields (toolbar-level, independent of sidebar)
Array fields can be configured for dynamic post population via the import key in handoff-wp.config.json. Dynamic array configuration is nested under the component type and component ID:
{
"import": {
"block": {
"posts-latest": {
"posts": {
"postTypes": ["post", "page"],
"selectionMode": "query",
"maxItems": 12,
"renderMode": "mapped",
"fieldMapping": { ... }
}
}
}
}
}The import key controls which component types are imported and where dynamic array fields are configured:
- Type-level:
"element": falseskips all elements;"block": { ... }imports all blocks with per-component overrides - Component-level:
"posts-latest": { ... }provides field-level dynamic array configs;falseskips a specific component - Field-level:
"posts": { ...DynamicArrayConfig }enables dynamic post population on that array field
Types and components not listed default to imported with no dynamic arrays.
The editor provides a three-button toggle for dynamic array fields:
| Mode | Source Value | Description |
|---|---|---|
| Query | 'query' |
Posts fetched via WP_Query with taxonomy filters, ordering, and pagination |
| Select | 'select' |
Specific posts hand-picked by the user via search |
| Manual | 'manual' |
Static content entered through the standard Repeater UI |
For each dynamic array field items, the compiler adds:
| Attribute | Type | Purpose |
|---|---|---|
itemsSource |
string |
Mode: 'query', 'select', or 'manual' |
itemsPostType |
string |
WordPress post type to query |
itemsSelectedPosts |
array |
Hand-picked post IDs (select mode) |
itemsQueryArgs |
object |
WP_Query arguments |
itemsFieldMapping |
object |
Post field → template field mapping |
itemsItemOverrides |
object |
Per-field overrides applied to all items |
itemsRenderMode |
string |
'mapped' or 'template' |
itemsPaginationEnabled |
boolean |
Whether to show pagination (if configured) |
Maps WordPress post fields to Handoff template fields:
{
"title": "post_title",
"image": "featured_image",
"url": "permalink",
"cta_label": { "type": "static", "value": "Read More" },
"cardType": { "type": "manual" },
"category": { "type": "taxonomy", "taxonomy": "category", "format": "first" }
}| Type | Syntax | Description |
|---|---|---|
| Simple string | "post_title" |
Maps to a core post field or date part |
| Static | { "type": "static", "value": "..." } |
Hardcoded value, fixed at compile time |
| Manual | { "type": "manual" } |
User-editable via sidebar control (see below) |
| Meta | { "type": "meta", "key": "..." } |
Reads from post meta |
| Taxonomy | { "type": "taxonomy", "taxonomy": "...", "format": "first"|"all" } |
Taxonomy term(s) |
| Custom | { "type": "custom", "callback": "..." } |
PHP callback function |
When a field mapping uses { "type": "manual" }, the field is not resolved from post data. Instead:
- Compile time: The compiler reads the field's property definition from the Handoff component schema (the array item's
properties) and generates anadvancedFieldsentry for theDynamicPostSelectorcomponent. - Control type: Derived automatically from the property's type —
select→ dropdown,boolean→ toggle,number→ number input, anything else → text input. - Editor UI: The control appears in the Advanced Options section of the
DynamicPostSelectorsidebar panel, below Order & Limit. - Runtime: The value is stored in the block's
{arrayName}ItemOverridesattribute. When mapping posts to template items, manual fields returnnullfrom the resolver; the item override value is then applied to every item in the array.
This is the recommended approach for fields like card type or button labels that should be uniform across all items but editable by the user (unlike static, which is fixed at compile time).
In the editor, dynamic arrays use useSelect with @wordpress/core-data to fetch and display live post data. A loading spinner shows while posts are resolving. In manual mode, the standard repeater fields are used directly with no post fetching.
In render.php, dynamic arrays generate a WP_Query (for query mode) or get_posts() (for select mode) and map results through the field mapping configuration. In manual mode, the array attribute is used directly.
The legacy dynamicArrays config (flat "componentId.fieldName" keys with an enabled flag) is auto-migrated to the import structure at load time if no import key is present. A deprecation warning is logged.
In addition to dynamic post arrays, the compiler supports three specialized arrayType configs for array fields that are populated automatically from page context rather than from posts.
Specialized arrays are configured alongside dynamic post arrays in the import config, keyed by the array field name:
{
"import": {
"block": {
"hero-article": {
"breadcrumb": {
"arrayType": "breadcrumbs"
},
"tags": {
"arrayType": "taxonomy",
"taxonomies": ["post_tag", "category"],
"maxItems": 3
}
},
"grid-three-column": {
"articles": { "...DynamicArrayConfig..." },
"pagination": {
"arrayType": "pagination",
"connectedField": "articles"
}
}
}
}
}Auto-generates a breadcrumb trail from the current page/post context.
| Config Key | Type | Description |
|---|---|---|
arrayType |
"breadcrumbs" |
Identifies this as a breadcrumbs array |
Server behavior: Calls handoff_get_breadcrumb_items() which inspects the current WordPress query to build a contextual breadcrumb trail. Returns an empty array on the front page or when no meaningful trail exists (i.e., no Home-only trails). Covers: hierarchical pages, single posts with blog + category parents, custom post types with archive links, search results, date/tag/author/category archives, and taxonomy archives.
Standard output shape: { label: string, url: string }
Auto-reshape: The compiler inspects the Handoff component's array item property schema and generates PHP code to reshape each breadcrumb item into the template's expected structure. For example, if the template expects { link: { label, url } }, the compiler generates:
$breadcrumb = array_map(function($crumb) {
return ['link' => ['label' => $crumb['label'], 'url' => $crumb['url']]];
}, handoff_get_breadcrumb_items());Block attributes:
| Attribute | Type | Default | Purpose |
|---|---|---|---|
{field} |
array |
[] |
The breadcrumb items array |
{field}Enabled |
boolean |
true |
Toggle breadcrumb visibility |
Editor behavior: Fetches live breadcrumb data via the handoff/v1/breadcrumbs REST endpoint for the current post. Items are reshaped client-side to match the template's expected structure and displayed in the editor preview. The sidebar shows a BreadcrumbsSelector component with an enabled/disabled toggle.
Populates an array with terms from a WordPress taxonomy on the current post.
| Config Key | Type | Description |
|---|---|---|
arrayType |
"taxonomy" |
Identifies this as a taxonomy array |
taxonomies |
string[] |
Taxonomy slugs available in the editor dropdown (e.g., ["post_tag", "category"]) |
maxItems |
number? |
Maximum terms to return (default: all) |
Server behavior: In auto mode, calls wp_get_post_terms() for the selected taxonomy on the current post, limited by maxItems. In manual mode, uses the items saved directly in the block attribute.
Standard output shape: { label: string, url: string, slug: string }
Auto-reshape: Same as breadcrumbs — the compiler inspects the component's array item properties and generates PHP mapping code. For example, if items expect { link: { label, url } }, each term is wrapped accordingly.
Block attributes:
| Attribute | Type | Default | Purpose |
|---|---|---|---|
{field} |
array |
[] |
The taxonomy items array |
{field}Enabled |
boolean |
false |
Toggle taxonomy visibility |
{field}Taxonomy |
string |
First taxonomy in config | Selected taxonomy slug |
{field}Source |
string |
"auto" |
"auto" or "manual" |
Editor behavior: In auto mode, uses useSelect with @wordpress/core-data to fetch terms for the current post from the selected taxonomy and displays them in the editor preview. In manual mode, uses the standard Repeater UI. The sidebar shows a TaxonomySelector component with an enabled toggle, auto/manual tabs, and a taxonomy dropdown.
Derives pagination links from a sibling dynamic post array's WP_Query result.
| Config Key | Type | Description |
|---|---|---|
arrayType |
"pagination" |
Identifies this as a pagination array |
connectedField |
string |
The sibling array field name whose WP_Query drives pagination |
Server behavior: After the connected field's WP_Query executes, calls handoff_build_pagination() with the current page number and total pages. Returns numbered page links with ellipsis gaps, each containing { label, url, active }.
Standard output shape: { label: string, url: string, active: boolean }
Auto-reshape: Same as breadcrumbs and taxonomy — the compiler maps the standard shape into the template's expected item structure.
Block attributes:
| Attribute | Type | Default | Purpose |
|---|---|---|---|
{field} |
array |
[] |
The pagination items array |
{field}Enabled |
boolean |
true |
Toggle pagination visibility |
Editor behavior: Pagination depends on WP_Query results that cannot be replicated client-side. The editor preview shows a placeholder message ("Pagination renders on the frontend"). The sidebar shows a PaginationSelector component with an enabled/disabled toggle.
For all three specialized array types, the compiler performs item shape mapping at compile time:
- Read standard shape: Each helper returns a canonical flat shape (
{ label, url }for breadcrumbs/taxonomy,{ label, url, active }for pagination). - Read template shape: The compiler reads the array field's
items.propertiesfrom the Handoff component schema to determine the expected item structure. - Generate mapping code: If the shapes differ, the compiler generates
array_map()PHP code (and equivalent JS for editor preview) that restructures each item. Nested properties (e.g.,link.label) are wrapped into sub-arrays/objects automatically. - Pass through when shapes match: If the standard shape already matches the template shape, no mapping code is generated.
Generated styles for the editor preview:
.{component-id}-editor-preview {
min-height: 120px;
position: relative;
// ...
}
.handoff-editable-field {
cursor: text;
outline: 1px dashed transparent;
&:hover { outline-color: var(--wp-admin-theme-color); }
&:focus { outline-style: solid; }
}Includes styles for the Repeater component (10up Block Components).
Scans the template for CSS class names and generates minimal structural fallbacks. Most styling comes from the theme's shared design system styles.
The compiler generates shared utility components in shared/components/:
A unified post selection UI that combines query building and manual post selection. Supports:
- Post type selection
- Taxonomy filtering with
FormTokenField - Ordering and pagination controls
- Manual post search via
ComboboxControl - Per-item field overrides
Sidebar toggle control for breadcrumbs array fields. Reads/writes {attrName}Enabled. Breadcrumb data is fetched live via REST in the editor preview.
Sidebar control for taxonomy array fields with auto/manual tabs. In auto mode, shows a taxonomy dropdown ({attrName}Taxonomy). In manual mode, renders a Repeater with custom item fields via renderManualItems prop. Reads/writes {attrName}Enabled, {attrName}Source, {attrName}Taxonomy.
Sidebar toggle control for pagination array fields. Reads/writes {attrName}Enabled. Pagination is server-rendered only.
Manual post selection with search, drag-to-reorder, and multi-select.
Query builder UI for constructing WP_Query arguments.
All layout previously using __experimental WordPress components has been replaced with stable alternatives:
VStack→<Flex direction="column" gap={N}>HStack→<Flex align="..." gap={N}>Text→<span style={{...}}>with appropriate stylesDivider→<hr>with border styling
The full compilation pipeline, in order:
- Load configuration — Read
handoff-wp.config.jsonfor API URL, output paths, andimportconfig (with backward-compat migration from legacydynamicArrays) - Fetch component list — Filter components by
importconfig (skip types set tofalse, skip individual components set tofalse) - Fetch component — GET from Handoff API (
/api/component/{name}.json) - Validate template — Check that template variables match property definitions
- Extract dynamic array configs — Look up per-component field configs from
import[type][componentId] - Generate block.json — Map properties to Gutenberg attributes with defaults
- Generate index.js — Transpile Handlebars to JSX, build sidebar controls, detect inline fields
- Generate render.php — Transpile Handlebars to PHP, generate attribute extraction
- Generate editor.scss — Editor preview styles with editable field highlights
- Generate style.scss — Frontend structural styles
- Generate README.md — Block documentation with property table
- Generate shared components — Shared index files (once per compilation run, if any component has dynamic arrays)
- Generate categories PHP — Block category registration (once per compilation run)
- Format output — Run Prettier on generated JS/SCSS files
- Write files — Output to the configured directory structure
- Reconcile local blocks — Mark any
blocks/{slug}/not in this run’s compile output as deprecated (__handoff.removedFromHandoff,supports.inserter: false, title prefixed with(Deprecated))
output/
├── blocks/
│ └── {component-id}/
│ ├── block.json
│ ├── index.js
│ ├── render.php
│ ├── editor.scss
│ ├── style.scss
│ └── README.md
├── shared/
│ ├── index.js
│ ├── components/
│ │ ├── index.js
│ │ ├── DynamicPostSelector.js
│ │ └── DynamicPostSelector.editor.scss
│ └── utils/
│ ├── index.js
│ └── mapPostEntityToItem.js
├── handoff-blocks.php (plugin main file)
└── handoff-block-categories.php (category registration)