Leed Markdown has two readers. The published site renders it with markdown-it and a stack of Leed plugins; the CMS editor parses it into a ProseMirror document and writes it back out again. Those two implementations do not accept exactly the same language, and the site’s is the more permissive of the pair. Every entry on this page follows from that single fact — including the useful corollary that markdown which renders perfectly on your site can still fail to import.
The shape of a round trip
Markdown goes into the editor, becomes a ProseMirror document, lives as a collaborative Yjs document while anyone is editing it, and is serialized back to markdown when the page is published or read over MCP. Anything the ProseMirror schema does not model cannot survive that trip, because there is nowhere in the document for it to live.
flowchart LR
MD["Leed Markdown"] --> P["parseMarkdownToProseMirror"]
P --> Y["Y.Doc (collab-provider)"]
Y --> S["serializeBodyToMarkdown"]
S --> MD2["Leed Markdown"]
MD --> R["markdown-it → published HTML"]
P -.->|"raw HTML throws here"| PN["MarkdownParseError"]
Y -.-> YN["anything the schema does not model is gone"]
S -.-> SN["alignment, cell spans, tab-label attrs,<br/>extra embed params dropped; { } : @ escaped"]
R -.-> RN["more permissive: renders things<br/>the editor cannot read back"]
classDef note fill:none,stroke-dasharray:3 3;
class PN,YN,SN,RN note;
The losses are therefore not arbitrary. They are all the same loss, observed at different places in one cycle.
What a save discards
The editor re-emits data-id and data-assetid plus the attributes each node type actually models, and nothing else. Everything below is dropped the first time a page is opened and saved — including a save triggered by an MCP edit.
| Authored | After a save | Why |
|---|---|---|
## Head {.cls #hid data-id="h1"} | ## Head {data-id="h1"} | A ProseMirror heading has no class or id attribute |
Para {.cls data-id="p"} | Para {data-id="p"} | Same, for paragraphs |
`code`{data-id="ic"} | `code` | The inline-code serializer writes bare backticks |
1. Item {start="14"} | 1. Item {start=14 …} | The start value is re-emitted unquoted, which the site then ignores |
| ` | :— | ----: |
A cell with colspan / rowspan / {.class} | a plain cell | Cells serialize with inline content only; no braces are written |
@tab python {class="lang-tab"} | @tab python | A tab models label, index and id only |
{% form formId="f" autosave="true" %} | {% form formId="f" data-id="fm" %} | The form serializer writes formId and data-id and nothing else |
{% audio path=… controls autoplay %} | {% audio path=… assetId=… autoplay="true" data-id=… %} | Bare boolean flags become explicit values, and a flag at its default disappears |
{% cloudflare … thumbnailTimestamp="31.1" %} | … autoplay="false" muted="false" loop="false" controls="true" allowFullscreen="true" | Video defaults are materialized; thumbnailTimestamp is dropped |
{% icon fa-solid fa-home fa-2x %} | {% icon fa-solid fa-home 2x %} | The size attribute is re-emitted without its fa- prefix |
> Line 1 / > / > Line 2 | > Line 1 / > Line 2 | The blockquote serializer writes each text block as one > line, so a paragraph break inside a quote is lost |
| A Handlebars comment in body text | \{\{!-- c --\}\} | Literal {, }, : and @ are backslash-escaped on the way out |
Any literal {, }, : or @ in prose | backslash-escaped | Same escape rule |
| Your attribute order | reordered | data-id first on a block, last on an embed |
That escape rule is the most confusing artifact you will meet in a round-tripped file. It is not corruption: the backslashes are there so the braces cannot be re-read as an attribute block on the next parse, and they do not appear in the rendered page.
What one layer has and the other does not
| Feature | Published site | CMS editor |
|---|---|---|
| Raw HTML pass-through | Renders verbatim | Parse error |
Heading and paragraph class / id | Yes | Not modeled; dropped on save |
| Inline-code attributes | No — a bare <code> either way | No |
Table cell colspan / rowspan / {.class} | Yes | Renders, but no control and not serialized |
| Table column alignment | Yes | Lost on save |
| Tab-label attributes | Yes | Lost on save |
Arbitrary {% form %} parameters | Yes | Lost on save |
| Nested tab groups | Broken — the inner tabs flatten into the outer group | Not insertable |
An alert type outside the five — :::custom | Falls through; renders as an ordinary paragraph | Accepted — becomes an alert node with type: "custom", unstyled, and is written back out as :::custom |
| Bare-URL autolinking | No | Yes |
| Smart typography inside a tab panel | Off | Not applicable |
| Highlight color | No — every <mark> is the theme’s default | Editor only; stored but never emitted |
| Tracked suggestions, comments, addition and deletion marks | No | Editor only; stripped on serialize |
| The resolved-suggestions archive | No | Editor only; never serialized |
| Emoji reactions and block comments | No | Editor only |
data-code-tabs on a tab group | No | Editor only; excluded on serialize |
| Footnotes and definition lists | No | No |
The three “editor only” rows in the middle are the collaboration layer, not content. They are supposed to disappear at publish time; nothing is lost when they do.
The alert row inverts this page’s lead, which is why it is worth reading twice: here the editor accepts more than the site does. The site registers its five containers by name, so a sixth name is simply not a container; the editor’s fence pattern captures any word and stores it, and there is no list of permitted types anywhere in the schema to reject it against. The failure is silent in both directions — a plain paragraph on the site, a colorless panel in the editor. Alerts enumerates the five that work.
Defects, not design
Three rows above are bugs rather than boundaries — the two layers disagree in a way neither one intends.
| Behavior | Bug or by design | Workaround | Owning page |
|---|---|---|---|
| A numbered list started at N in the editor publishes starting at 1 | Bug — the serializer writes {start=14} unquoted, and the site’s attribute-line parser reads only key="value" | Write {start="14"} by hand and do not re-save the page in the editor | Lists and Task Lists |
| Table column alignment is flattened on the first save | Bug — the serializer writes a fixed delimiter row regardless of the alignment it parsed | Set alignment in CSS against the table’s own class, not in the delimiter row | Tables |
An icon written with color="red" publishes as broken markup | Bug — the color is emitted inside the positional class list and copied straight into the class attribute | Use the Tailwind text-… and dark:text-… classes, which round-trip correctly | Embeds and Icons |
All three are tracked on Known Limitations, which is the page to check before you build a workaround — one of them may already be fixed.
Everything else on this page is a boundary rather than a defect: the editor models a document, and a document model is necessarily narrower than a text format.
Raw HTML
This is the sharpest edge in the format, and the one most likely to stop an import cold.
The published site renders arbitrary HTML verbatim. The editor’s parser cannot: the ProseMirror schema has no node for an HTML blob, so the parse throws rather than dropping the tag.
| Input | Result in the editor |
|---|---|
<div class="x">hi</div> | MarkdownParseError: Could not parse the provided markdown |
Some <u>underlined</u> text | MarkdownParseError: Could not parse the provided markdown |
line<br>next | Accepted — becomes a hard line break |
<br> is the sole exception.
If the HTML you need is structural — a two-column layout, a callout, a card — the Leed equivalents cover most of it: Alerts, Collapsible Sections, Tabs and Attributes between them replace most hand-written wrapper markup, and what remains usually belongs in a layout rather than in a page body.
Things Leed Markdown does not have
| Common markdown feature | What actually happens | Use instead |
|---|---|---|
Footnotes — Text[^1] | Not implemented. On the site the reference is parsed as an ordinary reference link and renders as a stray ^1 anchor; the editor escapes it to Text\[^1\] | An inline parenthetical, or a collapsible section holding the aside |
Definition lists — a term, then : definition | Not implemented. Renders as one plain paragraph | A two-column table |
$…$ and $$…$$ math | Not implemented. Renders as literal dollar signs | The math inline fence and the math block fence — see Math |
| Bare-URL autolinking on the site | Not enabled. https://example.com in prose stays text | Write the link — [example.com](https://example.com) |
| Frontmatter written inside a body | Not a thing. Frontmatter is JSON generated at publish time from the page record | Set the fields on the page itself; the contract is Front Matter Reference |
Two divergences that are neither loss nor bug
Bare URLs. The site renderer has link detection switched off; the editor’s parser has it on. So a bare URL that arrives through an import becomes a real link in the document — and then serializes back out as a real markdown link, at which point it works everywhere. One you type into a published file stays text. The behavior is inconsistent, but nothing is lost either way; the fix is to write links as links. Links and Internal Links covers the syntax.
Smart typography inside tab panels. Tab panels are rendered by a second markdown instance with the typographer disabled, so quotes, dashes and ellipses are converted outside a panel and left alone inside one. This is a rendering difference on the published page only; the stored markdown is identical either way. It is described in place on Tabs.
How to check your own content
Two checks catch nearly everything on this page, and both take a minute.
Diff what you sent against what came back. After any write, read the page back with get_page_markdown and compare it to the markdown you supplied. Every row in the discard table above shows up in that diff immediately, and the diff is the only reliable way to see them — nothing warns you at write time. Markdown for AI, MCP and Import covers the read-plan-apply loop this fits into.
Grep the built output for page-removed. A pageid: link whose target did not render in the build has its anchor replaced by a <span class="page-removed"> that keeps the link text, so a broken internal link looks completely normal on the page. Searching the built HTML for that class is how you find them.