English | 简体中文
A TypeScript migration of markdown-it with modular architecture for tree-shaking and separate parse/render imports.
This is an active migration of markdown-it to TypeScript with the following goals:
- ✅ Full TypeScript type safety
- ✅ Modular architecture (separate parse/render imports)
- ✅ Tree-shaking support
- ✅ Ruler-based rule system
- ✅ API compatibility with original markdown-it
- All 7 core rules (normalize, block, inline, linkify, replacements, smartquotes, text_join)
- CoreRuler with enable/disable/getRules support
- Full parsing pipeline
- All 11 block rules:
- table (GFM tables)
- code (indented code blocks)
- fence (fenced code blocks)
- blockquote (block quotes)
- hr (horizontal rules)
- list (bullet and ordered lists with nesting)
- reference (link reference definitions)
- html_block (raw HTML blocks)
- heading (ATX headings
#) - lheading (Setext headings
===) - paragraph (paragraphs)
- StateBlock with full line tracking (200+ lines)
- BlockRuler implementation (80 lines)
- ParserBlock refactored with Ruler pattern
- All 12 inline rules (text, escape, linkify, strikethrough, etc.) with full post-processing coverage
- StateInline with 18 properties, 3 methods
- InlineRuler implementation mirroring markdown-it behavior
- Renderer ported from markdown-it with attribute handling & highlight support
- Type definitions with Token interface and renderer options
- Helper functions (parseLinkLabel, parseLinkDestination, parseLinkTitle)
- Common utilities (html_blocks, html_re, utils)
markdownit()instances exposerender,renderInline, andrendererfor plugin compatibility
npm install markdown-it-tsimport markdownIt from 'markdown-it-ts'
const md = markdownIt()
const tokens = md.parse('# Hello World')
console.log(tokens)Use the built-in renderer for full markdown-it compatibility:
import markdownIt from 'markdown-it-ts'
const md = markdownIt()
const html = md.render('# Hello World')
console.log(html)If you initially import core-only and want to attach rendering (to keep bundles smaller when only parse is needed elsewhere), use the provided helper:
import markdownIt, { withRenderer } from 'markdown-it-ts'
const md = withRenderer(markdownIt())
const html = md.render('# Hello World')
console.log(html)You can customize the parser and renderer by enabling or disabling specific rules:
import markdownIt from 'markdown-it-ts'
const md = markdownIt()
.enable(['linkify', 'typographer'])
.disable('html')
const result = md.render('Some markdown content')
console.log(result)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, { 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.parse(src, {})
// Append-only edits use the fast path
src += '\nworld!'
md.parse(src, {})Try the quick benchmark (build first):
npm run build
node scripts/quick-benchmark.mjsMore:
- Full performance matrix across modes and sizes:
npm run perf:matrix - Non-stream chunked sweep to tune thresholds:
npm run perf:sweep - See detailed findings in
docs/perf-report.md.
Adaptive chunk sizing
- Non-stream full fallback now chooses chunk size automatically by default (
fullChunkAdaptive: true), targeting ~8 chunks and clamping sizes into practical ranges. - Stream chunked fallback also uses adaptive sizing by default (
streamChunkAdaptive: true). - You can restore fixed sizes by setting the respective
*Adaptive: falseflags or by providing explicit*SizeChars/*SizeLinesvalues.
If you want to display or persist the suggested chunk settings without enabling auto-tune, you can query them directly:
import markdownIt, { recommendFullChunkStrategy, recommendStreamChunkStrategy } from 'markdown-it-ts'
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→ writesdocs/perf-latest.mdanddocs/perf-latest.json- Also archives
docs/perf-history/perf-<shortSHA>.jsonwhen git is available
-
Compare two snapshots (fail on regressions beyond threshold):
node scripts/perf-compare.mjs docs/perf-latest.json docs/perf-history/perf-<baselineSHA>.json --threshold=0.10
-
Accept the latest run as the new baseline (after manual review):
pnpm run perf:accept
-
Run the regression check against the most recent baseline (same harness):
pnpm run perf:check:latest
-
Inspect detailed deltas by size/scenario (sorted by worst):
pnpm run perf:diff
See docs/perf-regression.md for details and CI usage.
This repo can run a subset of the original markdown-it tests and pathological cases. They are disabled by default because they require:
- A sibling checkout of the upstream
markdown-itrepo (referenced by relative path in tests) - Network access for fetching reference scripts
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 testNotes
- Pathological tests are heavy and use worker threads and network; enable only when needed.
- CI keeps these disabled by default.
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 testConvenience scripts
pnpm run test:original # same as RUN_ORIGINAL=1 pnpm test
pnpm run test:original:network # also sets RUN_NETWORK=1Latest one-shot parse results on this machine (Node.js v23): markdown-it-ts is roughly at parity with upstream markdown-it in the 5k–100k range.
Examples from the latest run (avg over 20 iterations):
- 5,000 chars: 0.00ms vs 0.43ms → ~2627.6× faster (0.00× time)
- 20,000 chars: 0.97ms vs 0.84ms → ~0.9× faster (1.16× time)
- 50,000 chars: 2.59ms vs 2.20ms → ~0.8× faster (1.18× time)
- 100,000 chars: 5.58ms vs 4.94ms → ~0.9× faster (1.13× time)
- 200,000 chars: 12.35ms vs 13.31ms → ~1.1× faster (0.93× time)
- Notes
- Numbers vary by Node version, CPU, and content shape; see
docs/perf-latest.mdfor the full table and environment details. - Streaming/incremental mode is correctness-first by default. For editor-style input, using
StreamBufferto flush at block boundaries can yield meaningful wins on append-heavy workloads.
We also compare parse-only performance against remark (parse-only). The following figures are taken from the latest archived snapshot docs/perf-history/perf-d660c6e.json (generatedAt 2025-11-14, Node v23.7.0) and show one-shot parse times and append-workload times reported by the harness.
One-shot parse (oneShotMs) — markdown-it-ts vs remark (lower is better):
- 5,000 chars: 0.00ms vs 6.28ms → 38645.9× faster
- 20,000 chars: 0.97ms vs 27.84ms → 28.6× faster
- 50,000 chars: 2.59ms vs 77.26ms → 29.8× faster
- 100,000 chars: 5.58ms vs 168.28ms → 30.2× faster
- 200,000 chars: 12.35ms vs 436.27ms → 35.3× faster
Append workload (appendWorkloadMs) — markdown-it-ts vs remark:
- 5,000 chars: 0.44ms vs 19.30ms → 43.9× faster
- 20,000 chars: 1.25ms vs 91.79ms → 73.6× faster
- 50,000 chars: 3.51ms vs 252.39ms → 72.0× faster
- 100,000 chars: 17.95ms vs 568.25ms → 31.7× faster
- 200,000 chars: 40.55ms vs 1304.01ms → 32.2× faster
Notes on interpretation
- These numbers compare parse-only times produced by the project's perf harness.
remarkworkflows often include additional tree transforms/plugins; real-world workloads may differ. - Results are machine- and content-dependent. For reproducible comparisons run the local harness and compare
docs/perf-latest.jsonor the archiveddocs/perf-history/*.jsonfiles. - Source:
docs/perf-history/perf-d660c6e.json(one-shot and appendWorkload values).
Reproduce locally
pnpm build
node scripts/quick-benchmark.mjsThis will update docs/perf-latest.md and refresh the snippet above.
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.