# markdown-to-jsx v9: the ast option retired, tag filtering on by default

> markdown-to-jsx is a TypeScript markdown parser and compiler aimed at React, Solid, Vue, React Native and plain HTML. Version 9 retired the ast option, turned tag filtering on for nine dangerous tags, split the package across seven documented entry points, and left the React code on the main entry point marked for removal.

**quantizor/markdown-to-jsx** — A very fast and versatile markdown toolchain. Output to AST, React, React Native, SolidJS, Vue, HTML, and more!

- Repository: https://github.com/quantizor/markdown-to-jsx
- Website: https://markdown-to-jsx.quantizor.dev/
- Stars: 2,395 · Forks: 213
- Language: TypeScript
- License: not declared
- Published: 2026-09-28 · Updated: 2026-09-28 · Language: en
- Canonical page: https://hysenlabs.com/projects/quantizor-markdown-to-jsx

## The ast option is retired in favor of a parser() function

The v9 upgrade notes remove the `ast: true` flag that v8 accepted on `compiler()`. What replaces it is a standalone `parser()` function that hands back the parsed tree without rendering it, and the same substitution appears as the first numbered step of the migration guide.

```typescript
/** v8 */ compiler('# Hello world', { ast: true })
/** v9 */ parser('# Hello world')
```

Two more names disappear in the same release. `namedCodesToUnicode` is gone, on the grounds that all named HTML entities now work by default through the full entity list, so a caller has nothing left to map by hand. The root manifest shows where that list comes from: a script named `entities` runs `bun scripts/generate-entities.ts`, which regenerates the table the old option used to extend.

Type names had already churned once. The older guide sits inside a collapsed `details` block, and in it the v7 result type `MarkdownToJSX.ParserResult` becomes `MarkdownToJSX.ASTNode` in v8. That release also folds `RuleType.textBolded`, `textEmphasized`, `textMarked` and `textStrikethroughed` into a single `RuleType.textFormatted` member plus boolean flags carried on the node. Code that reads bold or strikethrough nodes is therefore on its third spelling across three major versions, and the upgrade note for it is the only place that history is written down.

## One script tag, two different escapes in the output

v9 turns `tagfilter` on by default for the React, HTML, Solid, Vue and React Native outputs. The names it covers are `script`, `iframe`, `style`, `title`, `textarea`, `xmp`, `noembed`, `noframes` and `plaintext`, matching the GitHub Flavored Markdown rule the notes refer to. The mechanism is narrower than the word filter suggests. Only the leading `<` of each tag is neutralized, the body and the closing tag stay visible as inert text, and allowed nested tags inside a filtered parent still render normally. Two earlier behaviors are named as the things being replaced: some renderers used to mount these tags live, and escaped output used to drop everything after the opener.

```typescript
compiler('<script>alert("xss")</script>')
// React: <span>&lt;script&gt;alert("xss")&lt;/script&gt;</span>
// HTML:  &lt;script>alert("xss")&lt;/script>
```

Those two comment lines are the interesting part. React wraps the result in a span and escapes both angle brackets, while the HTML target escapes only the opening one, which leaves a bare `>` sitting in the string. The same source input therefore produces two byte sequences that differ, so a filter, a header or a content policy written against one target is not automatically right for the other. Passing `tagfilter: false` restores the previous live rendering for anyone who needs those tags to mount for real.

## Seven documented entry points, one of them already marked for removal

The table of contents is organized by entry point: Main, React, a nested React Server Components heading, React Native, SolidJS, Vue.js, HTML and Markdown. The new entry points note in the upgrade section names only three of them, React, HTML and markdown, and gives the reason as better tree shaking and separation of concerns.

```typescript
// React-specific usage
import Markdown, { compiler, parser } from 'markdown-to-jsx/react'

// HTML string output
import { compiler, astToHTML, parser } from 'markdown-to-jsx/html'

// Markdown string output (round-trip compilation)
import { compiler, astToMarkdown, parser } from 'markdown-to-jsx/markdown'
```

The two string targets deserve a closer look. The HTML entry exports `astToHTML`, and the markdown entry exports `astToMarkdown`, described as round-trip compilation, so a tree can leave the parser as Markdown source again rather than only as markup. What is not symmetric is the support commitment. The main entry point keeps working for backward compatibility, but the React code reachable from it is deprecated and scheduled for removal in a future major release, so a new project has one clearly blessed import path and six that are documented without any such note. The package description makes the same list, promising output to AST, React, React Native, SolidJS, Vue, HTML and more, which is consistent with the section list but says nothing about which paths will survive.

