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 it | What you get back |
|---|---|
| Any HTML string — a full page, a snippet, CMS output, SPA-rendered DOM | Clean, readable Markdown |
| A list of HTML files | Parallel 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) andMinimal, 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 (
Minimalmode, or thedrop_interactive_shelloption) - 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
- New to mdka? Start with Installation.
- Upgrading from
2.9.0? Read Migrating to 3.0:3.0changes no conversion output, but it renames and removes names. - Ready to integrate? Jump to Usage & Examples.
- Evaluating? Read Design Philosophy and Performance Characteristics.
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:
| Platform | Package |
|---|---|
| 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 makenpm install mdkawork 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:
| Platform | Wheel tag |
|---|---|
| Linux x86_64, glibc 2.17 or later | manylinux_2_17_x86_64 |
| Linux aarch64, glibc 2.17 or later | manylinux_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 later | musllinux_1_2_aarch64 |
| Windows x64 | win_amd64 |
| macOS on Apple silicon, 11.0 or later | macosx_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
| Mode | Best for |
|---|---|
Balanced | General use; default |
Minimal | LLM 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, notos.PathLike: passstr(path). The list arguments (html_list,paths) take any sequence ofstr— but a barestris 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.
| Flag | Description |
|---|---|
-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-ids | Emit <a id="…"></a> anchors for elements with an id. On by default in every mode except minimal |
--no-preserve-ids | Turn anchor emission off, in any mode |
--drop-shell | Drop nav, header, footer, aside |
--emphasis-from-style | Let 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-breaks | Write a hard break as \ + newline instead of two trailing spaces (RFC 052). Off by default; see Options for the trade-off |
-h, --help | Show this help |
-V, --version | Show 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 use | What 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_wrappers | Gone. Delete it: it could not change the output (why) |
| Rust: the file functions | ConvertResult is gone; single returns PathBuf, bulk returns Vec<FileOutcome> |
Node.js: anything that converts a file, or ends in With | Almost everyone. ConvertResult is gone and four functions are gone (longest section) |
| Python: the file functions | Single returns str; BulkConvertResult is now FileOutcome |
CLI: --mode strict|semantic|preserve, --preserve-classes, --preserve-data, --preserve-aria, --unwrap-wrappers | Each 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:
| Surface | Result |
|---|---|
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 |
| Python | ConversionMode.Strict is an AttributeError. A mode is never a string in Python |
CLI --mode strict | exit 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
| Removed | Existed in |
|---|---|
preserve_classes, preserve_data_attrs, preserve_aria_attrs | Rust, Node.js (preserveClasses, preserveDataAttrs, preserveAriaAttrs), Python, CLI (--preserve-classes, --preserve-data, --preserve-aria) |
preserve_unknown_attrs, drop_presentation_attrs | Rust only — there is nothing to remove in Node.js, Python or the CLI |
the builder ConversionOptions::preserve_aria_attrs | Rust |
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.
| Removed | Use |
|---|---|
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 optionaloptionsargument, an additive change). BalancedandMinimal, unrenamed and byte-identical.preserve_idsanddrop_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’sConversionMode,MdkaErrorand_withfunction pairs.
Finding what you need to change
| Binding | Search for |
|---|---|
| Rust | ConvertResult, 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.js | htmlToMarkdownWith, WithAsync, htmlFileToMarkdown, htmlFilesToMarkdown, ConvertResult, .dest, .error, preserveClasses, preserveDataAttrs, preserveAriaAttrs, unwrapUnknownWrappers, and any mode: string |
| Python | ConvertResult, 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
| Operation | Rust | Node.js | Python |
|---|---|---|---|
| Convert one string (default mode) | html_to_markdown | htmlToMarkdown (options optional) | html_to_markdown |
| … with options | html_to_markdown_with | — (htmlToMarkdown takes them) | html_to_markdown_with |
| … asynchronously | — | htmlToMarkdownAsync (options optional) | — |
| Convert many strings (default mode) | html_to_markdown_many | htmlToMarkdownMany (options optional) | html_to_markdown_many |
| … with options | html_to_markdown_many_with | — (htmlToMarkdownMany takes them) | html_to_markdown_many_with |
| Convert one file | html_file_to_markdown | htmlFileToMarkdown (async, options optional) | html_file_to_markdown |
| … with options | html_file_to_markdown_with | — (htmlFileToMarkdown takes them) | html_file_to_markdown_with |
| Convert many files (parallel) | html_files_to_markdown | htmlFilesToMarkdown (async, options optional) | html_files_to_markdown |
| … with options | html_files_to_markdown_with | — (htmlFilesToMarkdown takes them) | html_files_to_markdown_with |
| The library’s version | version | version | version |
That is 9 functions in Rust, 6 in Node.js and 9 in Python. What the table shows:
- Node.js has no
…Withfunctions. JavaScript has optional arguments, sohtmlToMarkdown(html, options?)is the idiom, and each Node.js function takes its options optionally. Rust has no optional parameters, so a_withpair 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
FileOutcomeeach, so one failure cannot hide the rest. See Error Handling.
Types
| Type | Language | Description |
|---|---|---|
ConversionMode | Rust, Python | Enum (Rust) / class (Python): Balanced · Minimal. Node.js has no such type: it passes the mode as a string in mode |
ConversionOptions | Rust | Controls pre-processing per call; built via for_mode() |
JsConversionOptions | Node.js | The 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 |
FileOutcome | Rust, Node.js, Python | What 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 |
MdkaError | Rust, Python | Rust: 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
modestring throws or rejects in Node.js, and astrwhere aConversionModebelongs is aTypeErrorin 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 raisesMdkaError, and Node.js rejects with anError(or, for a failing file inside a bulk call, returns it in that entry’serror). 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.mdfile is written alongside the input (same directory, stem unchanged).Some(dir)→ the.mdfile is written intodir. 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
| Mode | Default? | Converts |
|---|---|---|
Balanced | ✅ Yes | General use |
Minimal | Differently: 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:
| Surface | What 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.js | htmlToMarkdown and htmlToMarkdownMany throw, and htmlToMarkdownAsync and the file functions reject, with the same message |
| Python | ConversionMode.Strict, Semantic and Preserve do not exist: AttributeError. A mode is never a string in Python, so there is no string form |
| CLI | mdka --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
From a mode (recommended)
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
| Field | Balanced | Minimal | Effect |
|---|---|---|---|
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:
| Input | Output |
|---|---|
<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:
| Input | Output |
|---|---|
<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 (& → &,
" → ") 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:
| Property | Adds | Value grammar |
|---|---|---|
font-weight | bold | bold, or a number ≥ 600 |
font-style | italic | italic 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 theBlockkind 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 withaplain 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 stripping | works in Python-Markdown | |
|---|---|---|
| two trailing spaces (default) | no — many editors strip them on save, and markdownlint’s MD009 flags them | yes |
| backslash (this option) | yes | no — 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.
| Removed | Where it existed | Why it could not act |
|---|---|---|
preserve_classes, preserve_data_attrs, preserve_aria_attrs, preserve_unknown_attrs, drop_presentation_attrs | Rust (all five). Node.js and Python had the first three; the CLI had --preserve-classes, --preserve-data and --preserve-aria | Markdown 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_wrappers | Rust; unwrapUnknownWrappers in Node.js; unwrap_unknown_wrappers in Python; --unwrap-wrappers on the CLI | Unwrapping 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:
| Surface | What happens |
|---|---|
| Rust | Compile error: the field (and ConversionOptions::preserve_aria_attrs) no longer exists |
| Node.js | TypeScript: a compile error. Plain JavaScript: nothing — the option is ignored, with no error and no warning |
| Python | TypeError: … got an unexpected keyword argument |
| CLI | Exit 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.
| Call | On failure |
|---|---|
html_file_to_markdown, html_file_to_markdown_with | Raises 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_with | Raises 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 functions | No 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.
| Call | On failure |
|---|---|
htmlFileToMarkdown | The 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 |
htmlFilesToMarkdown | The 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, htmlToMarkdownMany | Throw 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 functions | Reject 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
| HTML | Markdown output | Notes |
|---|---|---|
<h1> – <h6> | # – ###### | ATX-style headings |
<p> | Paragraph (blank lines around) | |
<blockquote> | > prefix | On every line inside the quote, including blank lines (>) and code block lines. Nesting produces > > , > > > , … |
<pre><code> | Fenced code block ``` | Preserves whitespace and newlines |
<ul> | - list | Tight or loose, see Lists |
<ol> | 1. list | Respects start attribute. Tight or loose, see Lists |
<li> | List item | Everything 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 separator | Each 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 separator | Act as paragraph breaks; unwrapped (tag removed, children kept) in Minimal, rendered in Balanced; the output is the same either way |
<figure>, <figcaption> | Block separator | Never 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 fallback | See Tables |
Inline Elements
| HTML | Markdown output | Notes |
|---|---|---|
<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="…"> |  | title attribute →  |
<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.
-
<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 instance10<sup>n</sup>→10ⁿ. Not flattened, deliberately: French1<sup>er</sup>and2<sup>e</sup>stay mapped to1ᵉʳ/2ᵉ—10<sup>e</sup>is a legitimate exponent (ten to the power e), so flatteninge/erwould 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. -
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 superscriptq;<sub>:a e h i j k l m n o p r s t u v x— only 17, Unicode has no subscriptb c d f g q w y z, sox<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. -
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. -
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
| HTML | Current behaviour | Status |
|---|---|---|
<video>, <audio> | No output for the media element itself | Not 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:
| Text | Escaped | Not 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 word | snake_case_here |
` | always | |
[ | always | |
] | inside link text and image alt text | elsewhere |
! | before [, where it would start an image | Wow! 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: © → \© | a & b |
\ | before punctuation or at the end of a line | path\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 with | Would become | Written as |
|---|---|---|
a number, then . or ) | an ordered list | 1986\. A great year — the delimiter is escaped, not the digit |
- or +, then a space | a bullet list | \- not a list |
#, then a space | a 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.
Links and images
A destination with a space, a control character or unbalanced parentheses is
written in angle brackets; a line break in it is written as . 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 entity | After parsing | In Markdown |
|---|---|---|
& | & | & (\& before a letter, a digit or #) |
< | < | < (\< before a letter, /, ! or ?) |
> | > | > |
| non-breaking space | preserved 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
Stringis 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:
| Workload | 3.0.0 | 3.1.0 | delta |
|---|---|---|---|
| Single string conversion | 10 507 B | 10 319 B | −188 B (−1.8%) |
| Bulk, 5 files | 33 845 B | 32 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:
| Workload | 3.1.0 | 3.1.1 | 3.2.0 |
|---|---|---|---|
| Single string conversion | 10 319 B | 10 319 B | 10 319 B |
| Bulk, 5 files | 32 905 B | 32 905 B | 32 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:
| Dataset | run 1 | run 2 | run 3 | run 4 | median |
|---|---|---|---|---|---|
| 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:
| Files | 2.9.0 | 3.0.0 | delta | per file |
|---|---|---|---|---|
| 10 | 1,345,884–1,346,234 B | 1,347,484–1,347,884 B | +0.12% | +160–167 B |
| 100 | 13,458,340–13,461,840 B | 13,474,440–13,478,440 B | +0.12% | +161–166 B |
| 1000 | 134,615,600–134,650,600 B | 134,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:
| Sitting | What it measured at 16–32 threads |
|---|---|
| First regeneration | 3.0.0 faster by up to 37%, growing with thread count |
| Second, on a machine with load logged throughout, twice | 3.0.0 slower by 50–80%, growing with thread count |
| Third, six independent re-measurements | No 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:
| Call | 2.9.0 | 3.0.0 | delta |
|---|---|---|---|
| no options | 1958–2016 ns | 1984–1992 ns | −24 to +26 ns |
one key (mode) | 2690–2730 ns | 2532–2549 ns | −158 to −181 ns |
| three keys | 2725–2745 ns | 2670–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
| Date | 2026-09-30, one sitting, 18:24–18:57 |
| Commit | 2756c47 (3.0.0 tag plus two documentation-only commits — no code differs from the tagged release) |
| Machine | AMD Ryzen 9 9950X (16-core / 32-thread) |
| OS | CachyOS Linux, kernel 7.2.8-1-cachyos |
| Rust | 1.98.1 (48a229cea 2026-09-01) |
| Criterion | 0.8.2 |
Load average, convert | before 1.47/1.96/1.58 — during 1.96–3.19 (1 min) — after 2.64/2.90/2.44 |
Load average, memory | before 2.64/2.90/2.44 — during 2.65–3.70 (1 min) — after 2.22/2.65/2.60 |
Load average, scaling | before 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
| Library | Version | HTML parser | Approach |
|---|---|---|---|
| mdka | 3.0.0 (main @ 2756c47) | scraper (html5ever) | Full DOM tree; non-recursive DFS |
| mdka_v1 | 1.6.9 | html5ever | Full DOM tree; older implementation |
| html2md | 0.2.15 | html5ever | DOM-based converter |
| fast_html2md | 0.0.61 | lol_html | Streaming rewriter |
| htmd | 0.5.4 | html5ever | DOM-based converter |
| html_to_markdown_rs | 3.1.0 | html5ever | DOM-based converter |
| html2text | 0.16.7 | html5ever | Text-oriented converter |
| dom_smoothie | 0.17.0 | dom_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.
| Dataset | mdka | mdka_v1 | html2md | fast_html2md | htmd | html_to_markdown_rs | html2text | dom_smoothie |
|---|---|---|---|---|---|---|---|---|
| small | 138.50 µs | 100.00 µs | 116.83 µs | 68.70 µs | 75.29 µs | 83.40 µs | 253.64 µs | 250.75 µs |
| medium | 1.3495 ms | 1.6324 ms | 1.3374 ms | 737.66 µs | 837.62 µs | 916.66 µs | 2.8420 ms | 2.2785 ms |
| large | 12.173 ms | 52.153 ms | 10.901 ms | 5.8554 ms | 6.4987 ms | 7.9625 ms | 29.066 ms | 20.217 ms |
| deep_nest | 27.968 ms | 317.86 ms | 28.480 ms | 4.5184 ms | 65.728 ms | 57.274 ms | 27.420 ms | — |
| flat | 5.5819 ms | 12.869 ms | 6.0287 ms | 3.8180 ms | 4.2074 ms | 4.0612 ms | 12.939 ms | 24.862 ms |
| malformed | 35.34 µs | 32.34 µs | 64.24 µs | 45.44 µs | 56.95 µs | 34.78 µs | 80.66 µs | 5.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.
| Dataset | mdka | mdka_v1 | html2md | fast_html2md | htmd | html_to_markdown_rs | html2text | dom_smoothie |
|---|---|---|---|---|---|---|---|---|
| small | 118.6 KB | 816.3 KB | 167.7 KB | 117.9 KB | 182.6 KB | 232.5 KB | 764.5 KB | 325.4 KB |
| medium | 1013.2 KB | 47.06 MB | 1.61 MB | 1.14 MB | 1.65 MB | 1.95 MB | 8.50 MB | 2.85 MB |
| large | 8.19 MB | 3.60 GB | 14.00 MB | 9.14 MB | 14.14 MB | 16.74 MB | 74.89 MB | 23.08 MB |
| deep_nest | 3.25 MB | 668.62 MB | 3.42 MB | 5.33 MB | 4.97 MB | 2.55 MB | 18.48 MB | SKIPPED |
| flat | 4.00 MB | 932.93 MB | 6.60 MB | 5.60 MB | 6.70 MB | 7.87 MB | 40.28 MB | 35.47 MB |
| malformed | 50.9 KB | 44.0 KB | 63.9 KB | 107.7 KB | 68.0 KB | 71.4 KB | 464.4 KB | 1.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 depth | Total conversion | scraper::Html::parse_document alone |
|---|---|---|
| 1,000 | 1.289 ms | 1.228 ms |
| 5,000 | 24.175 ms | 23.894 ms |
| 10,000 | 95.193 ms | 94.468 ms |
| 20,000 | 383.158 ms | 382.657 ms |
| 40,000 | 1561.849 ms | 1560.911 ms |
| 80,000 | 6410.873 ms | 6360.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 size | mdka | mdka_v1 |
|---|---|---|
| 10 KB | 138.14 µs | 101.60 µs |
| 50 KB | 651.80 µs | 666.64 µs |
| 100 KB | 1.3441 ms | 1.6424 ms |
| 500 KB | 6.3934 ms | 14.137 ms |
| 1 MB | 12.092 ms | 52.378 ms |
| 5 MB | 66.018 ms | 1.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 listsin_preand 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 functionhtml_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
idanchors; 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:
| Language | Package | Mechanism |
|---|---|---|
| Rust | mdka on crates.io | native library |
| Node.js | mdka on npm | napi-rs native module |
| Python | mdka on PyPI | PyO3 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:
| Crate | Purpose |
|---|---|
scraper | HTML parsing (html5ever wrapper) |
ego-tree | DOM tree traversal |
rayon | Parallel file conversion |
tikv-jemallocator, tikv-jemalloc-ctl | Ensures fragmentation avoidance and scalable concurrency |
thiserror | MdkaError derive macro |
Benchmark and comparison dependencies (criterion, competitors) are
[dev-dependencies] and do not affect library consumers.