anzal1/samemark

The same mdast as remark-parse, much faster. Drop-in for unified, identical output, plus an incremental parser for streaming LLM output.

JavaScript

0

18 commits

updated Oct 12, 2026

See the code

See what people are saying

README

samemark

The same mdast as remark-parse, much faster.

Live demo: a long chat answer streamed into react-markdown (left) and into samemark/stream (right), with per-update timings measured in your browser and a check that both DOMs are identical, plus a playground that compares samemark's tree with mdast-util-from-markdown as you type.

Screen capture of the live demo: the same 10,000 character chat answer streams into react-markdown on the left and samemark/stream on the right, with per-update timings, a sparkline for each side and a badge reporting that both panes hold identical HTML.

The capture above is the live demo, recorded in headless Chromium on a GitHub Actions runner (capture run 38066505899: 250 updates, 1.76 s of update time on the left, 94 ms on the right, identical HTML at all 25 checks). On your own device the numbers will differ; they are measured in your browser.

Horizontal bar chart of end to end markdown to HTML time for six inputs, remark-parse against samemark. samemark is 6.2x to 10.3x faster, for example 5.00 s down to 486 ms on the Node.js API docs.

samemark is a markdown parser that returns the same mdast tree as mdast-util-from-markdown with GFM (what remark-parse + remark-gfm produce), positions included, and falls back to that real parser for anything it does not reproduce exactly.

micromark by Titus Wormer defines the behavior; this parser matches it and falls back to it. Its test suite and its output are the specification.

How it was made. AI agents wrote most of the code. Every change is checked against micromark's own output: the full tree must be equal, node by node, on thousands of real documents, the CommonMark spec examples, and tens of millions of generated inputs. Nothing here relies on trust in the author or the model; the checks are in this repository and the commands to run them are below.

What you get

In a real unified pipeline (markdown to HTML with remark-gfm, remark-rehype, rehype-stringify) the whole run is 6.2x to 10.3x faster with samemark as the parser, across six inputs on a GitHub Actions runner (Node 24, median of 7 rounds, identical HTML on every file). Parsing alone is 28x to 47x faster; the rest of the pipeline is unchanged, which is why the end-to-end number is smaller.

Streaming a 30,000 character chat answer into a real DOM in Chromium, samemark/stream with memoised blocks spends 60x less main-thread time than react-markdown re-rendering the whole message on every chunk, and 77x less with the CPU throttled 6x as a phone proxy (79.4 s down to 1.03 s over 750 pushes; 103 ms down to 1.1 ms per push), with the same DOM. Swapping only the parser inside react-markdown gives 5x to 6x.

Building a 239-page Astro site took 29.6 s with remark-parse and 22.9 s with samemark (23% less).

All numbers come from one CI run (38036885270) and are reproducible with npm run bench. Details and method: Benchmarks.

  • parse(source) returns an mdast Root equal to fromMarkdown(source, {extensions: [gfm()], mdastExtensions: [gfmFromMarkdown()]}).
  • remarkParseFast is a drop-in for remark-parse in a unified processor.
  • remark-gfm, remark-frontmatter (yaml, toml or both) and remark-math (with or without singleDollarTextMath) are implemented natively, alone or together. Anything else (remark-directive, MDX, custom frontmatter fences, other micromark extensions) is detected and given to the real parser. You get micromark's answer, at micromark's speed.
  • No micromark at parse time. The fast path imports six small pure helper packages (four micromark-util-* packages plus ccount and decode-named-character-reference) and nothing else. mdast-util-from-markdown is only needed for the fallback, as an optional peer dependency.

Install

npm install samemark
# for the fallback (recommended; the main entry imports it):
npm install mdast-util-from-markdown unified remark-gfm

Node 18 or newer, or any runtime with ES2020 and TextDecoder (browsers, Deno, Bun, edge runtimes).

Usage

With unified, replace remark-parse:

import {unified} from 'unified'
import remarkGfm from 'remark-gfm'
import remarkRehype from 'remark-rehype'
import rehypeStringify from 'rehype-stringify'
import remarkParseFast from 'samemark'

const file = await unified()
  .use(remarkParseFast) // instead of remark-parse
  .use(remarkGfm)
  .use(remarkRehype)
  .use(rehypeStringify)
  .process('# Hello *world*')

Inside a framework that already installs remark-parse (Astro, MDX, Next.js plugins), add it to remarkPlugins; it replaces the parser after remark-gfm has registered, which is the order it needs:

// astro.config.mjs
import remarkParseFast from 'samemark'
export default {markdown: {remarkPlugins: [remarkParseFast]}}

With react-markdown (it builds a unified processor on every render with remark-parse first, so a plugin that sets the parser takes over; verified by the benchmark below):

import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import remarkParseFast from 'samemark'

const plugins = [remarkParseFast, remarkGfm] // define once, outside the component

export const Answer = ({text}) => <Markdown remarkPlugins={plugins}>{text}</Markdown>

Chat apps re-render the whole message on every streamed token, so the parser runs again over a growing string each time; see the react-markdown numbers in Benchmarks.

Without a processor:

import {parse} from 'samemark'
const tree = parse('# Hello *world*') // mdast Root

Entries

EntryFallbackImports
samemarkthe real parser, mdast-util-from-markdown (optional peer)needs mdast-util-from-markdown installed
samemark/streamnone; incremental parser for streamed text, stock GFM with optional frontmatter and maththe six helper packages; also CommonJS
samemark/corenone: throws on unsupported extensions, unless remark-parse was set up before the plugin (it then takes over) or {fallback} is passedonly the six helper packages; also require('samemark/core') from CommonJS

The main entry also runs a one-time check per processor: three small documents are parsed by both parsers with the processor's own extensions; any difference sends that processor to the fallback (reason: "self-check ... failed"). That guards against a micromark release that changes behavior. It is a tripwire, not a proof.

Options

Both parse(source, options) and remarkParseFast(options) take:

OptionMeaning
strictThrow instead of falling back (also when the fast parser itself throws). Default false.
onFallback(reason)Called with a reason string for every document parsed by the fallback.
warnOne console.warn per distinct fallback reason.
fallback(text, ctx)Parser to use instead of the default fallback. ctx has extensions, mdastExtensions, settings.

parse additionally accepts extensions and mdastExtensions (the arrays you would give fromMarkdown). Left out, stock GFM is assumed. source may be a string, a Uint8Array (UTF-8) or an object with toString().

Nobody should find out about a fallback in a profiler. stats is exported by both entries:

import {stats, check} from 'samemark'
stats // {fast: 120, fallback: 0, lastFallbackReason: null}
check(micromarkExtensions, fromMarkdownExtensions) // null when the fast path is exact, else the reason

Streaming for chat UIs

Chat UIs re-parse the whole message every time a token arrives. With samemark as the parser that re-parse is 3x to 7x cheaper depending on the runtime, with identical output (react-markdown numbers in Benchmarks). samemark/stream removes the re-parse: it is incremental, and it stays exactly identical to parsing the whole text.

import {createStreamParser} from 'samemark/stream'

const stream = createStreamParser()          // options: {frontmatter, math}, same meaning as remark-frontmatter / remark-math
for await (const chunk of tokens) {
  const tree = stream.push(chunk)            // mdast Root, equal to parse(everything pushed so far), positions included
  render(tree)
}
stream.end()                                 // final tree; stream.reset() starts a new message
  • After every push the tree equals what parse() returns for all text so far, which equals micromark's tree. Chunks can split anything: a token, a fence, a table row, a link.
  • Blocks that can no longer change keep their object identity from push to push (an unfinished tail is re-parsed, the rest is reused), so React.memo keyed on the node skips them. The cost of a push is the new text plus the unfinished tail, not the whole message.
  • A later reference or footnote definition can change how an earlier block resolves; the affected blocks are rebuilt as new objects, so identity changes exactly when the content can have changed.
  • Supports stock GFM, optionally with frontmatter and math. For other extensions parse the whole message with the main entry. Also require('samemark/stream') from CommonJS.

A React integration lives in examples/react-chat/StreamMarkdown.jsx: a useMarkdownStream() hook and a <StreamMarkdown tree> component that renders the mdast with mdast-util-to-hast and hast-util-to-jsx-runtime, one memoised block per top-level node, applying the same URL sanitising and raw HTML handling as react-markdown. Its HTML equals react-markdown's for the whole message (test/stream.test.mjs, and the DOM comparison in the Chromium benchmark below).

import {StreamMarkdown, useMarkdownStream} from './StreamMarkdown.jsx'

function Answer({tokens}) {                  // tokens: an async iterable of strings
  const {tree, push, reset} = useMarkdownStream()
  useEffect(() => {
    reset()
    ;(async () => { for await (const chunk of tokens) push(chunk) })()
  }, [tokens])
  return <StreamMarkdown tree={tree} />
}

Streaming cost

Parser only, from node bench/stream-bench.mjs in CI (ubuntu-latest, 12 characters per push, a generated LLM-style answer): total time to push the whole answer, a full re-parse per push against createStreamParser.

Answer lengthFull re-parse per pushsamemark/streamSpeedup
10,451 characters422 ms66 ms6.4x
30,416 characters981 ms54 ms18.2x
100,193 characters9,232 ms133 ms69.4x

Identity of that parser, in CI: 48,196,453 pushes over 465,378 documents (corpus replays, fuzz strings, 1-character, random, token-like and whole-line chunkings, with and without frontmatter and math), each tree compared with a full parse; 0 failing.

Line chart of the total time to stream an answer in 12 character pushes at 10,451, 30,416 and 100,193 characters. A full re-parse per push takes 422 ms, 981 ms and 9,232 ms; samemark/stream takes 66 ms, 54 ms and 133 ms.

