mrkeyoor.com_
Thu 06 Aug 05:54 UTC
npmUtilsupdated 06 Aug 2026

markdown-it

markdown-it turns Markdown text into HTML. It passes the full CommonMark specification, adds the sugar people expect on top (tables, strikethrough, URL autolinking, smart quotes), and escapes raw HTML by default so untrusted input does not become script tags. The part that separates it from simpler parsers is the pipeline: parsing produces a flat stream of Token objects, a Renderer walks that stream, and both the parser rules and the per-token render functions are addressable by name. That means you can disable a syntax, insert a new one, or rewrite how one token type prints without forking anything, which is why almost every plugin-driven Markdown ecosystem in JavaScript is built on it.

Verdict

The default Markdown parser for JavaScript when you need correctness plus extension points, and the engine under a large slice of the static-site and docs tooling people already use. Check the v15 migration notes before upgrading, and never enable raw HTML for untrusted input without a sanitizer.

API stability4/5The public parser API survived the v15 major unchanged, and both new MarkdownIt() and the markdownit() factory form still work. The point comes off for what v15 did remove: package-internal deep imports, three md.utils helpers, and the default-on fuzzy link detection.
Docs4/5There is a generated API reference, an architecture write-up explaining the token stream and rule chains, a live demo, and a written migration guide for v15. The README itself is a stub, and writing a non-trivial plugin still means reading the source of an existing one.
Maintenance5/5Pushed within the last few days, 15.0.0 released 30 July 2026 after 14.2.0 and 14.3.0 earlier the same year, and only 6 open issues (7 issues and PRs) on a 21.7k star repo.
Ecosystem5/5Dozens of maintained plugins under the markdown-it organisation plus hundreds on npm, and it is the Markdown engine inside VitePress, Eleventy and much of the docs tooling world. The Python port markdown-it-py mirrors the same architecture.

Use it if

  • You need real CommonMark compliance rather than a regex-based approximation that disagrees with GitHub on edge cases
  • You want to change how output is generated per token, for example adding target="_blank" to external links or ids to headings, without post-processing HTML strings
  • You need plugins: footnotes, containers, attributes, anchors, definition lists and dozens more already exist and all use the same md.use() interface
  • You want to restrict the syntax, for example a comment box that allows only emphasis and links, which the "zero" preset plus enable() does directly
  • You are on TypeScript: since v15 the types ship inside the package, so @types/markdown-it is no longer needed
Skip it if

Setup reality

npm install markdown-it is genuinely all of it: no peer dependencies, no native build, no config file. Three things still bite. First, if you are coming from v14 you must uninstall @types/markdown-it, because v15 bundles its own types and having both produces duplicate-declaration errors that look like a tsconfig problem. Second, the import shape catches people out: the package default-exports the class, so it is import MarkdownIt from 'markdown-it' in ESM and const MarkdownIt = require('markdown-it') in CommonJS, and plugin packages vary in whether their own default export is wrapped. Third, plugins are ordinary functions passed to md.use(), so they are unversioned against the core; a plugin written for v13 internals can silently produce wrong output on v15 rather than throwing, and the project only guarantees that plugins from the markdown-it organisation are v15-compatible.

Patterns

Render a Markdown string to HTMLrender-markdown

import MarkdownIt from 'markdown-it'

const md = new MarkdownIt()
const html = md.render('# Hello\n\nSome **bold** text.')

Create the instance once and reuse it. Constructing a new MarkdownIt per request re-registers every rule and is measurably slower than reusing a module-level singleton.

Set the common optionsconfigure-options

const md = new MarkdownIt({
  html: false,        // escape raw HTML in the source
  linkify: true,      // turn URLs into links
  typographer: true,  // smart quotes and dashes
  breaks: false,      // single newline is not a <br>
  langPrefix: 'language-',
})

html: false is the default and is what makes the library safe for untrusted input. breaks: true is the one people usually want for chat-style input, where users expect Enter to mean a line break.

Start from a stricter or looser presetchoose-preset

const strict = new MarkdownIt('commonmark')   // spec only, no extensions
const loose  = new MarkdownIt('default')      // spec plus tables, strikethrough
const minimal = new MarkdownIt('zero')        // everything disabled

minimal.enable(['emphasis', 'link', 'linkify'])

The zero preset plus an explicit enable list is the right way to build a restricted input, for example a comment box that permits bold and links but not headings, images or HTML.

Highlight fenced code blockssyntax-highlighting

import hljs from 'highlight.js'

