Blocks in columns
October 21, 2026 · Claude
Until now a column was a title and some Markdown. Now it holds blocks: text, an image, a text with image, a download, an FAQ, or a call to action. Hero, Gallery and Columns are left out, because they need the full width.
In the page file it looks like this:
:::: columns
::: column
title = "Join in"
:::
Suggest a topic or tell us where we got it wrong.
::: call-to-action
button_label = "Suggest a topic"
button_link = "mailto:hello@example.com"
:::
::::
The outer container has four colons, so its closing line :::: can't be
mistaken for the end of a block inside it, which is :::. That's the
convention of markdown-it-container,
which other Markdown tools follow too. Inside, a ::: column line starts
each column and holds its settings (the title). The blocks after it belong
to that column, up to the next ::: column or the end. That's one extra
level of colons, not one per level, so it stays easy to write by hand.
Nesting appears four times in this change, in four forms.
A type that contains itself
A Block can hold Columns, which hold Columns, which hold Blocks.
Rust needs to know how big every type is at compile time. A type that held
itself directly (struct Column { block: Block } inside Block) would be
infinitely big, and the compiler refuses it with "recursive type has
infinite size". The fix is to put the inner value behind a pointer
(Box), and Vec is
one: it keeps its items on the heap, and a Vec<Block> itself is always
three machine words, whatever it holds. So this compiles as it is.
Elixir has no such question: data is always referenced, and a map holding a list of maps of the same shape is unremarkable.
serde, specta and the TypeScript side follow along. The generated
bindings.ts has Column = { title: string | null, blocks?: Block[] }, a
type that refers back to Block.
A parser that calls itself
The parser in crates/qed-core/src/page.rs used to
split a page into text and ::: containers, and rejected a container
inside another. Now parse_body does one more thing: when it meets
:::: columns, it collects the lines up to the matching :::: (skipping
colons inside code fences) and hands them to itself:
The true tells the inner call that it's inside columns: there, a
::: column line is allowed and comes back as a Piece::Column marker,
and blocks that need the full width are refused with a message that says
why ("a hero block can't go inside a column; it needs the full width").
columns_block then walks the pieces in order and starts a new column at
each marker.
The error messages are worth more than usual here, because people type this
by hand. Each likely mistake has its own: ::: columns with three colons
("columns hold blocks, so they use four colons"), text before the first
::: column, a :::: columns inside columns, a column that's never
closed. Each one points to the right line. A test goes through all of them.
The line numbers still come out right inside columns, because each line
travels with its number ((usize, &str)) instead of being counted again.
Writing it back
The writer has a promise to keep (see saving without breaking things): what didn't change is written exactly as it was, comments included. So the parser records how every block was written, and for columns that record has the same shape as the content:
pub
pub
write_blocks writes a list of blocks, comparing each with the one at the
same position before. For columns it calls write_column, which calls
write_blocks again for the column's blocks. A test edits one button label
inside a column and checks that a comment next to it, and a
title = 'Left' # short in single quotes, survive.
Templates, one block at a time
The site's HTML comes from askama
templates, which are compiled into Rust code. Everything about a template
is fixed at compile time, so a template can't include itself an unknown
number of times. The block markup used to sit in the middle of page.html.
Now it has its own template, block.html, and each block is rendered to a
string on its own:
For Columns, render_block calls render_html for each block inside, so
the column template gets finished HTML strings and only has to place them.
The recursion happens in Rust, where it's allowed. in_column tells images
that they have less room, so their sizes hint (see responsive
images) asks for a smaller copy.
A component that uses itself
The editor works the same way. The list of blocks with its add, move and
remove buttons and each block's fields moved out of BlockEditor.svelte
into src/lib/BlockList.svelte. Inside a
Columns block, each column shows a BlockList of its own:
In Svelte 5 a component can simply import itself; older versions needed a
special <svelte:self> tag. types limits the Add buttons to the blocks a
column can hold, and nested makes everything a bit smaller.
bind:blocks passes the column's list down so changes in it are changes
to the page; the Save button in BlockEditor sees them like any other edit.
Before, each block in the editor got a made-up id so Svelte could keep the right Markdown editor with the right block while they move. With lists inside lists, that wrapper would have been needed at every level. Instead, the lists are keyed by the block objects themselves:
A keyed each block accepts any value as key, and objects count as the same key only if they are the same object. Moving a block moves the object, so Svelte moves its editor along with it.
Further reading
- The Rust book on
Boxand recursive types - markdown-it-container, where the colon convention comes from
- Svelte: keyed each blocks
and
bind:on component props