React render included (DOM commit, memoised blocks):

From CI run 38036885270 (Chromium, GitHub Actions runner); reproduce locally with npm run bench:stream (jsdom, smaller numbers) or run the bench workflow.

One chat message streamed 40 characters per push into a real DOM node (Chromium 156.0.8078.4, React 19 production, flushSync per push, so each push is parse + React render + DOM commit; layout and paint are not included). (a) react-markdown re-rendering the whole message per push, (b) the same with samemark as the parser, (c) the stream parser with per-block memoisation (examples/react-chat). "Total" is the sum of all push times on the main thread. The DOM after the final push, and after every 25th push, is compared with (a): the last column.

CPUVariantPushesTotalPer push p50p95worstSame DOM as (a)
no throttling(a) react-markdown, whole message per push75011.29 s15 ms28 ms32 ms31/31
no throttling(b) react-markdown + samemark7502.24 s (5.0x less)2.9 ms5.0 ms31 ms31/31
no throttling(c) stream parser + memoised blocks750187 ms (60.3x less)0.2 ms0.6 ms6.4 ms31/31
4x slower(a) react-markdown, whole message per push75050.91 s67 ms126 ms141 ms31/31
4x slower(b) react-markdown + samemark7509.85 s (5.2x less)13 ms24 ms122 ms31/31
4x slower(c) stream parser + memoised blocks750669 ms (76.1x less)0.8 ms2.2 ms30 ms31/31
6x slower(a) react-markdown, whole message per push75079.43 s103 ms198 ms219 ms31/31
6x slower(b) react-markdown + samemark75015.33 s (5.2x less)20 ms36 ms207 ms31/31
6x slower(c) stream parser + memoised blocks7501.03 s (77.0x less)1.1 ms3.2 ms45 ms31/31

Environment: Node v24.21.0, GitHub Actions ubuntu24 X64 (github-hosted), INTEL(R) XEON(R) PLATINUM 8573C x4, linux x64. Each cell is the median of 7 rounds after one warm-up round, one fresh process per cell.

(a) is what a chat UI does today: react-markdown re-parses and re-renders the whole message on every push. (b) swaps the parser only. (c) is examples/react-chat.

Grouped horizontal bars of main-thread time streaming a 30,000 character answer into the DOM in Chromium, at no CPU throttling, 4x and 6x. react-markdown takes 11.29 s, 50.91 s and 79.43 s; with samemark 2.24 s, 9.85 s and 15.33 s; samemark/stream with memoised blocks 187 ms, 669 ms and 1.03 s.

Examples

Each snippet was run against this repository; the unified, frontmatter and fallback ones print what is shown.

unified pipeline (markdown to HTML):

import {unified} from 'unified'
import remarkGfm from 'remark-gfm'
import remarkRehype from 'remark-rehype'
import rehypeStringify from 'rehype-stringify'
import remarkParseFast from 'samemark'

const file = await unified()
  .use(remarkParseFast)
  .use(remarkGfm)
  .use(remarkRehype)
  .use(rehypeStringify)
  .process('# Hello *world*\n\n| a | b |\n|---|---|\n| ~~x~~ | y |')
console.log(String(file))
// <h1>Hello <em>world</em></h1>
// <table> ... <td><del>x</del></td> ... </table>

react-markdown, one line changed:

import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import remarkParseFast from 'samemark'

const plugins = [remarkParseFast, remarkGfm] // module level: not recreated per render
export const Answer = ({text}) => <Markdown remarkPlugins={plugins}>{text}</Markdown>

Streaming chat with samemark/stream. In React use examples/react-chat (the live demo runs it). Without a framework:

import {createStreamParser} from 'samemark/stream'

const stream = createStreamParser()
let tree
for (const chunk of ['# Hel', 'lo\n\nSome **bo', 'ld** text\n\n- a\n- b\n']) {
  tree = stream.push(chunk) // same tree as parse(everything so far); finished blocks keep their identity
}
console.log(tree.children.map(node => node.type)) // [ 'heading', 'paragraph', 'list' ]

Astro:

// astro.config.mjs
import remarkParseFast from 'samemark'
export default {markdown: {remarkPlugins: [remarkParseFast]}}

Frontmatter and math stay on the fast path:

import remarkFrontmatter from 'remark-frontmatter'
import remarkMath from 'remark-math'
import {stats} from 'samemark'

const file = await unified()
  .use(remarkParseFast)
  .use(remarkGfm)
  .use(remarkFrontmatter)
  .use(remarkMath)
  .use(remarkRehype)
  .use(rehypeStringify)
  .process('---\ntitle: Notes\n---\n\nEuler: $e^{i\\pi}+1=0$\n')
console.log(String(file)) // <p>Euler: <code class="language-math math-inline">e^{i\pi}+1=0</code></p>
console.log(stats)        // { fast: 1, fallback: 0, lastFallbackReason: null }

samemark parses math into the same inlineMath and math nodes as remark-math; typesetting is the renderer's job, exactly as with remark-parse. Add rehype-katex (or rehype-mathjax) after remark-rehype to turn them into equations:

import rehypeKatex from 'rehype-katex'

unified().use(remarkParseFast).use(remarkGfm).use(remarkMath).use(remarkRehype).use(rehypeKatex).use(rehypeStringify)

samemark/core in the browser (no fallback import; the browser build is about 20 KB gzipped, see Bundle size). It needs a bundler that honours package exports, such as esbuild or Vite:

import {parse} from 'samemark/core'

const tree = parse('# Hello *world*') // mdast Root, GFM

Checking fallbacks so nobody finds one in a profiler:

import remarkDirective from 'remark-directive'
import remarkParseFast, {stats} from 'samemark'

const reasons = []
await unified()
  .use(remarkParseFast, {onFallback: reason => reasons.push(reason)})
  .use(remarkGfm)
  .use(remarkDirective) // not in the supported set: the real parser takes over
  .use(remarkRehype)
  .use(rehypeStringify)
  .process(':::note\nhi\n:::')
console.log(reasons) // ['micromark extension not in the supported set (GFM, frontmatter, math): ...']
console.log(stats)   // { fast: 0, fallback: 1, lastFallbackReason: '...' }

Pass {strict: true} to throw instead of falling back.

Compatibility

Exact statement. The fast path is verified, and only verified, against these pinned versions (package.json devDependencies, installed by npm ci):

PackageVersion
micromark4.0.3
micromark-extension-gfm3.0.0
mdast-util-gfm3.1.0
mdast-util-from-markdown2.1.0
remark-parse11.0.0
remark-gfm4.0.1
unified11.0.5

Other versions are not claimed. A newer micromark that changes output is caught by the main entry's self-check (it then uses the fallback), and by npm test in this repository, which fails if the stock GFM construct set changes (node scripts/gen-signature.mjs --check).

What counts as supported is decided by construct names, not by function source, so minified bundles of micromark keep the fast path (tested with an esbuild --minify bundle that includes the frontmatter and math extensions). Settings that hide in closures (singleTilde: false, singleDollarTextMath: false, which fences a frontmatter matter uses, anywhere: true) are read by running the real tokenizers on probe strings; the result is compared with what the stock configuration does. Any unknown construct, any disable entry, a custom or anywhere frontmatter matter, or a missing GFM piece means fallback.

Which setups take the fast path

Generated by node scripts/compat-table.mjs from the same cases npm test asserts (each fast row is also compared with remark-parse on a document that exercises it):

SetupPathReported reason
remark-gfmfast
remark-gfm twicefast
remark-gfm with tablePipeAlign / tableCellPadding (serialisation options only)fast
remark-gfm + remark-breaks (tree transform only)fast
remark-gfm + remark-smartypants (tree transform only)fast
remark-gfm with singleTilde: falsefallback (real parser)strikethrough is not the stock GFM one (singleTilde: false or unknown tokenizer)
remark-gfm + remark-frontmatter (yaml)fast
remark-gfm + remark-frontmatter ['yaml', 'toml']fast
remark-gfm + remark-frontmatter 'toml'fast
remark-gfm + remark-frontmatter with a custom fence (same type, other fence)fallback (real parser)frontmatter fences differ from the stock yaml matters (custom or anywhere matter)
remark-gfm + remark-frontmatter with a custom matter typefallback (real parser)micromark extension not in the supported set (GFM, frontmatter, math): flow|unnamed construct at code 60 ('<')
remark-gfm + remark-frontmatter with anywhere: truefallback (real parser)frontmatter fences differ from the stock yaml matters (custom or anywhere matter)
remark-gfm + remark-mathfast
remark-gfm + remark-math with singleDollarTextMath: falsefast
remark-gfm + remark-frontmatter + remark-mathfast
remark-frontmatter + remark-math without remark-gfmfallback (real parser)GFM is not fully enabled (missing micromark construct attentionMarkers|126)
remark-gfm + remark-directivefallback (real parser)micromark extension not in the supported set (GFM, frontmatter, math): flow|unnamed construct at code 58 (':')
remark-gfm + remark-frontmatter + remark-directivefallback (real parser)micromark extension not in the supported set (GFM, frontmatter, math): flow|unnamed construct at code 45 ('-')
remark-mdx (MDX)fallback (real parser)GFM is not fully enabled (missing micromark construct attentionMarkers|126)
remark-gfm + remark-mdxfallback (real parser)micromark extension not in the supported set (GFM, frontmatter, math): disable|{"null":["autolink","codeIndented","htmlFlow","htmlText"]}
no remark-gfm (CommonMark only)fallback (real parser)no GFM extensions (CommonMark-only parsing needs the fallback)
unknown micromark constructfallback (real parser)micromark extension not in the supported set (GFM, frontmatter, math): text|atSign
micromark disable entryfallback (real parser)micromark extension not in the supported set (GFM, frontmatter, math): disable|{"null":["codeIndented"]}

