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. The two share one parser for every Leed construct, but the editor stores a document, and a document can hold less than a text format can. Every entry on this page follows from that single fact — including the corollary that markdown which renders perfectly from a template can come back from a CMS page body changed. What changes is now always reported, and it changes only once.

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 kept as text"| PN["comments stripped, ids dropped"]
    Y -.-> YN["anything the schema does not model is gone"]
    S -.-> SN["extra cell keys and embed params dropped;<br/>{ } escaped"]
    R -.-> RN["template Markdown can use<br/>what a page body cannot store"]

    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 or rewritten the first time a page is opened and saved — including a save triggered by an MCP edit. That first save is also the last change: the stored body is a fixed point, so a second save, a read over MCP or a publish changes nothing further, and the site renders the stored body exactly as the editor reads it. Nothing is lost silently either: a write reports every row below that loses content as dropped — a lost line or part of one under dropped_content, a stripped attribute group under dropped_attributes.

AuthoredAfter a saveWhy
## Head {.cls #hid data-id="h1"}## Head {data-id="h1"}A ProseMirror heading has no class attribute, and no element ever takes a user-written id
Para {.cls data-id="p"}Para {data-id="p"}Same, for paragraphs
## **Bold** heading## Bold headingInline code is the only mark a heading allows; any other mark is stripped and the text kept
## See [the guide](https://example.com)## See the guideA link is a mark too, so only its text survives and the destination is gone
## ![alt](photo.png) Title(nothing)An image is not a mark; it cannot live in a heading, and the whole heading goes with it
`code`{#c data-id="ic"}`code`{data-id="ic"}An id is dropped from every element; a code span keeps its class and data-id
A delimiter row with alignment colons, such as :--- or ----:the same alignment, as :--------- or ---------:Alignment is kept; the delimiter row is rewritten at a fixed width
A cell group such as {.red style="color:red" colspan="2"}{colspan="2" class="red"}A cell keeps colspan, rowspan and a class, and nothing else
An empty filler cell under a rowspan, or a span that overlaps another cell or runs past the tabledropped, or cut to fitA row lists only the cells that start in it, and a span never covers another cell
A cell colwidth or background color, stored before tables kept their structuredroppedNeither is modeled; this is the one loss a save does not report
An image in a table cellan empty cellA cell holds inline text only
@tab python {class="lang-tab" id="py"}@tab python {class="lang-tab"}A tab label keeps its class; the id is dropped
{% form formId="f" autosave="true" extra="1" %}{% form formId="f" autosave %}The form keeps formId, autosave, redirect 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
A Handlebars comment, or an HTML comment, in body textremovedComments are stripped on import, as the site strips them from a template
A code block or table inside a list itemmoved to directly after the outermost listA list item cannot hold either; the block is moved, in order, with nothing lost
Any literal { or } in prose, including a trailing group that is not attribute syntax such as {timeout}backslash-escaped, text keptSame escape rule; colons, at-signs and the inside of a code span are never escaped
A paragraph starting with :::, @tab, ===tabs-container, ===, +++ or ++>the first character backslash-escapedSo the prose cannot be re-read as an alert, a tab or a collapsible
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, and a leading marker cannot be re-read as a block, on the next parse. They do not appear in the rendered page.

What one layer has and the other does not

FeaturePublished siteCMS editor
Raw HTMLRendered verbatim from a templateKept as literal text — shown as typed on the page; an HTML comment is stripped
Heading and paragraph classYesNot modeled; dropped on save
A user-written id ({#x}, {id="x"})No — dropped from every elementNo — dropped and reported
Inline-code attributesNo — a bare <code> either wayclass and data-id kept
Table cell colspan / rowspan / {.class}YesYes — kept; Merge cells and Split cell set the spans
Table column alignmentYesYes — kept; set from the table menu
Nested tab groupsBroken — the inner tabs flatten into the outer groupNot insertable; on import a nested group is lifted out to after the outer one
An alert type outside the five — :::customAn alert alert-custom aside, unstyled unless your theme styles itAccepted — an alert node with type: "custom", written back out as :::custom
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 is worth reading twice: both layers accept any type name, and neither checks it against a list. The site renders an unknown type as an aside with the class alert-<type>, and the editor stores it and writes it back unchanged — but only the five types Alerts lists come with an icon and theme colors. Any other name is a plain panel until your own CSS styles it.

Defects, not design

One behavior is a bug rather than a boundary — the two layers disagree in a way neither one intends.

BehaviorBug or by designWorkaroundOwning page
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

It is tracked on Known Limitations, which is the page to check before you build a workaround — it may already be fixed. Two defects were on this list until they were fixed: a numbered list that published from 1 whatever its start was, which the editor now writes natively (14. Item), and table column alignment, which was flattened on the first save and is now kept, along with cell spans and classes. Tables has the form the editor writes them in.

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. Raw HTML no longer stops an import, but it does not survive one as HTML either.

The published site renders HTML written in a template verbatim. A CMS page body cannot hold it: the ProseMirror schema has no node for an HTML blob, so the editor keeps the tag as literal text — escaped, so that it reads back as the same text — and the published page shows it exactly as typed.

InputStored in the page body
<div class="x">hi</div>The text <div class="x">hi</div>, shown as typed
Some <u>underlined</u> textThe text, tags included, shown as typed
line<br>nextA hard line break
A line holding only <br> tagsA paragraph of line breaks
<!-- a note -->, inline or on its own lineRemoved

<br> is the only tag that becomes structure. Inside a code span or a fence, HTML and comments are code and are kept as written.

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. Without a definition the reference stays text, and the editor escapes it to Text\[^1\]; a [^1]: … line is read as an ordinary link reference definition, so the reference becomes a linkAn 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 autolinkingNot enabled, in the editor or on the site. https://example.com in prose stays text whether it is imported, pasted or publishedWrite 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

Divergences that are neither loss nor bug

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.

Glyph swaps on the site. Outside a tab panel, the site’s typographer swaps some characters for display: straight quotes become curly quotes (“”, ‘’), -- and --- become – and —, (c), (r) and (tm) in either case become ©, ® and ™ (so their case is lost), +- becomes ±, and exactly three dots become …. It never drops a character you wrote: !!!!, ????, ,,, .. and .... render as written, and ?... renders as ?…. The editor and the CTA settings editor store and show text exactly as typed, so the stored markdown never changes.

How to check your own content

Two checks catch nearly everything on this page, and both take a minute.

Read the write’s report, then diff. set_page_body and create_page_from_markdown return a validation array listing what the write cleaned or dropped — a lost line under dropped_content, a stripped attribute group or id under dropped_attributes — and every body tool returns a warning when it stripped an attribute group. To see exactly what was stored, read the page back with get_page_body and compare it to the markdown you supplied; every row in the discard table above shows up in that diff. 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