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 (balanced, or minimal to strip page chrome)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: two conversion modes — Balanced (the default) and Minimal, which strips to body text and structure for LLM input.
  • 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)
  • Pre-processing to strip navigation and other page chrome (Minimal mode, or the drop_interactive_shell option)
  • 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 = "3"

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.

The Linux x64 glibc archive is built against glibc 2.17 — the same floor the npm bindings and Python wheels below promise — so it starts on any distribution with glibc 2.17 or newer, including Ubuntu 20.04, Debian 11 and RHEL 8. The two musl archives are statically linked and have no glibc requirement.

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 six platforms, resolved automatically through optionalDependencies:

PlatformPackage
Linux x64 (glibc)@mdka/lib-linux-x64-gnu
Linux x64 (musl, e.g. Alpine)@mdka/lib-linux-x64-musl
Linux arm64 (glibc)@mdka/lib-linux-arm64-gnu
Linux arm64 (musl)@mdka/lib-linux-arm64-musl
macOS Apple Silicon@mdka/lib-darwin-arm64
Windows x64 (MSVC)@mdka/lib-win32-x64-msvc

The two glibc bindings are built against glibc 2.17 — the same floor the Python wheels below advertise — so any distribution with glibc 2.17 or newer can load them. That covers everything from CentOS 7 onward, including Ubuntu 20.04, Debian 11 and RHEL 8. The musl bindings link musl statically and have no glibc requirement at all.

On any other platform — macOS Intel, Windows ARM, and architectures such as 32-bit ARM or RISC-V — there is no fallback inside the package. npm install succeeds there, because a native package that does not match the platform is skipped by design, and require('mdka') then fails with an error that names the platform, lists the six above and points back to this page. The published tarball contains five files (index.js, index.d.ts, loader.js, 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 Multiple Strings

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

let pages = vec!["<h1>A</h1>", "<p>B</p>", "<ul><li>C</li></ul>"];
let results = html_to_markdown_many(&pages);
assert_eq!(results, vec!["# A\n", "B\n", "- C\n"]);

let opts = ConversionOptions::for_mode(ConversionMode::Minimal);
let results = html_to_markdown_many_with(&pages, &opts);

Each input is converted independently and the results keep the input order. When the parallel feature is on (the default), the conversions run across CPU cores via rayon; with the feature off, the same functions run sequentially instead.

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 dest = html_file_to_markdown("page.html", None::<&str>)?;
    println!("page.html → {}", dest.display());

    // Output goes to a specific directory
    let dest = 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)?;

    // One `FileOutcome` per input, in input order: a failing file does not stop
    // the others, and the outcome names the file it belongs to.
    for outcome in html_files_to_markdown(&files, out_dir) {
        let src = outcome.src.display();
        match outcome.result {
            Ok(dest) => println!("{src} → {}", dest.display()),
            Err(e)   => eprintln!("Error: {src}: {e}"),
        }
    }
    Ok(())
}

A single file returns the destination path or an error; only the bulk function reports per file, because one failure must not hide the rest.

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::Minimal);
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
MinimalLLM pre-processing, compression — the only mode that converts differently

There were five modes until 3.0. Strict, Semantic and Preserve were aliases of Balanced that produced identical output and could not differ; they were removed, and "strict".parse::<ConversionMode>() now returns an error that says so. See Conversion Modes for why.

Error Handling

use mdka::{html_file_to_markdown, MdkaError};

