Lithic docs
lithicapp.io

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:

  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 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
---
  • title is 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 title and no props.

Note: the GET/PUT …?format=markdown API emits and reads the page's field values as frontmatter, keyed by field key. 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 page title is the exception: the serializer library carries a title key, but the API never writes one and never reads one — a title is structure, changed with PATCH …/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 ![alt](url) 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 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 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 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 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 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 shown 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.
  • 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.

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: 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: 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 (![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.

Width and alignment. A chosen width and/or alignment ride the image as a Pandoc-style attribute block appended with no space before it:

![Diagram](https://example.com/d.png){width=50% align=center}
![Logo](attachment:0f0e…){align=right}
  • width is 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.
  • align is left | center | right. Absent means the reader's default (flush with the text), which is not the same stored value as left.
  • Fixed write order, width then align, one space between. An image with neither attribute is written exactly as it always was — no braces — so every .md produced before this form existed is still canonical.
  • Unknown keys inside the block are ignored (the same rule the basalt-embed fence states above), so an attribute a later version writes beside a width or an align does 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 (![a](u) {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 ![alt](url) 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. 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 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=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 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 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 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=markdown would 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 yield two words too, 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/html is 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.