Frameworks. Astro (plain markdown, remark-gfm default): fast. Starlight adds remark-directive for asides, so it falls back and gains nothing. Docusaurus and MDX compile through micromark-extension-mdxjs: fallback. @mdx-js/mdx with format: 'md' and remark-gfm: fast.

How identity is verified

Correctness at a glance (every row compares the full tree, positions included, against mdast-util-from-markdown + GFM; details and commands in the table below):

CheckScaleResultReproduce
CommonMark and GFM spec examples652 CommonMark 0.31.2, 672 GFM spec and 30 GFM extension examplesall identicalnpm run check
Pinned docs corpora and synthetic corpusNode.js API docs and changelog, Rust book, Vite docs, React changelog, 240 generated documentsall identicalnpm run corpus && npm run check
README corpus4,780 npm package READMEsall identical at the last runnode bench/readme-corpus.mjs && node bench/run.mjs --readme --nospeed
Held-out files10,893 non-README markdown filesall identical at the last runnode bench/heldout.mjs --roots=DIR
Differential fuzztens of millions of generated inputs (the weekly full matrix is about 60M)0 failing; known corner shapes are listed in KNOWN_GAPS.mdnode bench/fuzz.mjs --mode=regress, ci.yml, full.yml
Stream parser, tree after every push48,196,453 pushes over 465,378 documents0 failingnode bench/stream-diff.mjs --mode=regress, full.yml

"Identical" means JSON-equal trees including every position, not "looks the same" (firstDiff in bench/common.mjs; undefined fields are treated as absent).

CheckWhatCommand
Specall 652 CommonMark 0.31.2 examples, the 672 GFM spec examples and the 30 GFM extension examples, full tree comparednpm run check
Docs and synthetic corporaNode.js API docs and changelog, Rust book, Vite docs, a React changelog (pinned commits, fetched in CI, bench/corpora.json) and 240 generated documents (bench/synthetic.mjs), full tree comparednpm run corpus && npm run check
README corpus (development)4,780 README.md files of npm packages (30 MB) collected on the author's machine, full tree compared (all identical at the last run). 4,648 of them are in bench/readme-corpus-manifest.json (package, version, sha256) and can be rebuilt from npm, slowlynode bench/readme-corpus.mjs && node bench/run.mjs --readme --nospeed
CommonMark specall 652 examples of spec 0.31.2npm run check
Held-out10,893 non-README markdown files from the author's machine, never used while writing the parser (all identical at the last run). The file list is machine specific, so others cannot repeat this exact set; pass your own listnode bench/heldout.mjs --roots=DIR
Differential fuzzingexhaustive strings (17 characters, length up to 6), random, token-biased, mutations of corpus paragraphs, spec crossoversnode bench/fuzz.mjs --mode=exhaustive --maxlen=5 --shard=0/64 (the full matrix, about 60M cases in 60 jobs, is .github/workflows/full.yml, weekly or by hand; ci.yml runs about 2M cases on every push)
Frontmatter and maththe corpus and spec examples with yaml / toml / custom frontmatter prepended (CRLF, unclosed fences, not at the start) and with math enabled, plus extension-aware fuzzingnode bench/frontmatter.mjs --sample=24
Streamingsamemark/stream replays documents in random chunk sizes; the tree after every push must equal a full parse, and micromark's at checkpoints (corpus, spec examples, edge inputs, definitions arriving late)node bench/stream-diff.mjs --mode=regress, node --test test/stream.test.mjs
Regressionsevery input that ever failednode bench/fuzz.mjs --mode=regress
Edge inputsCRLF, CR, BOM, NUL, lone surrogates, empty, 50k-character lines, deep nesting, unicode, tabs; 10 MB inputs in CInode --test test/edge.test.mjs; BIG=1 for 10 MB
Packagingminified bundle keeps the fast path, edge-runtime VM, Chromium, Deno, Node 18 to 24, packed tarball in an empty projectnpm test; ci.yml (Node 18 to 24, packed tarball), full.yml (Deno, Chromium, 10 MB inputs)

Third-party text is never committed. The docs corpora are fetched at pinned commits (licences: MIT for Node.js, Vite and React, MIT or Apache-2.0 for the Rust book, BSD-2-Clause code with CC-BY-SA spec text for the GFM spec examples), the README corpus is rebuilt from npm by sha256 manifest, and the synthetic corpus is generated from a seed.

Known differences

On the tested set (spec examples, corpora, held-out, edge inputs) there are none. Differential fuzzing did find shapes that differ, all block-level corners around footnote definitions and lists; they are listed with minimal inputs in KNOWN_GAPS.md and none occurs in a real document so far. What is not covered by the identity claim: non-GFM extensions (they fall back, by design), non-string input types, and pipelines other than the ones in the compatibility table.

Behavior that is different on purpose: micromark throws a RangeError on roughly 10,000 levels of nesting; samemark parses it. Large single inputs: micromark's time grows faster than linearly with document size (a mixed document took 1.6 s at 400 KB, 5.1 s at 800 KB and 19 s at 1.6 MB on a fast laptop; one 500 KB paragraph of inline markup takes 16 s; a 10 MB comparison ran 42 minutes and exhausted a 6 GB heap), while samemark parsed a 10 MB mixed document in about 0.4 s. That is why the full-tree comparison in the edge tests stops at 2 to 3 MB and the 10 MB cases only check that samemark finishes and does not throw.

Benchmarks

Which numbers come from which input. The tables below were produced by the bench workflow (and the parser-level stream benchmark by bench/stream-bench.mjs) while the project lived in a private repository. Rows named "README corpus" ran on the 4,780-file development corpus of npm package READMEs (a 1 in 4 or 1 in 16 sample for the slower tests; 4,648 of the files are rebuildable from bench/readme-corpus-manifest.json). Rows named after a repository (node-api-docs, rust-book, vite-docs, node-changelog-v22, react-changelog) ran on the pinned commits in bench/corpora.json; anyone can fetch exactly those with npm run corpus. The react-markdown tables use the generated chat-style answer (bench/perf/chat-text.mjs), the Astro site uses the three docs sets, and npm run bench on a fresh checkout measures the pinned docs sets plus the synthetic corpus (and the README corpus with --readme once it is rebuilt). Runner CPUs differ between jobs and runs, so compare the ratios within a table, not absolute times across tables.

Environment: Node v24.21.0, GitHub Actions ubuntu24 X64 (github-hosted), AMD EPYC 7763 64-Core Processor x4, linux x64. Each cell is the median of 7 rounds after one warm-up round, one fresh process per cell.

End to end: markdown to HTML with unified

unified().use(parser).use(remarkGfm).use(remarkRehype).use(rehypeStringify), with remark-parse or with samemark as the parser. Output HTML is compared file by file.

InputFilesSizeremark-parsesamemarkSpeedupParse share beforeParse share afterIdentical HTML
node-api-docs704.75 MB5.00 s486 ms10.3x92%33%70/70
node-changelog-v2210.88 MB1.33 s171 ms7.8x89%21%1/1
rust-book1121.22 MB1.07 s125 ms8.6x93%40%112/112
vite-docs570.56 MB570 ms73 ms7.8x92%29%57/57
react-changelog10.30 MB498 ms71 ms7.0x90%19%1/1
synthetic corpus (240 generated documents)660.25 MB546 ms89 ms6.2x88%22%66/66

Content pipeline, as a docs or blog site runs it: remark-gfm, remark-frontmatter (yaml) and remark-math before remark-rehype. vite-docs has real frontmatter in every page; the synthetic (or README) documents get a generated block so every file has one. Both sides are compared on the HTML.

InputFilesSizeremark-parsesamemarkSpeedupIdentical HTMLsamemark path
synthetic corpus (240 generated documents) with a frontmatter block added660.26 MB559 ms95 ms5.9x66/66fast
vite-docs570.56 MB543 ms66 ms8.2x57/57fast
react-changelog10.30 MB517 ms70 ms7.3x1/1fast

For context, other converters on the same input (different pipeline and output: HTML strings, no mdast, not drop-in replacements):

Inputmarkdown-it render (HTML)marked parse (HTML)
node-api-docs477 ms449 ms
node-changelog-v22182 ms144 ms
rust-book122 ms103 ms
vite-docs74 ms66 ms
react-changelog71 ms57 ms
synthetic corpus (240 generated documents)89 ms70 ms

Environment: Node v24.21.0, GitHub Actions ubuntu24 X64 (github-hosted), AMD EPYC 7763 64-Core Processor x4, linux x64. Each cell is the median of 7 rounds after one warm-up round, one fresh process per cell.

react-markdown (desktop, Node, renderToStaticMarkup, React production build)

react-markdown 10.1.0 builds a new unified processor on every render (unified().use(remarkParse).use(remarkPlugins)...), so remarkPlugins={[remarkParseFast, remarkGfm]} replaces its parser on every render. The input is a synthetic chat-style answer (headings, lists, code, tables, links; bench/perf/chat-text.mjs). Output HTML is compared at 120 prefix lengths, partial constructs included: 120/120 identical. samemark path: 6148 fast, 0 fallback.

One update at a fixed message length (this is also the cost of rendering a finished long answer):

Message lengthreact-markdown defaultwith samemarkSpeedup
10,000 characters36 ms5 ms6.5x
30,000 characters92 ms13 ms7.0x

Streaming simulation: the message grows by a few characters at a time and the whole message is re-rendered after each step, as chat apps do:

StreamUpdatesdefault: total / mean / worst updatesamemark: total / mean / worst updateSpeedup
to 10,000 characters, 20 per step5003.25 s / 6 ms / 16 ms676 ms / 1 ms / 4 ms4.8x
to 30,000 characters, 20 per step150027.98 s / 19 ms / 47 ms5.31 s / 4 ms / 9 ms5.3x

react-markdown in Chromium, CPU throttled as a phone proxy

Chromium 156.0.8078.4, react-markdown 10.1.0 production bundle, renderToStaticMarkup in the page. CPU throttling uses DevTools Emulation.setCPUThrottlingRate (4x and 6x slowdown of the main thread). It is a proxy for a slower device, not a measurement of one. Same chat-style input as the desktop table; streaming steps are 100 characters here to keep the throttled runs short.

CPUUpdate at 10,000 characters: default / samemarkUpdate at 30,000 characters: default / samemarkStream to 10,000: mean update default / samemarkStream to 30,000: mean update default / samemarkIdentical HTML
no throttling9.6 ms / 2.0 ms (4.8x)29 ms / 5.4 ms (5.4x)5.1 ms / 1.0 ms (4.9x)14 ms / 2.7 ms (5.3x)60/60
4x slower45 ms / 7.9 ms (5.7x)145 ms / 22 ms (6.6x)24 ms / 4.3 ms (5.5x)65 ms / 12 ms (5.5x)60/60
6x slower70 ms / 12 ms (6.0x)200 ms / 35 ms (5.8x)35 ms / 6.5 ms (5.3x)102 ms / 18 ms (5.8x)60/60

Streaming a 30,000 character answer into the DOM

One chat message streamed 40 characters per push into a real DOM node (Chromium 156.0.8078.4, React 19 production, flushSync per push, so each push is parse + React render + DOM commit; layout and paint are not included). (a) react-markdown re-rendering the whole message per push, (b) the same with samemark as the parser, (c) the stream parser with per-block memoisation (examples/react-chat). "Total" is the sum of all push times on the main thread. The DOM after the final push, and after every 25th push, is compared with (a): the last column.

CPUVariantPushesTotalPer push p50p95worstSame DOM as (a)
no throttling(a) react-markdown, whole message per push75011.29 s15 ms28 ms32 ms31/31
no throttling(b) react-markdown + samemark7502.24 s (5.0x less)2.9 ms5.0 ms31 ms31/31
no throttling(c) stream parser + memoised blocks750187 ms (60.3x less)0.2 ms0.6 ms6.4 ms31/31
4x slower(a) react-markdown, whole message per push75050.91 s67 ms126 ms141 ms31/31
4x slower(b) react-markdown + samemark7509.85 s (5.2x less)13 ms24 ms122 ms31/31
4x slower(c) stream parser + memoised blocks750669 ms (76.1x less)0.8 ms2.2 ms30 ms31/31
6x slower(a) react-markdown, whole message per push75079.43 s103 ms198 ms219 ms31/31
6x slower(b) react-markdown + samemark75015.33 s (5.2x less)20 ms36 ms207 ms31/31
6x slower(c) stream parser + memoised blocks7501.03 s (77.0x less)1.1 ms3.2 ms45 ms31/31

Environment: Node v24.21.0, GitHub Actions ubuntu24 X64 (github-hosted), INTEL(R) XEON(R) PLATINUM 8573C x4, linux x64. Each cell is the median of 7 rounds after one warm-up round, one fresh process per cell.

Parse only

Same mdast on both sides (mdast-util-from-markdown with micromark-extension-gfm and mdast-util-gfm, the reference). "Identical" counts files whose full tree, positions included, is equal.

InputFilesSizemdast-util-from-markdown + GFMsamemarkSpeedupIdentical
node-api-docs704.75 MB4.34 s121 ms35.7x70/70
node-changelog-v2210.88 MB1.06 s23 ms46.8x1/1
rust-book1121.22 MB921 ms32 ms29.1x112/112
vite-docs570.56 MB486 ms17 ms27.9x57/57
react-changelog10.30 MB414 ms13 ms31.7x1/1
synthetic corpus (240 generated documents)2401.08 MB1.54 s51 ms30.3x240/240

Other parsers on the same input. Different output: markdown-it returns a token list and marked returns its own token tree, neither is mdast, and neither is a drop-in replacement. Shown for scale only.

Inputmarkdown-it parse (tokens)marked lexer (tokens)
node-api-docs423 ms298 ms
node-changelog-v22154 ms81 ms
rust-book94 ms63 ms
vite-docs64 ms34 ms
react-changelog61 ms30 ms
synthetic corpus (240 generated documents)210 ms139 ms

Environment: Node v24.21.0, GitHub Actions ubuntu24 X64 (github-hosted), AMD EPYC 7763 64-Core Processor x4, linux x64. Each cell is the median of 7 rounds after one warm-up round, one fresh process per cell.

MDX (@mdx-js/mdx compile)

Plain format: 'md' with remark-gfm, default parser versus samemark as the parser. format: 'mdx' loads micromark-extension-mdxjs, which is not in the stock GFM set, so samemark falls back there by design; it is measured on the files that are valid MDX to show the overhead is nil. Compiled output is compared.

InputFormatFiles compileddefaultwith samemarkSpeedupIdentical outputsamemark path
node-api-docsmd70 of 706.21 s1.55 s4.0xyes280 fast, 0 fallback
node-api-docsmdx0 of 70n/an/an/an/ano file in this set is valid MDX
node-changelog-v22md1 of 11.68 s509 ms3.3xyes4 fast, 0 fallback
node-changelog-v22mdx0 of 1n/an/an/an/ano file in this set is valid MDX
rust-bookmd112 of 1121.34 s367 ms3.7xyes448 fast, 0 fallback
rust-bookmdx29 of 112245 ms251 ms1.0xyes0 fast, 116 fallback

Real site: Astro 6.4.8 building 239 real markdown pages

rust-book, vite-docs, node-api-docs (6.53 MB of markdown), stock Astro markdown pipeline (remark-parse, remark-gfm, smartypants, Shiki, rehype-raw). Cold build each time (dist, .astro and the Vite cache removed), 3 builds per variant alternating, median shown, Node v24.21.0, AMD EPYC 7763 64-Core Processor x4.

ParserBuild wall clockTime inside the markdown parserParser share of buildDocuments parsedFell back
remark-parse (default)29.6 s7.07 s23.9%239n/a
samemark22.9 s0.50 s2.2%2390

Build time saved: 6.7 s (22.7%). Parser time saved: 6.57 s.

Individual builds (s): default 29.2, 29.6, 29.7; samemark 22.7, 22.9, 23.3.

Reading the react-markdown numbers. The speedup per update is smaller than the parse-only number because React rendering and the hast to JSX step are unchanged. It differs by runtime: Node 6.5x to 7.0x, unthrottled Chromium 4.8x to 5.4x, throttled Chromium 5.3x to 6.6x. I do not have a verified explanation for why the ratio grows under CPU throttling (throttling slows the main thread; garbage collection and compilation threads are not slowed the same way, which is a guess, not a measurement). The absolute numbers are the practical result: a finished 30,000 character answer renders in 35 ms instead of 200 ms at 6x throttle.

Reproduce:

npm ci
npm run bench:small   # a few seconds: the synthetic corpus sample plus one changelog, laptop sized
npm run bench         # the full run (what CI ran), fetches the pinned documents first

npm run bench prints the exact tables above. Method: each cell is one fresh Node process (own JIT and heap), one warm-up round, then the median of the stated number of rounds over all files of the input; GC is forced between rounds; both sides are compared for identical output before timings are reported (the run fails otherwise). markdown-it and marked are included for scale only: they produce tokens or HTML, not mdast, and are not drop-in replacements. Documents: bench/corpora.json pins repository, commit and sha256 of every set.

Bundle size

Minified with esbuild, ESM, platform neutral. node scripts/size.mjs reproduces the table.

bundleminifiedgzipbrotli
samemark/core (parser + 6 helper packages, everything the fast path needs)87.4 KB31.2 KB27.0 KB
samemark/core for browsers (bundler uses the browser condition: entities decoded by the DOM)52.6 KB19.5 KB17.4 KB
samemark main entry, mdast-util-from-markdown left external (peer)88.4 KB31.7 KB27.5 KB
samemark main entry, everything bundled (includes mdast-util-from-markdown + micromark)144.0 KB47.1 KB40.9 KB
of which: decode-named-character-reference (HTML entity table)35.0 KB11.6 KB9.6 KB
baseline: mdast-util-from-markdown + micromark-extension-gfm + mdast-util-gfm114.6 KB34.6 KB29.7 KB
baseline: unified + remark-parse + remark-gfm143.6 KB44.1 KB37.8 KB

The browser row is what a bundler produces for browsers: decode-named-character-reference then decodes entities through the DOM instead of shipping its 35 KB table. Edge runtimes (workerd, worker, edge-light, deno) get the table.

Where it fits

Sätteri, the Rust engine that is the default markdown processor in Astro 7, says in its docs that it "isn't a drop-in replacement for unified" and that existing remark and rehype plugins won't run unmodified. samemark is for the projects that stay on unified: the same mdast, existing plugins run unchanged, and it is plain JavaScript with no native binary.

Credits

  • micromark, mdast-util-from-markdown and the GFM extensions by Titus Wormer and contributors define the behavior this parser reproduces, and supply the helper packages it imports (micromark-util-*, ccount, decode-named-character-reference).
  • The CommonMark spec and its examples.
  • unified and remark.

License

MIT. Third-party licences of the code bundled in dist/core.cjs are in dist/LICENSES.txt.

anzal1/samemark

