UNPKG

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
# 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