micro-mdx-parser
v1.1.28
Published
A tiny parser to convert markdown or html into JSON
Readme
micro-mdx-parser
A tiny parser that turns markdown + HTML + JSX into a JSON tree you can render however you want.
npm install micro-mdx-parserconst { 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
- Do one thing. Structure mixed markdown/HTML/JSX into JSON. Rendering, sanitizing, and markdown-to-HTML are explicitly out of scope — compose them yourself.
- Client-first. Must run in the browser with no build step, so live preview / CMS editing works. No
eval, no dynamicimport, no compiler. - Mappable. Every node carries its exact
position(line, column, char index) andtagValue, so you can map a node back to the source — essential for editors, error reporting, and partial re-rendering. - JSX props are data, not strings.
style={{…}}, numbers, booleans, and arrays come back as real JS values viaoparser; functions come back as source strings you can choose to evaluate (or not). - 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-parserModule 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 img → Image). |
| 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 checksSource 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.cjsTesting
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 directlyRunnable 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