The same mdast as remark-parse, much faster. Drop-in for unified, identical output, plus an incremental parser for streaming LLM output.

JavaScript

0

18 commits

updated Oct 12, 2026

See the code

See what people are saying

README

samemark

The same mdast as remark-parse, much faster.

Live demo: a long chat answer streamed into react-markdown (left) and into samemark/stream (right), with per-update timings measured in your browser and a check that both DOMs are identical, plus a playground that compares samemark's tree with mdast-util-from-markdown as you type.

Screen capture of the live demo: the same 10,000 character chat answer streams into react-markdown on the left and samemark/stream on the right, with per-update timings, a sparkline for each side and a badge reporting that both panes hold identical HTML.

The capture above is the live demo, recorded in headless Chromium on a GitHub Actions runner (capture run 38066505899: 250 updates, 1.76 s of update time on the left, 94 ms on the right, identical HTML at all 25 checks). On your own device the numbers will differ; they are measured in your browser.

Horizontal bar chart of end to end markdown to HTML time for six inputs, remark-parse against samemark. samemark is 6.2x to 10.3x faster, for example 5.00 s down to 486 ms on the Node.js API docs.

samemark is a markdown parser that returns the same mdast tree as mdast-util-from-markdown with GFM (what remark-parse + remark-gfm produce), positions included, and falls back to that real parser for anything it does not reproduce exactly.

micromark by Titus Wormer defines the behavior; this parser matches it and falls back to it. Its test suite and its output are the specification.

How it was made. AI agents wrote most of the code. Every change is checked against micromark's own output: the full tree must be equal, node by node, on thousands of real documents, the CommonMark spec examples, and tens of millions of generated inputs. Nothing here relies on trust in the author or the model; the checks are in this repository and the commands to run them are below.

What you get

In a real unified pipeline (markdown to HTML with remark-gfm, remark-rehype, rehype-stringify) the whole run is 6.2x to 10.3x faster with samemark as the parser, across six inputs on a GitHub Actions runner (Node 24, median of 7 rounds, identical HTML on every file). Parsing alone is 28x to 47x faster; the rest of the pipeline is unchanged, which is why the end-to-end number is smaller.

Streaming a 30,000 character chat answer into a real DOM in Chromium, samemark/stream with memoised blocks spends 60x less main-thread time than react-markdown re-rendering the whole message on every chunk, and 77x less with the CPU throttled 6x as a phone proxy (79.4 s down to 1.03 s over 750 pushes; 103 ms down to 1.1 ms per push), with the same DOM. Swapping only the parser inside react-markdown gives 5x to 6x.

Building a 239-page Astro site took 29.6 s with remark-parse and 22.9 s with samemark (23% less).

All numbers come from one CI run (38036885270) and are reproducible with npm run bench. Details and method: Benchmarks.

  • parse(source) returns an mdast Root equal to fromMarkdown(source, {extensions: [gfm()], mdastExtensions: [gfmFromMarkdown()]}).
  • remarkParseFast is a drop-in for remark-parse in a unified processor.
  • remark-gfm, remark-frontmatter (yaml, toml or both) and remark-math (with or without singleDollarTextMath) are implemented natively, alone or together. Anything else (remark-directive, MDX, custom frontmatter fences, other micromark extensions) is detected and given to the real parser. You get micromark's answer, at micromark's speed.
  • No micromark at parse time. The fast path imports six small pure helper packages (four micromark-util-* packages plus ccount and decode-named-character-reference) and nothing else. mdast-util-from-markdown is only needed for the fallback, as an optional peer dependency.

Install

npm install samemark
# for the fallback (recommended; the main entry imports it):
npm install mdast-util-from-markdown unified remark-gfm

Node 18 or newer, or any runtime with ES2020 and TextDecoder (browsers, Deno, Bun, edge runtimes).

Usage

With unified, replace remark-parse:

import {unified} from 'unified'
import remarkGfm from 'remark-gfm'
import remarkRehype from 'remark-rehype'
import rehypeStringify from 'rehype-stringify'
import remarkParseFast from 'samemark'

const file = await unified()
  .use(remarkParseFast) // instead of remark-parse
  .use(remarkGfm)
  .use(remarkRehype)
  .use(rehypeStringify)
  .process('# Hello *world*')

Inside a framework that already installs remark-parse (Astro, MDX, Next.js plugins), add it to remarkPlugins; it replaces the parser after remark-gfm has registered, which is the order it needs:

// astro.config.mjs
import remarkParseFast from 'samemark'
export default {markdown: {remarkPlugins: [remarkParseFast]}}

With react-markdown (it builds a unified processor on every render with remark-parse first, so a plugin that sets the parser takes over; verified by the benchmark below):

import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import remarkParseFast from 'samemark'

const plugins = [remarkParseFast, remarkGfm] // define once, outside the component

export const Answer = ({text}) => <Markdown remarkPlugins={plugins}>{text}</Markdown>

Chat apps re-render the whole message on every streamed token, so the parser runs again over a growing string each time; see the react-markdown numbers in Benchmarks.

Without a processor:

import {parse} from 'samemark'
const tree = parse('# Hello *world*') // mdast Root

Entries

EntryFallbackImports
samemarkthe real parser, mdast-util-from-markdown (optional peer)needs mdast-util-from-markdown installed
samemark/streamnone; incremental parser for streamed text, stock GFM with optional frontmatter and maththe six helper packages; also CommonJS
samemark/corenone: throws on unsupported extensions, unless remark-parse was set up before the plugin (it then takes over) or {fallback} is passedonly the six helper packages; also require('samemark/core') from CommonJS

The main entry also runs a one-time check per processor: three small documents are parsed by both parsers with the processor's own extensions; any difference sends that processor to the fallback (reason: "self-check ... failed"). That guards against a micromark release that changes behavior. It is a tripwire, not a proof.

Options

Both parse(source, options) and remarkParseFast(options) take:

OptionMeaning
strictThrow instead of falling back (also when the fast parser itself throws). Default false.
onFallback(reason)Called with a reason string for every document parsed by the fallback.
warnOne console.warn per distinct fallback reason.
fallback(text, ctx)Parser to use instead of the default fallback. ctx has extensions, mdastExtensions, settings.

parse additionally accepts extensions and mdastExtensions (the arrays you would give fromMarkdown). Left out, stock GFM is assumed. source may be a string, a Uint8Array (UTF-8) or an object with toString().

Nobody should find out about a fallback in a profiler. stats is exported by both entries:

import {stats, check} from 'samemark'
stats // {fast: 120, fallback: 0, lastFallbackReason: null}
check(micromarkExtensions, fromMarkdownExtensions) // null when the fast path is exact, else the reason

Streaming for chat UIs

Chat UIs re-parse the whole message every time a token arrives. With samemark as the parser that re-parse is 3x to 7x cheaper depending on the runtime, with identical output (react-markdown numbers in Benchmarks). samemark/stream removes the re-parse: it is incremental, and it stays exactly identical to parsing the whole text.

import {createStreamParser} from 'samemark/stream'

const stream = createStreamParser()          // options: {frontmatter, math}, same meaning as remark-frontmatter / remark-math
for await (const chunk of tokens) {
  const tree = stream.push(chunk)            // mdast Root, equal to parse(everything pushed so far), positions included
  render(tree)
}
stream.end()                                 // final tree; stream.reset() starts a new message
  • After every push the tree equals what parse() returns for all text so far, which equals micromark's tree. Chunks can split anything: a token, a fence, a table row, a link.
  • Blocks that can no longer change keep their object identity from push to push (an unfinished tail is re-parsed, the rest is reused), so React.memo keyed on the node skips them. The cost of a push is the new text plus the unfinished tail, not the whole message.
  • A later reference or footnote definition can change how an earlier block resolves; the affected blocks are rebuilt as new objects, so identity changes exactly when the content can have changed.
  • Supports stock GFM, optionally with frontmatter and math. For other extensions parse the whole message with the main entry. Also require('samemark/stream') from CommonJS.

A React integration lives in examples/react-chat/StreamMarkdown.jsx: a useMarkdownStream() hook and a <StreamMarkdown tree> component that renders the mdast with mdast-util-to-hast and hast-util-to-jsx-runtime, one memoised block per top-level node, applying the same URL sanitising and raw HTML handling as react-markdown. Its HTML equals react-markdown's for the whole message (test/stream.test.mjs, and the DOM comparison in the Chromium benchmark below).

import {StreamMarkdown, useMarkdownStream} from './StreamMarkdown.jsx'

function Answer({tokens}) {                  // tokens: an async iterable of strings
  const {tree, push, reset} = useMarkdownStream()
  useEffect(() => {
    reset()
    ;(async () => { for await (const chunk of tokens) push(chunk) })()
  }, [tokens])
  return <StreamMarkdown tree={tree} />
}

Streaming cost

Parser only, from node bench/stream-bench.mjs in CI (ubuntu-latest, 12 characters per push, a generated LLM-style answer): total time to push the whole answer, a full re-parse per push against createStreamParser.

Answer lengthFull re-parse per pushsamemark/streamSpeedup
10,451 characters422 ms66 ms6.4x
30,416 characters981 ms54 ms18.2x
100,193 characters9,232 ms133 ms69.4x

Identity of that parser, in CI: 48,196,453 pushes over 465,378 documents (corpus replays, fuzz strings, 1-character, random, token-like and whole-line chunkings, with and without frontmatter and math), each tree compared with a full parse; 0 failing.

Line chart of the total time to stream an answer in 12 character pushes at 10,451, 30,416 and 100,193 characters. A full re-parse per push takes 422 ms, 981 ms and 9,232 ms; samemark/stream takes 66 ms, 54 ms and 133 ms.

