micro-mdx-parser

A tiny parser to convert markdown or html into JSON

MIT 39 个版本
mdx
安装
npm install micro-mdx-parser
yarn add micro-mdx-parser
pnpm add micro-mdx-parser
bun add micro-mdx-parser
README

micro-mdx-parser

A tiny parser that turns markdown + HTML + JSX into a JSON tree you can render however you want.

npm version npm downloads license runs in the browser

npm install micro-mdx-parser
const { parse } = require('micro-mdx-parser')

parse('<Callout type="warning">Heads up **friend**</Callout>')
// → [{ type: 'component', tagName: 'Callout', props: { type: 'warning' }, children: [...] }]

That's the whole idea: hand it a string, get back a flat JSON array of nodes (text, element, component, comment) with parsed props and source positions. What you render those nodes into is up to you.


TL;DR

The problem. You want to write content in markdown and drop in React/JSX components (<Callout>, <YouTube id="..."/>, <div style={{...}}>) — i.e. MDX. But the official MDX toolchain compiles to a JS module at build time. That's heavy, needs a bundler step, and can't run in the browser at edit-time. If you're building a CMS, a live preview, or a comment box, you need something that parses MDX-ish content in the client, cheaply, without eval or a compile step.

The solution. micro-mdx-parser does one job: it walks the string and pulls every HTML tag, self-closing element, and capitalized component out into structured nodes — parsing their props into real JavaScript values — while leaving the markdown prose untouched as text nodes. No compilation. No evaluation. No build step. Just a tree.

