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.
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.
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 builddist/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.
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.
| 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. |
This is the feature, so it is specified rather than assumed.
- URL scheme allowlist on every
hrefandsrc: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, butdata:is alsotext/htmlandimage/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
setAttributeand attribute names are literals. Values are never interpolated into markup. - Text goes through
createTextNode/textContentexclusively. - Code block info strings are reduced to
[a-z0-9-]before becoming alanguage-*class. urlFilteris 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: anhttp/httpsimage 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__orconstructoras a node type resolves to nothing. - Recursion is capped at 32 levels; deeper input flattens to text rather than overflowing the stack.
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 them345 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.
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, sizeThe 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.
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.
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 letjava<!-- -->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.
Titlefollowed 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,
aover---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.
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.
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://
npm run build # regenerates dist/ and demo/index.htmlThen 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.
file://verification across Chrome, Firefox and Safari.demo/index.htmlis 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.
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.