React render included (DOM commit, memoised blocks):

From CI run 38036885270 (Chromium, GitHub Actions runner); reproduce locally with npm run bench:stream (jsdom, smaller numbers) or run the bench workflow.

One chat message streamed 40 characters per push into a real DOM node (Chromium 156.0.8078.4, React 19 production, flushSync per push, so each push is parse + React render + DOM commit; layout and paint are not included). (a) react-markdown re-rendering the whole message per push, (b) the same with samemark as the parser, (c) the stream parser with per-block memoisation (examples/react-chat). "Total" is the sum of all push times on the main thread. The DOM after the final push, and after every 25th push, is compared with (a): the last column.

CPUVariantPushesTotalPer push p50p95worstSame DOM as (a)
no throttling(a) react-markdown, whole message per push75011.29 s15 ms28 ms32 ms31/31
no throttling(b) react-markdown + samemark7502.24 s (5.0x less)2.9 ms5.0 ms31 ms31/31
no throttling(c) stream parser + memoised blocks750187 ms (60.3x less)0.2 ms0.6 ms6.4 ms31/31
4x slower(a) react-markdown, whole message per push75050.91 s67 ms126 ms141 ms31/31
4x slower(b) react-markdown + samemark7509.85 s (5.2x less)13 ms24 ms122 ms31/31
4x slower(c) stream parser + memoised blocks750669 ms (76.1x less)0.8 ms2.2 ms30 ms31/31
6x slower(a) react-markdown, whole message per push75079.43 s103 ms198 ms219 ms31/31
6x slower(b) react-markdown + samemark75015.33 s (5.2x less)20 ms36 ms207 ms31/31
6x slower(c) stream parser + memoised blocks7501.03 s (77.0x less)1.1 ms3.2 ms45 ms31/31

Environment: Node v24.21.0, GitHub Actions ubuntu24 X64 (github-hosted), INTEL(R) XEON(R) PLATINUM 8573C x4, linux x64. Each cell is the median of 7 rounds after one warm-up round, one fresh process per cell.

(a) is what a chat UI does today: react-markdown re-parses and re-renders the whole message on every push. (b) swaps the parser only. (c) is examples/react-chat.

Grouped horizontal bars of main-thread time streaming a 30,000 character answer into the DOM in Chromium, at no CPU throttling, 4x and 6x. react-markdown takes 11.29 s, 50.91 s and 79.43 s; with samemark 2.24 s, 9.85 s and 15.33 s; samemark/stream with memoised blocks 187 ms, 669 ms and 1.03 s.

Examples

Each snippet was run against this repository; the unified, frontmatter and fallback ones print what is shown.

unified pipeline (markdown to HTML):

import {unified} from 'unified'
import remarkGfm from 'remark-gfm'
import remarkRehype from 'remark-rehype'
import rehypeStringify from 'rehype-stringify'
import remarkParseFast from 'samemark'

const file = await unified()
  .use(remarkParseFast)
  .use(remarkGfm)
  .use(remarkRehype)
  .use(rehypeStringify)
  .process('# Hello *world*\n\n| a | b |\n|---|---|\n| ~~x~~ | y |')
console.log(String(file))
// <h1>Hello <em>world</em></h1>
// <table> ... <td><del>x</del></td> ... </table>

react-markdown, one line changed:

import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import remarkParseFast from 'samemark'

const plugins = [remarkParseFast, remarkGfm] // module level: not recreated per render
export const Answer = ({text}) => <Markdown remarkPlugins={plugins}>{text}</Markdown>

Streaming chat with samemark/stream. In React use examples/react-chat (the live demo runs it). Without a framework:

import {createStreamParser} from 'samemark/stream'

const stream = createStreamParser()
let tree
for (const chunk of ['# Hel', 'lo\n\nSome **bo', 'ld** text\n\n- a\n- b\n']) {
  tree = stream.push(chunk) // same tree as parse(everything so far); finished blocks keep their identity
}
console.log(tree.children.map(node => node.type)) // [ 'heading', 'paragraph', 'list' ]

Astro:

// astro.config.mjs
import remarkParseFast from 'samemark'
export default {markdown: {remarkPlugins: [remarkParseFast]}}

Frontmatter and math stay on the fast path:

import remarkFrontmatter from 'remark-frontmatter'
import remarkMath from 'remark-math'
import {stats} from 'samemark'

const file = await unified()
  .use(remarkParseFast)
  .use(remarkGfm)
  .use(remarkFrontmatter)
  .use(remarkMath)
  .use(remarkRehype)
  .use(rehypeStringify)
  .process('---\ntitle: Notes\n---\n\nEuler: $e^{i\\pi}+1=0$\n')
console.log(String(file)) // <p>Euler: <code class="language-math math-inline">e^{i\pi}+1=0</code></p>
console.log(stats)        // { fast: 1, fallback: 0, lastFallbackReason: null }

samemark parses math into the same inlineMath and math nodes as remark-math; typesetting is the renderer's job, exactly as with remark-parse. Add rehype-katex (or rehype-mathjax) after remark-rehype to turn them into equations:

import rehypeKatex from 'rehype-katex'

unified().use(remarkParseFast).use(remarkGfm).use(remarkMath).use(remarkRehype).use(rehypeKatex).use(rehypeStringify)

samemark/core in the browser (no fallback import; the browser build is about 20 KB gzipped, see Bundle size). It needs a bundler that honours package exports, such as esbuild or Vite:

import {parse} from 'samemark/core'

const tree = parse('# Hello *world*') // mdast Root, GFM

Checking fallbacks so nobody finds one in a profiler:

import remarkDirective from 'remark-directive'
import remarkParseFast, {stats} from 'samemark'

const reasons = []
await unified()
  .use(remarkParseFast, {onFallback: reason => reasons.push(reason)})
  .use(remarkGfm)
  .use(remarkDirective) // not in the supported set: the real parser takes over
  .use(remarkRehype)
  .use(rehypeStringify)
  .process(':::note\nhi\n:::')
console.log(reasons) // ['micromark extension not in the supported set (GFM, frontmatter, math): ...']
console.log(stats)   // { fast: 0, fallback: 1, lastFallbackReason: '...' }

Pass {strict: true} to throw instead of falling back.

Compatibility

Exact statement. The fast path is verified, and only verified, against these pinned versions (package.json devDependencies, installed by npm ci):

PackageVersion
micromark4.0.3
micromark-extension-gfm3.0.0
mdast-util-gfm3.1.0
mdast-util-from-markdown2.1.0
remark-parse11.0.0
remark-gfm4.0.1
unified11.0.5

Other versions are not claimed. A newer micromark that changes output is caught by the main entry's self-check (it then uses the fallback), and by npm test in this repository, which fails if the stock GFM construct set changes (node scripts/gen-signature.mjs --check).

What counts as supported is decided by construct names, not by function source, so minified bundles of micromark keep the fast path (tested with an esbuild --minify bundle that includes the frontmatter and math extensions). Settings that hide in closures (singleTilde: false, singleDollarTextMath: false, which fences a frontmatter matter uses, anywhere: true) are read by running the real tokenizers on probe strings; the result is compared with what the stock configuration does. Any unknown construct, any disable entry, a custom or anywhere frontmatter matter, or a missing GFM piece means fallback.

Which setups take the fast path

Generated by node scripts/compat-table.mjs from the same cases npm test asserts (each fast row is also compared with remark-parse on a document that exercises it):

SetupPathReported reason
remark-gfmfast
remark-gfm twicefast
remark-gfm with tablePipeAlign / tableCellPadding (serialisation options only)fast
remark-gfm + remark-breaks (tree transform only)fast
remark-gfm + remark-smartypants (tree transform only)fast
remark-gfm with singleTilde: falsefallback (real parser)strikethrough is not the stock GFM one (singleTilde: false or unknown tokenizer)
remark-gfm + remark-frontmatter (yaml)fast
remark-gfm + remark-frontmatter ['yaml', 'toml']fast
remark-gfm + remark-frontmatter 'toml'fast
remark-gfm + remark-frontmatter with a custom fence (same type, other fence)fallback (real parser)frontmatter fences differ from the stock yaml matters (custom or anywhere matter)
remark-gfm + remark-frontmatter with a custom matter typefallback (real parser)micromark extension not in the supported set (GFM, frontmatter, math): flow|unnamed construct at code 60 ('<')
remark-gfm + remark-frontmatter with anywhere: truefallback (real parser)frontmatter fences differ from the stock yaml matters (custom or anywhere matter)
remark-gfm + remark-mathfast
remark-gfm + remark-math with singleDollarTextMath: falsefast
remark-gfm + remark-frontmatter + remark-mathfast
remark-frontmatter + remark-math without remark-gfmfallback (real parser)GFM is not fully enabled (missing micromark construct attentionMarkers|126)
remark-gfm + remark-directivefallback (real parser)micromark extension not in the supported set (GFM, frontmatter, math): flow|unnamed construct at code 58 (':')
remark-gfm + remark-frontmatter + remark-directivefallback (real parser)micromark extension not in the supported set (GFM, frontmatter, math): flow|unnamed construct at code 45 ('-')
remark-mdx (MDX)fallback (real parser)GFM is not fully enabled (missing micromark construct attentionMarkers|126)
remark-gfm + remark-mdxfallback (real parser)micromark extension not in the supported set (GFM, frontmatter, math): disable|{"null":["autolink","codeIndented","htmlFlow","htmlText"]}
no remark-gfm (CommonMark only)fallback (real parser)no GFM extensions (CommonMark-only parsing needs the fallback)
unknown micromark constructfallback (real parser)micromark extension not in the supported set (GFM, frontmatter, math): text|atSign
micromark disable entryfallback (real parser)micromark extension not in the supported set (GFM, frontmatter, math): disable|{"null":["codeIndented"]}

