mdlib-pure #
Lightweight Markdown renderer for the browser.
Website, docs and playground: https://michelepolo.github.io/mdlib-pure/ · for AI agents: llms.txt
- 100% CommonMark 0.31.2: all 652 spec examples pass
- GFM as an opt-in plugin (~4 KB): tables, strikethrough, autolinks, task lists, footnotes, alerts. 54/54 GFM extension examples pass
- Zero dependencies, ~11 KB gzipped core
- Safe by default: raw HTML off,
javascript:URLs blocked, DOM output without HTML parsing - Extensible: block, inline and delimiter rules, AST transforms, render overrides
- Framework-neutral output: HTML string, DOM nodes, or React/Vue/Preact elements
- Tailwind-friendly: semantic HTML with no classes by default, or a class map per node type
- Linear time on adversarial input
- MIT licensed
Install #
Not on the npm registry yet: npm install mdlib-pure does not work. Until
the first release there, install the tarball attached to the
GitHub release:
npm install https://github.com/MichelePolo/mdlib-pure/releases/download/mdlib-pure-v0.1.1/mdlib-pure-0.1.1.tgz
npm records the URL in your package.json and the tarball's checksum in
package-lock.json, so npm ci works on CI and for your colleagues. Imports
are the same as they will be from the registry (from 'mdlib-pure',
from 'mdlib-pure/gfm'…).
To build the tarball yourself instead (Node 22.18 or later):
git clone https://github.com/MichelePolo/mdlib-pure.git
cd mdlib-pure
npm ci # dev tools only: the package itself has no dependencies
npm pack # builds dist/, writes mdlib-pure-0.1.1.tgz
cd ../my-app
npm install ../mdlib-pure/mdlib-pure-0.1.1.tgz
The framework wrappers are not on a registry either, and have GitHub
releases too: Blazor (the .nupkg, not on
nuget.org) and Angular (a tarball, not on npm).
See Framework wrappers.
Usage #
import { createMarkdown } from 'mdlib-pure'
const md = createMarkdown()
md.render('# Hello *world*') // '<h1>Hello <em>world</em></h1>\n'
md.renderTo(element, '# Hello') // builds DOM nodes directly
md.parse('# Hello') // mdast-style AST
Options #
createMarkdown({
plugins: [], // see "Plugins"
html: false, // recognize raw HTML (trusted input only, or with sanitize)
sanitize: undefined, // (html) => html, e.g. DOMPurify.sanitize
allowUrl: isSafeUrl, // (url, 'link' | 'image') => boolean
decodeEntity: undefined,// named-entity decoder (default: the browser's DOM)
classes: {}, // CSS classes per node type
attributes: undefined, // (node) => extra attributes, e.g. data-sourcepos
highlight: undefined, // (code, lang, meta) => html | VNodes | null
positions: false, // line/column/offset on block nodes
})
Full reference: docs/api.md.
GitHub Flavored Markdown #
import { createMarkdown } from 'mdlib-pure'
import { gfm } from 'mdlib-pure/gfm'
const md = createMarkdown({ plugins: gfm() })
// turn parts off: gfm({ footnotes: false, alerts: false })
Also exported one by one: table, strikethrough, autolink, tasklist,
footnotes, tagfilter, alerts.
Syntax highlighting #
Bring your own highlighter through highlight(code, lang, meta):
import hljs from 'highlight.js/lib/core'
createMarkdown({
// a string is trusted HTML for inside <code>
highlight: (code, lang) => (lang && hljs.getLanguage(lang) ? hljs.highlight(code, { language: lang }).value : null),
})
// shiki returns a whole <pre>: it replaces the block
createMarkdown({ highlight: (code, lang) => shiki.codeToHtml(code, { lang: lang ?? 'text', theme: 'github-dark' }) })
Return null for the default rendering, or VNodes (h('span', { class: 'kw' }, 'let'))
to stay on the DOM-safe path of renderTo(). Strings are treated as trusted
HTML: with untrusted input, use a highlighter that escapes code (all of the
above do) or set sanitize.
React, Vue, Preact #
import { createElement } from 'react'
import { toElements } from 'mdlib-pure/adapter'
<article className="md">{toElements(md.toVNodes(src), createElement, 'react')}</article>
// Vue: toElements(vnodes, h, 'vue') · Preact: toElements(vnodes, h, 'preact')
The adapter applies each framework's conventions (className, uncontrolled
checkboxes, Vue's array children, dangerouslySetInnerHTML/innerHTML for
trusted raw HTML from highlight). Tested against the real React 19 on all
CommonMark and GFM examples. With html: true, inline HTML arrives in
fragments: use md.render() + sanitize on a container instead.
Node, SSR, workers #
Named entities (©, –…) are decoded through the DOM to keep the
core small. Outside the browser, import the full table:
import { decodeEntity } from 'mdlib-pure/entities'
const md = createMarkdown({ decodeEntity })
Styling #
Theme with semantic tokens. Import mdlib-pure/theme.css and put
class="md" on the container. Everything is customizable through --md-*
tokens; neutrals follow your text color and dark mode follows color-scheme.
See docs/tokens.md.
:root { --md-color-link: oklch(55% 0.2 260); --md-font-heading: "Inter", sans-serif; }
Tailwind CSS v4. The integration file loads the theme under your
utilities and exposes the tokens as theme values (text-md-link,
bg-md-surface…). Tailwind's preflight changes nothing inside .md: list
markers, link underlines and inline images stay as in the plain theme.
@import "tailwindcss";
@import "mdlib-pure/tailwind.css";
Or skip the theme: default output is plain semantic HTML, so @tailwindcss/typography works as is:
<article class="prose dark:prose-invert">…</article>
Or add utility classes per node type:
createMarkdown({
classes: {
heading: 'font-semibold tracking-tight',
heading1: 'text-3xl',
paragraph: 'my-4 leading-7',
link: 'text-primary underline-offset-4 hover:underline',
inlineCode: 'rounded bg-muted px-1 font-mono text-sm',
code: 'rounded-lg bg-muted p-4 overflow-x-auto',
},
})
Tailwind scans your sources for class names, so define the map in your own code (as above), not in a file under
node_modules.
Plugins #
const mark = {
name: 'mark',
delimiters: [{ char: '=', type: 'mark', sizes: [2] }],
render: { mark: (node, r) => r.h('mark', null, r.children(node)) },
}
createMarkdown({ plugins: [mark] }).render('==highlighted==')
// '<p><mark>highlighted</mark></p>\n'
A plugin can provide:
| Field | For |
|---|---|
block |
new block syntax (math fences, containers, tables) |
inline |
syntax starting at a trigger character (@mention, :emoji:) |
delimiters |
symmetric runs handled like emphasis (~~, ==) |
transform |
changes to the AST after parsing (heading ids, TOC) |
render |
HTML output per node type, including core nodes |
For a complete application example, see the podcast plugin guide and copyable source: a split editor with participant completion, planned section durations, interactive tasks and linked supporting documents, using fictional people and content. Try the interactive sample.
First-party plugins:
mdlib-pure/headings: GitHub-style heading ids, optional#permalinks, andtoc(root)for a table of contents. For untrusted input, useheadings({ prefix: 'user-content-' })(see docs/security.md).mdlib-pure/container: fenced containers (::: warning Title…:::, nestable with longer fences).mdlib-pure/math:$inline$,$$display$$and$$blocks. The output works as-is with KaTeX auto-render or MathJax, or passmath({ render: (tex, display) => katex.renderToString(tex, { displayMode: display }) }).mdlib-pure/mark:==highlight==→<mark>.mdlib-pure/mermaid:```mermaiddiagrams, with no dependency on Mermaid:renderMermaid(el, { load: () => import('mermaid') })loads it only when a page has diagrams, themed from the tokens (light and dark).mdlib-pure/element:<mdlib-markdown>and<mdlib-editor>Web Components (an editor with live preview, styled bymdlib-pure/editor.css), every plugin included: docs/elements.md.mdlib-pure/deflist: definition lists (Term/: Definition, Pandoc and PHP Markdown Extra style) →<dl>.mdlib-pure/frontmatter: YAML (---) / TOML (+++) front matter, kept out of the output;getFrontmatter(root)returns the raw text.
The full guide, written for both humans and AI agents, is
AGENTS.md. templates/plugin is a
complete, tested plugin to copy.
Framework wrappers #
Both wrap the Web Components of mdlib-pure/element: the Blazor package
bundles them, the Angular package depends on mdlib-pure. Neither is on a
public registry yet. They offer the same features with each framework's idioms: a
new plugin or element reaches both (the agents guide, rule 11).
Blazor (.NET 10) #
MdlibPure.Blazor wraps the Web Components for
every render mode: <MarkdownEditor @bind-Value>, <Markdown> and an AST
service. Not on nuget.org: download the .nupkg from the
GitHub release
and add it from a local package feed. Version 0.1.1 adds
PodcastEditor, with typed episode
binding, configurable participants, supporting documents and planned duration.
Angular (21, 22) #
ngx-mdlib-pure wraps the Web Components as
standalone components: <mdlib-editor [(value)]> (also a form control for
ngModel and reactive forms), <mdlib-markdown [source]>,
provideMdlibPure() and a MarkdownService for the AST, the table of
contents and HTML; ngx-mdlib-pure/podcast adds
PodcastEditor
(since 0.2.0). Server-side rendering and hydration work. Not on npm,
like mdlib-pure itself: install both tarballs from the GitHub releases
(angular-v0.2.0,
mdlib-pure-v0.1.1).
Performance #
Node 22, render to HTML string, median of 15 runs (full method and results; differences under ~10% are noise):
| CommonMark examples (exact) | spec.txt MB/s | README MB/s | min + gzip | |
|---|---|---|---|---|
| mdlib-pure | 652/652 | 43.5 | 36.4 | 10.9 KB |
| marked | 503/652 | 44.6 | 32.7 | 14.0 KB |
| markdown-it | 649/652 | 35.6 | 22.0 | 40.6 KB |
| commonmark.js | 652/652 | 47.4 | 48.2 | 46.8 KB |
With GFM: mdlib-pure + gfm() 39.6 / 25.1 MB/s, marked 38.8 / 27.2,
markdown-it 28.7 / 17.4 (mdlib-pure also does footnotes, task lists and
alerts).
Reproduce: npm run build && npm install --prefix bench && node bench/run.js.
Security #
html: false(default): HTML in the source is not recognized and renders as text.- Every
href/srcpasses throughallowUrl. The default is an allowlist: relative URLs,http:,https:,mailto:,tel:,sms:, plusdata:image/{png,gif,jpeg,webp}for images only. renderTo()builds DOM withcreateElement/createTextNode. Only when raw HTML is present (you enabledhtml, or a plugin emits raw HTML) does it assigninnerHTML, aftersanitize.- Linear-time parsing, nesting caps and output-amplification limits.
Details and threat model: docs/security.md.
Development #
npm test # 1000+ tests, TypeScript runs natively in Node ≥ 22.18
npm run test:browser # DOM path, theme and Tailwind build in Chromium
npm run spec # CommonMark conformance report
npm run verify # typecheck + tests + invariants + size budgets
npm run build # dist/
Live demo: npm run build, serve the repository root with any static
server, open /demo/.
License #
MIT © Michele Polo. Spec fixtures in test/fixtures keep their own
licenses (test data only, not published): see
test/fixtures/README.md.