Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

2 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

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.

Every construct in scope is implemented, the demo is a single self-contained file that works over file://, and the CommonMark pass rate is published below rather than claimed.

The ordering was deliberate. Establishing the safety property against hand-written ASTs before any parsing existed means both passes inherited it, rather than having it bolted on afterwards and called a feature.

What works today

import { render } from './src/index.js';

document.querySelector('#out').appendChild(render('# Hello\n\n- **one**\n- two'));

Blocks — ATX headings, paragraphs, fenced code with info strings, thematic breaks, nestable blockquotes, nestable ordered and unordered lists with start and tight/loose handling, GFM pipe tables with alignment.

Inline — emphasis and strong, code spans with the doubled-backtick escape, links with titles, images, autolinks, backslash escapes, hard line breaks, strikethrough.

Inlining it into a single-file app

The example above uses import, which needs a module loader or a server — fine for most setups, but not for a page you want to open directly from disk or hand someone as one file. npm run build also produces a classic script with no import/export in it at all:

npm run build

dist/markdown-renderer.js (or the smaller dist/markdown-renderer.min.js) defines a single global, markdown, instead of exporting anything. Paste its contents into a <script> tag — or load it with a plain <script src="markdown-renderer.js"></script> — and it works, including over file://, where import is blocked by CORS and just fails silently:

<!doctype html>
<div id="out"></div>
<script>
  /* the contents of dist/markdown-renderer.js, inlined */
</script>
<script>
  document.getElementById('out').appendChild(
    markdown.render('# Hello\n\n- **one**\n- two')
  );
</script>

Same function, same output — just reached through markdown.render(...) instead of an imported render(...). demo/index.html is exactly this pattern: tools/build.mjs inlines the whole bundle into one <script> tag, which is what makes that page open and run with nothing external at all.

Tables can be left out

render() includes them by default. block.js does not import table.js itself — the block pass takes a table function through its options, and render() is what supplies it — so a consumer who composes the block, inline, and emit passes directly, instead of importing render(), drops the module rather than shipping it behind a flag. Measured, that saves 0.40 KB gzipped.

// Everything, tables included.
import { render } from './src/index.js';

// Or compose without them, and table.js never enters the bundle.
import { parseBlocks } from './src/block.js';
import { parseInline } from './src/inline.js';
import { emit } from './src/emit.js';

const render = (md) => emit(parseBlocks(md, { inline: parseInline }), {});

allowTables: false stops tables being parsed if you have already imported the layer. Only a build that omits the module saves the bytes.

Options

Option Default Effect
allowImages true false renders images as their alt text
allowTables true false stops the block pass recognising tables; the rows stay paragraphs
baseUrl null Resolves relative links and images against this
headingOffset 0 h1 becomes h(1+offset), clamped to h1–h6
linkTarget null e.g. '_blank'; always paired with rel="noopener noreferrer"
urlFilter null (url, kind) called on every href/src that already passed the scheme allowlist, where kind is 'href' or 'src'. Return a string to rewrite the URL, false/null to reject it, anything else to leave it unchanged.

Safety behaviour

This is the feature, so it is specified rather than assumed.

  • URL scheme allowlist on every href and src: http:, https:, mailto:, and relative URLs. Everything else renders as plain text, not as a dead link.
  • URLs are normalised the way a browser normalises them before the scheme is checked — tabs, newlines and leading control characters are stripped — so a tab inside java<TAB>script: cannot smuggle a scheme past a check that reads a different string than the one that eventually runs.
  • data: is rejected, including for images. Inlining a PNG is a reasonable thing to want, but data: is also text/html and image/svg+xml, and separating them reliably costs more than it is worth here. Consumers who need inline images can walk the returned fragment.
  • Attributes are set with setAttribute and attribute names are literals. Values are never interpolated into markup.
  • Text goes through createTextNode / textContent exclusively.
  • Code block info strings are reduced to [a-z0-9-] before becoming a language-* class.
  • urlFilter is the escape hatch for policy the scheme allowlist can't express — an allowed host list, or routing images through a proxy so a render never makes a direct request to an attacker-chosen URL (relevant if the markdown itself is untrusted, e.g. LLM output: an http/https image URL is otherwise fetched the instant it renders, no click required, which is enough to exfiltrate data through the request itself). It only ever sees a URL that already cleared the scheme allowlist — it narrows, it does not widen, what gets through.
  • Node types are dispatched through a switch, not an object lookup, so __proto__ or constructor as a node type resolves to nothing.
  • Recursion is capped at 32 levels; deeper input flattens to text rather than overflowing the stack.

CommonMark scorecard

Not a CommonMark implementation, and it does not claim to be. The suite is run anyway and the number published, because an honest "passes 329 of 426 it should, here is exactly what it does not do" is more useful than a compatibility badge — and it makes regressions visible.