Frameworks. Astro (plain markdown, remark-gfm default): fast. Starlight adds remark-directive for asides, so it falls back and gains nothing. Docusaurus and MDX compile through micromark-extension-mdxjs: fallback. @mdx-js/mdx with format: 'md' and remark-gfm: fast.

How identity is verified

Correctness at a glance (every row compares the full tree, positions included, against mdast-util-from-markdown + GFM; details and commands in the table below):

CheckScaleResultReproduce
CommonMark and GFM spec examples652 CommonMark 0.31.2, 672 GFM spec and 30 GFM extension examplesall identicalnpm run check
Pinned docs corpora and synthetic corpusNode.js API docs and changelog, Rust book, Vite docs, React changelog, 240 generated documentsall identicalnpm run corpus && npm run check
README corpus4,780 npm package READMEsall identical at the last runnode bench/readme-corpus.mjs && node bench/run.mjs --readme --nospeed
Held-out files10,893 non-README markdown filesall identical at the last runnode bench/heldout.mjs --roots=DIR
Differential fuzztens of millions of generated inputs (the weekly full matrix is about 60M)0 failing; known corner shapes are listed in KNOWN_GAPS.mdnode bench/fuzz.mjs --mode=regress, ci.yml, full.yml
Stream parser, tree after every push48,196,453 pushes over 465,378 documents0 failingnode bench/stream-diff.mjs --mode=regress, full.yml

"Identical" means JSON-equal trees including every position, not "looks the same" (firstDiff in bench/common.mjs; undefined fields are treated as absent).

CheckWhatCommand
Specall 652 CommonMark 0.31.2 examples, the 672 GFM spec examples and the 30 GFM extension examples, full tree comparednpm run check
Docs and synthetic corporaNode.js API docs and changelog, Rust book, Vite docs, a React changelog (pinned commits, fetched in CI, bench/corpora.json) and 240 generated documents (bench/synthetic.mjs), full tree comparednpm run corpus && npm run check
README corpus (development)4,780 README.md files of npm packages (30 MB) collected on the author's machine, full tree compared (all identical at the last run). 4,648 of them are in bench/readme-corpus-manifest.json (package, version, sha256) and can be rebuilt from npm, slowlynode bench/readme-corpus.mjs && node bench/run.mjs --readme --nospeed
CommonMark specall 652 examples of spec 0.31.2npm run check
Held-out10,893 non-README markdown files from the author's machine, never used while writing the parser (all identical at the last run). The file list is machine specific, so others cannot repeat this exact set; pass your own listnode bench/heldout.mjs --roots=DIR
Differential fuzzingexhaustive strings (17 characters, length up to 6), random, token-biased, mutations of corpus paragraphs, spec crossoversnode bench/fuzz.mjs --mode=exhaustive --maxlen=5 --shard=0/64 (the full matrix, about 60M cases in 60 jobs, is .github/workflows/full.yml, weekly or by hand; ci.yml runs about 2M cases on every push)
Frontmatter and maththe corpus and spec examples with yaml / toml / custom frontmatter prepended (CRLF, unclosed fences, not at the start) and with math enabled, plus extension-aware fuzzingnode bench/frontmatter.mjs --sample=24
Streamingsamemark/stream replays documents in random chunk sizes; the tree after every push must equal a full parse, and micromark's at checkpoints (corpus, spec examples, edge inputs, definitions arriving late)node bench/stream-diff.mjs --mode=regress, node --test test/stream.test.mjs
Regressionsevery input that ever failednode bench/fuzz.mjs --mode=regress
Edge inputsCRLF, CR, BOM, NUL, lone surrogates, empty, 50k-character lines, deep nesting, unicode, tabs; 10 MB inputs in CInode --test test/edge.test.mjs; BIG=1 for 10 MB
Packagingminified bundle keeps the fast path, edge-runtime VM, Chromium, Deno, Node 18 to 24, packed tarball in an empty projectnpm test; ci.yml (Node 18 to 24, packed tarball), full.yml (Deno, Chromium, 10 MB inputs)

Third-party text is never committed. The docs corpora are fetched at pinned commits (licences: MIT for Node.js, Vite and React, MIT or Apache-2.0 for the Rust book, BSD-2-Clause code with CC-BY-SA spec text for the GFM spec examples), the README corpus is rebuilt from npm by sha256 manifest, and the synthetic corpus is generated from a seed.

Known differences

On the tested set (spec examples, corpora, held-out, edge inputs) there are none. Differential fuzzing did find shapes that differ, all block-level corners around footnote definitions and lists; they are listed with minimal inputs in KNOWN_GAPS.md and none occurs in a real document so far. What is not covered by the identity claim: non-GFM extensions (they fall back, by design), non-string input types, and pipelines other than the ones in the compatibility table.

Behavior that is different on purpose: micromark throws a RangeError on roughly 10,000 levels of nesting; samemark parses it. Large single inputs: micromark's time grows faster than linearly with document size (a mixed document took 1.6 s at 400 KB, 5.1 s at 800 KB and 19 s at 1.6 MB on a fast laptop; one 500 KB paragraph of inline markup takes 16 s; a 10 MB comparison ran 42 minutes and exhausted a 6 GB heap), while samemark parsed a 10 MB mixed document in about 0.4 s. That is why the full-tree comparison in the edge tests stops at 2 to 3 MB and the 10 MB cases only check that samemark finishes and does not throw.

Benchmarks

Which numbers come from which input. The tables below were produced by the bench workflow (and the parser-level stream benchmark by bench/stream-bench.mjs) while the project lived in a private repository. Rows named "README corpus" ran on the 4,780-file development corpus of npm package READMEs (a 1 in 4 or 1 in 16 sample for the slower tests; 4,648 of the files are rebuildable from bench/readme-corpus-manifest.json). Rows named after a repository (node-api-docs, rust-book, vite-docs, node-changelog-v22, react-changelog) ran on the pinned commits in bench/corpora.json; anyone can fetch exactly those with npm run corpus. The react-markdown tables use the generated chat-style answer (bench/perf/chat-text.mjs), the Astro site uses the three docs sets, and npm run bench on a fresh checkout measures the pinned docs sets plus the synthetic corpus (and the README corpus with --readme once it is rebuilt). Runner CPUs differ between jobs and runs, so compare the ratios within a table, not absolute times across tables.

Environment: Node v24.21.0, GitHub Actions ubuntu24 X64 (github-hosted), AMD EPYC 7763 64-Core Processor x4, linux x64. Each cell is the median of 7 rounds after one warm-up round, one fresh process per cell.

End to end: markdown to HTML with unified

unified().use(parser).use(remarkGfm).use(remarkRehype).use(rehypeStringify), with remark-parse or with samemark as the parser. Output HTML is compared file by file.

InputFilesSizeremark-parsesamemarkSpeedupParse share beforeParse share afterIdentical HTML
node-api-docs704.75 MB5.00 s486 ms10.3x92%33%70/70
node-changelog-v2210.88 MB1.33 s171 ms7.8x89%21%1/1
rust-book1121.22 MB1.07 s125 ms8.6x93%40%112/112
vite-docs570.56 MB570 ms73 ms7.8x92%29%57/57
react-changelog10.30 MB498 ms71 ms7.0x90%19%1/1
synthetic corpus (240 generated documents)660.25 MB546 ms89 ms6.2x88%22%66/66

Content pipeline, as a docs or blog site runs it: remark-gfm, remark-frontmatter (yaml) and remark-math before remark-rehype. vite-docs has real frontmatter in every page; the synthetic (or README) documents get a generated block so every file has one. Both sides are compared on the HTML.

InputFilesSizeremark-parsesamemarkSpeedupIdentical HTMLsamemark path
synthetic corpus (240 generated documents) with a frontmatter block added660.26 MB559 ms95 ms5.9x66/66fast
vite-docs570.56 MB543 ms66 ms8.2x57/57fast
react-changelog10.30 MB517 ms70 ms7.3x1/1fast

For context, other converters on the same input (different pipeline and output: HTML strings, no mdast, not drop-in replacements):

Inputmarkdown-it render (HTML)marked parse (HTML)
node-api-docs477 ms449 ms
node-changelog-v22182 ms144 ms
rust-book122 ms103 ms
vite-docs74 ms66 ms
react-changelog71 ms57 ms
synthetic corpus (240 generated documents)89 ms70 ms

Environment: Node v24.21.0, GitHub Actions ubuntu24 X64 (github-hosted), AMD EPYC 7763 64-Core Processor x4, linux x64. Each cell is the median of 7 rounds after one warm-up round, one fresh process per cell.

react-markdown (desktop, Node, renderToStaticMarkup, React production build)

react-markdown 10.1.0 builds a new unified processor on every render (unified().use(remarkParse).use(remarkPlugins)...), so remarkPlugins={[remarkParseFast, remarkGfm]} replaces its parser on every render. The input is a synthetic chat-style answer (headings, lists, code, tables, links; bench/perf/chat-text.mjs). Output HTML is compared at 120 prefix lengths, partial constructs included: 120/120 identical. samemark path: 6148 fast, 0 fallback.

One update at a fixed message length (this is also the cost of rendering a finished long answer):

Message lengthreact-markdown defaultwith samemarkSpeedup
10,000 characters36 ms5 ms6.5x
30,000 characters92 ms13 ms7.0x

Streaming simulation: the message grows by a few characters at a time and the whole message is re-rendered after each step, as chat apps do:

