Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

mdka

mdka is a HTML to Markdown written in Rust. “ka” means “化 (か)” pointing to conversion.

It aims to strike a practical balance between conversion quality and runtime efficiency — readable output from real-world HTML, without sacrificing speed or memory.

At a Glance

What you give itWhat you get back
Any HTML string — a full page, a snippet, CMS output, SPA-rendered DOMClean, readable Markdown
A list of HTML filesParallel Markdown output via rayon
A conversion mode (minimal, semantic, …)Pre-processed output tuned for your use case

Key Properties

  • Parser foundation: scraper, which is built on html5ever — the same battle-tested parser used by the Servo browser engine. It handles malformed, deeply-nested, and real-world HTML gracefully.
  • Crash-resistant: a non-recursive DFS traversal means even 10,000 levels of nesting will not overflow the stack.
  • Configurable: five conversion modes let you tune the pre-processing pipeline — from noise-free LLM input to lossless archiving.
  • Multi-language: available as a Rust library, a Node.js package (napi-rs), and a Python package (PyO3).

When to Choose mdka

mdka is a good fit if you need:

  • Stable, predictable output from diverse HTML sources (CMS, SPA, scraped pages)
  • Mode-based pre-processing to strip navigation, preserve ARIA, or retain attributes
  • Memory efficiency at scale (bulk file conversion, streaming pipelines)
  • Multi-language access from a single underlying Rust implementation

If raw speed on simple, well-formed HTML is the only concern, a streaming rewriter will be faster.

Quick Navigation

Installation

As a Rust Library

Add mdka to your Cargo.toml:

[dependencies]
mdka = "2"

That is the only step. mdka has no system dependencies.

Minimum Supported Rust Version: 1.88 (2024 Edition)

As a CLI Binary

Download a prebuilt binary — no Rust toolchain needed — from the latest release. Which platforms have one, and the exact archive name for each, is listed in the README’s Quick Start; that table is the single source, so it is linked here rather than repeated.

Or install directly with cargo, which builds for whatever platform you are on:

cargo install mdka-cli

Or build from source using the mdka-cli crate in the workspace:

git clone https://github.com/nabbisen/mdka-rs
cd mdka-rs
cargo build --release -p mdka-cli
# Binary: ./target/release/mdka

As a Node.js Package

npm install mdka
# or
yarn add mdka

Requires Node.js 16 or later.

Prebuilt native bindings are published for three platforms, resolved automatically through optionalDependencies:

PlatformPackage
Linux x64 (glibc)@mdka/lib-linux-x64-gnu
macOS Apple Silicon@mdka/lib-darwin-arm64
Windows x64 (MSVC)@mdka/lib-win32-x64-msvc

On any other platform — musl, Linux arm64, macOS Intel, Windows ARM — there is no fallback inside the package. The published tarball contains four files (index.js, index.d.ts, package.json, README.md) and no Rust source, so npm run build cannot work from an installed copy: there is nothing to build, and the napi toolchain is a development dependency that is not installed for consumers.

What does work on those platforms:

  • Build the binding from the repository — clone nabbisen/mdka-rs, then cd node && npm install && npm run build, which needs a Rust toolchain. This produces a local binding; it does not make npm install mdka work elsewhere.
  • Use the CLI instead: cargo install mdka-cli, which builds from source for whatever platform you are on.
  • Use the Rust crate directly, if the surrounding project allows it.

As a Python Package

pip install mdka

Requires CPython 3.10 or later.

Pre-built wheels are published for CPython on these platforms. Each wheel uses Python’s stable ABI, so one wheel serves every CPython from 3.10 upward, including versions released after it:

PlatformWheel tag
Linux x86_64, glibc 2.17 or latermanylinux_2_17_x86_64
Linux aarch64, glibc 2.17 or latermanylinux_2_17_aarch64
Linux x86_64, musl 1.2 or later (e.g. Alpine)musllinux_1_2_x86_64
Linux aarch64, musl 1.2 or latermusllinux_1_2_aarch64
Windows x64win_amd64
macOS on Apple silicon, 11.0 or latermacosx_11_0_arm64

Other interpreters — PyPy, free-threaded CPython — and other platforms have no wheel. pip install mdka there falls back to the source distribution, which needs a Rust toolchain to build. On free-threaded CPython that build works, but mdka requires the GIL: Python re-enables it when mdka is imported, and emits a RuntimeWarning saying so. To build from source on purpose: pip install mdka --no-binary mdka with Rust installed.

These apply from mdka 2.3.0. On CPython 3.8 or 3.9, pip does not offer 2.3.0 or later and installs the latest 2.2.x release instead.

Usage & Examples

Choose the section for your environment:

  • Rust — integrate directly into a Rust project
  • Node.js — use from JavaScript or TypeScript
  • Python — use from Python
  • CLI — use from the command line

All four share the same underlying conversion engine, so results are consistent across languages.

Usage — Rust

Basic Conversion

use mdka::html_to_markdown;

fn main() {
    let html = r#"
        <h1>Getting Started</h1>
        <p>mdka converts <strong>HTML</strong> to <em>Markdown</em>.</p>
        <ul>
            <li>Fast</li>
            <li>Configurable</li>
            <li>Crash-resistant</li>
        </ul>
    "#;

    let md = html_to_markdown(html);
    println!("{md}");
}

Output:

# Getting Started

mdka converts **HTML** to *Markdown*.

- Fast
- Configurable
- Crash-resistant

Conversion with Options

Use html_to_markdown_with to control the conversion pipeline via ConversionOptions.

use mdka::{html_to_markdown_with};
use mdka::options::{ConversionMode, ConversionOptions};

// Strip navigation and extract body text — good for LLM input
let mut opts = ConversionOptions::for_mode(ConversionMode::Minimal);
opts.drop_interactive_shell = true;

let html = r#"
    <header><nav><a href="/">Home</a></nav></header>
    <main>
        <article>
            <h1>Article Title</h1>
            <p>The main content of the page.</p>
        </article>
    </main>
    <footer>Copyright 2025</footer>
"#;

let md = html_to_markdown_with(html, &opts);
assert!(md.contains("# Article Title"));
assert!(!md.contains("Home"));       // nav removed
assert!(!md.contains("Copyright"));  // footer removed

Converting a Single File

use mdka::html_file_to_markdown;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    // Output goes to the same directory as the input: page.html → page.md
    let result = html_file_to_markdown("page.html", None::<&str>)?;
    println!("{} → {}", result.src.display(), result.dest.display());

    // Output goes to a specific directory
    let result = html_file_to_markdown("page.html", Some("out/"))?;
    Ok(())
}

Bulk Parallel Conversion

use mdka::html_files_to_markdown;
use std::path::Path;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let files = vec!["a.html", "b.html", "c.html"];
    let out_dir = Path::new("out/");
    std::fs::create_dir_all(out_dir)?;

    for (src, result) in html_files_to_markdown(&files, out_dir) {
        match result {
            Ok(dest) => println!("{} → {}", src, dest.display()),
            Err(e)   => eprintln!("Error: {src}: {e}"),
        }
    }
    Ok(())
}

Conversion runs in parallel using rayon. The number of threads defaults to the number of logical CPU cores.

Bulk Conversion with Options

use mdka::{html_files_to_markdown_with};
use mdka::options::{ConversionMode, ConversionOptions};
use std::path::Path;

let opts = ConversionOptions::for_mode(ConversionMode::Semantic);
let files = vec!["a.html", "b.html"];
let results = html_files_to_markdown_with(&files, Path::new("out/"), &opts);

Conversion Modes at a Glance

ModeBest for
BalancedGeneral use; default
StrictDebugging, diff comparison
MinimalLLM pre-processing, compression
SemanticSPA content, accessibility-aware output
PreserveArchiving, audit trails

See Conversion Modes for full details.

Error Handling

use mdka::{html_file_to_markdown, MdkaError};

match html_file_to_markdown("missing.html", None::<&str>) {
    Ok(result) => println!("→ {}", result.dest.display()),
    Err(MdkaError::Io(e)) => eprintln!("IO error: {e}"),
}

MdkaError currently has one variant: Io, wrapping std::io::Error. html_to_markdown and html_to_markdown_with are infallible — they always return a String and never panic on any input, no matter how malformed.

Usage — Node.js

Installation

npm install mdka

Basic Conversion

const { htmlToMarkdown } = require('mdka')

const html = `
  <h1>Hello</h1>
  <p>mdka converts <strong>HTML</strong> to <em>Markdown</em>.</p>
`
const md = htmlToMarkdown(html)
console.log(md)
// # Hello
//
// mdka converts **HTML** to *Markdown*.

Async Conversion

htmlToMarkdownAsync offloads work to a Rust thread pool, keeping the Node.js event loop free:

const { htmlToMarkdownAsync } = require('mdka')

const html = '<h1>Hello</h1>'
const pages = [
  { html: '<h1>A</h1>' },
  { html: '<p>B</p>' },
]

async function main() {
  const md = await htmlToMarkdownAsync(html)

  // Concurrent conversion of many pages
  const results = await Promise.all(pages.map(p => htmlToMarkdownAsync(p.html)))
  console.log(md, results)
}
main()

Conversion with Options

const { htmlToMarkdownWith, htmlToMarkdownWithAsync } = require('mdka')

const html = '<nav>menu</nav><h1>Title</h1><p>Body</p>'

// Strip nav/header/footer — useful for content extraction
const md = htmlToMarkdownWith(html, {
  mode: 'minimal',
  dropInteractiveShell: true,
})

// Async version
async function main() {
  const mdAsync = await htmlToMarkdownWithAsync(html, { mode: 'semantic' })
  console.log(mdAsync)
}
main()

Available mode strings: "balanced" (default), "strict", "minimal", "semantic", "preserve".

Single File Conversion

const { htmlFileToMarkdown, htmlFileToMarkdownWith } = require('mdka')

async function main() {
  // Output to same directory: page.html → page.md
  const sameDir = await htmlFileToMarkdown('page.html')
  console.log(`${sameDir.src} → ${sameDir.dest}`)

  // Output to specific directory
  const outDir = await htmlFileToMarkdown('page.html', 'out/')

  // With options
  const withOpts = await htmlFileToMarkdownWith('page.html', 'out/', {
    mode: 'minimal',
    dropInteractiveShell: true,
  })
}
main()

Bulk Parallel Conversion

const { htmlFilesToMarkdown, htmlFilesToMarkdownWith } = require('mdka')

const files = ['a.html', 'b.html', 'c.html']

async function main() {
  const results = await htmlFilesToMarkdown(files, 'out/')

  for (const r of results) {
    if (r.error) console.error(`${r.src}: ${r.error}`)
    else         console.log(`${r.src} → ${r.dest}`)
  }

  // With options
  const withOpts = await htmlFilesToMarkdownWith(files, 'out/', {
    mode: 'semantic',
  })
}
main()

Two inputs whose output names collide — a/index.html and b/index.html both becoming out/index.md — are not both converted. The first in the array wins and each later one comes back with error set, rather than silently overwriting.

Three of the deprecated attribute options are accepted here and have no effect: preserveClasses, preserveDataAttrs and preserveAriaAttrs. Markdown has no attribute syntax to carry them into.

Passing any of the three to a synchronous function emits a DeprecationWarning. By default the call still succeeds — but under node --throw-deprecation it throws. Remove the option; it changes nothing. The Async functions cannot emit the warning at all, so silence from them is not evidence that no deprecated option is in use. While migrating, suppress mdka’s notices narrowly:

const { htmlToMarkdownWith } = require('mdka')

// Drop only mdka's own deprecation notices; everything else passes through.
const emitWarning = process.emitWarning
process.emitWarning = function (warning, ...rest) {
  const message = typeof warning === 'string' ? warning : warning?.message
  if (message?.startsWith('mdka: `')) return
  return emitWarning.call(process, warning, ...rest)
}

const md = htmlToMarkdownWith('<p>x</p>', { preserveClasses: true })

This still works under --throw-deprecation, and other deprecation warnings throw as before.

The other two — preserveUnknownAttrs and dropPresentationAttrs — exist on the Rust ConversionOptions but are not fields of JsConversionOptions, which has seven. In TypeScript, passing either is a compile error:

TS2353: Object literal may only specify known properties, and
'preserveUnknownAttrs' does not exist in type 'JsConversionOptions'.

Use mode.

TypeScript

Type definitions are bundled. No @types/ package is needed:

import {
  htmlToMarkdown,
  htmlToMarkdownWith,
  htmlToMarkdownAsync,
  htmlFileToMarkdown,
  htmlFilesToMarkdown,
  type JsConversionOptions,
  type ConvertResult,
} from 'mdka'

const html: string = '<h1>Title</h1>'

const opts: JsConversionOptions = {
  mode: 'minimal',
  dropInteractiveShell: true,
}
const md: string = htmlToMarkdownWith(html, opts)

The options type is exported as JsConversionOptions, not ConversionOptions — the name comes from the napi-rs binding rather than from the Rust ConversionOptions it mirrors.

Usage — Python

Installation

pip install mdka

Basic Conversion

import mdka

html = """
<h1>Hello</h1>
<p>mdka converts <strong>HTML</strong> to <em>Markdown</em>.</p>
"""

md = mdka.html_to_markdown(html)
print(md)
# # Hello
#
# mdka converts **HTML** to *Markdown*.

Conversion with Options

import mdka

html = "<nav>menu</nav><h1>Title</h1><p>Body</p>"

# Strip nav/header/footer — useful for LLM pre-processing
md = mdka.html_to_markdown_with(
    html,
    mode=mdka.ConversionMode.Minimal,
    drop_interactive_shell=True,
)

# Favour semantic structure — for SPAs and accessibility-aware output
md = mdka.html_to_markdown_with(
    html,
    mode=mdka.ConversionMode.Semantic,
)

Three of the deprecated attribute options are accepted here and have no effect: preserve_classes, preserve_data_attrs and preserve_aria_attrs. Markdown has no attribute syntax to carry them into.

Passing any of the three emits a DeprecationWarning. By default that is only a warning and the call succeeds — but under warnings-as-errors it fails: python -W error, or pytest with filterwarnings = error, turns the call into a raised DeprecationWarning. Remove the argument; it changes nothing. While migrating, suppress it narrowly — for mdka’s notices only, for one block:

import warnings
import mdka

