Basalt Markdown Spec (P1)
Status: living document, written with the serializer (ADR-0012).
Markdown is Basalt's 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, enforced by the CI property gate
(packages/markdown/src/roundtrip.property.test.ts):
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/views are deferred (P2/P3) — the seams exist in
packages/markdown/src/extensions.ts but are not parsed in P1.
Implementation: packages/markdown (unified/remark parser + a hand-written
canonical serializer), depends on @basalt/core only.
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(ADR-0013). In P1 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 in P1 serializes the body only (doc plane). Frontmatter (title/props) round-trips through the serializer library but is not yet written by the Markdown API route — page title and field values are structure-plane and get their Markdown surface with the field registry in P3.
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 |
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 Basalt does not persist) 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 Basalt 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 Basalt shows 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.
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 per ADR-0012 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 — see
Configuration → Link previews.
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.
Inline (rich text)
Marks: bold (**), italic (*), strike (~~), highlight
(<mark>…</mark>), text colour (<span data-color="…">…</span>), code
(backticks), and links ([text](href)). This is exactly the @basalt/core 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 Basalt
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 (ICON_COLORS in @basalt/core: 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 (ADR-0012)
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@basalt/corebody blocks and applies them through the doc mutator in one Yjs transaction (never a direct table write — ADR-0002).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 ADR-0012 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).