## The option list is wider than the documentation next to it

Under Library Options, the table of contents names `createElement`, `forceWrapper`, `overrides`, `evalUnserializableExpressions`, `ignoreHTMLBlocks`, `renderRule`, `sanitizer`, `slugify`, `wrapper` with a nested Other useful recipes heading, and `wrapperProps`. Separate headings promise Syntax highlighting, Handling shortcodes, Streaming Markdown, Usage with Preact, AST Anatomy with Node Types and Type Checking, and a Gotchas section.

The part of the README that reaches this repository stops one line into the note about JSX not preserving newlines, immediately after the first usage example:

```tsx
import Markdown from 'markdown-to-jsx'
import React from 'react'
import { render } from 'react-dom'

render(<Markdown># Hello world!</Markdown>, document.body)
```

So every one of those subsections is announced by the table of contents and none of its text is present here. That gap covers the two option names carrying the most weight for untrusted input, `evalUnserializableExpressions` and `sanitizer`, along with the separate Raw HTML sanitization section. What is visible does support one of the promises in the description: any HTML tag rendered by the compiler or by the `<Markdown>` component can be overridden to add props or to swap in a different representation entirely, and the visible note itself warns against writing Markdown inline in JSX because JSX does not preserve newlines in multiline text.

## The published package and the private root are different names

The installation instruction is one line, and it names the published package rather than anything in the root manifest:

```shell
npm i markdown-to-jsx
```

That root manifest calls itself `@markdown-to-jsx/root`, sets `private` to true and `type` to `module`, and lists five workspaces: `lib`, `benchmarks`, `browser`, `native` and `site`. The development dependencies pin React 19 around a single patch: `@types/react` at `^19.2.6`, `react` at `^19.2.8` and `react-dom` at exactly `19.2.8`, with no caret on the last one. A fresh install can move React forward while holding react-dom in place, which is the kind of split that shows up later as a rendering or hook mismatch rather than as an install error.

The `overrides` block forces `rollup` to `^4.61.1`, `vite` to `^7.3.5` and `yaml` to `^2.8.3` across the whole workspace tree, so a transitive dependency cannot pick its own major. Other entries say what the maintainer leans on: `biome` for lint and format, `knip` for unused code, `@changesets/cli` for releases, `rimraf`, and `in-publish` carried as a dependency rather than wired into a script. The lockfile at the root is `bun.lock`, and every task in the manifest is invoked through `bun run`.

## Several scripts begin with pushd, which a POSIX shell does not have

The build script reads `pushd lib; bun run build; popd && bun scripts/verify-declarations.ts`, and the benchmark scripts follow the same shape: `pushd benchmarks; bun run bench; popd`, with `bench:all`, `bench:jsx` and `bench:html` doing the same thing. `pushd` and `popd` are built into cmd.exe. The default shell on macOS and Linux has no such command, so there the line fails at its first token, and in the build script the verification step after `&&` never runs at all.

The same manifest offers a second way to reach a workspace that does not depend on directory stack commands. `test` runs `bun run --filter markdown-to-jsx test`, `dev` runs `bun run --filter @markdown-to-jsx/site dev`, and `test:browser` chains a package build, a site build and a browser test through the same filter form. So the repository holds one mechanism for entering a workspace that assumes a Windows shell and another that assumes no shell at all.

Around those sit single purpose tasks: `lint` is `biome check .`, `format` is `biome check --write --unsafe .`, `knip` is `knip`, `fixture` regenerates a test fixture through `bun scripts/generate-fixture.ts`, `analyze` runs `bun scripts/analyze-patterns.ts`, and `hooks:install` points git at the hooks directory with `git config core.hooksPath .githooks`.

## The speed claim has a benchmark harness behind it and no figure in front

The package description calls this a very fast and versatile markdown toolchain, and the opening line of the README text says it is extremely fast, capable of processing large documents fast enough for real-time interactivity. Neither sentence carries a number, a document size, a parser version or a hardware note, which is the shape a speed claim takes when it has not been pinned to a measurement.

The tree suggests the measurement exists. A `benchmarks` workspace is declared, and the root scripts expose `bench`, `bench:all`, `bench:jsx` and `bench:html`, plus `metrics` running `bun --expose-gc scripts/metrics.ts`, `profile` running `bun --cpu-prof --expose-gc --cpu-prof-md scripts/profile.ts`, and `analyze`. Exposed garbage collection and a CPU profiler wired into scripts are what you reach for when the intent is to publish figures rather than to make the suite pass. A `fixtures` directory and a `fixture` script that regenerates one are the other half of a repeatable setup.

