A modern, type-safe rewrite of markdown-it in TypeScript: pluggable rules, split parse/render, CommonMark-compatible; fast one-shot parsing and even faster streaming/incremental updates.
See the codeA TypeScript-first Markdown parser and renderer compatible with the markdown-it public API for common plugin patterns, with streaming/incremental parsing and async render.
English | 简体中文
Quick links: Docs index · Stream optimization · Performance report · Compatibility report
Runtime note
markdown-it-tsis ESM-only and requires Node.js >= 18.import MarkdownIt from 'markdown-it-ts'In CommonJS projects, use dynamic import inside an async function:
async function main() { const { default: MarkdownIt } = await import('markdown-it-ts') const md = MarkdownIt() console.log(md.render('# ok')) } main().catch((error) => { console.error(error) process.exitCode = 1 })
A TypeScript migration of markdown-it with modular architecture for tree-shaking and separate parse/render imports.
markdown-it-ts targets the markdown-it public API for common parser, renderer, and plugin usage. Private markdown-it/lib/... imports, undocumented upstream internal state assumptions, direct CommonJS require('markdown-it-ts'), and Node.js < 18 are unsupported.
| Level | API surface |
|---|---|
| Stable target | MarkdownIt(), parse, render, renderInline, renderAsync, renderer.rules, Token, and public ruler/plugin APIs |
| Advanced | Root withRenderer, documented subpath exports such as core, renderer helpers, and common utilities |
| Experimental | stream, chunkedParse, StreamBuffer, UnboundedBuffer, EditableBuffer, PieceTable, iterable/sink parsing, and chunk strategy recommenders via markdown-it-ts/experimental; selected helpers also have explicit subpaths such as markdown-it-ts/stream/buffer, markdown-it-ts/stream/chunked, markdown-it-ts/stream/debounced, and markdown-it-ts/support/chunk_recommend |
The root entry no longer exposes experimental helpers as top-level named exports. Some large-input helpers remain available as experimental instance methods for compatibility, but they are not part of the stable markdown-it compatibility contract.
Common 0.x import migrations:
| 0.x import | 1.0 import |
|---|---|
import { StreamBuffer } from 'markdown-it-ts' | import { StreamBuffer } from 'markdown-it-ts/experimental' or markdown-it-ts/stream/buffer |
import { chunkedParse } from 'markdown-it-ts' | import { chunkedParse } from 'markdown-it-ts/experimental' or markdown-it-ts/stream/chunked |
import { recommendFullChunkStrategy } from 'markdown-it-ts' | import { recommendFullChunkStrategy } from 'markdown-it-ts/support/chunk_recommend' |
import { UnboundedBuffer } from 'markdown-it-ts' | import { UnboundedBuffer } from 'markdown-it-ts/experimental' |
The core TypeScript port is complete. Compatibility is maintained against the markdown-it public API and common plugin patterns with the following goals:
#)===)markdownit() instances expose render, renderInline, and renderer for plugin compatibilitynpm install markdown-it-ts
import markdownIt from 'markdown-it-ts'
const md = markdownIt()
const tokens = md.parse('# Hello World')
console.log(tokens)
Use the built-in renderer for the markdown-it-compatible render API:
import markdownIt from 'markdown-it-ts'
const md = markdownIt()
const html = md.render('# Hello World')
console.log(html)
Security note: markdown-it-ts is not an HTML sanitizer. Raw HTML is escaped by default, but html: true passes raw HTML through, and plugin-authored attributes are treated as trusted output. Sanitize rendered HTML at your application boundary when untrusted authors can provide raw HTML or plugin-controlled attributes.
For normal usage, keep the original markdown-it-compatible API:
const md = markdownIt()
const tokens = md.parse(hugeMarkdown)
const html = md.render(hugeMarkdown)
Those default parse / render calls may auto-activate an internal large-input path once the document crosses the large-document threshold. For compatibility, that implicit path is used only when no plugin has been installed and the parser rulers have not been modified. Any .use() call, including renderer-only plugins, keeps the plain full parse path unless you explicitly opt into experimental.fullChunkedFallback; stream parsing has the separate experimental.streamChunkedFallback opt-in.
Use the explicit stream-oriented APIs only when your upstream input already arrives as chunks and you do not want to join it into one giant string first:
import MarkdownIt from 'markdown-it-ts'
import { UnboundedBuffer } from 'markdown-it-ts/experimental'
const md = MarkdownIt()
const tokens = md.parseIterable(fileChunks)
const buffer = new UnboundedBuffer(md, { mode: 'stream' })
for await (const chunk of logChunks) {
buffer.feed(chunk)
buffer.flushAvailable()
}
const finalTokens = buffer.flushForce()
Large-input tuning options are available under experimental:
const md = MarkdownIt({
experimental: {
autoUnbounded: false,
fullChunkedFallback: true,
},
})
The older top-level experimental option names are still accepted for 1.x compatibility, but the namespaced form is preferred.
parseIterable / parseAsyncIterable are advanced entry points for explicit Iterable<string> / AsyncIterable<string> inputs. UnboundedBuffer is the advanced append-only path for real chunk streams and only keeps a bounded tail in memory instead of the whole historical source string.
If you also need bounded output memory for explicit chunk-stream inputs, use the sink form instead of retaining a full token array:
md.parseIterableToSink(fileChunks, (tokens, info) => {
consumeTokenChunk(tokens, info)
})
For arbitrary in-place edits, use EditableBuffer. It stores the source in a piece table and reparses only from an anchor before the affected block instead of flattening and reparsing the whole document every time. Internally, both the full parse and the localized reparse paths now hand a PieceTableSourceView straight to md.core.parseSource(...), so the selected range no longer needs to be materialized as one giant intermediate string first.
Markdown is not always chunk-local. Some constructs depend on document-level state, including reference definitions, footnote definitions, abbreviation definitions, and plugin-defined global state.
chunkedParse() and complete-string unbounded parsing use a correctness-first fallback by default for known global-state constructs. Chunked parsing also falls back to a full parse when a forced chunk boundary is not on a blank-line boundary, because long lists, blockquotes, HTML blocks, and paragraphs are not safe to split arbitrarily.
Iterable/sink parsing is streaming-oriented. It cannot always know future document-level definitions before committing earlier chunks, so documents with reference, footnote, or abbreviation definitions should use full-string parsing or avoid early flushing when exact full-parse parity is required.
The detector is intentionally conservative. It may fall back for definitions that appear inside code fences or raw text, because fallback is correctness-first.
You can explicitly disable only the known global-state fallback:
chunkedParse(md, source, env, {
fallbackOnGlobalState: false,
})
Unsafe non-blank chunk boundaries still fall back to a full parse because splitting there is not token-stream safe.
Disabling the global-state fallback is a performance-oriented mode and may produce output that differs from a full parse for documents with global state.
Need async renderer rules (for example, asynchronous syntax highlighting)? Use renderAsync which awaits async rule results:
const md = markdownIt()
const html = await md.renderAsync('# Hello World', {
highlight: async (code, lang) => {
const highlighted = await someHighlighter(code, lang)
return highlighted
},
})
The main package entry already includes render, renderAsync, renderInline, renderer, and the advanced withRenderer helper. markdown-it-ts/plugins/with-renderer is also kept for custom/core-shaped instances; normal markdown-it-ts users do not need to call it.
parse / render usage stays unchanged; plugin/custom-rule instances keep full-parse semantics by default, while stock parser instances can use internal large-input optimizations.renderAsync), and exposes tuning knobs for large-input and append-heavy workloads. Benchmark numbers below are split by corpus and comparison semantics; the stock-subset rows are not a promise that every workload is faster.docs/stream-optimization.md, markdown-it-ts/experimental, and documented subpaths for recommend*Strategy, StreamBuffer, chunkedParse, etc.), so teams can build adaptive streaming pipelines quickly. The repository’s benchmark scripts (perf:generate, perf:update-readme) keep comparison data up to date in CI, reducing the risk of unnoticed regressions.markdown-it-ts/experimental; selected helpers also have explicit subpath imports. Some advanced instance methods and options remain available for existing large-input integrations and are marked experimental in the type declarations.You can customize parser options and enable or disable specific rules:
import markdownIt from 'markdown-it-ts'
const md = markdownIt({
linkify: true,
typographer: true,
html: false,
}).disable('image')
const result = md.render('Some markdown content')
console.log(result)
Build the demo site into ./demo and open it in your browser.
Note: the demo build uses the current project's published build artifact (the files in dist/). The demo script runs npm run build before bundling, so the demo reflects the current repo source.
This ensures demo/markdown-it.js is produced from the most recent dist/index.js output.
You can generate API documentation into ./apidoc using the built-in script. The script will attempt to use pnpm dlx or npx if available, otherwise it uses the locally-installed ndoc from node_modules.
# build and generate docs
npm run build
npm run doc
# open generated docs
open apidoc/index.html # macOS
xdg-open apidoc/index.html # Linux
This repository has separate workflows for code quality and documentation/demo validation.
.github/workflows/ci.yml runs lint, typecheck, unit tests, build, package smoke tests, and runtime smoke tests for the packed package..github/workflows/ci-docs.yml builds API docs and the demo site, and conditionally deploys them when Netlify secrets are configured..github/workflows/perf-regression.yml is a manual benchmark workflow (workflow_dispatch) for comparing full benchmark snapshots between a base ref and a head ref when a change needs deeper parser/render performance validation.Files to inspect: .github/workflows/ci.yml, .github/workflows/ci-docs.yml, .github/workflows/perf-regression.yml
You can deploy both the generated API docs (apidoc/) and the demo site (demo/) to Netlify. There are two supported workflows:
netlify-cli locally or use the helper scripts included in package.json.Deploy docs locally:
# set environment variables first
export NETLIFY_AUTH_TOKEN=your_token_here
export NETLIFY_SITE_ID_DOCS=your_docs_site_id
pnpm run netlify:deploy:docs
Deploy demo locally:
export NETLIFY_AUTH_TOKEN=your_token_here
export NETLIFY_SITE_ID_DEMO=your_demo_site_id
pnpm run netlify:deploy:demo
The repo contains two GitHub Actions workflows, one for docs and one for demo. Each workflow will only run if you add the required secrets to the repository:
Add these as GitHub Secrets for the repository (Settings → Secrets and variables → Actions). When pushed to main, the workflows will run and deploy to the corresponding Netlify site.
Files to inspect: .github/workflows/deploy-netlify-docs.yml and .github/workflows/deploy-netlify-demo.yml
Automatic CI deploy: when you push to main, the CI workflow will build the project, generate docs, and build the demo. After a successful build the workflow attempts to deploy both apidoc/ and demo/ to Netlify automatically — but only if the corresponding GitHub Actions secrets are set:
NETLIFY_AUTH_TOKEN — Netlify Personal Access TokenNETLIFY_SITE_ID_DOCS — Netlify Site ID for the docs siteNETLIFY_SITE_ID_DEMO — Netlify Site ID for the demo siteIf those secrets exist, the CI will publish both sites. If not, the CI will skip publishing and still report build/lint/docs/demo status.
# build demo and open ./demo/index.html (macOS / Linux / Windows supported)
npm run gh-demo
If you only want to build the demo (skip publishing) you can run:
npm run demo
To publish the demo automatically set GH_PAGES_REPO to your target repo (you must have push access):
export GH_PAGES_REPO='git@github.com:youruser/markdown-it.github.io.git'
npm run gh-demo
Subpath exports
For advanced or tree-shaken imports you can target subpaths directly:
import { Token } from 'markdown-it-ts/common/token'
import { withRenderer } from 'markdown-it-ts/plugins/with-renderer'
import Renderer from 'markdown-it-ts/render/renderer'
import { StreamBuffer } from 'markdown-it-ts/stream/buffer'
import { chunkedParse } from 'markdown-it-ts/stream/chunked'
import { DebouncedStreamParser, ThrottledStreamParser } from 'markdown-it-ts/stream/debounced'
Plugins are regular functions that receive the markdown-it-ts instance. For full type-safety use the exported MarkdownItPlugin type:
import markdownIt, { type MarkdownItPlugin } from 'markdown-it-ts'
const plugin: MarkdownItPlugin = (md) => {
md.core.ruler.after('block', 'my_rule', (state) => {
// custom transform logic
})
}
const md = markdownIt().use(plugin)
For large documents or append-heavy editing flows, you can enable the stream parser and an optional chunked fallback. See the detailed guide in docs/stream-optimization.md.
Quick start:
import markdownIt from 'markdown-it-ts'
const md = markdownIt({
stream: true, // enable stream mode
streamChunkedFallback: true, // use chunked on first large parse or large non-append edits
// optional tuning
// By default, chunk size is adaptive to doc size (streamChunkAdaptive: true)
// You can pin fixed sizes by setting streamChunkAdaptive: false
streamChunkSizeChars: 10_000,
streamChunkSizeLines: 200,
streamChunkFenceAware: true,
})
let src = '# Title\n\nHello'
md.stream.parse(src, {})
// Pass only the delta to avoid scanning the stable history prefix.
md.stream.append('\n\nworld!\n')
// Keep an in-memory snapshot when switching away from a thread.
const snapshot = md.stream.snapshot()
md.stream.reset()
md.stream.restore(snapshot)
Try the quick benchmark (build first):
npm run build
node scripts/quick-benchmark.mjs
More:
npm run perf:matrixnpm run perf:sweeppnpm run perf:familiespnpm run perf:strategiespnpm run perf:stream-historypnpm run perf:gatedocs/perf-report.md.docs/stream-optimization.md and docs/parse-strategy-matrix.md.Adaptive chunk sizing
fullChunkAdaptive: true), targeting ~8 chunks and clamping sizes into practical ranges.streamChunkAdaptive: true).*Adaptive: false flags or by providing explicit *SizeChars/*SizeLines values.If you want to display or persist the suggested chunk settings without enabling auto-tune, you can query them directly:
import markdownIt from 'markdown-it-ts'
import {
recommendFullChunkStrategy,
recommendStreamChunkStrategy,
} from 'markdown-it-ts/support/chunk_recommend'
const size = 50_000
const fullRec = recommendFullChunkStrategy(size)
// { strategy: 'plain', fenceAware: true }
const streamRec = recommendStreamChunkStrategy(size)
// { strategy: 'discrete', maxChunkChars: 16_000, maxChunkLines: 250, fenceAware: true }
These mirror the same mappings used internally when autoTuneChunks: true and no explicit sizes are provided.
To make sure each change is not slower than the previous run at any tested size/config, we ship a tiny perf harness and a comparator:
Generate the latest report and snapshot:
npm run perf:generate → writes docs/perf-latest.md and docs/perf-latest.jsondocs/perf-history/perf-<shortSHA>.json when git is availableCompare two snapshots (fail on regressions beyond threshold):
node scripts/perf-compare.mjs docs/perf-latest.json docs/perf-history/perf-<baselineSHA>.json --threshold=0.10Accept the latest run as the new baseline (after manual review):
pnpm run perf:acceptRun the regression check against the most recent baseline (same harness):
pnpm run perf:check:latestRun the per-token-type render benchmark against markdown-it:
pnpm run perf:render-rules--include-noise to also show zero-token / sub-signal categoriespnpm run perf:render-rules:check to fail if any meaningful category regresses beyond the thresholdRun the parser rule-family hotspot benchmark:
pnpm run perf:familiesdocs/perf-family-hotspots.md and docs/perf-family-hotspots.jsonRun the long-text default-strategy benchmark and gate:
pnpm run perf:strategiespnpm run perf:strategy:checkpnpm run perf:gatedocs/perf-large-defaults.* and docs/parse-strategy-matrix.mdInspect detailed deltas by size/scenario (sorted by worst):
pnpm run perf:diffSee docs/perf-regression.md for details and CI usage.
CI always runs the vendored upstream CommonMark good.txt fixture via test/compat/commonmark-fixture.test.mjs, plus the local plugin compatibility matrix.
This repo can also run a subset of the original markdown-it tests and pathological cases. Those optional suites are disabled by default because they require:
markdown-it repo (referenced by relative path in tests)To enable upstream tests locally:
# Ensure directory layout like:
# ../markdown-it/ # upstream repo with index.mjs and fixtures
# ./markdown-it-ts/ # this repo
RUN_ORIGINAL=1 pnpm test
Notes
Alternative: set a custom upstream path without sibling layout
# Point to a local checkout of markdown-it
MARKDOWN_IT_DIR=/absolute/path/to/markdown-it RUN_ORIGINAL=1 pnpm test
Convenience scripts
pnpm run test:original # same as RUN_ORIGINAL=1 pnpm test
pnpm run test:original:network # also sets RUN_NETWORK=1
markdown-it-ts is optimized for parser throughput while preserving the markdown-it public API and common plugin model. The benchmark report now separates three different questions: fixed-configuration native API throughput, tuned/best-of scenarios, and equivalent-output comparisons.
For default, unmodified parser instances, the stock block scanner also has a hybrid path: supported headings, single-line paragraphs, tight bullet lists, and fences keep the fast block scan even when their inline content needs the standard emphasis/link/code/entity rules. Plugin or custom-rule instances continue to use the general parser.
The historical size-based numbers below use the repository's synthetic stock-subset: ATX headings, plain single-line paragraphs, flat tight bullet lists, and fenced code. Its repeated paragraph/list content intentionally exercises the stock-fast parser/renderer and last-output caches. Treat these results as a specialized fast-path benchmark, not as a claim about general Markdown.
The compact table below reports the synthetic stock subset, a feature-mixed synthetic corpus, and repository-owned MIT-licensed documents independently. It uses default MarkdownIt() instances; the feature-mixed and real-world OX rows enable tables and strikethrough to align those features more closely. Parse rows are native API throughput, not equivalent output: markdown-it-ts returns mutable Token[], while @ox-content/napi returns an object containing an mdast JSON string. Render rows compare native behavior and explicitly report whether HTML is identical.
| Corpus | Chars | TS parse | OX parse | TS parse path | TS render | OX render | TS render path | HTML equal? |
|---|---|---|---|---|---|---|---|---|
| synthetic stock-subset (~100k) | 100,126 | 0.6382ms | 0.8475ms | stock-fast | 0.3556ms | 0.7629ms | stock-fast | no |
| synthetic feature-mixed (~100k) | 100,450 | 3.9569ms | 1.1164ms | general | 4.8427ms | 1.0122ms | token-renderer | no |
| docs/architecture.md | 6,564 | 0.0812ms | 0.0335ms | general | 0.0992ms | 0.0255ms | token-renderer | no |
| docs/development.md | 4,756 | 0.0866ms | 0.0304ms | general | 0.1045ms | 0.0273ms | token-renderer | no |
| docs/security.md | 1,375 | 0.0251ms | 0.0093ms | general | 0.0312ms | 0.0083ms | token-renderer | no |
No aggregate winner is calculated across corpora. See the generated report for every size, per-file real-world results, strategy diagnostics, and the first HTML output difference.
For the Markstream consumer pipeline, see the streaming and history restore audit, including all 59 workloads, CPU samples, and rejected experiments.
In the latest stock-subset snapshot (Node.js version and CPU are recorded in docs/perf-latest.md), tuned one-shot parsing compares as follows with upstream markdown-it:
Tuned stock-subset parser comparison (markdown-it-ts best one-shot vs @ox-content/napi parse only):
This is a best-of S1–S5 result for markdown-it-ts, not a fixed-configuration headline. The @ox-content/napi parse-only API returns an AST JSON string; these rows compare native throughput with different schemas and do not include a follow-up JSON.parse.
If the @ox-content/napi AST JSON string is immediately materialized into JavaScript objects:
Experimental stock-subset AST JSON output (parseStockFastAstJson) compared with @ox-content/napi parse-only:
What the specialized native baseline teaches us:
JSON.parse measures an additional consumer cost, but still does not make its schema equivalent to markdown-it tokens.parseStockFastAstJson section is the separate equivalent-output comparison: it asserts identical mdast JSON before timing.Specialized stock-subset native render behavior (markdown-it-ts.render vs @ox-content/napi parse + render):
These rows use the default render APIs rather than S1–S5 best-of. They are not equivalent-output results: the benchmark records an HTML difference (for example, OX adds heading IDs) and must not be generalized to feature-mixed Markdown.
Legacy stock-subset summary (tuned parse + default native render):
| Size | markdown-it-ts parse | @ox-content/napi parse | Parse comparison | markdown-it-ts render | @ox-content/napi render | Render comparison |
|---|---|---|---|---|---|---|
| 5,000 | 0.0633ms | 0.0439ms | ~1.4× slower, ~44% more time | 0.0214ms | 0.0391ms | ~1.8× faster, ~45% less time |
| 20,000 | 0.1240ms | 0.1629ms | ~1.3× faster, ~24% less time | 0.0745ms | 0.1511ms | ~2× faster, ~51% less time |
| 100,000 | 0.7630ms | 0.9344ms | ~1.2× faster, ~18% less time | 0.3528ms | 0.7640ms | ~2.2× faster, ~54% less time |
This legacy ranking covers only the specialized synthetic stock-subset; it is not a general Markdown ranking. It is generated from the latest docs/perf-latest.json snapshot.
Parse ranking uses the fastest tuned markdown-it-ts one-shot scenario for each size. Render ranking uses default MarkdownIt().render() native behavior, and cross-library HTML is not equivalent, so neither table represents one equivalent-work pipeline.
Parse ranking (one-shot parse, ms)
| Size | Rank | Library | oneShotMs |
|---|---|---|---|
| 5,000 | 1 | @ox-content/napi | 0.0439ms |
| 5,000 | 2 | markdown-it-ts | 0.0633ms |
| 5,000 | 3 | markdown-it | 0.1852ms |
| 5,000 | 4 | markdown-exit | 0.2703ms |
| 5,000 | 5 | remark | 4.9303ms |
| 20,000 | 1 | markdown-it-ts | 0.1240ms |
| 20,000 | 2 | @ox-content/napi | 0.1629ms |
| 20,000 | 3 | markdown-it | 0.7413ms |
| 20,000 | 4 | markdown-exit | 1.0053ms |
| 20,000 | 5 | remark | 22.98ms |
| 50,000 | 1 | markdown-it-ts | 0.3529ms |
| 50,000 | 2 | @ox-content/napi | 0.4385ms |
| 50,000 | 3 | markdown-it | 2.4740ms |
| 50,000 | 4 | markdown-exit | 2.5837ms |
| 50,000 | 5 | remark | 67.66ms |
| 100,000 | 1 | markdown-it-ts | 0.7630ms |
| 100,000 | 2 | @ox-content/napi | 0.9344ms |
| 100,000 | 3 | markdown-it | 4.5285ms |
| 100,000 | 4 | markdown-exit | 6.7814ms |
| 100,000 | 5 | remark | 151.72ms |
| 200,000 | 1 | markdown-it-ts | 1.4971ms |
| 200,000 | 2 | @ox-content/napi | 1.7007ms |
| 200,000 | 3 | markdown-it | 9.6193ms |
| 200,000 | 4 | markdown-exit | 12.59ms |
| 200,000 | 5 | remark | 417.05ms |
Render ranking (parse + HTML output, ms)
| Size | Rank | Library | renderMs |
|---|---|---|---|
| 5,000 | 1 | markdown-it-ts | 0.0214ms |
| 5,000 | 2 | @ox-content/napi | 0.0391ms |
| 5,000 | 3 | markdown-it | 0.2325ms |
| 5,000 | 4 | markdown-exit | 0.3043ms |
| 5,000 | 5 | remark + rehype | 4.7874ms |
| 20,000 | 1 | markdown-it-ts | 0.0745ms |
| 20,000 | 2 | @ox-content/napi | 0.1511ms |
| 20,000 | 3 | markdown-it | 0.9201ms |
| 20,000 | 4 | markdown-exit | 1.2125ms |
| 20,000 | 5 | remark + rehype | 22.66ms |
| 50,000 | 1 | markdown-it-ts | 0.1772ms |
| 50,000 | 2 | @ox-content/napi | 0.3701ms |
| 50,000 | 3 | markdown-it | 2.3052ms |
| 50,000 | 4 | markdown-exit | 3.0101ms |
| 50,000 | 5 | remark + rehype | 70.51ms |
| 100,000 | 1 | markdown-it-ts | 0.3528ms |
| 100,000 | 2 | @ox-content/napi | 0.7640ms |
| 100,000 | 3 | markdown-it | 4.8893ms |
| 100,000 | 4 | markdown-exit | 6.1163ms |
| 100,000 | 5 | remark + rehype | 163.02ms |
| 200,000 | 1 | markdown-it-ts | 0.7035ms |
| 200,000 | 2 | @ox-content/napi | 1.5096ms |
| 200,000 | 3 | markdown-it | 10.66ms |
| 200,000 | 4 | markdown-exit | 13.31ms |
| 200,000 | 5 | remark + rehype | 412.00ms |
For append-heavy editor or streaming workloads, enable the stream parser or use StreamBuffer / UnboundedBuffer. These paths are designed to avoid reparsing stable historical text when the input shape is safe for incremental parsing.
Benchmark results are workload-, CPU-, and Node-version-dependent. docs/perf-latest.json records the Node version, platform, CPU, generated time, benchmark version, and commit for each generated snapshot. Reproduce locally with:
pnpm run build
pnpm run perf:generate
Full parse/render comparisons against @ox-content/napi, remark, micromark, and markdown-exit live in docs/perf-latest.md and docs/perf-report.md. Keep README numbers as a short orientation only; benchmark claims should cite the synthetic harness, environment, and snapshot file.
Contributions are welcome! Please open an issue or submit a pull request for any enhancements or bug fixes.
markdown-it-ts is a TypeScript re-implementation that stands on the shoulders of markdown-it. We are deeply grateful to the original project and its maintainers and contributors (notably Vitaly Puzrin and the markdown-it community). Many ideas, algorithms, renderer behaviors, specs, and fixtures originate from markdown-it; this project would not exist without that work.
This project is licensed under the MIT License. See the LICENSE file for more details.
TypeScript
64.0%
JavaScript
32.4%
Vue
2.8%
A modern, type-safe rewrite of markdown-it in TypeScript: pluggable rules, split parse/render, CommonMark-compatible; fast one-shot parsing and even faster streaming/incremental updates.
See the codeA TypeScript-first Markdown parser and renderer compatible with the markdown-it public API for common plugin patterns, with streaming/incremental parsing and async render.
English | 简体中文
Quick links: Docs index · Stream optimization · Performance report · Compatibility report
Runtime note
markdown-it-tsis ESM-only and requires Node.js >= 18.import MarkdownIt from 'markdown-it-ts'In CommonJS projects, use dynamic import inside an async function:
async function main() { const { default: MarkdownIt } = await import('markdown-it-ts') const md = MarkdownIt() console.log(md.render('# ok')) } main().catch((error) => { console.error(error) process.exitCode = 1 })
A TypeScript migration of markdown-it with modular architecture for tree-shaking and separate parse/render imports.
markdown-it-ts targets the markdown-it public API for common parser, renderer, and plugin usage. Private markdown-it/lib/... imports, undocumented upstream internal state assumptions, direct CommonJS require('markdown-it-ts'), and Node.js < 18 are unsupported.
| Level | API surface |
|---|---|
| Stable target | MarkdownIt(), parse, render, renderInline, renderAsync, renderer.rules, Token, and public ruler/plugin APIs |
| Advanced | Root withRenderer, documented subpath exports such as core, renderer helpers, and common utilities |
| Experimental | stream, chunkedParse, StreamBuffer, UnboundedBuffer, EditableBuffer, PieceTable, iterable/sink parsing, and chunk strategy recommenders via markdown-it-ts/experimental; selected helpers also have explicit subpaths such as markdown-it-ts/stream/buffer, markdown-it-ts/stream/chunked, markdown-it-ts/stream/debounced, and markdown-it-ts/support/chunk_recommend |
The root entry no longer exposes experimental helpers as top-level named exports. Some large-input helpers remain available as experimental instance methods for compatibility, but they are not part of the stable markdown-it compatibility contract.
Common 0.x import migrations:
| 0.x import | 1.0 import |
|---|---|
import { StreamBuffer } from 'markdown-it-ts' | import { StreamBuffer } from 'markdown-it-ts/experimental' or markdown-it-ts/stream/buffer |
import { chunkedParse } from 'markdown-it-ts' | import { chunkedParse } from 'markdown-it-ts/experimental' or markdown-it-ts/stream/chunked |
import { recommendFullChunkStrategy } from 'markdown-it-ts' | import { recommendFullChunkStrategy } from 'markdown-it-ts/support/chunk_recommend' |
import { UnboundedBuffer } from 'markdown-it-ts' | import { UnboundedBuffer } from 'markdown-it-ts/experimental' |
The core TypeScript port is complete. Compatibility is maintained against the markdown-it public API and common plugin patterns with the following goals:
#)===)markdownit() instances expose render, renderInline, and renderer for plugin compatibilitynpm install markdown-it-ts
import markdownIt from 'markdown-it-ts'
const md = markdownIt()
const tokens = md.parse('# Hello World')
console.log(tokens)
Use the built-in renderer for the markdown-it-compatible render API:
import markdownIt from 'markdown-it-ts'
const md = markdownIt()
const html = md.render('# Hello World')
console.log(html)
Security note: markdown-it-ts is not an HTML sanitizer. Raw HTML is escaped by default, but html: true passes raw HTML through, and plugin-authored attributes are treated as trusted output. Sanitize rendered HTML at your application boundary when untrusted authors can provide raw HTML or plugin-controlled attributes.
For normal usage, keep the original markdown-it-compatible API:
const md = markdownIt()
const tokens = md.parse(hugeMarkdown)
const html = md.render(hugeMarkdown)
Those default parse / render calls may auto-activate an internal large-input path once the document crosses the large-document threshold. For compatibility, that implicit path is used only when no plugin has been installed and the parser rulers have not been modified. Any .use() call, including renderer-only plugins, keeps the plain full parse path unless you explicitly opt into experimental.fullChunkedFallback; stream parsing has the separate experimental.streamChunkedFallback opt-in.
Use the explicit stream-oriented APIs only when your upstream input already arrives as chunks and you do not want to join it into one giant string first:
import MarkdownIt from 'markdown-it-ts'
import { UnboundedBuffer } from 'markdown-it-ts/experimental'
const md = MarkdownIt()
const tokens = md.parseIterable(fileChunks)
const buffer = new UnboundedBuffer(md, { mode: 'stream' })
for await (const chunk of logChunks) {
buffer.feed(chunk)
buffer.flushAvailable()
}
const finalTokens = buffer.flushForce()
Large-input tuning options are available under experimental:
const md = MarkdownIt({
experimental: {
autoUnbounded: false,
fullChunkedFallback: true,
},
})
The older top-level experimental option names are still accepted for 1.x compatibility, but the namespaced form is preferred.
parseIterable / parseAsyncIterable are advanced entry points for explicit Iterable<string> / AsyncIterable<string> inputs. UnboundedBuffer is the advanced append-only path for real chunk streams and only keeps a bounded tail in memory instead of the whole historical source string.
If you also need bounded output memory for explicit chunk-stream inputs, use the sink form instead of retaining a full token array:
md.parseIterableToSink(fileChunks, (tokens, info) => {
consumeTokenChunk(tokens, info)
})
For arbitrary in-place edits, use EditableBuffer. It stores the source in a piece table and reparses only from an anchor before the affected block instead of flattening and reparsing the whole document every time. Internally, both the full parse and the localized reparse paths now hand a PieceTableSourceView straight to md.core.parseSource(...), so the selected range no longer needs to be materialized as one giant intermediate string first.
Markdown is not always chunk-local. Some constructs depend on document-level state, including reference definitions, footnote definitions, abbreviation definitions, and plugin-defined global state.
chunkedParse() and complete-string unbounded parsing use a correctness-first fallback by default for known global-state constructs. Chunked parsing also falls back to a full parse when a forced chunk boundary is not on a blank-line boundary, because long lists, blockquotes, HTML blocks, and paragraphs are not safe to split arbitrarily.
Iterable/sink parsing is streaming-oriented. It cannot always know future document-level definitions before committing earlier chunks, so documents with reference, footnote, or abbreviation definitions should use full-string parsing or avoid early flushing when exact full-parse parity is required.
The detector is intentionally conservative. It may fall back for definitions that appear inside code fences or raw text, because fallback is correctness-first.
You can explicitly disable only the known global-state fallback:
chunkedParse(md, source, env, {
fallbackOnGlobalState: false,
})
Unsafe non-blank chunk boundaries still fall back to a full parse because splitting there is not token-stream safe.
Disabling the global-state fallback is a performance-oriented mode and may produce output that differs from a full parse for documents with global state.
Need async renderer rules (for example, asynchronous syntax highlighting)? Use renderAsync which awaits async rule results:
const md = markdownIt()
const html = await md.renderAsync('# Hello World', {
highlight: async (code, lang) => {
const highlighted = await someHighlighter(code, lang)
return highlighted
},
})
The main package entry already includes render, renderAsync, renderInline, renderer, and the advanced withRenderer helper. markdown-it-ts/plugins/with-renderer is also kept for custom/core-shaped instances; normal markdown-it-ts users do not need to call it.
parse / render usage stays unchanged; plugin/custom-rule instances keep full-parse semantics by default, while stock parser instances can use internal large-input optimizations.renderAsync), and exposes tuning knobs for large-input and append-heavy workloads. Benchmark numbers below are split by corpus and comparison semantics; the stock-subset rows are not a promise that every workload is faster.docs/stream-optimization.md, markdown-it-ts/experimental, and documented subpaths for recommend*Strategy, StreamBuffer, chunkedParse, etc.), so teams can build adaptive streaming pipelines quickly. The repository’s benchmark scripts (perf:generate, perf:update-readme) keep comparison data up to date in CI, reducing the risk of unnoticed regressions.markdown-it-ts/experimental; selected helpers also have explicit subpath imports. Some advanced instance methods and options remain available for existing large-input integrations and are marked experimental in the type declarations.You can customize parser options and enable or disable specific rules:
import markdownIt from 'markdown-it-ts'
const md = markdownIt({
linkify: true,
typographer: true,
html: false,
}).disable('image')
const result = md.render('Some markdown content')
console.log(result)
Build the demo site into ./demo and open it in your browser.
Note: the demo build uses the current project's published build artifact (the files in dist/). The demo script runs npm run build before bundling, so the demo reflects the current repo source.
This ensures demo/markdown-it.js is produced from the most recent dist/index.js output.
You can generate API documentation into ./apidoc using the built-in script. The script will attempt to use pnpm dlx or npx if available, otherwise it uses the locally-installed ndoc from node_modules.
# build and generate docs
npm run build
npm run doc
# open generated docs
open apidoc/index.html # macOS
xdg-open apidoc/index.html # Linux
This repository has separate workflows for code quality and documentation/demo validation.
.github/workflows/ci.yml runs lint, typecheck, unit tests, build, package smoke tests, and runtime smoke tests for the packed package..github/workflows/ci-docs.yml builds API docs and the demo site, and conditionally deploys them when Netlify secrets are configured..github/workflows/perf-regression.yml is a manual benchmark workflow (workflow_dispatch) for comparing full benchmark snapshots between a base ref and a head ref when a change needs deeper parser/render performance validation.Files to inspect: .github/workflows/ci.yml, .github/workflows/ci-docs.yml, .github/workflows/perf-regression.yml
You can deploy both the generated API docs (apidoc/) and the demo site (demo/) to Netlify. There are two supported workflows:
netlify-cli locally or use the helper scripts included in package.json.Deploy docs locally:
# set environment variables first
export NETLIFY_AUTH_TOKEN=your_token_here
export NETLIFY_SITE_ID_DOCS=your_docs_site_id
pnpm run netlify:deploy:docs
Deploy demo locally:
export NETLIFY_AUTH_TOKEN=your_token_here
export NETLIFY_SITE_ID_DEMO=your_demo_site_id
pnpm run netlify:deploy:demo
The repo contains two GitHub Actions workflows, one for docs and one for demo. Each workflow will only run if you add the required secrets to the repository:
Add these as GitHub Secrets for the repository (Settings → Secrets and variables → Actions). When pushed to main, the workflows will run and deploy to the corresponding Netlify site.
Files to inspect: .github/workflows/deploy-netlify-docs.yml and .github/workflows/deploy-netlify-demo.yml
Automatic CI deploy: when you push to main, the CI workflow will build the project, generate docs, and build the demo. After a successful build the workflow attempts to deploy both apidoc/ and demo/ to Netlify automatically — but only if the corresponding GitHub Actions secrets are set:
NETLIFY_AUTH_TOKEN — Netlify Personal Access TokenNETLIFY_SITE_ID_DOCS — Netlify Site ID for the docs siteNETLIFY_SITE_ID_DEMO — Netlify Site ID for the demo siteIf those secrets exist, the CI will publish both sites. If not, the CI will skip publishing and still report build/lint/docs/demo status.
# build demo and open ./demo/index.html (macOS / Linux / Windows supported)
npm run gh-demo
If you only want to build the demo (skip publishing) you can run:
npm run demo
To publish the demo automatically set GH_PAGES_REPO to your target repo (you must have push access):
export GH_PAGES_REPO='git@github.com:youruser/markdown-it.github.io.git'
npm run gh-demo
Subpath exports
For advanced or tree-shaken imports you can target subpaths directly:
import { Token } from 'markdown-it-ts/common/token'
import { withRenderer } from 'markdown-it-ts/plugins/with-renderer'
import Renderer from 'markdown-it-ts/render/renderer'
import { StreamBuffer } from 'markdown-it-ts/stream/buffer'
import { chunkedParse } from 'markdown-it-ts/stream/chunked'
import { DebouncedStreamParser, ThrottledStreamParser } from 'markdown-it-ts/stream/debounced'
Plugins are regular functions that receive the markdown-it-ts instance. For full type-safety use the exported MarkdownItPlugin type:
import markdownIt, { type MarkdownItPlugin } from 'markdown-it-ts'
const plugin: MarkdownItPlugin = (md) => {
md.core.ruler.after('block', 'my_rule', (state) => {
// custom transform logic
})
}
const md = markdownIt().use(plugin)
For large documents or append-heavy editing flows, you can enable the stream parser and an optional chunked fallback. See the detailed guide in docs/stream-optimization.md.
Quick start:
import markdownIt from 'markdown-it-ts'
const md = markdownIt({
stream: true, // enable stream mode
streamChunkedFallback: true, // use chunked on first large parse or large non-append edits
// optional tuning
// By default, chunk size is adaptive to doc size (streamChunkAdaptive: true)
// You can pin fixed sizes by setting streamChunkAdaptive: false
streamChunkSizeChars: 10_000,
streamChunkSizeLines: 200,
streamChunkFenceAware: true,
})
let src = '# Title\n\nHello'
md.stream.parse(src, {})
// Pass only the delta to avoid scanning the stable history prefix.
md.stream.append('\n\nworld!\n')
// Keep an in-memory snapshot when switching away from a thread.
const snapshot = md.stream.snapshot()
md.stream.reset()
md.stream.restore(snapshot)
Try the quick benchmark (build first):
npm run build
node scripts/quick-benchmark.mjs
More:
npm run perf:matrixnpm run perf:sweeppnpm run perf:familiespnpm run perf:strategiespnpm run perf:stream-historypnpm run perf:gatedocs/perf-report.md.docs/stream-optimization.md and docs/parse-strategy-matrix.md.Adaptive chunk sizing
fullChunkAdaptive: true), targeting ~8 chunks and clamping sizes into practical ranges.streamChunkAdaptive: true).*Adaptive: false flags or by providing explicit *SizeChars/*SizeLines values.If you want to display or persist the suggested chunk settings without enabling auto-tune, you can query them directly:
import markdownIt from 'markdown-it-ts'
import {
recommendFullChunkStrategy,
recommendStreamChunkStrategy,
} from 'markdown-it-ts/support/chunk_recommend'
const size = 50_000
const fullRec = recommendFullChunkStrategy(size)
// { strategy: 'plain', fenceAware: true }
const streamRec = recommendStreamChunkStrategy(size)
// { strategy: 'discrete', maxChunkChars: 16_000, maxChunkLines: 250, fenceAware: true }
These mirror the same mappings used internally when autoTuneChunks: true and no explicit sizes are provided.
To make sure each change is not slower than the previous run at any tested size/config, we ship a tiny perf harness and a comparator:
Generate the latest report and snapshot:
npm run perf:generate → writes docs/perf-latest.md and docs/perf-latest.jsondocs/perf-history/perf-<shortSHA>.json when git is availableCompare two snapshots (fail on regressions beyond threshold):
node scripts/perf-compare.mjs docs/perf-latest.json docs/perf-history/perf-<baselineSHA>.json --threshold=0.10Accept the latest run as the new baseline (after manual review):
pnpm run perf:acceptRun the regression check against the most recent baseline (same harness):
pnpm run perf:check:latestRun the per-token-type render benchmark against markdown-it:
pnpm run perf:render-rules--include-noise to also show zero-token / sub-signal categoriespnpm run perf:render-rules:check to fail if any meaningful category regresses beyond the thresholdRun the parser rule-family hotspot benchmark:
pnpm run perf:familiesdocs/perf-family-hotspots.md and docs/perf-family-hotspots.jsonRun the long-text default-strategy benchmark and gate:
pnpm run perf:strategiespnpm run perf:strategy:checkpnpm run perf:gatedocs/perf-large-defaults.* and docs/parse-strategy-matrix.mdInspect detailed deltas by size/scenario (sorted by worst):
pnpm run perf:diffSee docs/perf-regression.md for details and CI usage.
CI always runs the vendored upstream CommonMark good.txt fixture via test/compat/commonmark-fixture.test.mjs, plus the local plugin compatibility matrix.
This repo can also run a subset of the original markdown-it tests and pathological cases. Those optional suites are disabled by default because they require:
markdown-it repo (referenced by relative path in tests)To enable upstream tests locally:
# Ensure directory layout like:
# ../markdown-it/ # upstream repo with index.mjs and fixtures
# ./markdown-it-ts/ # this repo
RUN_ORIGINAL=1 pnpm test
Notes
Alternative: set a custom upstream path without sibling layout
# Point to a local checkout of markdown-it
MARKDOWN_IT_DIR=/absolute/path/to/markdown-it RUN_ORIGINAL=1 pnpm test
Convenience scripts
pnpm run test:original # same as RUN_ORIGINAL=1 pnpm test
pnpm run test:original:network # also sets RUN_NETWORK=1
markdown-it-ts is optimized for parser throughput while preserving the markdown-it public API and common plugin model. The benchmark report now separates three different questions: fixed-configuration native API throughput, tuned/best-of scenarios, and equivalent-output comparisons.
For default, unmodified parser instances, the stock block scanner also has a hybrid path: supported headings, single-line paragraphs, tight bullet lists, and fences keep the fast block scan even when their inline content needs the standard emphasis/link/code/entity rules. Plugin or custom-rule instances continue to use the general parser.
The historical size-based numbers below use the repository's synthetic stock-subset: ATX headings, plain single-line paragraphs, flat tight bullet lists, and fenced code. Its repeated paragraph/list content intentionally exercises the stock-fast parser/renderer and last-output caches. Treat these results as a specialized fast-path benchmark, not as a claim about general Markdown.
The compact table below reports the synthetic stock subset, a feature-mixed synthetic corpus, and repository-owned MIT-licensed documents independently. It uses default MarkdownIt() instances; the feature-mixed and real-world OX rows enable tables and strikethrough to align those features more closely. Parse rows are native API throughput, not equivalent output: markdown-it-ts returns mutable Token[], while @ox-content/napi returns an object containing an mdast JSON string. Render rows compare native behavior and explicitly report whether HTML is identical.
| Corpus | Chars | TS parse | OX parse | TS parse path | TS render | OX render | TS render path | HTML equal? |
|---|---|---|---|---|---|---|---|---|
| synthetic stock-subset (~100k) | 100,126 | 0.6382ms | 0.8475ms | stock-fast | 0.3556ms | 0.7629ms | stock-fast | no |
| synthetic feature-mixed (~100k) | 100,450 | 3.9569ms | 1.1164ms | general | 4.8427ms | 1.0122ms | token-renderer | no |
| docs/architecture.md | 6,564 | 0.0812ms | 0.0335ms | general | 0.0992ms | 0.0255ms | token-renderer | no |
| docs/development.md | 4,756 | 0.0866ms | 0.0304ms | general | 0.1045ms | 0.0273ms | token-renderer | no |
| docs/security.md | 1,375 | 0.0251ms | 0.0093ms | general | 0.0312ms | 0.0083ms | token-renderer | no |
No aggregate winner is calculated across corpora. See the generated report for every size, per-file real-world results, strategy diagnostics, and the first HTML output difference.
For the Markstream consumer pipeline, see the streaming and history restore audit, including all 59 workloads, CPU samples, and rejected experiments.
In the latest stock-subset snapshot (Node.js version and CPU are recorded in docs/perf-latest.md), tuned one-shot parsing compares as follows with upstream markdown-it:
Tuned stock-subset parser comparison (markdown-it-ts best one-shot vs @ox-content/napi parse only):
This is a best-of S1–S5 result for markdown-it-ts, not a fixed-configuration headline. The @ox-content/napi parse-only API returns an AST JSON string; these rows compare native throughput with different schemas and do not include a follow-up JSON.parse.
If the @ox-content/napi AST JSON string is immediately materialized into JavaScript objects:
Experimental stock-subset AST JSON output (parseStockFastAstJson) compared with @ox-content/napi parse-only:
What the specialized native baseline teaches us:
JSON.parse measures an additional consumer cost, but still does not make its schema equivalent to markdown-it tokens.parseStockFastAstJson section is the separate equivalent-output comparison: it asserts identical mdast JSON before timing.Specialized stock-subset native render behavior (markdown-it-ts.render vs @ox-content/napi parse + render):
These rows use the default render APIs rather than S1–S5 best-of. They are not equivalent-output results: the benchmark records an HTML difference (for example, OX adds heading IDs) and must not be generalized to feature-mixed Markdown.
Legacy stock-subset summary (tuned parse + default native render):
| Size | markdown-it-ts parse | @ox-content/napi parse | Parse comparison | markdown-it-ts render | @ox-content/napi render | Render comparison |
|---|---|---|---|---|---|---|
| 5,000 | 0.0633ms | 0.0439ms | ~1.4× slower, ~44% more time | 0.0214ms | 0.0391ms | ~1.8× faster, ~45% less time |
| 20,000 | 0.1240ms | 0.1629ms | ~1.3× faster, ~24% less time | 0.0745ms | 0.1511ms | ~2× faster, ~51% less time |
| 100,000 | 0.7630ms | 0.9344ms | ~1.2× faster, ~18% less time | 0.3528ms | 0.7640ms | ~2.2× faster, ~54% less time |
This legacy ranking covers only the specialized synthetic stock-subset; it is not a general Markdown ranking. It is generated from the latest docs/perf-latest.json snapshot.
Parse ranking uses the fastest tuned markdown-it-ts one-shot scenario for each size. Render ranking uses default MarkdownIt().render() native behavior, and cross-library HTML is not equivalent, so neither table represents one equivalent-work pipeline.
Parse ranking (one-shot parse, ms)
| Size | Rank | Library | oneShotMs |
|---|---|---|---|
| 5,000 | 1 | @ox-content/napi | 0.0439ms |
| 5,000 | 2 | markdown-it-ts | 0.0633ms |
| 5,000 | 3 | markdown-it | 0.1852ms |
| 5,000 | 4 | markdown-exit | 0.2703ms |
| 5,000 | 5 | remark | 4.9303ms |
| 20,000 | 1 | markdown-it-ts | 0.1240ms |
| 20,000 | 2 | @ox-content/napi | 0.1629ms |
| 20,000 | 3 | markdown-it | 0.7413ms |
| 20,000 | 4 | markdown-exit | 1.0053ms |
| 20,000 | 5 | remark | 22.98ms |
| 50,000 | 1 | markdown-it-ts | 0.3529ms |
| 50,000 | 2 | @ox-content/napi | 0.4385ms |
| 50,000 | 3 | markdown-it | 2.4740ms |
| 50,000 | 4 | markdown-exit | 2.5837ms |
| 50,000 | 5 | remark | 67.66ms |
| 100,000 | 1 | markdown-it-ts | 0.7630ms |
| 100,000 | 2 | @ox-content/napi | 0.9344ms |
| 100,000 | 3 | markdown-it | 4.5285ms |
| 100,000 | 4 | markdown-exit | 6.7814ms |
| 100,000 | 5 | remark | 151.72ms |
| 200,000 | 1 | markdown-it-ts | 1.4971ms |
| 200,000 | 2 | @ox-content/napi | 1.7007ms |
| 200,000 | 3 | markdown-it | 9.6193ms |
| 200,000 | 4 | markdown-exit | 12.59ms |
| 200,000 | 5 | remark | 417.05ms |
Render ranking (parse + HTML output, ms)
| Size | Rank | Library | renderMs |
|---|---|---|---|
| 5,000 | 1 | markdown-it-ts | 0.0214ms |
| 5,000 | 2 | @ox-content/napi | 0.0391ms |
| 5,000 | 3 | markdown-it | 0.2325ms |
| 5,000 | 4 | markdown-exit | 0.3043ms |
| 5,000 | 5 | remark + rehype | 4.7874ms |
| 20,000 | 1 | markdown-it-ts | 0.0745ms |
| 20,000 | 2 | @ox-content/napi | 0.1511ms |
| 20,000 | 3 | markdown-it | 0.9201ms |
| 20,000 | 4 | markdown-exit | 1.2125ms |
| 20,000 | 5 | remark + rehype | 22.66ms |
| 50,000 | 1 | markdown-it-ts | 0.1772ms |
| 50,000 | 2 | @ox-content/napi | 0.3701ms |
| 50,000 | 3 | markdown-it | 2.3052ms |
| 50,000 | 4 | markdown-exit | 3.0101ms |
| 50,000 | 5 | remark + rehype | 70.51ms |
| 100,000 | 1 | markdown-it-ts | 0.3528ms |
| 100,000 | 2 | @ox-content/napi | 0.7640ms |
| 100,000 | 3 | markdown-it | 4.8893ms |
| 100,000 | 4 | markdown-exit | 6.1163ms |
| 100,000 | 5 | remark + rehype | 163.02ms |
| 200,000 | 1 | markdown-it-ts | 0.7035ms |
| 200,000 | 2 | @ox-content/napi | 1.5096ms |
| 200,000 | 3 | markdown-it | 10.66ms |
| 200,000 | 4 | markdown-exit | 13.31ms |
| 200,000 | 5 | remark + rehype | 412.00ms |
For append-heavy editor or streaming workloads, enable the stream parser or use StreamBuffer / UnboundedBuffer. These paths are designed to avoid reparsing stable historical text when the input shape is safe for incremental parsing.
Benchmark results are workload-, CPU-, and Node-version-dependent. docs/perf-latest.json records the Node version, platform, CPU, generated time, benchmark version, and commit for each generated snapshot. Reproduce locally with:
pnpm run build
pnpm run perf:generate
Full parse/render comparisons against @ox-content/napi, remark, micromark, and markdown-exit live in docs/perf-latest.md and docs/perf-report.md. Keep README numbers as a short orientation only; benchmark claims should cite the synthetic harness, environment, and snapshot file.
Contributions are welcome! Please open an issue or submit a pull request for any enhancements or bug fixes.
markdown-it-ts is a TypeScript re-implementation that stands on the shoulders of markdown-it. We are deeply grateful to the original project and its maintainers and contributors (notably Vitaly Puzrin and the markdown-it community). Many ideas, algorithms, renderer behaviors, specs, and fixtures originate from markdown-it; this project would not exist without that work.
This project is licensed under the MIT License. See the LICENSE file for more details.
TypeScript
64.0%
JavaScript
32.4%
Vue
2.8%