A host plugin for server-rendered Gutenberg blocks — the kind you write as a
plain block.json + render.php pair, with no build step and no per-block
JavaScript. Framix Blocks is the engine: it discovers blocks from directories
that any plugin can register, validates each one defensively, and registers it
with WordPress so the editor gets a live preview and an Inspector for free.
It ships zero blocks of its own. That is deliberate. Framix Blocks is the
project-agnostic runtime; the blocks themselves live wherever they belong — most
often in a small per-site plugin that registers its blocks/ directory through
a single filter. The engine updates like any other WordPress plugin, straight
from GitHub Releases; your site's blocks stay in your own repository and ship on
your own cadence.
On init (priority 5) the loader reads one filter:
$dirs = apply_filters( 'framix_blocks_block_dirs', array() );Each entry is an absolute path to a directory whose immediate subdirectories are
blocks — <dir>/<slug>/block.json plus <dir>/<slug>/render.php. For every
block found, the loader:
- Validates
block.jsondefensively. An invalid block is skipped and logged (gated behindWP_DEBUG); it never fatals, so one bad block can't break the editor for the rest. - Derives the render callback name from the block's
name.namespace/slugbecomes<namespace>_<slug>_renderwith dashes turned to underscores — soframix/sample-cardresolves toframix_sample_card_render. - Requires
render.php, which defines that function. - Calls
register_block_type()on the directory, letting WordPress read the metadata and — viasupports.autoRegister— generate the editor preview and Inspector.
The only JavaScript the plugin ships is three generic editor shims: a
MediaUpload picker for attributes declaring "control": "media" (storing an
attachment ID), a sidebar CRUD interface for "control": "repeater" arrays, and
the inline text-edit shim described below. None renders the block body — the
server-rendered output stays canonical in both the editor and on the front end.
Inside a repeater's fields, each field renders the control its schema implies —
at any nesting depth:
| Field definition | Control |
|---|---|
"control": "repeater" |
a nested repeater (recursion) |
"control": "media" |
a MediaUpload picker, storing an attachment ID |
"type": "boolean" |
a toggle |
"control": "textarea" |
a multi-line box |
| anything else | a single-line text input |
So a repeater of rows that each carry their own image needs no per-block JavaScript:
"speakers": {
"type": "array",
"control": "repeater",
"label": "Speakers",
"fields": {
"photo": { "type": "integer", "control": "media", "label": "Photo" },
"name": { "type": "string", "label": "Name" },
"bio": { "type": "string", "control": "textarea", "label": "Bio" },
"moderator": { "type": "boolean", "label": "Moderator" }
}
}New rows are seeded with the right type per field — 0 for media and numbers,
false for booleans, [] for nested repeaters, "" for text — so render.php
never receives a string where it expects an attachment ID.
Before 1.4.0 only the nested repeater and the text input were implemented, so a block needing per-row images had to hand-roll an
editor.jsand keep its rows in a JSON-string attribute. If you maintain a block built that way, it keeps working — but new blocks should use a plain repeater.
A render template can opt an element into click-to-edit by printing a marker in
its opening tag with the framix_block_edit_attr() helper. In the editor canvas
the marked text gains a hover affordance, and clicking it opens a small popover
with the matching control — derived from the block.json attribute schema, with
no per-block JavaScript.
<h3 class="card-title"<?php echo framix_block_edit_attr( 'title' ); ?>><li<?php echo framix_block_edit_attr( 'items', $i, 'label' ); ?>>The markers are REST-gated — emitted only during the editor's SSR preview,
never on the front end, so the public render stays byte-identical. Only text
attributes are editable inline: a plain string attribute gets a text input,
a string attribute with "control": "textarea" gets a textarea, and string
fields inside a repeater are reachable with framix_block_edit_attr( $attr, $i, 'field' ) — the index is into the raw attribute array, so if your template
sorts or slices the array, pass the original array index, not the loop counter.
Media attributes and selects stay in the sidebar.
Framix Blocks is engine-only by design, so two concerns stay cleanly separated:
- The engine (this plugin) is project-agnostic. It is installed and updated like any WordPress plugin, from this repository's GitHub Releases. It never contains a specific site's blocks, so updating it can never overwrite them.
- A site's blocks live in that site's own code repository, in a companion
plugin (by convention,
framix-site-blocks) that registers its block directory through theframix_blocks_block_dirsfilter and deploys with the rest of the site's code.
A companion plugin needs only this:
<?php
/**
* Plugin Name: Framix Site Blocks
* Requires Plugins: framix-blocks
* Update URI: false
*/
add_filter(
'framix_blocks_block_dirs',
static function ( array $dirs ): array {
$dirs[] = __DIR__ . '/blocks';
return $dirs;
}
);Requires Plugins: framix-blocks lets WordPress enforce the dependency both
ways. Update URI: false keeps a site-specific plugin from ever being offered a
wp.org "update" for a colliding slug.
A block is a directory with two files. The metadata:
{
"$schema": "https://schemas.wp.org/trunk/block.json",
"apiVersion": 3,
"name": "framix/hero",
"title": "Hero",
"category": "framix",
"textdomain": "framix-blocks",
"attributes": {
"heading": { "type": "string", "default": "", "label": "Heading" }
},
"supports": { "autoRegister": true },
"style": "file:./style.css"
}And the renderer — the function name follows directly from the block's name:
<?php
// blocks/hero/render.php
if ( ! function_exists( 'framix_hero_render' ) ) {
function framix_hero_render( $attributes, $content = '', $block = null ) {
$heading = isset( $attributes['heading'] ) ? (string) $attributes['heading'] : '';
$wrapper = get_block_wrapper_attributes( array( 'class' => 'framix-hero' ) );
ob_start();
?>
<section <?php echo $wrapper; // escaped by get_block_wrapper_attributes() ?>>
<?php if ( '' !== $heading ) : ?>
<h2 class="framix-hero__heading"><?php echo esc_html( $heading ); ?></h2>
<?php endif; ?>
</section>
<?php
return ob_get_clean();
}
}Drop that pair under a directory you've registered through the filter, and the
block is live — editor preview, Inspector, and front-end render, no build
tooling involved. A complete reference block (framix/sample-card) demonstrating
the media and repeater controls ships with the framix-site-blocks scaffold.
A "control": "media" attribute stores an attachment ID and gets a
MediaUpload picker in the Inspector. Often you want it to start with an
image already chosen — a placeholder hero, a default avatar — without asking
every editor to pick one. Declare a media object on the attribute and ship
the file in the block's assets/ directory:
{
"attributes": {
"image": {
"type": "integer",
"default": 0,
"control": "media",
"media": {
"default_asset": "assets/hero-default.webp",
"alt": "Abstract gradient backdrop"
}
}
}
}default_asset is a path relative to the block directory, and the schema
default stays 0 — the engine fills in the real value. At registration the
engine sideloads the file into the site's media library the first time it
sees it, then rewrites the attribute's default to the new attachment ID. The
editor opens with the image pre-selected in the MediaUpload control, and your
render.php resolves the ID through wp_get_attachment_image() exactly as it
would for an editor-chosen image — there is no separate code path for the
default.
Resolution is per environment and idempotent. The engine keys each asset by content hash (sha256), so the sideload happens once: every later registration reuses the same attachment, on that environment. Staging and production each resolve to their own attachment ID — IDs are never carried across environments, which is why the default is expressed as a file in the repository rather than a baked-in number. Replace the file with new content and the next registration sideloads the new version (the old attachment is left untouched — the engine never deletes attachments).
If anything goes wrong — the file is missing, unreadable, or the sideload
fails — the attribute simply keeps its 0 default and the block renders
without a default image. A bad asset is logged (under WP_DEBUG) and skipped;
it never fatals and never blocks the rest of the page. Operators can find the
hash-to-ID map in the framix_blocks_media_defaults option.
Rasters only — not SVG. default_asset accepts webp, png, jpg,
jpeg, gif, and avif. WordPress refuses SVG uploads, so SVGs can't be
sideloaded; for decorative or inline art, ship the SVG in assets/ and
reference it directly with framix_block_asset_url() instead of routing it
through a media control.
Any file you ship under a block's assets/ directory is vetted at
registration. The rules, so a malformed tree never trips the validator:
- Allowed extensions only:
webp,png,jpg,jpeg,gif,svg,avif,woff,woff2,ttf,otf,json. Anything else —.php,.js,.html, dotfiles — is rejected. - No extensionless files.
LICENSE,Makefile, and friends have no allowlisted extension and are refused; keep them out ofassets/. - No symlinks. A symlink can point a friendly-looking name at something dangerous outside the tree, so they're rejected outright.
- 20 MB cap on the total size of the directory.
A block whose assets/ tree breaks a rule is skipped and logged, like any
other validation failure — it can't take the editor down with it.
Framix Blocks updates natively through the WordPress updater, using Plugin Update Checker v5 (vendored) against this repository's GitHub Releases. New tagged releases appear in Dashboard → Updates and update in place like any other plugin — no external service, no authentication.
Each release publishes a framix-blocks.zip asset with a stable download URL:
https://github.com/framix-team/framix-blocks/releases/download/v<X.Y.Z>/framix-blocks.zip
You can install or pin a version directly from that URL.
- WordPress 7.0 or newer
- PHP 8.1 or newer
Framix Blocks is one component of a larger, deliberately enterprise-grade way of operating WordPress. FramixOS and the Framix WP Agent (a Claude Code plugin) drive that process: site code is canonical in git, work happens staging-first, every production write passes through explicit confirmation and fresh-backup gates, and plugin updates are applied agent-driven through the WordPress-native updater rather than improvised against a live site.
This plugin is built for that model. Because the engine is project-agnostic and self-updating, while a site's blocks live in the site's own repository, the two move independently and safely: the agent can roll the engine forward from a tagged release without touching a site's blocks, and a site's blocks ship through the same reviewed, git-canonical pipeline as the rest of its code. You can use Framix Blocks on its own — it is a self-contained, standard WordPress plugin — but it was designed as the block runtime for that operating process.
GPL-2.0-or-later. See LICENSE.