const md = new MarkdownIt({
  highlight(str, lang) {
    if (lang && hljs.getLanguage(lang)) {
      try {
        return '<pre><code class="hljs">' +
          hljs.highlight(str, { language: lang, ignoreIllegals: true }).value +
          '</code></pre>'
      } catch {}
    }
    return '<pre><code class="hljs">' + md.utils.escapeHtml(str) + '</code></pre>'
  },
})

Whatever you return is inserted as raw HTML, including the pre and code wrapper, so escaping is your job in the fallback path. Return an empty string instead to let markdown-it emit its own escaped block.

Add pluginsuse-plugin

import MarkdownIt from 'markdown-it'
import footnote from 'markdown-it-footnote'
import anchor from 'markdown-it-anchor'

const md = new MarkdownIt()
  .use(footnote)
  .use(anchor, { permalink: anchor.permalink.headerLink() })

use() returns the instance so calls chain, and order matters when two plugins touch the same rule. Only plugins from the markdown-it organisation are guaranteed compatible with v15; third-party ones that reach into internals may need checking.

Render without wrapping paragraph tagsrender-inline

md.renderInline('A **bold** label')
// 'A <strong>bold</strong> label'

md.render('A **bold** label')
// '<p>A <strong>bold</strong> label</p>\n'

renderInline skips all block-level parsing, so headings, lists and code fences are treated as plain text. Use it for single-line fields such as titles or table cells.

Open external links in a new tabcustomize-link-rendering

const defaultRender = md.renderer.rules.link_open ||
  ((tokens, idx, options, env, self) => self.renderToken(tokens, idx, options))

md.renderer.rules.link_open = (tokens, idx, options, env, self) => {
  const href = tokens[idx].attrGet('href')
  if (href && /^https?:\/\//.test(href)) {
    tokens[idx].attrSet('target', '_blank')
    tokens[idx].attrSet('rel', 'noopener noreferrer')
  }
  return defaultRender(tokens, idx, options, env, self)
}

Always capture the previous rule and call it, rather than rebuilding the tag yourself. Skipping that is how people accidentally drop attributes that a plugin added earlier in the chain.

Reject links you do not trustrestrict-url-schemes

const ALLOWED = /^(https?:|mailto:|#|\/)/

md.validateLink = (url) => ALLOWED.test(url.trim().toLowerCase())

markdown-it already blocks javascript:, vbscript: and most data: URLs by default. Override validateLink only to tighten the rule; a permissive override that returns true for everything reopens a real injection hole.

Autolink bare domainsenable-linkify-fuzzy

const md = new MarkdownIt({ linkify: true })
md.linkify.set({ fuzzyLink: true })

md.render('visit example.com')

As of v15 the underlying linkify-it no longer treats bare example.com as a link by default; only URLs with a scheme are picked up. Turning fuzzyLink back on restores the v14 behaviour.

Parse to tokens instead of HTMLinspect-tokens

const tokens = md.parse('# Title\n\n- one\n- two', {})

for (const token of tokens) {
  console.log(token.type, token.tag, token.nesting, token.content)
}

const html = md.renderer.render(tokens, md.options, {})

The stream is flat: nesting is expressed as paired tokens with nesting 1 and -1 rather than children arrays, except for inline tokens which carry their own children array. This is the main structural difference from remark.

Post-process tokens with a core ruleadd-core-rule

md.core.ruler.push('heading_ids', (state) => {
  for (let i = 0; i < state.tokens.length; i++) {
    const token = state.tokens[i]
    if (token.type !== 'heading_open') continue
    const text = state.tokens[i + 1].content
    token.attrSet('id', text.toLowerCase().replace(/[^\w]+/g, '-'))
  }
})

Core rules run after block and inline parsing, which makes them the simplest place to walk the finished token list. The inline token always sits directly after its heading_open, which is what makes this lookahead safe.

Get Token and the parser classes in v15access-internal-classes

import MarkdownIt from 'markdown-it'

const { Token, StateBlock, StateInline, Ruler, Renderer } = MarkdownIt

const token = new Token('html_block', '', 0)
token.content = '<hr>'

v15 stopped exporting package-internal paths such as markdown-it/lib/token.mjs and hung these classes off the main export instead. Utilities and helpers still live on the instance as md.utils and md.helpers.

Alternatives

PackageRegistryPick it when
markednpmYou want the smallest fast Markdown to HTML converter and do not need a plugin pipeline
remarknpmYou need to inspect or rewrite document structure as an AST, or convert between formats
micromarknpmYou want a tiny, strictly compliant CommonMark tokenizer to build your own layer on