Skip to content

Latest commit

 

History

History
744 lines (560 loc) · 31.2 KB

File metadata and controls

744 lines (560 loc) · 31.2 KB

Handoff WordPress Compiler — Transpilation Specification

This document describes how the Handoff WordPress compiler reads Handoff component data (Handlebars templates and property definitions) and transforms them into WordPress Gutenberg blocks.


Table of Contents

  1. Overview
  2. Input Format
  3. Output Format
  4. Property Type Mappings
  5. Handlebars-to-JSX Pipeline
  6. Handlebars-to-JSX Mappings
  7. Handlebars-to-PHP Mappings
  8. Helper Expressions
  9. Field Editing (Inline vs Sidebar)
  10. Dynamic Arrays
  11. Styles
  12. Shared Components
  13. Pipeline Steps

1. Overview

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.


2. Input Format

HandoffComponent

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
}

HandoffProperty

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 }>;
}

Handlebars Template Syntax

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

3. Output Format

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

block.json

Registers the block with WordPress. Contains:

  • name: handoff/{component-id} (kebab-case)
  • attributes: Gutenberg attribute definitions mapped from Handoff properties
  • editorScript, editorStyle, style, render: file references
  • __handoff: metadata with Handoff and Figma URLs, plus optional removed-from-compile flags (see below)
  • supports.inserter: set to false when a block is deprecated (removed from compile output)

Deprecation types

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: true
  • removedFromHandoffAt: ISO timestamp when marked
  • removedFromHandoffReason: 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.

index.js

Registers the block via registerBlockType(). The edit() function renders:

  1. <InspectorControls> with <PanelBody> panels for sidebar editing
  2. <BlockControls> with <MediaReplaceFlow> for image toolbar buttons
  3. Editor preview div with transpiled JSX from the Handlebars template

render.php

PHP template that extracts block attributes and renders the component server-side. Uses get_block_wrapper_attributes() for the root element.


4. Property Type Mappings

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

Default Values

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

5. Handlebars-to-JSX Pipeline

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

6. Handlebars-to-JSX Mappings

Property References

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}

Loops

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>))}

Conditionals

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>)}

Attributes

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

Naming Conventions

  • properties.field_namefieldName (snake_case to camelCase)
  • Reserved JS words are prefixed: classblockClass, superblockSuper
  • Component IDs: hero_article → block name handoff/hero-article

7. Handlebars-to-PHP Mappings

Property References

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; ?>

Loops

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) : ?>

Conditionals

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) : ?>

Escaping in PHP

  • Text values: esc_html()
  • URLs (href, src): esc_url()
  • Attribute values: esc_attr()
  • Raw HTML / richtext: $content (InnerBlocks) or wp_kses_post()

PHP Attribute Extraction

$attributes = $attributes ?? [];
$title = $attributes['title'] ?? '';
$image = $attributes['image'] ?? ['src' => '', 'alt' => ''];
$items = $attributes['items'] ?? [];

8. Helper Expressions

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)

9. Field Editing (Inline vs Sidebar)

The {{#field}} Marker

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)

Inline-Editable Types

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

HandoffLinkField Component

For link and button fields, the compiler generates a HandoffLinkField component that mirrors the WordPress core Button block pattern:

  1. A <RichText> for the label text (inline contenteditable)
  2. A <Popover> anchored to the field, containing a <LinkControl> for URL editing
  3. The popover opens on click and only renders when isSelected is true
  4. The original Handoff markup (e.g., <a> tag with classes) is preserved

Sidebar-Only Types

These types never have inline equivalents:

  • numberRangeControl
  • booleanToggleControl
  • selectSelectControl
  • arrayRepeater (10up)
  • object → Nested field controls

Special Cases

  • <a> tags have their href attribute stripped in the editor to prevent navigation and allow click-to-edit
  • Only one <InnerBlocks> is allowed per block; subsequent richtext fields become no-ops in the editor
  • BlockControls with MediaReplaceFlow is always generated for image fields (toolbar-level, independent of sidebar)

10. Dynamic Arrays

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": { ... }
        }
      }
    }
  }
}