match html_file_to_markdown("missing.html", None::<&str>) {
    Ok(dest) => println!("→ {}", 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.

Crate Version

println!("{}", mdka::version()); // the installed version, e.g. "3.0.0"

Returns the crate’s version as declared in Cargo.toml.

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

Every Node.js function takes its options as an optional last argument — there is no separate …With function (they were removed in 3.0):

const { htmlToMarkdown, htmlToMarkdownAsync } = require('mdka')

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

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

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

Available mode strings: "balanced" (default) and "minimal". "strict", "semantic" and "preserve" were aliases of "balanced" and were removed in 3.0: passing one throws (or, from htmlToMarkdownAsync and the file functions, rejects) with conversion mode 'strict' was removed in 3.0; it was an alias of 'balanced'. Use 'balanced'. — see Conversion Modes.

Converting Multiple Strings

const { htmlToMarkdownMany } = require('mdka')

const pages = ['<h1>A</h1>', '<p>B</p>', '<ul><li>C</li></ul>']
const results = htmlToMarkdownMany(pages)
// ['# A\n', 'B\n', '- C\n']

// With options — the same shape as htmlToMarkdown's
const withOpts = htmlToMarkdownMany(pages, { mode: 'minimal' })

Each input is converted independently and the results keep the input order.

Single File Conversion

const { htmlFileToMarkdown } = require('mdka')

async function main() {
  // Output to same directory: page.html → page.md
  // Resolves to the path that was written — a string.
  const dest = await htmlFileToMarkdown('page.html')
  console.log(`page.html → ${dest}`)

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

  // With options (outDir may be null to keep the default)
  const withOpts = await htmlFileToMarkdown('page.html', 'out/', {
    mode: 'minimal',
    dropInteractiveShell: true,
  })
}
main()

A single file fails the call: if it cannot be read or written, the promise rejects with an Error (IO error: …). There is no result object to inspect.

Bulk Parallel Conversion

const { htmlFilesToMarkdown } = require('mdka')

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

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

  // One FileOutcome per input, in input order: src, ok, and exactly one of
  // dest (when ok) and error (when not).
  for (const r of results) {
    if (r.ok) console.log(`${r.src} → ${r.dest}`)
    else      console.error(`${r.src}: ${r.error}`)
  }

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

A failing file does not reject the promise and does not stop the others; its entry has ok: false. The promise rejects only if the call as a whole cannot proceed — the output directory cannot be created. That is the difference from htmlFileToMarkdown, on purpose: with one file, failing the call is the answer; with many, one bad file must not hide the rest.

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 ok: false and error set, rather than silently overwriting.

Four options were removed in 3.0: preserveClasses, preserveDataAttrs, preserveAriaAttrs and unwrapUnknownWrappers. None of them ever changed the output. In TypeScript, passing one is a compile error:

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

In plain JavaScript it is an error too, from 3.0. An option key that is not recognised is rejected with a message that names it — a key removed in 3.0 says option 'preserveClasses' was removed in 3.0; … and a misspelt one says unknown option 'mdoe'. Valid options: mode, preserveIds, dropInteractiveShell. The synchronous functions throw it and the Promise-returning ones reject with it. (Until 3.0 napi silently dropped unknown keys, so a removed option that was still passed told you nothing.)

JsConversionOptions has three fields: mode, preserveIds and dropInteractiveShell.

Package Version

const { version } = require('mdka')
console.log(version()) // the installed version, e.g. "3.0.0"

TypeScript

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

import {
  htmlToMarkdown,
  htmlToMarkdownAsync,
  htmlToMarkdownMany,
  htmlFileToMarkdown,
  htmlFilesToMarkdown,
  type JsConversionOptions,
  type FileOutcome,
} from 'mdka'

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

const opts: JsConversionOptions = {
  mode: 'minimal',
  dropInteractiveShell: true,
}
const md: string = htmlToMarkdown(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,
)

# Balanced is the default: naming it is optional
md = mdka.html_to_markdown_with(
    html,
    mode=mdka.ConversionMode.Balanced,
)

Four keyword arguments were removed in 3.0: preserve_classes, preserve_data_attrs, preserve_aria_attrs and unwrap_unknown_wrappers. None of them ever changed the output. Passing one is now a TypeError, like any unknown keyword:

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

Remove it. (preserve_unknown_attrs and drop_presentation_attrs were never exposed by this binding.) The _with functions now take mode, preserve_ids and drop_interactive_shell.

Available modes: ConversionMode.Balanced (default) and ConversionMode.Minimal. Strict, Semantic and Preserve were aliases of Balanced and were removed in 3.0: ConversionMode.Strict is now an AttributeError. A mode is never a string in Python — mode="balanced" is a TypeError — so there is no string form to migrate. See Conversion Modes.

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.

Use html_to_markdown_many_with to pass mode and the other conversion options, the same keyword arguments html_to_markdown_with accepts:

import mdka

pages = ["<h1>A</h1>", "<p>B</p>", "<ul><li>C</li></ul>"]
results = mdka.html_to_markdown_many_with(pages, mode=mdka.ConversionMode.Minimal)

Single File Conversion

import mdka

# Output to same directory: page.html → page.md
# Returns the path that was written, as a str.
dest = mdka.html_file_to_markdown("page.html")
print(f"page.html → {dest}")

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

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

A single file fails the call: if it cannot be read or written it raises MdkaError (IO error: …). There is no result object to inspect.

html_file_to_markdown_with is the same function under the name the _with convention would predict, accepting the same keyword arguments.

Bulk File Conversion

import mdka

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

# One FileOutcome per input, in input order: src, dest, error and ok — exactly
# one of dest and error is set, and ok says which.
for r in results:
    if r.ok:
        print(f"{r.src} → {r.dest}")
    else:
        print(f"Error: {r.src}: {r.error}")

A failing file does not raise and does not stop the others; its entry has ok == False. MdkaError is raised only if the call as a whole cannot proceed — the output directory cannot be created. That is the difference from html_file_to_markdown, on purpose: with one file, failing the call is the answer; with many, one bad file must not hide the rest. (ConvertResult and BulkConvertResult were removed in 3.0; FileOutcome is the one result type.)

html_files_to_markdown_with is the same function under the _with name, accepting mode and the other conversion options.

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.

Package Version

import mdka

print(mdka.version())  # the installed version, e.g. "3.0.0"
print(mdka.__version__)  # the same string

Type Annotations

mdka ships type stubs (mdka/__init__.pyi and mdka/mdka_python.pyi) next to a py.typed marker, so a type checker (checked with mypy) reports calls against the real signatures:

import mdka

n: int = mdka.html_to_markdown("<p>hi</p>")   # error: "str" is not assignable to "int"
mdka.html_to_markdown(42)                      # error: argument 1 must be "str"
mdka.html_file_to_markdown()                   # error: missing argument "path"

The stubs are the reference for every signature; read them, or ask your editor, rather than a copy in prose. Two things worth knowing about them:

  • They describe what the extension accepts, which is narrower than “anything path-like”. File paths are str, not os.PathLike: pass str(path). The list arguments (html_list, paths) take any sequence of str — but a bare str is rejected at runtime, which a type checker cannot express.
  • They are checked against the built extension in CI (python -m mypy.stubtest mdka), so a signature that changes in Rust without the stub changing fails the build rather than misleading a type checker.

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 -o out/ *.html                          # balanced, the default

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) · minimal. strict, semantic and preserve were removed in 3.0; asking for one is an error that says so
--preserve-idsEmit <a id="…"></a> anchors for elements with an id. On by default in every mode except minimal
--no-preserve-idsTurn anchor emission off, in any mode
--drop-shellDrop nav, header, footer, aside
--emphasis-from-styleLet an inline style attribute add bold/italic a tag would not otherwise carry (RFC 049). Off by default; see Options for where it does and does not reach
--backslash-hard-breaksWrite a hard break as \ + newline instead of two trailing spaces (RFC 052). Off by default; see Options for the trade-off
-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.

Four flags were removed in 3.0: --preserve-classes, --preserve-data, --preserve-aria and --unwrap-wrappers. None of them ever changed the output. Passing one now fails with exit status 1 and a single line on stderr that says so — for example error: `--preserve-classes` was removed in 3.0; Markdown has no attribute syntax, so it never changed the output. Remove it from the command line: the output is the same without it. — so a script that still passes one fails loudly instead of appearing to work. Remove the flag; nothing else changes. Likewise --mode strict, --mode semantic and --mode preserve fail with error: conversion mode 'strict' was removed in 3.0; it was an alias of 'balanced'. Use 'balanced'.

Errors and per-file progress go to stderr, not stdout. The in.html -> in.md progress line printed for each converted file, and every error, never mix into stdout. That is what makes the stdin form safe to redirect: echo '<h1>Hi</h1>' | mdka > out.md leaves out.md holding only the converted Markdown.

A file argument is a different mode: mdka page.html writes page.md beside its input and prints only the progress line (to stderr), so stdout is empty. mdka page.html > out.md therefore creates an empty out.md and the conversion lands in page.md. To choose where the file goes, use -o.

For full mode descriptions see Conversion Modes.

Migrating to 3.0

3.0 changes no conversion output. Not one byte, in either surviving mode (Balanced and Minimal). That is asserted, not hoped: a test converts a corpus of 58 documents in both modes and compares every byte with what the published 2.9.0 produced, from goldens generated by running 2.9.0 (mode_identity.rs). So there are no fixtures to re-verify and no output to diff. Everything below is compile-and-rename: names that went away, and one change to what the file functions return.

If you only call the string functions — html_to_markdown, html_to_markdown_with, html_to_markdown_many and their per-language forms — with Balanced, Minimal or no mode, 3.0 needs nothing from you. See What did not change.

This is the first breaking release since 2.0.0, and the first one mdka has staged deliberately: 2.0.0 was a rewrite whose changes were reconstructed after the fact, with no runway and no guide. It is the price of removing things that did nothing and of giving the file functions a result shape that says what it means; the release before it (2.9.0) changed nothing and warned about the last removal, and 3.0 followed it immediately, so you may not have seen a single warning — this page is written for that reader.

At a glance

You useWhat breaks
Strict, Semantic or Preserve (any binding, any string form)The name is gone. Use Balanced: the output is identical
preserve_classes, preserve_data_attrs, preserve_aria_attrs (and, Rust only, preserve_unknown_attrs, drop_presentation_attrs)Gone. Delete them: they never changed the output
unwrap_unknown_wrappersGone. Delete it: it could not change the output (why)
Rust: the file functionsConvertResult is gone; single returns PathBuf, bulk returns Vec<FileOutcome>
Node.js: anything that converts a file, or ends in WithAlmost everyone. ConvertResult is gone and four functions are gone (longest section)
Python: the file functionsSingle returns str; BulkConvertResult is now FileOutcome
CLI: --mode strict|semantic|preserve, --preserve-classes, --preserve-data, --preserve-aria, --unwrap-wrappersEach fails with a message that says what to do

The shape behind the file-function changes

One distinction explains every change to what the file functions return: can it fail, not one versus many.

  • A string conversion cannot fail, so it returns a string, or a list of them. No result type. Unchanged.
  • One file can fail, and you asked about exactly one thing, so failure fails the call, through each language’s own channel — a Rust Err, a Python raise, a Node rejection. It returns the destination path and nothing else; the input path you passed in is not repeated back.
  • Many files must not let one bad file hide the rest, so each file gets its own outcome, and the outcome says which file it belongs to: FileOutcome.

Removed modes and options (all bindings)

Modes: Strict, Semantic, Preserve → Balanced

They were aliases of Balanced: byte-for-byte the same output, and they could not differ. Replace the name; nothing else changes.

A mode read from a string — a config file, an environment variable, --mode — cannot be found by the compiler, so change those strings now. What each surface does with a removed name:

SurfaceResult
Rust "strict".parse::<ConversionMode>()Err("conversion mode 'strict' was removed in 3.0; it was an alias of 'balanced'. Use 'balanced'.")
Node.js { mode: 'strict' }throws (htmlToMarkdown, htmlToMarkdownMany) or rejects (htmlToMarkdownAsync, the file functions) with the same message
PythonConversionMode.Strict is an AttributeError. A mode is never a string in Python
CLI --mode strictexit status 1, the same message on stderr

That message is deliberately different from unknown conversion mode: bogus: a removed name is an obsolete configuration, not a wrong one.

Options that never did anything

RemovedExisted in
preserve_classes, preserve_data_attrs, preserve_aria_attrsRust, Node.js (preserveClasses, preserveDataAttrs, preserveAriaAttrs), Python, CLI (--preserve-classes, --preserve-data, --preserve-aria)
preserve_unknown_attrs, drop_presentation_attrsRust only — there is nothing to remove in Node.js, Python or the CLI
the builder ConversionOptions::preserve_aria_attrsRust

Markdown has no syntax for HTML attributes, so “preserve” or “drop” an attribute was never expressible in the output. They were deprecated in 2.2.0. Delete them; the output is unchanged. preserve_ids and drop_interactive_shell are the two options that act on the output, and they stay.

unwrap_unknown_wrappers

This one you may not have been warned about, so here is all of it. It was an option that controlled whether a wrapper element — <div>, <section>, <article>, <main> — had its tag removed. It could not change the output: unwrapping a wrapper removes the tag but keeps the paragraph break it stood for, and Markdown has no wrapper element to show the difference. It was deprecated in 2.9.0 and removed in 3.0, which followed immediately, so almost no one ran a version that warned.

Removing the call changes nothing. Delete unwrap_unknown_wrappers (Rust, Python), unwrapUnknownWrappers (Node.js) or --unwrap-wrappers (CLI). Minimal still unwraps wrappers, and Balanced still renders them; that is a property of the mode, and the two write the same bytes.

If wrapper handling ever becomes expressible in Markdown, it returns as a new option, designed for what it can then do. This one does not come back.

Rust

html_file_to_markdown and html_file_to_markdown_with return the destination path, not a ConvertResult:

// before (2.9.0)
let result = mdka::html_file_to_markdown("page.html", None::<&str>)?; // ConvertResult
println!("{} -> {}", result.src.display(), result.dest.display());
// after (3.0)
fn main() -> Result<(), Box<dyn std::error::Error>> {
    let dest = mdka::html_file_to_markdown("page.html", None::<&str>)?; // PathBuf
    println!("page.html -> {}", dest.display()); // you already had the input path
    Ok(())
}

html_files_to_markdown and html_files_to_markdown_with return Vec<FileOutcome>, one per input in input order, and lose their 'a lifetime parameter: the outcome owns its src.

// before (2.9.0): Vec<(&P, Result<PathBuf, MdkaError>)>
for (src, result) in mdka::html_files_to_markdown(&files, out_dir) {
    match result {
        Ok(dest) => println!("{} -> {}", src, dest.display()),
        Err(e) => eprintln!("{src}: {e}"),
    }
}
// after (3.0): Vec<FileOutcome>
use std::path::Path;

fn main() {
    let files = vec!["a.html", "b.html"];
    for outcome in mdka::html_files_to_markdown(&files, Path::new("out/")) {
        let src = outcome.src.display();
        match outcome.result {
            Ok(dest) => println!("{src} -> {}", dest.display()),
            Err(e) => eprintln!("{src}: {e}"),
        }
    }
}

FileOutcome is pub struct FileOutcome { pub src: PathBuf, pub result: Result<PathBuf, MdkaError> }.

FileOutcome is not Clone, and this is the one break that is not a rename. MdkaError wraps std::io::Error, which cannot be cloned, so neither can the outcome. If you cloned parts of the old tuple, restructure: move the outcome apart (let FileOutcome { src, result } = outcome;), or clone only the half that can be cloned (outcome.src.clone(), outcome.result.as_ref().ok().cloned()).

ConversionMode::parse_mode is deprecated, not removed. It discards the reason a name was rejected, so "strict" and "bogus" both give None. Use str::parse, which reports why:

use mdka::options::ConversionMode;

let mode: ConversionMode = "minimal".parse().expect("a valid mode");
assert_eq!(mode, ConversionMode::Minimal);
assert!("strict".parse::<ConversionMode>().unwrap_err().contains("was removed in 3.0"));

Also gone: the variants ConversionMode::Strict, Semantic and Preserve; the fields preserve_classes, preserve_data_attrs, preserve_aria_attrs, preserve_unknown_attrs, drop_presentation_attrs and unwrap_unknown_wrappers of ConversionOptions; and its builder preserve_aria_attrs.

Node.js

This is the longest section, and that is the point: Node.js is the binding that 3.0 touches most. Its file functions returned an object that served both single and bulk conversion, so every Node.js caller that converts a file is affected; and it is the only binding that had two conventions at once — htmlToMarkdownWith beside htmlToMarkdownMany(htmls, options?) — so four function names go.

Functions: the …With names are gone

JavaScript has optional arguments, so the options are now an optional last argument on the base name. Rust and Python keep their _with pairs; that is their idiom.

RemovedUse
htmlToMarkdownWith(html, options)htmlToMarkdown(html, options?)
htmlToMarkdownWithAsync(html, options)htmlToMarkdownAsync(html, options?)
htmlFileToMarkdownWith(path, outDir, options)htmlFileToMarkdown(path, outDir?, options?)
htmlFilesToMarkdownWith(paths, outDir, options)htmlFilesToMarkdown(paths, outDir, options?)

htmlToMarkdownMany(htmls, options?) and version() are unchanged. To find your uses: grep -rn "With(\|WithAsync(" --include='*.js' --include='*.ts' . and check each hit is one of the four above.

// after (3.0)
const { htmlToMarkdown, htmlToMarkdownAsync } = require('mdka')

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

// was htmlToMarkdownWith(html, {...})
const md = htmlToMarkdown(html, { mode: 'minimal', dropInteractiveShell: true })

// was htmlToMarkdownWithAsync(html, {...})
async function main() {
  const mdAsync = await htmlToMarkdownAsync(html, { mode: 'minimal' })
  console.log(md, mdAsync)
}
main()

Single file: a string, and failure rejects

htmlFileToMarkdown used to resolve to a ConvertResult whose dest? and error? were declared optional even though a successful single-file call never left dest unset and never set error. It now resolves to the destination path, a string, and a failure rejects the promise with an Error whose message starts IO error: :

// after (3.0)
const { htmlFileToMarkdown } = require('mdka')

async function main() {
  const dest = await htmlFileToMarkdown('page.html', 'out/') // was: r.dest
  console.log(`wrote ${dest}`)

  try {
    await htmlFileToMarkdown('/no/such/file.html')
  } catch (e) {
    console.error(e.message) // "IO error: …"
  }
}
main()

Code that read r.src should use the path it passed in; code that checked r.error should use try/catch (or .catch) instead.

Many files: FileOutcome, with ok

htmlFilesToMarkdown resolves to Array<FileOutcome>, one per input in input order: { src, dest?, error?, ok }. ok is new — Node.js did not have it. Exactly one of dest and error is set, and ok says which (TypeScript cannot express “exactly one of”, which is why ok exists):

// after (3.0)
const { htmlFilesToMarkdown } = require('mdka')

async function main() {
  for (const r of await htmlFilesToMarkdown(['a.html', 'b.html'], 'out/')) {
    if (r.ok) console.log(`${r.src} -> ${r.dest}`)
    else console.error(`${r.src}: ${r.error}`) // was: if (r.error) …
  }
}
main()

A failing file does not reject the promise and does not stop the others. The promise rejects only when the call as a whole cannot proceed — the output directory cannot be created (cannot create out_dir: …) or the options are wrong. That is the difference by arity, on purpose: with one file, failing the call is the answer; with many, one bad file must not hide the rest.

TypeScript

ConvertResult is no longer exported; import FileOutcome. At 3.0, JsConversionOptions had three fields, mode, preserveIds and dropInteractiveShell; later releases have added more — see Options for the current set:

import { htmlFilesToMarkdown, type FileOutcome, type JsConversionOptions } from 'mdka'

const opts: JsConversionOptions = { mode: 'minimal' }

async function main() {
  const outcomes: FileOutcome[] = await htmlFilesToMarkdown(['a.html'], 'out/', opts)
  for (const o of outcomes) console.log(o.ok, o.src)
}
main()

An unrecognised option key is now an error

This is a behaviour change for code that was already wrong. Until 3.0, napi silently dropped any option key it did not know, so { preserveClasses: true }, or a typo like { mdoe: 'minimal' }, did nothing and told you nothing. It is now rejected, naming the key, and a key removed in 3.0 says so:

option 'preserveClasses' was removed in 3.0; Markdown has no attribute syntax, so it never changed the output. Remove it: the output is the same without it.
option 'unwrapUnknownWrappers' was removed in 3.0; it could not change the output. Remove it: the output is the same without it.
unknown option 'mdoe'. Valid options: mode, preserveIds, dropInteractiveShell

The synchronous functions (htmlToMarkdown, htmlToMarkdownMany) throw it; the Promise-returning ones (htmlToMarkdownAsync, htmlFileToMarkdown, htmlFilesToMarkdown) reject with it. So a call that passed a removed option and worked before will now fail: search your code for preserveClasses, preserveDataAttrs, preserveAriaAttrs and unwrapUnknownWrappers and delete them. (The TypeScript compiler already flagged them.) null and undefined are still accepted as “no options”.

Python

html_file_to_markdown and html_file_to_markdown_with return the destination path as a str, and still raise MdkaError on failure. They used to return a ConvertResult, which is gone:

# after (3.0)
import mdka

dest = mdka.html_file_to_markdown("page.html", "out/")  # was: result.dest
print(f"wrote {dest}")

try:
    mdka.html_file_to_markdown("/no/such/file.html")
except mdka.MdkaError as e:
    print(e)  # IO error: …

BulkConvertResult is renamed FileOutcome, with the same four fields (src, dest, error, ok). Exactly one of dest and error is set and ok says which. It is frozen: you cannot assign to its attributes. Its repr is now Python’s own with all four fields — it used to print error=Some("…"), a Rust Option, and omit dest and ok:

# after (3.0)
import mdka

for r in mdka.html_files_to_markdown(["a.html", "b.html"], "out/"):
    if r.ok:
        print(r.src, "->", r.dest)
    else:
        print("skipped", r.src, r.error)
    # repr(r) == "FileOutcome(src='a.html', dest='out/a.md', error=None, ok=True)"

A failing file does not raise and does not stop the others. MdkaError is raised only if the output directory cannot be created.

Also gone: ConversionMode.Strict, Semantic and Preserve (AttributeError), and the keyword arguments preserve_classes, preserve_data_attrs, preserve_aria_attrs and unwrap_unknown_wrappers (TypeError: … got an unexpected keyword argument). Rename ConvertResult or BulkConvertResult in your type annotations to str and FileOutcome. mdka.version() is unchanged.

CLI

Five things were removed. Each now exits with status 1 and one line on stderr that says what to do, so a script that still passes one fails loudly instead of looking as though it worked:

$ mdka --mode strict page.html
error: conversion mode 'strict' was removed in 3.0; it was an alias of 'balanced'. Use 'balanced'.

$ mdka --preserve-classes page.html
error: `--preserve-classes` was removed in 3.0; Markdown has no attribute syntax, so it never changed the output. Remove it from the command line: the output is the same without it.

$ mdka --unwrap-wrappers page.html
error: `--unwrap-wrappers` was removed in 3.0; it could not change the output. Remove it from the command line: the output is the same without it.

$ mdka --mode bogus page.html
error: unknown conversion mode: bogus. Valid: balanced|minimal

--mode semantic, --mode preserve, --preserve-data and --preserve-aria read like the first two. Remove the flag, or change the mode to balanced; the output is the same. --preserve-ids, --no-preserve-ids and --drop-shell are unchanged. The in.html -> in.md progress lines are unchanged.

What did not change

A migration guide that only lists losses reads like a bigger break than it is. Unchanged in 3.0:

  • The string conversions, in every language: html_to_markdown, html_to_markdown_with, html_to_markdown_many, html_to_markdown_many_with (Rust, Python), htmlToMarkdown, htmlToMarkdownAsync, htmlToMarkdownMany (Node.js — which gained an optional options argument, an additive change).
  • Balanced and Minimal, unrenamed and byte-identical.
  • preserve_ids and drop_interactive_shell, the two options that act on output.
  • version(), in all three.
  • Every element and escaping rule. No output moved: see the opening.
  • MdkaError, and Python’s ConversionMode, MdkaError and _with function pairs.

Finding what you need to change

BindingSearch for
RustConvertResult, html_file_to_markdown, html_files_to_markdown, parse_mode, Strict, Semantic, Preserve, preserve_classes, preserve_data_attrs, preserve_aria_attrs, preserve_unknown_attrs, drop_presentation_attrs, unwrap_unknown_wrappers — the compiler will find most of them
Node.jshtmlToMarkdownWith, WithAsync, htmlFileToMarkdown, htmlFilesToMarkdown, ConvertResult, .dest, .error, preserveClasses, preserveDataAttrs, preserveAriaAttrs, unwrapUnknownWrappers, and any mode: string
PythonConvertResult, BulkConvertResult, html_file_to_markdown, Strict, Semantic, Preserve, preserve_classes, preserve_data_attrs, preserve_aria_attrs, unwrap_unknown_wrappers
CLI and scripts--mode, --preserve-classes, --preserve-data, --preserve-aria, --unwrap-wrappers; and any config file that sets a mode

API Reference

mdka has one core and two bindings, and they are not identical. The tables below list every exported function and type in all three — Rust, Node.js and Python — one row per operation, with each language’s own name and an em dash (—) where a language does not have it. The gaps are real, and they are the reason for the layout.

This page describes the current surface. The Rust functions have reference pages (Core Functions, Error Handling); Node.js and Python are documented in their usage pages, and their error behaviour is in Error Handling (Node.js).

Functions

OperationRustNode.jsPython
Convert one string (default mode)html_to_markdownhtmlToMarkdown (options optional)html_to_markdown
… with optionshtml_to_markdown_with— (htmlToMarkdown takes them)html_to_markdown_with
… asynchronously—htmlToMarkdownAsync (options optional)—
Convert many strings (default mode)html_to_markdown_manyhtmlToMarkdownMany (options optional)html_to_markdown_many
… with optionshtml_to_markdown_many_with— (htmlToMarkdownMany takes them)html_to_markdown_many_with
Convert one filehtml_file_to_markdownhtmlFileToMarkdown (async, options optional)html_file_to_markdown
… with optionshtml_file_to_markdown_with— (htmlFileToMarkdown takes them)html_file_to_markdown_with
Convert many files (parallel)html_files_to_markdownhtmlFilesToMarkdown (async, options optional)html_files_to_markdown
… with optionshtml_files_to_markdown_with— (htmlFilesToMarkdown takes them)html_files_to_markdown_with
The library’s versionversionversionversion

That is 9 functions in Rust, 6 in Node.js and 9 in Python. What the table shows:

  • Node.js has no …With functions. JavaScript has optional arguments, so htmlToMarkdown(html, options?) is the idiom, and each Node.js function takes its options optionally. Rust has no optional parameters, so a _with pair is the idiom there, and Python keeps its pair for consistency with itself.
  • Only Node.js has an async string function, and only one: htmlToMarkdownAsync. Neither Rust nor Python has one.
  • Node.js’s file functions are async-only — they return a Promise. Rust’s and Python’s block until done.
  • What a file function returns differs by arity, on purpose. One file returns the destination path and fails the call through the language’s own channel; many files return one FileOutcome each, so one failure cannot hide the rest. See Error Handling.

Types

TypeLanguageDescription
ConversionModeRust, PythonEnum (Rust) / class (Python): Balanced · Minimal. Node.js has no such type: it passes the mode as a string in mode
ConversionOptionsRustControls pre-processing per call; built via for_mode()
JsConversionOptionsNode.jsThe options object taken by every Node.js function except version. Every field is optional: mode (a string), preserveIds and dropInteractiveShell. An unrecognised key is an error that names it, and a key removed in 3.0 says so. Python takes the same fields as keyword arguments instead
FileOutcomeRust, Node.js, PythonWhat happened to one file in a bulk conversion. Rust: { src: PathBuf, result: Result<PathBuf, MdkaError> }. Node.js and Python cannot export a Result, so they flatten it: src, dest, error and ok, where exactly one of dest and error is set and ok says which
MdkaErrorRust, PythonRust: the only error type, an enum whose one variant wraps std::io::Error. Python: the exception the file functions raise. Node.js has no error class: it rejects with a plain Error

Guarantees

Everywhere — Rust, Node.js and Python:

  • The string conversions accept any string — empty, binary-looking, or deeply nested — and never panic. (In Node.js and Python a bad option is different: an unknown mode string throws or rejects in Node.js, and a str where a ConversionMode belongs is a TypeError in Python. That is a bad argument, not bad HTML.)
  • Output is always valid UTF-8.
  • Output always ends with a single newline when the input produces any content.

Rust only:

  • File functions report IO errors as a Result<_, MdkaError>. The bindings do not: Python raises MdkaError, and Node.js rejects with an Error (or, for a failing file inside a bulk call, returns it in that entry’s error). See Error Handling.

Core Functions

This page is the Rust reference. Every Rust function below has a Node.js and a Python counterpart, and some operations exist in only one binding — the table on the API Reference lists all three side by side.

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_to_markdown_many

pub fn html_to_markdown_many<S>(htmls: &[S]) -> Vec<String>
where
    S: AsRef<str> + Sync,

Converts many HTML strings, each independently, with the default Balanced mode. Equivalent to calling html_to_markdown on each.

Input: A slice of anything that is AsRef<str> — &[&str], &[String].
Output: A Vec<String> in the same order and of the same length as the input. An empty slice gives an empty Vec.
Errors: None — this function is infallible, so it returns plain strings and not a Result.

The conversions run in parallel across CPU cores when the parallel feature is on (the default), and sequentially otherwise. The function exists either way; only how it runs changes.

let mds = mdka::html_to_markdown_many(&["<h1>A</h1>", "<h1>B</h1>"]);
assert_eq!(mds, vec!["# A\n", "# B\n"]);

html_to_markdown_many_with

pub fn html_to_markdown_many_with<S>(htmls: &[S], opts: &ConversionOptions) -> Vec<String>
where
    S: AsRef<str> + Sync,

Same as html_to_markdown_many, but applies the given ConversionOptions to every string.

Errors: None.

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

let opts = ConversionOptions::for_mode(ConversionMode::Minimal);
let mds = mdka::html_to_markdown_many_with(&["<p>a</p>", "<p>b</p>"], &opts);
assert_eq!(mds.len(), 2);

html_file_to_markdown

pub fn html_file_to_markdown(
    path: impl AsRef<Path>,
    out_dir: Option<impl AsRef<Path>>,
) -> Result<PathBuf, 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: the path of the .md file that was written. The caller already has the input path, and a failure is the Err, so there is nothing else to return.
Errors: MdkaError::Io if the file cannot be read or the output cannot be written. A single file fails the call; only html_files_to_markdown reports per file.

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

    // page.html → out/page.md
    let dest = mdka::html_file_to_markdown("page.html", Some("out/"))?;
    println!("wrote {}", 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<PathBuf, MdkaError>

Same as html_file_to_markdown, but applies the given ConversionOptions.


html_files_to_markdown

pub fn html_files_to_markdown<P>(paths: &[P], out_dir: &Path) -> Vec<FileOutcome>
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 FileOutcome in the same order as paths. Each element is the outcome for one file independently: a failing file does not stop the others, and the outcome names the file it belongs to.

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 outcome in mdka::html_files_to_markdown(&files, Path::new("out/")) {
        let src = outcome.src.display();
        match outcome.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<P>(
    paths: &[P],
    out_dir: &Path,
    opts: &ConversionOptions,
) -> Vec<FileOutcome>
where
    P: AsRef<Path> + Sync,

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


version

pub fn version() -> &'static str

The crate’s version, as declared in Cargo.toml.

Errors: None.

assert!(!mdka::version().is_empty());

FileOutcome

pub struct FileOutcome {
    pub src:    PathBuf,
    pub result: Result<PathBuf, MdkaError>,
}

What happened to one file in a bulk conversion. src is the input path as it was passed in; result is the path that was written, or the error that stopped that file — exactly one of the two, which the type expresses.

The distinction behind it is can it fail, not one versus many. A string conversion cannot fail, so it returns a string. A single file can, and you asked about exactly one thing, so it fails the call: html_file_to_markdown returns the destination path or an Err. Many files can each fail, and one failure must not abort the batch, so each gets its own outcome.

The Node.js and Python bindings cannot export a Rust Result, so they flatten it to src, dest, error and ok; see the Types table.

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?Converts
Balanced✅ YesGeneral use
MinimalDifferently: drops shell elements, emits no id anchors

There are two modes, and they make two behaviours.

Removed in 3.0: Strict, Semantic and Preserve

Until 3.0 there were five modes. Strict, Semantic and Preserve were aliases of Balanced: they produced byte-for-byte the same output and could not differ, so they were deprecated in 2.8.0 and removed in 3.0.

Why they could not differ: the presets differed from each other only in the defaults of six fields that could not change the output — five about HTML attributes, for which Markdown has no syntax, and one about wrapper elements, whose unwrapping leaves no trace in Markdown. Their names described purposes (“for debugging”, “for archiving”, “for accessibility”) that the conversion could not act on. Nothing that used one converts differently now; the name only has to change.

What to do: use Balanced, or name no mode at all. Use Minimal if you wanted something genuinely different.

What you see if you still name one, on each surface — all of them fail, and say removed rather than unknown, because a removed name is an obsolete configuration, not a wrong one:

SurfaceWhat happens
Rust, "strict".parse::<ConversionMode>()Err("conversion mode 'strict' was removed in 3.0; it was an alias of 'balanced'. Use 'balanced'."). The variants ConversionMode::Strict, Semantic and Preserve no longer exist, so naming one is a compile error. ConversionMode::parse_mode returns None and no message, as it does for any name it does not accept — which is why it is deprecated since 3.0.0 in favour of str::parse, which reports why a name was rejected
Node.jshtmlToMarkdown and htmlToMarkdownMany throw, and htmlToMarkdownAsync and the file functions reject, with the same message
PythonConversionMode.Strict, Semantic and Preserve do not exist: AttributeError. A mode is never a string in Python, so there is no string form
CLImdka --mode strict exits with status 1 and prints error: conversion mode 'strict' was removed in 3.0; it was an alias of 'balanced'. Use 'balanced'. on stderr

A name that never existed (--mode bogus) still gets the unknown message, with the list of valid modes.

It is asserted, not just observed. tests/output_validity/mode_identity.rs runs a corpus of inputs — including wrappers in table cells, list items, quotes and <pre> — through Balanced and Minimal and fails if either differs by a byte from what the published 2.9.0 produced (P3).

Minimal is the only mode that converts differently from Balanced — it drops shell elements (drop_interactive_shell) and does not emit id anchors (preserve_ids).


Balanced (default)

What it does: keeps id attributes (emits anchors) and keeps shell elements (nav/header/footer/aside). A wrapper element (<div>, <section>, <article>, <main>) is rendered as the paragraph break it stands for.

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

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


Minimal

What it does: drops shell elements (nav/header/footer/aside and their children), does not emit id anchors, and unwraps wrapper elements — which changes nothing in the output on its own, since unwrapping keeps the paragraph break the wrapper stood for.

One thing it does change: with emphasis_from_style on, a wrapper’s own inline style is what would have carried the bold/italic — unwrapping discards the tag, and there is nothing left to read it from. A <span style="font-weight:700"> that would gain bold in Balanced gains nothing in Minimal, including the Google Docs paste shape the option exists for (see that page for the example). This is not a limit of the option; it follows from Minimal unwrapping the element before anything downstream can read it.

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

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

Choosing a Mode

Want the most aggressive extraction (LLM input, etc.)? → Minimal
Everything else                                        → Balanced (default)

ConversionOptions

pub struct ConversionOptions {
    pub mode: ConversionMode,
    pub preserve_ids:           bool,
    pub drop_interactive_shell: bool,
    pub emphasis_from_style:    bool,
    pub backslash_hard_breaks:  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.

There are five fields, and four of them act on the output: preserve_ids, drop_interactive_shell, emphasis_from_style and backslash_hard_breaks. Six more existed until 3.0 and are described under Removed in 3.0 below — none of them ever changed a byte of output.

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

FieldBalancedMinimalEffect
preserve_ids✅❌Emits anchors
drop_interactive_shell❌✅Drops shell elements
emphasis_from_style❌❌Lets inline style add bold/italic
backslash_hard_breaks❌❌Writes a hard break as \ + newline, not two spaces

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.

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.

emphasis_from_style

mdka reads the element, not the environment. An inline style attribute is part of the element it appears on — it arrived in the document mdka was handed, and reading it is reading the input. A class is a reference to a stylesheet mdka was never given; resolving it would mean inventing what mdka cannot see.

  • <span style="font-weight:700"> works — the document itself says bold.
  • <span class="c7"> never will, whatever the stylesheet says — mdka does not read stylesheets, and this is permanent, not merely unimplemented.
  • <style> blocks and linked stylesheets are out of scope for the same reason.

With this option on (default off), any element’s inline style can add bold and/or italic it would not otherwise carry, read independently:

PropertyAddsValue grammar
font-weightboldbold, or a number ≥ 600
font-styleitalicitalic or oblique

A value the grammar does not recognise (bolder/lighter — relative to a parent’s computed weight mdka does not track — a number strictly between 500 and 600, or anything unparseable) is read as “this element says nothing”, the same as no style at all: never an error, and never guessed at.

Turning this on can change output for any document that has such a style. That is why it is opt-in.

Independently of the option, and unaffected by it, a font-weight of normal or ≤ 500 (or a font-style of normal) still removes the emphasis a <b>/<strong>/<i>/<em> tag would otherwise carry on its own — this shipped in 3.0.0, before this option existed, most often seen on a Google Docs paste’s own un-bolding wrapper, and stays on with the option off:

<b style="font-weight:400">not actually bold</b>

→ not actually bold (no **), with emphasis_from_style either way.

A style that only restates a tag’s own default adds nothing, either. b/strong/i/em are not the only elements whose own rendering is already bold or italic by the browser’s default stylesheet:

  • Bold by default: b, strong, h1–h6, th
  • Italic by default: i, em, cite, address, var, dfn

For the wider set, the rule is the same one the negation case above already relies on: a style saying only what the tag already means is not new information, so it must not add a span the tag’s own rendering never had:

<h1 style="font-weight:700">Title</h1>

→ # Title (no **), with emphasis_from_style either way. A heading’s # already carries the whole meaning; # **Title** would say “a heading containing bold text,” which the source did not mean — there is no markdown for “heading, and also bold,” nor any need.

cite, address, var and dfn convert as plain text (see Block Elements) independently of this option — restating their default italic rendering via style does not change that:

<cite style="font-style:italic">A Work</cite>

→ A Work, with emphasis_from_style either way. The same input converts identically whether or not the source happened to inline the tag’s own default style — unlike the option’s general case, output here does not depend on which browser produced the clipboard HTML.

th is in the bold-default set for the same reason, even though table cells never receive emphasis from this option regardless (see the table boundary below): the set describes what the tag’s own rendering already means, not only the paths this option happens to reach today.

An authored style nested inside one of these tags still works — only the outer tag’s own restated default is ignored, not a genuinely distinct declaration on a descendant:

<h1 style="font-weight:700"><span style="font-weight:700">Title</span></h1>

→ # **Title** — the <span> is not a heading, and its own style still adds emphasis.

A style that removes one of these tags’ own default (font-weight:400 on a heading, say) has no visible effect, unlike the <b> case just above: a heading’s # never consults the emphasis-span state at all, so there is no markdown for “un-bolding” a heading — nothing to remove, where <b> had an actual span.

With the option on, a declaration on a container reaches paragraphs and headings among its descendants, including one nested inside a quote or a list item — not every descendant a browser would render bold. A style on a <div> applies to the <p>s inside it, each opening and closing its own bold:

<div style="font-weight:700"><p>a</p><p>b</p></div>

→

**a**

**b**

Two boundaries worth knowing, both invisible in rendered HTML:

  • Whether a list item or a quote is emphasised depends on whether its text is wrapped in <p>. <blockquote style="font-weight:700">a</blockquote> stays plain (> a); <blockquote style="font-weight:700"><p>a</p></blockquote> is emphasised (> **a**). A browser renders both the same; mdka’s rule is about the Block kind the traversal already builds, not about how the bare text would look on screen.
  • Table cells never receive it, even wrapped in <p>. <div style="font-weight:700"><table><tr><td><p>a</p></td></tr></table></div> converts with a plain in its cell, same as without the <p>.

Neither is planned to widen: the option’s decided cases are met, it is off by default, and output with it off is byte-identical to 3.0.0.

In Minimal mode, the option does nothing for its own motivating case. Minimal unwraps <span>/<div>/<section>/<article>/<main> before this option — or anything else — can read their style; a discarded tag carries no declaration to read. The Google Docs paste shape this option was built for:

<b style="font-weight:normal" id="…"><span style="font-weight:700">bold</span> plain</b>

→ **bold** plain in Balanced, but plain bold plain in Minimal — the <span> that carried the bold declaration is gone by the time anything downstream could read it. This follows from Minimal’s own wrapper unwrapping, not from a limit of this option: see Conversion Modes for why. A reader converting Google Docs pastes in Minimal should not expect this option to help.

backslash_hard_breaks

A hard break (<br>) is written as two trailing spaces and a newline by default, and as a backslash and a newline with this option on. The two are the same break in CommonMark — they parse to the same events — so the choice is about what survives the editor and the renderer, not about meaning. Pick by which of these your Markdown will meet:

survives whitespace strippingworks in Python-Markdown
two trailing spaces (default)no — many editors strip them on save, and markdownlint’s MD009 flags themyes
backslash (this option)yesno — Python-Markdown (the engine behind MkDocs) does not read it as a break and shows a literal \

Neither form dominates. If your Markdown is edited in an editor that strips trailing whitespace, the backslash keeps the break; if it is rendered by Python-Markdown, the spaces are the only form that breaks. The default stays the spaces, so output is unchanged for everyone who does not ask.

<p>one<br>two</p>

→ one ⏎two by default (two trailing spaces), and one\⏎two with backslash_hard_breaks.

Where the option is not applied, by design:

  • In a heading, a hard break cannot exist, and the backslash would stay visible in the heading text, so the heading keeps its two spaces either way (## one ⏎two).
  • At the end of its block, a <br> is not a break. The default still writes its two trailing spaces there; CommonMark ignores them, so they are inert. With this option on, nothing is written at all, so no literal \ can appear.
  • In a table cell, <pre> and a code span, the break is written as before.

A run of <br> is the one place the default is actually wrong, and this option fixes it for that case. a<br><br>b in the default writes a whitespace-only line between the two breaks. CommonMark reads that as a blank line, so the default splits the run into two paragraphs. With this option on, every break in the run is a backslash, and the run stays one paragraph with every break kept. The default is not changed, so output without the option is exactly what it was.

Removed in 3.0

Six fields were removed, along with the builder method preserve_aria_attrs. None of them ever changed the output, in any released version, so removing them changes nothing about how a document converts — only code that names them has to change. Delete the assignment, the keyword argument or the flag.

RemovedWhere it existedWhy it could not act
preserve_classes, preserve_data_attrs, preserve_aria_attrs, preserve_unknown_attrs, drop_presentation_attrsRust (all five). Node.js and Python had the first three; the CLI had --preserve-classes, --preserve-data and --preserve-ariaMarkdown has no syntax for HTML attributes, so “preserve” or “drop” an attribute was never expressible in the output. Deprecated since 2.2.0; see RFC 005 for the analysis
unwrap_unknown_wrappersRust; unwrapUnknownWrappers in Node.js; unwrap_unknown_wrappers in Python; --unwrap-wrappers on the CLIUnwrapping a wrapper element (<div>, <section>, <article>, <main>) removes the tag but keeps the paragraph break it stood for, and Markdown has no wrapper element to show the difference. Deprecated since 2.9.0

The two reasons are not the same. The attribute options can never come back: Markdown has no attribute syntax. unwrap_unknown_wrappers was inert because of what the renderer leaves behind, so if wrapper handling ever becomes expressible it returns as a new option, designed for what it can then do — not as a repair of the old field. (Minimal still unwraps wrappers, internally; that is a property of the mode, not something you can set.)

What you see if you still use one:

SurfaceWhat happens
RustCompile error: the field (and ConversionOptions::preserve_aria_attrs) no longer exists
Node.jsTypeScript: a compile error. Plain JavaScript: nothing — the option is ignored, with no error and no warning
PythonTypeError: … got an unexpected keyword argument
CLIExit status 1 and one line on stderr saying the flag was removed and what to do

<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, html_to_markdown_with, html_to_markdown_many and html_to_markdown_many_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(dest)              => println!("→ {}", 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 dest = 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, and each FileOutcome names the file it belongs to:

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

That is the difference by arity, on purpose: one file fails the call (the Err of html_file_to_markdown), many report per file. If the output directory cannot be created, Rust reports it on every outcome, since creating it is part of converting each file; the CLI and the bindings check it once, up front, and fail the call instead (below).


Python errors

Python raises. mdka.MdkaError is an ordinary subclass of Exception.

CallOn failure
html_file_to_markdown, html_file_to_markdown_withRaises MdkaError when the file cannot be read or the output cannot be written. The message is IO error: …. On success they return the destination path, as a str
html_files_to_markdown, html_files_to_markdown_withRaises MdkaError only if the output directory cannot be created (cannot create out_dir: …). A failing input file does not raise: its entry comes back with ok == False, dest is None and error set, and the other files are unaffected
the string functionsNo MdkaError. Passing a str where a ConversionMode belongs (mode="balanced") is a TypeError: Python does not accept a mode as a string

Each element of a bulk result is a FileOutcome — src, dest, error and ok, and exactly one of dest and error is set:

for r in mdka.html_files_to_markdown(files, "out/"):
    if r.ok:
        print(r.src, "->", r.dest)
    else:
        print("skipped", r.src, r.error)

Node.js errors

Node.js has no error class: every failure is a plain Error, and a file problem’s message begins IO error: . Every file function is async, so a failure is a promise rejection.

CallOn failure
htmlFileToMarkdownThe promise rejects with an Error (IO error: …) when the file cannot be read or the output cannot be written. On success it resolves to the destination path, a string
htmlFilesToMarkdownThe promise rejects only if the output directory cannot be created (cannot create out_dir: …). A failing input file does not reject: its entry in the resolved array has ok: false and error set, and the other files are unaffected
htmlToMarkdown, htmlToMarkdownManyThrow an Error for a bad option: an unknown mode string (unknown conversion mode: …), a mode removed in 3.0, or an unrecognised or removed option key (which the message names)
htmlToMarkdownAsync and the file functionsReject with the same message for a bad option
for (const r of await htmlFilesToMarkdown(files, 'out/')) {
  if (r.ok) console.log(`${r.src} -> ${r.dest}`)
  else console.error(`${r.src}: ${r.error}`)
}

A bulk entry is a FileOutcome: src, ok, and exactly one of dest and error. A single-file call has no such object — it resolves to the destination path or rejects — which is the arity difference described above.

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
<li><input type="checkbox">…- [ ] … / - [x] … (checked)Only when the checkbox is the item’s own first content; any other <input>, or one anywhere else in the item, is not a task marker
<hr>---
<dl>—Not itself rendered; a transparent container. <dt>/<dd> do the work, below
<dt>, <dd>Block separatorEach its own paragraph-like block, in source order. A <dl> therefore reads as a run of paragraphs — Markdown has no definition-list syntax, and inventing one (bolding the term, a - prefix) would assert a structure the source never had
<div>, <article>, <section>, <main>Block separatorAct as paragraph breaks; unwrapped (tag removed, children kept) in Minimal, rendered in Balanced; the output is the same either way
<figure>, <figcaption>Block separatorNever unwrapped, in any mode. These carry structural meaning that unwrapping is not meant to discard — they’re excluded from the wrapper-candidate set entirely, not merely blocked by a secondary check
<table>GFM table, or a non-welding fallbackSee Tables

Inline Elements

HTMLMarkdown outputNotes
<strong>, <b>**text**No delimiters when the element’s own inline style sets font-weight to normal or a number ≤ 500: <b style="font-weight:normal">x</b> converts to x. Google Docs, for one, wraps pasted content in such an element. bolder, lighter and numbers above 500 do not suppress anything. Only this one property, on this element, is read. Nothing is inherited, and bold is never added: text made bold only by <span style="font-weight:700">, or by a font-weight on a parent element, converts as plain text
<em>, <i>*text*No delimiters when the element’s own inline style sets font-style: normal. The same rule: only that property, on that element, and italic carried only by a <span style="font-style:italic"> is lost
<del>, <s>~~text~~No delimiters at all if the element wraps a block, or is inside code — the same rule <strong>/<em> already follow
<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")
<sup>Plain text (an ordinal, 1st/1º), a Unicode superscript (², ⁿ, ⁻⁹), or a marker: ^(…)See Superscript and subscript below
<sub>A Unicode subscript (₂, ₙ, ᵢ), or a marker: _(…)Same rules; see below
<br> \n (trailing two spaces + newline)

Superscript and subscript

<sup> and <sub> are converted by four rules, tried in this order. The reason there is a marker rule at all (rule 4): 2<sup>n − 1</sup> used to convert to 2n − 1, which reads as “two times n minus one” — a different statement, not a lossy one.

The rule behind all four: render notation, flatten typography. A superscript is notation when flattening it changes what it means — 10⁻⁹ → 10-9 says something different, so it is rendered. It is typography when flattening changes nothing — 1st is 1st either way, so it is flattened rather than turned into notation that only looks raised.

  1. <sup> only: an English ordinal suffix or a Spanish ordinal indicator, immediately after the digit it modifies, is written as plain text — not mapped to Unicode. 1<sup>st</sup> 2<sup>nd</sup> 3<sup>rd</sup> 4<sup>th</sup> → 1st 2nd 3rd 4th; 1<sup>º</sup> 2<sup>ª</sup> → 1º 2ª. These two are a closed set — st, nd, rd, th, º (U+00BA), ª (U+00AA) — not a shape: the same letters elsewhere are notation and still map under rule 2, for instance 10<sup>n</sup> → 10ⁿ. Not flattened, deliberately: French 1<sup>er</sup> and 2<sup>e</sup> stay mapped to 1ᵉʳ/2ᵉ — 10<sup>e</sup> is a legitimate exponent (ten to the power e), so flattening e/er would destroy notation to tidy typography, the exact error this rule exists to avoid. Not in scope: <sup>TM</sup> and <sup>®</sup> stay as ^(TM)/^(®) — mapping them to ™/® is a different change, a mapping rather than a flattening, and is not made here.

  2. A real Unicode superscript or subscript, when every character has one. 10<sup>−9</sup> → 10⁻⁹, x<sup><i>n</i></sup> → xⁿ, H<sub>2</sub>O → H₂O, x<sub>max</sub> → xₘₐₓ. The characters that map are digits, + - = ( ), U+2212 MINUS SIGN (treated as -), and lowercase letters:

    • <sup>: a b c d e f g h i j k l m n o p r s t u v w x y z — 25 of 26, there is no superscript q;
    • <sub>: a e h i j k l m n o p r s t u v x — only 17, Unicode has no subscript b c d f g q w y z, so x<sub>y</sub> can never map.

    Content that is only emphasis (<i>, <em>, <b>, <strong>, <var>) is seen through: a superscript character cannot carry emphasis anyway. The rule is all or nothing — a run with one character that has no form (an uppercase letter, /, a space) is not half-converted, which would be a different number.

  3. Text that already delimits itself is left exactly as it was. If the text inside is bracketed — [1] or (a b) — it is a marker already (a citation, a note, a parenthetical) and nothing is added. This is why the citation markers on real pages (<sup><a href="#c1">[1]</a></sup>) convert as they always did.

  4. Everything else gets a visible marker, always parenthesised: 2<sup>n − 1</sup> → 2^(n − 1), x<sub>y</sub> → x_(y), x<sup>N</sup> → x^(N). Emphasis inside survives: x<sup><i>n</i> + N</sup> → x^(*n* + N). The subscript marker’s _ is escaped (\_(y)) wherever it could otherwise pair with another underscore and italicise the text between them — after a space or punctuation, inside emphasis, or when an unescaped _ is already open in the paragraph.

Inside code (<pre>, <code>) nothing is mapped and no marker is written; the text stays as it is. <sup>/<sub> are converted the same way in every mode.

<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.

<ins> and <u> are also not in either table: their children are kept as plain text, with no markup at all. GFM has no insertion or underline syntax, and ~~ (the closest available delimiter) would say the opposite of what <ins> means, so nothing is emitted rather than something misleading.

Also kept as plain text, permanently rather than pending: <cite>, <abbr>, <q>, <kbd>, <small>, <time>, <mark>. Markdown has no syntax for any of them, and inventing one — the way a bolded <cite> or a blockquoted <q> would — is the mistake this project does not make.

Tables

Almost every <table> becomes a GFM table. Only four shapes are inexpressible: more than one whole-row header, the row-header pattern (<th> used as each row’s own first cell rather than as a whole header row), a nested <table> anywhere in a cell, and <caption> — GFM has no syntax for any of these.

<table><thead><tr><th>Name</th><th>Age</th></tr></thead>
<tbody><tr><td>Alice</td><td>30</td></tr></tbody></table>

converts to:

| Name | Age |
| --- | --- |
| Alice | 30 |

A table missing a <th> entirely gets an empty header row synthesized over the data, rather than promoting row 1 to a heading the source never wrote. colspan/rowspan are expanded by repeating the spanned content across every cell it covers, header included. A cell holding block content — multiple paragraphs, a list, a code block, a heading, a blockquote — flattens to inline: paragraphs join with <br>; a list becomes <br>-separated - /N. marker text (two spaces of indent per nesting level); a code block becomes one code span per source line, <br>-joined; a heading or blockquote contributes its text plain, without inventing emphasis or a quote marker the cell can’t hold:

<table><tr><th colspan="2">Name</th><th>Age</th></tr>
<tr><td>Alice</td><td>Smith</td><td>30</td></tr>
<tr><td colspan="2"><p>Bob Jones</p><p>(pending)</p></td><td>27</td></tr></table>

converts to:

| Name | Name | Age |
| --- | --- | --- |
| Alice | Smith | 30 |
| Bob Jones<br>(pending) | Bob Jones<br>(pending) | 27 |

Alignment is carried from align= or text-align: into :--/--:/:-:. A literal | in a cell is escaped (\|), including one produced by a nested code span, link, or image. A ragged row — one with a different cell count from the header — is not a blocker: GFM pads a short row, and a body row longer than the header widens the whole table’s grid instead (an empty header cell is synthesized over the extra column, so nothing is lost). <br> inside a cell survives as literal inline HTML rather than a Markdown hard break, which would end the cell early.

A table that is inexpressible never welds its cells together — the H1H2ab defect below is fixed regardless of which case applies. Every <tr>/<td>/<th>/<caption> renders as its own paragraph-like block, so row and column structure is not preserved, but every cell’s text is clearly separated:

<table><tr><th>R1</th><td>1</td></tr><tr><th>R2</th><td>2</td></tr></table>

converts to R1, 1, R2, 2, each its own paragraph (blank-line separated) — not R11R22. A nested table is analyzed on its own terms: it may become its own expressible table even while the table containing it falls back. A <caption> is never dropped silently; it appears as its own paragraph, in its document position.

Turning the row/column structure of an inexpressible table into a real GFM table (flattening a cell’s block content, synthesising a header where none exists, expanding a span) is a separate, larger piece of work, tracked as its own follow-up. See ROADMAP.md for scheduling.

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 every mode, including Minimal, which unwraps it — unwrapping removes the tag, not the paragraph break it stood for; 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

Exception: a nested list whose own first item is empty gets a blank line first, regardless. Without one, its bare marker line is not read as a nested list at all — an unordered marker alone is a setext-heading underline for the text above it, and an ordered one is absorbed as that text’s own lazy-continuation, taking any non-empty siblings after it along (RFC 038). mdka inserts the blank line to keep the output unambiguous. This is a disambiguation, not a looseness call: the rule above is unchanged, and the list itself still counts as tight by it — but CommonMark reads any blank line as loose once it sees one, so a reparse of this shape shows every item’s text wrapped in a paragraph regardless of what counted this list as tight in the first place. A nested list whose first item has real content, like inner above, is never affected.

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

HTMLCurrent behaviourStatus
<video>, <audio>No output for the media element itselfNot yet scheduled

Tables were the largest gap here until they gained the treatment described in Tables, above: an expressible table is real GFM; anything else at least never welds its cells together. <dl>, <del>/<s>, checkbox list items and <sup>/<sub> closed the remaining ones — see their own rows in Block Elements and Inline Elements, above.

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. 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 — that is Minimal. A document being archived for audit purposes wants nothing removed — that is Balanced, the default; there is no stronger preset.

Those are the two conversion modes. (There were five until 3.0; the other three were aliases of Balanced and were removed.) The preset is 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

These figures describe mdka 3.0.0, not the current release — see 3.1.0 to 3.2.0 immediately below for what changed since. They describe 3.0.0 (main @ 2756c47, the tagged 3.0.0 release plus two documentation-only commits), measured 2026-09-30, with load average recorded rather than merely asserted (see Conditions). They replace the 2.3.0-era numbers this page carried between 2.3.0 and 3.0.0, and they also replace this page’s own first 3.0.0 regeneration — that first sitting had unrecorded background load, and two of its findings did not survive a clean re-run. Both are corrected below rather than quietly dropped.

3.1.0 to 3.2.0: what changed, and what was not re-measured

Added 2026-10-01 after 3.1.0; extended 2026-10-06 to cover 3.1.1 and 3.2.0. The figures below were measured at 3.0.0 and have not been regenerated. That is deliberate — regeneration costs over three hours of a single machine, and RFC 012 §9 records that this page is a dated snapshot, not a release obligation. What a reader needs instead is to know whether 3.1.0 invalidates it, so here is the honest answer in both directions.

3.1.0 did change the hot path, for every document, whether or not you use its new option. RFC 049 replaced the emphasis tracker — one frame per matched <strong>/<b>/<em>/<i> — with two inherited-value stacks that every element pushes and pops, block or inline. emphasis_from_style being off by default does not avoid that work.

Allocation was measured, and it went down. Paired, same machine, same toolchain, interleaved, three runs each, zero variance on every point:

Workload3.0.03.1.0delta
Single string conversion10 507 B10 319 B−188 B (−1.8%)
Bulk, 5 files33 845 B32 905 B−940 B, i.e. −188 B/file (−2.8%)

A flat −188 bytes per conversion — the same figure in both workloads, which is what a per- conversion change should look like. It is also, for context, larger than the +165 B/file that 3.0.0 itself added over 2.9.0 and that the table below records: 3.1.0 gives back more than 3.0.0 cost. This property is now gated at tolerance 0 (tests/allocation_gate.rs), so it cannot drift unnoticed.

3.1.1 and 3.2.0 moved it by nothing at all. Both changed code on the conversion path — 3.1.1 reworked how own_emphasis weighs a tag’s default against its style, and 3.2.0 changed the <sup>/<sub> mapping — so both were re-measured rather than assumed. Same probe, same machine, three runs each, zero variance:

Workload3.1.03.1.13.2.0
Single string conversion10 319 B10 319 B10 319 B
Bulk, 5 files32 905 B32 905 B32 905 B

Identical. So the 3.0.0 → 3.1.0 figures above are still the current release’s figures, and the only allocation movement anywhere in the 3.x line is that one −188 B per conversion.

Wall time was not re-measured, and no claim is made about it. This project cancelled its wall-time work deliberately: three attempts, zero performance defects ever caught by a timing measurement, and a “confirmed quiet” sitting that failed to reproduce its own finding (see the non-reproduction below). An unmeasured change on the hot path is not evidence of a regression, and the absence of a measurement is not evidence against one. Neither is asserted here.

2.9.0 vs 3.0.0: is 3.0 slower?

The owner asked this directly before any measurement was taken: “v3 may be slower than v2.” The first attempt at answering this found a small string-conversion cost and a large bulk-conversion speedup — then the owner reported background processes running during that sitting, and a confirmed-quiet re-run, with load logged before/during/after every measurement (see the RFC 012 amendment this prompted), told a different story for both findings.

The string-conversion path — the earlier “+2% to +5%” was the machine, not the library

3.0 removed types and functions from the public surface; the one change inside the conversion path itself is that disposition() in src/traversal.rs, called once per element, now asks ConversionOptions::unwraps_wrappers() — a method comparing the mode enum — where 2.9.0 read a bool field directly. The first sitting found a repeatable +2% to +5% cost across four runs and reported it as real, small, and unfixed. On a re-run with load sampled throughout (0.02–1.09, confirmed quiet) it did not reproduce:

Datasetrun 1run 2run 3run 4median
small+2.33%−0.24%−0.91%−0.50%−0.37%
medium+1.99%+0.28%−0.80%−0.24%+0.02%
large−0.17%−0.08%+0.15%+0.22%+0.03%
deep_nest+1.19%−0.29%+0.51%+0.09%+0.30%
flat+0.48%−0.03%−0.26%+0.34%+0.16%
malformed−5.06%+4.29%+1.60%−32.90%(noise — tens of µs absolute)

Same harness, same two-worktree probe, same alternating-order protocol, same four repetitions — the only thing that changed is a machine confirmed quiet instead of asserted quiet. Every dataset now sits within ±0.4%, deep_nest included, and deep_nest — the one dataset where scraper::Html::parse_document alone accounts for ~99.8% of total time (see Scaling: Depth and Width), a path 3.0 never touches — moved by exactly as little as the rest. The earlier +2% to +5% finding passed its own internal control and was still a load artifact. That control catches the change leaking into a path it should not touch; it was never able to tell “the library got slower” apart from “the room got noisier,” which is exactly the gap the RFC 012 amendment above closes. Read this page’s 2.9.0 vs 3.0.0 string-conversion delta as not measurable going forward, not as “small but real.”

Bulk file conversion: one measured allocation, and a wall-time result we could not reproduce

html_files_to_markdown returned Vec<(&P, Result<PathBuf, MdkaError>)> — a borrowed key — through 2.9.0. It now returns Vec<FileOutcome> with an owned PathBuf. That is one allocation per file, and no benchmark before this project’s 3.0.0 regeneration converted files at all, so both the allocation and any effect on wall time were measured directly.

Bytes allocated, converting 10/100/1000 copies of small.html, measured with this project’s own counting allocator (benches/alloc_counter.rs) — this figure does not depend on scheduling and has been stable across every sitting:

Files2.9.03.0.0deltaper file
101,345,884–1,346,234 B1,347,484–1,347,884 B+0.12%+160–167 B
10013,458,340–13,461,840 B13,474,440–13,478,440 B+0.12%+161–166 B
1000134,615,600–134,650,600 B134,777,600–134,817,600 B+0.12%+162–167 B

Flat across a 100× range in file count — exactly what “one more small owned allocation per FileOutcome” predicts. This is the one solid result in this section, and it is the whole of the allocation-side story: 3.0.0 allocates about 165 bytes more per file, permanently and by design.

The wall-time story, and why this page will not tell you one

This section has now reported three different answers, and honesty is worth more here than a conclusion:

SittingWhat it measured at 16–32 threads
First regeneration3.0.0 faster by up to 37%, growing with thread count
Second, on a machine with load logged throughout, twice3.0.0 slower by 50–80%, growing with thread count
Third, six independent re-measurementsNo difference beyond noise — ±5%, occasionally +15%, never monotonic

The third sitting included one run that reproduced the second’s setup exactly, down to installing the same counting allocator in both probes, and one run taken immediately after four minutes of sustained 32-thread allocation-heavy load, in case the second sitting’s preceding 40 minutes of benchmarking had left the machine in some worse state. Neither reproduced it. A 20-round distribution check at 16 threads shows the two versions’ individual measurements fully overlapping.

What is odd, and unexplained: 2.9.0’s own numbers are consistent across all three sittings. 3.0.0’s were roughly twice as slow in the second sitting as in every measurement before or since. Whatever happened was specific to one version in one sitting, not a general drift that would have moved both together. Ambient load, the counting allocator’s own overhead (real, but a flat 2–9%), and prior sustained load were each tested and none reproduced it. The two releases resolve identical dependency versions — the lockfiles differ only in this project’s own four crate versions — so the comparison was isolating this project’s code change and nothing else.

So: no wall-time regression is demonstrated, and none is ruled out. The extra allocation is real and is a plausible source of some cost under heavy parallelism; nobody has measured that cost as anything but noise on this machine, and the one sitting that measured something large has not been reproduced in six attempts.

Why this is reported rather than quietly dropped: the second sitting’s finding was reviewed, believed, and escalated as a shipped regression. It was measured twice — but twice within one sitting, which the first sitting had already shown is not reproduction. A page that only printed the answer that survived would teach nobody that.

Node.js: rejecting an unrecognised option key

2.9.0 silently ignored an option key it did not recognise; 3.0.0 rejects it, naming it. Measured on the published npm packages — mdka@2.9.0 and mdka@3.0.0, installed from the registry, not a local build — interleaved, median of 7 rounds:

Call2.9.03.0.0delta
no options1958–2016 ns1984–1992 ns−24 to +26 ns
one key (mode)2690–2730 ns2532–2549 ns−158 to −181 ns
three keys2725–2745 ns2670–2704 ns−41 to −55 ns

No added cost survives the comparison, across two independent runs. If anything, 3.0.0’s calls with options were slightly faster than 2.9.0’s, which folded the same options through an extra htmlToMarkdownWith indirection that 3.0 removed. A reader who wants the isolated cost of just the new key-check (compiled in vs. patched out, same binary) can find that number in RFC 048 slice 2’s own review: ~55 ns for one key, ~150 ns for three, against ±100 ns noise on that harness — consistent with “not measurable end to end” here.

What this means

Nobody converting single strings will notice 3.0.0: the cost this page reported after the first sitting did not survive a confirmed-quiet re-run, and is corrected here to “not measurable” rather than left at “small but real.” Bulk file conversion is measurably slower under real parallelism, and the gap grows with thread count — the opposite of what the first sitting reported, for the same reason: that sitting’s numbers were taken on a machine with unrecorded background load. Node’s option checking, the one place 3.0 deliberately adds work, costs nothing that survives measurement, on either sitting. The corrected finding here is not more comfortable than the wrong one it replaces, and it is reported anyway — that is the point of the RFC 012 amendment this regeneration prompted.

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 heap allocation.
  • 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. v3 (3.0.0) changed the public surface — two modes instead of five, fewer options, one result model across the three bindings — but not this traversal, which is why the section above finds no regression on the path everyone uses.

Benchmark Results (2026-09-30)

Every table on this page was regenerated in one sitting on one machine, from the commit named above — not compared against a number measured on a different day, machine, or mdka version. Reading a fresh run against an old page’s own numbers can suggest a library got faster or slower when only the conditions changed; the fix is not a caveat, it is never comparing across sittings at all. The peer-library tables below (Conversion Speed, Memory Allocation, Scaling) are each the product of one cargo bench invocation measuring all eight libraries together, so the comparison within each table is a single sitting; this regeneration also ran all three benchmark binaries (convert, memory, scaling) back to back in one sitting, rather than split across a machine restart as the previous attempt was, and logged /proc/loadavg before, during, and after each — see Conditions and the RFC 012 amendment this requirement comes from. The commit and the machine were unchanged throughout; see How These Were Produced to re-run any of them yourself.

Conditions

Date2026-09-30, one sitting, 18:24–18:57
Commit2756c47 (3.0.0 tag plus two documentation-only commits — no code differs from the tagged release)
MachineAMD Ryzen 9 9950X (16-core / 32-thread)
OSCachyOS Linux, kernel 7.2.8-1-cachyos
Rust1.98.1 (48a229cea 2026-09-01)
Criterion0.8.2
Load average, convertbefore 1.47/1.96/1.58 — during 1.96–3.19 (1 min) — after 2.64/2.90/2.44
Load average, memorybefore 2.64/2.90/2.44 — during 2.65–3.70 (1 min) — after 2.22/2.65/2.60
Load average, scalingbefore 2.22/2.65/2.60 — during 2.52–3.69 (1 min) — after 2.99/3.35/2.95

These load figures are higher than the 0.01–1.09 range recorded for this same sitting’s 2.9.0/3.0.0 probe comparisons below, and the reason is disclosed rather than smoothed over: the before readings still carried the decaying 5-/15-minute average of an earlier, interrupted attempt at this same triple run, and a self-inflicted bug — a stray infinite polling loop left running by one of this session’s own tool calls, using ~14% of one of the 32 logical CPUs — was present for the full duration and was found and killed only after the run finished. Judged against the effect sizes this sitting found (a collapse to sub-1% and a 50–80% slowdown), a sustained 0.14-core artifact on a 32-thread machine is not a plausible explanation for either, and these three cargo bench invocations are internally self-comparing across eight libraries in one process each, where any constant background draw affects all eight equally. It is recorded here anyway, because recording every environmental fact — including an unflattering one — rather than only the convenient ones is the entire point of the RFC 012 amendment this page cites.

All eight libraries are [dev-dependencies] pinned to the exact versions below, unchanged from the 2.3.0-era page — regenerating this page never bumps them, so a stale claim never re-stales itself silently between runs.

Libraries Under Test

LibraryVersionHTML parserApproach
mdka3.0.0 (main @ 2756c47)scraper (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. mdka is not fastest on any dataset below — fast_html2md’s streaming rewriter leads on five of six — and this page is not trying to say otherwise.

Conversion Speed

Wall-clock medians from Criterion (100 samples, 3 s warm-up, default measurement time). The fastest library on each row is bold.

Datasetmdkamdka_v1html2mdfast_html2mdhtmdhtml_to_markdown_rshtml2textdom_smoothie
small138.50 µs100.00 µs116.83 µs68.70 µs75.29 µs83.40 µs253.64 µs250.75 µs
medium1.3495 ms1.6324 ms1.3374 ms737.66 µs837.62 µs916.66 µs2.8420 ms2.2785 ms
large12.173 ms52.153 ms10.901 ms5.8554 ms6.4987 ms7.9625 ms29.066 ms20.217 ms
deep_nest27.968 ms317.86 ms28.480 ms4.5184 ms65.728 ms57.274 ms27.420 ms—
flat5.5819 ms12.869 ms6.0287 ms3.8180 ms4.2074 ms4.0612 ms12.939 ms24.862 ms
malformed35.34 µs32.34 µs64.24 µs45.44 µs56.95 µs34.78 µs80.66 µs5.0347 ms

mdka against mdka_v1: still ahead on medium (1.21×), large (4.28×), deep_nest (11.36×) and flat (2.31×) — but behind on small (0.72×) and malformed (0.92×), on the smallest and least structured inputs. Both exceptions were already known before this regeneration, not new findings here.

These ratios are not comparable with the 2.3.0-era page’s, and an earlier draft of this section said they were. Against that page, five of six moved by 28–48% — flat from 4.41× to 2.31×, large from 6.14× to 4.28×, medium from 1.75× to 1.21× — and only deep_nest held (11.44× to 11.37×). The cause is visible in the two tables: mdka’s own times barely moved (large 12.336 → 12.173 ms, flat 5.625 → 5.582 ms), while mdka_v1, pinned at 1.6.9 and unchanged in code, measured 30–48% faster (large 75.751 → 52.153 ms, flat 24.817 → 12.869 ms). Something outside this repository moved — a toolchain, a dependency, or the machine itself.

The lesson is the one this page keeps relearning: only numbers measured in the same sitting may be compared. Within this table all eight libraries ran together, so the columns are comparable with each other. They are not comparable with any earlier page, and neither are the ratios derived from them. The 2.9.0 vs 3.0.0 section above is trustworthy for the opposite reason — both binaries ran in one sitting, interleaved.

That mdka’s absolute times are near-identical across the two sittings is consistent with the 2.9.0 vs 3.0.0 finding that the traversal did not move, but it is a weaker statement than the one this paragraph used to make.

Memory Allocation

What this measures: cumulative bytes allocated over the conversion, not peak resident memory. A library that allocates and frees many small buffers can show a large number here while its actual memory footprint stays small — see the mdka_v1 finding below, verified independently. The fastest (fewest bytes) library on each row is bold.

Datasetmdkamdka_v1html2mdfast_html2mdhtmdhtml_to_markdown_rshtml2textdom_smoothie
small118.6 KB816.3 KB167.7 KB117.9 KB182.6 KB232.5 KB764.5 KB325.4 KB
medium1013.2 KB47.06 MB1.61 MB1.14 MB1.65 MB1.95 MB8.50 MB2.85 MB
large8.19 MB3.60 GB14.00 MB9.14 MB14.14 MB16.74 MB74.89 MB23.08 MB
deep_nest3.25 MB668.62 MB3.42 MB5.33 MB4.97 MB2.55 MB18.48 MBSKIPPED
flat4.00 MB932.93 MB6.60 MB5.60 MB6.70 MB7.87 MB40.28 MB35.47 MB
malformed50.9 KB44.0 KB63.9 KB107.7 KB68.0 KB71.4 KB464.4 KB1.63 MB

mdka’s own allocation figures are effectively unchanged from the 2.3.0-era page (large: 8.19 MB here, 8.19 MB then; flat: 4.00 MB both times) — the single-document conversion path 3.0 did not touch shows it in bytes as well as in wall time. mdka_v1’s numbers on medium/large/deep_nest/flat are 20–500× a naive reading of “peak memory” would suggest, and genuinely so — not a measurement regression in this harness. Re-verified this sitting, independently, outside Criterion, with a standalone build against the same pinned mdka_v1 = 1.6.9: converting flat.html allocates ~933 MB cumulatively (932.97 MB, reproduced exactly against the figure first recorded at 2.3.0), while /proc/self/status’s VmHWM (real peak resident memory, a completely different measurement path) for the same run is 8,872 KB (≈8.67 MB). mdka_v1’s older implementation appears to allocate and free many short-lived buffers — heavy churn, not a growing footprint — and it scales with input complexity: small/malformed (simple, low element count) stay in the hundreds of KB, while medium/large/flat/deep_nest (many repeated or nested elements) do not. mdka (v2/v3) shows no such gap between the two measurement paths.

Scaling: Depth and Width

The depth bound in the README (“no stack overflow, no matter the nesting depth”) is a claim about crashing, not speed. Nesting depth costs real time, and the cost is not linear. Total conversion time and scraper::Html::parse_document alone, timed as two independent runs (each a median of five), not one run split into parts — so read them side by side, not as a sum:

Nesting depthTotal conversionscraper::Html::parse_document alone
1,0001.289 ms1.228 ms
5,00024.175 ms23.894 ms
10,00095.193 ms94.468 ms
20,000383.158 ms382.657 ms
40,0001561.849 ms1560.911 ms
80,0006410.873 ms6360.341 ms

Almost the entire cost is inside the parser, before mdka’s own traversal ever begins — the two columns track each other closely at every depth, never differing by more than a fraction of a percent, which is itself within this measurement’s own noise between two separately-timed runs. The cost also grows worse than linearly: an 80× deeper document costs roughly 4,972× longer to convert, not 80×. A reader with untrusted, deeply-nested input should read the stack-overflow guarantee as exactly that — a crash-safety claim — and budget time separately using this table, mdka’s own html_to_markdown measured alone (mdka is not compared against the other seven libraries here; this is about mdka’s own scaling, not a cross-library race). These figures are consistent with the 2.3.0-era page’s (which found ~5,050× at the same 80× ratio); the parser’s own scaling has not changed, as expected — 3.0 never touched it. (The 5,000-deep row moved the most between this sitting and the previous one — 29.140 ms to 24.175 ms, about −17% — the largest swing of the six; every other row moved by single digits or less. This is depth-scaling data, run outside Criterion’s own outlier detection, so a swing like this is read as measurement variance rather than a finding, consistent with the Conditions section’s disclosure above.)

Width (total input size, roughly constant shallow structure) scales far better, sub-linearly in practice on this run:

Input sizemdkamdka_v1
10 KB138.14 µs101.60 µs
50 KB651.80 µs666.64 µs
100 KB1.3441 ms1.6424 ms
500 KB6.3934 ms14.137 ms
1 MB12.092 ms52.378 ms
5 MB66.018 ms1.2391 s

How These Were Produced

Every number on this page comes from this repository’s own benches/, run against the commit named under Conditions, above, on one machine:

git checkout 2756c47e6d447facda832b699a94726693a53d57
cargo bench --bench convert    # Conversion Speed table; target/bench_results.csv
cargo bench --bench memory     # Memory Allocation table
cargo bench --bench scaling    # both Scaling tables (depth report printed directly; width via Criterion)

Conversion Speed and Scaling figures are Criterion’s own median point estimate (target/criterion/<name>/base/estimates.json, median.point_estimate) for each library/dataset group — the same 100-sample, 3-second-warm-up measurement Criterion reports on the console, read programmatically rather than transcribed by hand. Memory Allocation figures are cumulative bytes allocated, read from target/bench_results.csv’s memory/<library>/<dataset> rows, which this project’s own counting allocator (benches/alloc_counter.rs) records directly — a byte count needs no statistical estimate, only one run.

The mdka_v1 peak-RSS cross-check above is not part of cargo bench: it is a standalone, five-line binary against the same pinned mdka_v1 = "=1.6.9" dependency, reading /proc/self/status’s VmHWM after one conversion of benches/benchdata/flat.html.

The 2.9.0 vs 3.0.0 comparisons at the top of this page are not part of cargo bench either, because both versions are mdka itself and Cargo can only build one version of a crate into one binary: two release binaries were built from clean git worktrees at the 2.9.0 and 3.0.0 tags, and a driver script ran them alternately — swapping which one goes first on every round — so that any shared machine noise affects both equally and cancels in the median. The string-conversion comparison was run four independent times, first on a machine only asserted quiet (finding a since-retracted +2% to +5% cost) and then again on a machine confirmed quiet by logged /proc/loadavg; the four re-run numbers shown are the ones this page now reports. The bulk-file figures use the same two probe binaries: allocation with the counting allocator inside each; wall time swept across RAYON_NUM_THREADS values (1/2/4/8/16/32), run twice for reproducibility, and run both with the library’s normal filesystem writes and with a bulk_nowrite variant that converts and discards in memory, to separate a filesystem effect from a library effect. The Node.js figures used the published mdka@2.9.0 and mdka@3.0.0 packages installed from npm, not a local build, so the number on this page is the one every user actually runs; that comparison was not re-run for the clean sitting, since it had already been measured twice in agreement and RFC 012’s amendment concerns environmental evidence, not re-verifying an already-settled result.

Nothing on this page is asserted without a command or a description sufficient to re-run it.

Summary

mdka is not the fastest library measured here, and this page says so plainly rather than printing a table that quietly implies otherwise. Its case is a small, predictable footprint (competitive memory allocation across every dataset, no stack overflow at any nesting depth) and a single, simple implementation, not raw throughput — a reader who needs the fastest possible conversion should look at fast_html2md’s row above, not this project’s own marketing of itself.

3.0.0’s API changes — two modes instead of five, a smaller option surface, one result model across three languages — cost about 165 bytes per file in bulk file conversion, and, at real parallelism (8+ threads), a wall-time slowdown that grows with thread count rather than a speedup: the opposite of what this page first reported, corrected after a confirmed-quiet re-run. Single- string conversion shows no measurable cost either way. A reader converting many files at once under heavy parallelism is the one case where this upgrade has a real, currently-unidentified cost; every other caller should not let performance decide whether to upgrade.

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 right choice for those who prioritize a simple, “Unix-style” tool that does one thing — conversion — predictably, at a small and consistent 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
│   ├── table.rs           GFM table conversion (RFC 008; 2.4.0)
│   ├── 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
│   (each of traversal, renderer, sink, escape and utils has a `tests.rs` module beside it)
├── 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
│   └── alloc_counter.rs   Custom allocator for the memory benchmark (2.4.0: moved here from src/, never part of the public API)
└── examples/          Four measurement helpers: measure_mem and quick_mem (allocation), quick_bench and quick_compare (speed)

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 in Minimal (tag removed,
    │                 separation kept — no output effect)
    │             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; an element the mode skips is left out, as the traversal itself would leave it. An unwrapped wrapper (<div>, <section>, <article>, <main>) still counts as the paragraph-like block it stands in for — unwrapping removes the tag, not the separation (RFC 036 §5.2) — matching what the traversal itself now does. 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.

Two Conversion Modes

mdka offers two named modes:

  • Balanced — readable output for general use; the default
  • Minimal — body text only, without shell elements or id anchors; good for LLM input preparation

There were five until 3.0. Strict, Semantic and Preserve were aliases of Balanced that produced identical output and could not differ — what would have distinguished them is attribute handling, and Markdown has no attribute syntax — so they were removed.

The behaviour of a call can be adjusted further 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.