Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Migrating to 3.0

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

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

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

At a glance

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

The shape behind the file-function changes

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

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

Removed modes and options (all bindings)

Modes: Strict, Semantic, Preserve → Balanced

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

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

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

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

Options that never did anything

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

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

unwrap_unknown_wrappers

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

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

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

Rust

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

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

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

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

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

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

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

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

use mdka::options::ConversionMode;

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

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

Node.js

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

Functions: the …With names are gone

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

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

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

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

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

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

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

Single file: a string, and failure rejects

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

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

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

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

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

Many files: FileOutcome, with ok

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

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

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

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

TypeScript

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

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

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

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

An unrecognised option key is now an error

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

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

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

Python

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

# after (3.0)
import mdka

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

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

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

# after (3.0)
import mdka

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

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

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

CLI

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

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

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

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

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

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

What did not change

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

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

Finding what you need to change

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