You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
How should Leafdown safely render raw Markdown HTML as live editor content while retaining a coherent in-document Markdown source-editing experience?
Context
Leafdown currently preserves raw HTML as code-like text and does not render it as browser DOM. The product direction is now to render raw HTML live in the editor.
The remaining uncertainty is the safe rendering boundary: supported elements and attributes, URL and resource-loading policy, fallback behavior for unsafe content, and how inline and block HTML enter source editing without detached input widgets.
Typora is the interaction reference: inline HTML renders with surrounding content and block HTML can enter a source-editing mode. Its security model is not adopted by implication; Leafdown defines its own policy in this spike.
The installed editor engine is Milkdown Kit 7.21.3, not 7.21.2 (@milkdown/kit is pinned exactly in package.json). 7.22.0 is the current upstream release; its changelog contains no raw-HTML work, so the findings below hold for both.
Raw HTML is not only "preserved as code-like text" today. When the caret is at an html node, markerPresentation.ts renders a detached <input aria-label="Markdown source"> widget, and html is its only consumer (SOURCE_NODE_NAMES = new Set(["html"])). The detached widget Render safe raw HTML live in the editor #62 removes is this one.
Conclusion
Render raw HTML live only where the stored token is a complete element that the allowlist accepts unchanged; everything else keeps today's code-like text. The recommended data flow is:
The html node's value string stays the single source of truth and the only thing serialized. Nothing in the parse or serialize path changes.
At view time a NodeView parses valueonce into a detached <template>, inspects the parsed tree against a Leafdown-owned allowlist, and — if it passes — adopts those same parsed nodes into the rendered DOM.
A failing fragment renders exactly as today: data-type="html" code-like text.
Parse-once/inspect/adopt is the security property that matters: there is no second parse for a mutation-XSS payload to diverge into, because the tree that was inspected is the tree that is inserted.
The render predicate is a single conjunction, and rendering happens only when it holds:
value parses to exactly one child node, and that node is an Element. Comments, processing instructions, and CDATA are excluded — they render as nothing at all, leaving content the user cannot see or place a caret in.
The element and every attribute it carries are on the allowlist.
The fragment is self-contained: the element is void, or the trimmed source ends with the matching </tag>.
Sanitization is a structural no-op — the inspected tree is identical to the parsed tree.
The last condition is deliberate. A fragment that sanitization would change is never rendered in altered form; it falls back to text, so what renders and what saves can never disagree, and there is no partially-sanitized state to specify or test.
Why this shape, and not the obvious one
CommonMark raw HTML is a token stream, not a tree, and Milkdown stores each token as an inline atom carrying one value string (html.ts, atom: true, inline: true). Measured against the corpus fixtures:
Source
Stored as
<div class="note">Block</div>
one atom, whole element
<section class="garden">\n*Markdown*\n</section>
one atom, newlines intact
<details>…blank line…</details>
three nodes: <details>\n<summary>…</summary> atom, a real Markdown paragraph, </details> atom
Text <span class="leaf">raw *inline* HTML</span> after
five nodes: text, <span class="leaf"> atom, text, emphasis mark, text, </span> atom
So most stored tokens are fragments of an element whose content lives in sibling nodes. An inline atom cannot own those siblings, which is why "render each html node" does not work. A prototype NodeView confirmed the failure concretely: <span class="leaf">inline</span> rendered as an empty <span class="leaf"></span>, with the word inline outside it and the closing tag rendering as nothing.
The valuable renderable case is therefore self-contained block-level HTML, which is also the case CommonMark tokenizes whole. Void inline tags (<br>, <img>) are the only self-contained inline tokens.
Rejected alternatives
Pair adjacent open/close atoms into one rendered element. Merging in a remark transformer destroys the Markdown inside: <span>**bold**</span> currently yields a real strong mark, and merging makes it literal, changing document semantics to gain presentation. Pairing at view level is not available either — decorations cannot re-parent a sibling range into a wrapper — and a mark-based representation cannot express the unbalanced tags the corpus already covers (<em> opened, </strong> closed).
Reconstruct the element tree (rehype-raw-style pipeline, HTML in the schema). Moves Markdown ownership out of Milkdown's remark bridge, which Use Milkdown Kit decided against, and contradicts Accept Milkdown GFM preset behavior. Round-trip fidelity for every unbalanced fragment in corpus/commonmark/html.md becomes Leafdown's to maintain.
DOMPurify. It is present in the pnpm store at 3.4.12 only as a transitive dependency of @milkdown/components, and is not resolvable from the project root, so this is adding a dependency rather than using one. It would re-parse a string this design has already parsed — reintroducing the second parse that parse-once/adopt exists to remove — and would still not spare us the allowlist, because Leafdown's is far narrower than DOMPurify's defaults. Reconsider if the allowlist ever needs URL-bearing or namespaced content (SVG, MathML), where its hardening earns the dependency.
Element.setHTML() (Sanitizer API).MDN states it is not Baseline and "does not work in some of the most widely-used browsers". Leafdown targets three WebViews, so this needs a fallback path anyway, and a feature-detected sanitizer means two behaviors and two test matrices.
Keep text-only. Ruled out by the product direction in this issue and Render safe raw HTML live in the editor #62; it remains the fallback if the effort boundary below is exceeded.
Allowlist
No attributes are allowed in the first slice. class and id are excluded deliberately: class would let document content reach into application CSS, and id introduces duplicate-fragment hazards. style is excluded and cannot be delegated to CSP, because style-src includes 'unsafe-inline'.
Tier 1 (implement in Render safe raw HTML live in the editor #62):br, b, strong, i, em, u, s, del, ins, mark, sub, sup, code, kbd, samp, var, abbr, small, span, and self-contained div, p, section, details, summary, hr, dl, dt, dd.
Tier 2 (deferred): URL-bearing elements — a[href], img[src|alt|width|height]. These must route through the existing backend resolution (resolve_markdown_link_target, resolve_markdown_image_target) rather than gain a second, weaker path. Keeping them out of the first slice keeps the whole URL-policy surface out of it and makes Render safe raw HTML live in the editor #62's "existing Markdown link and local-image safety policies remain intact" true by construction.
Security boundary
Sanitization is the first layer and CSP the second, not the reverse. The configured CSP blocks inline script and javascript: (script-src 'self', no 'unsafe-inline'), remote loads (default-src 'self'; img-src 'self' asset: http://asset.localhost), form submission, and <base>. Its one gap for this work is style-src 'unsafe-inline', which the allowlist covers by admitting no style attribute.
Script execution inside the editor surface is high severity here rather than cosmetic: withGlobalTauri: true exposes the IPC bridge to any executing script, and open_markdown_link_target accepts allow_outside_folder from its caller, so the outside-folder confirmation is a frontend workflow rather than a backend guard.
In-document source editing
Use the existing source-projection engine with a new html adapter, modeled on sourceProjectionFootnoteReferenceAdapter.ts. That adapter already solves the same problem for an atom: caret adjacency or NodeSelection finds the target, the atom is replaced by its source as literal document text, and finalization re-parses or commits literal text. Inline and block HTML use one adapter; they differ only in whether value contains newlines. History, dirty state, clipboard, and finalize-before-serialize come from the engine unchanged. markerPresentation.ts's detached <input> and its parseSourceNode/serializeSourceNode helpers are deleted, since html is their only consumer.
Two questions about multi-line block HTML source editing are unresolved, and #62 should settle them in that order.
Whether multi-line projected source works at all. createLiteralSourceProjectionSlice builds schema.text(source), and whether ProseMirror preserves \n in a paragraph text node through its DOM observer was not tested here. If it does not, the second question is moot.
If it does, Enter still ends the session. sourceProjection.ts finalizes on event.key === "Enter" with no modifier check, then returns false so the normal keymap still runs. Letting an html projection keep Enter means adding a key hook to SourceProjectionAdapter, which has none today — engine-level work that puts every existing adapter's Enter behavior in scope, not a local addition.
If either answer is unfavorable, the cheap first slice is to restrict live rendering to single-line tokens, or to project multi-line block source read-only, and to handle multi-line editing separately.
Markdown preservation
Serialization is unaffected by construction: rendering reads value and never writes it, and sanitization never rewrites it. This was verified rather than assumed — a prototype NodeView rendering live DOM for both a block and an inline fragment produced a byte-identical getMarkdown() round trip.
Defect found: authored <br> is silently deleted
Independent of this spike and pre-existing. remarkPreserveEmptyLinePlugin in @milkdown/preset-commonmark deletes every mdast html node whose trimmed value is exactly <br />, <br>, <br >, or <br/>, anywhere in the tree, to support Milkdown's own empty-paragraph round trip. It is unconditionally part of the commonmark composed preset Leafdown uses. Measured:
Input
Round trip
a <br /> b
a b
a<br>b
ab
line<br>\nnext
line\nnext
a <BR> b
a <BR> b (survives; the match list is case-sensitive)
An author's <br> is lost on save. This blocks rendering <br> — the most common raw inline HTML in Markdown — and is tracked as #193, which carries the full diagnosis and the empty-paragraph coupling that constrains the fix.
Render predicate: each corpus shape — self-contained block, blank-line-split block, inline open/close pair, void tag, comment, processing instruction, CDATA, mismatched tags, malformed tag-like text — asserted as rendered or as code-like text.
Security: the existing htmlSafety.test.tsx cases must continue to pass unchanged, since hostile fragments fail the predicate and stay text. Add: off-allowlist attribute on an allowlisted element, style attribute, namespaced content (<svg>, <math>), and an element whose sanitization is not a no-op.
Serialization: byte-identical round trip for every fixture in corpus/commonmark/html.md, rendered and unrendered alike.
Source editing: entry from both caret sides and from NodeSelection, clean-session restore, invalid source committing as literal text, multi-line block source, and interaction with history, dirty state, and save-time finalize.
Required documentation updates
docs/decisions.md — a new Editor decision recording live rendering of self-contained raw HTML, superseding the text-only policy.
docs/specification.md — Rendering (the "never rendered as live DOM" rule), and Marker Visibility and Presentation where raw HTML's editing surface changes from the detached input to source projection.
docs/architecture.md — Security ("Do not parse or render raw HTML"), Milkdown Responsibilities (the same claim), and Verification Strategy ("Literal HTML rendering and script-execution prevention").
CHANGELOG.md — under Unreleased.
corpus/README.md — only if its taxonomy names the expected raw-HTML presentation.
Residual uncertainty
HTML parsing and the self-containment predicate were measured under happy-dom, which is not Chromium. Confirm against WebView2 during implementation; the corpus and the desktop E2E suite are the right places.
Block-level HTML is stored inside a paragraph, so a rendered <div> becomes a block box inside an inline box. No DOM re-parse occurs, so ProseMirror's document correspondence is not at risk, but layout and caret behavior around such a node need checking in the real WebView. A CSS-level fix is available, because every measured block case put the atom alone in its paragraph.
Whether multi-line projected source survives ProseMirror's DOM observer, which decides whether multi-line block HTML can be edited in place at all.
Whether rendering bare <span> and <div> with no attributes is worth having, given it is visually indistinguishable from plain text. It costs nothing to include and keeps the predicate uniform, but it may be better to leave attribute-less inline wrappers as text.
Effort boundary observed
Time-boxed to evidence that separates the options: read the installed html node, remark transformers, and projection adapters; probe node structure, round trip, and the render predicate against corpus fixtures; verify the sanitizer options against official sources. All prototype code was disposable and has been deleted; the working tree is unchanged. Stopped before the allowlist implementation, the adapter, and any dependency or documentation change.
Question
How should Leafdown safely render raw Markdown HTML as live editor content while retaining a coherent in-document Markdown source-editing experience?
Context
Leafdown currently preserves raw HTML as code-like text and does not render it as browser DOM. The product direction is now to render raw HTML live in the editor.
The remaining uncertainty is the safe rendering boundary: supported elements and attributes, URL and resource-loading policy, fallback behavior for unsafe content, and how inline and block HTML enter source editing without detached input widgets.
Typora is the interaction reference: inline HTML renders with surrounding content and block HTML can enter a source-editing mode. Its security model is not adopted by implication; Leafdown defines its own policy in this spike.
Related context
Validate
Exit criteria
Outcome
Corrections to this issue's premises
@milkdown/kitis pinned exactly inpackage.json). 7.22.0 is the current upstream release; its changelog contains no raw-HTML work, so the findings below hold for both.htmlnode,markerPresentation.tsrenders a detached<input aria-label="Markdown source">widget, andhtmlis its only consumer (SOURCE_NODE_NAMES = new Set(["html"])). The detached widget Render safe raw HTML live in the editor #62 removes is this one.Conclusion
Render raw HTML live only where the stored token is a complete element that the allowlist accepts unchanged; everything else keeps today's code-like text. The recommended data flow is:
htmlnode'svaluestring stays the single source of truth and the only thing serialized. Nothing in the parse or serialize path changes.valueonce into a detached<template>, inspects the parsed tree against a Leafdown-owned allowlist, and — if it passes — adopts those same parsed nodes into the rendered DOM.data-type="html"code-like text.Parse-once/inspect/adopt is the security property that matters: there is no second parse for a mutation-XSS payload to diverge into, because the tree that was inspected is the tree that is inserted.
The render predicate is a single conjunction, and rendering happens only when it holds:
valueparses to exactly one child node, and that node is anElement. Comments, processing instructions, and CDATA are excluded — they render as nothing at all, leaving content the user cannot see or place a caret in.</tag>.The last condition is deliberate. A fragment that sanitization would change is never rendered in altered form; it falls back to text, so what renders and what saves can never disagree, and there is no partially-sanitized state to specify or test.
Why this shape, and not the obvious one
CommonMark raw HTML is a token stream, not a tree, and Milkdown stores each token as an inline atom carrying one
valuestring (html.ts,atom: true, inline: true). Measured against the corpus fixtures:<div class="note">Block</div><section class="garden">\n*Markdown*\n</section><details>…blank line…</details><details>\n<summary>…</summary>atom, a real Markdown paragraph,</details>atomText <span class="leaf">raw *inline* HTML</span> after<span class="leaf">atom, text,emphasismark, text,</span>atomSo most stored tokens are fragments of an element whose content lives in sibling nodes. An inline atom cannot own those siblings, which is why "render each
htmlnode" does not work. A prototype NodeView confirmed the failure concretely:<span class="leaf">inline</span>rendered as an empty<span class="leaf"></span>, with the wordinlineoutside it and the closing tag rendering as nothing.The valuable renderable case is therefore self-contained block-level HTML, which is also the case CommonMark tokenizes whole. Void inline tags (
<br>,<img>) are the only self-contained inline tokens.Rejected alternatives
<span>**bold**</span>currently yields a realstrongmark, and merging makes it literal, changing document semantics to gain presentation. Pairing at view level is not available either — decorations cannot re-parent a sibling range into a wrapper — and a mark-based representation cannot express the unbalanced tags the corpus already covers (<em>opened,</strong>closed).Use Milkdown Kitdecided against, and contradictsAccept Milkdown GFM preset behavior. Round-trip fidelity for every unbalanced fragment incorpus/commonmark/html.mdbecomes Leafdown's to maintain.@milkdown/components, and is not resolvable from the project root, so this is adding a dependency rather than using one. It would re-parse a string this design has already parsed — reintroducing the second parse that parse-once/adopt exists to remove — and would still not spare us the allowlist, because Leafdown's is far narrower than DOMPurify's defaults. Reconsider if the allowlist ever needs URL-bearing or namespaced content (SVG, MathML), where its hardening earns the dependency.Element.setHTML()(Sanitizer API). MDN states it is not Baseline and "does not work in some of the most widely-used browsers". Leafdown targets three WebViews, so this needs a fallback path anyway, and a feature-detected sanitizer means two behaviors and two test matrices.Allowlist
No attributes are allowed in the first slice.
classandidare excluded deliberately:classwould let document content reach into application CSS, andidintroduces duplicate-fragment hazards.styleis excluded and cannot be delegated to CSP, becausestyle-srcincludes'unsafe-inline'.br,b,strong,i,em,u,s,del,ins,mark,sub,sup,code,kbd,samp,var,abbr,small,span, and self-containeddiv,p,section,details,summary,hr,dl,dt,dd.a[href],img[src|alt|width|height]. These must route through the existing backend resolution (resolve_markdown_link_target,resolve_markdown_image_target) rather than gain a second, weaker path. Keeping them out of the first slice keeps the whole URL-policy surface out of it and makes Render safe raw HTML live in the editor #62's "existing Markdown link and local-image safety policies remain intact" true by construction.Security boundary
Sanitization is the first layer and CSP the second, not the reverse. The configured CSP blocks inline script and
javascript:(script-src 'self', no'unsafe-inline'), remote loads (default-src 'self';img-src 'self' asset: http://asset.localhost), form submission, and<base>. Its one gap for this work isstyle-src 'unsafe-inline', which the allowlist covers by admitting nostyleattribute.Script execution inside the editor surface is high severity here rather than cosmetic:
withGlobalTauri: trueexposes the IPC bridge to any executing script, andopen_markdown_link_targetacceptsallow_outside_folderfrom its caller, so the outside-folder confirmation is a frontend workflow rather than a backend guard.In-document source editing
Use the existing source-projection engine with a new
htmladapter, modeled onsourceProjectionFootnoteReferenceAdapter.ts. That adapter already solves the same problem for an atom: caret adjacency orNodeSelectionfinds the target, the atom is replaced by its source as literal document text, and finalization re-parses or commits literal text. Inline and block HTML use one adapter; they differ only in whethervaluecontains newlines. History, dirty state, clipboard, and finalize-before-serialize come from the engine unchanged.markerPresentation.ts's detached<input>and itsparseSourceNode/serializeSourceNodehelpers are deleted, sincehtmlis their only consumer.Two questions about multi-line block HTML source editing are unresolved, and #62 should settle them in that order.
createLiteralSourceProjectionSlicebuildsschema.text(source), and whether ProseMirror preserves\nin a paragraph text node through its DOM observer was not tested here. If it does not, the second question is moot.Enterstill ends the session.sourceProjection.tsfinalizes onevent.key === "Enter"with no modifier check, then returnsfalseso the normal keymap still runs. Letting anhtmlprojection keepEntermeans adding a key hook toSourceProjectionAdapter, which has none today — engine-level work that puts every existing adapter'sEnterbehavior in scope, not a local addition.If either answer is unfavorable, the cheap first slice is to restrict live rendering to single-line tokens, or to project multi-line block source read-only, and to handle multi-line editing separately.
Markdown preservation
Serialization is unaffected by construction: rendering reads
valueand never writes it, and sanitization never rewrites it. This was verified rather than assumed — a prototype NodeView rendering live DOM for both a block and an inline fragment produced a byte-identicalgetMarkdown()round trip.Defect found: authored
<br>is silently deletedIndependent of this spike and pre-existing.
remarkPreserveEmptyLinePluginin@milkdown/preset-commonmarkdeletes every mdasthtmlnode whose trimmed value is exactly<br />,<br>,<br >, or<br/>, anywhere in the tree, to support Milkdown's own empty-paragraph round trip. It is unconditionally part of thecommonmarkcomposed preset Leafdown uses. Measured:a <br /> ba ba<br>babline<br>\nnextline\nnexta <BR> ba <BR> b(survives; the match list is case-sensitive)An author's
<br>is lost on save. This blocks rendering<br>— the most common raw inline HTML in Markdown — and is tracked as #193, which carries the full diagnosis and the empty-paragraph coupling that constrains the fix.Test coverage to specify in #62
htmlSafety.test.tsxcases must continue to pass unchanged, since hostile fragments fail the predicate and stay text. Add: off-allowlist attribute on an allowlisted element,styleattribute, namespaced content (<svg>,<math>), and an element whose sanitization is not a no-op.corpus/commonmark/html.md, rendered and unrendered alike.NodeSelection, clean-session restore, invalid source committing as literal text, multi-line block source, and interaction with history, dirty state, and save-time finalize.Required documentation updates
docs/decisions.md— a new Editor decision recording live rendering of self-contained raw HTML, superseding the text-only policy.docs/specification.md—Rendering(the "never rendered as live DOM" rule), andMarker Visibility and Presentationwhere raw HTML's editing surface changes from the detached input to source projection.docs/architecture.md—Security("Do not parse or render raw HTML"),Milkdown Responsibilities(the same claim), andVerification Strategy("Literal HTML rendering and script-execution prevention").CHANGELOG.md— underUnreleased.corpus/README.md— only if its taxonomy names the expected raw-HTML presentation.Residual uncertainty
<div>becomes a block box inside an inline box. No DOM re-parse occurs, so ProseMirror's document correspondence is not at risk, but layout and caret behavior around such a node need checking in the real WebView. A CSS-level fix is available, because every measured block case put the atom alone in its paragraph.<span>and<div>with no attributes is worth having, given it is visually indistinguishable from plain text. It costs nothing to include and keeps the predicate uniform, but it may be better to leave attribute-less inline wrappers as text.Effort boundary observed
Time-boxed to evidence that separates the options: read the installed
htmlnode, remark transformers, and projection adapters; probe node structure, round trip, and the render predicate against corpus fixtures; verify the sanitizer options against official sources. All prototype code was disposable and has been deleted; the working tree is unchanged. Stopped before the allowlist implementation, the adapter, and any dependency or documentation change.