The claim is checkable in principle, and the check is not in the text that reaches this repository. The `site` workspace, the `docs` directory and the homepage domain recorded in the root `CNAME` file are where a results table would belong.

## No LICENSE file in the root listing, beside four files written for coding agents

The package metadata records no license, and the list of top level repository entries contains no LICENSE file either. That inventory runs from `.changeset/`, `.cursor/`, `.gitattributes`, `.githooks/`, `.github/`, `.gitignore`, `.nvmrc` and `.vscode/` through `AGENTS.md`, `BACKLOG.md`, `CHANGELOG.md`, `CLAUDE.md`, `CNAME`, `CONTRIBUTING.md` and `README.md`, and on to `benchmarks/`, `biome.jsonc`, `browser/`, `bun.lock`, `bunfig.toml`, `compiler-wisdom.md`, `docs/`, `fixtures/`, `knip.jsonc`, `lib/`, `native/`, `notes/`, `package.json`, `public/`, `scripts/`, `site/`, `standards/` and `tsconfig.json`. Nothing in that list states redistribution terms, so a team evaluating the package has to ask the maintainer rather than read the answer off the repository.

Four entries in the same list are addressed to coding agents rather than to users of the library: `AGENTS.md`, `CLAUDE.md`, the `.cursor/` directory and `compiler-wisdom.md`, joined by `notes/`, `standards/` and `BACKLOG.md`. That is a visible account of how the work is done, and it also means part of the project's reasoning sits in plain files at the root instead of in the docs site. The release trail in the metadata is short: `markdown-to-jsx@9.10.1` on 2026-07-31, `9.10.2` on 2026-08-03 and `9.10.3` on 2026-09-15, with the last push carrying the same date as the newest tag.

## Conclusion

markdown-to-jsx earns a look for one specific reason: the v9 defaults are conservative where it counts. A filtered list of dangerous tags that keeps the rest of the source visible as inert text is easier to reason about than output that silently disappears, and a standalone parser function is a cleaner way to reach the tree than a boolean option. Two things should be settled before it goes into a build. First, the license, since the package metadata records none and the repository root lists no LICENSE file, so confirm the terms with the maintainer rather than assume them. Second, what your bundler does with seven entry points when the main one is already on a deprecation path, and whether the React and HTML outputs are both acceptable to whatever sanitizes your rendered markup, because the same source escapes two different ways. On speed, the measurement harness is in the tree and the figure is not published here.

## FAQ

### What does markdown-to-jsx output besides React elements?

The package documents entry points for HTML strings and Markdown strings, exporting astToHTML and astToMarkdown for round-trip compilation, and the parser() function returns the parsed tree without rendering it. The package description also names React Native, SolidJS and Vue as targets.

### What changed in markdown-to-jsx version 9?

The ast option was removed in favor of the parser() function, namedCodesToUnicode was removed because all named HTML entities work by default through a generated list, and tagfilter is now on for script, iframe, style, title, textarea, xmp, noembed, noframes and plaintext.

### How does markdown-to-jsx handle a script tag now?

Tag filtering neutralizes only the leading angle bracket and keeps the body and closing tag visible as inert text. The React output shown in the upgrade notes wraps the result in a span and escapes both angle brackets, while the HTML output escapes only the opening one.

### Which license does markdown-to-jsx use?

The package metadata records no license, and no LICENSE file appears among the top level repository entries. The terms are not stated anywhere in that listing, so a team has to ask the maintainer.

### How is markdown-to-jsx installed?

The installation section gives one command, npm i markdown-to-jsx, and says to use your favorite package manager. The root manifest in the repository is a private package called @markdown-to-jsx/root with five workspaces, so the published name and the root name are different strings.

### When were the most recent markdown-to-jsx releases?

The metadata lists markdown-to-jsx@9.10.1 on 2026-07-31, 9.10.2 on 2026-08-03 and 9.10.3 on 2026-09-15, and the last push carries the same date as the newest tag.

## Sources

- [Issues](https://github.com/quantizor/markdown-to-jsx/issues)
- [Project website](https://markdown-to-jsx.quantizor.dev/)
- [quantizor/markdown-to-jsx on GitHub](https://github.com/quantizor/markdown-to-jsx)
- [README](https://github.com/quantizor/markdown-to-jsx/blob/main/README.md)
- [Releases](https://github.com/quantizor/markdown-to-jsx/releases)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/quantizor-markdown-to-jsx
