Fidelity and Unsupported Syntax

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.

AuthoredAfter a saveWhy
## 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 cellCells serialize with inline content only; no braces are written
@tab python {class="lang-tab"}@tab pythonA 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 2The 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 prosebackslash-escapedSame escape rule
Your attribute orderreordereddata-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

FeaturePublished siteCMS editor
Raw HTML pass-throughRenders verbatimParse error
Heading and paragraph class / idYesNot modeled; dropped on save
Inline-code attributesNo — a bare <code> either wayNo
Table cell colspan / rowspan / {.class}YesRenders, but no control and not serialized
Table column alignmentYesLost on save
Tab-label attributesYesLost on save
Arbitrary {% form %} parametersYesLost on save
Nested tab groupsBroken — the inner tabs flatten into the outer groupNot insertable
An alert type outside the five — :::customFalls through; renders as an ordinary paragraphAccepted — becomes an alert node with type: "custom", unstyled, and is written back out as :::custom
Bare-URL autolinkingNoYes
Smart typography inside a tab panelOffNot applicable
Highlight colorNo — every <mark> is the theme’s defaultEditor only; stored but never emitted
Tracked suggestions, comments, addition and deletion marksNoEditor only; stripped on serialize
The resolved-suggestions archiveNoEditor only; never serialized
Emoji reactions and block commentsNoEditor only
data-code-tabs on a tab groupNoEditor only; excluded on serialize
Footnotes and definition listsNoNo

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.

BehaviorBug or by designWorkaroundOwning page
A numbered list started at N in the editor publishes starting at 1Bug — 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 editorLists and Task Lists
Table column alignment is flattened on the first saveBug — the serializer writes a fixed delimiter row regardless of the alignment it parsedSet alignment in CSS against the table’s own class, not in the delimiter rowTables
An icon written with color="red" publishes as broken markupBug — the color is emitted inside the positional class list and copied straight into the class attributeUse the Tailwind text-… and dark:text-… classes, which round-trip correctlyEmbeds 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.

InputResult in the editor
<div class="x">hi</div>MarkdownParseError: Could not parse the provided markdown
Some <u>underlined</u> textMarkdownParseError: Could not parse the provided markdown
line<br>nextAccepted — 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 featureWhat actually happensUse 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 : definitionNot implemented. Renders as one plain paragraphA two-column table
$…$ and $$…$$ mathNot implemented. Renders as literal dollar signsThe math inline fence and the math block fence — see Math
Bare-URL autolinking on the siteNot enabled. https://example.com in prose stays textWrite the link — [example.com](https://example.com)
Frontmatter written inside a bodyNot a thing. Frontmatter is JSON generated at publish time from the page recordSet 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.

ESC