Skip to content

Render safe raw HTML live in the editor #62

Description

@Azganoth

Summary

Implement the raw Markdown HTML rendering and in-document source-editing model selected by #61.

Leafdown should render policy-approved raw HTML as safe live editor content rather than code-like text. Raw HTML enters source editing in the document itself, then returns to safe rendered content after valid edits.

#61 established that CommonMark raw HTML is a token stream rather than a tree, and that Milkdown stores each token as an inline atom holding one value string. Most stored tokens are therefore fragments of an element whose content lives in sibling nodes, which an inline atom cannot own. The selected policy renders only tokens that are complete, allowlisted elements and leaves every other token exactly as it renders today.

Expected behavior

  • Raw HTML renders live when, and only when, all of the following hold. Any failure renders the token as today's code-like text, unchanged.
    • value parses to exactly one child node and that node is an Element. Comments, processing instructions, and CDATA are excluded, because they would render as nothing and leave content the user can neither see nor 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. A fragment that sanitization would alter is never rendered in altered form, so what renders and what saves can never disagree.
  • Rendering parses value once into a detached template, inspects that parsed tree, and adopts those same nodes into the rendered DOM. No second parse exists for a mutation-XSS payload to diverge into.
  • value remains the single source of truth and the only thing serialized. Rendering never writes it, and sanitization never rewrites it.
  • Scripts, event handlers, unsafe URLs, embedded documents, forms, unauthorized remote resources, and every other disallowed construct fail the predicate, stay code-like text, and cannot execute, load, or escape the editor surface.
  • Raw HTML enters in-document source editing through the shared source-projection engine, with no detached input widget. Valid edited source returns to safe rendered HTML; invalid, incomplete, or now-unsupported source finalizes as literal text without content loss.
  • Caret, selection, undo/redo, dirty state, and save-time serialization remain correct during source editing.
  • Existing Markdown link and local-image safety policies remain intact, which the first slice satisfies by construction: no URL-bearing element is on the allowlist.

Allowlist

No attributes are allowed. class would let document content reach into application CSS, id introduces duplicate-fragment hazards, and style cannot be delegated to CSP because style-src includes 'unsafe-inline'.

Elements: 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.

br renders only once #193 is fixed. It is deleted before reaching the editor document today, so nothing can render it.

Related context

Done when

  • The render predicate is implemented as one decision, and rendering happens only when it holds.
  • Rendering parses once and adopts the inspected nodes rather than re-parsing a sanitized string.
  • Allowlisted, self-contained HTML renders as safe live editor content.
  • Every other token — disallowed elements and attributes, unbalanced fragments, comments, processing instructions, CDATA, malformed input, and anything sanitization would alter — renders as code-like text with value untouched.
  • Raw HTML enters and finalizes in-document source editing through the shared projection engine.
  • The detached <input> source widget is removed from markerPresentation.ts, along with parseSourceNode and serializeSourceNode, whose only consumer is the html node.
  • Round-trip behavior is byte-identical for every fixture in corpus/commonmark/html.md, rendered and unrendered alike.
  • Focused tests cover the render predicate per corpus shape; the existing htmlSafety.test.tsx cases continue to pass unchanged; and new security cases cover an off-allowlist attribute on an allowlisted element, a style attribute, namespaced content (<svg>, <math>), and a fragment whose sanitization is not a no-op.
  • Source-editing tests cover entry from both caret sides and from NodeSelection, clean-session restore, invalid source committing as literal text, and interaction with history, dirty state, and save-time finalize.
  • docs/decisions.md records the accepted safe live-HTML direction and supersedes the former text-only policy.
  • docs/specification.md defines live HTML rendering, source editing, and fallback behavior in Rendering and Marker visibility and presentation.
  • docs/architecture.md updates Security, Milkdown responsibilities, and Verification strategy, each of which currently states that raw HTML is never rendered.
  • Deferred HTML controls and customization remain tracked by the existing Post-rendering HTML controls Project draft.
  • CHANGELOG.md documents the user-visible change under Unreleased.

Notes

Implementation direction

Source editing should reuse the existing engine with a new html adapter modeled on sourceProjectionFootnoteReferenceAdapter.ts, which 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.

Two questions are unresolved and should be settled before committing to multi-line block HTML source editing, in this 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 is untested. If it does not, question 2 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.

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.

Two risks to verify against the real WebView rather than the test environment:

  • The self-containment predicate and HTML parsing were measured under happy-dom, which is not Chromium.
  • 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. A CSS-level fix is available, because every measured block case put the atom alone in its paragraph.

Out of scope

  • URL-bearing elements — a[href] and img[src|alt|width|height]. These must route through the existing backend resolution rather than gain a second, weaker path, and keeping them out of the first slice keeps the whole URL-policy surface out of it.
  • Fixing Authored <br> is deleted on load and lost on save #193, which is required before br can render but is a separate defect in the parse path.
  • User-configurable HTML allowlists or rendering controls.
  • Custom CSS authoring for document HTML.
  • HTML export or rich-text export.
  • Image node-view editing.
  • Footnote tooltips or definition-management UI.

Metadata

Metadata

Assignees

Labels

FeatureNew feature or request

Projects

Status
Ready

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions