mdream

Installation
npm install mdream
pnpm add mdream
yarn add mdream
For the JavaScript-only engine (hook-based plugins, splitter, pure HTML parser):
pnpm add @mdream/js
Bundler Compatibility
The mdream package uses native Node.js bindings (NAPI-RS) which cannot be statically bundled. If your bundler fails to resolve mdream, mark it as external:
Next.js / Turbopack:
const nextConfig = {
serverExternalPackages: ['mdream'],
}
Webpack / other bundlers:
externals: ['mdream']
[!TIP]
@mdream/js has zero native dependencies and works with all bundlers without configuration.
[!TIP]
Using Vite? @mdream/vite handles this automatically.
Table of Contents
API Reference
htmlToMarkdown()
Converts a complete HTML string to Markdown synchronously.
Rust engine (mdream):
import { htmlToMarkdown } from 'mdream'
function htmlToMarkdown(html: string, options?: Partial<MdreamOptions>): string
JS engine (@mdream/js):
import { htmlToMarkdown } from '@mdream/js'
function htmlToMarkdown(html: string, options?: Partial<MdreamOptions>): string
Example:
import { htmlToMarkdown } from 'mdream'
const markdown = htmlToMarkdown('<h1>Hello World</h1><p>Some content.</p>')
streamHtmlToMarkdown()
Converts an HTML ReadableStream to Markdown incrementally. Returns an AsyncIterable<string> that yields Markdown chunks as they are processed.
import { streamHtmlToMarkdown } from 'mdream'
function streamHtmlToMarkdown(
htmlStream: ReadableStream<Uint8Array | string> | null,
options?: Partial<MdreamOptions>,
): AsyncIterable<string>
Example:
import { streamHtmlToMarkdown } from 'mdream'
const response = await fetch('https://example.com')
const stream = response.body
for await (const chunk of streamHtmlToMarkdown(stream, {
origin: 'https://example.com',
})) {
process.stdout.write(chunk)
}
Engines
Mdream includes two rendering engines, automatically selecting the best one for your environment:
| Rust (NAPI) | mdream | Declarative config only | Node.js (default) |
| Rust (WASM) | mdream | Declarative config only | Edge, browser |
| JavaScript | @mdream/js | Hook-based + declarative | Custom plugins, splitter |
import { htmlToMarkdown } from '@mdream/js'
import { htmlToMarkdown } from 'mdream'
Both engines accept the same declarative plugin configuration (origin, minimal, frontmatter, isolateMain, tailwind, filter, extraction, tagOverrides, clean). The JS engine additionally supports hooks for imperative plugin transforms.
Options
MdreamOptions (Rust engine)
Defined in mdream:
interface MdreamOptions {
origin?: string
minimal?: boolean
clean?: boolean | CleanOptions
frontmatter?: boolean | ((frontmatter: Record<string, string>) => void) | FrontmatterConfig
isolateMain?: boolean
tailwind?: boolean
filter?: { include?: string[], exclude?: string[], processChildren?: boolean }
extraction?: Record<string, (element: ExtractedElement) => void>
tagOverrides?: Record<string, TagOverride | string>
wrapWidth?: number
}
MdreamOptions (JS engine)
The JS engine extends the shared EngineOptions with hook-based plugin support:
interface MdreamOptions extends EngineOptions {
hooks?: TransformPlugin[]
}
interface EngineOptions {
origin?: string
clean?: boolean | CleanOptions
plugins?: BuiltinPlugins
wrapWidth?: number
}
interface BuiltinPlugins {
filter?: { include?: (string | number)[], exclude?: (string | number)[], processChildren?: boolean }
frontmatter?: boolean | ((fm: Record<string, string>) => void) | FrontmatterConfig
isolateMain?: boolean
tailwind?: boolean
extraction?: Record<string, (element: ExtractedElement) => void>
tagOverrides?: Record<string, TagOverride | string>
}
Note: The JS engine uses options.plugins.filter while the Rust engine uses options.filter directly.
CleanOptions
Post-processing cleanup applied to the final Markdown output. All options default to false unless clean: true is set.
interface CleanOptions {
urls?: boolean
fragments?: boolean
emptyLinks?: boolean
blankLines?: boolean
redundantLinks?: boolean
selfLinkHeadings?: boolean
emptyImages?: boolean
emptyLinkText?: boolean
}
Example:
const markdown = htmlToMarkdown(html, {
clean: {
urls: true,
emptyLinks: true,
emptyImages: true,
},
})
FrontmatterConfig
interface FrontmatterConfig {
additionalFields?: Record<string, string>
metaFields?: string[]
onExtract?: (frontmatter: Record<string, string>) => void
}
TagOverride
Override how specific HTML tags are rendered in Markdown. String values act as aliases.
Unknown tags pass through as plain text. Tag matching is strict: only the standard HTML tags ship with built-in Markdown semantics. Custom elements (<my-widget>), web components, and any non-standard tag emit their text content verbatim, with the surrounding tag dropped. To render a custom tag with Markdown semantics, alias it with tagOverrides:
htmlToMarkdown('<my-em>hi</my-em>', { tagOverrides: { 'my-em': 'em' } })
interface TagOverride {
enter?: string
exit?: string
spacing?: number[]
isInline?: boolean
isSelfClosing?: boolean
collapsesInnerWhiteSpace?: boolean
alias?: string
}
Example:
const markdown = htmlToMarkdown(html, {
tagOverrides: {
'x-heading': 'h2',
'callout': {
enter: '> **Note:** ',
exit: '',
spacing: [2, 2],
},
},
})
Output is GitHub Flavored Markdown. Mdream emits a fixed GFM dialect tuned for LLM input: ATX headings (#), fenced code blocks (```), - bullets, _ emphasis, ** strong, --- horizontal rules, inline links. These are not configurable. For simple delimiter swaps you can use tagOverrides:
htmlToMarkdown(html, {
tagOverrides: {
em: { enter: '*', exit: '*', isInline: true },
strong: { enter: '__', exit: '__', isInline: true },
hr: { enter: '* * *', exit: '' },
},
})
Structural style differences (setext headings, indented code blocks, reference-style links, ~~~ fences, dynamic list markers) are out of scope. If you need turndown-style configurability, use turndown. If you have a use case for these in mdream, please open an issue.
FilterOptions
interface FilterOptions {
include?: (string | number)[]
exclude?: (string | number)[]
processChildren?: boolean
}
Presets
Minimal Preset
The minimal preset enables the following plugins together:
- frontmatter: Extracts metadata from HTML
<head> into YAML frontmatter
- isolateMain: Extracts the main content area, skipping navigation, headers, and footers
- tailwind: Converts Tailwind utility classes to Markdown formatting
- filter: Excludes
form, fieldset, object, embed, footer, aside, iframe, input, textarea, select, button, nav
- clean: All post-processing cleanup enabled
Rust engine:
import { htmlToMarkdown } from 'mdream'
const markdown = htmlToMarkdown(html, {
origin: 'https://example.com',
minimal: true,
})
JS engine (using withMinimalPreset):
import { htmlToMarkdown } from '@mdream/js'
import { withMinimalPreset } from '@mdream/js/preset/minimal'
const markdown = htmlToMarkdown(html, withMinimalPreset({
origin: 'https://example.com',
}))
withMinimalPreset() returns an EngineOptions object with all plugin defaults applied. You can override individual plugins:
const markdown = htmlToMarkdown(html, withMinimalPreset({
plugins: {
frontmatter: false,
filter: { exclude: ['nav'] },
},
}))
Built-in Plugins
All built-in plugins work with both the Rust and JS engines through declarative configuration.
Frontmatter Plugin
Extracts metadata from the HTML <head> element and generates YAML frontmatter.
Extracted fields by default: title, description, keywords, author, date, og:title, og:description, twitter:title, twitter:description.
htmlToMarkdown(html, { frontmatter: true })
htmlToMarkdown(html, {
frontmatter: (fm) => {
console.log(fm.title)
console.log(fm.description)
},
})
htmlToMarkdown(html, {
frontmatter: {
additionalFields: { source: 'https://example.com' },
metaFields: ['robots', 'viewport'],
onExtract: fm => console.log(fm),
},
})
Output example:
---
title: My Page Title
meta:
description: A page description
'og:title': My Page Title
---
Isolate Main Plugin
Isolates the main content area using the following priority:
- If an explicit
<main> element exists (within 5 depth levels), use its content exclusively
- Otherwise, find content between the first header tag (
h1-h6) and the first <footer>
- Headings inside
<header> tags are skipped during fallback detection
- The
<head> section is always passed through for other plugins (e.g., frontmatter)
htmlToMarkdown(html, { isolateMain: true })
Tailwind Plugin
Converts Tailwind CSS utility classes to semantic Markdown formatting:
font-bold, font-semibold, font-medium, font-extrabold, font-black | **bold** |
italic, font-italic | *italic* |
line-through | ~~strikethrough~~ |
hidden, invisible | Content removed |
absolute, fixed, sticky | Content removed |
Supports responsive breakpoint prefixes (sm:, md:, lg:, xl:, 2xl:) with mobile-first resolution.
htmlToMarkdown(html, { tailwind: true })
Filter Plugin
Filters HTML elements by CSS selectors, tag names, or TAG_* constants.
htmlToMarkdown(html, {
filter: {
exclude: ['nav', '#sidebar', '.footer', 'aside'],
},
})
htmlToMarkdown(html, {
filter: {
include: ['article', 'main'],
},
})
The JS engine also supports TAG_* integer constants for filtering:
import { TAG_FOOTER, TAG_NAV } from '@mdream/js'
htmlToMarkdown(html, {
plugins: {
filter: { exclude: [TAG_NAV, TAG_FOOTER] },
},
})
Elements with style="position: absolute" or style="position: fixed" are also automatically excluded when the filter plugin is active.
Extracts elements matching CSS selectors during conversion. Callbacks receive the matched element with its accumulated text content and attributes.
htmlToMarkdown(html, {
extraction: {
'h2': (el) => {
console.log('Heading:', el.textContent)
},
'img[alt]': (el) => {
console.log('Image:', el.attributes.src, el.attributes.alt)
},
'a[href]': (el) => {
console.log('Link:', el.textContent, el.attributes.href)
},
},
})
The ExtractedElement interface:
interface ExtractedElement {
selector: string
tagName: string
textContent: string
attributes: Record<string, string>
}
Hook-Based Plugins (JS Engine)
The JS engine (@mdream/js) supports imperative hook-based plugins for custom transform logic. These allow you to intercept and modify the conversion pipeline at multiple stages.
import { htmlToMarkdown } from '@mdream/js'
import { createPlugin } from '@mdream/js/plugins'
const myPlugin = createPlugin({
onNodeEnter(node) {
if (node.name === 'h1')
return '** '
},
processTextNode(textNode) {
if (textNode.parent?.attributes?.id === 'highlight') {
return { content: `**${textNode.value}**`, skip: false }
}
},
})
const markdown = htmlToMarkdown(html, { hooks: [myPlugin] })
Plugin Hooks
interface TransformPlugin {
beforeNodeProcess?: (
event: NodeEvent,
state: MdreamRuntimeState,
) => undefined | void | { skip: boolean }
onNodeEnter?: (
node: ElementNode,
state: MdreamRuntimeState,
) => string | undefined | void
onNodeExit?: (
node: ElementNode,
state: MdreamRuntimeState,
) => string | undefined | void
processAttributes?: (
node: ElementNode,
state: MdreamRuntimeState,
) => void
processTextNode?: (
node: TextNode,
state: MdreamRuntimeState,
) => { content: string, skip: boolean } | undefined
}
createPlugin()
A typed identity function for creating plugins with full TypeScript inference:
import { createPlugin } from '@mdream/js/plugins'
const plugin = createPlugin({
beforeNodeProcess({ node }) {
if (node.type === 1 && node.attributes?.class?.includes('ad')) {
return { skip: true }
}
},
})
Built-in Plugin Functions (JS Engine)
The following plugin factory functions are available from @mdream/js/plugins:
import {
createPlugin,
extractionCollectorPlugin,
extractionPlugin,
filterPlugin,
frontmatterPlugin,
isolateMainPlugin,
tailwindPlugin,
} from '@mdream/js/plugins'
Markdown Splitting (JS Engine)
Split HTML into Markdown chunks during conversion. Compatible with the LangChain Document structure.
Available from @mdream/js/splitter.
Basic Chunking
import { TAG_H2 } from '@mdream/js'
import { htmlToMarkdownSplitChunks } from '@mdream/js/splitter'
const html = `
<h1>Documentation</h1>
<h2>Installation</h2>
<p>Install via npm...</p>
<h2>Usage</h2>
<p>Use it like this...</p>
`
const chunks = htmlToMarkdownSplitChunks(html, {
headersToSplitOn: [TAG_H2],
chunkSize: 1000,
chunkOverlap: 200,
stripHeaders: true,
})
chunks.forEach((chunk) => {
console.log(chunk.content)
console.log(chunk.metadata.headers)
console.log(chunk.metadata.code)
console.log(chunk.metadata.loc)
})
Streaming Chunks (Memory Efficient)
For large documents, use the generator version to process chunks one at a time:
import { htmlToMarkdownSplitChunksStream } from '@mdream/js/splitter'
for (const chunk of htmlToMarkdownSplitChunksStream(html, options)) {
await processChunk(chunk)
if (foundTarget)
break
}
Splitter Options
interface SplitterOptions {
headersToSplitOn?: number[]
chunkSize?: number
chunkOverlap?: number
lengthFunction?: (text: string) => number
stripHeaders?: boolean
returnEachLine?: boolean
keepSeparator?: boolean
origin?: string
plugins?: BuiltinPlugins
hooks?: TransformPlugin[]
clean?: boolean | CleanOptions
}
Chunk Metadata
Each chunk includes metadata for context:
interface MarkdownChunk {
content: string
metadata: {
headers?: Record<string, string>
code?: string
loc?: {
lines: { from: number, to: number }
}
}
}
Use with Presets
Combine splitting with presets:
import { TAG_H2 } from '@mdream/js'
import { withMinimalPreset } from '@mdream/js/preset/minimal'
import { htmlToMarkdownSplitChunks } from '@mdream/js/splitter'
const chunks = htmlToMarkdownSplitChunks(html, withMinimalPreset({
headersToSplitOn: [TAG_H2],
chunkSize: 500,
origin: 'https://example.com',
}))
Content Negotiation
The @mdream/js/negotiate module provides HTTP content negotiation utilities for serving Markdown to LLM clients:
import { parseAcceptHeader, shouldServeMarkdown } from '@mdream/js/negotiate'
const serveMarkdown = shouldServeMarkdown(
request.headers.get('accept'),
request.headers.get('sec-fetch-dest'),
)
if (serveMarkdown) {
return new Response(markdown, {
headers: { 'Content-Type': 'text/markdown' },
})
}
shouldServeMarkdown() uses Accept header quality weights and position ordering. It returns true when text/markdown or text/plain has higher priority than text/html. Browser navigation requests (sec-fetch-dest: document) always return false.
Pure HTML Parser (JS Engine)
If you only need to parse HTML into a DOM-like event stream without converting to Markdown, use parseHtml from the JS engine:
import { parseHtml } from '@mdream/js/parse'
const html = '<div><h1>Title</h1><p>Content</p></div>'
const { events, remainingHtml } = parseHtml(html)
events.forEach((event) => {
if (event.type === 0 && event.node.type === 1) {
console.log('Entering element:', event.node.name)
}
})
The parser provides:
- Pure AST event stream with no markdown generation overhead
- Enter/exit events for each element and text node
- Plugin support during parsing
- Streaming compatible via
parseHtmlStream()
CLI Usage
Mdream provides a CLI that works with Unix pipes.
Pipe site to Markdown:
curl -s https://en.wikipedia.org/wiki/Markdown \
| npx mdream --origin https://en.wikipedia.org --preset minimal \
| tee output.md
Local file to Markdown:
cat index.html \
| npx mdream --preset minimal \
| tee output.md
CLI Options
--origin <url> | Base URL for resolving relative links and images |
--preset minimal | Enable the minimal preset |
--wrap-width <n> | Hard-wrap prose at n characters (code, tables, and headings are never wrapped) |
-h, --help | Display help information |
The CLI reads HTML from stdin and writes Markdown to stdout. It uses the streaming API internally.
Browser and Edge Usage
Edge / Cloudflare Workers
For edge runtimes (Cloudflare Workers, Vercel Edge), mdream automatically selects the WASM build via export conditions (workerd, edge-light). Both htmlToMarkdown and streamHtmlToMarkdown are available:
import { htmlToMarkdown, streamHtmlToMarkdown } from 'mdream'
const markdown = htmlToMarkdown('<h1>Hello World</h1>')
const response = await fetch('https://example.com')
for await (const chunk of streamHtmlToMarkdown(response.body)) {
}
You can also import the edge entry point directly:
import { htmlToMarkdown } from 'mdream/worker'
The mdream/worker entry provides an async API since WASM must be initialized first:
import { htmlToMarkdown, initWorker, terminateWorker } from 'mdream/worker'
await initWorker('https://cdn.example.com/mdream_edge_bg.wasm')
const markdown = await htmlToMarkdown('<h1>Hello</h1>')
terminateWorker()
Browser CDN (IIFE)
Use mdream directly via CDN with no build step. Call init() once to load the WASM binary, then use htmlToMarkdown() synchronously.
<script src="https://unpkg.com/mdream/dist/iife.js"></script>
<script>
await window.mdream.init()
const markdown = window.mdream.htmlToMarkdown('<h1>Hello</h1><p>World</p>')
console.log(markdown)
</script>
You can pass a custom WASM URL or ArrayBuffer to init():
await window.mdream.init('https://cdn.example.com/mdream_edge_bg.wasm')
const wasmBytes = await fetch('/wasm/mdream_edge_bg.wasm').then(r => r.arrayBuffer())
await window.mdream.init(wasmBytes)
CDN Options:
- unpkg:
https://unpkg.com/mdream/dist/iife.js
- jsDelivr:
https://cdn.jsdelivr.net/npm/mdream/dist/iife.js
Web Worker
For browser environments, mdream/worker runs conversions off the main thread using a Web Worker:
import { htmlToMarkdown, initWorker, terminateWorker } from 'mdream/worker'
await initWorker('/path/to/mdream_edge_bg.wasm')
const markdown = await htmlToMarkdown('<h1>Hello</h1>')
terminateWorker()
For advanced content extraction (article detection, boilerplate removal), use @mozilla/readability before mdream:
import { Readability } from '@mozilla/readability'
import { JSDOM } from 'jsdom'
import { htmlToMarkdown } from 'mdream'
const dom = new JSDOM(html, { url: 'https://example.com' })
const article = new Readability(dom.window.document).parse()
if (article) {
const markdown = htmlToMarkdown(article.content)
}
llms.txt Generation
For llms.txt artifact generation, use the separate @mdream/llms-txt package. It accepts pre-converted Markdown and generates llms.txt and llms-full.txt artifacts.
import { generateLlmsTxtArtifacts } from '@mdream/llms-txt'
import { htmlToMarkdown } from 'mdream'
const result = await generateLlmsTxtArtifacts({
files: [
{ title: 'Home', url: '/', content: htmlToMarkdown(homeHtml) },
{ title: 'About', url: '/about', content: htmlToMarkdown(aboutHtml) },
],
siteName: 'My Site',
origin: 'https://example.com',
generateFull: true,
})
console.log(result.llmsTxt)
console.log(result.llmsFullTxt)
Related Packages
License
Licensed under the MIT license.