Template Builder — Developer Reference
The Template Builder is a full-screen React + Zustand visual editor for creating reusable post-card layout templates and titlebars. It is intentionally layout-only: no WP_Query, no data fetching, no business logic. Its output is a JSON node tree (schema) that the PHP compiler converts into a self-contained .php template file at wp-content/sanilwb-templates/template-{id}.php.
The two builder types share the same UI and store:
| Builder type | What it builds | Used by |
|---|---|---|
template |
Post-card layout (image, title, excerpt, date, etc.) | Posts widget in the Page Builder |
titlebar |
Title bar shown above a carousel | Title Bar widget in the Page Builder |
Architecture Overview
TemplateBuilder.jsx ← root component, seeds store, renders UI
├── BuilderTopbar ← shared topbar shell
│ ├── Name input ← edits store.name
│ ├── Settings dropdown ← category picker + PreviewPostPicker
│ └── SaveButton ← save state machine, AJAX POST
├── NodeTree / HistoryPanel ← left sidebar: only one visible at a time (activePanel state)
│ ├── AddNodeMenu ← "+" button to add a node (NodeTree)
│ ├── NodeItem (×N) ← each node row: clicking opens the settings panel
│ └── HistoryPanel ← undo/redo timeline viewer, toggled via the header History button
├── CanvasPreview ← iframe canvas, live-updates via store subscription
│ └── DevicePreviewControls ← desktop / tablet / mobile toggle
└── SettingsSidebar ← right-side panel (shared shell)
└── NodeSettingsDialog ← form content for the selected node, auto-applies every field change
All layout state lives in useTemplateBuilderStore (Zustand). CanvasPreview subscribes to the store and rebuilds the iframe HTML whenever nodes change. No props are passed between the tree and the canvas — both import the same store module.
The node settings panel has no Save/Cancel buttons — every field edit auto-applies (debounced) straight into the store, and the canvas updates live. Every mutating store action also pushes an undo/redo history entry; see History / Undo-Redo below.
Entry Point and Data Bootstrap
| File | Purpose |
|---|---|
| admin/assets/js/src/template-builder/index.jsx | React app entry — mounts <TemplateBuilder /> |
| admin/templates/template-builder/editor-page.php | PHP template — renders the editor page and injects window.TemplateBuilderData |
| admin/class-sanilwb-admin-template-builder.php | Enqueues dist/template-builder.js, builds the data object |
window.TemplateBuilderData shape
PHP injects this object before the JS bundle runs. initFromWindowData() reads it on mount.
{
templateId: number, // 0 for a new template
nonce: string, // wp_create_nonce('sanilwb_templates')
schema: array, // saved node tree ([] for new templates)
name: string, // template display name
category: string, // template category slug (e.g. 'default')
templateCategories: string[], // all available category slugs
builderType: string, // 'template' or 'titlebar'
imageSizes: object, // wp_get_registered_image_subsizes() output
nextIndex: number, // used to auto-name new templates ("Template 3")
previewPost: object, // { title, excerpt, author, avatar, date_ago, date_np, topic, section_name }
pluginUrl: string, // plugin base URL (for sample image src in canvas)
}
On mount, initFromWindowData() parses the schema, seeds nodes[], and sets savedSnapshot so the dirty-tracking can detect changes.
Data Structure
The Template Builder stores layout as a hierarchical node tree. Container nodes (div) can have children; all other node types are leaves.
nodes[]
└── node
├── id (auto-incremented integer, ephemeral)
├── type ('div' | 'heading' | 'image' | 'excerpt' | 'date' | 'author' | 'button' | 'topic' | 'carousel_nav')
├── children[] (populated only for div nodes; empty array for leaf nodes)
└── ...fields (flat key-value pairs for all style and content fields)
PHP schema format
buildSavePayload() serializes the tree to a JSON string for the AJAX save. The PHP side stores this in wp_sanilwb_templates.schema_json. The format strips children from leaves and ensures all values are strings:
[{
"type": "div",
"id": "101",
"direction": "column",
"children": [
{ "type": "image", "id": "102", "image-size": "square", "link": "1" },
{ "type": "heading", "id": "103", "link": "1" },
{ "type": "excerpt", "id": "104", "lines": "2" }
]
}]
The supports field
When saving, collectSupports(nodes) walks the tree and collects all leaf node types present (e.g. "image,heading,excerpt,date"). This comma-separated string is saved to the supports column in wp_sanilwb_templates. The Page Builder reads it to show or hide conditional display toggles in the Posts widget dialog (e.g. "Show Image" is only shown when the selected template supports_image).
Responsive field naming
Fields marked responsive: true in the node type config are stored with device suffixes:
| Stored key | Device |
|---|---|
fieldName |
Desktop (base) |
fieldName__tablet |
Tablet override |
fieldName__mobile |
Mobile override |
When reading, the device cascade is: mobile → __mobile → __tablet → base; tablet → __tablet → base; desktop → base.
Store — useTemplateBuilderStore
File: admin/assets/js/src/template-builder/stores/useTemplateBuilderStore.js
Single source of truth for all editor state.
State
| Key | Type | Purpose |
|---|---|---|
nodes |
Node[] |
The live node tree |
selectedNodeId |
number\|null |
Currently selected node (highlighted in tree) |
hoveredNodeId |
number\|null |
Node the mouse is over in the tree |
name |
string |
Template display name |
category |
string |
Template category slug |
templateCategories |
string[] |
Available category options |
builderType |
string |
'template' or 'titlebar' |
templateId |
number |
DB id (0 for unsaved) |
savedSnapshot |
string\|null |
JSON string of nodes at last save — null for a brand-new unsaved template |
savedName |
string |
Template name at last save |
savedCategory |
string |
Category at last save |
isDirty |
boolean |
True when nodes/name/category differ from last save |
previewPost |
object |
Sample post data used by the canvas for placeholder content |
imageSizes |
object |
WordPress registered image sizes — populates the image-size picker |
styleClipboard |
object\|null |
{ nodeType, values } — style values copied from a node, ready to paste |
past |
Array |
Undo stack: [{ id, label, nodes }], oldest first |
future |
Array |
Redo stack: [{ id, label, nodes }], oldest first |
Actions
Init and serialization
| Action | Description |
|---|---|
initFromWindowData() |
Reads window.TemplateBuilderData and seeds all store state on mount |
serializeToJson() |
Returns the node tree serialized to a JSON string (used for dirty-check comparison) |
buildSavePayload() |
Returns { id, name, category, builder_type, supports, schema } — the AJAX POST body |
markAsSaved(newTemplateId?) |
Called after a successful save. Updates savedSnapshot, savedName, savedCategory, isDirty=false. Updates templateId if this was a brand-new template. |
Node CRUD
Every action below that mutates nodes also pushes an undo/redo history entry — see History / Undo-Redo.
| Action | Description |
|---|---|
addNode(type) |
Creates a new node of the given type and inserts it based on the current selection: nothing selected → root; div selected → last child of that div; leaf selected → sibling after the leaf. Sets selectedNodeId to the new node. Snapshots immediately ('Add Node'). |
removeNode(id) |
Removes a node and all its descendants from the tree. Snapshots immediately ('Remove Node'). |
moveNode(id, parentId, newIndex) |
Moves a node to a new position — used by the DnD drag-end handler. Snapshots immediately ('Move Node') once the move target is confirmed valid. |
updateNode(id, fields) |
Merges new field values into an existing node — called automatically (debounced) by NodeSettingsDialog's auto-apply as the user edits fields. Uses a debounced snapshot ('Edit Node') so a whole editing burst collapses into one undo entry. |
setNodes(nodes) |
Replaces the entire node tree — called once per completed drag by useSortableTree's drag-end handler (not fired mid-drag). Snapshots immediately ('Move Node'). |
Style clipboard
| Action | Description |
|---|---|
copyNodeStyles(nodeId) |
Copies all style-tab field values from the node into styleClipboard. Uses getStyleFieldNames() to identify which keys belong to the style tab so content fields are never included. Does not mutate nodes, so no history entry is pushed. |
pasteNodeStyles(nodeId) |
Applies styleClipboard.values onto the target node. Does nothing if the clipboard is empty or the node types don't match. Snapshots immediately ('Paste Style') once those guards pass. |
Selection
| Action | Description |
|---|---|
selectNode(id) |
Sets selectedNodeId |
setHoveredNodeId(id) |
Sets hoveredNodeId |
UI state
| Action | Description |
|---|---|
setTemplateName(name) |
Updates name and marks isDirty: true. Not part of the undo/redo history — only nodes is tracked. |
setCategory(category) |
Updates category and marks isDirty: true. Not part of the undo/redo history. |
setPreviewPost(previewPost) |
Replaces the sample post data used by the canvas |
History
See History / Undo-Redo for the full behavior. Every action that mutates nodes calls snapshot(label) (or the debounced variant) first to push the current tree onto the undo stack.
| Action | Description |
|---|---|
snapshot(label) |
Saves the current nodes to past[] before any mutation. Clears future[]. Flushes any pending debounced edit first so it is not lost or merged into the wrong entry. Caps past[] at 50 entries. |
undo() |
Flushes any pending debounced edit, then pops past[], pushes current nodes to future[], restores the popped tree |
redo() |
Flushes any pending debounced edit, then pops future[], pushes current nodes to past[], restores the popped tree |
jumpToHistory(targetIndex) |
Flushes any pending debounced edit, then computes the equivalent past/future arrays for an arbitrary jump in a single set() call (not a loop of undo()/redo() calls, which would re-render every subscriber once per step). Used by the History panel's click-to-jump feature. |
Debounced edit snapshots: updateNode calls scheduleDebouncedSnapshot(label) instead of snapshot(label) directly, since NodeSettingsDialog's auto-apply calls it on every field change (debounced ~150ms). The pre-edit tree is captured once when the burst starts, and the history entry is only pushed to past[] after a 500ms pause in edits — so typing in the settings panel collapses into a single undo entry instead of one per debounced commit. A single module-scope timer is shared (only one node settings panel can be open at a time), so switching to a different node mid-edit flushes the in-progress burst as its own entry rather than merging it with the new one under the wrong label.
Internal tree helpers (not exported)
findNode(nodes, id)— finds a node anywhere in the tree by id, or null.findParentContext(nodes, id)— returns{ parent, list, index }for any node id.parentis null for root-level nodes.removeNodeFromTree(nodes, id)— returns a new tree with the node and its descendants removed.updateNodeInTree(nodes, id, fields)— returns a new tree with the matching node updated. Also prunes breakpoint keys that were explicitly cleared (set to'') so the device cascade falls back to the desktop base.countNodesOfType(nodes, type)— used to auto-generate admin labels ("excerpt","excerpt1","excerpt2", …).collectSupports(nodes)— collects all leaf node types present in the tree as a comma-separated string.serializeNode(node)— converts one node to the PHP schema format (stripschildrenfrom leaves, converts values to strings, omits empty keys).loadHierarchy(schema)— converts the PHP schema format back to the internal tree format (always ensureschildren: []on every node, convertsidto number).
UI Components
TemplateBuilder.jsx
File: admin/assets/js/src/template-builder/TemplateBuilder.jsx
The root component. Renders:
- BuilderTopbar — shared topbar shell. Left slot: template name
<input>. Right slot: Settings dropdown +SaveButton. - Settings dropdown — contains a category
<select>andPreviewPostPicker. Opens on clicking the gear icon. - Body — left sidebar (
NodeTree+AddNodeMenu, orHistoryPanelwhen toggled), canvas (CanvasPreview+DevicePreviewControls), and a right-sideSettingsSidebarwrappingNodeSettingsDialog.
editingNodeId is local state in TemplateBuilder. Clicking any NodeItem row calls openNodeSettings(nodeId), which sets it. The sidebar title is derived from the editing node's type. NodeSettingsDialog receives nodeId as a prop and renders its form content inside the sidebar.
activePanel ('components' | 'history') is also local state, controlling which panel occupies the left sidebar slot. The sidebar header has a History toggle button (sanilwb-tb-history-toggle-btn) next to the "Components" label that switches to 'history'; the HistoryPanel's own close button switches back to 'components'.
Keyboard shortcuts: TemplateBuilder attaches a keydown listener to the parent window for Ctrl+Z (undo) and Ctrl+Y / Ctrl+Shift+Z (redo), skipped when focus is in an INPUT/TEXTAREA/SELECT/contentEditable element. Because the canvas preview lives in an iframe with its own window, CanvasPreview.jsx attaches a matching listener to iframe.contentWindow once the iframe loads — see its section below.
NodeTree.jsx
File: admin/assets/js/src/template-builder/components/NodeTree.jsx
Renders the flat/hierarchical list of node items. Drag and drop is handled by useSortableTree (from shared hooks), which uses @dnd-kit/core. On drag end, moveNode(id, parentId, newIndex) is called. Nodes can be reordered within their parent but cannot be reparented via drag — that would require a separate drop target.
NodeItem.jsx
File: admin/assets/js/src/template-builder/components/NodeItem.jsx
Each row in the node tree. Shows the node's icon, resolved label (resolveNodeLabel()), and an ellipsis dropdown with:
| Action | Description |
|---|---|
| Copy styles | Calls copyNodeStyles(nodeId) |
| Paste styles | Calls pasteNodeStyles(nodeId) — disabled when styleClipboard is empty or type doesn't match |
| Delete | Calls removeNode(nodeId) with a window.confirm guard |
Clicking the row itself calls both selectNode(nodeId) and onSettingsOpen(nodeId), opening the settings sidebar for that node. The ellipsis dropdown uses stopPropagation so opening the menu does not also open the settings panel.
div nodes also show a child-count indicator and a collapse toggle. The selected node is highlighted via selectedNodeId.
AddNodeMenu.jsx
File: admin/assets/js/src/template-builder/components/AddNodeMenu.jsx
Dropdown button that lists all node types available for the current builderType. Calls getAvailableNodeTypes(builderType) from nodeTypes.js to get the filtered list. Clicking an entry calls addNode(type).
NodeSettingsDialog.jsx
File: admin/assets/js/src/template-builder/components/NodeSettingsDialog.jsx
Renders the content of the settings panel for the selected node — no outer chrome. The SettingsSidebar shell in TemplateBuilder.jsx provides the header, title, and X button. NodeSettingsDialog returns only:
<DeviceProvider initialValues={savedValues} itemId={nodeId}>
<DialogBody tabs={tabs} extraValues={extraValues} autoApply onSave={handleSave} onClose={onClose} />
</DeviceProvider>
The autoApply prop (shared Dialog.jsx behavior, also used by Page Builder's settings sidebar) means there is no Save/Cancel footer — every field change is debounced (~150ms) and calls onSave automatically. handleSave calls updateNode(nodeId, serializedValues) on every one of those debounced commits, so the store (and therefore the canvas) always reflects the panel's current values without the user clicking anything. Closing the panel is handled entirely by SettingsSidebar's own header X button — onClose never needs to be called from inside the dialog itself.
Tab config comes from getNodeDialogTabs(type, builderType, imageSizes).
builderType is passed as an extraValue (read-only, not saved) so showIf: '__builderType=template' and showIf: '__builderType=titlebar' conditions work correctly to hide/show fields depending on which builder is active.
CanvasPreview.jsx
File: admin/assets/js/src/template-builder/components/CanvasPreview.jsx
Renders an <iframe> that shows a live visual preview of the current node tree. The canvas loads ?sanilwb_preview=1 and communicates with the parent via postMessage (it does not mount a React root inside the iframe the way Page Builder's IframeCanvas.jsx does).
Live update pipeline: The component subscribes to the store. Because NodeSettingsDialog auto-applies every field change straight into nodes (debounced), the store is always the single source of truth — no buffer-overlay merging is needed here. On every change:
1. renderCanvas(nodes, opts) — converts nodes to an HTML string.
2. buildNodeStyles(nodes, device) — converts nodes to a CSS string.
3. Both strings are injected into the iframe via postMessage, debounced 100ms. If the nodes reference is unchanged since the last full render (i.e. only CSS-affecting values changed), only the CSS string is re-sent (sanilwb:update-css) instead of replacing the HTML — this avoids an innerHTML replacement flickering images on every keystroke.
Preview post: Sample post data from store.previewPost is passed to renderCanvas so placeholder content (title, excerpt, author, date, topic) reflects a real post shape.
Undo/redo keyboard shortcut inside the iframe: keydown events that originate inside the iframe fire on the iframe's own window, not the parent's — they never bubble to TemplateBuilder.jsx's listener. CanvasPreview therefore attaches its own Ctrl+Z / Ctrl+Y / Ctrl+Shift+Z listener to iframe.contentWindow inside handleIframeLoad(), calling useTemplateBuilderStore.getState().undo() / .redo() directly. It is removed on unmount via the removeKeyDownListener ref. This mirrors the parent listener's input-field guard so typing inside canvas-rendered fields is not intercepted.
DevicePreviewControls.jsx
File: admin/assets/js/src/template-builder/components/DevicePreviewControls.jsx
Desktop / tablet / mobile toggle buttons. Writes to useDeviceStore. The canvas reads the active device and applies responsive overrides when rendering styles and HTML.
| Mode | Canvas iframe width |
|---|---|
| desktop | unconstrained |
| tablet | 768 px |
| mobile | 375 px |
SaveButton.jsx
File: admin/assets/js/src/template-builder/components/SaveButton.jsx
Handles the entire save flow. Since NodeSettingsDialog auto-applies every field change directly into the store as it happens, there is nothing left to flush before saving — SaveButton calls buildSavePayload() directly and POSTs to window.ajaxurl with action sanilwb_template_save.
On success, calls markAsSaved(returnedId). The button is disabled when !isDirty or a save is already in flight.
State machine: idle → saving → saved (1.5 s) → idle.
PreviewPostPicker.jsx
File: admin/assets/js/src/template-builder/components/PreviewPostPicker.jsx
Lives inside the settings dropdown. Lets the user search for a real post to use as the canvas preview source. Calls sanilwb_search_posts and sanilwb_get_post_preview AJAX actions. On selection, calls setPreviewPost(postData) to update the canvas sample data.
HistoryPanel.jsx
File: admin/assets/js/src/template-builder/components/HistoryPanel.jsx
Thin wrapper that connects the shared admin/assets/js/src/shared/components/HistoryPanel.jsx presentational component to useTemplateBuilderStore's past, future, and jumpToHistory. All the timeline rendering logic (flat list, current-entry highlight, jump-index math) lives in the shared component — this file only supplies the store data and Template-Builder-specific empty-state copy. Page Builder's page-builder/components/HistoryPanel.jsx is the same kind of thin wrapper around the same shared component, so the ~90 lines of timeline logic exist in exactly one place.
Rendered in place of NodeTree in the left sidebar when activePanel === 'history' (see TemplateBuilder.jsx above). Its close button switches activePanel back to 'components'.
History / Undo-Redo
Every mutation to nodes — add, remove, move, paste-style, or an auto-applied field edit — is undoable. The implementation mirrors Page Builder's usePageBuilderStore history stack (see mkdocs/docs/features/page-builder.md) one-to-one, keyed on nodes instead of sections:
- Store:
past[]/future[]arrays of{ id, label, nodes }entries, capped at 50.snapshot(label),undo(),redo(), andjumpToHistory(targetIndex)actions — see the Store Actions tables above for the exact mechanics, including the debounced-snapshot collapsing used byupdateNode. - UI: the
HistoryPanel.jsxcomponent (above) rendered in the left sidebar, toggled via a header button next to "Components". - Keyboard shortcuts:
Ctrl+Z(undo),Ctrl+Y/Ctrl+Shift+Z(redo) — wired in bothTemplateBuilder.jsx(parent window) andCanvasPreview.jsx(the canvas iframe's own window), since keydown events originating inside the iframe never bubble to the parent. See those two components' sections above for the exact hook points. - What is not tracked:
nameandcategory(template metadata) are not part of the undo/redo history — only the visualnodestree is. This matches Page Builder, where onlysectionsis tracked.
Node Type Registry
File: admin/assets/js/src/template-builder/config/nodeTypes.js
NODE_TYPES is a plain object keyed by type string. Each entry drives the Add menu, default values on creation, dialog tabs, canvas rendering, and compiler output.
Exported functions
| Function | Purpose |
|---|---|
getNodeDialogTabs(type, builderType, imageSizes?) |
Returns the full assembled tab config for a node's settings dialog. Deep-copies the node's dialogTabs, resolves image size options for image nodes at runtime, appends SHARED_STYLE_FIELDS into the style tab, and appends SHARED_ADVANCED_TAB last. |
getNodeDefaults(type, builderType) |
Returns the initial field values for a new node. Uses defaultValuesTitlebar if builderType === 'titlebar' and the node type defines it; otherwise uses defaultValues. |
getAvailableNodeTypes(builderType) |
Returns all node type configs whose builderTypes array includes the given builder type. Used by AddNodeMenu. |
getStyleFieldNames(type, builderType) |
Returns a flat array of all style-tab field name strings for a node type (including __tablet and __mobile variants for responsive fields). Used by copyNodeStyles to know exactly which keys belong to the style tab. |
Shared field groups
These constants can be spread into any node's style tab fields array:
| Constant | What it adds |
|---|---|
SIZE_SECTION_FIELDS |
A "Size" section header (with device toggle) + Width, Height, and Aspect Ratio inputs. Spread into any leaf node's style tab to give it standard sizing controls. |
ASPECT_RATIO_FIELD |
Standalone Aspect Ratio input-group (W and H sub-inputs, responsive). Included in SIZE_SECTION_FIELDS and also added to the div node's Size section individually. |
FLEX_ITEM_FIELDS |
Flex Shrink, Flex Grow, Flex Basis, Align Self — for any node inside a flex container |
GRADIENT_FIELDS |
Gradient angle, start color, end color, color stops — for any node with a background color |
buildFontFields(prefix) |
Font Size, Font Family, Font Weight, Font Style — for any text node. Returns four field configs using the given prefix (e.g. 'heading' → heading-font-size, heading-font-family, etc.) |
Node types reference
| Type | Builder types | canHaveChildren |
Key content fields | Key style fields |
|---|---|---|---|---|
div |
template, titlebar | true | — | direction, align, justify, flex-wrap, gap, bgcolor, box-shadow, min/max-width, min/max-height, aspect-ratio |
heading |
template, titlebar | false | link (wrap in post link); source_text (titlebar only) |
width, height, aspect-ratio, heading-color, heading-hover-color, font fields |
image |
template | false | image-size, link |
width, height, aspect-ratio, animate-hover, box-shadow |
excerpt |
template | false | lines (line clamp: 2–5) |
width, height, aspect-ratio, excerpt-color, font fields, text-align |
date |
template | false | format (time_ago/readable), show-icon |
width, height, aspect-ratio, date-color, font fields |
author |
template | false | avatar, link |
width, height, aspect-ratio, author-color, author-hover-color, font fields |
button |
template, titlebar | false | btn-text, btn-link (template only) |
width, height, aspect-ratio, btn-bg-color, btn-hover-bg-color, btn-color, btn-hover-color, gradient, font fields, display, text-align |
topic |
template | false | topic-link |
width, height, aspect-ratio, topic-bg-color, topic-hover-bg-color, topic-color, topic-hover-color, gradient, font fields, display, text-align |
carousel_nav |
titlebar only | false | nav-prev-text, nav-next-text |
width, height, aspect-ratio, nav-size, nav-font-size, nav-color, nav-hover-color, nav-bg-color, nav-hover-bg-color |
Notes:
- All leaf node types (every type except div) include a Size section via SIZE_SECTION_FIELDS: Width, Height, and Aspect Ratio (W × H input-group). The div node has its own Size section with the same Aspect Ratio field.
- Aspect Ratio is stored as two separate fields: aspect-ratio-x (W) and aspect-ratio-y (H). Both the JS preview and the PHP compiler only apply the ratio when both values are positive integers.
- For image nodes, the aspect-ratio CSS is emitted by buildNodeStyles.js (not as an inline style) so it updates live in the canvas preview without requiring a full HTML re-render. When neither custom field is set, the ratio is auto-computed from the registered WordPress image size dimensions.
- heading has defaultValuesTitlebar which sets source: 'taxonomy' — in a titlebar, the heading shows the section/category name instead of the post title.
- button.btn-link is hidden in titlebar builder via showIf: '__builderType=template' — titlebar buttons get their URLs from the Page Builder widget instead.
- carousel_nav applies its border fields to the .sanilwb-carousel-prev and .sanilwb-carousel-next child buttons rather than its own wrapper div.
- All node types include SHARED_STYLE_FIELDS (spacing/borders) and SHARED_ADVANCED_TAB (visibility + admin label + CSS class) via getNodeDialogTabs().
Canvas Rendering
renderCanvas.js
File: admin/assets/js/src/template-builder/utils/renderCanvas.js
Pure function: renderCanvas(nodes, opts) → HTML string.
Converts the hierarchical node tree into the HTML injected into the canvas iframe. Every node is rendered to its semantic HTML element with data-node-id="{id}" for CSS targeting.
- Uses
previewPostsample data (Nepali placeholder text by default) to fill in titles, excerpts, author names, dates, and topics. - Checks
sanilwb_show_{device}flags viaisHiddenForDevice(node)and adds.sanilwb-tbve-hiddento hidden nodes. - Empty tree renders a placeholder "Add a component to start building." message.
Per-type HTML output:
| Type | HTML element | Notes |
|---|---|---|
div |
<div class="sanilwb-tbve-div"> |
Recursively renders children inside |
image |
<div class="sanilwb-tbve-image-wrap"> wrapping <img> |
Uses admin/assets/images/sample-post-image.jpg as the sample src; inline style contains only width (for initial layout); aspect-ratio is intentionally emitted as a CSS rule by buildNodeStyles.js so live-preview updates work without a full HTML re-render |
heading |
<h3 class="sanilwb-tbvisual-heading"> |
Wraps in <a> if link=1; uses source_text for titlebar mode |
excerpt |
<p class="sanilwb-tbvisual-excerpt"> |
Line-clamp applied inline |
date |
<span class="sanilwb-tbvisual-date"> |
Shows Bootstrap icon if show-icon=1 |
author |
<span class="sanilwb-tbvisual-author"> |
Renders initials avatar and name |
button |
<a> (if btn-link=post) or <span> |
Uses btn-text value |
topic |
<a> or <span class="sanilwb-tbvisual-button sanilwb-tbvisual-topic"> |
|
carousel_nav |
<span> wrapping .sanilwb-carousel-prev + .sanilwb-carousel-next buttons |
buildNodeStyles.js
File: admin/assets/js/src/template-builder/utils/buildNodeStyles.js
Pure function: buildNodeStyles(nodes, device, opts?) → CSS string.
The opts object currently accepts one key: imageSizes (the WordPress registered image sizes object). This is required for the image node to compute its default aspect-ratio when no custom ratio is set.
Generates CSS rules targeting [data-node-id="X"] for every node in the tree. Uses CSS rules rather than inline styles so :hover pseudo-classes work naturally in the canvas.
Applies the device cascade (mobile → tablet → desktop) via getBreakpointValue(node, fieldName) before emitting any property. Shared helpers:
buildSizeCss(node)— emits width, height, and aspect-ratio (fromaspect-ratio-x/aspect-ratio-y). Called for all leaf node types exceptimage(which has its own size logic).buildTypographyCss(node, prefix)— emits color, font-size, font-weight, font-family, font-style.buildLinearGradientCss(node)— emitsbackground-image: linear-gradient(...). Requires bothgradient-color-1andgradient-color-2to be set.buildAdvancedTabCss(node)— emits margin-top, margin-bottom, padding-top/bottom, padding-left/right.buildFlexItemCss(node)— emits flex-shrink, flex-grow, flex-basis, align-self.buildBoxShadowCss(node)— emits box-shadow using theSHADOWSpreset map (small/medium/large).buildHoverLinkColorRule(selector, node, field)— emitsselector a:hover { color: ... }.buildHoverColorsRule(selector, node, bgField, colorField)— emitsselector:hover { ... }.
image node special case: aspect-ratio is emitted as a CSS rule here (not as an inline style in renderCanvas.js). This is intentional — the CanvasPreview optimization sends CSS-only updates (sanilwb:update-css) when only field values change, so anything that must update during live typing must live in the CSS layer. When aspect-ratio-x/y are set, those values are used; otherwise the ratio is auto-computed from the registered image size dimensions.
carousel_nav special case: Border fields are redirected to the .sanilwb-carousel-prev and .sanilwb-carousel-next child selectors rather than the outer wrapper, since the wrapper is invisible and the buttons are the visible interactive elements.
PHP Compiler
SANILWB_Template_Compiler
File: includes/class-sanilwb-template-compiler.php
Compiles a node tree (schema_json) into a self-contained PHP template file on disk. The compiled file lives at:
wp-content/sanilwb-templates/template-{id}.php
The directory is protected: an .htaccess denies direct browser access, and an index.php silences directory listings.
Public API:
SANILWB_Template_Compiler::compile(int $id, string $name, array $nodes, string $type = 'template'): bool
Dispatches to compile_template() or compile_titlebar() based on $type.
What the compiled layout template does:
The compiled file receives $args (from the shortcode) and $query (a WP_Query object). It:
1. Reads grid config from $args['columns'], $args['gap_horizontal'], $args['gap_vertical'].
2. Reads wrapper style from $args['margin_top'], $args['margin_bottom'], $args['padding_vertical'], $args['padding_horizontal'], $args['bck_color'], $args['bck_border_radius'].
3. Opens a CSS grid wrapper.
4. Loops while ($query->have_posts()) and renders each post card using the compiled node HTML.
5. Calls wp_reset_postdata() after the loop.
What the compiled titlebar template does:
Receives $args only (no $query). Renders the titlebar HTML once, reading heading text from $args['heading_text'] (if provided) or falling back to the WordPress archive/category title.
CSS compilation:
compile_css(int $id, array $nodes) generates per-node CSS from the same field values and writes it to wp-content/sanilwb-templates/template-{id}.css. This CSS file is enqueued by the shortcode renderer when the template is used. It mirrors the logic in buildNodeStyles.js so the canvas preview matches compiled output.
The compiler reads breakpoint overrides from apply_breakpoint_overrides($nodes) and emits them inside @media blocks matching the compiler's breakpoint values:
| Breakpoint | Media query |
|---|---|
| tablet | @media (max-width: 1024px) |
| mobile | @media (max-width: 767px) |
These match the canvas preview widths (tablet: 768 px, mobile: 375 px) — the compiler emits max-width: 1024px so the tablet override kicks in at the same viewport width where the canvas switches to tablet simulation.
Hover rules are breakpoint-aware too. heading-hover-color, author-hover-color, btn-hover-bg-color/btn-hover-color, topic-hover-bg-color/topic-hover-color, and nav-hover-color/nav-hover-bg-color (plus heading-color/author-color's own link-targeting fix, see below) are built by a shared build_hover_rules(array $node, string $selector): string method, called once for the desktop pass and again for each breakpoint that has any override, using a $node already resolved through apply_breakpoint_overrides(). This was previously inlined directly in collect_css_rules() and only ever read the base (desktop) hover-color key — the dialog fields have always allowed a device toggle on these (responsive: true in nodeTypes.js) and the canvas preview (buildHoverLinkColorRule()/buildHoverColorsRule() in buildNodeStyles.js) already resolved them per-device, but the compiled frontend CSS silently ignored the breakpoint override and always applied the desktop hover color everywhere. build_hover_rules() closes that gap so the compiled output now matches what the canvas always showed.
Heading/Author link color targets <a> directly, not just the parent. A color set on heading-color/author-color only reaches a wrapped link's <a> tag via CSS inheritance, and the browser's own default link color beats a merely-inherited value — so build_hover_rules() also emits selector a { color:...!important } (not just the :hover variant) whenever the node is linked, at all three breakpoints. Page Builder's Heading and Post Title widgets have the identical fix, added for the same reason — see build_typography_responsive_style() in class-sanil-website-builder-public.php.
Font sync: After compiling, SANILWB_Template_Compiler calls SANILWB_Font_Sync::update_entity_fonts() to queue any Google Fonts used in the template for download.
Save / AJAX
AJAX action: sanilwb_template_save
Nonce: window.TemplateBuilderData.nonce (action: sanilwb_templates)
Handler: SANILWB_Ajax::template_save()
The save payload from buildSavePayload():
{
action: 'sanilwb_template_save',
nonce: window.TemplateBuilderData.nonce,
id: templateId, // 0 for new templates
name: 'Template Name',
category: 'default',
builder_type: 'template', // or 'titlebar'
supports: 'image,heading,excerpt', // comma-separated leaf node types
schema: '[{"type":"div",...}]', // JSON string
}
On success the handler:
1. Upserts the row in wp_sanilwb_templates via SANILWB_DB.
2. Calls SANILWB_Template_Compiler::compile(id, name, decoded_schema, builder_type).
3. Returns { success: true, data: { id: newId } }.
SaveButton then calls markAsSaved(returnedId). If templateId was 0, the returned id updates the store so subsequent saves use the correct DB id.
List Page
File: admin/templates/template-builder/list-page.php
The template list page uses SANILWB_Templates_List_Table (a WP_List_Table subclass) for the table UI. Actions available per template: Edit, Trash, Duplicate. Trashed templates can be Restored or Permanently Deleted.
Adding a New Node Type — Checklist
-
nodeTypes.js — Add an entry to
NODE_TYPESwithtype,label,icon(Lucide icon component),canHaveChildren,builderTypes,defaultValues, anddialogTabs.getAvailableNodeTypes()auto-discovers it — no other registration needed. For leaf nodes, spread...SIZE_SECTION_FIELDSinto the style tab fields so the node gets standard Width / Height / Aspect Ratio controls consistent with all other node types. -
class-sanilwb-template-compiler.php — Add a
case 'your-type':in the HTML rendering switch (~line 602). Add abuild_your_type_style()method and call it from the style-dispatch block (~line 401). -
buildNodeStyles.js — Add a
if (type === 'your-type')block inwalkNodeTree()to generate the JS-side CSS for the live canvas preview. Mirror the compiler's logic exactly. -
renderCanvas.js — Add a
if (type === 'your-type')block inrenderNode()to generate the HTML element the canvas will show. -
Test: Add the node in the builder, configure its settings, verify the canvas preview matches, save, and confirm the frontend renders correctly when the template is used in the Posts widget.
Adding a New Field to an Existing Node — Checklist
-
nodeTypes.js — Add the field config object to the node's
dialogTabsarray. Add a default value todefaultValues. -
class-sanilwb-template-compiler.php — Read the new field in
build_{type}_style()(or in the HTML case if it affects markup). The compiler reads directly from node data — it does NOT read the JS config. -
buildNodeStyles.js — Add JS-side CSS generation for the live canvas preview. Only add special handling here if the field can't map directly to a CSS property.