Markdown specification
Status: living document, written with the serializer.
Markdown is the interchange contract: every page serializes to canonical Markdown and parses back loss-free. Storage stays blocks + CRDT; this spec fixes the canonical dialect — the one output form the serializer emits and the parser normalizes any input toward.
Two guarantees, both held by a property test that runs on every build:
parse(serialize(doc)) ≡ docfor every canonical document.serialize(parse(md)) ≡ normalize(md)— canonical Markdown is a fixed point.
Base dialect: CommonMark + GFM (tables, task lists, strikethrough, autolink
literals) + YAML frontmatter. Obsidian-compatible reference syntax
([[wikilink]], ![[transclusion]], ^anchors) and basalt: fenced forms
for databases and views are deferred — the seams exist but are not parsed
yet.
A note on the names below: the fenced forms are spelled basalt-embed and
basalt-view, and they are the platform's, not any one product's. What matters
about them is that they are what the serializer writes and the parser reads;
they appear here in the spelling your files will contain.
Document shape
A document is optional YAML frontmatter followed by a sequence of body blocks, separated by a single blank line. The serializer always ends the document with a trailing newline (an empty document serializes to the empty string).
Frontmatter
---
title: My Page
key: value
---
titleis emitted first; all remaining keys follow sorted.- Remaining keys are the page's workspace-field values, keyed by field
key. They are carried opaquely (any YAML scalar / list / map). - Multiline string values are always double-quoted (no
|block scalars — the fence boundary would swallow a trailing newline). No line folding, for determinism. - Frontmatter is omitted entirely when there is no
titleand no props.
Note: the
GET/PUT …?format=markdownAPI emits and reads the page's field values as frontmatter, keyed by fieldkey. Formula fields are computed, so they are neither emitted nor imported. Relation values are emitted on read and ignored on write, because the relation table is authoritative. The pagetitleis the exception: the serializer library carries atitlekey, but the API never writes one and never reads one — a title is structure, changed withPATCH …/pages/{pageId}.
Block mapping
Canonical form per P1 body block. All richtext follows the inline rules below.
| Block | Canonical Markdown |
|---|---|
paragraph |
plain line of inline content |
heading |
#, ##, ### (levels clamp to 1–3) |
list_item (bullet) |
- item |
list_item (ordered) |
1. 2. … (renumbered sequentially from 1) |
todo |
- [ ] item / - [x] item |
quote |
> … blockquote |
toggle |
> [!toggle] summary callout (see below) |
callout |
> [!note] … / > [!note|<icon>] … callout (see below) |
code |
fenced ``` block with optional language info string |
divider |
*** thematic break |
image |
 on its own line, plus {width=50% align=center} when a layout is chosen |
table |
GFM pipe table (see below) |
Headings
# H1, ## H2, ### H3. Input heading levels 4–6 clamp to ###; setext
headings (=== / --- underlines) normalize to ATX. An empty heading is the
bare hashes (#).
Lists (bullet, ordered, task)
-
Bullets always use
-. Ordered lists always use1.2.… renumbered from 1 regardless of the input's start value or)vs.markers. -
Lists are tight (no blank lines between sibling items).
-
Task items are GFM checkboxes:
- [ ](open) /- [x](done). Input[X]normalizes to[x]. -
Nesting is by indentation: bullet/ordered content indents 2 spaces (past
-/ the checkbox), matching the marker width; an ordered item indents 3 (past1.). A list item's children are its nested blocks. -
Adjacent list items of the same kind (bullet vs ordered, and task vs non-task) form one Markdown list; a change of kind starts a new list.
-
A child is any block, not only a nested list — an image, a code block, a second paragraph. It belongs to the item when it is indented to the marker width and to the list's parent when it is not, which is CommonMark's own rule:
- Step one An item whose own text is empty renders as a bare marker line with the child indented under it (
-then two spaces), because a list item may begin with at most one blank line and the marker line already is that line.
Quote
> A quoted paragraph.
>
> - nested content
The quote's own richText is its leading line; further blocks are its
children. A quote whose own text is empty never leads with a paragraph child
(the paragraph would be re-absorbed as the quote's own text).
Toggle (collapsible) — Obsidian callout form
> [!toggle] Summary text
>
> Body block one.
>
> - body list
- The first line of a blockquote that reads
[!toggle](optionally with a fold hint+/-, which is not persisted) is a toggle — Obsidian callout semantics. Because CommonMark resolves\[and[identically, this is unambiguous. - The summary rides the marker line and becomes the toggle's
richText; the remaining blocks become itschildren. - An empty-summary toggle is
> [!toggle]. - A blockquote whose first line is
[!toggle]…can therefore never be a plain quote. Since the callout block arrived, that is true of any[!…]marker: a blockquote leading with one is a toggle or a callout, never a quote.
Callout / highlight ("Hervorhebung") — Obsidian callout form
> [!note] A plain callout
> [!note|💡] With a chosen icon
>
> Body block one.
Structurally identical to the toggle above — a blockquote whose first line carries a marker — because it is the same shape, and following the toggle's precedent keeps one convention rather than two. The differences:
- The type token is
note, nottoggle.[!toggle]is reserved; every other type token in a blockquote's first line reads as a callout. - A user-chosen icon rides the marker after a
|:[!note|🎉]. Obsidian's syntax has no icon slot, and keepingnoteas the leading type token means the block still renders as a callout in any Obsidian-compatible tool. - The icon is percent-encoded for
% ] | [ * _ \~ < > &:` and whitespace — the characters that would end the marker, split the text node it is matched on, or be turned into an autolink by GFM. Emoji pass through untouched, so the common case stays legible. (Backslash escapes cannot be used: remark decodes them before the parser sees the text, exactly as for wiki-link labels.) - The lead line rides the marker line and becomes the callout's
richText; the remaining blocks become itschildren. An empty lead is> [!note]. - Callout types from other tools keep their meaning:
[!warning],[!tip],[!info],[!important],[!success],[!danger],[!question]and a few more are read as a callout carrying the icon shown for that type, and re-serialize as[!note|⚠️]etc. An unknown type degrades to the neutral, icon-less form.noteitself is deliberately NOT in that table — it is the canonical icon-less spelling and must stay one.
Table — GFM pipe table
| Region | Owner | Budget |
| :--- | --- | ---: |
| North | Ada | 1200 |
| South | *Grace* | 900 |
The block model is scoped to exactly what a pipe table can express, and
nothing more: one header row (which also carries the per-column alignment),
rectangular body rows, and inline-only cells. No merged cells, no block content
inside a cell, no column widths — none of them has a pipe-table form, so none of
them is representable (TableContentSchema, and the editor's cell content
expression is a single paragraph).
- Canonical form: every cell padded with one space (
| a |); delimiter row---,:---,---:,:---:; at least one column. - Ragged input converges: a short row is padded with empty cells and a long one truncated to the header's width — which is what GFM does on read, so normalization is a single step and idempotent.
|in a cell is escaped everywhere in the cell — inside a code span and inside a link destination too, per GFM. GFM counts a pipe as escaped when the backslash run in front of it is odd, so the escaper adds one backslash to an even run and two to an odd one.- The one lossy case in the dialect: a pipe inside a code span preceded
by an odd number of backslashes. A code span's backslashes are literal (they
cannot themselves be escaped) and GFM's row splitter consumes exactly one, so
the content's backslash run before a pipe is always even after a round trip.
Such a cell gains one backslash rather than breaking the row into an extra
column, and is exact from the second write on (the result has an even run).
This is the table's equivalent of the mention-label
]gap. - A table has no
children: its cells are content, andflattenBlocksemits exactly one projection row for a whole table.
Code
```lang
code text
```
- The info string is the language (first whitespace-delimited token, backticks stripped); omitted when there is no language.
- The fence length grows past the longest internal backtick run so any content fences safely. Indented code blocks normalize to fenced.
- View-time syntax highlighting (
@tiptap/extension-code-block-lowlightin the web editor) is schema-compatible and does not affect this serialization. - Whether long lines WRAP has no carrier here, deliberately. It is a reader's view preference — a device setting plus a per-block, per-session override that the editor remembers for you — not a property of the document, so the info string stays the language and nothing else. Do not invent an info-string flag for it: it would describe one reader's column width to every other reader, and writing it would make reading an edit.
Link embeds — bookmark card and iframe (basalt-embed)
A pasted URL can be inserted four ways. Two are ordinary Markdown and need no extension:
<https://example.com/post> plain link
[How the analytical engine worked](https://…) titled link
The other two — a bookmark card and an iframe embed — have no Markdown
form, so they are a fenced block with a basalt info string, the
same convention the inline database embed uses (basalt-view):
```basalt-embed
kind: card
url: https://example.com/post
title: How the analytical engine worked
description: A short account of the mill and the store.
site: example.com
image: https://example.com/og.png
icon: https://example.com/favicon.ico
```
- Body grammar: one
key: valueper line, order-insensitive, values are single-line.urlis required and must behttp(s); so mustimageandicon, on both write and read — these end up as<a href>,<img src>and<iframe src>, and this block can be written by hand through the MarkdownPUTsurface. kindiscardoriframe. An unknown or missingkindreads ascard, the form that asks least of the reader's browser.- Unknown keys are ignored, so a field added later does not make an older client drop the block.
- A fence with no usable
urlstays an ordinary code block. Nothing disappears silently — a hand-writtenbasalt-embedthat is not a real embed is visible text. - On the storage side this is not a new block type at all: it is a
codeblock whose language isbasalt-embed, so it travels the existing block ⇄ PM ⇄ Markdown pipeline and the round-trip property suite covers it already.
The metadata is a snapshot, not a live mirror. title, description,
site, image and icon are written when the block is inserted and are not
refreshed on render. That is what makes the .md file self-describing in
another tool (the fence shows the URL and what it was about), and it is what
keeps rendering a page from causing outbound requests: fetching a URL's
metadata happens once, when the card is inserted, and only if link previews are
switched on for the instance at all.
The cost is staleness: a retitled page keeps the old title until somebody
re-inserts the card, which is the right trade for a bookmark.
In another Markdown tool the block shows as a code block rather than a card. That is the honest failure mode of the fence convention: the URL, title and description are all present as legible plain text, greppable and losslessly re-importable. A reader loses the picture, never the content.
Divider
*** (never ---, which would collide with the frontmatter fence at document
start). Input --- / ___ / - - - normalize to ***.
Image (structural)
A lone image on its own line () is a structural image block.
An image amid other inline content is not a block — it degrades to a link
(P1 has no inline-image node).
- External URLs serialize as-is:
. - Attachment-backed images use
. (The presigned upload pipeline is later; the structural block + URL/attachment form exist now.) altis escaped as full inline text (see below) so markup-looking alt text (*a*,[x]) round-trips as a literal string.
Width and alignment. A chosen width and/or alignment ride the image as a Pandoc-style attribute block appended with no space before it:
{width=50% align=center}
{align=right}
widthis an integer percent of the content column, 10–100 — never pixels. The same document is read on a 390px phone and a 1600px desktop, and a share of the column is the only unit that means the same thing on both.width=100%is not the same as no width: it stretches a small image to fill the column, which the absent form never does.alignisleft|center|right. Absent means the reader's default (flush with the text), which is not the same stored value asleft.- Fixed write order,
widththenalign, one space between. An image with neither attribute is written exactly as it always was — no braces — so every.mdproduced before this form existed is still canonical. - Unknown keys inside the block are ignored (the same rule the
basalt-embedfence states above), so an attribute a later version writes beside awidthor analigndoes not turn a working image into a paragraph here. The block has to name at least one key this version knows, though:{foo=bar}on its own is not a layout block and stays literal text, because the alternative is deleting words a writer typed. And an unusable value for a key that is known ({width=300px},{align=justify}— also when it stands next to a usable one, as in{width=50% align=justify}) leaves the whole block as literal text rather than silently dropping half of it. - The block is only read when it is the entire text following the image. A
space before it (
 {see note}) is prose, and that paragraph degrades like any other paragraph containing an image.
In a foreign Markdown tool the picture still renders — the  is
untouched — and the brace block simply shows as literal text beside it. That is
the opposite trade from the basalt-embed fence above, and deliberately so:
for a bookmark card the picture is decoration, but for an image the picture is
the content, so it is the one thing that must not be lost.
Inline (rich text)
Marks: bold (**), italic (*), strike (~~), highlight
(<mark>…</mark>), text colour (<span data-color="…">…</span>), code
(backticks), and links ([text](href)). That is the whole mark set; unknown
marks (e.g. underline) are dropped on parse. Hard line breaks
degrade to a space; soft breaks collapse to a space.
Canonical mark handling:
- Mark nesting order, outermost first:
link,bold,italic,strike,highlight,color,code.codeis a leaf (never wraps other marks). - Adjacent spans with identical mark sets are merged; empty spans dropped. Two marks are the same only if their VALUE matches too — a link's href, a colour's name — so a red run beside a blue one stays two runs.
- At each position the mark covering the longest following run opens outermost (ties broken by the order above), so mixed runs stay CommonMark-parseable.
link,highlightandcolorare bracketing marks: they open outermost, and an emphasis run never ends on a span carrying one (CommonMark flanking rules around[/)and</>).
Highlight
CommonMark has no highlight syntax, so the canonical form is the HTML element:
plain <mark>marked</mark> again
==text== was rejected deliberately: it would need a micromark extension and
an escaping scheme that cannot work — remark decodes \= to = before the
parser sees the text, so escaped and unescaped == are indistinguishable and any user
text containing == would break the round trip. < and > are escaped in
every text run, so a literal <mark> a user types serializes as \<mark\> and
parses back as text; the element is therefore unambiguous, and every Markdown
renderer already displays it.
Text colour
CommonMark has no colour syntax either, so — like the highlight — the canonical form is an HTML element:
plain <span data-color="blue">blue</span> again
The value is a palette name, never CSS. The palette is the closed set of
nine shared with icon tinting (gray,
brown, orange, yellow, green, blue, purple, pink, red). A name
outside it is not a colour: the span degrades to literal text like any other raw
inline HTML, so the contract cannot be used to smuggle arbitrary CSS into a
page. On parse, data-color is accepted double-quoted, single-quoted or bare.
Why a name and not a hex value: each palette name resolves to a pair of theme
tokens (--bs-icon-*, light and dark), so coloured text stays legible when the
reader switches theme — which a hand-picked colour cannot promise. It also keeps
the serialized form short and reviewable.
Nesting is allowed and round-trips as written (<span data-color="red">a<span data-color="blue">b</span></span>); the editor never produces it, because a
ProseMirror mark excludes its own type.
Escaping (determinism)
- Every text run backslash-escapes
\ ` * _ [ ] < > ~ & # ! @and defuses autolink literals (www.→www\.,http(s)://→http\://) so plain text never becomes a link on re-parse. - Line-start bullet (
-,+) and ordered (1.,1)) markers are escaped in paragraph/heading text. - Code spans pad and size their fences per CommonMark's stripping rules.
- Link/image destinations re-encode
&and use the<…>form when they contain spaces/parens/brackets.
Degradation (non-canonical input)
Constructs outside the P1 block set normalize deterministically (idempotent
under normalize):
- Heading levels 4–6 →
###; setext → ATX. - Raw HTML (block and inline) → literal text (escaped on re-serialize).
- Link/image reference definitions and footnotes → dropped; visible text kept.
- Inline images → link (or alt text).
API surface
GET /api/v1/workspaces/:workspaceId/pages/:pageId?format=markdown→text/markdownbody (canonical serialization of the page's current body blocks, read from the live Yjs doc) + a strongETag.PUT …/pages/:pageId?format=markdownwith atext/markdownbody → parses to body blocks and applies them through the doc mutator in one Yjs transaction, never as a direct table write.If-Match: <etag>enforces optimistic concurrency; a mismatch is412.If-Matchabsent = unconditional write;If-Match: *matches any existing representation.- Response echoes the stored canonical Markdown + the new
ETag.
The ETag is a strong validator derived from the canonical Markdown bytes
(SHA-256). Equal content yields an equal ETag, so it is stable across doc
compaction and reflects exactly the representation the API serves. This is the
honest limitation: Markdown PUT is last-writer-per-block under
If-Match, not a character-level CRDT merge (agents edit transactionally;
humans edit live in the collaborative editor).
Block identity (attrs.id) is preserved by the mutator's clone across a
replace, so the projection and any references stay stable even though the
Markdown text itself carries no ids in P1 (^anchors arrive in P2).
Clipboard
The clipboard is the same contract, reached by a gesture instead of a request. It adds no syntax: the browser runs the very parser and serializer this document specifies — the same code, not a second implementation of it — so what a copy produces is a document this spec's round-trip guarantee already covers. The rules that are specific to a fragment, and therefore visible here:
Copy writes canonical Markdown to text/plain, alongside the text/html
flavour ProseMirror has always written.
- A selection that spans block boundaries emits the block form — byte-for-byte
what
GET …?format=markdownwould emit for those blocks, minus the trailing newline (a clipboard string is not a document). - A selection strictly inside one textblock emits the inline form: the
rich text alone, with no block marker. Selecting two words in a heading yields
two words, not# two words— and the same holds however deep that textblock sits, so two words out of a list item, to-do, quote, callout, toggle summary or table cell yieldtwo wordstoo, never- two words. Edge whitespace is trimmed, because CommonMark strips whitespace at a block's edges and could not read it back. - A partially selected code block emits its text verbatim, unescaped: it is not prose, and escaping would corrupt the characters that make it code.
- Frontmatter is never emitted — a selection is a body fragment, and page field values belong to a page.
- Because
text/htmlis still written, a paste back into the app takes the HTML flavour, so block ids and slice context survive an internal copy exactly as before. The Markdown text is what every other target receives.
Paste of plain text turns Markdown into real blocks.
- Only text carrying a Markdown signal is parsed as Markdown — an ATX heading,
a bullet/ordered marker, a quote marker, a fence, a pipe-table row, a thematic
break, a leading
---frontmatter fence, or an inline**bold**/~~strike~~/`code`/[text](url)/[[wikilink]]. Everything else pastes as it did before, one paragraph per line: CommonMark folds single newlines into soft breaks, and a hard-wrapped email must not silently collapse into one paragraph. - Pasted blocks always get fresh ids. A fragment is new content, not a re-statement of blocks that exist.
- Pasted frontmatter is dropped. A fragment has no page whose field values it could set.
- A bare URL is not a Markdown paste: it stays with the link-paste gesture (plain link, bookmark card, or embed).
- Pasting into a code block inserts verbatim text, unchanged.