Skip to content
Get started A3 6 min read Reference

JSON structure

Read a saved page and recognise every node.

In the Architecture doc you met the page hierarchy as a concept. Now you'll meet it as a JSON file — and learn to read one without flinching. Every saved page is one of these shapes; every theme sample shipped under master/sample/ is one of these shapes; the engine's getData() returns one of these shapes. Master this and you can debug almost anything.

The page JSON shape

Open demo/themes/default/master/sample/email/Minimal.json (or run builder.getData() in DevTools while on /builder.php). The top-level object has three first-class keys plus the page tree:

Five fields you'll see on **every** page tree:
  • theme — string. The theme dir under demo/themes/. Determines which template family (P.template.html, Heading.template.html, …) renders this tree.
  • name — string. The class name of this node — for the root, always "PageElement". The engine uses this to look up the right Element class when rehydrating.
  • template — string. Which *.template.html file to use. Often matches name minus the Element suffix (PageElementPage).
  • formats — object. CSS-like properties (text_color, padding_top, border_radius, …). Always lower-snake-case, never camelCase. Different element types declare different format keys; the union is documented in docs/core/ELEMENT_DEFINITION.md.
  • blocks — array (Page only). The page's rows. Each entry is a BlockElement — see next section.

That formats separation is the engine's core abstraction: presentational properties are in formats, structural / behavioural properties are top-level. A heading's text lives at text, but its colour lives at formats.text_color. Every element follows that split.

BlockElement — one row

Inside the page's blocks array, every entry is a row. The shape:

A block carries **vertical rhythm only** — `padding_top` and `padding_bottom`. Margins on Block are forbidden (regression-scar in `docs/BUILDER.md`'s Hardened Rules — collapsing margins inside email-client environments produce subtle visual bugs). Width comes from the parent Page's container width; horizontal alignment comes from the elements inside.

The elements array holds the actual content of that row. Each entry is either a leaf element type (PElement, HeadingElement, ImageElement, …) or a GridElement for N-up layouts.

Leaf elements — by type

The shipped engine surfaces around 30 element types. The four you'll see most often:

PElement (paragraph)

`text` is plain text; rich-text styling (bold, italic, link) flows through `text-formats` for inline ranges (a separate, lazier-to-debug shape — covered in [Layout best practice](?doc=08-layout-best-practice)).

HeadingElement

Same shape as `PElement`, with bigger default font sizes and stronger weight.

ImageElement

`src` is path-relative to the theme's `assets/` (the engine prefixes `MEDIA_URL` at render time). Width is in pixels — never percentage strings (typed-units rule, `THEME_BEST_PRACTICE.md` RULE-H1).

ButtonElement

Same `formats` family as paragraph + heading, with the spatial padding keys for the button shape.

GridElement — N-up columns

When a row needs columns (image+text split, 3-up feature grid), wrap the row's content in a GridElement:

The shape repeats: a `CellElement` holds `blocks` (just like `PageElement`); each block holds `elements` again. That recursion is why the page hierarchy in [Architecture](?doc=02-architecture) drew Block twice. `formats.width` on a cell is a percentage (sums to 100 across the cells in one Grid).

The round-trip rule

A page's JSON is its single source of truth. Two engine guarantees fall out of that:

  1. builder.getData() is idempotent. Calling it twice in a row returns identical JSON.
  2. load(getData(x)) is the identity function. If you getData() a page, then load() the result back, the canvas is byte-identical to before.

That round-trip is what lets you debug fearlessly — copy the JSON, mutate it in your editor, paste it back into builder.load() and you'll see the change in the canvas. The examples/api/2-get-data-load/ example walks the round-trip end-to-end with two side-by-side Builder instances copying state between each other.

The pattern that breaks the rule:

`getData()` returns the page tree; `getHtml()` returns the rendered HTML string. Conflating them is regression-scar #73 in BUILDER.md — the kind of bug a fresh buyer hits at boot when they paste an outdated snippet from a forum thread.

Common debug recipes

"My save isn't persisting" — open DevTools, run builder.getData() in the Console. If it returns something sensible, the engine is fine — your handler is dropping it. If it returns undefined, the engine isn't loaded yet (script error before boot).

"I want to load a saved JSON without my backend" — paste it into localStorage, then on reload run builder.load(JSON.parse(localStorage.getItem('myPage')), THEME_TEMPLATES, THEME_CONFIG_DATA, MEDIA_URL) in the Console. The THEME_* globals are populated by the page; you only need to substitute the page tree.

"I edited the JSON manually and now it won't load" — the engine validates name + template + formats keys against the active theme. A console.warn in DevTools tells you which node is invalid. Most often: a typo in template ("Heading " with a trailing space, or "heading" instead of "Heading").

"Something I expected isn't in the JSON" — open the canvas, right-click the element, choose "Inspect" in your browser to see the rendered DOM, then builder.getData() to see the JSON. The two are connected by templates; what's missing in JSON wasn't part of the editable model in the first place.

Did you make it?

You should now be able to:

  • ✅ Read a page JSON top to bottom and identify each node's name + template + formats.
  • ✅ Distinguish a BlockElement (row, vertical padding only) from a GridElement (N-up columns) from a leaf element (PElement, HeadingElement, …).
  • ✅ State the round-trip rule: load(getData(x)) === x, getData() and getHtml() are separate methods.
  • ✅ Open DevTools and use builder.getData() to debug a save.

If a step was murky:

  • 🩹 Real samples look bigger than the snippets here → that's expected. tree demo/themes/default/master/sample/email/Minimal.json is ~250 lines once you include all the rows. Read top to bottom — each chunk fits one of the shapes above.
  • 🩹 Round-trip not working → confirm you saved the JSON output of builder.getData(), not JSON.stringify(builder) (which captures internal state, not the page tree).
  • 🩹 I have a custom element type whose shape isn't here → see Build a custom Element. Custom Elements declare their own JSON shape via getData() + static parse() overrides.

What's next?

Stuck?

  • 🐛 grep src/includes/<TypeName>Element.js (e.g. PElement.js) — every leaf element class declares its getData() shape. The JSON is whatever that method returns.
  • 🐛 the canonical schema spec is docs/core/STRUCTURE.md. Heavier than this doc — read it when you need the full surface, not the buyer 80%.