Contents
Overview
This tutorial shows how to build and integrate a lightweight mini-builder inside a WordPress metabox using the Meta Box (metabox.io or the Meta Box plugin) approach plus plain JavaScript. The mini-builder provides a block palette, an editable canvas, reordering and removal controls, and stores the layout as structured JSON in post meta. The example is dependency-free (vanilla JS WP core) and intended to be a solid foundation you can extend (media pickers, advanced block types, REST saving, live previews).
What youll get
- PHP metabox registration — register the metabox, output the builder UI, enqueue assets, and handle saving and sanitization.
- Vanilla JavaScript — add/remove blocks, edit block attributes, reorder blocks, and serialize to a hidden meta field.
- CSS — minimal styling for the metabox UI.
- Front-end rendering — simple renderer that converts the saved JSON into HTML in the theme.
- Security and sanitization — nonce checks, capability checks, and structured validation and escaping.
Prerequisites
- WordPress site with ability to add plugin code or theme functions.php modifications.
- Familiarity with PHP, JavaScript, and WordPress hooks (add_meta_box, save_post, wp_enqueue_script/style).
- Optional: Meta Box plugin if you prefer to register the metabox via that plugin. This tutorial uses core WP add_meta_box for clarity and portability.
Architecture and data model
The mini-builder stores an ordered array of blocks as JSON in a single post meta key (for example, _mini_builder_data). Each block is an object with at least a type and attributes. Example data:
[
{ type: heading, attrs: { text: Welcome, level: 2 } },
{ type: paragraph, attrs: { text: Intro paragraph. } },
{ type: image, attrs: { url: https://example.com/image.jpg, alt: Alt text } }
]
Step 1 — Register the metabox, enqueue scripts and styles
Add the code below into a plugin file or themes functions.php. It demonstrates: registering metabox, printing nonce, enqueuing JS and CSS, and passing the available block definitions to JavaScript via localized data.
[
label => Heading,
attrs => [ text => Heading text, level => 2 ]
],
paragraph => [
label => Paragraph,
attrs => [ text => Paragraph text ]
],
image => [
label => Image,
attrs => [ url => , alt => ]
]
]
wp_localize_script(mini-builder-script, MiniBuilderData, [
blocks => blocks,
nonce => wp_create_nonce(mini_builder_nonce)
])
}
// Meta box output
function mini_builder_meta_box_cb(post) {
meta_key = _mini_builder_data
value = get_post_meta(post->ID, meta_key, true)
if (!value) {
value = json_encode([]) // default empty array
} else if (is_array(value)) {
value = wp_json_encode(value)
}
// Output container, palette, canvas, hidden input and nonce
echo
echo
echo
echo
wp_nonce_field(mini_builder_nonce, mini_builder_nonce_field)
echo
}
// Save and sanitize
function mini_builder_save_post(post_id) {
// Check autosave, post type, capabilities, and nonce
if (defined(DOING_AUTOSAVE) DOING_AUTOSAVE) {
return
}
if (!isset(_POST[mini_builder_nonce_field]) !wp_verify_nonce(_POST[mini_builder_nonce_field], mini_builder_nonce)) {
return
}
if (!current_user_can(edit_post, post_id)) {
return
}
meta_key = _mini_builder_data
if (!isset(_POST[meta_key])) {
// If field is missing, delete meta to avoid stale data
delete_post_meta(post_id, meta_key)
return
}
raw = wp_unslash(_POST[meta_key])
decoded = json_decode(raw, true)
if (!is_array(decoded)) {
// Invalid JSON -> clear or ignore here we clear
delete_post_meta(post_id, meta_key)
return
}
// Basic structure validation and sanitization
allowed_types = [heading, paragraph, image]
clean = []
foreach (decoded as block) {
if (!is_array(block)) {
continue
}
if (empty(block[type]) !in_array(block[type], allowed_types, true)) {
continue
}
type = block[type]
attrs = isset(block[attrs]) is_array(block[attrs]) ? block[attrs] : []
if (type === heading) {
text = isset(attrs[text]) ? wp_kses_post(attrs[text]) :
level = isset(attrs[level]) ? intval(attrs[level]) : 2
if (level < 1 level > 6) { level = 2 }
clean[] = [type=>heading, attrs=>[text=>text, level=>level]]
} elseif (type === paragraph) {
text = isset(attrs[text]) ? wp_kses_post(attrs[text]) :
clean[] = [type=>paragraph, attrs=>[text=>text]]
} elseif (type === image) {
url = isset(attrs[url]) ? esc_url_raw(attrs[url]) :
alt = isset(attrs[alt]) ? sanitize_text_field(attrs[alt]) :
clean[] = [type=>image, attrs=>[url=>url, alt=>alt]]
}
}
// Save sanitized JSON
update_post_meta(post_id, meta_key, clean)
}
?>
Step 2 — Metabox UI HTML structure (what the JS will build)
The PHP meta box callback rendered three basic elements: a palette container, a canvas container, and the hidden input that stores JSON. The JavaScript is responsible for populating the palette (using localized block definitions), adding blocks to the canvas, editing attributes of each block, reordering, deleting, and persisting the JSON value in the hidden input.
Step 3 — JavaScript: the mini-builder behavior
Below is a complete vanilla JavaScript implementation. It:
- Builds the palette from MiniBuilderData.blocks
- Adds blocks to the canvas (pushes default attrs)
- Renders block items with Edit / Up / Down / Delete controls
- Supports inline editors for block attributes
- Serializes to the hidden input #mb-data on every change
- Initializes from existing post meta JSON
// File: js/mini-builder.js
(function(){
// Helper shorteners
var qs = function(sel, ctx){ return (ctx document).querySelector(sel) }
var qsa = function(sel, ctx){ return Array.prototype.slice.call((ctx document).querySelectorAll(sel)) }
// Elements
var paletteEl = qs(#mb-palette)
var canvasEl = qs(#mb-canvas)
var dataInput = qs(#mb-data)
// Block registry passed via wp_localize_script as MiniBuilderData.blocks
var blocks = window.MiniBuilderData window.MiniBuilderData.blocks ? window.MiniBuilderData.blocks : {}
// Current model (array of block objects)
var model = []
function init() {
buildPalette()
// Load initial data
try {
var raw = dataInput.value []
var parsed = JSON.parse(raw)
if (Array.isArray(parsed)) {
model = parsed
} else {
model = []
}
} catch (e) {
model = []
}
renderCanvas()
}
function buildPalette() {
paletteEl.innerHTML = Blocks
var ul = document.createElement(ul)
ul.className = mb-palette-list
Object.keys(blocks).forEach(function(key){
var b = blocks[key]
var li = document.createElement(li)
var btn = document.createElement(button)
btn.type = button
btn.className = mb-add-button
btn.textContent = b.label key
btn.dataset.blockType = key
btn.addEventListener(click, function(){
addBlock(key)
})
li.appendChild(btn)
ul.appendChild(li)
})
paletteEl.appendChild(ul)
}
function addBlock(type) {
var def = blocks[type]
if (!def) return
// Deep copy of default attrs
var attrs = JSON.parse(JSON.stringify(def.attrs {}))
model.push({ type: type, attrs: attrs })
renderCanvas()
syncToInput()
}
function renderCanvas() {
canvasEl.innerHTML =
var title = document.createElement(h4)
title.textContent = Canvas
canvasEl.appendChild(title)
if (model.length === 0) {
var p = document.createElement(p)
p.className = mb-empty
p.textContent = No blocks yet. Add a block from the palette.
canvasEl.appendChild(p)
return
}
var list = document.createElement(ul)
list.className = mb-canvas-list
model.forEach(function(block, index){
var li = document.createElement(li)
li.className = mb-block-item
li.dataset.index = index
var header = document.createElement(div)
header.className = mb-block-header
var title = document.createElement(strong)
title.textContent = (blocks[block.type] blocks[block.type].label) ? blocks[block.type].label : block.type
header.appendChild(title)
var controls = document.createElement(div)
controls.className = mb-controls
// Edit button
var edit = document.createElement(button)
edit.type = button
edit.className = mb-btn mb-edit
edit.textContent = Edit
edit.addEventListener(click, function(){ toggleEditor(li, block, index) })
controls.appendChild(edit)
// Up button
var up = document.createElement(button)
up.type = button
up.className = mb-btn mb-up
up.textContent = Up
up.disabled = index === 0
up.addEventListener(click, function(){ moveBlock(index, index - 1) })
controls.appendChild(up)
// Down button
var down = document.createElement(button)
down.type = button
down.className = mb-btn mb-down
down.textContent = Down
down.disabled = index === model.length - 1
down.addEventListener(click, function(){ moveBlock(index, index 1) })
controls.appendChild(down)
// Delete
var del = document.createElement(button)
del.type = button
del.className = mb-btn mb-delete
del.textContent = Delete
del.addEventListener(click, function(){ deleteBlock(index) })
controls.appendChild(del)
header.appendChild(controls)
li.appendChild(header)
// Render simple preview
var preview = document.createElement(div)
preview.className = mb-preview
preview.innerHTML = getBlockPreviewHTML(block)
li.appendChild(preview)
// Editor container (hidden by default)
var editor = document.createElement(div)
editor.className = mb-editor
editor.style.display = none
buildEditorFields(editor, block, index)
li.appendChild(editor)
list.appendChild(li)
})
canvasEl.appendChild(list)
}
function getBlockPreviewHTML(block) {
if (block.type === heading) {
var level = parseInt(block.attrs.level, 10) 2
level = Math.min(Math.max(level,1),6)
return escapeHtml(block.attrs.text )
} else if (block.type === paragraph) {
return escapeHtml(block.attrs.text )
} else if (block.type === image) {
var url = block.attrs.url
var alt = escapeHtml(block.attrs.alt )
if (!url) {
return No image set
}
return
}
return Unknown block
}
function toggleEditor(listItem, block, index) {
var editor = listItem.querySelector(.mb-editor)
if (!editor) return
if (editor.style.display === none editor.style.display === ) {
editor.style.display = block
} else {
editor.style.display = none
}
}
function buildEditorFields(container, block, index) {
container.innerHTML =
if (block.type === heading) {
var labelText = document.createElement(label)
labelText.textContent = Text
var inputText = document.createElement(input)
inputText.type = text
inputText.className = mb-input-text
inputText.value = block.attrs.text
inputText.addEventListener(input, function(){
block.attrs.text = inputText.value
onModelChange()
})
container.appendChild(labelText)
container.appendChild(inputText)
var labelLevel = document.createElement(label)
labelLevel.textContent = Level
var select = document.createElement(select)
select.className = mb-select-level
[1,2,3,4,5,6].forEach(function(l){
var opt = document.createElement(option)
opt.value = l
opt.textContent = H l
if ((block.attrs.level 2) == l) { opt.selected = true }
select.appendChild(opt)
})
select.addEventListener(change, function(){
block.attrs.level = parseInt(select.value, 10)
onModelChange()
})
container.appendChild(labelLevel)
container.appendChild(select)
} else if (block.type === paragraph) {
var labelP = document.createElement(label)
labelP.textContent = Text
var textarea = document.createElement(textarea)
textarea.className = mb-textarea
textarea.value = block.attrs.text
textarea.addEventListener(input, function(){
block.attrs.text = textarea.value
onModelChange()
})
container.appendChild(labelP)
container.appendChild(textarea)
} else if (block.type === image) {
var labelUrl = document.createElement(label)
labelUrl.textContent = Image URL
var inputUrl = document.createElement(input)
inputUrl.type = text
inputUrl.className = mb-input-text
inputUrl.value = block.attrs.url
inputUrl.addEventListener(input, function(){
block.attrs.url = inputUrl.value
onModelChange()
})
container.appendChild(labelUrl)
container.appendChild(inputUrl)
var labelAlt = document.createElement(label)
labelAlt.textContent = Alt text
var inputAlt = document.createElement(input)
inputAlt.type = text
inputAlt.className = mb-input-text
inputAlt.value = block.attrs.alt
inputAlt.addEventListener(input, function(){
block.attrs.alt = inputAlt.value
onModelChange()
})
container.appendChild(labelAlt)
container.appendChild(inputAlt)
} else {
container.textContent = No editor available for this block type.
}
}
function moveBlock(from, to) {
if (to < 0 to >= model.length) return
var item = model.splice(from, 1)[0]
model.splice(to, 0, item)
renderCanvas()
syncToInput()
}
function deleteBlock(index) {
model.splice(index, 1)
renderCanvas()
syncToInput()
}
function onModelChange() {
// Update previews for all items without fully re-rendering
var items = qsa(.mb-block-item, canvasEl)
items.forEach(function(li){
var idx = parseInt(li.dataset.index, 10)
var preview = li.querySelector(.mb-preview)
if (preview model[idx]) {
preview.innerHTML = getBlockPreviewHTML(model[idx])
}
})
syncToInput()
}
function syncToInput() {
try {
dataInput.value = JSON.stringify(model)
} catch (e) {
dataInput.value = []
}
}
// Small escaping helpers
function escapeHtml(s) {
return String(s ).replace(/[<>]/g, function(m){
return {
:amp,
<:lt,
>:gt,
:quot,
:#39
}[m]
})
}
function escapeAttr(s) {
return String(s ).replace(//g, quot)
}
// Initialize on DOM ready
if (document.readyState === loading) {
document.addEventListener(DOMContentLoaded, init)
} else {
init()
}
})()
Step 4 — Minimal CSS for the metabox
Place this CSS in css/mini-builder.css or inline it in your plugin. It keeps the UI readable and usable inside the WP metabox.
/ File: css/mini-builder.css /
#mini-builder-app { font-family: Arial, sans-serif }
.mb-palette, .mb-canvas { margin-bottom: 1em padding: 8px background: #fff border: 1px solid #ddd border-radius: 3px }
.mb-palette-list, .mb-canvas-list { list-style: none margin: 0 padding: 0 }
.mb-palette-list li { display: inline-block margin-right: 6px }
.mb-add-button { background:#0073aacolor:#fffborder:nonepadding:6px 10pxborder-radius:3pxcursor:pointer }
.mb-add-button:hover { background:#006799 }
.mb-block-item { border:1px solid #e2e2e2 margin-bottom:8px padding:8px border-radius:3px background:#fafafa }
.mb-block-header { display:flex justify-content:space-between align-items:center margin-bottom:6px }
.mb-controls button { margin-left:6px }
.mb-preview { margin-bottom:6px }
.mb-editor label { display:block margin:6px 0 2px font-size:12px color:#333 }
.mb-input-text, .mb-select-level, .mb-textarea { width:100% padding:6px border:1px solid #ddd border-radius:3px box-sizing:border-box }
.mb-textarea { min-height:60px resize:vertical }
.mb-empty { color:#666 font-style:italic }
.mb-image-placeholder { color:#999 font-style:italic }
.mb-btn { background:#f1f1f1border:1px solid #dddpadding:3px 8pxborder-radius:3px cursor:pointer }
.mb-btn:disabled { opacity:0.5 cursor:default }
Step 5 — Front-end rendering of saved layout
To render the saved JSON in your theme template (single.php or a block template), get the post meta and convert each block to HTML. Keep escaping in mind. Example:
. text .
} elseif (type === paragraph) {
text = isset(attrs[text]) ? wp_kses_post(attrs[text]) :
echo . text .
} elseif (type === image) {
url = isset(attrs[url]) ? esc_url(attrs[url]) :
alt = isset(attrs[alt]) ? esc_attr(attrs[alt]) :
if (url) {
echo
}
}
}
}
?>
Security and sanitization notes
- Nonces: A nonce is created and printed in the metabox and verified on save_post. This prevents CSRF in the admin area.
- Capability checks: Verify current_user_can(edit_post, post_id) before saving. Do not rely solely on nonce.
- Sanitization: Sanitize each attribute according to its type: use wp_kses_post() for rich text, esc_url_raw() for URLs, sanitize_text_field() for plain strings, and cast numbers carefully.
- Structured validation: Only accept allowed block types and shape attributes before saving. This prevents stored XSS or unexpected structures.
Advanced extensions and tips
- Media picker integration: If you want a WordPress media uploader for image blocks, integrate wp.media in admin scripts and accept attachment IDs instead of raw URLs. That requires enqueuing wp-media scripts and probably using jQuery for the canonical WP examples, but its also doable in vanilla JS.
- Drag-and-drop / Sortable: For a better UX you can integrate a drag-and-drop reorderer such as SortableJS or use HTML5 drag-and-drop on the list. If using external libraries, enqueue them properly and adjust sync logic.
- REST or AJAX saving: Instead of saving through post meta on save_post, implement a REST endpoint to autosave builder state or to fetch block previews on demand.
- Block types registry: Keep your block definitions centralized (labels, default attrs, editor schema, and renderer functions). You may create a PHP registry that can be extended by themes/plugins via filters.
- Gutenberg compatibility: If your site uses Gutenberg, consider whether the mini-builder is an internal editing tool or if you should build custom blocks or a block category for the Block Editor.
Troubleshooting and common gotchas
- If the hidden inputs JSON looks malformed, ensure JavaScript properly escapes values use JSON.stringify before saving and confirm dataInput is not overwritten by other scripts.
- When registering scripts, ensure wp_localize_script is called with the correct handle used in wp_enqueue_script.
- If styles look broken within the metabox, check for CSS specificity conflicts with admin styles—use clear class names and concise rules.
- Remember to clear or migrate old meta if you change the data structure use versioning if required (store a schema_version in meta).
Extending with templates, repeaters, and layouts
You can extend the mini-builder to support templates (presets of multiple blocks), repeating sections (rendering as repeaters where each item is data-driven), or layout blocks (columns with nested blocks). For nested structures, adapt the schema to allow a block to have a children array and update the editor and renderer accordingly. Keep validation strict and consider limiting nesting depth to avoid complexity.
Final design notes
This tutorial produces a minimal but complete implementation: a metabox UI, a safe server-side save routine, front-end rendering guidance, and a dependency-free JavaScript editor. Use it as a foundation: replace the editor widgets with richer inputs, integrate the media library, or swap the persistence model to REST for autosaves. Keep security and sanitization at the forefront when adding richer HTML or file inputs.
|
|
Acepto donaciones de BAT's mediante el navegador Brave 🙂 |