You stay in control of rendering. Text nodes still contain raw markdown (**bold**, [links](…), # headings). Pair them with any markdown renderer; map element/component nodes to your own component registry. (See Rendering the tree.)

Why micro-mdx-parser?

You want… micro-mdx-parser
Parse MDX-ish content in the browser ✅ Pure JS, no bundler/compiler step
JSX props as real values (style={{…}}, count={2}, arrow fns) ✅ via oparser
Capitalized tags → components (<MyThing/>) type: 'component'
Source positions (line/column/char index) for every node ✅ great for editors & CMS
Round-trip back to HTML stringify(ast)
Structural validation (e.g. unclosed void tags) validate(ast), pluggable rules
Tiny footprint, one dependency
A markdown → HTML renderer ❌ that's your job — see below
A full MDX compiler with imports/exports/eval ❌ by design

Quick example

const { parse } = require('micro-mdx-parser')

const input = `# Welcome

Some **markdown** prose, then a component:

<Callout type="warning">
  Watch out!
</Callout>

<img src="a.jpg" alt="pic" />`

console.log(parse(input))
[
  // markdown stays raw, as text — note positions
  {
    type: 'text',
    content: '# Welcome\n\nSome **markdown** prose, then a component:\n\n',
    position: { start: { index: 0, line: 1, column: 1 }, end: {/*…*/} }
  },
  // capitalized tag → component, with parsed props + nested children
  {
    type: 'component',
    tagName: 'Callout',
    tagValue: '<Callout type="warning">\n  Watch out!\n</Callout>',
    props: { type: 'warning' },
    propsRaw: ' type="warning"',
    children: [ { type: 'text', content: '\n  Watch out!\n', position: {/*…*/} } ],
    position: {/*…*/}
  },
  // lowercase tag → element; self-closing flagged; props coerced
  {
    type: 'element',
    tagName: 'img',
    tagValue: '<img src="a.jpg" alt="pic" />',
    props: { src: 'a.jpg', alt: 'pic' },
    propsRaw: ' src="a.jpg" alt="pic" ',
    children: [],
    isSelfClosing: true,
    position: {/*…*/}
  }
]

The key thing to understand: the heading and **markdown** came back as a plain text node — not an <h1> or <strong>. This parser separates the component layer from the prose layer; it does not render markdown. That separation is the whole point (see next section).


The mental model

input string
────────────────────────────────────────────────────────────
# Welcome

Some **markdown** prose
  → stays as a `text` node

<Callout type="warning"> … </Callout>
  → becomes a `component` node

<img src="a.jpg" />
  → becomes an `element` node

                         parse()

flat array of nodes: [ text, component, element, … ]

      ┌─────────────────────┴─────────────────────┐
      ▼                                           ▼

text nodes                              element/component nodes
→ your markdown renderer                → your component registry

markdown-it, marked,                    React, Preact, Vue,
@davidwells/markdown                    plain DOM…

micro-mdx-parser owns the boring, fiddly part — finding tag boundaries, matching close tags, coercing JSX props, tracking positions — and hands you a clean tree. You own rendering.


Design philosophy

  1. Do one thing. Structure mixed markdown/HTML/JSX into JSON. Rendering, sanitizing, and markdown-to-HTML are explicitly out of scope — compose them yourself.
  2. Client-first. Must run in the browser with no build step, so live preview / CMS editing works. No eval, no dynamic import, no compiler.
  3. Mappable. Every node carries its exact position (line, column, char index) and tagValue, so you can map a node back to the source — essential for editors, error reporting, and partial re-rendering.
  4. JSX props are data, not strings. style={{…}}, numbers, booleans, and arrays come back as real JS values via oparser; functions come back as source strings you can choose to evaluate (or not).
  5. HTML semantics where it matters. Void tags (<img>, <br>), auto-closing tags (<li>, <td>), and raw-text tags (<script>, <style>, <template>) follow HTML rules so real-world content doesn't blow up the tree.

How it compares

micro-mdx-parser @mdx-js/mdx unified (remark/rehype) himalaya marked / markdown-it
Output JSON node array compiled JS module mdast/hast AST JSON (HTML only) HTML string
Markdown + JSX in one pass ✅ (JSX structural) ✅ (full) ✅ (with remark-mdx)
Parses JSX props to JS values partial n/a
Runs in browser, no build step ⚠️ heavy ⚠️ heavy
Compiles / evaluates code ❌ (none)
Renders markdown to HTML
Source positions per node partial
Footprint tiny (1 dep) large large tiny small

Pick this when you need to understand the structure of MDX-ish content at runtime (CMS, live preview, custom renderer) without a compiler. Pick @mdx-js/mdx when you want full MDX (imports/exports, expressions) compiled at build time. Pick unified when you want the rich remark/rehype plugin ecosystem and a true markdown AST.


Installation

# npm
npm install micro-mdx-parser

# pnpm
pnpm add micro-mdx-parser

# yarn
yarn add micro-mdx-parser

Module format. Ships as CommonJS (main: src/index.js). Use require(...) in Node, or import via any bundler (webpack, Vite, esbuild, Rollup) for the browser:

import { parse, stringify, validate } from 'micro-mdx-parser'

Runtime: Node and modern browsers. One runtime dependency (oparser) for prop parsing.


API

const { parse, stringify, validate, parseDefaults } = require('micro-mdx-parser')

parse(input, options?) → Node[]

Parse a string into a flat array of nodes (elements nest their children).

parse('<div class="box"><span>hi</span></div>')

Options

Option Type Default Description
includePositions boolean true Attach position (line/column/char index) to every node.
offset { lineOffset, charOffset } Shift all positions. Handy when you stripped frontmatter before parsing and want positions relative to the original file.
transforms { [tagName]: (node) => node } Rewrite a node by tag name at parse time (e.g. remap imgImage).
voidTags string[] HTML void tags Tags with no closing tag (img, br, …).
closingTags string[] list Tags that auto-close when a sibling opens (li, td, …).
childlessTags string[] ['style','script','template'] Raw-text tags whose body is kept verbatim, not parsed.
closingTagAncestorBreakers object map Ancestors that prevent auto-closing (e.g. li inside ul).
// Remap a tag during parse
parse('<img src="a.jpg" />', {
  transforms: { img: (node) => ({ ...node, tagName: 'Image' }) }
})
// → [{ type: 'element', tagName: 'Image', props: { src: 'a.jpg' }, ... }]

// Re-base positions onto the original file after stripping frontmatter
parse(bodyWithoutFrontmatter, { offset: { lineOffset: 7, charOffset: 210 } })

stringify(ast, options?) → string

Serialize a node array back to an HTML string. Round-trips the structure (note: markdown text inside text nodes is emitted as-is, not converted to HTML).

const ast = parse('<div class="a"><span>hi</span></div>')
stringify(ast)
// → '<div class="a"><span>hi</span></div>'

validate(stringOrAst, options?) → Error[]

Run structural rules over the tree. Accepts a string (parses it first) or an existing AST. Returns a flat array of { message, value, position } — empty when clean. The default rule flags void tags missing their />.

validate('<img src="a.jpg">')
// → [{ message: 'Missing closing "/>" on "element". <img src="a.jpg">', value: '<img src="a.jpg">', position: {…} }]

// Bring your own rules: each is (node) => string | undefined
validate('<div>hi</div>', {
  rules: [ (node) => node.tagName === 'div' ? 'No bare divs allowed' : undefined ]
})
// → [{ message: 'No bare divs allowed', ... }]

parseDefaults

The default options object (tag tables + includePositions: true). Exported so you can read or extend the defaults.


Node shapes

Every node has a type. position is present unless includePositions: false.

// Prose / markdown / whitespace — content is raw, never rendered
{ type: 'text', content: '# Heading\n\nSome **bold** text', position }

// Lowercase HTML tag
{
  type: 'element',
  tagName: 'div',
  tagValue: '<div class="box">hi</div>',  // exact source slice
  props: { class: 'box' },                // parsed JS values
  propsRaw: ' class="box"',               // raw attribute string
  children: [ /* nodes */ ],
  isSelfClosing: true,                    // only when self-closed
  position
}

// Capitalized tag → component (same shape, different type)
{ type: 'component', tagName: 'Callout', props: {}, children: [...], position }

// HTML comment
{ type: 'comment', content: ' TODO ', position }

props coercion examples (powered by oparser):

parse('<Widget count={2} active style={{ color: "red" }} onClick={() => run()} />')[0].props
// → {
//     count: 2,                       // number
//     active: true,                   // boolean shorthand
//     style: { color: 'red' },        // object
//     onClick: '() => run()'          // function kept as a source string
//   }

Rendering the tree

parse() gives you structure; you decide what it becomes. A typical React renderer walks the array, runs text nodes through a markdown renderer and maps element/component nodes to a component registry:

import { parse } from 'micro-mdx-parser'
import Markdown from '@davidwells/markdown' // or markdown-it, marked, etc.

const registry = { Callout, YouTube, img: 'img' /* … */ }

function render(nodes) {
  return nodes.map((node, i) => {
    if (node.type === 'text')    return <Markdown key={i}>{node.content}</Markdown>
    if (node.type === 'comment') return null
    const Tag = registry[node.tagName] || node.tagName
    return <Tag key={i} {...node.props}>{render(node.children)}</Tag>
  })
}

function Mdx({ source }) {
  return <>{render(parse(source))}</>
}

The same tree works for Preact, Vue, plain DOM, or non-DOM targets — nothing here is React-specific.


Architecture

 input string
 ┌──────────┐   masks code fences, JSX props, arrow fns & inline URLs so
 │  format  │   their <, {, } don't confuse the tokenizer (un-masked later)
 └──────────┘
 ┌──────────┐   scans text vs. tags, emits a flat token stream,
 │  lexer   │   tracking line/column/index as it goes
 └──────────┘
 ┌──────────┐   builds the tree: matches open/close tags, applies
 │  parser  │   void / auto-close / raw-text HTML rules
 └──────────┘
 ┌──────────┐   shapes nodes (element vs component), parses props via
 │  format  │   oparser, restores masked chars, runs transforms
 └──────────┘
 Node[]   ──►  stringify()  back to HTML
          └─►  validate()   structural checks

Source map: src/lexer.js · src/parser.js · src/format.js · src/stringify.js · src/validate.js · src/tags.js


Performance

Pure single-pass parsing, no compile step. On Node 22, a 24 KB mixed-MDX file (examples/giant-md/giant.md) parses in ~1.4 ms (≈17 MB/s), single-threaded.

A golden-output perf harness lives in scripts/perf-parse-corpus.cjs:

node scripts/perf-parse-corpus.cjs                 # benchmark + check golden outputs
node scripts/perf-parse-corpus.cjs --write-golden  # regenerate golden checksums
MICRO_MDX_PERF_ITERATIONS=1000 node scripts/perf-parse-corpus.cjs

Testing

Tests use uvu — run any test file directly, no config:

npm test               # run everything in src/*.test.js
npm run test:lexer     # lexer only
npm run test:parser    # parser only
npm run test:stringify # stringify only
node src/parser.test.js  # run a single file directly

Runnable input/output samples live in examples/ (try node examples/md.js, node examples/html.js).


Troubleshooting

Symptom Cause Fix
# Heading / **bold** came back as a text node, not <h1> By design — this parser doesn't render markdown Run text node content through a markdown renderer (see Rendering the tree).
Component didn't become type: 'component' Tag name isn't capitalized Components must start with an uppercase letter (<Callout/>, not <callout/>).
onClick/onChange prop is a string, not a function Functions are intentionally not evaluated new Function('return (' + props.onClick + ')')() only if you trust the source — never on untrusted input.
< or { inside a code fence broke the tree A fence the masker didn't catch Confirm fences are well-formed; file a repro from examples/giant-md/ — many tricky cases are covered there.
Positions look wrong after stripping frontmatter Positions are relative to the string you passed in Pass offset: { lineOffset, charOffset } to re-base onto the original file.
<script>/<style> body got mangled It shouldn't — raw-text tag bodies are preserved verbatim. If not, please open an issue with a repro.

Limitations

Honest about what this is not:

  • Not a markdown renderer. Markdown stays as raw text. Bring your own renderer.
  • Not a full MDX compiler. No import/export, no JSX expression evaluation, no {count + 1} interpolation. Capitalized tags and their props are parsed structurally; nothing is executed.
  • Not a sanitizer. Output reflects input. If you render untrusted content, sanitize it yourself (e.g. DOMPurify on the rendered output).
  • Function props are strings. Evaluating them is your call (and a security decision).
  • Lenient, not spec-strict. It aims to produce a useful tree from real-world MDX-ish content, not to enforce strict HTML5 or JSX validity. Use validate() for the checks you care about.

FAQ

Does it convert markdown to HTML? No. It separates components/HTML from prose and leaves markdown as text nodes. Pair it with any markdown renderer. This is intentional — see the mental model.

Why not just use @mdx-js/mdx? MDX compiles to a JS module at build time and is heavy to run in a browser at edit-time. This was built to parse MDX-ish content in the client (CMS, live preview) cheaply, with no compile/eval step.

Does it run in the browser? Yes — pure JS, no build step required. Import it through your bundler.

How are JSX props parsed? Via oparser: numbers, booleans, objects, and arrays become real JS values; style={{…}} becomes an object; functions are returned as source strings (not evaluated).

Lowercase vs uppercase tags? Lowercase → type: 'element'. Uppercase first letter → type: 'component'. That's the only difference in shape.

Can I get the original source for a node? Yes — every node has tagValue (the exact source slice) and position (start/end with line, column, index).

Can I turn an AST back into a string? Yes — stringify(ast).

Is it TypeScript? It's JavaScript. The node shapes above are stable; types may be added later.


Credits

Fork of himalaya by Chris Andrejewski, extended to handle MDX-style components, JSX prop parsing, validation, and richer position tracking. Prop parsing by oparser.

License

MIT © David Wells

版本列表
1.1.32 2026-07-18
1.1.31 2026-07-18
1.1.29 2026-07-16
1.1.28 2026-07-15
1.1.27 2026-06-29
1.1.26 2026-06-27
1.1.24 2024-10-04
1.1.23 2024-10-04
1.1.22 2024-10-04
1.1.21 2024-10-04
1.1.20 2024-10-04
1.1.19 2024-10-04
1.1.18 2024-10-04
1.1.17 2024-10-04
1.1.16 2024-10-04
1.1.15 2024-10-03
1.1.14 2024-10-03
1.1.13 2024-09-10
1.1.12 2024-09-10
1.1.11 2024-05-01
1.1.10 2023-11-22
1.1.9 2023-11-17
1.1.8 2023-11-17
1.1.7 2023-11-17
1.1.6 2023-11-15
1.1.5 2023-11-15
1.1.4 2023-11-15
1.1.3 2023-11-14
1.1.2 2023-11-14
1.1.1 2023-11-13
1.1.0 2023-03-21
1.0.7 2023-02-20
1.0.6 2023-01-14
1.0.5 2023-01-08
1.0.4 2023-01-08
1.0.3 2023-01-07
1.0.2 2023-01-06
1.0.1 2023-01-06
1.0.0 2023-01-06