Midground developer documentation
1. Overview #
theme/midground/ Theme: presentation, fallback layout, WooCommerce styles
plugin/midground-builder/
midground-builder.php Bootstrap, constants, activation/deactivation
uninstall.php Clean-up on delete (never deletes pages)
wpml-config.xml What WPML copies, translates or ignores
includes/ PHP (autoload: Midground\X\Y -> includes/X/Y.php)
Plugin.php Service registry and upgrades
Core/ Css (value validation), Ids, Migrate_Legacy (Kulisse 1.0 -> Midground)
Model/ Document (validation), Props (field sanitising), Migrations, Storage
Elements/ Registry + definitions/*.php (67 elements)
Render/ Renderer, Context, Dynamic (dynamic data sources)
Style/ Style_Compiler, style-keys.json, Globals (design values), Font_Library
Effects/Recipes.php Motion recipes and their resolution to keyframes
Templates/ Templates (CPT mg_template), Conditions, header/footer site parts
Components/ Components (CPT mg_component) and REST
Import/ Importer (block/classic import and restore), Transfer (export/import files)
Media/ Models (GLB/glTF validation), Background_Video, Geocode, Icons
Frontend/ Frontend (assets, CSS), Consent, Seo
Integrations/ Plugins (forms, booking), Reviews, Multilingual, Assistant (AI)
License/ License (updates), Provider, Provider_Edd, Provider_Lemon
Admin/, Rest/, Woo/, Library/, Cli/
library/ Sections and page layouts (PHP data)
src/ Source for the editor, runtime and shared logic (built with esbuild)
editor/ React via wp.element: store, canvas (iframe), drag and drop, panels, tour
runtime/ Front end: fx.js (motion), media.js (consent, background video, maps),
carousel.js, countdown.js, popups.js, reveal.js, counters.js,
models.js + viewer3d.js (3D)
shared/ Used by the editor and the tests: style-compiler.js, doc-ops.js, recipes.js
assets/ Built files (committed, so the plugin works without a build)
docs/ The user guide as HTML (built from docs/*.md)
demo/midground-demo/ Optional: starter sites, the Skumring showcase, placeholder pictures (CC0)
tests/ js/ (node:test), php/ (integration in WordPress), e2e/ (Playwright)
tools/ build.mjs, package.py, i18n.sh, docs-site.mjs, perf.mjs, demo tools
dev/ Docker Compose environment (WordPress, MariaDB, WP-CLI)
Principles: the theme owns presentation only; all content and logic live in the plugin, so content survives a theme switch. The server is authoritative for HTML (one PHP renderer), while the style compiler exists in both PHP and JS with shared test data, so the editor can show styles without a round trip and the published page is identical.
2. The document format #
Every page, template and component stores one document as JSON in the post meta _mg_doc:
{
"version": 2,
"settings": {
"layout": "full", "headerOverlay": false, "background": "", "previewPost": 0,
"seo": { "title": "", "description": "", "image": { "id": 12, "url": "…" }, "noindex": false }
},
"content": [
{
"id": "a1b2c3d4",
"type": "section",
"name": "Intro",
"props": { "width": "contained" },
"style": { "base": { "paddingTop": "var(--mg-s-8)" }, "mobile": { "paddingTop": "48px" } },
"fx": [ { "id": "f1e2d3c4", "recipe": "rise", "intensity": 80, "trigger": "view", "start": 0, "end": 60 } ],
"attrs": { "anchor": "intro", "classes": "my-class", "ariaLabel": "" },
"bgVideo": { "source": "file", "file": { "id": 40, "url": "…" }, "poster": { "id": 41, "url": "…" }, "mobile": "poster" },
"hideOn": [ "mobile" ],
"hidden": false,
"locked": false,
"children": [ { "id": "b2c3d4e5", "type": "heading", "props": { "text": "Hello <em>world</em>", "level": "h1" } } ]
}
]
}
- id: stable,
^[a-z][a-z0-9]{5,15}$, unique in the document. Kept on save; invalid or duplicate ids are replaced (warningid_regenerated). - style: buckets
base(all),tablet(≤ 1199.98 px) andmobile(≤ 767.98 px), inherited downwards. Keys and valid values are defined inincludes/Style/style-keys.json(shared by PHP and JS). Elements can add their own responsive keys (style_vars, e.g.columnsLayout). - fx: up to 8 effects (a recipe or custom keyframes
keys), see section 5. - bgVideo: background video for boxes (file, link, YouTube or Vimeo, poster, phone behaviour).
- Media values are always
{ id, url, alt, width, height }. - Component copies (
type: "component") haveprops.ref(component id) andoverrideskeyed by inner node id:{ "<id>": { "props": {…}, "style": {…}, "hidden": true } }.
Validation (Model\Document::normalize()) runs on every save, autosave and import: unknown fields are dropped, props are sanitised by the element's schema (Model\Props), styles are validated per key, links allow only http(s), mailto, tel, relative paths and #anchors, HTML goes through wp_kses (scripts in Custom HTML only with unfiltered_html), maximum depth 24 and 4,000 nodes, and elements of an unknown type (for example from a disabled extension) are kept with generically sanitised data (warning unknown_type:<type>).
Migrations: version is compared with MIDGROUND_SCHEMA_VERSION. Older documents are upgraded when read (Model\Migrations), step by step; extensions can add steps with the filter midground/migrations. Documents from a newer version are left alone.
Kulisse 1.0: sites built with the product's earlier name are moved over once, after the old plugin is deactivated (Core\Migrate_Legacy): options, post types, meta keys, CSS variable references, the block comment and the theme's settings. Static copies are rendered again in the background.
3. Storage and life cycle #
Model\Storage::save():
- Normalises the document and stores
_mg_docand_mg_enabledbefore the post is updated, so WordPress copies the meta into the revision (wp_post_revision_meta_keys). - The first time: stores the original content in
_mg_original(can be restored exactly). - Writes a static HTML copy to
post_content, wrapped in the block<!-- wp:midground/document {"id":…,"hash":…,"layout":…} --> … <!-- /wp:midground/document -->. With the plugin active, the block is rendered live from the document; without the plugin, WordPress shows the copy. Search, excerpts and SEO tools read the copy. Shortcodes stay as shortcodes in the copy. - Stores a hash of the copy in
_mg_hash(to notice edits made in other editors) and the generated CSS in_mg_css(a cache, tagged withmidground_css_generation).
Autosaves go to _mg_autosave without publishing. Restoring a revision in WordPress brings the document along. Deactivation deletes nothing. uninstall.php deletes the CSS cache and scheduled jobs; templates, components, settings, keys and the licence only when "delete on uninstall" is chosen. After a plugin update the static copies are refreshed in the background (WP-Cron, 20 posts per run), or with wp midground refresh.
4. Rendering #
Render\Renderer::render_doc( $doc, Context $ctx ) renders recursively. Context::$mode is frontend, editor (adds data-mg-id, data-mg-slot, data-mg-inline) or static (for the copy in post_content: no editor or effect attributes, shortcodes kept raw). Element CSS is compiled by Style\Style_Compiler with class selectors .mg-<id> and collected per page in one <style> block. Design values become CSS variables (--mg-c-*, --mg-s-*, --mg-t-*, --mg-r-*, --mg-shadow-*, --mg-font-*) on :root, shared with the theme. Fonts copied into the WordPress font library are served with @font-face rules from the site itself.
Templates: Templates decides which template applies per location (header, footer, content) with Conditions::resolve(). Themes ask through the filter midground_render_location (the Midground theme) or the function midground_location( 'header' ). Content templates take over through template_include. On first run a simple header and footer are created (filter midground/create_default_parts); an untouched default part is retired when another header or footer is imported.
5. Extension API #
A complete, tested example is in docs/examples/midground-example-extension (its own element, recipe, dynamic source and editor control). The PHP test extensions: the documented example plugin registers and renders runs it.
Elements #
add_action( 'midground/register_elements', function () {
midground_register_element( array(
'type' => 'my-notice', // ^[a-z][a-z0-9-]{1,40}$
'label' => __( 'Notice', 'my-textdomain' ),
'category' => 'blocks', // layout|basic|blocks|navigate|dynamic|commerce|motion|utility
'icon' => 'info', // a Lucide icon name
'inline' => array( 'text' ), // props edited directly on the canvas
'style' => array( 'typography', 'spacing', 'border', 'appearance' ),
'props' => array(
'text' => array( 'type' => 'inline', 'label' => __( 'Text', 'my-textdomain' ), 'default' => '' ),
'tone' => array( 'type' => 'segmented', 'options' => array( 'info' => 'Info', 'warn' => 'Warning' ), 'default' => 'info' ),
),
'render' => function ( array $node, array $props, $ctx, $r ): string {
$p = '<p' . \Midground\Render\Renderer::attrs( $r->inline_attr( $ctx, 'text' ) ) . '>' . $props['text'] . '</p>';
return $r->wrap( $node, $ctx, $p, array( 'class' => 'my-notice--' . $props['tone'] ) );
},
) );
} );
Field types (props.*.type): text, textarea, inline (inline HTML), richtext, html, url, link, number, range (min/max/step), toggle, select, segmented (options), color, length, icon, media, gallery, model, post, term, menu, component, posts, terms, query, repeater (fields), datetime (YYYY-MM-DDTHH:MM), place ({ address, lat, lng }), formref and bookingref (a form or booking from a supported plugin), key, object. Shared keys: label, help, default, show (show the field only when other props have given values), dynamic (which dynamic data types the field accepts).
Other definition keys: children (true or a list of allowed child types), parent (allowed parents), preset (children/style created on insert), style_targets (key or @group → selector suffix, for elements whose styles belong on an inner tag), style_vars (own responsive keys that become CSS variables), requires (callable; when it returns false, a stub is registered that keeps the content and shows the unavailable message in the editor), dynamic (depends on context), script (needs the runtime), tag (default wrapper tag), keywords, description.
$r->wrap() must be used for the outermost tag: it adds classes, id/anchor, aria-label, effects, background video and editor markers. $r->children( $node, $ctx ) renders children; $r->slot_attr() marks where children are when they are not directly inside the wrapper.
Motion recipes #
add_action( 'midground/register_recipes', function () {
midground_register_recipe( 'my-sway', array(
'label' => __( 'Sway', 'my-textdomain' ),
'group' => 'through', // enter|through|exit|appear|pointer|hover|reveal
'trigger' => 'view', // view|page|pin|appear|pointer|hover|reveal
'keys' => array( array( 0, array( 'rz' => -4 ) ), array( 1, array( 'rz' => 4 ) ) ),
) );
} );
Channels: tx, ty, tz (px), px, py (drift as a share of the screen width / scroll distance), rx, ry, rz (degrees), s (scale, rest 1), o (0–1, rest 1), cx, cy (clipping from the right/bottom in %). Recipes are resolved on the server to keyframes scaled by strength and screen-size factors (data-mg-fx). The runtime combines effects on one element: movement and rotation add up, scale and opacity multiply, clipping takes the maximum. pointer and hover recipes have axes; pointer recipes can have a touch recipe used on touch screens. reveal recipes split the text into words or letters.
Dynamic data sources #
add_action( 'midground/register_dynamic_sources', function () {
midground_register_dynamic_source( 'site.year', array(
'label' => __( 'Current year', 'my-textdomain' ),
'group' => 'site',
'type' => 'text', // text|html|url|media|number
'resolve' => fn( $args, $ctx ) => wp_date( 'Y' ),
) );
} );
A binding is stored as { "dyn": "site.year", "args": {}, "fallback": "…" } in a field that allows it.
Forms, booking, consent and SEO plugins #
The supported plugins can be extended with filters:
midground/form_plugins– form plugins for the Form element (id, name, how to list forms and the shortcode to render one).midground/booking_plugins– booking plugins for the Booking element.midground/consent_plugins– cookie plugins Midground offers to work with (slug → name).midground/seo_plugin– which SEO plugin (if any) takes over the Search and sharing tab.
The editor #
After start-up the editor sends the event midground:editor-ready with window.MidgroundEditor. Register your own content controls as React components (through wp.element):
document.addEventListener( 'midground:editor-ready', ( e ) => {
e.detail.contentExtensions[ 'my-notice' ] = ( { node } ) =>
wp.element.createElement( 'p', null, 'Characters: ' + ( node.props.text || '' ).length );
} );
window.MidgroundEditor.store (get/set/subscribe/change) and window.MidgroundEditor.actions (setProp, addElement, select …) are available to extensions; startTour() and showShortcuts() open the tour and the shortcut list. The runtime on the site sends midground:ready with window.Midground (engine, models, refresh( scope )).
Hooks and filters #
| Hook | Type | Use |
|---|---|---|
midground/booted | action | The plugin has started |
midground/register_elements / _recipes / _dynamic_sources | action | Register extensions |
midground/document_saved | action | ( $post_id, $doc ) after a save |
midground/render_context | action | Adjust the render context |
midground/rest_routes | action | Add REST routes under midground/v1 |
midground/upgraded | action | ( $previous_version ) after an update |
midground/site_part_replaced | action | ( 'header' | 'footer' ) retire the untouched default part |
midground/post_types | filter | Post types that can be built with Midground |
midground/migrations | filter | Schema migrations |
midground/model_hosts | filter | Hosts allowed for external 3D models |
midground/template_for_location | filter | Override the chosen template per location |
midground/query_args | filter | Adjust queries in post and product lists |
midground/editor_config | filter | The configuration the editor receives |
midground/always_enqueue_styles | filter | Load Midground CSS on every page |
midground/inject_header_in_other_themes | filter | Header/footer templates in other themes |
midground/create_default_parts | filter | Create a header and footer on first run (default true) |
midground/font_collection | filter | The font collection behind Add fonts (default google-fonts) |
midground/woo_scripts_needed | filter | Whether a request needs WooCommerce's scripts |
midground/license_provider | filter | The licence provider object (see section 9) |
midground/guide_url | filter | Where the editor's Help menu opens the user guide |
midground/form_plugins, /booking_plugins, /consent_plugins, /seo_plugin | filter | See above |
midground_render_location | filter | For themes: ( false, 'header' ) → true when Midground rendered it |
midground_page_layout | filter (theme) | Page layout in the Midground theme |
PHP functions: midground_register_element(), midground_register_recipe(), midground_register_dynamic_source(), midground_is_builder_post(), midground_render_post(), midground_location().
6. REST API (/wp-json/midground/v1) #
All routes need a logged-in user and a nonce (X-WP-Nonce), and check capabilities per post.
| Route | Method | Capability |
|---|---|---|
/documents/{id} | GET, PUT | edit_post (publishing needs publish_posts, otherwise "pending") |
/documents/{id}/autosave | POST, DELETE | edit_post |
/documents/{id}/lock | POST | edit_post |
/documents/{id}/revisions, /revisions/{rev} | GET | edit_post |
/render | POST | edit_post for the owner |
/globals | GET / PUT | edit_posts / edit_theme_options |
/templates/{id} | GET, PUT | edit_theme_options |
/site-parts/{type} | POST | edit_theme_options (create a header or footer) |
/components, /components/{id} | GET, POST | edit_posts / edit_pages |
/library | GET | edit_posts |
/import/{id}/preview, /import/{id}/restore | POST | edit_post |
/fonts/catalog, /fonts, /fonts/{id} | GET, POST, DELETE | edit_theme_options |
/geocode | GET | edit_posts (address search through OpenStreetMap Nominatim, cached) |
/integrations | GET | edit_posts (forms and booking plugins found) |
/ai | POST | edit_posts, the assistant turned on and a key set |
/tour | POST | edit_posts (remember that the tour was seen) |
Requests over 4 MB are refused (413). Saving while another user holds the lock gives 423.
7. WP-CLI #
wp midground status # what Midground manages; warns about edits made elsewhere
wp midground refresh [<id>...] # render static copies and CSS again (no revisions)
wp midground-demo list # starter sites and whether they are imported
wp midground-demo import [--site=<slug>] [--parts=site,templates,design,fonts,shop]
wp midground-demo remove [--site=<slug>]
import without --site imports the Skumring showcase; remove without --site removes every starter site. The slugs are skumring, agency, restaurant, shop, local, experience and saas.
8. Build, tests and translations #
npm ci && npm run build # esbuild: editor, block editor, runtime, viewer3d (+ CSS)
npm test # node:test – style compiler, CSS values, motion
npm run test:php # tests/php inside WordPress through WP-CLI (Docker)
npm run test:e2e # Playwright: desktop Chromium, Pixel 7, Firefox and WebKit
node tools/perf.mjs # performance measurements
bash tools/i18n.sh # .pot, nb_NO .po/.mo and JSON for the editor
node tools/docs-site.mjs # the user guide as HTML (plugin docs/ and dist/docs)
python tools/package.py # ZIP files in dist/
The style compiler has shared test data in tests/fixtures/*.json that runs both in JS (tests/js) and PHP (tests/php/test-render.php) to make sure the results are identical.
Translations: strings are extracted from PHP and from the built JS files (so the JSON files get the right names for wp_set_script_translations). Norwegian translations are kept in tools/i18n/nb_NO.json (key domain|context|text); tools/i18n.sh merges them in.
The starter sites live in demo/midground-demo/starters/<slug>.php (content built with the helpers in starters/helpers.php and the builder's library/helpers.php). Their placeholder pictures are generated by tools/demo/placeholders.py, and the previews on the Starter sites screen by tools/demo/previews.sh.
9. Licences and updates #
Midground has a neutral licence layer so it can be sold through different shops. Choose the shop in wp-config.php of the site that sells, or in the build:
define( 'MIDGROUND_LICENSE_PROVIDER', 'edd' ); // or 'lemonsqueezy'
define( 'MIDGROUND_LICENSE_STORE', 'https://shop.example.com' ); // EDD only
define( 'MIDGROUND_LICENSE_ITEM', 'Midground' ); // EDD product name
Other shops can be connected with the filter midground/license_provider, returning an object that implements Midground\License\Provider (activate, deactivate, check, latest). With a valid licence, updates of the plugin and the theme are offered through WordPress's normal update screens. Without a provider, the licence box says that licences are not set up.
10. Security and privacy #
- Capabilities are checked in every REST route and admin action; nonces on every change.
- All data is sanitised when saved and escaped when printed (also for documents that never went through a save).
- No arbitrary code: no
eval, no PHP in templates, scripts only in Custom HTML for users withunfiltered_html. - Uploaded models are validated (GLB header, length, JSON chunk, glTF 2.0, no external files); size limit in the settings. External model URLs only from approved https hosts.
- API keys are stored on the server and never sent to the editor or the page (except the Google Maps Embed key, which Google requires in the map address). Review data is fetched by the server and cached.
- No calls to other services, tracking or cookies from the plugin or the theme – unless a site owner turns on a feature that needs one (Google fonts copied once, maps, reviews, AI assistant, address search in the editor). Videos and maps from other services load only after consent.