Import Config Structure

The import key controls which component types are imported and where dynamic array fields are configured:

  • Type-level: "element": false skips all elements; "block": { ... } imports all blocks with per-component overrides
  • Component-level: "posts-latest": { ... } provides field-level dynamic array configs; false skips 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.

Modes

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

Additional Attributes

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)

Field Mapping

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" }
}

Mapping Source Types

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

Manual Field Mapping

When a field mapping uses { "type": "manual" }, the field is not resolved from post data. Instead:

  1. Compile time: The compiler reads the field's property definition from the Handoff component schema (the array item's properties) and generates an advancedFields entry for the DynamicPostSelector component.
  2. Control type: Derived automatically from the property's type — select → dropdown, boolean → toggle, number → number input, anything else → text input.
  3. Editor UI: The control appears in the Advanced Options section of the DynamicPostSelector sidebar panel, below Order & Limit.
  4. Runtime: The value is stored in the block's {arrayName}ItemOverrides attribute. When mapping posts to template items, manual fields return null from 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).

Editor Preview

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.

Server-Side Rendering

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.

Backward Compatibility

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.

Specialized Array Types

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.

Config Structure

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"
        }
      }
    }
  }
}

Breadcrumbs (arrayType: "breadcrumbs")

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.

Taxonomy (arrayType: "taxonomy")

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.

Pagination (arrayType: "pagination")

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.

Auto-Reshape Pipeline

For all three specialized array types, the compiler performs item shape mapping at compile time:

  1. Read standard shape: Each helper returns a canonical flat shape ({ label, url } for breadcrumbs/taxonomy, { label, url, active } for pagination).
  2. Read template shape: The compiler reads the array field's items.properties from the Handoff component schema to determine the expected item structure.
  3. 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.
  4. Pass through when shapes match: If the standard shape already matches the template shape, no mapping code is generated.

11. Styles

Editor Styles (editor.scss)

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).

Frontend Styles (style.scss)

Scans the template for CSS class names and generates minimal structural fallbacks. Most styling comes from the theme's shared design system styles.


12. Shared Components

The compiler generates shared utility components in shared/components/:

DynamicPostSelector

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

BreadcrumbsSelector

Sidebar toggle control for breadcrumbs array fields. Reads/writes {attrName}Enabled. Breadcrumb data is fetched live via REST in the editor preview.

TaxonomySelector

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.

PaginationSelector

Sidebar toggle control for pagination array fields. Reads/writes {attrName}Enabled. Pagination is server-rendered only.

PostSelector (legacy)

Manual post selection with search, drag-to-reorder, and multi-select.

PostQueryBuilder (legacy)

Query builder UI for constructing WP_Query arguments.

Layout Components

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 styles
  • Divider<hr> with border styling

13. Pipeline Steps

The full compilation pipeline, in order:

  1. Load configuration — Read handoff-wp.config.json for API URL, output paths, and import config (with backward-compat migration from legacy dynamicArrays)
  2. Fetch component list — Filter components by import config (skip types set to false, skip individual components set to false)
  3. Fetch component — GET from Handoff API (/api/component/{name}.json)
  4. Validate template — Check that template variables match property definitions
  5. Extract dynamic array configs — Look up per-component field configs from import[type][componentId]
  6. Generate block.json — Map properties to Gutenberg attributes with defaults
  7. Generate index.js — Transpile Handlebars to JSX, build sidebar controls, detect inline fields
  8. Generate render.php — Transpile Handlebars to PHP, generate attribute extraction
  9. Generate editor.scss — Editor preview styles with editable field highlights
  10. Generate style.scss — Frontend structural styles
  11. Generate README.md — Block documentation with property table
  12. Generate shared components — Shared index files (once per compilation run, if any component has dynamic arrays)
  13. Generate categories PHP — Block category registration (once per compilation run)
  14. Format output — Run Prettier on generated JS/SCSS files
  15. Write files — Output to the configured directory structure
  16. 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))

Directory Structure

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)