Skip to content

Define safe live raw HTML rendering for the editor #61

Description

@Azganoth

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

  • Inspect Milkdown 7.21.2’s raw-HTML node representation and Markdown round-trip behavior.
  • Prototype safe rendering for representative inline and block HTML fixtures.
  • Define the allowlist for elements, attributes, URL schemes, and styling.
  • Define behavior for scripts, event attributes, embedded content, forms, unsafe URLs, remote resources, malformed HTML, and unsupported elements.
  • Determine how inline and block HTML enter and finalize in-document source editing.
  • Determine how the implementation preserves source and serialization when rendered content is sanitized or rejected.
  • Establish focused security, serialization, and interaction test cases.

Exit criteria

  • A recommended safe-rendering data flow and sanitization boundary is recorded.
  • Supported, unsupported, and rejected HTML behavior is explicitly defined.
  • Inline and block source-editing interactions are defined.
  • Markdown preservation and serialization behavior is defined.
  • Security and regression test coverage is specified.
  • Render safe raw HTML live in the editor #62 is updated with the selected policy, implementation criteria, and required documentation and changelog changes.

Outcome

Corrections to this issue's premises

  • 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:

  1. 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.
  2. At view time a NodeView parses value once 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.
  3. 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.

  1. 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.
  2. 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.

Test coverage to specify in #62

  • 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.mdRendering (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.mdSecurity ("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.

Metadata

Metadata

Assignees

Labels

SpikeInvestigation needed before committing to implementation

Projects

Status
Done

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions