Blocks
@webx-ui/module-blocks is the section of the panel where a block type is made entirely — its fields, its Blade template, its styles and its script — and wx-blocks, the field that builds an entity's content out of such blocks. Its other half, webx-ui/module-blocks on the server, stores the types, renders them into a page and serves the preview. This page is both, because neither is useful alone.
Access to the section is access to deployment. A block type is Blade, and Blade is PHP: whoever can save a block can run anything the application can. blocks.manage is given the way a shell is given, and a production site can turn editing off altogether and bring its types in by import.
Install
pnpm add @webx-ui/module-blocks
composer require webx-ui/module-blocks
php artisan migrateimport { createAdmin } from '@webx-ui/module-admin'
import { blocks } from '@webx-ui/module-blocks'
import '@webx-ui/module-blocks/style.css'
createAdmin({
modules: [blocks()],
})The section appears in the System group, above SEO, once both halves are there. Permissions: blocks.view opens the section and the picker, blocks.manage writes. php artisan vendor:publish --tag=webx-blocks-config publishes config/webx-blocks.php; the keys are listed at the end.
An entity with blocks
The tree of blocks is a JSON column on the entity, and the draft the preview shows is the draft column the versions mechanism of module-admin uses. Two macros add both:
Schema::create('pages', function (Blueprint $table) {
$table->id();
$table->string('title');
$table->blocks(); // json `blocks`, nullable
$table->draft(); // json `draft` + `published_at`, from module-admin
$table->timestamps();
});use WebxUi\Admin\Versions\{HasDraft, HasVersions};
use WebxUi\Blocks\HasBlocks;
class Page extends Model
{
use HasBlocks;
use HasDraft;
use HasVersions;
}HasBlocks is the whole integration on the model side. $page->blocks is the tree, $page->renderBlocks() the HTML, $page->blockTypes() the slugs it uses. A view prints it where the content goes:
{{-- resources/views/page.blade.php --}}
@extends('layouts.app')
@section('content')
{!! $page->renderBlocks() !!}
@endsectionand the layout prints the styles and scripts of whatever the page rendered:
<head>
…
@webxBlocks
</head>@webxBlocks is evaluated where it stands, after the content — with @extends and with components the layout runs last — so it prints exactly the bundle of the types on this page and nothing on a page without blocks. @webxBlocks('styles') and @webxBlocks('scripts') split it when the scripts belong before </body>.
Name the models that hold blocks in the config, so the section knows where a type stands and an agent knows what it may address:
// config/webx-blocks.php
'entities' => [App\Models\Page::class],A page that needs no editor stays an ordinary Laravel page — its own route, its own controller — and may still call Blocks::render($tree) wherever it likes inside its view.
The wx-blocks node
An entity's screen puts the constructor on a tab as one node:
{
"id": "content",
"type": "wx-blocks",
"name": "blocks",
"label": "Content"
}Nothing selected, the tab is two columns: the tree of blocks and the whole page in preview, at desktop width. A block selected, it is three: the tree, the block's fields as a form, and the page as a phone at one to one — the width that breaks first and is still readable. Desktop and tablet open full-screen from a button; Esc comes back.
The preview is the entity's own /_preview/… address, and only the screen that hosts the node knows which entity it is editing. It hands the address in:
import { provideBlocksPreview } from '@webx-ui/module-blocks'
provideBlocksPreview({ url: previewUrl, reload: reloadCounter })url is what Preview::url($entity) returned — signed, an hour long, one entity; bump reload after an autosave so the frame shows the new draft. Without it the field is a tree with a form, which is all a screen outside the panel can be. After a field changes, the constructor asks the server to draw that one block and swaps it into the frame between the marker comments, so the page does not reload on every keystroke.
Every row in the tree carries three buttons: an eye, a copy and a bin. The eye switches that block off — it stays in the content and is edited the same way, and the site stops drawing it, along with everything nested inside it. Their own switches keep their values, so a container turned back on is exactly what it was. A hidden block is dimmed in the tree, carries an eye-off beside its name, and is absent from the preview: a preview that still showed it would not say which block is the switched-off one.
Inside a block's own schema the same node makes the type a container:
{
"id": "content",
"type": "wx-blocks",
"label": "Content",
"props": { "allow": ["text", "gallery", "columns"], "max": 8 }
}Its value is a list of nodes of the same shape as the page's own, and the template prints it with @blocks('content'). Nesting stops at max_depth (five by default): deeper than that is not a constructor but layout by mouse.
A block type
Five things, all made in the section:
| Field | What it is |
|---|---|
schema | The fields an editor fills in — screen nodes, same format |
template | Blade that prints the block from those fields |
styles | CSS for this block alone |
script | Optional: the body of an initialiser, run once per block on the page |
sample | A value for every field |
Around them, the settings: slug (also the CSS prefix), title and description for the picker, group — one of webx-blocks.groups, the sections of the picker — allow (what may go inside; null means not a container), allowed_in (where it may go; type slugs, root for the page itself, null for anywhere), max_per_entity, is_enabled.
The schema is screen nodes. { "id": "title", "type": "wx-input", "label": "Title" } — the id is the variable the template gets and the key in the values, so it is snake_case. Layout nodes (wx-card, wx-tabs, wx-row) group fields; the values stay flat. Translatable labels work with the same trans:: marker as everywhere on a screen.
A line with markup in it is wx-rich-text with inline, not wx-input. A heading like Deeply heard<span>.</span> Gently guided — an accent the site's styles colour — in a plain input is shown to the editor raw, tags and all. The inline field shows the words with the accent drawn, offers bold, italic and accent, and stores the same string, cleaned on the server to those few tags. The template prints it unescaped, {!! $heading !!}, as it would any rich text:
{ "id": "heading", "type": "wx-rich-text", "label": "Heading", "props": { "inline": true } }Switching a field from wx-input is safe for what pages already hold: the values are strings either way, and the first save runs them through the allowlist — which takes every attribute off, so a theme that styles span.dot should style the heading's span instead. Until a type is switched, the editor warns under a plain field whose value holds tags, and the value is kept exactly as stored.
A value the site keeps in one place is a shortcode. Call us on [phone] in any text, textarea or rich text field prints the number from «Settings» → «Shortcodes» as a link that dials, and a changed number changes every page. The template needs nothing: the field arrives resolved, with the editor's text escaped. See Shortcodes — also for when to use one rather than the inline accent mark.
The template is Blade with the fields as variables, plus $block (key, type, version, depth, value('name', default)) and $entity, the record the block stands on. The root element carries data-wx-block="{slug}": the runtime finds the block by it and the panel highlights it by it — by the exact slug, so data-wx-block="Quote" on a type called quote is a block whose script never runs. A marker that is not the slug is said under the editor and refused on publishing. Every block renders in its own try/catch — on the site a failure goes to the log and leaves a gap; in the preview it is a notice with the line.
A variable holds what the field type makes of the stored value, the same way a screen's values are read for the site: wx-media stores { path, alt, title } and the template also gets url, worked out when the block is printed, so a library that moves to another disk rewrites no page. A value whose type nobody registered on the server — wx-blocks, a field of the project's own — arrives as it is stored. $block->values holds the same map, which is what a template hands its script in data-wx-values.
A form of the Inbox is a wx-inbox-form field, not a text field with a slug typed in: the editor picks the form by name, the value is its slug, and a slug no form has is refused when it is written — by the panel and by blocks_set_content / blocks_edit_content alike, the latter with the slugs there are. The template prints it with the Inbox's own tag, which prints nothing for an empty value, a form switched off, or one deleted since the page was saved:
{ "id": "form", "type": "wx-inbox-form", "label": "Form", "help": "Empty: no form." }@if ($form)
<x-webx-inbox::form :slug="$form" />
@endifA type that used to keep the slug in a wx-input changes the field's type and nothing else: the stored slugs are what the new field keeps, and the pages open with their forms chosen. The sample's form is not copied into a block added from the picker — it is a form of the author's site. A type compiled on a site without module-inbox cannot use the tag; the contact block of the default theme checks class_exists(\WebxUi\Inbox\View\Components\Form::class) first.
A text field the template changes goes through wx_text(). A text or textarea field arrives as a plain string, or — once an editor types a shortcode into it — as HTML that {{ }} prints without escaping again. A string function or a cast turns the second kind into a string, and {{ }} escapes it a second time: {{ rtrim($heading, '.') }} prints Call <a href=… for Call [phone].. wx_text() takes either kind and returns one whose changes stay HTML:
<h2>{{ wx_text($heading)->trimEnd('.') }}</h2>
<q>{{ wx_text($item['quote'])->trim('“”"') }}</q>
<p>{{ wx_text($lead)->map(fn ($text) => Str::limit($text, 120)) }}</p>trim, trimStart, trimEnd, stripPrefix, stripSuffix, map and isEmpty work on what the editor typed — the brackets, not what they print — so trimming a full stop never cuts into a phone number, and a [dot] at the end stays. Saving warns about a string function applied to a text field inside {{ }}, naming the field.
The way in is the same walk. What a save keeps is what the field type makes of what was sent — the editor's save and blocks_edit_content alike — so a type that cleans what it is given cleans it here too, rather than on screens only. The same two things pass through untouched: a value whose key the schema no longer names, and a value of a type nobody registered. A nested tree is walked as blocks, not handed to a field type: the blocks inside it are kept by their own schemas.
A gallery is wx-gallery, not a repeater of pictures. wx-gallery and wx-files hold a list of those same values, in an order somebody dragged them into, and the editor picks ten of them in one trip to the library rather than ten times over. wx-repeater is still the answer for a list whose items have fields of their own beside the picture.
{ "id": "shots", "type": "wx-gallery", "label": "Photographs", "props": { "max": 12 } }@foreach ($shots as $shot)
<img src="{{ $shot['url'] }}" alt="{{ $shot['alt'] }}"
width="{{ $shot['width'] }}" height="{{ $shot['height'] }}">
@endforeachEach item arrives resolved with everything the library knows: url and thumb, name, extension and mime, size, and width and height for a picture. The whole set rather than the address alone, because a template has nothing to ask the library with — width and height are what keep the page from jumping, and size with extension are what a link to a document is labelled with. A file that has since been deleted comes back with url: null instead of breaking the page.
The styles start with .b-{slug}, in BEM: .b-hero__title, .b-hero--wide. Width decisions are container queries, because the block does not know whether it is the page or a third of it. Saving reports what leaks — a selector outside the prefix, a bare element selector, @media, a missing data-wx-block, a text field changed by a string function without wx_text() — as warnings under the editor; only a marker naming another slug is refused, on publishing.
The script is a body, not a program:
// the field
const Swiper = await webx.use('swiper')
new Swiper(el.querySelector('.b-gallery__track'), values.autoplay ? { autoplay: true } : {})goes into the bundle as webx.block('gallery', async (el, values) => { … }), and a thirty-line runtime calls it on every [data-wx-block="gallery"] on the page. webx.provide('swiper', Swiper) in the site's own bundle is how a block reaches what the site already ships; webx-blocks.provides lists those names, so the editor shows them beside the script and an agent asks for what exists. webx.mount(root) runs the initialisers again on a subtree — the panel uses it after swapping a block in the preview.
The sample is not decoration. It is the thumbnail an editor picks the block by, the values the publish check runs on, and the clearest documentation of the data shape.
Versions and publishing
Every save writes a version; nothing is edited in place. A type has two pointers: the draft the editor works on and the published version the site prints. Publishing is its own step, and it can be refused: the template is compiled and rendered on the sample and on the values of every page the block already stands on. One typo would otherwise take down every page with the block, and the first to know would be a visitor. A refusal shows the line and, when a page rather than the sample broke, which page.
The history lists every version with its author and source — panel, mcp or import — and that is an audit log as much as an undo buffer: a block is code. Restoring an old version makes it the draft; publishing it is the same step as any other.
webx-blocks.editing = false makes the section read-only whatever the permission says: on a site whose types arrive by import, nobody needs to run Blade from a form.
Preview
use WebxUi\Blocks\Facades\Preview;
Preview::url($page, adminId: $admin->id);
// https://example.test/_preview/page/12?token=…/_preview/{type}/{id} does what the address registry does for the real address — finds the route type, loads the entity, hands both to the type's handler — and the handler answers with the same view it answers the site with. What differs: the entity carries its draft over its columns (withDraft()), the block types render at their drafts, every block is wrapped in the marker comments the panel finds it by, and the response is no-store and noindex. The token is signed with the application key, lives preview.ttl minutes and opens one entity; a bad or expired one is a 403.
A handler tells a preview from a visit with PreviewGrant::of($request) — and that is where an unpublished entity is a 404 to everybody else. The route runs through preview.middleware (web by default) so the site's locale and session handling apply to the preview as to the page.
The block editor's stage
The editor draws the block a type is being written for on a page of the site — the site's layout, its header, footer, fonts and base styles — because a block judged against the browser's defaults looks like a draft, and one judged on the site looks like what it will be.
/_preview/block-stage is that page: the component webx-blocks.layout names, with an empty pair of markers for the content and an empty <style> in the head. The panel loads it once and then, on every change of the template, the styles or the sample, swaps the block in between the markers and its styles into the <style>, so the header and footer do not redraw under the editor's typing. A new script is the one change that reloads the page: a script registered in a page cannot be taken back. Links and forms of the site are inert there; its own buttons are not, so a newsletter popup or a cookie bar the layout opens can be closed.
// config/webx-blocks.php
'layout' => 'layout', // <x-layout>, the same one the pages stand inEmpty prints webx-blocks::standalone, a bare document. php artisan webx:panel --sync sets the key the same way it does for pages and the blog, and webx:doctor warns while it is empty. The route runs through preview.middleware and needs an editor's session with blocks.view.
The thumbnails in the list and in the picker take the site's look from the same page: the panel loads it once, keeps the stylesheets, the <style> elements and the font preloads of its head and the chain of elements around the markers, and drops the header, the footer and every script. Each thumbnail is then its block inside those wrappers, on the site's styles, without a newsletter popup in forty cards. With layout empty there is nothing to take, and they stay bare.
What the editor suggests
Each file of a type is written with the other files at hand, and the editor offers what they already say as you type:
- Template — the schema's fields straight after
{{or{!!(the echo is written whole, closing braces included), and after any$, together with@foreachvariables,$blockand$entity; the keys of a picked file ($image['url']) and of a repeater's item; the classes the styles declare insideclass="; Blade directives after@, with@blocks('…')for everywx-blocksfield. - Styles — after a
.in a selector, the block's root class and the classes the template uses, the ones nothing styles yet first. - Fields — the keys a node does not have yet, each with its value (
"label": "","props": {}), and fortypeevery type the panel has registered, the modules' included.
Components
A block stands in a page's content, where an editor put it. Some markup repeats inside other templates instead — a recipe card in a catalogue, in «similar recipes», on a hand-made page — and a site wants to change it in one place, from the panel. That is a component: a block type called by tag from any template.
<x-webx-block type="recipe-card" :card="$card" />
<x-webx-block type="recipe-card" :card="$card" fallback="webx-recipes::partials.card" />
<x-webx-block type="section" :title="$title">
<p>The body, printed as {{ $slot }}.</p>
<x-slot:aside>…</x-slot:aside>
</x-webx-block>The same tag works in a block's template, in a module's view and in the site's layout. Everything else is the block machinery: Blade, CSS and a script in the database, versions, a draft, the stage, the checks before publishing, export and MCP.
- Kind. A type is a
block(editors add it to pages) or acomponent(the picker leaves it out). Any type can be called by tag — a call-to-action block with fixed values on a hand-made page is fine — the kind only says where the type is offered. A type that stands on pages cannot become a component until it is taken off them. - Input. A component's schema is what the tag passes. Any field works as an attribute (
tone="dark",:image="$picture"— values are resolved the way stored ones are), and two nodes exist for components:wx-data, a value passed from code (:card="$card") whoseprops.shapenames a form a module registered, andwx-slot, a named slot.$slotneeds no node.typeandfallbackare reserved;cta-labelis read as$block->value('cta-label'). - Which version. The tag prints the published version of the type, and the draft on a preview or on the stage — so a change to a component shows on any page's preview before it reaches the site.
fallbackis a view printed when the type does not exist or was never published, with the same values and slots. Without it, a missing component is a gap on the site, a log line and a plate on the preview.- Who calls whom. Every version records the types its template calls by a literal
type. A type's editor shows the blocks that call it, and publishing it first draws every one of them — on its sample and on the pages it stands on — with the new version in place. It is refused, naming the block and the page, if one breaks; a circle of calls is refused as well. A type other types call cannot be deleted.:type="$x"works but is not tracked, and the editor warns. - Calls from view files are not seen by any of this. A module's views are covered by the module's declaration (below); a site's own view that calls a component should give it a
fallback, or keep the component.
A place a module declares
A module that prints a card declares the place in code, with its own partial as the fallback:
$this->app->make(BlockComponents::class)->declare(
slug: 'recipe-card',
module: 'recipes',
fallback: 'webx-recipes::partials.card',
title: 'Recipe card',
schema: [['type' => 'wx-data', 'id' => 'card', 'label' => 'Recipe', 'props' => ['shape' => 'recipes.card']]],
);and calls it with @webxPart('recipe-card', ['card' => $card], 'webx-recipes::partials.card') — the tag when the blocks module is installed, the partial when it is not. Nothing is written to the tables: the section shows the place as a card «Standard view · module Recipes» with a Customise button. Customising makes a component of that slug whose draft is the fallback view as the site has it (a copy the site published into resources/views/vendor/ wins) and whose sample is the module's real one. The partial keeps printing until the component is published; Reset to standard — deleting the component — brings it back. The module's styles are not copied: they keep applying to the same classes.
A customised part brings its own styles and script, so the module's page prints @webxPartAssets in its head — @webxBlocks when the blocks module is installed, nothing when it is not — and renders its body before the layout: the bundle is made of what was rendered, and a head slot is worked out before the body.
Layout regions
A block type can also stand in the site's header or footer: a region of the layout is a named place whose content is a tree of blocks, edited and published like a page, with the markup from code printed while it is empty. The tag, the declaration, the preview on a real page and the Site regions section are on a page of their own — Layout regions. A type meant only for a region names it in allowed_in: ["region:header"].
Styles and scripts on the site
The types on a page are known from its tree, so the page gets one stylesheet and one script, named by the hash of the types and versions on it: /blocks/{hash}.css, cached forever. Two hundred pages share a couple of dozen bundles; publishing a version changes the hash only where the block stands. Small sets can be inlined instead (bundles.inline_below).
php artisan webx:blocks:bundles --prune # drop bundles of versions no longer published
php artisan webx:blocks:bundles --warm # build the bundle of every entity in `entities`
php artisan webx:blocks:clear # forget the cached registry of typesExport and import
The one real cost of keeping types in the database is that a block does not travel through git on its own. Two commands close that:
php artisan webx:blocks:export # resources/blocks/{slug}.json, one per published type
php artisan webx:blocks:export hero --draft --path=/tmp/blocks
php artisan webx:blocks:import # the types the site lacks, as drafts; the rest are kept
php artisan webx:blocks:import --publish # …and publish what passes the checks
php artisan webx:blocks:import --dry-run # say what would change
php artisan webx:blocks:import --replace=hero # take the file's hero over the site'sA file is the row's settings and one version's content, flat, pretty-printed for a diff. Import checks it by the rules the panel checks a save with and creates the types the site does not have; with --publish it runs the publish checks and publishes what passes — a type that fails is left as a draft and the command says so with its exit code. A type the site already has is never overwritten: the same as the file, it is unchanged; different, it is differs, kept, and the command prints what differs — fields added, removed or changed, lines of the template, styles, script and sample, the row's settings. Whoever changed it, the client included, the site's version stays. To take the file's, name the type: --replace=<slug>, repeated or comma-separated, writes a new version from the file and leaves the one before in the history. There is no «replace all» — --replace=* and a slug the files do not carry are refused before anything is written. Keep the export in the repository as a seed for a fresh install and as the thing that gets reviewed; moving types from a local site to a production one becomes two commands, the second run once, by a person.
The same files move through the section too, for whoever has no shell on the site. Export at the top of «Blocks» — and on a type's editor, with that type ticked — takes the ticked types and every component they call, so the file works on a site that has none of them, and saves one .json: a pack, the command's documents in a list under "format": "webx-blocks", what is called first. Import reads such a pack, or a single file the command wrote, and asks the server twice: first without writing, to show what each type would become — new, differing, unchanged or refused, and why — then for real. Types come in as drafts. A type the site already has and that differs shows its difference folded under the row and a «Replace» switch, off by default: only the rows switched on get a new draft version, and there is no «select all». The switch beside the button publishes what passes the checks — switched on, the plan runs those checks as well, so a type they would hold back says so before anything is written. The command reads packs as well, so a file from the panel can go into resources/blocks as it is.
Export needs only to see the section; import is blocks.manage and a site with editing on, like making a type by hand.
On a site with a theme the same files have a third home: theme/blocks/, and the blocks/ of every theme layer below it. Nothing puts them on a site by itself — webx:setup prints the line for a dev copy: php artisan webx:blocks:import --theme reads the layers instead of resources/blocks, by the same rules — webx:theme:sync, a deploy and composer update never write a block type (Styles › Blocks of the theme). The default theme brings six — hero, text, media-text, cards, cta, contact (Styles › The six blocks) — and on a site with it the demo's own hero and text are not seeded: the theme's stand in their place.
The two drift apart quietly, so export on the day you edit. A type changed in the panel and not written back leaves the file describing the release before: the site is fine, because the site reads the database — what breaks is the next fresh install, the next copy of the site imported from the files, and the test run that builds a page out of them. That is a failure somewhere else entirely, one release later, and it reads like a broken package rather than a stale file.
For an agent: MCP
The section is also a set of tools. webx-ui/mcp comes with the blocks module, and the panel serves one MCP server at /api/cms/mcp. Passport comes with the panel; a site switches it on once (webx:setup already does):
php artisan vendor:publish --tag=passport-migrations && php artisan migrate
php artisan passport:keysSomebody then connects their own agent to it: they paste that address into Claude, ChatGPT or claude mcp add --transport http webx <address>, the client sends them to the panel to sign in and agree, and it leaves with a token of theirs. The agent acts as that administrator. On the machine the site runs on, php artisan mcp:start webx is the same server over stdio, trusted the way tinker is.
| Tool | What it does |
|---|---|
blocks_list | The types, one short row each: slug, title, kind, group, versions, on how many pages; full: true adds every setting and the fields |
blocks_get | One type in full, at the current or a given version, with the warnings on it |
blocks_create | A new type as a draft |
blocks_update | Any settings and any of the five content fields; a version only when content differs; rename_to renames the type everywhere it stands; answers with a summary and the warnings |
blocks_publish | Publish the draft — the same checks as the panel; dry_run runs them and moves nothing |
blocks_delete | Delete a type nobody uses and nothing calls; undoes a customised module component |
blocks_versions | The history of a type: number, source, author, comment, which is draft and published |
blocks_version_restore | An old version back as a new draft |
blocks_usage | Where a type stands — entity, id, title, address, live or draft — and who calls it |
blocks_render | Draw a type on values (the sample by default): HTML, styles, script, or the failing line |
blocks_get_content | An entity's blocks: the map with outline, one node with key, both trees by default |
blocks_set_content | Replace the entity's draft with a tree of nodes; keys are kept or made |
blocks_edit_content | Change one block at a time: set, unset, add, move, duplicate, remove, hide, show, by key |
blocks_preview_url | A signed link to the entity's draft as the page it will be |
blocks_region_* | A region: publish, unpublish, discard its draft, versions and restore, adopt the markup from code |
Pages and services have their history too: pages_versions / pages_version_restore and services_versions / services_version_restore — what the panel's «History» tab does, and the way an agent undoes a bad edit of the content.
For components, blocks_list and blocks_get add the kind, the types a template calls (uses) and the ones that call it (used_by), the fields of each data shape, and declared — the places modules call a component from, customised or not. blocks_create with a declared slug and no template is Customise; a refused publication names the block and the page it would break. blocks_delete on a customised component brings the module's own view back.
render and preview_url are what close the loop: without them an agent writes a template it never sees, and the site gets the markup it imagined. Every tool that changes something accepts dry_run: true and then reports what it would do. Publishing an entity is not offered: the agent writes the draft, a person looks at the preview and publishes.
Changing part of a page
Reading a page and writing it back is the expensive way to change one heading: the whole tree goes both ways, and anything an editor did in between is quietly lost. blocks_edit_content names the node instead, and everything else stays the object it already was:
{
"entity": "page",
"id": 12,
"revision": "8a41c0d2f7b3",
"ops": [
{ "op": "set", "key": "b7f3", "values": { "title": "A new heading" }, "locale": "en" },
{ "op": "add", "type": "text", "parent": "b1a0", "after": "b7f3", "values": {} },
{ "op": "move", "key": "b9de", "before": "b7f3" },
{ "op": "remove", "key": "b2c1" },
{ "op": "hide", "key": "b4aa" }
]
}setmerges field by field: a field nobody mentions keeps its value, andlocalewrites one language of a localized field rather than replacing the map with a string.parentomitted means the top level;fieldnames thewx-blocksfield when the parent block has more than one.beforeandafterplace the node among its siblings.hideandshoware the eye of the tree (above);outlinereportshidden: truefor a hidden block — the only way to tell from the content that it is not on the page.revisionis the oneblocks_get_contentreturned. Send it and the edit is refused when the entity changed in between, instead of overwriting whoever changed it.blocks_set_contenttakes it too.
What is checked
Every value a block holds is checked by its field type's rules — the same rules() a settings screen runs — on every door: blocks_set_content, blocks_edit_content, the panel's save and autosave, a region's save, and a publication (for a draft written before the checks). A number past its min/max, an option the select does not offer, a rating, a checkbox group or a repeater past its max, something that is not a date or a colour, a link that is not http(s), mailto, tel, a relative path or an #anchor, a library key nobody has, a form of the Inbox nobody made — each is refused, naming the block's key and the field (hero [k1], field [title]: …); the panel opens that block and shows the message under the field. Where a block stands is checked too: a container field's props.allow and props.max (or the type's allow when the field names none), a type's allowed_in and max_per_entity. A picture deleted from the library after a page was written does not stop the page from being saved; a key typed now that the library never had does. The same holds for the slug of a deleted form in a wx-inbox-form field.
A field switched to localized (or back) on a type pages already use: blocks_update and the editor say how many pages hold values of it, and publishing the type converts them — a plain value becomes the main language's; a map keeps the main language, and while other languages hold text the publication asks first (drop_translations over MCP, a confirmation in the panel). Until then the site reads either shape.
Start from the map rather than the page: blocks_get_content with outline: true answers with the keys, types, nesting and a line of text each, and with key it answers with that one node in full. The values of twenty blocks are not what you need to edit one.
Before writing, an agent reads the module's resources: blocks://guidelines (the house rules above, as a page for a model), blocks://catalog (every type with its fields and sample, to reuse rather than duplicate), blocks://fields (the node types a schema may use) and blocks://site (the picker groups, what webx.provide() offers, the nesting limit, the entities). One prompt, design_block, packages the loop: read, reuse or create, render on the sample and on nothing, fix, report — and do not publish unless asked.
Config
config/webx-blocks.php:
| Key | Default | What it is |
|---|---|---|
groups | content, layout, media | The sections of the picker, in order; labels from the dictionary |
editing | true | Off, and the section is read-only: types arrive by import |
provides | [] | What the site's bundle hands to blocks through webx.provide() |
max_depth | 5 | How deep containers may nest |
compiled | next to the app's compiled views | Where compiled templates go, one file per version |
cache | on, a day | The registry of published types |
thumbnails.cache | on | The pictures of the list and the picker, kept until any type changes |
thumbnails.ttl | 3600 | Seconds a thumbnail of a type that reads records may lag behind them |
bundles.path | blocks | The prefix of /{hash}.css and .js |
bundles.inline_below | 0 | Sets lighter than this many bytes are printed inline |
entities | [] | The models that use HasBlocks |
preview.path | _preview | The service prefix; closed to the address registry |
preview.ttl | 60 | Minutes a preview link lives |
preview.middleware | ['web'] | What the preview route runs through |
layout | empty | The site's layout the block editor's stage draws a block in |
What is deferred
The «Fields» tab is a JSON editor of the schema with a live form beside it. A visual field builder — add, reorder and configure fields without JSON — is planned, not started.
Dragging a block between two containers is "duplicate" plus "remove", not a drag.
The line above the tree for what the view prints from the record's own fields, and the panel's menu folding to icons while the constructor is open, arrive with the first content module that needs them.