Minimal markdown renderer

Renders markdown to DOM nodes, never to an HTML string. There is no innerHTML in the call path, so there is no injection sink to sanitise. This page is a single self-contained file and works over file://.

13.13 KB minified 4.82 KB min+gzip 0 dependencies 0 innerHTML returns a DocumentFragment

Live

Edit the left pane. The right pane is real DOM built by the renderer — not an HTML string that was parsed back. Note what happens to the hostile links and the raw <script> tag near the bottom.

markdown
rendered DOM

Supported syntax

A documented subset, not a CommonMark implementation. Everything below is covered by tests.

ConstructExampleRenders as
Headings# through ######h1h6
Paragraphstextp
Fenced codethree backticks, then a languagepre > code.language-js
Blockquotes> quotedblockquote, nestable
Unordered lists- * +ul, nestable, tight/loose
Ordered lists1. 1)ol with start
Thematic breaks--- *** ___hr
Tables| a | b |table with alignment
Emphasis*a* _a_em
Strong**a** __a__strong
Strikethrough~~a~~del
Inline codebacktick-delimited; a doubled backtick escapes a literal onecode
Links[t](url "title")a, scheme-checked
Images![alt](url)img, scheme-checked
Autolinks<https://…>a
Hard breakstwo trailing spaces, or \br
Escapes\*literal
Raw HTML<div>not supported — escaped to text, deliberate
HTML comments<!-- a -->not supported — dropped; inline ones stay text
Setext headingsa line, then ---not supported — paragraph + hr
Indented codefour leading spacesnot supported — paragraph
Reference links[a][b]not supported — literal text
Footnotes, math, task listsout of scope

Where it diverges, and why

  • Raw HTML is escaped, never passed through. The single most important safety decision, and a deliberate CommonMark deviation. In an offline app there is no upstream to sanitise at, and injected script reads the whole local document store rather than leaking a session.
  • Link and image URLs pass a scheme allowlisthttp:, https:, mailto:, and relative. Everything else renders as plain text, not as a dead link. data: is rejected even for images. A urlFilter option runs after the allowlist for callers who need a tighter policy still — a host allowlist, or routing images through a proxy so a render never fetches an attacker-chosen URL directly. Relevant if the markdown itself isn't trusted, e.g. LLM output.
  • Emphasis uses a simpler rule than CommonMark's delimiter-run algorithm, which is a large fraction of a full parser's size. Ordinary documents parse identically; adversarial delimiter soup does not.
  • Setext headings and indented code are not supported. Both are ambiguous with constructs that are, and rarely hand-authored.