markdown-to-jsx
Version:
A very fast and versatile markdown toolchain. AST, React, React Native, SolidJS, Vue, Markdown, and HTML output available with full customization.
368 lines (269 loc) • 18.9 kB
Plain Text
# markdown-to-jsx
> CommonMark + GFM compliant markdown parser and compiler toolchain for JS/TS. Renders to React, React Native, Solid, Vue, HTML strings, or normalized markdown. Fast enough for real-time streaming. Current major version: v9.
Raw HTML in the source is parsed into real elements, never `dangerouslySetInnerHTML`. Every emitted tag is overridable. GFM tables, task lists, strikethrough, autolinks, footnotes, and tag filtering are on by default. Zero runtime dependencies and no network access.
Mental model: `parser(md) -> ASTNode[]` then `astTo*(ast, options) -> output`. `compiler(md, options)` is those two steps fused, and the framework `<Markdown>` components wrap `compiler`. Reach for `parser` only when you need to inspect or transform nodes before rendering.
## Install
```shell
npm i markdown-to-jsx
```
## Entry points
Pick the entry point for your target. Each one ships only the renderer it names, so tree-shaking works.
| Import | Default export | Named exports | Output |
| -------------------------- | -------------- | ----------------------------------------------------------------------------- | --------------- |
| `markdown-to-jsx` | `Markdown` | `compiler`, `parser`, `RuleType`, `sanitizer`, `slugify`, `MarkdownToJSX` | React elements |
| `markdown-to-jsx/react` | `Markdown` | above + `astToJSX`, `MarkdownProvider`, `MarkdownContext` | React elements |
| `markdown-to-jsx/native` | `Markdown` | above + `astToNative`, `MarkdownProvider`, `MarkdownContext` | RN elements |
| `markdown-to-jsx/solid` | `Markdown` | above + `astToJSX`, `MarkdownProvider`, `MarkdownContext` | Solid JSX |
| `markdown-to-jsx/vue` | `Markdown` | above + `astToJSX`, `MarkdownProvider`, `MarkdownOptionsKey` | Vue vnodes |
| `markdown-to-jsx/html` | none | `compiler`, `parser`, `astToHTML`, `RuleType`, `sanitizer`, `slugify` | HTML string |
| `markdown-to-jsx/markdown` | none | `compiler`, `parser`, `astToMarkdown`, `markdown`, `RuleType` | markdown string |
The bare `markdown-to-jsx` entry is the legacy one: its React code still works but is deprecated. New React code should import `markdown-to-jsx/react`.
## Core patterns
React component. Works unchanged in a Server Component and a Client Component; no `'use client'` needed.
```tsx
import Markdown from 'markdown-to-jsx/react'
;<Markdown options={{ wrapper: 'article' }}>{content}</Markdown>
```
Direct compile, no component:
```tsx
import { compiler } from 'markdown-to-jsx/react'
const element = compiler('# Hello world')
```
AST first, render second:
```tsx
import { parser, astToJSX, RuleType } from 'markdown-to-jsx/react'
const ast = parser('# Hello world')
const headings = ast.filter(node => node.type === RuleType.heading)
const element = astToJSX(ast)
```
HTML string for server rendering:
```ts
import { compiler } from 'markdown-to-jsx/html'
const html = compiler('# Hello world') // "<h1 id="hello-world">Hello world</h1>"
```
Markdown in, normalized markdown out:
```ts
import { compiler } from 'markdown-to-jsx/markdown'
compiler('Setext\n======\n\n* a\n* b') // "# Setext\n\n- a\n- b"
```
React Native, styled per element key:
```tsx
import Markdown from 'markdown-to-jsx/native'
import { Linking, StyleSheet } from 'react-native'
;<Markdown
options={{
styles: StyleSheet.create({
text: { color: '#333', fontSize: 17 }, // base under all rendered text
heading1: { fontSize: 32, fontWeight: 'bold' },
link: { color: '#58a6ff' },
}),
onLinkPress: url => Linking.openURL(url),
}}
>
{content}
</Markdown>
```
Solid, reactive by passing an accessor:
```tsx
import Markdown from 'markdown-to-jsx/solid'
const [content, setContent] = createSignal('# Hello world')
;<Markdown>{content}</Markdown>
```
Vue 3, via `h()` under the hood:
```tsx
import Markdown, { compiler } from 'markdown-to-jsx/vue'
const vnode = compiler('# Hello world')
```
## Options
Shared across every renderer unless noted.
| Option | Type | Default | Effect |
| ------------------------------- | ----------------------------- | -------- | -------------------------------------------------------------------------------- |
| `createElement` | `function` | - | Hook `(type, props, children)` before elements are built. JSX renderers only. |
| `disableAutoLink` | `boolean` | `false` | Leave bare URLs as text. |
| `disableParsingRawHTML` | `boolean` | `false` | Skip converting raw HTML to elements. |
| `enforceAtxHeadings` | `boolean` | `false` | Require a space after `#` for a heading. |
| `evalUnserializableExpressions` | `boolean` | `false` | Run `eval()` on JSX prop expressions. Unsafe; see Don't. |
| `forceBlock` / `forceInline` | `boolean` | `false` | Pin the whole input to block or inline parsing. |
| `forceWrapper` | `boolean` | `false` | Wrap even a single child. JSX renderers only. |
| `ignoreHTMLBlocks` | `boolean` | `false` | Emit HTML blocks as literal text. |
| `optimizeForStreaming` | `boolean` | `false` | Hold back incomplete syntax while content streams in. |
| `overrides` | `object` | - | Swap the component or props used for a tag name. |
| `preserveFrontmatter` | `boolean` | `false` | Render frontmatter instead of dropping it. |
| `renderRule` | `function` | - | Intercept rendering per AST node, before anything else. |
| `sanitizer` | `function` | built-in | Replace URL scheme sanitization. Signature `(value, tag, attribute)`. |
| `slugify` | `function` | built-in | Replace heading id generation from plain text content. Duplicate ids still get `-1`, `-2`, … suffixes. |
| `tagfilter` | `boolean` | `true` | Escape leading `<` on `script`, `iframe`, `style`, `title`, `textarea`, `xmp`, `noembed`, and kin; keep body and closer as inert text; allowed nested tags still render. |
| `wrapper` | `string \| component \| null` | `'div'` | Element wrapping multiple children. `null` returns an array. JSX renderers only. |
| `wrapperProps` | `object` | - | Props for that wrapper. |
React Native adds `styles`, `onLinkPress`, and `onLinkLongPress`.
## Overrides
Keys are HTML tag names, and they fire for both parsed markdown and raw HTML. A capitalized key registers a custom component usable directly in the markdown source.
```tsx
import DatePicker from './date-picker'
;<Markdown
options={{
overrides: {
h1: { component: Title, props: { className: 'page-title' } },
code: SyntaxHighlightedCode, // shorthand for { component: ... }
iframe: () => null, // remove entirely
DatePicker, // <DatePicker timezone="UTC+5" /> in the markdown
},
}}
>
{content}
</Markdown>
```
Props the library always supplies: `a` gets `href`/`title`, `img` gets `src`/`alt`/`title`, `input[type=checkbox]` gets `checked`/`readonly`, `ol` gets `start`, `td`/`th` get `style`. Inline text renders as `span`, inline code as `code`, fenced code as `pre > code`.
Common one-liners, all through the same mechanism:
```tsx
/** open every link in a new tab */
{ a: { props: { target: '_blank', rel: 'noopener noreferrer' } } }
/** drop images but keep their alt text as visible copy */
{ img: ({ alt }) => alt }
/** rewrite image sources onto a CDN */
{ img: props => <img {...props} src={CDN + props.src} /> }
/** unwrap a tag, keeping its content */
{ b: ({ children }) => children }
/** drop a tag and its content entirely */
{ iframe: () => null }
```
There is no built-in tag allow-list option. Build one by unwrapping (`({ children }) => children`) or dropping (`() => null`) each tag you want gone, or set `disableParsingRawHTML: true` to render every raw HTML tag as literal text.
Override keys match the source tag's exact case: `<MyThing />` needs the key `MyThing`, and a `mything` key will not fire.
## renderRule
Runs before every other rendering path and sees nodes that are normally skipped (`ref`, `footnote`, `frontmatter`). Call `next()` to fall through to the default.
Here it swaps `:smile:` style shortcodes for emoji as text nodes are rendered:
```tsx
import { RuleType } from 'markdown-to-jsx/react'
const shortcodes = { smile: '🙂' }
const detector = /(:[^:\s]+:)/g
compiler(content, {
renderRule(next, node, renderChildren, state) {
if (node.type === RuleType.text && node.text.includes(':')) {
return node.text
.split(detector)
.map(part =>
part.startsWith(':') && part.endsWith(':')
? shortcodes[part.slice(1, -1)] || part
: part
)
}
return next()
},
})
```
`RuleType.text` is the hottest node type in the parser, so keep any matcher on it cheap and benchmark it.
## Code blocks and syntax highlighting
A fenced block renders as `<pre><code class="language-js">`. The JSX renderers add a legacy `lang-js` alongside it; the HTML string renderer emits `language-js` only. Two ways to highlight, and which one you want depends on whether your highlighter takes a string or a DOM node.
Highlighters that take the code as a string (react-syntax-highlighter, Shiki, KaTeX) go through `renderRule`, where the raw text and language are both in hand:
```tsx
import { RuleType } from 'markdown-to-jsx/react'
import SyntaxHighlighter from 'react-syntax-highlighter'
import TeX from '@matejmazur/react-katex'
<Markdown
options={{
renderRule(next, node, renderChildren, state) {
if (node.type === RuleType.codeBlock) {
if (node.lang === 'latex') {
return <TeX as="div" key={state.key}>{String.raw`${node.text}`}</TeX>
}
return (
<SyntaxHighlighter key={state.key} language={node.lang}>
{node.text}
</SyntaxHighlighter>
)
}
return next()
},
}}
>
{content}
</Markdown>
```
Highlighters that mutate a mounted element (highlight.js) go through a `code` override that reads the class name:
```tsx
function HighlightedCode(props) {
const ref = React.useRef(null)
React.useEffect(() => {
if (ref.current && props.className?.includes('lang-') && window.hljs) {
window.hljs.highlightElement(ref.current)
// hljs skips an element it has already touched unless this is cleared
ref.current.removeAttribute('data-highlighted')
}
}, [props.className, props.children])
return <code {...props} ref={ref} />
}
;<Markdown options={{ overrides: { code: HighlightedCode } }}>{content}</Markdown>
```
The `code` override fires for inline backticks too, so branch on `props.className` when the two should render differently. To wrap or replace the surrounding block, override `pre`.
## Streaming
For markdown arriving token by token from an LLM or socket, `optimizeForStreaming` suppresses half-written syntax until its closing delimiter lands, so readers never see a flash of raw `**` or `[text](`.
```tsx
<Markdown options={{ optimizeForStreaming: true }}>{content}</Markdown>
```
Held back: unclosed HTML tags and comments, unclosed inline code, bold, italic, strikethrough, unclosed links, and a table before its first data row. Fenced code blocks stream visibly as they arrive.
## AST
`parser()` returns a flat array of block nodes. Each node has a `type` from the `RuleType` enum plus type-specific fields.
```tsx
parser('# Hi\n\nA **bold** word.')
// [
// { type: RuleType.heading, level: 1, id: 'hi', children: [{ type: RuleType.text, text: 'Hi' }] },
// { type: RuleType.paragraph, children: [
// { type: RuleType.text, text: 'A ' },
// { type: RuleType.textFormatted, tag: 'strong', children: [...] },
// { type: RuleType.text, text: ' word.' },
// ]},
// ]
```
Node shapes worth knowing:
- `refCollection` `{ refs }` sits first in the array whenever the document defines link references or footnotes (footnote keys carry a `^` prefix). It is skipped during rendering; footnotes are pulled from it into a `<footer>`.
- `heading` `{ level, id, children }`
- `paragraph` / `blockQuote` `{ children }`, blockquote adding `alert` for GFM callouts
- `codeBlock` `{ lang, text, attrs? }`
- `orderedList` `{ items, start? }` / `unorderedList` `{ items }`, where `items` is an array of node arrays
- `table` `{ header, cells, align }`
- `htmlBlock` `{ tag, attrs?, children? }`, always fully parsed into `children`
- `htmlSelfClosing` `{ tag, attrs? }`, `htmlComment` `{ text }`
- `text` `{ text }`, `codeInline` `{ text }`, `textFormatted` `{ tag, children }`
- `link` `{ target, title?, children }`, `image` `{ target, alt?, title? }`
- `gfmTask` `{ completed }`, `footnoteReference` `{ target, text }`, `frontmatter` `{ text }`
- `breakLine`, `breakThematic`, `ref`, `footnote`
JSX props on custom components are parsed for you: arrays and objects through `JSON.parse` (`data={[1, 2]}` arrives as an array), booleans as booleans, and functions or free variables kept as strings.
## Security
Raw HTML is sanitized in every renderer, always, independent of `options.sanitizer`. Stripped before render: every `on*` inline handler, `javascript:`/`vbscript:`/non-image `data:` URLs in `href`, `src`, `action`, `formaction`, `poster`, `cite`, `background`, `data`, `longdesc`, and `xlink:href` (including entity-obfuscated variants), and `iframe` `srcdoc`. Dangerous tag names are escaped separately by `tagfilter`.
`data:image/svg+xml` passes the filter and can still execute script if a user opens it as a top-level navigation, so treat SVG data URLs from untrusted authors with care.
## Gotchas
**A single newline does not make a line break.** Per CommonMark it is a soft break and renders as a space. For a `<br>`, end the line with two spaces or a backslash. This is the most common surprise by a wide margin.
```ts
compiler('a\nb') // "<p>a\nb</p>", renders as one line
compiler('a \nb') // "<p>a<br />\nb</p>", two trailing spaces
compiler('a\\\nb') // "<p>a<br />\nb</p>", trailing backslash
```
**Indented template literals become code blocks.** Four leading spaces is indented-code syntax, so markdown written inside an indented template literal parses as a code block rather than as content. Dedent the string before passing it in.
**Fenced code inside a raw HTML block stays literal.** The parser does not descend into a raw HTML block looking for markdown, so a fence inside a `<div>` renders as its own backticks. Put the fence at the top level and wrap it with a `pre` override if you need a container.
**Leading whitespace in an HTML block is trimmed** relative to the first line's indentation, so the block does not collide with indented-code syntax.
**Override keys are case-sensitive** and match the tag as written in the source.
**Function props arrive as strings.** `onClick={() => …}` reaches your component as the text `"() => …"`, by design.
## Do
- Import the entry point matching your renderer (`markdown-to-jsx/react`, `/html`, …) so unused renderers drop out of the bundle.
- Keep markdown in `.md` files or dedented variables. JSX collapses newlines, so markdown written inline in JSX loses its block structure.
- Use `overrides` for tag-level changes and `renderRule` for node-level ones. `overrides` is the simpler tool; reach past it only when you need `node.lang`, `node.text`, or a normally-skipped node.
- Set `optimizeForStreaming` for any incrementally arriving content.
- Set `wrapper: null` with `compiler()` when you want the children array rather than a container element, or `wrapper: React.Fragment` to render at the parent's DOM level.
- Pass `slugify: str => str` when headings are non-Latin and the default deburring would empty the id. The input is the heading's plain text (no link URLs or image alt). Duplicate heading text already gets unique ids (`foo`, `foo-1`); you do not need a stateful slugify closure for that.
- On React Native, set `styles.text` once for document-wide font and color; every other key merges over its default, so name only the properties you are changing.
## Don't
- Don't enable `evalUnserializableExpressions` on content you did not write. It runs `eval()` on JSX prop expressions. For untrusted input, look up handlers by name in `renderRule` instead.
- Don't disable `tagfilter` for user-generated content; it is the guard that escapes `<script>` and friends while keeping their source visible as inert text.
- Don't reach for `dangerouslySetInnerHTML`. Raw HTML in the source already becomes real elements.
- Don't call `compiler(..., { ast: true })`. That option is gone in v9; use `parser()`.
- Don't pass `namedCodesToUnicode`. Removed in v9; the full HTML entity table is built in.
## v9 notes
- `ast: true` removed. Use `parser()`.
- `namedCodesToUnicode` removed. All named entities decode by default.
- `tagfilter` now defaults to `true`, so `script`, `iframe`, `style`, and similar tags are escaped (GFM leading-`<` form, body and closer kept) rather than rendered live. Pass `tagfilter: false` for the old behavior.
- `parser`, `astToJSX`, `astToHTML`, `astToMarkdown`, `astToNative` and the per-renderer entry points are new in v9.
- From v8: `MarkdownToJSX.ParserResult` is now `MarkdownToJSX.ASTNode`, and the `textBolded`/`textEmphasized`/`textMarked`/`textStrikethroughed` rule types collapsed into `RuleType.textFormatted` with a `tag` field.
## Reference
- [Full documentation](https://github.com/quantizor/markdown-to-jsx#readme): every option with prose and longer examples
- [Releases](https://github.com/quantizor/markdown-to-jsx/releases): changelog
- [Issues](https://github.com/quantizor/markdown-to-jsx/issues): bug reports and questions