Tutorial: Creating a New Node Type
This tutorial walks you through adding a brand-new node type to the Template Builder from scratch. By the end you will have a fully working node that:
- appears in the "Add node" menu
- has its own settings dialog with fields
- renders correctly on the frontend
- previews live inside the canvas iframe
We will build a Badge node — a small pill-shaped label that displays static text (e.g. "New", "Hot"). Follow every step in order. Don't skip any file.
Before You Start
Read this so you understand what you're touching.
The 4-file pipeline
Every node type lives across four files, one for each concern:
| File | What it does |
|---|---|
admin/assets/js/src/template-builder/config/nodeTypes.js |
Declares the node: its label, icon, default field values, and the settings dialog fields |
admin/assets/js/src/template-builder/utils/renderCanvas.js |
Generates the HTML element shown in the canvas iframe preview |
includes/class-sanilwb-template-compiler.php |
Produces the actual PHP/HTML string that gets saved to disk and served to visitors |
admin/assets/js/src/template-builder/utils/buildNodeStyles.js |
Generates CSS in the browser so the live canvas preview matches what the compiler will produce |
No other registration is needed. getAvailableNodeTypes() in nodeTypes.js auto-discovers every entry in NODE_TYPES.
Why both renderCanvas.js and the compiler? The compiler runs once when the template is saved and writes a .php file served to visitors. renderCanvas.js runs in the browser in real time while the developer is editing — it generates the same HTML so the canvas preview is always live without needing a save. Both must produce identical output, or what the editor shows won't match what the site renders.
Builder types
A node can belong to 'template', 'titlebar', or both. Set this with the builderTypes array.
Most content nodes (heading, image, excerpt, etc.) belong to 'template' only because titlebar is for
carousel UI chrome, not post data.
Step 1 — Declare the node in nodeTypes.js
Open admin/assets/js/src/template-builder/config/nodeTypes.js.
Search for const NODE_TYPES to jump straight to it. Add your new entry inside the object,
after the last existing type. The object looks like:
const NODE_TYPES = {
div: { ... },
heading: { ... },
// ... other types ...
carousel_nav: { ... }, // <-- last entry
};
Add your badge entry right before the closing };:
badge: {
// The key here MUST match the 'type' value below — used as the lookup key everywhere.
type: 'badge',
// Human-readable name shown in the Add-node menu.
label: 'Badge',
// Dashicons icon class — omit the leading 'dashicons-' prefix.
// Full list: https://developer.wordpress.org/resource/dashicons/
icon: 'tag',
// false = this is a leaf node (no children). true = container (like div).
// Badges hold no children — they just display text.
canHaveChildren: false,
// Which builders show this node in the Add menu.
// Use ['template', 'titlebar'] if it should appear in both.
builderTypes: [ 'template' ],
// Default field values applied when this node is first created.
// Every field that should have a non-empty starting value needs an entry here.
defaultValues: {
'badge-text': 'New', // the label text
'badge-bg-color': '#e63946', // red background
'badge-color': '#ffffff', // white text
sanilwb_show_desktop: '1',
sanilwb_show_tablet: '1',
sanilwb_show_mobile: '1',
},
// The settings dialog tab definitions.
// getNodeDialogTabs() will automatically append the shared Spacing/Border fields
// and the Advanced tab — you do NOT need to add those yourself.
dialogTabs: [
{
// Content tab — what the badge says.
id: 'content',
label: 'Content',
fields: [
// A section header (visual divider — no field value).
{ type: 'section', label: 'Badge Label' },
{
name: 'badge-text', // key stored in the node's data
type: 'text', // renders as a plain text input
label: 'Label Text', // shown next to the input
responsive: false, // same text on all devices
},
],
},
{
// Style tab — colors and typography.
id: 'style',
label: 'Style',
fields: [
// showDeviceToggle: true adds desktop/tablet/mobile toggle buttons
// to this section header so the user can switch breakpoints.
{ type: 'section', label: 'Colors', showDeviceToggle: true },
{
name: 'badge-bg-color',
type: 'color',
label: 'Background',
// responsive: true means the value can differ per device.
// The stored keys become: badge-bg-color, badge-bg-color__tablet, badge-bg-color__mobile
responsive: true,
},
{
name: 'badge-color',
type: 'color',
label: 'Text Color',
responsive: true,
},
{ type: 'section', label: 'Typography', showDeviceToggle: true },
// buildFontFields() returns the four shared font field configs
// (font-size, font-family, font-weight, font-style) for a given prefix.
// It's defined at the top of nodeTypes.js — use it for any text node.
...buildFontFields( 'badge' ),
// FLEX_ITEM_FIELDS spread gives the node flex-shrink, flex-grow,
// flex-basis, align-self controls so it can participate in parent flex layouts.
...FLEX_ITEM_FIELDS,
// If your node has a background color field, also spread GRADIENT_FIELDS here.
// It adds gradient-angle, gradient-color-1, gradient-color-2, and the
// gradient-stops draggable bar as a "Gradient" section below the background color.
// Then call self::build_linear_gradient_css($node) in the PHP style builder
// and buildLinearGradientCss(node) in the JS preview block to emit the CSS.
// ...GRADIENT_FIELDS,
],
},
],
},
What the field type values mean
type value |
Renders as |
|---|---|
'text' |
Plain text input |
'number' |
Number input with optional unit label |
'color' |
Colour picker |
'select' |
Dropdown (options array required) |
'toggle' |
On/Off switch |
'font-family' |
Font family picker (populated from Google Fonts) |
'font-weight' |
Font weight dropdown (filters to selected font's available weights) |
'font-style' |
Normal/Italic select |
'input-group' |
Two or more number inputs side-by-side (e.g. min-width / max-width) |
'gradient-stops' |
Draggable bar with two color stop handles — reads gradient-color-1 and gradient-color-2 from the buffer to color itself live |
'section' |
Visual section header — no stored value |
Useful information
showDeviceToggle vs responsive
These two properties are independent and control different things:
showDeviceToggle: trueon a section separator (type: 'section') — purely UI. Renders the Desktop / Tablet / Mobile toggle buttons next to the section heading. Has no effect on how values are stored.responsive: trueon a field — controls actual storage. That field's name is included inresponsiveFieldNamesat serialization time, so__tabletand__mobilekeys are written on save.
You can have showDeviceToggle: false on a section but responsive: true on its fields — the fields still store per-device values correctly, the user just has no section-level toggle to switch devices. This is not a bug, but it is confusing UI.
The canvas device toggle is the global source of truth
The section-level showDeviceToggle and the canvas preview device toggle both write to the same activeDevice in useDeviceStore. If the user switches to tablet on the canvas and then edits a field with responsive: true, the value is written to buffer.tablet — regardless of whether showDeviceToggle is visible in that section or not. The section toggle is just a convenience shortcut.
Step 2 — Add the HTML renderer in the compiler
Open includes/class-sanilwb-template-compiler.php.
Search for function compile_nodes to jump to the method. Inside it there is a switch ( $type ) block.
Each case in that switch produces the HTML string that gets written to the compiled template file.
Your case will be called once per badge node in the tree.
Add your case at the bottom of the switch, just before the closing } of the switch block.
Look for the carousel_nav case — add yours right after it:
case 'badge':
// Sanitize the user-entered text before writing it into the output file.
// Never skip sanitization — this text comes from user input in the dialog.
$badge_text = htmlspecialchars(
! empty( $node['badge-text'] ) ? $node['badge-text'] : 'New',
ENT_QUOTES,
'UTF-8'
);
// Emit a <span> with the scoped CSS class (pbnode-{id} sanilwb-badge)
// so the compiled CSS can target it with the correct specificity.
$out .= '<span' . ( $class ? ' class="' . $class . '"' : '' ) . '>'
. $badge_text
. '</span>';
break;
Why $class and not a hardcoded class name?
$class is already assembled at the top of the loop. It contains three parts:
- pbnode-{id} — the scoped ID used by the CSS (e.g. pbnode-7)
- sanilwb-badge — the semantic type class for theme overrides
- the user's optional custom class from the Advanced tab
Always use $class rather than writing your own class string here.
Step 3 — Add the CSS builder in the compiler
Still inside class-sanilwb-template-compiler.php, search for function build_node_css to jump to the method.
This method builds the inline style declaration block for a node's selector. It uses a chain of if
blocks — one per node type. Find the last if block in the method (the if ( $type === 'date' ) block
is a good landmark) and add yours after it:
// Badge: background colour, text colour, typography, and pill shape.
if ( $type === 'badge' ) {
// Start with the base pill shape so the badge always looks like a badge,
// even if the user hasn't set any colours yet.
$style .= 'display:inline-block;padding:2px 8px;border-radius:4px;';
// Background colour.
$bg_color = sanitize_hex_color( $node['badge-bg-color'] ?? '' );
if ( $bg_color ) {
$style .= 'background-color:' . $bg_color . ';';
}
// Text colour.
$text_color = sanitize_hex_color( $node['badge-color'] ?? '' );
if ( $text_color ) {
$style .= 'color:' . $text_color . ';';
}
// Font size — cast to int so we never emit a non-numeric value.
$font_size = (int) ( $node['badge-font-size'] ?? 0 );
if ( $font_size > 0 ) {
$style .= 'font-size:' . $font_size . 'px;';
}
// Font weight — store as string because values like '600' are valid.
$font_weight = (int) ( $node['badge-font-weight'] ?? 0 );
if ( $font_weight > 0 ) {
$style .= 'font-weight:' . $font_weight . ';';
}
// Font family — sanitize before writing into a CSS string.
$font_family = sanitize_text_field( $node['badge-font-family'] ?? '' );
if ( $font_family ) {
$style .= 'font-family:"' . $font_family . '",sans-serif;';
}
}
Sanitization rules to always follow:
| Field type | Sanitization function |
|---|---|
| Hex colour | sanitize_hex_color() — returns empty string if invalid |
| Integer (font size, px values) | (int) $value — safe, always numeric |
| Text / font name | sanitize_text_field() — strips tags and extra whitespace |
| URL | esc_url() — rejects non-URL characters |
Never write a raw $node['key'] directly into the CSS string output.
Step 4 — Add the canvas HTML in renderCanvas.js
Open admin/assets/js/src/template-builder/utils/renderCanvas.js.
Search for function renderNode to jump to the rendering function. It contains a series of
if ( type === '...' ) blocks, one per node type, each returning an HTML string. Find the last
if block — the carousel_nav block is currently last. Add your badge block right after it,
before the closing fallback return:
if ( type === 'badge' ) {
// Read the badge text, falling back to the default if the field is empty.
// safe() strips dangerous characters (" ' < >) before writing into HTML.
const badgeText = safe( node['badge-text'] || 'New' );
// isHiddenForDevice() checks sanilwb_show_desktop/tablet/mobile against
// the current preview device. Add the hidden class so the canvas
// correctly shows/hides the node when device visibility is toggled.
const hiddenCls = isHiddenForDevice( node ) ? ' sanilwb-tbve-hidden' : '';
// The data-node-id attribute is required on every node element.
// buildNodeStyles.js uses it as the CSS selector: [data-node-id="X"].
// The sanilwb_css_class value is the user's optional custom class from
// the Advanced tab.
const cls = `sanilwb-tbvisual-badge${ hiddenCls }${ cssClass ? ' ' + cssClass : '' }`;
return `<span class="${ cls }" data-node-id="${ id }">${ badgeText }</span>`;
}
What renderNode returns: an HTML string for the element. This string is assembled for
every node in the tree, concatenated, and injected into the canvas iframe via postMessage.
The data-node-id attribute on every element is what connects each node to its CSS rule
produced by buildNodeStyles.js — without it, the styles have no selector to target.
Step 5 — Add the live-preview CSS in buildNodeStyles.js
Open admin/assets/js/src/template-builder/utils/buildNodeStyles.js.
Search for function walkNodeTree to jump to it. It iterates the node tree and builds the CSS string
that is injected into the canvas iframe. It mirrors the logic in the PHP compiler.
Find the last if ( type === '...' ) block in walkNodeTree() — the carousel_nav block is currently
last. Add your badge block right after it, before the generic shared-fields section:
if ( type === 'badge' ) {
// Base pill shape — mirrors the PHP compiler's hardcoded declarations.
cssDeclarations += 'display:inline-block;padding:2px 8px;border-radius:4px;';
// getBreakpointValue() reads the field value with the device cascade applied:
// mobile → falls back to tablet → falls back to base
// tablet → falls back to base
// desktop → reads base value only
const backgroundColor = getBreakpointValue( node, 'badge-bg-color' );
// sanitizeValue() strips dangerous characters (" ' < >) before writing into CSS.
if ( backgroundColor ) {
cssDeclarations += `background-color:${ sanitizeValue( backgroundColor ) };`;
}
// buildTypographyCss() reads badge-color, badge-font-size, badge-font-weight,
// badge-font-family, badge-font-style in one call — mirrors the PHP compiler's typography block.
const typographyCss = buildTypographyCss( node, 'badge' );
if ( typographyCss ) {
cssDeclarations += typographyCss + ';';
}
// Add advanced-tab spacing (margin/padding) — every node type must call this.
const advancedTabCss = buildAdvancedTabCss( node );
if ( advancedTabCss ) {
cssDeclarations += advancedTabCss + ';';
}
}
Why mirror the PHP compiler logic in JS?
buildNodeStyles.js generates CSS that the canvas preview uses while editing. The PHP compiler
generates the same CSS that gets written to the compiled .css file for visitors. If the two
diverge, what the editor shows won't match what the site renders. Keep them in sync by adding
any new CSS property to both files at the same time.
Step 6 — Build the JavaScript
The JS files are compiled by webpack. Run the build from the plugin root:
# One-time production build
npm run build
# Or, if you're actively editing, use watch mode (rebuilds on every file save)
npm run start
After the build completes, open the Template Builder in WordPress and click the + button to open the Add-node menu. You should see "Badge" in the list.
Checklist before you test
- [ ]
NODE_TYPES.badgeadded tonodeTypes.jswithtype,label,icon,canHaveChildren,builderTypes,defaultValues, anddialogTabs - [ ]
...SIZE_SECTION_FIELDSspread into the node's style tab fields — gives it Width / Height / Aspect Ratio controls, consistent with all other leaf node types - [ ]
if ( type === 'badge' )block added torenderNode()inrenderCanvas.js— returns the HTML string withdata-node-id - [ ]
case 'badge':added to theswitchincompile_nodes()in the compiler - [ ]
if ( $type === 'badge' )block added tobuild_node_css()in the compiler - [ ]
if ( type === 'badge' )block added towalkNodeTree()inbuildNodeStyles.js - [ ]
npm run buildcompleted without errors
Testing it
- Open any template in the Template Builder.
- Click + (Add node). You should see Badge with a tag icon.
- Click Badge to insert it. The canvas should immediately show a red pill with "New".
- Click the pencil/settings icon on the node. The dialog should open with a Content tab (label text input) and a Style tab (background, text colour, font size, etc.).
- Change the label text to "Hot" and the background to green. The canvas should update live.
- Save the template. Open a post that uses this template on the frontend — the badge should appear.
Troubleshooting
The node doesn't appear in the Add menu
Check that builderTypes includes the builder type you're working in ('template' or 'titlebar').
Also check for a JavaScript syntax error in nodeTypes.js — open the browser console.
The dialog is empty or missing tabs
Check that dialogTabs is a non-empty array and each tab has id, label, and fields.
Make sure id: 'style' exists — getNodeDialogTabs() looks for a 'style' tab to append
the shared spacing/border fields into.
The canvas preview doesn't update
You may have a JS error in buildNodeStyles.js. Open the browser console inside the iframe
(right-click the canvas → Inspect → Console). Also confirm the build ran after your changes.
The frontend renders differently from the preview
The JS and PHP CSS logic are out of sync. Compare your if ( type === 'badge' ) block in
buildNodeStyles.js with your if ( $type === 'badge' ) block in the compiler and make sure
they produce the same declarations.
A field value isn't being applied
Check that the field name in nodeTypes.js exactly matches the key you're reading in the
compiler (e.g. 'badge-bg-color' in both places). Field names are case-sensitive.