npm run spec        # fetch the 0.31.2 fixtures, once
npm run scorecard   # run them

345 of 652 (53%) of every example. 329 of 426 (77%) of the examples that use only supported constructs.

The second number is the real one. Scope is decided per example, not per section — a section-level split could never reach 100% no matter what was built, because excluding "Link reference definitions" does not remove reference links from the Links section, nor indented code from List items. A 100% score was unreachable by construction, not by any gap worth closing.

226 examples are out of scope, by reason:

Reason Examples Why
reference links 84 out of scope; needs a definition registry and a second pass
raw HTML passthrough 61 raw HTML is escaped, never passed through — the security property
indented code blocks 46 only fenced code is supported
setext headings 16 ambiguous with thematic breaks
entity decoding 14 the HTML5 table is 44 KB raw, larger than this library
tabs as indentation 5 tabs expand for indentation only

Of the 426 in scope, these are done:

Section In scope
ATX headings 17 / 17
Thematic breaks 17 / 17
Paragraphs 7 / 7
Hard line breaks 13 / 13
Images 8 / 8
Setext headings 12 / 12
Indented code blocks 3 / 3

And these are the open backlog:

Section In scope Missing
Emphasis and strong 94 / 129 the full delimiter-run algorithm
Links 21 / 43 percent-encoding, destination escapes, no-nested-links
Fenced code blocks 26 / 28 info-string edge cases
Block quotes 20 / 22 lazy continuation edge cases
List items 28 / 33 container-model detail
Lists 14 / 22 container-model detail
Code spans 18 / 21 stripping edge cases
Autolinks 11 / 19 email autolinks
Backslash escapes 5 / 8 escapes in destinations

The two largest items are both known tradeoffs rather than oversights. Emphasis at 94/129 is the simple-rule decision, exactly the divergence this library signed up for; CommonMark's delimiter-run algorithm is a large fraction of a full parser's size. Links at 21/43 is mostly URL percent-encoding and backslash escapes inside destinations, which is the one cluster here that is cheap to close.

One caveat on the method: scope is decided by pattern-matching each example, so a test that fires wrongly moves the denominator. They are written to err toward counting an example in scope, which makes the figure pessimistic rather than flattering. See DECLINED in test/commonmark/score.js.

Running the tests

The suite runs in a real browser. The security property is a claim about what the DOM does with a given tree, and a shimmed DOM would only tell you what the shim does.

npm run serve
# then open http://localhost:8137/test/

A green summary means everything passed. The last test is the one that matters: it attaches every hostile payload to the live document and asserts that a sentinel was never called.

Static gates run in Node:

npm run gates    # build, syntax, sinks, dist parity, scorecard, size

The shipped product has zero dependencies. dist/ is plain JavaScript you drop into a page; nothing is installed to use it, and nothing is bundled into it.

The repo has exactly one devDependency, terser, and it is optional. It minifies the build so the size gate can measure the artifact that actually ships rather than an estimate. Skip npm install and the build still runs — you get the readable bundle, no minified file, and no size figures. Terser never appears in anything a consumer receives.

The AST

Internal, not public API, so it can change. Recorded here because it is the shape the block and inline passes produce and consume between them.

Blocks

{ type: 'document',      children: [Block] }
{ type: 'heading',       level: 1..6, children: [Inline] }
{ type: 'paragraph',     children: [Inline] }
{ type: 'code',          value: String, info: String? }
{ type: 'blockquote',    children: [Block] }
{ type: 'list',          ordered: Boolean, start: Number?, children: [ListItem] }
{ type: 'listItem',      children: [Block] }
{ type: 'thematicBreak' }
{ type: 'table',         align: ['left'|'center'|'right'|null], children: [TableRow] }
{ type: 'tableRow',      header: Boolean, children: [TableCell] }
{ type: 'tableCell',     children: [Inline] }

Inlines

{ type: 'text',          value: String }
{ type: 'inlineCode',    value: String }
{ type: 'emphasis',      children: [Inline] }
{ type: 'strong',        children: [Inline] }
{ type: 'strikethrough', children: [Inline] }
{ type: 'break' }
{ type: 'link',          url: String, title: String?, children: [Inline] }
{ type: 'image',         url: String, alt: String, title: String? }

Unknown node types are dropped rather than throwing, so a future construct degrades to nothing instead of taking the page down.

A list also carries tight: Boolean. A tight list — no blank lines between its items — renders its items' inline content directly inside <li>; a loose one keeps the <p> wrappers. That is the whole visual difference between a compact list and a spaced-out one.

Deviations from CommonMark