with warnings.catch_warnings():
    warnings.filterwarnings(
        "ignore",
        category=DeprecationWarning,
        message=r"mdka: `preserve_",
    )
    md = mdka.html_to_markdown_with("<p>x</p>", preserve_classes=True)

This still works under python -W error, and leaves every other library’s deprecation warnings raising as before.

The other two — preserve_unknown_attrs and drop_presentation_attrs — exist on the Rust ConversionOptions but are not exposed by this binding at all. Passing either raises:

TypeError: html_to_markdown_with() got an unexpected keyword argument
'preserve_unknown_attrs'

Use mode to influence the output.

Available modes: ConversionMode.Balanced (default), Strict, Minimal, Semantic, Preserve.

Parallel Batch Conversion (GIL released)

html_to_markdown_many releases the GIL and uses rayon for parallel conversion:

import mdka

pages = ["<h1>A</h1>", "<p>B</p>", "<ul><li>C</li></ul>"]
results = mdka.html_to_markdown_many(pages)
# ['# A\n', 'B\n', '- C\n']

This is faster than calling html_to_markdown in a Python loop for large batches.

Single File Conversion

import mdka

# Output to same directory: page.html → page.md
result = mdka.html_file_to_markdown("page.html")
print(f"{result.src} → {result.dest}")

# Output to a specific directory
result = mdka.html_file_to_markdown("page.html", "out/")

# With options
result = mdka.html_file_to_markdown(
    "page.html",
    "out/",
    mode=mdka.ConversionMode.Minimal,
    drop_interactive_shell=True,
)

Bulk File Conversion

import mdka

files = ["a.html", "b.html", "c.html"]
results = mdka.html_files_to_markdown(files, "out/")

for r in results:
    if r.ok:
        print(f"{r.src} → {r.dest}")
    else:
        print(f"Error: {r.src}: {r.error}")

Error Handling

import mdka

try:
    result = mdka.html_file_to_markdown("missing.html")
except mdka.MdkaError as e:
    print(f"Conversion failed: {e}")

MdkaError is raised for IO errors (file not found, permission denied, etc.). html_to_markdown and html_to_markdown_with are always safe to call — they never raise exceptions regardless of input quality.

Type Annotations

mdka does not ship type information. There is no py.typed marker and no .pyi stubs, so a type checker treats every symbol below as Any. mypy will say so directly:

error: Skipping analyzing "mdka": module is installed, but missing library
stubs or py.typed marker  [import-untyped]

That message is accurate, and it is better than the alternative. Every public symbol is implemented in Rust and exposed through a compiled extension module, which a type checker cannot read signatures from. Shipping a bare py.typed would silence the warning without providing anything to check — the symbols would still resolve as Any, and a genuinely wrong annotation would then pass silently. Typed stubs are the real fix and are not written yet.

Until then, the signatures are:

from mdka import (
    html_to_markdown,          # (html: str) -> str
    html_to_markdown_with,     # (html: str, mode=..., **flags) -> str
    html_to_markdown_many,     # (html_list: list[str]) -> list[str]
    html_file_to_markdown,     # (path, out_dir=None, ...) -> ConvertResult
    html_files_to_markdown,    # (paths, out_dir, ...) -> list[BulkConvertResult]
    ConversionMode,            # enum
    ConvertResult,             # dataclass: src, dest (str)
    BulkConvertResult,         # dataclass: src, dest?, error?, ok
    MdkaError,                 # exception
)

Usage — CLI

The mdka command-line tool is provided by the mdka-cli crate.

Quick Reference

mdka [OPTIONS] [FILE...]

Run mdka --help to see the full option list with descriptions.

Common Patterns

Convert from stdin:

echo '<h1>Hello</h1>' | mdka
curl https://example.com | mdka

Convert a single file (output goes to the same directory):

mdka page.html          # → page.md

Convert to a specific directory:

mdka -o out/ page.html  # → out/page.md

Bulk conversion (-o is required for multiple files):