StreamUpdatesdefault: total / mean / worst updatesamemark: total / mean / worst updateSpeedup
to 10,000 characters, 20 per step5003.25 s / 6 ms / 16 ms676 ms / 1 ms / 4 ms4.8x
to 30,000 characters, 20 per step150027.98 s / 19 ms / 47 ms5.31 s / 4 ms / 9 ms5.3x

react-markdown in Chromium, CPU throttled as a phone proxy

Chromium 156.0.8078.4, react-markdown 10.1.0 production bundle, renderToStaticMarkup in the page. CPU throttling uses DevTools Emulation.setCPUThrottlingRate (4x and 6x slowdown of the main thread). It is a proxy for a slower device, not a measurement of one. Same chat-style input as the desktop table; streaming steps are 100 characters here to keep the throttled runs short.

CPUUpdate at 10,000 characters: default / samemarkUpdate at 30,000 characters: default / samemarkStream to 10,000: mean update default / samemarkStream to 30,000: mean update default / samemarkIdentical HTML
no throttling9.6 ms / 2.0 ms (4.8x)29 ms / 5.4 ms (5.4x)5.1 ms / 1.0 ms (4.9x)14 ms / 2.7 ms (5.3x)60/60
4x slower45 ms / 7.9 ms (5.7x)145 ms / 22 ms (6.6x)24 ms / 4.3 ms (5.5x)65 ms / 12 ms (5.5x)60/60
6x slower70 ms / 12 ms (6.0x)200 ms / 35 ms (5.8x)35 ms / 6.5 ms (5.3x)102 ms / 18 ms (5.8x)60/60

Streaming a 30,000 character answer into the DOM

One chat message streamed 40 characters per push into a real DOM node (Chromium 156.0.8078.4, React 19 production, flushSync per push, so each push is parse + React render + DOM commit; layout and paint are not included). (a) react-markdown re-rendering the whole message per push, (b) the same with samemark as the parser, (c) the stream parser with per-block memoisation (examples/react-chat). "Total" is the sum of all push times on the main thread. The DOM after the final push, and after every 25th push, is compared with (a): the last column.

CPUVariantPushesTotalPer push p50p95worstSame DOM as (a)
no throttling(a) react-markdown, whole message per push75011.29 s15 ms28 ms32 ms31/31
no throttling(b) react-markdown + samemark7502.24 s (5.0x less)2.9 ms5.0 ms31 ms31/31
no throttling(c) stream parser + memoised blocks750187 ms (60.3x less)0.2 ms0.6 ms6.4 ms31/31
4x slower(a) react-markdown, whole message per push75050.91 s67 ms126 ms141 ms31/31
4x slower(b) react-markdown + samemark7509.85 s (5.2x less)13 ms24 ms122 ms31/31
4x slower(c) stream parser + memoised blocks750669 ms (76.1x less)0.8 ms2.2 ms30 ms31/31
6x slower(a) react-markdown, whole message per push75079.43 s103 ms198 ms219 ms31/31
6x slower(b) react-markdown + samemark75015.33 s (5.2x less)20 ms36 ms207 ms31/31
6x slower(c) stream parser + memoised blocks7501.03 s (77.0x less)1.1 ms3.2 ms45 ms31/31

Environment: Node v24.21.0, GitHub Actions ubuntu24 X64 (github-hosted), INTEL(R) XEON(R) PLATINUM 8573C x4, linux x64. Each cell is the median of 7 rounds after one warm-up round, one fresh process per cell.

Parse only

Same mdast on both sides (mdast-util-from-markdown with micromark-extension-gfm and mdast-util-gfm, the reference). "Identical" counts files whose full tree, positions included, is equal.

InputFilesSizemdast-util-from-markdown + GFMsamemarkSpeedupIdentical
node-api-docs704.75 MB4.34 s121 ms35.7x70/70
node-changelog-v2210.88 MB1.06 s23 ms46.8x1/1
rust-book1121.22 MB921 ms32 ms29.1x112/112
vite-docs570.56 MB486 ms17 ms27.9x57/57
react-changelog10.30 MB414 ms13 ms31.7x1/1
synthetic corpus (240 generated documents)2401.08 MB1.54 s51 ms30.3x240/240

Other parsers on the same input. Different output: markdown-it returns a token list and marked returns its own token tree, neither is mdast, and neither is a drop-in replacement. Shown for scale only.

Inputmarkdown-it parse (tokens)marked lexer (tokens)
node-api-docs423 ms298 ms
node-changelog-v22154 ms81 ms
rust-book94 ms63 ms
vite-docs64 ms34 ms
react-changelog61 ms30 ms
synthetic corpus (240 generated documents)210 ms139 ms

Environment: Node v24.21.0, GitHub Actions ubuntu24 X64 (github-hosted), AMD EPYC 7763 64-Core Processor x4, linux x64. Each cell is the median of 7 rounds after one warm-up round, one fresh process per cell.

MDX (@mdx-js/mdx compile)

Plain format: 'md' with remark-gfm, default parser versus samemark as the parser. format: 'mdx' loads micromark-extension-mdxjs, which is not in the stock GFM set, so samemark falls back there by design; it is measured on the files that are valid MDX to show the overhead is nil. Compiled output is compared.

InputFormatFiles compileddefaultwith samemarkSpeedupIdentical outputsamemark path
node-api-docsmd70 of 706.21 s1.55 s4.0xyes280 fast, 0 fallback
node-api-docsmdx0 of 70n/an/an/an/ano file in this set is valid MDX
node-changelog-v22md1 of 11.68 s509 ms3.3xyes4 fast, 0 fallback
node-changelog-v22mdx0 of 1n/an/an/an/ano file in this set is valid MDX
rust-bookmd112 of 1121.34 s367 ms3.7xyes448 fast, 0 fallback
rust-bookmdx29 of 112245 ms251 ms1.0xyes0 fast, 116 fallback

Real site: Astro 6.4.8 building 239 real markdown pages

rust-book, vite-docs, node-api-docs (6.53 MB of markdown), stock Astro markdown pipeline (remark-parse, remark-gfm, smartypants, Shiki, rehype-raw). Cold build each time (dist, .astro and the Vite cache removed), 3 builds per variant alternating, median shown, Node v24.21.0, AMD EPYC 7763 64-Core Processor x4.

ParserBuild wall clockTime inside the markdown parserParser share of buildDocuments parsedFell back
remark-parse (default)29.6 s7.07 s23.9%239n/a
samemark22.9 s0.50 s2.2%2390

Build time saved: 6.7 s (22.7%). Parser time saved: 6.57 s.

Individual builds (s): default 29.2, 29.6, 29.7; samemark 22.7, 22.9, 23.3.

Reading the react-markdown numbers. The speedup per update is smaller than the parse-only number because React rendering and the hast to JSX step are unchanged. It differs by runtime: Node 6.5x to 7.0x, unthrottled Chromium 4.8x to 5.4x, throttled Chromium 5.3x to 6.6x. I do not have a verified explanation for why the ratio grows under CPU throttling (throttling slows the main thread; garbage collection and compilation threads are not slowed the same way, which is a guess, not a measurement). The absolute numbers are the practical result: a finished 30,000 character answer renders in 35 ms instead of 200 ms at 6x throttle.

Reproduce:

npm ci
npm run bench:small   # a few seconds: the synthetic corpus sample plus one changelog, laptop sized
npm run bench         # the full run (what CI ran), fetches the pinned documents first

npm run bench prints the exact tables above. Method: each cell is one fresh Node process (own JIT and heap), one warm-up round, then the median of the stated number of rounds over all files of the input; GC is forced between rounds; both sides are compared for identical output before timings are reported (the run fails otherwise). markdown-it and marked are included for scale only: they produce tokens or HTML, not mdast, and are not drop-in replacements. Documents: bench/corpora.json pins repository, commit and sha256 of every set.

Bundle size

Minified with esbuild, ESM, platform neutral. node scripts/size.mjs reproduces the table.

bundleminifiedgzipbrotli
samemark/core (parser + 6 helper packages, everything the fast path needs)87.4 KB31.2 KB27.0 KB
samemark/core for browsers (bundler uses the browser condition: entities decoded by the DOM)52.6 KB19.5 KB17.4 KB
samemark main entry, mdast-util-from-markdown left external (peer)88.4 KB31.7 KB27.5 KB
samemark main entry, everything bundled (includes mdast-util-from-markdown + micromark)144.0 KB47.1 KB40.9 KB
of which: decode-named-character-reference (HTML entity table)35.0 KB11.6 KB9.6 KB
baseline: mdast-util-from-markdown + micromark-extension-gfm + mdast-util-gfm114.6 KB34.6 KB29.7 KB
baseline: unified + remark-parse + remark-gfm143.6 KB44.1 KB37.8 KB

The browser row is what a bundler produces for browsers: decode-named-character-reference then decodes entities through the DOM instead of shipping its 35 KB table. Edge runtimes (workerd, worker, edge-light, deno) get the table.

Where it fits

Sätteri, the Rust engine that is the default markdown processor in Astro 7, says in its docs that it "isn't a drop-in replacement for unified" and that existing remark and rehype plugins won't run unmodified. samemark is for the projects that stay on unified: the same mdast, existing plugins run unchanged, and it is plain JavaScript with no native binary.

Credits

  • micromark, mdast-util-from-markdown and the GFM extensions by Titus Wormer and contributors define the behavior this parser reproduces, and supply the helper packages it imports (micromark-util-*, ccount, decode-named-character-reference).
  • The CommonMark spec and its examples.
  • unified and remark.

License

MIT. Third-party licences of the code bundled in dist/core.cjs are in dist/LICENSES.txt.

Project health

Latest release

0.1.0 · Oct 10, 2026

Releases this year

1