Beyond the no-raw-HTML decision, which is the deliberate one:

  • HTML comments are dropped, not rendered. A block that opens with <!-- is consumed through its closing --> and emits nothing. Whole blocks only: an inline <!-- … --> in the middle of a line stays visible text, because splicing a comment out of a run of characters would let java<!-- -->script: reassemble into a scheme after the URL check has read the string. An unclosed comment is not a comment either — CommonMark runs it to end of document, which would let one stray marker swallow a whole note, so it stays a paragraph.
  • Setext headings are not supported. Title followed by --- is a paragraph and a thematic break, not an h2. Ambiguous with thematic breaks, rarely hand-authored.
  • Indented code blocks are not supported. Four leading spaces is just a paragraph. Only fenced code produces a <pre>.
  • Nesting is capped at 24 levels in the block pass, below emit's own cap of 32. Past it, containers flatten to paragraph text rather than recursing.
  • Emphasis uses a simpler rule than CommonMark's delimiter-run algorithm, which is a large fraction of a full parser's size. In full: a run of three or more delimiters is strong inside emphasis, two is strong, one is emphasis; the opener must not be followed by whitespace and the closer must not be preceded by it; a closing run matching the opener's length wins over a longer one; underscore does not open or close inside a word, asterisk does. Ordinary documents parse the same as CommonMark. Adversarial delimiter soup does not.
  • Email autolinks are not supported. <https://…> works; <user@host> stays text. mailto: links written with normal link syntax work fine.
  • A table's header row must contain a pipe. Without one, a over --- is indistinguishable from a paragraph followed by a thematic break, and the thematic break is the more useful reading. Outer pipes remain optional.
  • Ragged table rows are padded or truncated to the header's column count, so the alignment array always lines up with the cells.
  • Reference links, footnotes, and math remain out of scope.

Known performance characteristic

Unmatched opening delimiters cost quadratic time: each one scans forward for a close that never arrives. It is bounded, not a hang — 10,000 [a]( sequences parse in about 450ms, 20,000 in about 1.7s. No regex backtracking is involved; the tokeniser deliberately has no nested quantifiers.

Worth knowing if you render untrusted input larger than a few tens of KB. A cap on destination scan length would make it linear, at the cost of rejecting absurdly long URLs.

Files

src/index.js                   render(); composes the three below
src/block.js                   the block pass — lines to block nodes
src/inline.js                  the inline pass — text runs to inline nodes
src/table.js                   the optional table layer
src/emit.js                    the only code that touches the DOM
test/index.html                browser runner
test/harness.js                ~40-line test runner
test/fixtures.js               hand-written ASTs and builders
test/emit.test.js              structural tests
test/block.test.js             block pass tests
test/inline.test.js            inline pass tests
test/table.test.js             table layer tests
test/xss.test.js               the safety corpus
test/commonmark.html           CommonMark scorecard, in a real browser
test/commonmark/score.js       scoring logic, shared by both runners
tools/build.mjs                hand-rolled bundler; writes dist/, min, and the demo
tools/commonmark.mjs           the same scorecard, headless, for CI
tools/dom-shim.mjs             ~120-line DOM, only so the scorecard runs in CI
tools/check-dist.mjs           built + minified bundles must match src/ exactly
tools/check-syntax.mjs         fails the build on any file that will not parse
tools/check-no-innerhtml.mjs   fails the build on any markup-parsing sink
tools/size.mjs                 gzip size of the shipped bundle; hard-fails past 5 KB
demo/template.html             source for the demo
demo/index.html                generated — single file, works over file://

Demo

npm run build     # regenerates dist/ and demo/index.html

Then open demo/index.html directly from disk. No server, no module loading, nothing external — it is one file. The default content includes hostile links and a raw <script> tag so the safety property is visible rather than described.

Known limitations

  • file:// verification across Chrome, Firefox and Safari. demo/index.html is self-contained and has no module loading, so it should work; it has not been opened in all three.
  • Entity decoding and URL percent-encoding, the bulk of the remaining CommonMark gap. Both are deliberate omissions.

Size

Measured by npm run size on dist/markdown-renderer.min.js, regenerated by every build. The demo's size pills come from the same measurement, so neither can drift from the artifact:

build minified min+gzip
with tables 13.13 KB 4.82 KB
without tables 11.93 KB 4.42 KB

The raw minified figure is what gets inlined into a single-file app opened over file://, where nothing compresses anything — that's this library's stated primary use case. The gzip figure is the fair number to compare against other renderers served over HTTP: marked and markdown-it publish gzipped sizes too, and against their 10 KB and 30 KB, this is roughly 4.8 KB — while neither of them removes the innerHTML sink, which is the actual argument for this library existing at all.

About

Minimal markdown renderer that emits DOM nodes, never an HTML string — no innerHTML in the call path, so there is no injection sink to sanitise.

Topics

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages