Content as Markdown
Block content in this project is authored as readable Markdown — a format we call blockmd. A single loader turns each .md file into the block JSON that the API serves, the deploy ships, and the documentation site renders. You write the Markdown; the JSON is generated, so there is nothing to hand-edit and nothing to keep in sync.
A document is frontmatter plus a body
The frontmatter carries the page's own fields — its type, title, description, and workflow state. The body is the block content: plain prose, headings, and lists become blocks on their own, and an explicit tag is used only where the shape needs one.
---
"@type": Document
title: About
description: Why this exists…
review_state: published
---
Plain prose becomes a text block. No wrapper needed.
## A heading is a text block too
A list, a quote, and a horizontal rule each map to their own block.Two frontmatter sections, blocks-matched and blocks-tagged, describe how the body decodes — they are the mapping that lets a page be read back into blocks without a stored schema. Everything below is about writing that body.
Three ways a block appears
A block shows up in the body in the cheapest form that can carry it:
- Bare Markdown — prose, a heading, a list, a fenced code block, a rule. It matches a matched prototype (usually the text block) with no wrapper at all.
- A paired tag,
<block type="…"> … </block>— an explicit block, a container that holds regions of children, or a block that needs a field its plain Markdown cannot express. - A self-closing tag,
<block type="…" … />— the escape hatch, a raw block built from its attributes, withdata='{…}'carrying any object fields verbatim.
Headings written inside a container are interpreted relative to that container, so a panel or section reads naturally as ## no matter how deeply it is nested.
Prototypes: matched versus tagged
A prototype maps a Markdown shape to a block type. It is written as a block tag whose fields hold ${…} references, and which of the two frontmatter sections it lives in decides how it is matched:
blocks-matchedprototypes are matched implicitly, from bare Markdown. Order is a cascade, like CSS: the most specific prototype wins — specificity being the number of concrete node kinds it names — and ties go to the one declared later. That is why the text block is declared beforetitle: a top heading ties on specificity, and the latertitletakes it.blocks-taggedprototypes match only when you write their<block type="…">tag. Use them for a shape too ambiguous to read from bare Markdown — a variable-shape container, or a leaf whose pattern would otherwise hijack a common reading. A teaser is a heading-with-a-link followed by prose, for instance, but you rarely want every such pair to silently become one, so a teaser is tagged.
References
An attribute value is either a plain JSON scalar or a ${…} reference; the two can never collide, because ${ is not a JSON token. A reference has three parts — a type, an optional selector, and an accessor — written as ${ type[selector]/accessor }.
<block type="heading" text="${h1/text}" tag="h${1/level}" />
<block type="callout" value="${p,blockquote/slate}" />
<block type="separator" _="${hr}" />The first line reads the first heading's text and interpolates its level into a tag name. The second accepts a set of node kinds — any paragraph or quote — as a rich value. The third is match-only: the _ field consumes the rule and stores nothing.
Selectors pick which node, by position or by label rather than by CSS (CSS cannot match on text):
- no selector — the first node of that kind
[2]— the second, by position[Summary]— scoped to the section under a## Summaryheading, by label text[Summary][2]— chained: the second, within that section
A trailing ? marks a reference optional: the node it names may be absent — the reference matches when the node is present and is skipped when it is not (an image with or without a caption paragraph, a teaser with or without a kicker or a description). Because the node is genuinely optional, one prototype covers both the present and the absent case — you do not write a second prototype for the shorter shape. A node is only optional when every reference to it is ?; url="${img/src}" keeps the image node required even alongside alt="${img?/alt}". (Separately, ? on an accessor that reads a present node also tolerates a missing part, e.g. ${img?/title} when the image has no title string.)
The node kinds a reference can target are the familiar Markdown ones: p, h1–h6 (or h relative, h* for any), img, a, ul, ol, li, blockquote, pre (a fenced code block), hr, table, a lone-bold strong or lone-italic em paragraph, and a dl / dt / dd definition list.
The accessor after the slash says what to read:
text— the node's text read as prose (from any node), for a one-line text fieldtextarea— the node's text line for line (from any node), for a textarea fieldslate— the node as a rich value (from any node)src,alt,title— an image's url, alt text, or title stringlink— a link target, stored as the object-browser form ([{"@id": url}])lang,textarea,meta— a fenced block's language, code, or info stringlevel— a heading's depth, for composing a tag name
Pick text or textarea by the widget of the field it fills. They differ only in a paragraph wrapped across lines: text reads the wrap as a space, as markdown and a rich value do, while textarea keeps every line, because a textarea's newlines are the author's own. A hard break (a trailing \) is a newline in both. A slate block's plaintext uses text, so it always matches its value.
Note that src and link name different fields, not two spellings of one: src is a bare source url (an image's url), while link is a link target in the object-browser form (a button or teaser href).
Regions hold a container's children
A container names each region of children with a <region> tag and the widget that region uses.
<block type="gridBlock">
<region name="items" widget="blocks_layout">
<block type="teaser" title="${h/text}" description="${p/text}" />
</region>
</block>A blocks_layout region is an ordered sequence of blocks — the standard shape, a blocks dictionary plus a blocks_layout.items order. It decodes using the global prototypes plus any the region scopes locally, so a grid can make a teaser prototype active only inside itself: the same heading + copy + image that is plain text elsewhere on the page becomes a card in the grid.
An object_list region is a list of typed items — accordion panels, code-example tabs — split out of a flat stream at each item's anchor, or iterated from an already-nested node's children (a table's rows and cells). A region name may be a dotted path such as table.rows.
An object_list region must declare idField, the field each item's id is stored in, with no default. Use the field name the block's schema uses, because frontends read it from there — tabs and panels are idField="@id", table rows and cells are idField="key". Every item is given its own id on import; frontends key items by it, so a missing id (or ids under a field frontends don't read) makes every item render as the last one.
typeField is the field each item's type is stored in, and it is needed only when the region allows more than one item type — then the stored type is the only thing that says which each item is, and leaving it off is an error. With one item type it is optional: leaving it off (or writing typeField=null) means the items store no type field, like a table's rows and cells. They still have a type — editors type them by their container (slateTable:rows, slateTable:rows:cells) — so writing a @type onto them would add a second name no frontend registers. Declare it anyway (tabs and panels use typeField="@type") when the stored content carries the type.
You never author item ids or block uids — the loader mints them, deterministically.
Tables
A table is mapped by its prototype like any other block — the engine knows what a markdown table is, but nothing about any block's table JSON. The rule names the node each item is: a row is a ${tr}, and a cell a ${th/…} (the header row) or a ${td/…} (any other row). Two item prototypes for the cells, each with its own type, say how a header cell is stored differently from a data cell:
<block type="slateTable">
<region name="table.rows" idField="key">
<block type="row" _="${tr}">
<region name="cells" idField="key" typeField="type">
<block type="header" value="${th/slate}" />
<block type="data" value="${td/slate}" />
</region>
</block>
</region>
</block>Read left to right: the rows at table.rows are the table's trs; each row's cells are its ths and tds, stored with type set to header or data and the cell's content in value. The same rules are read backwards to write the table. The table object's other fields — celled, striped — are written as dotted attributes, <block type="slateTable" table.celled>. A markdown table's header is exactly its first row, so a table whose header cells are anywhere else keeps the data-json form.
A markdown cell is one line of inline content, so ## cannot go in one — but a cell can hold several blocks, such as a heading above its text. Those are written as inline HTML tags, which a markdown cell allows, and the markdown between them reads as usual:
| Plan | Includes |
| --- | --- |
| <h3>Starter</h3>Everything to **get going** | <h3>Team</h3>Shared [workflows](./workflows.md) |A cell that is one paragraph is plain inline markdown. In a cell with several blocks, each heading is an <h2>–<h6> tag, and a paragraph stays bare unless it is empty or follows another bare paragraph, when it is a <p>…</p>. A cell holding anything else — a list or a quote — has no cell form, so its table keeps the data-json form rather than lose it.
Attaching fields with <fields>
The <fields> tag sets field values on the blocks in its scope, and its form chooses that scope:
- Self-closing,
<fields align="left" />, sets fields on the block it sits inside — the way to carry a field, such asstyles, that a block's clean Markdown cannot. - Enclosing,
<fields slotId="rendering"> … </fields>, sets fields on every block it wraps, as defaults: a wrapped block's own value wins, and nested wrappers merge outer-into-inner. This is how a field shared across a run of blocks is hoisted out so the bodies stay readable.
Typed attributes
An attribute's type comes from how it is written, with no schema involved:
- a bare name,
collapsed, is booleantrue, andcollapsed=falseisfalse— a false value is written like any other, never dropped as if absent - an unquoted value,
size=3, is coerced — number, boolean, null, or JSON - a double-quoted value,
title="Hi ${1/text}", is a string (so any references inside it survive) - a single-quoted
data='{ … }'carries raw JSON object fields verbatim
Dict fields
A block field whose value is a flat dictionary — a block's styles, or any custom block's object field — is written as one namespaced attribute per entry, field:key=value, rather than a JSON blob.
<block type="teaser" styles:align="left" styles:backgroundColor="grey" />The name is split on the first colon, so a key that itself contains a colon rides in the key untouched — styles:size:noprefix="medium" decodes to { styles: { "size:noprefix": "medium" } }. Each value is typed like any other attribute, so styles:noLine=false is a boolean and styles:span=2 a number. An empty dict writes nothing, and a value that is itself nested or a list can't flatten, so that block falls to the data='{…}' form instead. This is generic — nothing about it is specific to styles; it is simply the readable rung between a bare attribute and a JSON blob for any dictionary.
The fallback chain
Every block is written in the cheapest form that still reproduces it, stepping down only as needed: bare Markdown, then a light <block> tag, then a clean body with a <fields> tag, and finally a self-closing tag carrying data='{…}'. The last form always works, because it holds the block's full data, so nothing is ever lost — an irregular block simply falls further down the chain. Equality along the way is judged by meaning, ignoring derived or empty values, so a clean block is never pushed down the chain over incidental noise.
A single source of truth
The Markdown is the source; the block JSON is a generated artifact. There is no stored JSON to edit and no round-trip to keep byte-identical — a page is correct because the loader can decode it and the shared content validator accepts it, not because it matches a saved copy. Block uids are minted by the loader rather than authored, so moving or copying a block is just moving or copying its Markdown.
Where to look next
The richest examples are the documentation pages themselves and the block reference under Examples — every one is a blockmd file you can open and copy. To render, preview, and test this content locally, see Previewing and Testing Content, which serves these same .md files as a Plone REST API.