mdka -o out/ docs/*.html

Choose a conversion mode:

mdka --mode minimal --drop-shell page.html   # extract body text
mdka --mode preserve -o archive/ *.html      # maximum fidelity

All Options

This table mirrors mdka --help. If the two ever disagree, --help is the truth — it is generated from the binary you are running.

FlagDescription
-o, --output <DIR>Output directory (defaults to the input’s directory)
-m, --mode <MODE>Conversion mode: balanced (default) · strict · minimal · semantic · preserve
--preserve-idsKeep id attributes
--preserve-classesDeprecated, no effect. Markdown has no attribute syntax
--preserve-dataDeprecated, no effect. Same reason
--preserve-ariaDeprecated, no effect. Same reason
--drop-shellDrop nav, header, footer, aside
--unwrap-wrappersUnwrap div, span, section, article, main that carry no meaning
-h, --helpShow this help
-V, --versionShow the version
--End of options; everything after is a path

An unrecognised --prefixed argument is rejected rather than treated as a filename. If you genuinely have a file whose name begins with -, put -- before it: mdka -- -weird.html.

The three deprecated flags are still accepted, so existing command lines keep working, but they change nothing about the output. They are documented here only so that you can recognise them; do not reach for them expecting an effect. See ConversionOptions.

For full mode descriptions see Conversion Modes.

API Reference

mdka exposes a small, focused public API. The table below shows the complete surface — every function and type you need, nothing you don’t.

Functions

FunctionLanguageDescription
html_to_markdownRustConvert HTML string → Markdown (default mode)
html_to_markdown_withRustConvert with explicit ConversionOptions
html_file_to_markdownRustConvert one file; output alongside input or to out_dir
html_file_to_markdown_withRustSingle file with options
html_files_to_markdownRustParallel bulk conversion (rayon)
html_files_to_markdown_withRustBulk with options

Types

TypeDescription
ConversionModeEnum: Balanced · Strict · Minimal · Semantic · Preserve
ConversionOptionsControls pre-processing per-call; built via for_mode()
ConvertResultReturned by single-file functions: src + dest paths
MdkaErrorThe only error type: wraps std::io::Error

Guarantees

  • html_to_markdown and html_to_markdown_with never panic. They accept any &str, including empty strings, binary garbage, or deeply nested HTML.
  • File functions propagate IO errors via Result<_, MdkaError>.
  • Output is always valid UTF-8.
  • Output always ends with a single newline when the input produces any content.

Core Functions

html_to_markdown

pub fn html_to_markdown(html: &str) -> String

Converts an HTML string to Markdown using the default Balanced mode.

Input: Any valid or malformed HTML string. Empty strings are accepted.
Output: A Markdown string. Always ends with \n if the input produced any content.
Errors: None — this function is infallible.

let md = mdka::html_to_markdown("<h1>Hello</h1>");
assert_eq!(md, "# Hello\n");

html_to_markdown_with

pub fn html_to_markdown_with(html: &str, opts: &ConversionOptions) -> String

Same as html_to_markdown, but accepts a ConversionOptions value that controls pre-processing and conversion behaviour.

Input: Any HTML string + a ConversionOptions value.
Output: Markdown string.
Errors: None.

use mdka::options::{ConversionMode, ConversionOptions};

let mut opts = ConversionOptions::for_mode(ConversionMode::Minimal);
opts.drop_interactive_shell = true;
let md = mdka::html_to_markdown_with(html, &opts);

html_file_to_markdown

pub fn html_file_to_markdown(
    path: impl AsRef<Path>,
    out_dir: Option<impl AsRef<Path>>,
) -> Result<ConvertResult, MdkaError>

Reads one HTML file, converts it, and writes a .md file.

path: Path to the input .html file.
out_dir:

  • None → the .md file is written alongside the input (same directory, stem unchanged).
  • Some(dir) → the .md file is written into dir. The directory is created automatically if it does not exist.

Returns: ConvertResult with the resolved src and dest paths.
Errors: MdkaError::Io if the file cannot be read or the output cannot be written.

fn main() -> Result<(), Box<dyn std::error::Error>> {
    // page.html → page.md in the same folder
    let r = mdka::html_file_to_markdown("page.html", None::<&str>)?;

    // page.html → out/page.md
    let r = mdka::html_file_to_markdown("page.html", Some("out/"))?;
    println!("{} → {}", r.src.display(), r.dest.display());
    Ok(())
}

html_file_to_markdown_with

pub fn html_file_to_markdown_with(
    path: impl AsRef<Path>,
    out_dir: Option<impl AsRef<Path>>,
    opts: &ConversionOptions,
) -> Result<ConvertResult, MdkaError>

Same as html_file_to_markdown, but applies the given ConversionOptions.


html_files_to_markdown

pub fn html_files_to_markdown<'a, P>(
    paths: &'a [P],
    out_dir: &Path,
) -> Vec<(&'a P, Result<PathBuf, MdkaError>)>
where
    P: AsRef<Path> + Sync,

Converts multiple HTML files in parallel using rayon.

paths: Slice of paths to input HTML files.
out_dir: Directory for all output .md files. Created automatically if it does not exist, as with the single-file variants.
Returns: A Vec of (input_path, Result<output_path, error>) pairs in the same order as paths. Each element represents the outcome for one file independently.

use std::path::Path;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let files = vec!["a.html", "b.html", "c.html"];
    std::fs::create_dir_all("out/")?;

    for (src, result) in mdka::html_files_to_markdown(&files, Path::new("out/")) {
        match result {
            Ok(dest) => println!("{} → {}", src, dest.display()),
            Err(e)   => eprintln!("{src}: {e}"),
        }
    }
    Ok(())
}

html_files_to_markdown_with

pub fn html_files_to_markdown_with<'a, P>(
    paths: &'a [P],
    out_dir: &Path,
    opts: &ConversionOptions,
) -> Vec<(&'a P, Result<PathBuf, MdkaError>)>
where
    P: AsRef<Path> + Sync,

Same as html_files_to_markdown, but applies the given ConversionOptions to every file.


ConvertResult

pub struct ConvertResult {
    pub src:  PathBuf,
    pub dest: PathBuf,
}

Returned by the single-file functions. Both fields are absolute or relative paths depending on how path was passed in.

Note: The bulk functions (html_files_to_markdown*) return (&P, Result<PathBuf, MdkaError>) tuples rather than ConvertResult, because individual files within a batch may fail independently.

Conversion Modes

A conversion mode selects a preset of ConversionOptions fields. mdka reads these fields directly during its single-pass DOM traversal — there is no separate pre-processing stage.

Overview

ModeDefault?
Balanced✅ Yes
Strict
Minimal
Semantic
Preserve

⚠ Balanced, Strict, and Preserve currently produce identical output

This is the single most important fact on this page.

Balanced, Strict, and Preserve differ from each other only in the defaults of five fields — preserve_classes, preserve_data_attrs, preserve_aria_attrs, preserve_unknown_attrs, drop_presentation_attrs — and those five fields have no effect on output (see Field Reference). The fields that do affect output — preserve_ids, drop_interactive_shell, unwrap_unknown_wrappers — have the same value across all three modes.

This is a statement about today’s behaviour, not a deprecation. The three modes remain distinct API, are not merged, and may diverge again if attribute preservation is ever implemented as a real feature. Proven directly in tests/characterisation_structural.rs (balanced_strict_preserve_are_identical_on_the_wrapper_fixture, balanced_strict_preserve_are_identical_on_an_attribute_rich_element), which run all three through fixtures specifically chosen to discriminate a difference if one existed, rather than inferring identity from fixtures that happen not to distinguish them.

Minimal and Semantic are genuinely distinct from the other three and from each other — Minimal additionally drops shell elements (drop_interactive_shell), and Semantic additionally unwraps generic wrappers (unwrap_unknown_wrappers) without dropping shell elements.


Balanced (default)

What it does today: keeps id attributes (emits anchors), keeps shell elements (nav/header/footer/aside), does not unwrap wrapper elements.

let md = mdka::html_to_markdown(html); // Balanced is the default

Use when: you want the default behaviour without extra configuration.


Strict

Currently identical to Balanced and Preserve — see the notice above. Distinct API, in case attribute preservation becomes a real feature later.

use mdka::options::{ConversionMode, ConversionOptions};

let opts = ConversionOptions::for_mode(ConversionMode::Strict);
let md = mdka::html_to_markdown_with(html, &opts);

Minimal

What it does today: drops shell elements (nav/header/footer/aside and their children), unwraps generic wrapper elements (div/span/section/article/main), does not emit id anchors.

The most aggressive mode for extracting body content — useful for piping into an LLM prompt or a search index, where surrounding navigation chrome and wrapper markup are noise.

let opts = ConversionOptions::for_mode(ConversionMode::Minimal);
let md = mdka::html_to_markdown_with(html, &opts);

Semantic

What it does today: keeps shell elements, unwraps generic wrapper elements, emits id anchors. The one mode that unwraps wrappers without dropping shell elements — useful when you want compact structure but still need navigation landmarks preserved.

let opts = ConversionOptions::for_mode(ConversionMode::Semantic);
let md = mdka::html_to_markdown_with(html, &opts);

Preserve

Currently identical to Balanced and Strict — see the notice above. Distinct API, in case attribute preservation becomes a real feature later.

let opts = ConversionOptions::for_mode(ConversionMode::Preserve);
let md = mdka::html_to_markdown_with(html, &opts);

Choosing a Mode

Want wrappers unwrapped, but keep nav/header/footer?  → Semantic
Want the most aggressive extraction (LLM input, etc.)? → Minimal
Everything else                                        → Balanced (default)

Strict and Preserve are not listed above because they currently behave identically to Balanced — pick Balanced unless you specifically want the distinct API surface for forward compatibility.

ConversionOptions

pub struct ConversionOptions {
    pub mode: ConversionMode,

    // Attribute retention
    pub preserve_ids:             bool,
    pub preserve_classes:         bool,        // deprecated, no effect
    pub preserve_data_attrs:      bool,        // deprecated, no effect
    pub preserve_aria_attrs:      bool,        // deprecated, no effect
    pub preserve_unknown_attrs:   bool,        // deprecated, no effect

    // Structural behaviour
    pub drop_presentation_attrs:  bool,        // deprecated, no effect
    pub drop_interactive_shell:   bool,
    pub unwrap_unknown_wrappers:  bool,
}

ConversionOptions controls the details of how mdka’s single-pass DOM traversal renders Markdown. There is no separate pre-processing stage — the traversal in src/traversal.rs reads these fields directly as it walks the parsed document once. You rarely need to set individual fields — start with a mode and override only what differs from the default for that mode.

Five of the eight fields below have no effect on output and are deprecated as of 2.2.0. Markdown has no attribute syntax, so “preserve this attribute” was never expressible in the output format — see RFC 005 for the full history. They are marked below; nothing is removed, and no output changes if you are currently setting them.

Creating Options

use mdka::options::{ConversionMode, ConversionOptions};

let opts = ConversionOptions::for_mode(ConversionMode::Minimal);

for_mode returns sensible defaults for the chosen mode. See the table below.

Modify fields after creation

let mut opts = ConversionOptions::for_mode(ConversionMode::Balanced);
opts.drop_interactive_shell = true; // also strip nav/header/footer/aside
opts.preserve_ids           = false; // don't emit <a id="…"> anchors

Default

let opts = ConversionOptions::default(); // equivalent to for_mode(Balanced)

Field Defaults by Mode

FieldBalancedStrictMinimalSemanticPreserveEffect
preserve_idsEmits anchors
preserve_classesNone — deprecated
preserve_data_attrsNone — deprecated
preserve_aria_attrsNone — deprecated
preserve_unknown_attrsNone — deprecated
drop_presentation_attrsNone — deprecated
drop_interactive_shellDrops shell elements
unwrap_unknown_wrappersUnwraps wrapper elements

Because the five deprecated fields have no effect, Balanced, Strict, and Preserve currently produce byte-identical output — they differ from each other only in these fields’ defaults. See Conversion Modes for what this means when choosing a mode.

Field Reference

mode

The ConversionMode this options object was built from. Changing mode after construction does not re-apply mode defaults to the other fields — use for_mode() again instead.

preserve_ids

Whether to emit an anchor for elements carrying a non-empty id attribute. When enabled, <h2 id="install">Install</h2> produces:

## <a id="install"></a>Install

The anchor is the element’s leading content, placed after any heading marker, list marker, or blockquote prefix:

InputOutput
<h2 id="x">Text</h2>## <a id="x"></a>Text
<li id="x">Text</li>- <a id="x"></a>Text
<p id="x">Text</p> inside a <blockquote>> <a id="x"></a>Text

Exception: <a> and <pre>. These two elements open their own inline-link capture or code-fence region as part of entering them, so their anchor is emitted before the element instead, to avoid disturbing the link text or code content:

InputOutput
<a id="x" href="/">text</a><a id="x"></a>[text](/)
<pre id="x"><code>y</code></pre><a id="x"></a> on its own line, then the fenced block

An id on a descendant of a link or a code block is deliberately not emitted — an anchor injected into captured link text or into literal code content would corrupt it. <a href="/"><span id="s">Home</span></a> produces [Home](/) with no anchor for s.

The id value is escaped for HTML attribute context (&&amp;, "&quot;) before being written — this is the one place mdka constructs new HTML from an input-derived value, rather than passing existing markup through.

An empty id="" emits nothing. preserve_ids = false emits nothing regardless of id.

preserve_classes, preserve_data_attrs, preserve_aria_attrs, preserve_unknown_attrs, drop_presentation_attrs

No effect on output. Deprecated since 2.2.0. Markdown has no syntax for HTML attributes, so “preserve” or “drop” an attribute was never expressible in the output — these fields described behaviour the format could not represent, and never changed a single byte of Markdown in any released version. See RFC 005 for the analysis. They remain present on the struct and accept any value, and they change nothing about the output.

Setting one is not silent, though: each field is #[deprecated], so it is a compile-time warning. Under -D warnings it is a build failureRUSTFLAGS="-D warnings", or #![deny(warnings)] in your crate. Remove the assignment; it changes nothing. While migrating, allow it narrowly, around the assignment only rather than for the whole crate:

use mdka::options::{ConversionMode, ConversionOptions};

let mut opts = ConversionOptions::for_mode(ConversionMode::Balanced);
#[allow(deprecated)]
{
    opts.preserve_classes = true;
}

This builds under -D warnings.

Attribute preservation is a legitimate feature some Markdown flavours (Pandoc, kramdown) support. If mdka adds it, it will be a new, deliberately designed feature — not a repair of these fields.

drop_interactive_shell

Whether to remove <nav>, <header>, <footer>, and <aside> elements and all their children. Useful for content extraction from full web pages. Enabled by default in Minimal; disabled by default in every other mode.

unwrap_unknown_wrappers

Whether to replace <div>, <span>, <section>, <article>, and <main> with their children, discarding the wrapper tag itself, when unwrap_unknown_wrappers is enabled. Enabled in Minimal and Semantic.

<figure> and <figcaption> are never unwrapped, in any mode — see the Block Elements table for why they’re excluded even though they visually resemble the other wrapper elements.

Error Handling

MdkaError

#[derive(Error, Debug)]
pub enum MdkaError {
    #[error("IO error: {0}")]
    Io(#[from] std::io::Error),
}

MdkaError is the only error type in mdka. It has one variant, Io, which wraps a std::io::Error.

IO errors arise from the file-based functions when:

  • the input file does not exist or is not readable
  • the output directory cannot be created
  • the output file cannot be written

Infallible Functions

html_to_markdown and html_to_markdown_with never fail. They accept any string and return a String. Malformed HTML, empty input, binary-looking content, deeply nested structures — none of these cause a panic or an error.

Pattern Matching

use mdka::{html_file_to_markdown, MdkaError};

match html_file_to_markdown("page.html", None::<&str>) {
    Ok(result)            => println!("→ {}", result.dest.display()),
    Err(MdkaError::Io(e)) => eprintln!("IO error: {e}"),
}

Because there is only one variant today, you can also use ? directly:

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let result = mdka::html_file_to_markdown("page.html", None::<&str>)?;
    Ok(())
}

Bulk Conversion Errors

In html_files_to_markdown, each file fails independently. A failed file does not abort the rest of the batch:

for (src, result) in mdka::html_files_to_markdown(&files, Path::new("out/")) {
    if let Err(e) = result {
        eprintln!("skipped {}: {e}", src);
    }
}

Supported HTML Elements

The table below shows every HTML element that mdka recognises and what Markdown it produces. Elements not listed are either silently removed (script, style, etc.) or their children are kept as plain text.

Block Elements

HTMLMarkdown outputNotes
<h1><h6># ###### ATX-style headings
<p>Paragraph (blank lines around)
<blockquote>> prefixOn every line inside the quote, including blank lines (>) and code block lines. Nesting produces > > , > > > , …
<pre><code>Fenced code block ```Preserves whitespace and newlines
<ul>- listTight or loose, see Lists
<ol>1. listRespects start attribute. Tight or loose, see Lists
<li>List itemEverything inside it stays in the item: later lines are indented to the item’s content column (- → 2 spaces, 1. → 3, 10. → 4), including nested lists and code block lines
<hr>---
<div>, <article>, <section>, <main>Block separatorAct as paragraph breaks; unwrapped (tag removed, children kept) when unwrap_unknown_wrappers is on — Minimal and Semantic by default
<figure>, <figcaption>Block separatorNever unwrapped, in any mode. These carry structural meaning unwrap_unknown_wrappers is not meant to discard — they’re excluded from the wrapper-candidate set entirely, not merely blocked by a secondary check

Inline Elements

HTMLMarkdown outputNotes
<strong>, <b>**text**
<em>, <i>*text*
<code> (inline)`text`Only when not inside <pre>
<a href="…">[text](url)title attribute → [text](url "title")
<img src="…" alt="…">![alt](src)title attribute → ![alt](src "title")
<br> \n (trailing two spaces + newline)

<span> is not in either table, and that is deliberate: it produces no output of its own and no break. <span>A</span><span>B</span> converts to AB, with the children passed straight through.

Lists: tight and loose

Markdown has two kinds of list: tight, with no blank line between items, and loose, where items are separated by blank lines and each item’s text is a paragraph. HTML has no such distinction — whether an item’s text is wrapped in <p> is usually a matter of how the page was produced — so mdka decides by the item’s content:

A list is loose if and only if some item contains two or more blocks, where a run of inline content (text, links, bold, images, line breaks) counts as one block, and nested lists are not counted. Otherwise it is tight.

A block is a paragraph, heading, blockquote, code block or rule, and anything else the chosen mode renders as a block: <div> counts in Balanced, Strict and Preserve, but not in Minimal and Semantic, which unwrap it; an element the mode drops counts as nothing.

A CMS list with one <p> per item stays tight:

<ul><li><p>a</p></li><li><p>b</p></li></ul>
- a
- b

A nested list is not counted, so text followed by a sublist is still one block:

<ol><li>one<ol><li>inner</li></ol></li></ol>
1. one
   1. inner

Two paragraphs in one item make the whole list loose:

<ul><li><p>a</p><p>b</p></li><li>c</li></ul>
- a

  b

- c

Each list is decided on its own: a loose list nested inside a tight one leaves the outer list tight.

Not Yet Supported

These elements are not converted to their Markdown equivalent. Their text content still appears — children are kept as plain text — so the output is not empty, but the structure or emphasis they carry is lost.

HTMLCurrent behaviourStatus
<table>, <thead>, <tbody>, <tr>, <th>, <td>Cell text is emitted as plain text; no GFM table is produced and the row/column structure is lostPlanned
<dl>, <dt>, <dd>Term and description text run together as plain textPlanned
<del>, <s>Text kept, strike-through (~~text~~) not emittedPlanned
<sup>, <sub>Text kept inline, with no indication it was raised or loweredPlanned
<video>, <audio>No output for the media element itselfNot yet scheduled

Tables are the largest gap, and the cell text is not merely unstructured — it is run together without separators:

<table><thead><tr><th>H1</th><th>H2</th></tr></thead><tbody><tr><td>a</td><td>b</td></tr></tbody></table>

converts to H1H2ab. mdka adds no separator between cells; the only spacing that survives is whitespace already present in the source, so the same table written across two lines comes out as H1H2 ab. If your input is table-heavy, the converted Markdown will read as runs of joined text where the table was. See ROADMAP.md for scheduling.

Code Blocks and Language Hints

When a <code> element has a class containing language-<name>, the language name is included in the fenced block:

<pre><code class="language-rust">fn main() {}</code></pre>

Produces:

```rust
fn main() {}
```

The language-* class is preserved in all conversion modes, including Balanced which otherwise strips class attributes.

Always-Removed Elements

These elements and all their descendants are removed unconditionally, regardless of conversion mode:

<script> · <style> · <meta> · <link> · <template> · <iframe> · <object> · <embed> · <noscript> · <head> · <svg>

HTML comments are removed in all conversion modes, including Preserve. No mode retains comment content.

Shell Elements

<nav>, <header>, <footer>, <aside> are kept by default but can be removed by setting drop_interactive_shell = true or using ConversionMode::Minimal.

Text Processing Rules

mdka applies a small set of deterministic rules to produce consistent, readable Markdown from any HTML text content.

Whitespace Normalisation

HTML text nodes are normalised according to the HTML whitespace collapsing rules:

  • Leading and trailing whitespace is trimmed from block-level context.
  • Consecutive whitespace characters (spaces, tabs, newlines) within a text node are collapsed to a single space.
  • A single space is preserved between adjacent inline elements.
  • <br> produces a hard line break ( \n).
  • <pre> blocks are exempt — whitespace inside <pre> is reproduced exactly.

This is done in a single pass without regular expressions, which keeps allocation overhead low.

Markdown Escaping

Text is escaped by the context it is written in, so that the Markdown parses back — under CommonMark and GitHub Flavored Markdown — to exactly the text the HTML contained, with as few backslashes as possible. No character is escaped everywhere: a character gets a backslash only where, without one, it would be read as Markdown.

Text

In paragraphs, headings, list items, quotes and link text:

TextEscapedNot escaped
* and ~where they could open or close emphasis or strikethrough: *bold*\*bold\*with a space on both sides: 2 * 3
_the same, except inside a wordsnake_case_here
`always
[always
]inside link text and image alt textelsewhere
!before [, where it would start an imageWow! Really!
<before a letter, /, ! or ? — a tag, a comment or an autolink: <div>\<div>a < b, <3
&before a letter, a digit or # — a possible entity: &copy;\&copy;a & b
\before punctuation or at the end of a linepath\to\file

A few characters need a backslash only at the start of a block’s text — after any - , 1. or > that a list item or quote puts in front of it — or at the start of a line after a <br>:

Text begins withWould becomeWritten as
a number, then . or )an ordered list1986\. A great year — the delimiter is escaped, not the digit
- or +, then a spacea bullet list\- not a list
#, then a spacea heading\# not a heading
>a quote\> not a quote
~~~a code block\~\~\~
---a thematic break\---

After a <br>, a line of = or - would turn the paragraph into a heading, and a line such as | --- | --- | under a line containing | would turn it into a GFM table: its first character is escaped. In a heading, a trailing # is escaped, since it would be read as the heading’s closing sequence.

Code

Code spans and code blocks hold their text verbatim, with no escapes. The delimiter is sized to the content instead:

<p><code>snake_case</code> and <code>a`b</code></p>
`snake_case` and ``a`b``

A code block’s fence is one backtick longer than the longest run of backticks that starts a line inside it, and never shorter than three. A language hint containing a backtick is dropped, since a fence cannot hold one.

A destination with a space, a control character or unbalanced parentheses is written in angle brackets; a line break in it is written as &#10;. A title is written in "…", or in '…' or (…) when that avoids escaping its quotes:

<a href="/a b.html" title='say "hi"'>x</a>
[x](</a b.html> 'say "hi"')

Adjacent emphasis

Two emphasized runs of the same kind that touch would merge into one run of *, which CommonMark reads differently. One of them is written with _:

<p><em>a</em><em>b</em></p>
_a_*b*

Between two letters or digits (x<em>a</em><em>b</em>y) neither * nor _ can express this, and the output is left as it is.

HTML Entity Decoding

HTML entities in text nodes are decoded by the HTML parser (scraper / html5ever) before mdka processes them. The result is already Unicode text:

HTML entityAfter parsingIn Markdown
&amp;&& (\& before a letter, a digit or #)
&lt;<< (\< before a letter, /, ! or ?)
&gt;>>
&nbsp;non-breaking spacepreserved as space

Output Boundaries

  • Output always ends with exactly one newline (\n) when the input produces any content; the output is empty for empty input.
  • Leading blank lines that scraper adds when wrapping content in <html><body> are trimmed before the final string is returned.
  • Block elements (paragraphs, headings, lists, etc.) are separated by blank lines.

Design Philosophy

The Goal: Balance, not Dominance

There are excellent HTML-to-Markdown libraries in the Rust ecosystem — some prioritise raw speed, others maximise conversion fidelity. mdka is not trying to beat them on every axis.

Its aim is a practical balance:

Produce stable, readable Markdown from real-world HTML, with an easy API, without surprising the caller at runtime.

Speed and memory efficiency matter, and mdka is designed with both in mind. But they are means to an end, not the end itself.

Real-World HTML is Messy

Web content rarely arrives as clean, well-formed documents. In practice you encounter:

  • HTML that a CMS generated and no human ever wrote
  • SPA-rendered DOM fragments extracted from DevTools
  • Scraped pages with ad slots, cookie banners, and navigation wrapped around the content
  • Documents with 5,000 levels of nested <div> elements
  • Missing closing tags, duplicate attributes, and unknown elements

mdka uses scraper, which is built on html5ever — the same parser used by the Servo browser engine. It applies the HTML5 parsing algorithm, meaning: unknown elements are handled gracefully, missing tags are inferred, and the result is always a well-formed DOM tree, regardless of the input quality.

No Stack Overflows

A common failure mode in tree-processing code is stack overflow on deeply nested input. mdka uses an explicit Vec-based stack (non-recursive DFS) for its single tree traversal, which applies preprocessing and Markdown conversion together in one pass. This means it handles any nesting depth that fits in heap memory.

Configurable Pre-Processing

HTML from different sources needs different treatment. A page scraped from a news site has navigation, advertising, and footer content that a content extraction pipeline wants to remove. A document being archived for audit purposes should retain as much as possible.

The five conversion modes encode these intent differences as named, opinionated presets. They are applied inline during the single tree traversal, filtering as the DOM is walked rather than as a separate step — keeping the conversion logic itself simple and mode-agnostic.

One Allocator, Minimal Copies

The conversion pipeline is designed to minimise heap allocations:

  • Whitespace normalisation is done in a single pass, writing directly into the output String.
  • No regular expressions are used at runtime (avoiding compiled regex objects).
  • The output String is pre-allocated with a capacity estimate.
  • The #[global_allocator] counter in the CLI and benchmarks measures this directly.

Performance Characteristics

The Focus of mdka

The Rust ecosystem offers a variety of excellent HTML-to-Markdown converters. Many of these projects prioritize feature-richness, complex edge-case handling, or high extensibility.

mdka takes a different approach. Our mission is to provide a “minimalist, lightweight, and memory-efficient” converter, specifically optimized for resource-constrained environments or high-concurrency tasks where overhead must be kept to an absolute minimum.

The benchmarks presented here are not intended to rank libraries or declare a “winner.” Instead, they serve as internal metrics to verify whether mdka is successfully meeting its own design goals. We believe in choosing the right tool for the specific job, and we encourage developers to explore the diverse range of libraries available in the ecosystem to find the one that best fits their needs.

The Evolution: v1 to v2

With the release of v2, mdka underwent a complete architectural overhaul. We moved away from the original implementation to a ground-up rewrite focused on:

  • Stack-Safe Traversal: Implementing a non-recursive Deep First Search (DFS) to prevent stack overflow even with deeply nested HTML.
  • Optimized Memory Allocation: Reducing unnecessary clones and leveraging Rust’s ownership model to minimize peak memory usage.
  • Streamlined Processing: Simplifying the conversion logic to achieve a predictable and lightweight execution path.

This rewrite resulted in a dramatic performance leap and a significantly reduced memory footprint compared to our previous version.

Benchmark Results (2026-04-15)

The following data demonstrates how the v2 architecture has improved our efficiency and how it aligns with our goal of “reasonable speed with minimal resource consumption.”

The figures below are wall-clock medians from Criterion. The log also records outliers for each run, so small differences should be read with some caution.

Conditions

All libraries were benchmarked under the same conditions:
Linux x86_64 6.19, Rust 1.94.1, Criterion 0.8, 28 logical cores, 3 s warm-up, and 3 s measurement.

Libraries Under Test

LibraryVersionHTML parserApproach
mdka2.0.0scraper (html5ever)Full DOM tree; non-recursive DFS
mdka_v11.6.9html5everFull DOM tree; older implementation
html2md0.2.15html5everDOM-based converter
fast_html2md0.0.61lol_htmlStreaming rewriter
htmd0.5.4html5everDOM-based converter
html_to_markdown_rs3.1.0html5everDOM-based converter
html2text0.16.7html5everText-oriented converter
dom_smoothie0.17.0dom_query (html5ever)DOM-oriented converter

These libraries do not share the same design and do have different approach and goals.

Conversion Speed

Datasetmdka v2mdka v1html2mdfast_html2mdhtmdhtml_to_markdown_rshtml2textdom_smoothie
small131.52 µs131.66 µs132.21 µs79.50 µs90.47 µs107.82 µs350.92 µs317.37 µs
medium1.3040 ms2.2866 ms1.5266 ms887.59 µs1.0562 ms1.1660 ms3.3999 ms2.7643 ms
large12.336 ms75.751 ms12.455 ms7.0399 ms7.7896 ms9.6825 ms29.854 ms26.062 ms
deep_nest32.620 ms373.10 ms36.834 ms5.9868 ms72.481 ms96.744 ms30.903 ms29.408 ms
flat5.6253 ms24.817 ms6.7911 ms4.2114 ms5.5321 ms4.6975 ms14.023 ms
malformed31.712 µs40.178 µs71.778 µs52.948 µs62.302 µs41.109 µs96.822 µs5.6401 ms

mdka v2 is clearly ahead of mdka v1 in this run. The gain is small on the smallest input, but it becomes much more visible as the input gets larger or structurally harder: around 1.75× faster on medium, 6.1× on large, 11.4× on deep_nest, and 4.4× on flat. On malformed input, v2 is also faster than v1 and the fastest.

Memory Allocation

Datasetmdka v2mdka_v1html2mdfast_html2mdhtmdhtml_to_markdown_rshtml2textdom_smoothie
small113.5 KB240 KB231 KB154 KB93.6 KB232.5 KB764.5 KB325.4 KB
medium984.6 KB2.03 MB1.95 MB1.52 MB1.01 MB1.95 MB8.50 MB2.85 MB
large8.00 MB17.0 MB16.76 MB11.98 MB7.85 MB16.76 MB74.89 MB23.08 MB
deep_nest3.00 MB4.71 MB2.55 MB6.85 MB1.96 MB2.55 MB18.48 MB
flat3.93 MB7.90 MB7.87 MB7.46 MB4.84 MB7.87 MB40.28 MB35.47 MB
malformed44.7 KB91.6 KB71.4 KB145 KB62.3 KB71.4 KB464.4 KB1.63 MB

In this run, mdka v2 uses less heap than v1.

Summary

As shown in the results, the transition to v2 has allowed us to achieve our objectives of being lightweight and memory-efficient while maintaining competitive speed.

We recognize that other libraries may offer more features or different trade-offs that make them better suited for certain applications. mdka aims to be the best choice for those who prioritize a simple, “Unix-style” tool that does one thing—conversion—with the smallest possible footprint.

Architecture

Workspace Layout

mdka/
├── src/               mdka library crate (lib only)
│   ├── lib.rs             Public API surface
│   ├── options.rs         ConversionMode, ConversionOptions
│   ├── traversal.rs       Markdown conversion traversal
│   ├── renderer.rs        MarkdownRenderer state machine
│   │   ├── sink.rs            The output sink: the only writer of Markdown
│   │   └── escape.rs          Escaping by context (RFC 010)
│   ├── utils.rs           Tag classification helpers
│   └── alloc_counter.rs   Custom allocator for benchmarks (deprecated since 2.2.2, removed in 2.4.0)
├── tests/             integration test modules
├── cli/               mdka-cli binary crate
│   └── src/main.rs        Argument parsing + dispatch
├── node/              Node.js bindings (napi-rs v3)
├── python/            Python bindings (PyO3 v0)
├── benches/           criterion benchmarks
└── examples/          Allocation measurement tool

Conversion Pipeline

Each call to html_to_markdown_with follows these steps:

HTML string
    │
    ▼
[1] Parse        scraper::Html::parse_document()
    │             → html5ever DOM (tolerant HTML5 parsing)
    ▼
[2] Traverse     traversal::traverse(&doc, opts)
    │             → non-recursive DFS over ego-tree, Enter/Leave events
    │             Preprocessing is applied inline during this traversal:
    │               · drops script/style/head/svg/… unconditionally
    │               · drops shell elements when opted in
    │               · unwraps generic wrappers when opted in
    │             Drives MarkdownRenderer
    ▼
[3] Finalise     renderer.finish()
                  → trim trailing whitespace, single trailing newline

There is no intermediate HTML serialisation and no second parse. An earlier version of the engine preprocessed HTML into a filtered HTML string and re-parsed it before conversion; that round trip was removed, and this page now describes the single-parse, single-traversal pipeline that actually runs.

MarkdownRenderer

MarkdownRenderer is a state machine that tracks element context:

  • list_stack: nested ordered/unordered lists
  • in_pre and the pending code fence of the current <pre>
  • which open <a> and <code> elements opened a capture

It never writes Markdown itself. Every byte goes through the output sink (renderer/sink.rs), whose fields are private to its module, so an element handler cannot write around it:

  • Destinations. While a link’s text or an inline code span is being collected, content goes into that construct’s buffer; otherwise into the document. When the construct closes, the sink writes it into the destination it was opened in – or nothing, for a link with no text and no image or a code span with no text.
  • Bookkeeping per destination: newlines_emitted (prevents double blank lines), at_line_start, and the pending space between words.
  • The container prefix. Blockquotes and list items form a stack in the sink. Every line written inside them – content, blank separator lines and code block lines – starts with the whole stack’s prefix, outermost first: > for a quote (> alone on a blank line), and for a list item as many spaces as its content column (- → 2, 1. → 3). No element handler writes a prefix; the sink writes it before the first byte of a line. Line breaks are recorded and written when the next content arrives, so a blank line carries the prefix of exactly the containers still open across it. Whether a list is tight or loose is decided before rendering, by the same one pass over the document that finds inline elements around blocks.
  • Escaping by context (renderer/escape.rs). Text is escaped as the sink writes it, from the line it is on – a list item’s or quote’s content starts a block after its prefix – and the character before it. An escape that depends on the character after it (1986. is a list marker only before a space; ! opens an image only before [) waits, and is settled when the destination’s next byte is written, by whatever writes it: text, markup or a line break. A backslash is then inserted in front of the waiting character. Code-span captures are not escaped, and a fenced block’s opening fence is lengthened when it closes if its content holds a longer backtick run.

Inside code – an inline <code>, or a <pre> with or without <code> – child elements contribute text only: Markdown has no emphasis, links or images inside code, so an image there contributes nothing. A <pre> owns its fence and produces exactly one code block, holding the text of everything inside it in order; a <pre> without a <code> child still produces a balanced block, and the language comes from a <code> that opens the block.

Inline elements around block content. Before rendering, the traversal makes one bottom-up pass over the document and marks each <strong>/<b>, <em>/<i>, <code> and <a> that has a rendered block among its descendants. What counts as a block comes from the same classification the renderer’s block arms use, and elements the mode skips or unwraps are left out as the traversal itself would. A marked emphasis or code element writes no delimiters. A marked link is written once per run of inline content between block boundaries: ## [Title](/x). Separately, a <b>/<strong> or <i>/<em> whose own style negates its emphasis (font-weight normal or ≤ 500, font-style: normal) writes no delimiters.

Language Bindings

Both the Node.js and Python bindings are thin wrappers:

  • Node.js (napi-rs): exposes sync and async (tokio::spawn_blocking) variants. The async variants release the Node.js event loop during conversion.
  • Python (PyO3): exposes py.detach() on the batch function html_to_markdown_many, releasing the GIL for rayon parallel conversion.

The binding crates (mdka-node, mdka-python) have no conversion logic of their own — they call the same Rust functions as the library and CLI.

Features

Crash Resistance

mdka uses non-recursive DFS traversal throughout. An explicit Vec stack replaces the call stack, so documents with arbitrarily deep nesting will not cause a stack overflow. This has been tested with 10,000 levels of nested <div> elements.

Some fast converters use recursive tree traversal and will crash on deeply nested input. If your input source is not fully controlled, crash resistance matters.

Five Conversion Modes

Rather than a single fixed conversion strategy, mdka offers five named modes that tune the pre-processing pipeline:

  • Balanced — readable output for general use
  • Strict — maximum attribute retention for debugging
  • Minimal — body text only; good for LLM input preparation
  • Semantic — preserves ARIA and document structure
  • Preserve — maximum fidelity for archiving

Each mode can be further customised with per-call option flags. See Conversion Modes and ConversionOptions.

Parallel File Conversion

html_files_to_markdown and html_files_to_markdown_with use rayon to convert multiple files in parallel. Each file’s result is independent — one failed file does not stop the batch.

The Node.js and Python bindings expose this as an async function (htmlFilesToMarkdown, html_files_to_markdown) so the thread pool work does not block the caller’s event loop or hold the GIL.

Multi-Language API

The same Rust implementation is accessible from three languages:

LanguagePackageMechanism
Rustmdka on crates.ionative library
Node.jsmdka on npmnapi-rs native module
Pythonmdka on PyPIPyO3 extension module

All three call the same underlying conversion code and produce identical output for identical input.

html5ever Parser Foundation

The HTML parser is scraper, which is built on html5ever. html5ever implements the HTML5 parsing algorithm, the same one that web browsers use.

This means:

  • Missing closing tags are inferred correctly
  • Unknown elements are preserved (not silently dropped)
  • Malformed attribute syntax is normalised
  • The result is always a valid DOM tree, no matter the input

Predictable, Deterministic Output

For a given HTML input and ConversionOptions, mdka always produces the same Markdown string. There is no randomisation, no date-stamping, and no version-dependent output variation within a semver major version.

Minimal Dependencies

The runtime dependencies of the mdka library crate are:

CratePurpose
scraperHTML parsing (html5ever wrapper)
ego-treeDOM tree traversal
rayonParallel file conversion
tikv-jemallocator, tikv-jemalloc-ctlEnsures fragmentation avoidance and scalable concurrency
thiserrorMdkaError derive macro

Benchmark and comparison dependencies (criterion, competitors) are [dev-dependencies] and do not affect library consumers.