Build or edit Divi 5 pages on any WordPress site via the WP REST API — using native Divi 5 modules so pages stay editable in the Divi visual builder, with a custom-HTML "code" module fallback. Use whenever the user wants to create, build, edit, redesign, or lay out a page/section/hero/pricing/landing page on a Divi WordPress site (any site running Divi 5), set a page as the homepage, insert sections/rows/columns/modules, upload images to the media library, or "build a page from this design br...
Installs into .claude/skills of the current project.
Are you the author of divi5-builder?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/doughoseck-divi5-builder)
---
name: divi5-builder
description: >
Build or edit Divi 5 pages on any WordPress site via the WP REST API — using
native Divi 5 modules so pages stay editable in the Divi visual builder, with
a custom-HTML "code" module fallback. Use whenever the user wants to create,
build, edit, redesign, or lay out a page/section/hero/pricing/landing page on a
Divi WordPress site (any site running Divi 5), set a page as the
homepage, insert sections/rows/columns/modules, upload images to the media
library, or "build a page from this design brief". Handles credentials from
the shared .web-creds.txt securely. Trigger even if the user just says "add a
section to the site", "update the homepage", or names a site + a page change.
Also audits the media library and uploads folder of ANY WordPress site (Divi
or not): unused media, orphan files, what is taking disk space. Use for "clean
up the media library", "the site is too big", "what can we delete before the move".
Also updates plugins, themes and WordPress itself on ANY WordPress site, with page
checks before and after: use for "update the plugins", "what is out of date on
<site>", "run the updates", "update WordPress".
argument-hint: "[site] [what to build] (e.g. mysite 'a pricing section')"
allowed-tools: Bash, Read, Write, Edit
---
# Divi 5 site builder
Create and edit Divi 5 pages through the WordPress REST API. Divi 5 stores pages
as WordPress block markup (`<!-- wp:divi/* -->`), so you build a page by writing
that markup into `content` and POSTing it. This skill gives you a **spec → markup
compiler** (`scripts/divi.js`) and a **secure REST client** (`scripts/wp.js`) so
you rarely touch raw markup or credentials directly.
Default strategy (agreed with the author): **native modules first** — real
`divi/section/row/column/text/heading/button/image/blurb/icon-list` modules that
stay fully editable in the visual builder. Fall back to a `divi/code` module only
for a section whose layout the native set can't express.
And **design system first** (the recommended way to build, see the next section):
colours and presets exist BEFORE the first page, and a page carries content and
placement, not design.
## Design system first — the recommended way to build
A site built module by module ends up with the same yellow button styled fifteen
times. Changing it means fifteen edits, and moving it onto presets afterwards is a
project of its own (`references/divi5-globalize-site.md` is what that cost). Build
the other way round:
1. **Look at what the site has:** `node scripts/wp.js <site> design-system`.
2. **Create what is missing, before any page** (mu-plugin >= 1.8; on a new site run
`node scripts/ds-live-test.js <site> all` once first):
- one global colour per real colour: `wp.js <site> ds-color-set --label "Brand" --color "#112233"`
- one preset per repeating look: each button style, each section style (background
+ padding), the heading block, the card: `wp.js <site> ds-preset-set --file preset.json`
- what EVERY module of a type shares (heading case, body colour) belongs in that
type's DEFAULT preset, not in a named one.
3. **Reference them in the page spec.** Any section, row, column or module takes
`"modulePreset":"<id>"`:
```json
{"sections":[{"modulePreset":"sectionidaa","rows":[{"columns":[{"modules":[
{"type":"text","html":"<h1>Title</h1>","modulePreset":["textdefault","titleidaaa"]},
{"type":"button","text":"Book now","url":"https://…","modulePreset":"buttonidaa"}
]}]}]}]}
```
A module on a preset carries **content and placement only** (text, url, image,
margin, alignment). No colours, no fonts, no padding next to a preset.
4. **Need a look that no preset has?** Used once: set it on the module, with global
colours. Used twice: make a preset. Never copy a preset's values onto a module.
**Rules that are easy to get wrong** (each one was learnt from a failure):
- **One option group, one owner.** Never give a module a setting from a group its
preset already fills (`sizing`, `spacing`, `border`, a font group…). Divi writes the
module's CSS and the preset's CSS separately, so settings that only work together
(max-width + centring) stop working, and a value identical to the preset's is thrown
away at render. `layout` and `sizing` count as one group.
- **Stacking** `["<default id>","<id>"]` keeps what the type's default preset gives
(needed as soon as that default holds settings, because a module with its own preset
no longer gets the default). It is only safe when the two presets set DIFFERENT
things. When both set the same setting, do not stack: the last CSS rule written wins,
and that differs between a published page and a draft preview.
- **A preset id that does not exist fails silently.** Take ids from `design-system`.
- **A preset is made in a context.** A preset taken from a module inside a block-layout
column (every site converted from Divi 4) behaves differently inside a flex column
(what this compiler builds): a centred max-width block shrinks to its content. Make
presets for new pages from modules built the new way, and look at the result.
- **Proportion.** A small edit to an existing page uses what the site has. Do not
introduce presets for a one-off, and do not restyle a site that was not asked about.
`node scripts/preset-spec-test.js` tests the compiler side of this, no site needed.
## Styling rules — NON-NEGOTIABLE
These come straight from how the author builds; follow them exactly. The point of Divi is
that everything is editable in the builder and re-themeable from one place — inline
CSS and hard-coded per-module values destroy that.
1. **NEVER use inline CSS.** No `style="..."` attributes in any module's HTML
content — ever. It's the hardest thing to find and edit and it defeats the
builder. Module content holds clean **semantic HTML only** (`<h1>`, `<h2>`,
`<p>`, `<ul>`…). Style through **module settings** (the `decoration.*` objects
the compiler emits), never through markup. `scripts/divi.js` enforces this — do
not hand-write blocks that reintroduce inline styles. (The one exception is a
`code` module, which is intentionally raw HTML/CSS; keep those rare.)
2. **Type & colour come from GLOBAL variables/presets, not literals — and DON'T
set what you don't need to.** Reference global colours by gcid name
(`{"color":"gcid-heading-color"}`, `bg:"gcid-secondary-color"`) and global font
families by name (`"Outfit"`). Discover gcids with `wp.js global-colors`.
**Critical:** only set a typography prop when it must DEVIATE from the module's
default/preset. Assigning a value — *even one equal to the global default* —
pins it on the module, so when the global/preset changes later, that module
**won't update**. So a body paragraph that should just inherit gets **no**
`family`/`size`/`color` at all (omit them); a heading that only needs a
different weight+colour sets *just* those two, not `family`/`size`. Lean spec =
fewer overrides = the site stays re-themeable from one place. That's the goal.
3. **Layout: fractional flex for rows, CSS Grid for grids** (verified 5.9, updated).
- **Simple content rows → fractional flex (the compiler default).** Columns get
a per-breakpoint `flexType` (1/3 desktop → 1/2 tablet → full phone), ET's
documented native method. Just `{"layout":"1-1"|"1-1-1"|"1-1-1-1", …}` — the
compiler sets the fractions + wrap breakpoints. (`mode:"flexwrap"` = the old
100%-columns+wrap, for dynamic-count reflow.)
- **Card / pricing / gallery / looped grids → CSS Grid.** Put `grid` on the row:
`{"grid":{"cols":{"desktop":3,"tablet":2,"phone":1},"spans":[{"target":"first-child","span":2}]}, "columns":[…]}`. Child-agnostic (add/remove/loop freely).
⚠ a `span` rule does NOT reset on mobile — a spanned item won't collapse to 1
column on phone; avoid spans if you need clean mobile stacking.
- Still never hand-write `1/2`/`1/3` or inline widths — use these spec fields.
4. **Reach for the right native module.** `blurb` for icon+title+text cards
(icon chip via the blurb's Custom-CSS field, not inline), `button` styled via
its settings + global colours, `accordion` for FAQs (never a code module),
`divider` for spacing. A common trick: an invisible `divider` (line off, fixed
height) holds a column's height so a **column background image** can `cover`
the space — set `bgImage` on the column instead of using an image module.
**Cards = a styled column**, not a wrapper div: set `background`/`padding`/
`radius`/`border` on the column, and `hover` (`{borderColor, shadow:{blur}}`)
for a hover lift — hover states serialize under a `hover` key beside `desktop`.
A history, roadmap or "our story" page = the native `timeline` module, whose items can hold other modules
(tags, buttons): `references/divi5-modules-verified.md`, "Timeline module".
5. **Wrap by width, never by typed line breaks.** To make a heading break in a chosen place, cap the module's
max-width (in its preset) so the text wraps by itself; a typed `<br>` forces the same split on a phone, where it
is wrong. Check the break at desktop, tablet and phone widths.
6. **A design mock-up is a map, not the style.** When the user supplies an HTML or image design for a site that
already has its look, take the layout and the content from the design and the colours, fonts, sizes and heading
case from the site's presets. Where the design deviates (normal-case headings, another weight, another colour),
follow the site and say so; let the user ask for the exceptions. Colour part of a line with a utility class
(`references/divi5-presets-variables.md`, "More preset facts"), never an inline style.
7. **Never put an em dash (—) into site content.** In dates, ranges and running text use a plain hyphen
("28 - 30 May 2027", "2007 - 2027"). This holds for content you write and for content you take over from a design
or a brief: replace the em dashes before the page is written. Clients read an em dash as a typing mistake.
8. **A hero heading that can wrap needs a line height near 1.15.** Converted sites often carry 2em on hero titles and
subheadings; it is invisible on one line and looks double-spaced on two. Check with a long title. If the theme
forces a line height on every heading with `!important`, the module setting loses: override it for heroes in the
site CSS (`.et_pb_fullwidth_header .header-content h1 { line-height: 1.15em !important; }`) and set the same value
in each hero so the builder shows what the page shows.
## Prerequisites — required before you can do anything
Do not start building until **all three** are true for the target site. Confirm
them first (step 2 below checks them); if any is missing, stop and tell the user
exactly which — building without them produces a page that can't be written,
renders broken, or is in the wrong format entirely.
0. **The site must be on Divi 5.** This skill emits Divi 5 **block** markup
(`wp:divi/*`); **Divi 4 uses `[et_pb_*]` shortcodes — a totally incompatible
format**, so building on a Divi 4 site produces garbage. Check with
`node scripts/wp.js <site> divi-check` → must report `verdict:"divi5"`. If it
says `divi4`, STOP — the site needs upgrading to Divi 5 first.
1. **Access details in `~/.web-creds.txt`** — a `[site]` section with
`url`/`user`/`pass` (a WordPress **Application Password**). No creds → no API
access at all. `node scripts/wp.js <site> whoami` must return 200 + your name.
2. **The `divi5-builder-rest.php` mu-plugin installed & active on that site**
(bundled in `assets/`; drop it in `wp-content/mu-plugins/`). This is what makes
a page actually render as Divi — without it you can POST block content but you
**cannot** set the `_et_pb_use_builder` flag (protected meta; core REST drops
it and Divi's own routes reject app-password auth with `invalid_nonce`), so the
page stays a non-Divi sidebar page. Confirm with
`node scripts/wp.js <site> whoami` **and** a probe write: if `set-builder`
later reports the meta "did NOT stick", the plugin isn't active — installing it
is a one-time step per site. **A new site needs BOTH set up before first use.**
## Credentials — handle exactly like this
Site credentials live in `~/.web-creds.txt` (INI: `[site]` sections
with `url`/`user`/`pass`; `pass` is a WordPress **Application Password**).
- **Never** print, echo, log, or hardcode the password — not in chat, not in a
script, not in a tool argument, not in output. `scripts/wp.js` reads the file
at runtime, builds the auth header in memory, and only ever prints results.
Always go through it; never read the raw pass yourself into the transcript.
- Site name is the section header (`mysite`, `staging`, `anothersite`). Confirm
which site if ambiguous.
## Workflow
1. **Identify the site and intent.** Which `[site]`? New page, or edit an
existing one? Get the design brief (or the site's own design skill, if there
is one, for colours, fonts and voice).
2. **Check BOTH prerequisites + detect the site's format (do this first):**
```bash
node scripts/wp.js <site> whoami # prereq 1: 200 + your name = creds OK
node scripts/wp.js <site> divi-check # prereq 0: verdict:"divi5" (STOP if "divi4")
node scripts/wp.js <site> check-plugin # prereq 2: pluginActive:true = Divi mode possible
node scripts/wp.js <site> list-pages # find target page ids/status
node scripts/wp.js <site> builder-version # use this builderVersion in the spec
node scripts/wp.js <site> global-colors # discover gcid global-color IDs
```
If `whoami` fails → creds missing/wrong (see Prerequisites). If `check-plugin`
reports `pluginActive:false` → stop and have the user install
`assets/divi5-builder-rest.php`; you can build block content but the page won't
render as Divi until it's active.
3. **Set up the design system first** (see "Design system first" above):
`node scripts/wp.js <site> design-system` shows the colours and presets the site
has. For a new build, create the global colours and the presets the design needs
BEFORE writing a page, then reference them. For an edit, use what exists: if a
gcid clearly matches the role you need, reference it via `{"colorVar":"gcid-..."}`.
Only when the site has no matching global and you may not add one, emit the literal
hex from the brief with `{"color":"#RRGGBB"}`. When unsure of a gcid's actual hue,
read it from `design-system` — a wrong global is worse than a right literal.
4. **Author a page spec** (compact JSON) rather than raw block markup. The spec
shape and every module type are documented in the header of `scripts/divi.js`.
Quick reference:
- Sections carry `background` (`color` / `colorVar` / `image`+`gradientOverlay`)
and `padding`. Rows carry `layout` (`"1"`,`"1-1"`,`"1-1-1"`,`"1-1-1-1"`) and
`columns`. Columns carry `modules`.
- Modules: `heading`, `text` (HTML), `button` (`text`+`url`), `image`
(`src`+`alt`+`id`), `blurb` (`title`+`html`+`icon`), `iconlist`
(`items`+`icon`), `code` (raw HTML/CSS fallback), and `raw` (escape hatch to
emit any `divi/*` block with hand-written attrs).
- Set `builderVersion` to what step 2 reported.
- For anything native can't do, use a `code` module — it's a first-class Divi
module, not a hack, and renders reliably.
5. **Compile and preview locally:**
```bash
node scripts/divi.js compile <spec.json> --out content.html
```
6. **Write to the site.** Default to a **draft** first when building something new,
so you can verify before it's public:
```bash
node scripts/wp.js <site> create-page --title "..." --content-file content.html --status draft
# editing an existing page instead:
node scripts/wp.js <site> update-page <id> --content-file content.html
```
Publishing, or changing an existing live page, is a visible change — get the
user's OK before flipping a page to `publish` or editing a live page.
7. **Flip the page into "Divi mode" (essential — do this for every page you build).**
Writing Divi blocks into `content` is NOT enough on its own: a page only
renders as Divi (full-width, no theme sidebar, module CSS enqueued, "Divi"
badge in the Pages list) when it carries the meta `_et_pb_use_builder = on`.
Without it the theme wraps the page in its default sidebar template and the
modules look unstyled/broken.
```bash
node scripts/wp.js <site> set-builder <id> # layout defaults to no-sidebar
node scripts/wp.js <site> set-builder <id> --layout et_full_width_page
```
**Prerequisite (one-time per site):** these are protected `_et_*` meta that
core REST won't write and Divi's own REST routes gate behind a cookie-session
nonce (an Application Password can't satisfy it). So the site must have the
bundled `assets/divi5-builder-rest.php` installed (drop it in
`wp-content/mu-plugins/`), which exposes those meta keys to REST behind an
edit-capability check. If `set-builder` reports the meta "did NOT stick", the
mu-plugin isn't installed — point the user at `assets/divi5-builder-rest.php`.
Verify what's set with `node scripts/wp.js <site> page-meta <id>`.
8. **Verify it renders (don't just trust the POST).** Confirm the blocks
round-tripped and Divi rendered them as native modules:
```bash
node scripts/wp.js <site> get-page <id> --raw # blocks preserved?
node scripts/wp.js <site> rendered <id> # real Divi HTML, no leaked <!-- wp:divi comments
```
Rendered output should contain the actual text/module wrapper classes
(`et_pb_section` etc.) and none of the literal block comments. If block
comments leak into the rendered HTML, the markup was malformed — check escaping
and nesting against `references/divi5-format.md`.
9. **Homepage (only if asked).** `node scripts/wp.js <site> set-homepage <id>`
sets `show_on_front=page`. This changes the live front page — confirm first.
`reset-homefront` reverts to the blog index.
10. **Report** the page id + link and what you built. Offer next edits.
## Images
Reuse a media URL already in the library when you can. To add a new one:
```bash
node scripts/wp.js <site> upload-media <path/to/img.png> --alt "description"
```
It prints `{id, url}` — put the `url` in an image module's `src` and the `id` in
`id` (Divi likes both). Get the user's OK before uploading their files anywhere.
## Popups (Divi 5 Canvas + Interaction)
> Note: for video/lightbox popups the author usually prefers a dedicated popup **plugin**
> — don't default to building one. The Canvas approach below is a proven,
> plugin-free capability; offer it, but only build it when the user actually wants
> a native Divi popup.
>
> **Self-hosted (.mp4) video popup with autoplay-on-open:** neither the Canvas nor a
> YouTube-only popup plugin can do this — a self-hosted clip needs a native
> `<video>` + a `video.play()` call fired from the user's click (a user gesture,
> so it plays *with sound*; page-load autoplay would be blocked). Use the ready
> reusable snippet **`assets/video-popup-snippet.html`** (a WPCode "HTML Snippet",
> Site-Wide Footer). It's generic: any element with `data-tsm-video="<mp4 url>"`
> (optional `data-tsm-poster`) opens+autoplays it; on a Divi 5 button set that via
> Advanced ▸ Attributes and link the button to `#`.
A Divi 5 popup is a separate **Canvas** (an `et_pb_canvas` post) triggered by an
**Interaction** on a button — fully reproducible over REST. Pattern:
1. **Compile the popup canvas** — a full-screen hidden overlay section:
```bash
node scripts/divi.js compile-canvas <popup-spec.json> --out popup.html
```
`popup-spec.json` = `{ "targetId":"tourpop", "overlayColor":"rgba(8,32,20,.92)",
"width":"min(1000px,90%)", "modules":[ {"type":"video","src":"...mp4","frame":{...}},
{"type":"icon","unicode":"Q","iconType":"divi","absolute":true} ] }`.
The compiler makes the section hidden-by-default (`disabledOn` all breakpoints),
`position:fixed`, `100vh`, `zIndex 9999999`, overlay bg, `interactionTarget`
= targetId, and click-overlay-to-close.
2. **Create the canvas post:** `node scripts/wp.js <site> create-canvas --title "..." --content-file popup.html`
3. **Add a trigger** on the page: a `button` with `"popup":"<targetId>"`, or ANY module
with `"interactions":[{"trigger":"click","effect":"toggleVisibility","target":"<targetId>"}]`.
On Divi 5.13 every module renders as a trigger (proven live with a `divi/text`;
the trigger attribute comes from the generic module wrapper). On Divi 5.9 only
buttons did, so on an older site use a button or test first. Full list of the 8
triggers and 14 effects: `references/divi5-interactions-canvases.md`.
4. **Link the canvas to the page** (essential — without it Divi won't append the
canvas): `node scripts/wp.js <site> link-canvas <canvas_id> <page_id>`. This
writes the meta the builder uses (`_divi_canvas_parent_post_id` on the canvas +
`_divi_off_canvas_data` on the page). Requires the mu-plugin ≥ 1.2.
5. **Verify:** `rendered <page_id>` should now contain the canvas content (e.g. the
video `src`) appended, plus `et-interaction-target-<targetId>` and the button's
`data-interaction-trigger`.
The link is by meta, not post_parent. One popup per page via `link-canvas` as
written (single `_divi_off_canvas_data` pointer). Full serialization in
`references/divi5-format.md`.
Working on a canvas afterwards: `wp.js list-canvases`, `get-canvas <id> [--raw | --out f]`,
`update-canvas <id> --content-file f`. A canvas can belong to a Theme Builder header,
footer or body layout exactly as it belongs to a page (same two meta keys, the parent
id is the layout's id), and it only reaches the front end when something in the layout
being rendered targets an element inside it. A canvas on a header is on every page
that header serves: get a nod before writing to it.
## Speed check before you rely on interactions (and the profiler)
Interactions are the native way, but on one converted Divi 4 site ANY interaction took every page from ~5s to 14-17s,
because of a per-block cost in Divi 5.13's own front-end block parser (details and the measurements:
`references/divi5-interactions-canvases.md`). Another site showed nothing of the kind. So: time a page (3 loads, use the
3rd) before the first interaction and again after. If it jumps, build popups and toggles on that site as native hidden
sections driven by a few lines of script in a code module inside them (pattern in the same reference), not with interactions.
When a Divi/WordPress page is slow and you do not know why, do not theorise, measure:
```bash
node scripts/hook-profiler.js gen <dir> # writes d5b-profiler.php + a random key
php scripts/hook-profiler-test.php <dir> # 9 local checks, no WordPress needed
# the USER uploads d5b-profiler.php to wp-content/mu-plugins/ (you cannot), then:
node scripts/hook-profiler.js run <page-url> --key-file <dir>/d5b-profiler.key
```
It is read-only, does nothing without the key, and reports the slowest hook callbacks, who is attached to Divi's per-block
parser hooks, raw CPU benchmarks, and WordPress core's parser versus the site's `parse_blocks()` on the page's real content.
Have the user DELETE the file afterwards. Query Monitor first is a good split: if its "Database Queries" total is small and
"HTTP API Calls" is none, the time is PHP, and this profiler is the next step.
## Visibility toggles (monthly/annual pricing, "show more", etc.)
Do these **natively with Interactions**, never with hand-rolled JS. Pattern (from
the author's pricing toggle): split the content into **state-A** and **state-B** element
sets; give every toggle-able element a unique `interactionTarget` id; start
state-B elements `disabledOn` all breakpoints (hidden). Provide **two small
control buttons** ("show monthly" / "show annual") — one per state, the inactive
one also `disabledOn`. Each control button carries an `interactions` array with a
`click → toggleVisibility` effect for **every** target class in BOTH sets (incl.
the two controls themselves), so one click flips the whole group. The real CTA
buttons are separate (one per state, with the matching link, e.g.
`?cycle=monthly` vs `?cycle=annual`) and toggle too. `module.decoration`
carries `interactionTarget`, `interactionTrigger`, and `interactions.desktop.value.
interactions[]`; see `references/divi5-interactions-canvases.md` for the exact shape,
every trigger and effect, and how canvases reach the front end. (On Divi 5.13 any
module can be the trigger; on 5.9 only buttons rendered one.)
**Compiler support (use these props — no hand-wiring):**
- Any module: `"toggleId":"<id>"` makes it a toggle **target**; `"hidden":true`
starts it hidden (`disabledOn` all breakpoints) — the "state B" elements.
- A `button`: `"toggles":["idA","idB",…]` (+ optional `"trigger":"<id>"`) makes it a
**control** that flips every listed target on click. Give both control buttons
the **same full target list** (include both controls' own ids so they swap too).
- Cards: style the **column** — `background`/`padding`/`radius`/`border` +
`"hover":{"borderColor":"gcid-…","shadow":{"blur":"18px"}}` for a hover lift.
- **Anti-jump (important):** when you toggle two stacked elements (e.g. the two
CTA buttons, or the two control buttons), put them in their **own nested
row/column with `rowGap:"0px"`** so hiding one doesn't shift heights. In a spec,
nest with a `row` module: `{"type":"row","layout":"1","columns":[{"rowGap":"0px",
"modules":[btnMonthly, btnAnnual]}]}`. Give the control-buttons column `rowGap:"0px"`
too. (A column has one rowGap for all children, so isolate the zero-gap pair.)
A 9-tier pricing toggle was generated this way: each card has
monthly/annual price `text` + a nested `rowGap:0` row holding monthly/annual CTA
`button`s (annual ones `hidden`), each with a unique `toggleId`; two control
buttons carry `toggles` = all 38 ids.
## Prefer native modules — code/HTML is the LAST resort
Build with native Divi modules **wherever you can**, and lean hard toward native
before reaching for a `code` module. Almost everything that looks like it "needs
HTML" doesn't: a pricing table is `blurb`/`text`+`button` cards in flex columns; a
feature grid is blurbs; an FAQ is a native `accordion`; tabs/toggles are the
Toggle/Tabs modules; icon rows are `icon-list`. Pure HTML in a `code` module is
un-editable in the builder, un-themeable from presets, and brittle — treat it as a
genuine last resort for something with no native equivalent (a bespoke SVG
animation, a third-party embed). When you do use one, keep it small and isolated.
**Pitfall (learned the hard way):** never split interdependent JS/CSS across
separate `code` modules. A pricing monthly/annual toggle's `<script>` was bundled
into a *different* section's code module; when that section was later rebuilt
natively, the code module — and the script — vanished, silently breaking the
toggle. Any `<script>`/`<style>` must live in the **same** `code` module as the
markup it drives. Better still: do it natively so there's no loose JS to lose.
(And interactive toggles are exactly the kind of thing to keep native or in a
dedicated plugin rather than hand-rolled JS.)
## Module inventory & data-driven (verified 5.9)
The compiler emits these module `type`s (all verified from live builds; keys in
`references/divi5-modules-verified.md`):
- **Content:** heading, text, button, image, blurb, icon, iconlist, code, video, divider.
- **Native compound:** accordion, tabs, toggle, cta, testimonial, pricing (pricing-tables),
counters (bar), circle-counter, number-counter, countdown, heading-module.
- **Batch 2:** hero (`fullwidth-header`, `fullHeight:true` = fullscreen), slider (+slides),
person (`team-member`; image uses `url`), map (+pins), tooltip, timeline (+items),
post-carousel (`fullwidth-portfolio`), group + group-carousel.
- **Layout:** section/row/column; row `mode` fractional (default) / flexwrap; row `grid`.
- **Compound modules are CONTAINERS.** `hero` takes `modules:[…]` and nests them
inside the header, rendering below its own title/content — that is how you put
a linked image or an extra line of copy in a hero (verified live). The tell is
the serialization: a module written with a plain `-->` opener plus a
`<!-- /wp:divi/… -->` closer accepts children; a `/-->` self-close does not.
`dump-blocks` before assuming any module is a leaf. To drop a built-in button,
pass `button1:{text:'',url:''}` — emptying is what the builder itself does.
⚠ `builder-version` reads the FIRST EXISTING BLOCK's stamp, not the installed
Divi, so it goes stale after an upgrade. Both detailed in the format reference.
- **Third-party:** `filtergrid` (Divi Plugins `dp-dfg/filtergrid` — needs their plugin;
the author has the lifetime license). Query-driven CPT grid with built-in content/video
popups + skins; verified props + a raw `settings:{}` passthrough for any option.
**Data-driven (Loop Builder + dynamic content):**
- Put `loop:{ postTypes:["belt"], orderBy, order, postPerPage }` on a **column** or a
**group** → it repeats per queried post.
- Bind fields with the `dc()` helper in any content value: `dc('loop_post_title')`,
`dc('loop_post_featured_image',{thumbnail_size:'large'})` (as image `src`),
`dc('loop_post_excerpt')`, and **custom fields**
`dc('loop_post_meta_key_manual_custom_field',{select_loop_meta_key:'loop_post_meta_key_<key>'})`.
- Common pattern: a `group-carousel` whose `group` has a `loop` + `dc()`-bound modules
(looped CPT cards), or a grid column with `loop`.
**Adding any other module:** build it once in the VB on the scratch page,
run `node scripts/wp.js <site> dump-blocks <id>` to read its exact keys, then add a
builder — 2-minute loop. Don't guess; dump.
## Every Divi module: the catalogue (`scripts/catalog.js`)
The compiler has hand-written builders for the common modules. For everything else
(all 115 modules in Divi 5.13.1, WooCommerce ones included) there is a catalogue
GENERATED FROM THE USER'S OWN COPY OF DIVI: the `module.json` definitions the Visual
Builder itself is built from, plus each module's default attributes and Divi's
Divi 4 → 5 attribute map.
```bash
node scripts/catalog.js build "<path to unzipped Divi theme folder>" # once per Divi version
node scripts/catalog.js list [filter] # every module, its kind, its children / parents
node scripts/catalog.js show <module> # elements, fields, allowed option values, defaults
node scripts/catalog.js find <text> # which modules have a field or option like this
node scripts/catalog.js lint <content.html> # check block markup BEFORE writing it to a site
```
- **No catalogue yet?** Ask the user to download Divi from their Elegant Themes account
and unzip it anywhere; point `build` at the folder holding `style.css`. The catalogue
is derived from Divi (GPL), so it is generated locally and git-ignored, never shipped.
- **Workflow for a module the compiler lacks:** `show <module>` → write it as a `raw`
module (`{"type":"raw","block":"divi/xxx","attrs":{…},"inner":"<child blocks>"}`) →
`lint` → write to a draft → look at it. `show` tells you whether it is a container
and which child block it takes.
- **`lint` errors are hard facts:** unknown module, unknown element, a value that is not
one of a field's options, a child module outside its parent, a foreign module inside a
parent/child container, invalid JSON, unbalanced blocks. It raised ZERO false errors
on 3,333 builder-written blocks across two sites (`--strict` adds soft hints).
- **What it cannot tell you:** the VALUE SHAPE of the shared groups (font, spacing,
background, border, sizing, position…). Those are the same on every module and are in
`references/divi5-format.md`; when unsure, dump a builder-made example.
- **It knows less than Divi accepts.** `module.json` does not declare everything (e.g.
`divi/video` has a real `thumbnail` element it never lists). The catalogue also learns
elements from Divi's conversion map; anything still unknown to it may yet be valid, so
an "unknown element" on a block the BUILDER wrote is a catalogue gap, not a page bug.
- `node scripts/catalog-test.js` plants seven kinds of mistake and fails unless each is
caught and the clean original passes. Run it after touching `catalog.js`.
## Presets and design variables (read `references/divi5-presets-variables.md`)
Styling rule 2 says type and colour come from globals. This is how to find and use them:
- **See what the site has:** `node scripts/wp.js <site> design-system` (mu-plugin >= 1.6,
read-only): every module preset, option group preset, variable and global colour, with
ids. Divi itself offers no way to read these over REST.
- **Use a module preset:** `"modulePreset":["<id>"]` on the block. Omit it (or
`["default"]`) to get the site's default preset for that module. Never copy a preset's
attrs onto the block "to be safe": block attrs override every preset.
- **Use an option group preset:** `"groupPreset":{"<slot>":{"presetId":["<id>"],"groupName":"divi/font"}}`.
The slot is either an attribute path (`button`, `title.decoration.font`) or a composite
id (`designTitleText`); both occur in builder-saved content. `groupName` must be the
preset's own. `catalog.js show <module>` lists the module's elements to pick from.
- **A preset id that does not exist fails SILENTLY** (no error, no styling). Always take
ids from `design-system`, never from another site or from memory.
- **Variables** are `$variable({"type":"…","value":{"name":"gcid-…|gvid-…","settings":{}}})$`.
Colours take `settings` `hue`, `saturation`, `lightness`, `opacity`. Five colours and two
fonts always exist (`gcid-primary-color` … `--et_global_body_font`).
- **Creating or changing them (mu-plugin >= 1.8):** Divi's own save routes replace the WHOLE
store and want a cookie nonce, so never call those. The plugin writes ONE item per call
through Divi's own save functions, add-only, with the previous store kept for restore:
```bash
node scripts/wp.js <site> ds-selftest # FIRST on every site: must say "different: 0"
# (module presets; option group presets too from 1.8.2)
node scripts/wp.js <site> ds-color-set --label "Brand" --color "#112233" [--dry-run]
node scripts/wp.js <site> ds-variable-set --type numbers --label "Gap" --value 20px [--dry-run]
node scripts/wp.js <site> ds-preset-set --file preset.json [--dry-run] [--summary]
node scripts/wp.js <site> ds-backups ; node scripts/wp.js <site> ds-restore --store presets --index 0
```
Same name or label updates, so a script can run twice. `node scripts/ds-live-test.js <site> all`
proves colours, variables, presets and restore on a site and leaves its stores exactly as
they were: run it once per new site (staging, nobody editing). Option group presets
(`"kind":"group"`) are proven for one group (Border on a Text module, against a builder-made
one). For another group, try one module and look at it first. A group preset holds its own
group's settings only; the plugin leaves out the rest and reports it. On a site with
mu-plugin < 1.8, ask the user to make the preset in the builder and read its id.
## Globalising an existing site (read `references/divi5-globalize-site.md`)
Moving a site with hard-coded values onto global colours and presets WITHOUT a visible
change is a process, not a command. The reference has the order, the rules and what went
wrong the first time. `scripts/globalize.js` runs each step, dry run by default:
```bash
node scripts/globalize.js <site> inventory # what is pinned, how often
node scripts/globalize.js <site> colors --map map.json [--write] # literals -> global colours
node scripts/globalize.js <site> signatures # preset candidates + examples
node scripts/globalize.js <site> presets-build --defs defs.json # specs + which modules fit
node scripts/globalize.js <site> presets-create [--write]
node scripts/globalize.js <site> presets-assign [--write]
node scripts/globalize.js <site> restore --step presets-assign [--write]
```
Three rules carry most of the weight: assign a preset STACKED on the module type's default
preset when that default holds settings the preset does not set itself; never SPLIT an option group between a preset and a
module (Divi renders their CSS separately, and duplicates on the module are stripped at
render); and prove every step with `assets/snapshot-instrument.js` (before, control,
change, no-op CSS save, warm, diff), because a bulk write that "worked" says nothing about
what a visitor sees.
## Theme Builder: headers, footers, body layouts (mu-plugin >= 1.5)
Core REST does not expose Divi's Theme Builder post types (`et_template`,
`et_header_layout`, `et_body_layout`, `et_footer_layout`), so these commands go
through the mu-plugin. Check it first: `node scripts/wp.js <site> plugin-version`
must report 1.5.0 or later, otherwise the user re-uploads
`assets/divi5-builder-rest.php`.
| Command | Does |
|---|---|
| `tb-list` | Every template and layout: which layouts each template uses, its display conditions, and each layout's FORMAT (`divi5`, `divi4`, `divi5+legacy-shortcodes`, `empty`). `--json` for the full dump. |
| `tb-get <id> --out <file>` | Saves the layout's raw, unrendered content and prints its `hash`. |
| `tb-set <id> --content-file <f> --expect-hash <hash>` | Overwrites the layout. `--dry-run` runs every check without writing. `--mark-divi5` also sets the Divi 5 builder flags. |
| `tb-restore <id>` | Swaps the layout back to what the last `tb-set` replaced. Undoable by running it again. |
**A header or footer is on every page of the site. Treat `tb-set` as a live,
site-wide publish and get a nod from the user first, every time.** The route is
deliberately fussy, and these refusals are working as intended, not bugs to route
around:
- `stale` (409): the layout changed since your `tb-get`, usually because the user
edited it in the Visual Builder. Read it again and redo the change on top of
theirs. Never fetch a fresh hash just to force your old content through.
- `unbalanced_blocks`: the opening and closing block comments do not pair up. The
usual cause is a missing `<!-- /wp:divi/placeholder -->` at the very end (every
stored page has one; `divi.js` before 2026-09-21 left it off), then a cut-off
file. It is a count, not a parse: it does not prove the content is otherwise bad.
- `not_divi` / `empty_content`: the file holds no Divi blocks or shortcodes, or nothing.
Almost always the wrong file.
- `needs_unfiltered_html`: this WordPress user cannot save block JSON intact
(non-super-admin on multisite). Needs a different user, not a workaround.
- `roundtrip_failed`: what got stored does not MEAN what you sent (different blocks,
order, nesting, attributes or text), and the plugin already put the old content back.
From 1.5.1 a save that only re-serialises (`\"` to `"` and so on, see "What a
save does to your content" in the format reference) is accepted and reported as
`reserialised_on_save: true`; plugin 1.5.0 rejected those too. `new_hash` is the hash
of what is STORED, so use it, not the md5 of your file, as the next `--expect-hash`.
**Tests that keep the compiler and the guard honest** (run after touching either):
- `node scripts/roundtrip-test.js <site> <throwaway_draft_id>`: compiles a spec full of
quotes, backslashes, dashes, tags and non-ASCII, writes it, and fails unless the site
stores it byte for byte. Passed on Divi 5.9 and 5.13. Run once on any newer Divi.
- `php scripts/tree-guard-test.php <wp-includes dir> <sent.html> <stored.html>`: runs the
plugin's own comparison code locally. The pair must match and eight kinds of deliberate
damage must each be caught.
Always finish by loading a front-end page that uses the layout and checking it
rendered (`rendered <page_id>`, or the browser). Layout content is the same block
markup as a page, so `divi.js` output and `dump-blocks`-style reading both apply.
Scope: editing EXISTING layouts only. Creating templates, or changing which pages a
template applies to, means writing `et_template` meta and the master
`et_theme_builder` post's template list. That is not implemented.
Why the plugin calls `wp_slash()` before saving, in case you ever touch that code:
WordPress unslashes post content and post meta on save, and Divi 5 block JSON is
full of `<` style escapes. Tested on 26 real pages: every one is corrupted by
an unslashed write, none by a slashed one.
## Site-wide Custom CSS (mu-plugin >= 1.7)
Divi's Theme Options ▸ General ▸ Custom CSS field looks like a Divi setting but
isn't one — since WP 4.7 Divi redirects it straight into **WordPress core's own
Additional CSS system** (a `custom_css` post per active theme). Divi's own save
route for it rejects Application Password auth the same way Theme Builder does
(`invalid_nonce`, needs a live wp-admin session), and Divi 5's newer theme-options
REST route doesn't cover this field at all — checked its allowlist, `custom_css`
isn't in it. So until mu-plugin 1.7 this field was only editable by hand in
wp-admin.
```bash
node scripts/wp.js <site> css-get # { css: "..." }
node scripts/wp.js <site> css-get --raw # just the CSS text, for piping
node scripts/wp.js <site> css-set --file style.css # REPLACES the site's Custom CSS
node scripts/wp.js <site> css-set --file style.css --append # adds to what's there
```
Use this for styling something a page-level `code` module can't reach — a
plugin's own rendered markup (a Gravity Forms field, a WooCommerce widget) that
isn't part of any Divi page content, or any CSS the user wants living in the same
place they'd normally paste it by hand. It takes effect immediately (this is
WordPress core's own `wp_head` output, not Divi's builder-CSS static file cache —
no cache to clear here, unlike a page/Theme-Builder write).
**Scope it.** Custom CSS is global — write selectors scoped to the specific
element/form/page you're styling (an id like `#gform_wrapper_9`), not bare tag
selectors that would leak onto the rest of the site.
## Media and large-file audit: any WordPress site (read `references/wp-media-audit.md`)
"The site is too big", "clean up the media library", "what can go before we move it": use this. It does not need
Divi 5 (or Divi at all), only an administrator's application password and one read-only file the USER uploads:
`assets/wp-media-audit.php` into `wp-content/mu-plugins/` (it stores nothing; they delete it afterwards).
```bash
node scripts/media-audit.js scan <site> --dir <work-folder> # library items, files on disk, where each is referred to
node scripts/media-audit.js crawl <site> --dir <work-folder> # every public page: the cross-check
node scripts/media-audit.js report --dir <work-folder> # REPORT.md + CSV lists, biggest first
```
- Two findings, kept apart: library items nothing refers to, and files on disk with no library item. Plus the
biggest files, local video and audio, broken references, and the size of plugins, themes and the site root.
- Every library item is USED, MAYBE (kept as used), BACKGROUND (only revisions, trash, old builder copies) or
UNUSED. When in doubt, used.
- **Always run the crawl and read the cross-check line in the report.** It counts items the public pages use that
the database scan missed; it must be 0 or understood. It is how a new kind of reference gets noticed.
- Tables of plugins the site no longer uses still count as uses until you say so: `report --ignore-tables <table>`.
The report names every table that alone keeps items used.
- **Nothing here deletes, and neither do you.** To remove, quarantine: `scripts/media-quarantine.js` (needs a second
uploaded file, `assets/wp-media-quarantine.php`) moves the files to a holding folder outside `uploads/`, `verify`
checks every public page for newly missing files, `restore` puts back what is needed. Order: `verify` (baseline),
`plan`, `move` (dry run, then `--write`), `verify`, the user lives with it for some days, then the USER deletes the
quarantine folder and the library rows. One batch per decision.
- Tests: `php scripts/media-audit-test.php`, `node scripts/media-audit-test.js`, `php scripts/media-quarantine-test.php`.
## Updating plugins, themes and WordPress: any WordPress site (read `references/wp-site-updates.md`)
`scripts/site-updates.js` with `assets/wp-site-updates.php` (the user uploads it to `wp-content/mu-plugins/`).
```bash
node scripts/site-updates.js status <site> # what is out of date
node scripts/site-updates.js update <site> --all # the plan; nothing changes without --write
node scripts/site-updates.js update <site> --all --write # careful: one at a time, pages checked after each, stops at the first problem
node scripts/site-updates.js update <site> --all --fast --write # small brochure site: all in one go, pages checked at the end
```
- Show the user the plan first and get a go for the run: it changes a live site and there is NO rollback (a bad
update is undone from a backup). Careful mode for sites that matter, fast for small brochure sites.
- After every update the site clears Divi's generated CSS and the page cache by itself: on Divi 5 stale CSS after any
plugin, theme or WordPress update is the classic "the site looks broken". `site-updates.js clear <site>` does only that.
- A new WordPress release (6.8 -> 6.9) needs `--major`; maintenance releases are taken. Premium items without an
active licence are listed as blocked, never tried.
- The page check sees errors and broken pages, not shifted layouts: say what was and was not checked.
## Site search, background video, page cache (field notes)
- **Site search** (search field, a results template that really shows the results, pages before posts, a FAQs page
that comes first when a FAQ answers the search, filter-as-you-type): `references/wp-site-search.md`. Start by
fetching `/?s=<word>` and `/?s=<nonsense>`: if both list the same posts, the results template ignores the search.
- **Background video**: one file per breakpoint, plays on phones, always centre-cropped; and a header dropdown that
closes on a click elsewhere: `references/divi5-modules-verified.md` and `references/divi5-interactions-canvases.md`.
- **Page cache**: what helped and what did not, and why writes need a cache clear afterwards:
`references/wp-page-cache-notes.md`.
- **Tool limits**: `upload-media` can be refused (HTTP 413) for files of a few MB, and Theme Builder templates cannot
be created, only their layouts edited. In both cases the user does that one step in wp-admin.
- **A post grid with a popup per post, and a single-post template built from custom fields** (third-party Divi
FilterGrid; Video, Icon and Slider modules fed by ACF fields, each part hidden when its field is empty; an X icon;
big button presets with an always-visible icon): the last sections of `references/divi5-modules-verified.md`.
Writing to a custom post type, its ACF fields or a taxonomy: `wp.js rest-post wp/v2/<route> --data-file body.json`.
## Reference
`references/divi5-format.md` — the Divi 5 block serialization format, exact
per-module content keys, escaping rules, column-structure presets, and a minimal
valid page. Read it if you need to hand-write a `raw` module or debug rendering.
It was reverse-engineered from the pages of a live Divi 5 site and verified by a live
round-trip (build draft → confirm native render → delete), so trust it over
general web docs.
## Safety notes
- One shared creds file, many sites — always pass the right `<site>`.
- Prefer draft-first; treat publish, live-page edits, homepage changes, and media
uploads as visible actions needing a nod from the user.
- Clean up throwaway/test pages with `delete-page <id> --force`.