BunnyBlock releases
An experimental web site builder for WordPress. Lightweight and fast.
by BunnyCloud.IT · github.com/dimitrisp2/bunnyblock · website
Install
The author publishes release zips, so WP-CLI can install straight from GitHub:
wp plugin install https://github.com/dimitrisp2/bunnyblock/releases/download/v0.1.0/bunnyblock-0.1.0.zipReadme
BunnyBlock
A small, fast, extensible page builder for WordPress. JSON documents in, semantic HTML and a static CSS file out. No frontend JavaScript unless an element genuinely needs it — and most do not.
PLEASE NOTE!
This is meant to be an experiment, not a usable product. If you decide to use it, you are on your own! The code is provided for research purposes only, and any security issues should be reported as soon as possible.
Why
Most page builders ship a runtime to the visitor. BunnyBlock does not. A typical page built with it downloads two stylesheets and nothing else — no icon library, no slider bundle sitting on a page that has no slider. The builder is a build tool, not a dependency.
A few elements are genuinely stateful and cannot be built from HTML and CSS alone; those enqueue their own small script, and only on pages that actually use them. The accordion is not one of them — it is native <details>/<summary>, and pages using it stay script-free.
Three decisions follow from that:
- One renderer. The same PHP renders the published page, the editor canvas, and the live preview. There is no second implementation in JavaScript to drift out of sync, so the preview cannot lie to you.
- One stylesheet per page. Element styles compile to one rule per element per breakpoint in a cacheable file. No inline
styleattributes, so the markup stays readable and the CSS stays cacheable by any CDN in front of it. - PHP-first extensibility. Registering an ordinary element is a PHP class and one
add_action; the editor builds its inspector from the control schema. A genuinely stateful element can additionally overrideenqueue_frontend_assets(), and that asset is loaded only on documents containing the element.
Requirements
- WordPress 7.0+ tested, 6.2+ should work as is without guarantees.
- PHP 8.0+
- Node 20+ (only to build the editor; not needed at runtime)
Getting started
git clone https://github.com/dimitrisp2/bunnyblock.git wp-content/plugins/bunnyblock
cd wp-content/plugins/bunnyblock
npm install
npm run build
Then activate the plugin. Edit any page or post and click Edit with BunnyBlock, from either the post list row actions or the button above the WordPress editor.
dist/ is not committed. Without it the editor screen shows a build notice instead of loading — that is the reminder to run the command above.
| Command | What it does |
|---|---|
npm run dev |
Rebuild on change. Not a dev server: WordPress serves the editor, so there is nothing for one to host. |
npm run build |
Typecheck, then build dist/editor.js and dist/editor.css. |
npm run typecheck |
tsc --noEmit on its own. |
There is no test suite yet.
How it works
A page is a tree of nodes stored as JSON in post meta, carrying the schema version it was written with:
{
"version": 1,
"root": {
"id": "root",
"type": "container",
"props": { "gap": { "$t": "space.lg" } },
"children": [
{ "id": "a1b2c3", "type": "heading", "props": { "text": "Hello", "tag": "h1" } }
]
}
}
On the frontend, the_content is replaced with the rendered tree for any post that has been saved in BunnyBlock. Styles compile to uploads/bunnyblock/post-<id>.css, alongside a shared uploads/bunnyblock/global.css holding the design tokens and a sub-1KB reset. Both are regenerated on save and served as static files; if the uploads directory is not writable, the CSS is inlined instead so the page still renders correctly.
The editor canvas is an iframe pointed at the post's real URL with a nonced flag. The preview therefore renders inside the actual theme — real header, real footer, real container widths — and the editor's own chrome lives outside the frame where it physically cannot leak in. Every edit posts the working document to /render, which returns HTML and CSS produced by the same renderer the frontend uses.
Values: responsive and tokenised
Every prop value is one of four shapes:
| Shape | Example |
|---|---|
| Scalar | "24px" |
| Token reference | {"$t": "color.brand"} |
| Responsive map | {"$r": {"base": "16px", "lg": {"$t": "size.xl"}}} |
| Composite | {"top": "10px", "bottom": {"$t": "space.md"}} or [{"_id":"ri-abc","label":"English"}] |
Responsiveness is a property of the value, not of the control. Any control becomes responsive with 'responsive' => true, and element authors never write a line of breakpoint code — styles() receives only the props that the breakpoint being compiled actually declares. An element with no responsive overrides emits exactly one rule and zero media queries.
Breakpoints are mobile-first min-width queries (base, sm 480, md 768, lg 1024, xl 1440), so a value set at base cascades upward and you only declare the overrides you need.
Token references compile to var(--bb-color-brand, #7c3aed) — the literal is kept as a fallback, so a page still renders correctly if the global stylesheet fails to load. A rebrand is one edit in the token panel rather than a find-and-replace across every page ever built.
Adding an element
add_action( 'bunnyblock/elements/register', function ( $registry ) {
$registry->register( new My_Testimonial() );
} );
class My_Testimonial extends \BunnyBlock\Elements\Element_Base {
public function type(): string { return 'testimonial'; } // never change once shipped
public function title(): string { return 'Testimonial'; }
public function icon(): string { return 'square'; }
public function controls(): array {
return array(
\BunnyBlock\Elements\Controls::group( 'content', 'Content', array(
\BunnyBlock\Elements\Controls::richtext( 'quote', 'Quote' ),
\BunnyBlock\Elements\Controls::color( 'color', 'Colour', array( 'responsive' => true ) ),
) ),
);
}
public function styles( array $props ): array {
return array(
'color' => $props['color'] ?? null, // `?? null` means "only if set"
' cite' => array( 'font-style' => 'normal' ),
);
}
protected function render( array $props, string $children, array $attributes ): string {
return \BunnyBlock\Render\Html::tag( 'blockquote', $attributes, $props['quote'] ?? '' );
}
}
That is the whole story — the editor discovers the element, builds its panel, and sanitizes its props against the same schema. The shared Advanced group (padding, margin, responsive visibility, CSS classes, HTML id) is appended automatically.
One caveat: an element class's icon() method names the small Lucide icon shown for that element in
the editor chrome. That map lives in src/components/Icon.tsx, and unknown names fall back to a
neutral square. This is separate from Controls::icon(), the searchable content-icon picker.
Stateful elements override enqueue_frontend_assets():
public function enqueue_frontend_assets(): void {
wp_enqueue_script(
'my-element',
plugins_url( 'element.js', __FILE__ ),
array(),
'1.0.0',
true
);
}
BunnyBlock calls it once per distinct element type in the document. The editor canvas preloads registered element assets because an unsaved element can be inserted after the iframe has loaded; visitor-facing pages remain conditional.
Structured repeating data uses the same schema-driven approach:
Controls::repeater(
'people',
__( 'People', 'my-plugin' ),
array(
Controls::text( 'name', __( 'Name', 'my-plugin' ) ),
Controls::text( 'role', __( 'Role', 'my-plugin' ) ),
Controls::link( 'profile', __( 'Profile', 'my-plugin' ) ),
),
array(
'itemLabel' => __( 'Person', 'my-plugin' ),
'addLabel' => __( 'Add person', 'my-plugin' ),
'maxItems' => 20,
)
);
Each stored item is an ordered prop bag sanitized against those nested controls. The editor provides add, remove, collapse, keyboard-accessible move buttons, drag reorder and undo/redo. Nested fields are intentionally non-responsive. _id is reserved for stable editor identity and should be ignored by the element renderer.
Dynamic content and caching
The Post list element runs a WP_Query at render time, which makes a page's HTML depend on data outside that page. BunnyBlock itself handles that correctly — the stylesheet is compiled from props, not from query results, so it never goes stale, and the HTML is rendered per request. A third-party full-page cache is a different matter: it will keep serving an old list until the page is invalidated. Cache plugins can hook bunnyblock/post_list/rendered to register a dependency.
There is no pagination, deliberately: without JavaScript it would mean URL query parameters that collide with the main query on archive pages, multiply cache keys, and create canonical-URL problems. Use How many plus Skip instead.
Video
The Video element supports media-library files through the browser's native <video> controls and provider URLs through WordPress oEmbed. Provider markup is emitted through WordPress's own trusted oEmbed path; BunnyBlock does not admit arbitrary iframe elements or ship a video runtime of its own. Autoplayed native video is muted automatically to match browser policy. Native videos accept repeatable labelled WebVTT tracks for captions, subtitles, descriptions and chapters.
The element's Caption field is a visible figure caption, distinct from the timed text in a WebVTT track.
Tabs
The Tabs and Tab item elements render every panel as a labelled section first, then a small dependency-free script creates the tablist and wires role="tab", role="tabpanel", aria-selected, aria-controls, roving focus, Left/Right arrows and Home/End. If the script fails, all content remains visible under ordinary headings rather than becoming inaccessible. The script is loaded only on visitor pages whose document contains Tabs.
Icons
The Icon element provides a searchable bundled set of 34 Lucide icons with responsive size, colour and alignment controls. Icons are decorative by default; turn off Decorative to expose an accessible label. The selected value is a catalog name, never SVG supplied by the document.
Add-on elements can reuse the same picker with Controls::icon(). Both the React preview and PHP
renderer read assets/icons/catalog.json, and PHP rejects names outside that catalog. The reviewed
renderer emits the fixed geometry directly while svg and path remain outside the general HTML
allowlist. Lucide is distributed under the ISC license in assets/icons/LICENSE.
Security model
Documents arriving from the editor, an import, or the REST API are never trusted. Unknown element types are dropped along with their subtree; every prop is validated against its control definition and anything without a matching control is discarded, so a crafted document cannot smuggle in a prop and hope some element echoes it. Lengths and colours are validated by pattern rather than escaped, because they land in a stylesheet, and the compiler applies a final check before any value is written. Documents nested past 50 levels are rejected.
Accessibility
The base output targets WCAG 2.1 AA — anything the renderer or the compiled CSS does on its own. Author choices are not policed: pick unreadable colours and you get unreadable colours, without a warning or an override. The line is whose decision caused the failure.
REST API
Namespace bunnyblock/v1. All routes are capability-checked (edit_post for a document, edit_theme_options for site-wide tokens).
| Route | Purpose |
|---|---|
GET /config |
Element schemas, categories, breakpoints, tokens |
GET|POST /document/<id> |
Read or save a document |
POST /render/<id> |
Render an unsaved document to HTML + CSS |
GET|POST /tokens |
Read or save design tokens |
Hooks
bunnyblock/loaded · bunnyblock/elements/register · bunnyblock/elements/categories · bunnyblock/element/controls (and bunnyblock/element/{type}/controls) · bunnyblock/element/render · bunnyblock/breakpoints · bunnyblock/tokens · bunnyblock/global_css · bunnyblock/post_types · bunnyblock/document/migrations · bunnyblock/document/saved · bunnyblock/document/disabled · bunnyblock/sanitize_control · bunnyblock/post_list/query_args · bunnyblock/post_list/rendered
Working alongside the WordPress editor
A post built with BunnyBlock is labelled as such in the post list, and both the classic and block editors show a banner explaining that the body below them is not what visitors see. Back to WordPress editor hands the post back: it clears the flag and deletes the generated stylesheet, but leaves the document untouched, so opening BunnyBlock again brings the layout back exactly as it was.
The content element goes the other way — it places the post's own WordPress editor body inside a BunnyBlock layout, so a page can be laid out here and still have its prose written and edited in the block editor by people who never open the builder.
Project layout
bunnyblock.php Plugin header and bootstrap
includes/
Document/ Document model and schema migrations
Elements/ Element base class, control factories, sanitizer, registry
Types/ Container, Heading, Paragraph, Image, Content
Render/ Renderer, CSS compiler, stylesheet files, HTML helpers
Rest/ REST controller
Admin/ Editor screen, canvas preview
Value.php Value envelopes — the TypeScript counterpart is src/values.ts
Tokens.php Design tokens
Breakpoints.php Breakpoint definitions
src/ Editor app (React, zustand, immer). Never shipped to visitors.
assets/ Canvas bridge and admin stylesheetsRead the full README on GitHub →
Releases
| Tag | Published | Asset | Downloads |
|---|---|---|---|
| v0.1.0 | Jul 31, 2026 | bunnyblock-0.1.0.zip | 2 |
| v0.0.6 | Jul 31, 2026 | bunnyblock-0.0.6.zip | 0 |
| v0.0.5 | Jul 30, 2026 | bunnyblock-0.0.5.zip | 0 |
| v0.0.4 | Jul 30, 2026 | bunnyblock-0.0.4.zip | 1 |
| v0.0.3 | Jul 30, 2026 | bunnyblock-0.0.3.zip | 0 |
| v0.0.2 | Jul 30, 2026 | bunnyblock-0.0.2.zip | 1 |
| v0.0.1 | Jul 30, 2026 | bunnyblock-0.0.1.zip | 1 |