Basalt docs
GitHub

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):

  1. parse(serialize(doc)) ≡ doc for every canonical document.
  2. 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
---
  • title is 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 title and no props.

Note: the GET/PUT …?format=markdown API 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 ![alt](url) 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 use 1. 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 (past 1. ). 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
    
      ![Diagram](diagram.png)
    

    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 its children.
  • 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, not toggle. [!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 keeping note as 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 its children. 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. note itself 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, and flattenBlocks emits 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-lowlight in the web editor) is schema-compatible and does not affect this serialization.

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: value per line, order-insensitive, values are single-line. url is required and must be http(s); so must image and icon, 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 Markdown PUT surface.
  • kind is card or iframe. An unknown or missing kind reads as card, 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 url stays an ordinary code block. Nothing disappears silently — a hand-written basalt-embed that is not a real embed is visible text.
  • On the storage side this is not a new block type at all: it is a code block whose language is basalt-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 (![alt](url)) 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: ![alt](https://example.com/x.png).
  • Attachment-backed images use ![alt](attachment:<uuid>). (The presigned upload pipeline is later; the structural block + URL/attachment form exist now.)
  • alt is 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. code is 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, highlight and color are 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=markdowntext/markdown body (canonical serialization of the page's current body blocks, read from the live Yjs doc) + a strong ETag.
  • PUT …/pages/:pageId?format=markdown with a text/markdown body → parses to @basalt/core body 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 is 412.
    • If-Match absent = 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).