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.
| Authored | After a save | Why |
|---|---|---|
## 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 heading | Inline 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 guide | A link is a mark too, so only its text survives and the destination is gone |
##  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 table | dropped, or cut to fit | A 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 structure | dropped | Neither is modeled; this is the one loss a save does not report |
| An image in a table cell | an empty cell | A 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 text | removed | Comments are stripped on import, as the site strips them from a template |
| A code block or table inside a list item | moved to directly after the outermost list | A 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 kept | Same 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-escaped | So the prose cannot be re-read as an alert, a tab or a collapsible |
| 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, 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
| Feature | Published site | CMS editor |
|---|---|---|
| Raw HTML | Rendered verbatim from a template | Kept as literal text — shown as typed on the page; an HTML comment is stripped |
Heading and paragraph class | Yes | Not modeled; dropped on save |
A user-written id ({#x}, {id="x"}) | No — dropped from every element | No — dropped and reported |
| Inline-code attributes | No — a bare <code> either way | class and data-id kept |
Table cell colspan / rowspan / {.class} | Yes | Yes — kept; Merge cells and Split cell set the spans |
| Table column alignment | Yes | Yes — kept; set from the table menu |
| Nested tab groups | Broken — the inner tabs flatten into the outer group | Not insertable; on import a nested group is lifted out to after the outer one |
An alert type outside the five — :::custom | An alert alert-custom aside, unstyled unless your theme styles it | Accepted — an alert node with type: "custom", written back out as :::custom |
| 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 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.
| Behavior | Bug or by design | Workaround | Owning page |
|---|---|---|---|
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 |
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.
| Input | Stored in the page body |
|---|---|
<div class="x">hi</div> | The text <div class="x">hi</div>, shown as typed |
Some <u>underlined</u> text | The text, tags included, shown as typed |
line<br>next | A hard line break |
A line holding only <br> tags | A paragraph of line breaks |
<!-- a note -->, inline or on its own line | Removed |
<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 feature | What actually happens | Use 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 link | 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 | Not enabled, in the editor or on the site. https://example.com in prose stays text whether it is imported, pasted or published | 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 |
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.