Markdown in Forumulate
Forumulate stores pages as a block document, not as markdown. Markdown is an input — we parse it into blocks on the way in, and serialise blocks back to markdown on the way out. So the question "is this markdown supported" really means "is there a block for it" — and for a handful of competitor dialects it also means "which import route did you use", because translating somebody else's spelling is the importer's job and not the parser's.
What works
You write | You get |
|---|---|
| Headings, levels 1–6 |
paragraphs, | Paragraphs, bullet lists, ordered lists, blockquotes |
| A code block. The info string carries more — see below |
| A horizontal rule |
| An image block |
| A table with header cells |
| The column's alignment, kept on every cell in it |
| The matching marks |
| An underline mark |
| A highlight mark |
| A link |
| A checklist with per-item |
| A math block |
| An expandable block. Nests, and honours |
| A hint (or an expandable), the title as the hint's own |
| A hint block, styled |
Code fence options
The info string after the language takes more than the language:
```ts title="server.ts" lineNumbers highlight="3-5"language, title, lineNumbers and highlight are read. A label="…" form is accepted as a synonym for title, showLineNumbers for lineNumbers, and the highlight range takes either spelling — highlight="3-5" or the shorter {3-5}, which is what a page exported from here writes back.
Every other code-block attribute has a fence spelling too, and an exported page writes exactly these back:
In the fence | Does |
|---|---|
| Numbers the lines starting at 12 |
| Marks inserted / deleted lines green / red |
| Draws a |
| TypeScript/JavaScript: type hovers, |
| Dims every other line until the reader hovers or focuses the block |
| Marks those words wherever they occur ( |
| Shows 20 lines and a "Show all N lines" button |
| Folds those lines behind "8 collapsed lines" |
| Wraps long lines, or never wraps whatever the theme says |
| A terminal's dot bar, the title as an editor tab, or no frame |
| Fetches the code from there when the page is saved |
| …keeping only those lines (a |
| Draws an |
Line ranges are always block-relative: line 1 is the first line of the block, whatever showLineNumbers= starts the gutter at.
Diff lines in a real language: write the fence as Starlight does, ```diff lang="ts", or add the diff flag, ```ts diff. Lines starting with + or - become inserted / deleted lines with the marker stripped (a +++ / --- file header is left alone) and diffMarkers on. A plain ```diff with no lang= is still the diff grammar.
A fence written ```ansi is terminal output: its colour escapes are drawn as colours, and every other escape sequence is dropped.
Magic comments
On import, comments that other tools use to mark lines are read into the attributes above and removed from the code:
Comment | Becomes |
|---|---|
| The next line highlighted |
| The lines between highlighted |
| This line inserted / deleted |
| This line highlighted |
| This line in focus |
|
|
| This line and the next two |
Any comment leader works — //, #, --, ;, %, /* … */, <!-- … -->, {/* … */}. A comment on a line of its own applies to the line after it. Only an import reads them: a stored block is never re-scanned, and an export writes attributes, never comments.
Code groups
The same sample in several languages, as tabs in one code header:
:::codeGroup{syncKey="pm" picker="dropdown"}
```bash title="npm"
npm i postwick
```
```bash title="yarn"
yarn add postwick
```
:::Each block's title is its tab (unset: the language's name). picker is tabs (the default) or dropdown; syncKey keeps every group with the same key on the same tab, and groups also follow ordinary tabs with the same titles. :::codegroup is read too. Anything inside that is not a fence is kept, after the group.
Our own directive syntax
A page exported as markdown and re-imported round-trips, because the serialiser writes directives the parser reads back:
:::hint{style="warning"}
Mind the gap.
:::
:::tabs
::tab{title="npm"}
npm install
::
:::The vocabulary: hint, expandable, tabs / tab, cards / card, stepper / step, columns / column, grid / gridItem, updates / update, audience, button, embed, :::video, :::reusable, :::customcode and :::form, plus the interactive blocks:
:::customcodeby hand: the CSS as an attribute and the HTML as the body, taken verbatim (it is not parsed as markdown) up to the closing:::.:::customcode{html="…" css="…"}:::on one line works too; the editor's export writes an encodeddata="…"form instead.:::customcode{css=".hero { color: tomato }"} <div class="hero">Hello</div> :::Containers around ordinary markdown, so their words survive a renderer that ignores the fences:
:::carouselwith one::slideper slide,:::galleryand:::lightbox(images, one per paragraph),:::marquee(any blocks) and:::modal{label="Watch the demo" title="Demo"}.:::compare, the before/after slider, spelled out in the clear::::compare{before="…" after="…" beforeLabel="…" afterLabel="…"}:::.Data blocks —
:::pricing,:::testimonials,:::logosand:::stats— are one self-closing directive with the block's attributes encoded in adata="…"attribute, like:::form. They are tables of facts with no markdown a person would write, so export one from the editor rather than typing it.
:::carousel{autoplay interval="6000" loop perView="3"}
::slide{label="Ship"}

::
:::A section of a shaped page is not a directive: it is written as its ## heading, so a section's look (its background and the rest) stays in the block JSON and does not travel through markdown.
Any of them may also carry a block's hooks — anchor, class and hook (:::hint{style="info" anchor="limits" class="loud" hook="limits"}) — and they round-trip.
Blocks without a directive take the same hooks as an attribute list, the kramdown/Pandoc spelling: #anchor, .class (up to four), hook=name. A heading or paragraph takes it at the end of its line; a list, quote, code block, table, image, divider or formula takes it on its own {: …} line after a blank line below the block:
## Pricing {#pricing .wide hook=plans}
Our plans start at zero. {#intro .lede}
- Sign up
- Invite your team
{: #steps .tight}Only a list at the end of a line whose every token is valid counts, so braces in prose (the set {a, b}) stay prose; write \{#note} for a sentence that really ends in one (an export does this for you). Hooks on a list item or inside a table cell are the one case with no spelling. See Sections, video and block hooks.
A titled item takes title="…", which is what an export from here writes. This page used to show ::tab{label="npm"} instead; label is read as a synonym so that markdown still works, but title is the spelling to use.
This is the syntax to write if you are generating markdown for us deliberately. The GitBook and Mintlify forms below are translations into it.
What does not work, and will not
These have no block in the model. There is nothing half-built; write it a different way.
Nothing, as of the docs wave (2026-10): inline math was the one row here, and $E = mc^2$ is now a real mark. See "Docs blocks (wave D)" below.
Markdown footnotes used to be on this list and are not any more. Both spellings are read now: a reference [^1] with its [^1]: body definition underneath, and the inline label^[the note] that our own .md export writes. Either becomes the footnote mark on the text the marker followed, with the note as an attribute — so an imported site's footnotes survive, and a page exported from here can be imported back.
What depends on which route you imported through
Almost nothing, now. Every import door that takes Markdown — /docs/import with a JSON files array, /docs/import/zip, /docs/import/url and the .docx door — converts through one function, so the same file lands as the same blocks whichever one you hand it to. That was not true when this page was first written: the dialect translator was wired to the ZIP reader only, so a GitBook export became real callouts through /docs/import/zip and literal {% hint style="info" %} paragraphs through /docs/import — the route the agent surface and every docs-as-code pipeline uses. It is one call site at the shared conversion point now, which is why the column below has collapsed.
The container admonitions have since moved one step further in: :::note, :::warning Some title, ::::warning and :::details Some title are read by the converter itself, so they behave the same on every door there is — the git sync, PUT …/sections/:key, and the markdown source view in the editor included. They used to convert only on the /docs/import… routes, which meant the same repository of markdown landed two different ways depending on whether you imported it or synced it.
What is left in the right-hand column is the part that is genuinely route-specific: translating a competitor's dialect. A git sync is a two-way mirror — we write markdown back to your repository — so rewriting {% hint %} into our spelling there would edit your files, which an import is allowed to do and a mirror is not.
Construct | Every |
|
|---|---|---|
YAML front matter | Stripped; | Becomes a heading made of your metadata |
| A | Literal text |
| A | A |
| A | A |
| A | A |
| An | An |
GitBook | Translated or unwrapped | Literal text |
Mintlify/MDX | Hint blocks, whatever the export flavour is | Literal text |
MDX | Stripped | Literal text |
| An | Literal text |
Markdown footnotes, | Correct | Correct |
CRLF line endings do not change any of the above. They used to: every translation in that table is anchored on a line break, and a .zip produced on Windows published its callouts, its <details> and its footnote definitions as literal text while the identical file with Unix endings converted. One normalisation now runs ahead of all of them.
A title comes from the open line and nowhere else. :::note followed by two paragraphs is a callout with two paragraphs in it — not a callout whose title is your first paragraph, which is what it used to publish, and which no amount of reading the page would have told you was wrong.
So the advice that used to be here — "import a ZIP if you have the choice" — no longer applies to the JSON route, and you do not have to pre-translate anything for it. The one door that still differs is the GitHub/GitLab sync: it reads a repository file as plain Markdown without the dialect pass, so if your repo is a GitBook or Mintlify export, import it once through /docs/import/zip or /docs/import/url rather than connecting it as a synced repo.
The hint style aliases
One table, four styles — info, success, warning, danger — and every common spelling folds onto one of them. The block schema reports the eleven that are accepted as a style attribute: caution, check, danger, error, important, info, note, success, tip, warn, warning.
The same eleven work as a :::name directive, plus four more that an export writes as a heading but that are not attribute values: attention, bug, deprecated, done.
So :::tip and :::check both become style: "success". You do not need to know which four; write the word you mean.
caution is danger, not warning. It is red, matching GitHub's > [!CAUTION]. There used to be two alias tables that disagreed about exactly that word, so a page carrying both spellings rendered two different colours and re-imported as a third thing. There is one table now.
The block schema, machine-readable
curl https://forumulate.com/api/docs/block-schemaNo authentication, cached for five minutes. Returns { "nodes": [ … ] } with one entry per block type:
{
"type": "hint",
"content": "blocks",
"contentRequired": true,
"attrs": [
{
"name": "style",
"type": "string",
"required": false,
"values": ["info", "success", "warning", "danger"],
"accepts": [
"caution",
"check",
"danger",
"error",
"important",
"info",
"note",
"success",
"tip",
"warn",
"warning",
],
},
],
}It documents every block type — it is generated from the same union the validator uses, so it cannot fall behind. The same data, as one table per block with every attribute path, its JSON type and its accepted values, is the block schema reference.
It covers the whole model, not just the containers. The response's keys:
Key | What is in it |
|---|---|
| Every block type, each with its attrs (type, enum values, number bounds, nested keys), whether it holds |
|
|
| The three inline nodes: |
| Every mark, with the attrs each one actually uses: |
| The attributes every block also takes: |
| Each space type's theme namespace ( |
| Where the Markdown spelling of all of this is served. |
Enums are filled in too, so heading reports "level": { "type": "enum", "values": [1, 2, 3, 4, 5, 6] } rather than an empty list. The root, the inline nodes and the marks used to be missing, so a caller reading this endpoint knew every container and nothing about what goes inside one.
Reading it is still the right first move before writing block JSON by hand: it is why accordion and toggle are not types (the block is called expandable) and why tab {label} is not what a tab looks like in JSON. The alternative is bisecting 422s, which is how one customer learned the model across 36 pages.
Markdown inside block JSON
A string attribute is plain text, never markdown. A hero's eyebrow, title, sub and note, a pricing plan's name, description, priceNote and each feature's text, a card's title, description, kicker and cta, a testimonial's quote, a stat's label — every one of them is escaped and drawn as the characters you sent. "title": "Ship **faster**" shows two asterisks on the page. There is no attribute that is parsed as markdown, inline or block.
Formatted words live in a block's content, not its attributes. A block that holds other blocks — a hint, an expandable, a tab, a step, a column, a gridItem, a section, a modal — takes a content array of ordinary blocks, and bold, links and code are marks on the text nodes inside them:
{
"type": "hint",
"attrs": { "style": "info", "title": "Before you start" },
"content": [
{
"type": "paragraph",
"content": [
{ "type": "text", "text": "Create a " },
{ "type": "text", "text": "token", "marks": [{ "type": "bold" }] },
{ "type": "text", "text": " first." }
]
}
]
}A block whose words are all attributes — hero, pricing, card, person, testimonials, stats, logoWall — has no content at all, so it holds no formatting. When a band needs a formatted sentence beside a hero or a price table, put a paragraph next to it in the same section.
The same blocks, written in markdown
You rarely need block JSON for any of this: every door that takes a body takes markdown instead (markdown beside content, or body on a page and a front page), and the hero and pricing blocks have a markdown spelling. Because their attributes are a table of facts, that spelling carries them as one encoded attribute: data is the block's attrs as JSON, passed through encodeURIComponent.
:::hero{data="%7B%22title%22%3A%22Ship%20faster%22%2C%22layout%22%3A%22center%22%7D"}:::const data = encodeURIComponent(JSON.stringify({ title: 'Ship faster', layout: 'center' }));The same holds for :::pricing, :::testimonials, :::logos, :::stats, :::person and :::offices. A data value that does not decode, or decodes to attributes the block refuses, drops that block rather than failing the whole write. Exporting a page that already has one (?form=source) is the quickest way to get a correct value to edit.
Blog posts take the whole dialect
A blog post's markdown (and each sections value) goes through the same converter as a docs page — markdownToDoc — so every directive in Markdown directives works in a post: :::cards, :::hint, :::grid, :::columns, :::hero{data=…}::: and the rest. So do forum topics, changelog releases, roadmap items, status incidents and a space's front page, which all share that converter. A post that sends both content and markdown keeps content.
Mermaid diagrams
A ```mermaid fence becomes a mermaid block ({"type": "mermaid", "attrs": {"code": "…"}}). The page is published with the diagram's source in it, and the reader draws the diagram in the browser, loading Mermaid only on a page that has one. A diagram that does not parse stays on the page as its source.
What it looks like. A diagram is drawn in your site's own colours. The reader reads the colours the diagram's place on the page inherits — the canvas, surface, panel, ink, muted text, rules, brand and accent, so palette, paletteDark and the space's brand all reach it — and draws it with Mermaid's base theme, which derives every other shade from those: nodes on the surface colour edged in the brand, text in the ink, edges in the muted colour, notes edged in the accent, pie slices starting from the brand. The diagram's text uses the page's font.
Light and dark. The diagram follows the scheme the reader is showing: a site pinned to dark draws dark diagrams on any computer, and a reader who switches with the theme toggle — or whose operating system switches — sees every diagram redrawn in the new colours straight away. The redraw holds each diagram's box at its size until the new picture is ready, so nothing on the page moves.
Your own colours for one diagram. Mermaid's init directive, as the first line of the diagram, still overrides all of this for that diagram:
```mermaid
%%{init: {"theme": "base", "themeVariables": {
"primaryColor": "#eef2ff", "primaryTextColor": "#1e1b4b",
"primaryBorderColor": "#6366f1", "lineColor": "#6366f1"}}}%%
flowchart LR
A[Write] --> B[Publish]
```Write real colours (#hex) there rather than var(--brand): Mermaid computes from the values. Colours you set this way are fixed, so they show the same in both schemes. The directive cannot change the security level (diagrams always render with Mermaid's strict level: no scripts and no click handlers that run code).
Getting markdown back out
URL | What |
|---|---|
| One page. |
| The same, by id. |
| Every published page, concatenated. |
?form=source, if you are going to write it back
The default form of every .md address is the one a reader gets: it resolves {{variables}} to today's values, inlines reusable snippets, and appends the short "For AI agents" footer. That is right for an agent quoting the page and wrong for anything that will send the result back, because a read-edit-write loop then stores the resolved values and a copy of the footer, and the next export carries two of them. (Our own importing guide ended up like that.)
Add ?form=source and you get the markdown the page is written in — a {{variable}} is still a variable, a snippet is still a reference, no footer. It works on the public .md route above and on the authenticated GET …/pages/{pageId}/markdown. Anything invalid is refused rather than quietly answered as rendered.
The round trip
Round-tripping is the design goal, and ?form=source is a fixed point: export a page, re-import it, export it again, and the two exports are identical character for character. Every construct in the two tables above is checked that way on every import route, including nested inside a callout, a tab, a column, a grid cell and a collapsible — so a construct that imports correctly but cannot survive our own export does not read as a pass.
The first line is the title, and only the title
A page's title is a column, not a block: the reader prints it as the page's one <h1>, and markdown has exactly one place to put it. So:
an export writes the title as the leading
# …, from the title field;an import reads a leading
# …as the title and takes it out of the body.
If your body opens with its own # Same Thing heading, that heading is not written twice and comes back as the title rather than as a block. Nothing a reader sees changes — the reader already drops a first heading that repeats the title — and from that point the page is a fixed point. A body heading that only nearly matches (# Quickstart: under a title of Quickstart) no longer overwrites the title with its own spelling, which is what used to rename pages on a re-import.
The same rule governs the repository mirror: {slug}.md carries the title as its first line, so a page authored in the console keeps its name in your repo instead of being renamed after its filename.
The agent footer is never content
The default form of a .md page ends in a short ## For AI agents block. If you import a file that has one, it is recognised and left out — it is our furniture, not your content, and it used to arrive as four blocks (a rule, a heading, a list of our URLs and a sentence) and then be exported twice. A section you wrote yourself called "For AI agents" is untouched: recognition needs the rule, the heading, our endpoints and our closing sentence, all at the end of the file.
You still want ?form=source for a read-edit-write loop — it is the form with no footer and unresolved variables — but forgetting it no longer costs you four blocks.
Known limits
Markdown is the lossy medium, so two things are worth knowing:
Punctuation in a title is markdown. A title of
What's *new*exports as# What's *new*and re-imports asWhat's new, because the asterisks are emphasis by the time the parser sees them. The same is true of any text; it is just more visible in a title.A page with a title and an empty body cannot be spelled:
# Tand nothing else re-imports as a body of one heading. It settles on the second pass.
The last drift to go in the construct tables was a > [!WARNING] alert: the consumed [!WARNING] marker left the space from its line break at the front of the body, so the first export carried a leading space the re-import did not reproduce.
One known gap remains, and it is about nesting, not about any construct on its own: a block-level ::: directive or a stand-alone image placed INSIDE a list item comes back as the list's sibling rather than as its child. The export indents it under the item, as markdown requires; the re-import reads the indent, keeps the callout, and loses the nesting. So the content survives and the shape shifts once, after which it is stable — export it again and nothing moves.
(Up to three columns of indent is read. Four or more is an indented code block in CommonMark and stays code, which is what lets this page print :::note as an example without becoming one.)
Everything at the top level of a page, and everything nested inside a callout, an expandable, a tab, a column or a grid cell, is a fixed point on the first pass.
:::walkthrough
A code walkthrough (see the authoring reference) is a directive holding one fenced code block and its steps. A ::step with lines is a walkthrough step; a stepper's ::step never has one.
:::walkthrough{language="ts" title="server.ts"}
```ts
const client = createClient();
await client.ping();
```
::step{lines="1" title="Create the client"}
One client per process.
::
::step{lines="2"}
Check the connection.
::
:::It round-trips: the export writes exactly this, and the importer reads it back to the same block. The fence grows past any backtick run inside the code.
Docs blocks (wave D)
Every spelling below reads back into the same block it was written from — the export and the import are one table.
Callouts. The author's word is kept: :::tip, :::caution, > [!NOTE] store style (one of four) and kind (note, tip, caution, important, check), so a theme can paint a tip apart from a success. A title is the hint's own title, in any of these spellings: :::tip Title, :::tip[Title] (Docusaurus), :::tip{title="Title"} (Starlight), :::hint{style="info" title="Title" icon="book"}. It is no longer a bold first paragraph. A " in a title is kept as a ".
Directives.
Write | Block |
|---|---|
|
|
|
|
|
|
|
|
| a column taking 55 percent of the row — 5 to 95; anything else is warned about and dropped (it used to 422 the whole page) |
| a column as wide as what it holds, at the row's end — "Ask Question" on the same row as the heading beside it |
| a cell border, flag or colour. A cell without |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| the dated-list changelog |
Attribute lists. After a GFM table, {: collapseAfter=10 stickyHeader}; after a list, {: marker=arrow-end rule} (or rule=#e5e7eb); after a heading, {variant=code}. After a picture, {srcDark=/api/assets/b} is the dark-mode picture. A grid cell's gradient is JSON-only.
Inline.
Write | Mark |
|---|---|
| inline math ( |
| a pill — |
| an inline glyph; the text is its label |
| a key cap. |
| an annotation (tooltip) |
| a spoiler — it used to come back as literal bars |
| a variable reference (it was stored as text) |
Spoilers. A spoiler hides text until the reader clicks it, or focuses it and presses Enter. You can write a spoiler in three ways:
Write | Result |
|---|---|
| a spoiler inside a sentence |
| the same, in the BBCode form forums imported from Discourse use |
| a spoiler block (paragraphs, lists, images). |
A spoiler block is an expandable with style: "spoiler". It is drawn as a veiled bar and opens like any disclosure, so it works without JavaScript and a screen reader announces it as collapsed. An untitled spoiler block is exported back as [spoiler]…[/spoiler]. The forum composer has a spoiler button, and the docs editor has one in its selection toolbar plus a Spoiler item in the / menu. Before it is revealed, an inline spoiler is announced as "Spoiler, activate to reveal" in the reader's language instead of the hidden words.
Round 3 block attributes
The same table, both ways — an export writes these and an import reads them back. The blocks themselves are described in Authoring › Round 3 block attributes.
Write | Block |
|---|---|
| tabs that stay tabs even when every tab is one code block (unset, such a group draws as a code group) |
| the tab group in a bordered card |
| the one-line pill, centred |
| provider ids as before, and |
| the body's markdown drawn as prose / folded to 6 lines behind "Show more" |
| a heading's icon (one token: a glyph name, an emoji or an image address with no spaces), its tile look, and its line height (0.9–2) |
| a cell's text alignment and icon |
A {: …} attribute list right under a table's last row, with no blank line between, is the table's list — it used to become one more row reading {: collapseAfter=10}:
| Plan | Price |
| -------------------- | ----- |
| Free | $0 |
| {: collapseAfter=10} |A section's pattern keys are, like the rest of a section's look, JSON only.
Round 4 block attributes
Both ways, as before. The blocks are described in Authoring › Round 4 block attributes. A directive's attribute may now be written unquoted when the value has no space — :::cards{border=tile iconAlign=center} is border="tile" iconAlign="center" (it used to read as four flags and lose both values).
Write | Block |
|---|---|
| a card grid's own look |
| a callout's tone, title gap and edge |
| a code block's frame, chip, title face and file glyph |
| a code group's strip, frame, chip and line numbers for every sample |
| the pill strip and the active tab ( |
| a prompt's layout and phone row |
| a heading icon's tint |
| a cell icon's tint |
| a gap per screen size |
A table's round-4 keys ride its {: …} line, each one token:
| Class | Value |
| -------------------------------------------------------------------------------------------------------- | ----- |
| `p-1` | 4px |
| {: wrap=wrap more=solid,center,fade,bg:#111111,ink:#ffffff,font:mono,case:upper codeInk=#0e7490,#be123c} |more takes, in any order: outline/solid, start/center, fade/nofade, bg:#hex, bgDark:#hex, ink:#hex, inkDark:#hex, font:ui/font:mono, case:none/case:upper. A token that is not one of these makes the whole line text, as any bad token does.
A section's gradient patternColor is, like the rest of a section's look, JSON only.
Round 5 block attributes
Both ways, read and written by the same helpers as round 4. The blocks are described in Authoring › Round 5 block attributes.
Write | Block |
|---|---|
| a card grid's min card width, gaps, and a |
| the strip as a segmented pill group |
| a prompt's label case, surface, fold and desktop row |
| an HTML preview and its source as one card |
| a heading lifted into a forum home's page head, with a link beside it ( |
A value that is not one of the key's values is dropped with a warning (the page still writes).
When something is not converted
An unknown :::directive, an unknown attribute on a known one, a :::field with no name or a column width out of range is no longer dropped in silence: the write answers 200 with a line per item on X-Field-Warnings, e.g. markdown card.bogus: not an attribute of this directive; dropped.