Skip to content
rusthacks.dev
CLI

Build a Markdown to HTML Converter CLI in Rust With Pulldown

pulldown-cmark turns Markdown into a stream of events, and its HTML writer renders them. A small CLI adds file handling, options and a page template around it.

20 min read
Summarize in AI
Flat illustration for the post “Build a Markdown to HTML Converter CLI in Rust With Pulldown”, composed around cli.

Converting Markdown to HTML looks like a solved problem, and it is: call one function from a library and you are done. That is also how the interesting parts get missed. A real converter has to decide what to do with raw HTML in the input, which is a security question. It has to give headings stable anchors, rewrite links between pages, and build a table of contents, which are design questions. And it has to behave as a command line tool, reading standard input, writing to a file, and failing with a clear message. This guide builds mdhtml, a Markdown to HTML converter on top of pulldown-cmark, and spends most of its time on the decisions around the one-line conversion.

The tool compiles and runs on Rust 1.99 (edition 2024) with pulldown-cmark 0.13 and clap 4.6.7. All output blocks below are what the programs printed.

What pulldown-cmark gives you: a stream of events

pulldown-cmark is a pull parser. It does not build a tree. It walks the document and hands you a sequence of events: start of a heading, some text, end of a heading, start of a paragraph. A tiny document makes the shape clear. This program prints the events for a heading, a paragraph with emphasis and a link, and a list:

src/main.rsrust
use pulldown_cmark::{Options, Parser};

fn main() {
    let markdown = "# Title\n\nA *brief* [link](a.md).\n\n- one\n";
    for event in Parser::new_ext(markdown, Options::ENABLE_TABLES) {
        println!("{event:?}");
    }
}
Text
Start(Heading { level: H1, id: None, classes: [], attrs: [] })
Text(Borrowed("Title"))
End(Heading(H1))
Start(Paragraph)
Text(Borrowed("A "))
Start(Emphasis)
Text(Borrowed("brief"))
End(Emphasis)
Text(Borrowed(" "))
Start(Link { link_type: Inline, dest_url: Borrowed("a.md"), title: Borrowed(""), id: Borrowed("") })
Text(Borrowed("link"))
End(Link)
Text(Borrowed("."))
End(Paragraph)
Start(List(None))
Start(Item)
Text(Borrowed("one"))
End(Item)
End(List(false))

Every event is a plain enum value, and the text is Borrowed: the parser points into your input instead of copying it. Two consequences matter. The event stream is an ordinary Rust iterator, so you can map, filter and filter_map it, which is how every customization in this guide works. And because nothing is built in memory, converting a megabyte takes a few milliseconds.

To get HTML you pass the events to html::push_html. The simplest converter is three lines:

src/main.rsrust
let parser = Parser::new_ext(markdown, Options::ENABLE_TABLES | Options::ENABLE_FOOTNOTES);
let mut html = String::new();
pulldown_cmark::html::push_html(&mut html, parser);

Options turns on extensions beyond core CommonMark: tables, footnotes, strikethrough, task lists and smart punctuation. They are off by default so that the parser matches the standard unless you ask for more.

The decision most converters get wrong: raw HTML

Markdown allows raw HTML, and the parser passes it straight through. That is correct for a trusted author writing a blog and dangerous for anything else. Convert this document, which has a script tag, to see what comes out:

Text
<h2 id="features">Features</h2>
...
<script>alert('hi')</script>
<p>Footnotes work too.<sup class="footnote-reference"><a href="#1">1</a></sup></p>

The <script> element appears in the output exactly as written. If this text came from a user, a comment, a wiki edit or a pasted README, you have just published a cross-site scripting vulnerability. The converter must offer a safe mode, and the event stream makes one easy to build: raw HTML arrives as Event::Html for blocks and Event::InlineHtml for fragments, so a filter_map that drops both removes it.

src/convert.rsrust
use pulldown_cmark::{html, CowStr, Event, HeadingLevel, Options, Parser, Tag, TagEnd};

#[derive(Debug, Clone, Copy, Default)]
pub struct Settings {
    /// Drop raw HTML from the input instead of passing it through.
    pub safe: bool,
    /// Rewrite relative links that end in `.md` so they point at the generated `.html` page.
    pub rewrite_md_links: bool,
}

pub struct Rendered {
    pub body: String,
    pub toc: Vec<(HeadingLevel, String, String)>,
}

pub fn options() -> Options {
    Options::ENABLE_TABLES
        | Options::ENABLE_FOOTNOTES
        | Options::ENABLE_STRIKETHROUGH
        | Options::ENABLE_TASKLISTS
        | Options::ENABLE_SMART_PUNCTUATION
}

/// `Rust & WebAssembly: a tour` becomes `rust-webassembly-a-tour`.
pub fn slug(text: &str) -> String {
    let mut out = String::new();
    for c in text.chars() {
        if c.is_alphanumeric() {
            out.extend(c.to_lowercase());
        } else if !out.ends_with('-') && !out.is_empty() {
            out.push('-');
        }
    }
    out.trim_end_matches('-').to_string()
}

pub fn render(markdown: &str, settings: Settings) -> Rendered {
    // First pass: find the headings so each one can get an id and a table of contents entry.
    let mut toc = Vec::new();
    let mut seen: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
    let mut current: Option<(HeadingLevel, String)> = None;
    for event in Parser::new_ext(markdown, options()) {
        match event {
            Event::Start(Tag::Heading { level, .. }) => current = Some((level, String::new())),
            Event::Text(t) | Event::Code(t) => {
                if let Some((_, text)) = current.as_mut() {
                    text.push_str(&t);
                }
            }
            Event::End(TagEnd::Heading(_)) => {
                if let Some((level, text)) = current.take() {
                    let base = slug(&text);
                    // A repeated heading gets `-1`, `-2`, ... so anchors stay unique.
                    let count = seen.entry(base.clone()).or_insert(0);
                    let id = if *count == 0 { base } else { format!("{base}-{count}") };
                    *count += 1;
                    toc.push((level, text, id));
                }
            }
            _ => {}
        }
    }

    // Second pass: transform the event stream, then hand it to the HTML writer.
    let mut next_heading = 0;
    let events = Parser::new_ext(markdown, options()).filter_map(|event| match event {
        Event::Html(_) | Event::InlineHtml(_) if settings.safe => None,
        Event::Start(Tag::Heading { level, classes, attrs, .. }) => {
            let id = toc.get(next_heading).map(|(_, _, id)| CowStr::from(id.clone()));
            next_heading += 1;
            Some(Event::Start(Tag::Heading { level, id, classes, attrs }))
        }
        Event::Start(Tag::Link { link_type, dest_url, title, id }) if settings.safe && !is_safe_url(&dest_url) => {
            Some(Event::Start(Tag::Link { link_type, dest_url: CowStr::from("#"), title, id }))
        }
        Event::Start(Tag::Image { link_type, dest_url, title, id }) if settings.safe && !is_safe_url(&dest_url) => {
            Some(Event::Start(Tag::Image { link_type, dest_url: CowStr::from(""), title, id }))
        }
        Event::Start(Tag::Link { link_type, dest_url, title, id }) if settings.rewrite_md_links => {
            let dest_url = match dest_url.strip_suffix(".md") {
                Some(stem) if !dest_url.contains("://") => CowStr::from(format!("{stem}.html")),
                _ => dest_url,
            };
            Some(Event::Start(Tag::Link { link_type, dest_url, title, id }))
        }
        other => Some(other),
    });

    let mut body = String::new();
    html::push_html(&mut body, events);
    Rendered { body, toc }
}

/// Allow web links, mail links and relative paths. Anything with another scheme, such as
/// `javascript:` or `data:`, is replaced.
fn is_safe_url(url: &str) -> bool {
    let trimmed = url.trim_start();
    match trimmed.split_once(':') {
        // A colon before any slash or question mark means the text before it is a scheme.
        Some((scheme, _)) if !scheme.contains(['/', '?', '#']) => {
            matches!(scheme.to_ascii_lowercase().as_str(), "http" | "https" | "mailto")
        }
        _ => true,
    }
}

pub fn page(title: &str, rendered: &Rendered) -> String {
    let mut nav = String::new();
    for (level, text, id) in &rendered.toc {
        let depth = *level as usize;
        nav.push_str(&format!("<li class=\"h{depth}\"><a href=\"#{id}\">{}</a></li>\n", escape(text)));
    }
    format!(
        "<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<title>{}</title>\n</head>\n<body>\n<nav><ul>\n{nav}</ul></nav>\n<main>\n{}</main>\n</body>\n</html>\n",
        escape(title),
        rendered.body
    )
}

fn escape(text: &str) -> String {
    text.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;")
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn slugs_drop_punctuation_and_collapse_runs() {
        assert_eq!(slug("Rust & WebAssembly: a tour"), "rust-webassembly-a-tour");
        assert_eq!(slug("  Spaces  "), "spaces");
    }

    #[test]
    fn raw_html_passes_through_by_default_and_is_removed_in_safe_mode() {
        let md = "hello\n\n<script>alert(1)</script>\n\nworld <b onclick=\"x()\">bold</b>\n";
        assert!(render(md, Settings::default()).body.contains("<script>"));
        let safe = render(md, Settings { safe: true, ..Settings::default() }).body;
        assert!(!safe.contains("<script>") && !safe.contains("onclick"));
        assert!(safe.contains("hello") && safe.contains("world") && safe.contains("bold"));
    }

    #[test]
    fn safe_mode_neutralizes_script_urls_but_keeps_ordinary_links() {
        let md = "[a](javascript:alert(1)) [b](JaVaScRiPt:x) [c](https://example.test) [d](page.html?q=a:b) ![i](data:text/html;base64,AAAA)";
        let out = render(md, Settings { safe: true, ..Settings::default() }).body;
        assert!(!out.to_lowercase().contains("javascript:"), "{out}");
        assert!(!out.contains("data:"), "{out}");
        assert!(out.contains("https://example.test") && out.contains("page.html?q=a:b"));
    }

    #[test]
    fn markdown_links_are_rewritten_but_external_ones_are_not() {
        let md = "[a](guide.md) and [b](https://example.test/x.md)";
        let out = render(md, Settings { rewrite_md_links: true, ..Settings::default() }).body;
        assert!(out.contains("href=\"guide.html\""));
        assert!(out.contains("href=\"https://example.test/x.md\""));
    }

    #[test]
    fn every_heading_gets_a_unique_enough_id_and_a_toc_entry() {
        let r = render("# One\n\n## Two\n\n## Two\n", Settings::default());
        assert_eq!(r.toc.len(), 3);
        assert!(r.body.contains("id=\"one\""));
        assert!(r.body.contains("id=\"two\"") && r.body.contains("id=\"two-1\""));
    }
}

The full converter is in that listing, and the safe-mode arm is one line near the top of the filter_map: Event::Html(_) | Event::InlineHtml(_) if settings.safe => None. A test locks it in: with default settings the output contains <script>; with safe mode it contains neither the script nor an onclick attribute on an inline tag, and the surrounding text (hello, world, bold) survives.

Dropping HTML is not enough

A converter that removes raw HTML is still unsafe, and this is the part that deserves attention. Markdown has its own syntax for links and images, and the URLs inside can carry executable schemes. A plain link and an image, converted in safe mode before the URL fix:

Text
<p><a href="javascript:alert(1)">click</a> and <img src="data:text/html;base64,AAAA" alt="img" /></p>

The script tag is gone and the javascript: URL is not. Clicking the link runs the code. Safe mode therefore has a second job: inspect each link and image destination and replace anything that is not an ordinary web, mail or relative URL.

src/convert.rsrust
fn is_safe_url(url: &str) -> bool {
    let trimmed = url.trim_start();
    match trimmed.split_once(':') {
        // A colon before any slash or question mark means the text before it is a scheme.
        Some((scheme, _)) if !scheme.contains(['/', '?', '#']) => {
            matches!(scheme.to_ascii_lowercase().as_str(), "http" | "https" | "mailto")
        }
        _ => true,
    }
}

The function is an allow-list, not a block-list: it accepts three schemes by name and refuses everything else, so a scheme nobody has thought of yet is blocked by default. It lowercases the scheme because browsers do (JaVaScRiPt: runs), and it treats a colon after a slash or a question mark as part of the path, so a relative link like page.html?q=a:b survives. After the fix, the same input converts to:

Text
<p><a href="#">click</a> and <img src="" alt="img" /></p>
<p><a href="https://example.test">ok</a> <a href="page.html">rel</a></p>

The dangerous link now points at # and the image has an empty source, while the ordinary links are untouched. The test safe_mode_neutralizes_script_urls_but_keeps_ordinary_links asserts both halves, including that the mixed-case JaVaScRiPt: is caught.

Be honest about what this is. It is a reasonable defense for a small tool, and it is not a full HTML sanitizer. For content from untrusted users that will be shown on a website, convert with this tool and then pass the HTML through a real sanitizer such as the ammonia crate, which parses the output and applies an allow-list of tags and attributes. Layered defenses fail less often than any single filter, and a sanitizer is the layer that does not depend on you remembering every case.

Headings, anchors and a table of contents

Readers want to link to a section, and the table of contents needs somewhere to point. The converter makes two passes over the events. The first collects headings: when it sees the start of a heading it begins accumulating text, and at the end it computes an id from the text. The slug function lowercases, drops punctuation and joins words with hyphens, so Rust & WebAssembly: a tour becomes rust-webassembly-a-tour.

A naive slug collides when two headings share a title, and that happens constantly (Setup, Examples, Notes). The first version used the slug directly and a test that claimed to check uniqueness only checked the count of headings, which passed. Looking at the output showed the bug:

Text
id="notes"
id="setup"
id="setup"

Two headings with the same id are invalid HTML, and the second link in the table of contents jumps to the first section. The fix counts occurrences of each slug and appends a suffix to repeats, the convention used by GitHub:

Text
id="notes"
id="setup"
id="setup-1"

The test now asserts both id="two" and id="two-1" for a document with two Two headings. The lesson is about tests as much as slugs: a test that asserts the length of a list proves the list has that many entries and says nothing about the entries. Assert the thing you care about.

The second pass runs the events through filter_map and rewrites each heading start to carry its precomputed id, in order. Because the first pass and the second walk the same document in the same order, the nth heading in one corresponds to the nth in the other. That coupling is the weak point of the design, and it is acceptable here because both passes use the same options. Changing the options between passes would shift the indices, which is why the options live in one function.

Documentation written as a folder of Markdown files links between pages with guide.md. After conversion those links should point at guide.html. The event stream again makes this a local change: match Start(Tag::Link { dest_url, .. }), and if the destination ends in .md and has no :// in it, swap the suffix. Links to other sites that happen to end in .md are left alone, because the check for a scheme separator excludes them:

Text
<a href="guide.html">
<a href="https://example.test/guide.md">
<a href="#1">

The third line is the footnote reference, untouched. The rewrite handles fragments poorly (guide.md#setup would not match the suffix check), which is a good first extension: split off the #fragment before testing and reattach it afterward.

A full page, and the command line

A fragment of HTML is rarely what you want to publish. The page function wraps the body in a document with a charset, a title and a navigation list built from the table of contents, escaping the title and heading text so a heading containing < cannot break the markup. The title comes from the --title flag or, by default, the first heading:

Text
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Release notes</title>
</head>
<body>
<nav><ul>
<li class="h1"><a href="#release-notes">Release notes</a></li>
<li class="h2"><a href="#features">Features</a></li>
</ul></nav>
<main>
<h1 id="release-notes">Release notes</h1>

The binary is a thin shell, as every tool in this series is: parse arguments with clap, call convert::render, write the result, and map errors to an exit code.

src/main.rsrust
mod convert;

use std::io::{self, Read, Write};
use std::path::PathBuf;
use std::process::ExitCode;

use anyhow::{Context, Result};
use clap::Parser;

/// Convert Markdown to HTML.
#[derive(Parser)]
#[command(name = "mdhtml", version)]
struct Cli {
    /// Markdown file to read. Reads standard input when omitted.
    input: Option<PathBuf>,
    /// Write the result here instead of standard output.
    #[arg(short, long)]
    output: Option<PathBuf>,
    /// Wrap the output in a full HTML page with a table of contents.
    #[arg(long)]
    page: bool,
    /// Remove raw HTML from the input. Use this for text you do not trust.
    #[arg(long)]
    safe: bool,
    /// Point links to `other.md` at `other.html`.
    #[arg(long)]
    rewrite_links: bool,
    /// Page title; defaults to the first heading.
    #[arg(long)]
    title: Option<String>,
}

fn run(cli: &Cli) -> Result<()> {
    let markdown = match &cli.input {
        Some(path) => std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?,
        None => {
            let mut buf = String::new();
            io::stdin().read_to_string(&mut buf).context("reading standard input")?;
            buf
        }
    };
    let settings = convert::Settings { safe: cli.safe, rewrite_md_links: cli.rewrite_links };
    let rendered = convert::render(&markdown, settings);
    let output = if cli.page {
        let title = cli.title.clone().or_else(|| rendered.toc.first().map(|(_, t, _)| t.clone())).unwrap_or_else(|| "Untitled".into());
        convert::page(&title, &rendered)
    } else {
        rendered.body
    };
    match &cli.output {
        Some(path) => std::fs::write(path, output).with_context(|| format!("writing {}", path.display()))?,
        None => io::stdout().write_all(output.as_bytes()).context("writing standard output")?,
    }
    Ok(())
}

fn main() -> ExitCode {
    match run(&Cli::parse()) {
        Ok(()) => ExitCode::SUCCESS,
        Err(e) => {
            eprintln!("mdhtml: {e:#}");
            ExitCode::from(2)
        }
    }
}

Reading from a file, from standard input when none is given, writing to a file or standard output, and the three behavior flags (--page, --safe, --rewrite-links) cover most uses. A missing input file produces a one-line message with the path in it and exit status 2:

Text
mdhtml: reading nope.md: No such file or directory (os error 2)

Run mdhtml --safe docs/guide.md > guide.html in a shell script, and the pipeline works with curl, grep and xargs like any other filter. For a whole folder, find docs -name '*.md' | xargs -I{} mdhtml --page --safe --rewrite-links {} -o {}.html converts every page.

Front matter and code blocks: two more event-stream jobs

Static sites put metadata at the top of each page in a block fenced by ---, and tools often want to list or lint the code samples in a document. Neither needs a new parser. Front matter is text before Markdown starts, so you split it off first; code blocks are runs of events between Start(Tag::CodeBlock) and End(TagEnd::CodeBlock), so you collect them from the stream. This program does both on one document and prints what it found:

src/main.rsrust
use std::collections::BTreeMap;

use pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag, TagEnd};

/// Split a leading `---` block of `key: value` lines from the Markdown that follows it.
fn split_front_matter(text: &str) -> (BTreeMap<String, String>, &str) {
    let mut meta = BTreeMap::new();
    let Some(rest) = text.strip_prefix("---\n") else { return (meta, text) };
    let Some(end) = rest.find("\n---\n") else { return (meta, text) };
    for line in rest[..end].lines() {
        if let Some((key, value)) = line.split_once(':') {
            meta.insert(key.trim().to_string(), value.trim().trim_matches('"').to_string());
        }
    }
    (meta, &rest[end + 5..])
}

/// Every fenced code block with its language tag.
fn code_blocks(markdown: &str) -> Vec<(String, String)> {
    let mut out = Vec::new();
    let mut current: Option<(String, String)> = None;
    for event in Parser::new_ext(markdown, Options::empty()) {
        match event {
            Event::Start(Tag::CodeBlock(kind)) => {
                let lang = match kind {
                    CodeBlockKind::Fenced(info) => info.split_whitespace().next().unwrap_or("").to_string(),
                    CodeBlockKind::Indented => String::new(),
                };
                current = Some((lang, String::new()));
            }
            Event::Text(t) => {
                if let Some((_, code)) = current.as_mut() {
                    code.push_str(&t);
                }
            }
            Event::End(TagEnd::CodeBlock) => out.extend(current.take()),
            _ => {}
        }
    }
    out
}

fn main() {
    let doc = "---\ntitle: \"Ownership notes\"\ntags: rust, memory\n---\n# Intro\n\n```rust\nlet s = String::from(\"hi\");\n```\n\nText.\n\n```toml\n[dependencies]\nserde = \"1\"\n```\n";
    let (meta, body) = split_front_matter(doc);
    println!("front matter: {meta:?}");
    println!("body starts: {:?}", body.lines().next());
    for (lang, code) in code_blocks(body) {
        println!("{lang:>5}: {} line(s), first = {:?}", code.lines().count(), code.lines().next().unwrap_or(""));
    }
    let (no_meta, same) = split_front_matter("# Just a heading\n");
    println!("without front matter: {} keys, body unchanged: {}", no_meta.len(), same == "# Just a heading\n");
}
Text
front matter: {"tags": "rust, memory", "title": "Ownership notes"}
body starts: Some("# Intro")
 rust: 1 line(s), first = "let s = String::from(\"hi\");"
 toml: 2 line(s), first = "[dependencies]"
without front matter: 0 keys, body unchanged: true

Several details in split_front_matter are the sort that bite later. It returns the metadata and a slice of the original text, &str, borrowed from the input, so the body is not copied. It requires the opening fence at the very start and a closing fence on its own line, and if either is missing it returns the text unchanged with no metadata, which is the safe choice: a document that starts with a horizontal rule, ---, must not lose its first lines. It uses a BTreeMap so the keys print in a stable order, which matters for tests. And it handles only flat key: value lines. Real YAML has lists, nested maps and multi-line strings, and for those the right move is the serde_yaml crate (or TOML front matter with toml), not a longer hand-rolled parser.

code_blocks shows the standard technique for collecting text between two markers: keep an Option accumulator, set it on the start event, append Text events while it is Some, and take it on the end event. The language comes from the fence's info string, rust or toml here, and an indented block has none. A documentation checker uses this to find every rust block and compile it, which is exactly how cargo test checks doctests. The same loop with a different action (write each block to a file, count lines, run a formatter) covers most "do something with the code samples" tools.

Testing the transformations

A converter is a pure function from text to text, which is as testable as code gets, and its failures are specific: a link that did not rewrite, an id that collided, a script tag that survived. Keep one test per behavior and make each assertion about the property, not about the whole output string, because the exact HTML changes when the library updates. For example, assert that the output contains("href=\"guide.html\""), not that it equals a full document. Add one snapshot test of a complete sample document so that an upgrade shows up as a reviewable diff; the insta crate manages the stored snapshots. And add every bug you find to the suite as a failing case before fixing it: the duplicate-heading ids and the javascript: URL both entered the project that way.

Run the whole suite on a corpus of real documents once in a while. Parsers meet input that tests never imagine, such as a file with Windows line endings, a table with a missing cell, or a footnote reference with no definition. The event stream is forgiving, and the output should be too: render something sensible and never panic. A quick check is to run the converter over every Markdown file in a large repository and confirm that it exits zero for all of them.

Speed

The pass structure is cheap. A generated document of 4,000 sections with tables, lists and links, 0.56 MB, converted with --safe --rewrite-links --page in about 10 milliseconds once the file was in the cache (the first cold run took 0.23 s, dominated by reading the file from disk), producing 1.48 MB of HTML. The pull parser's lack of an in-memory tree is the reason. If you ever need to convert thousands of files, the time is in process startup and disk I/O, not in the conversion, so convert in one process with a loop instead of launching mdhtml per file.

Extending it

Each of these is a change to the event stream. Syntax highlighting for fenced code blocks: collect the text events inside Start(Tag::CodeBlock(...)), run them through a highlighter such as syntect, and emit one Event::Html with the colored markup. That is the one place where producing raw HTML inside safe mode is correct, because you generated it. Front matter: strip a leading --- block before parsing and return it separately. Heading permalinks: add an anchor Html event after each heading's text. Custom containers like :::note: the parser does not support them, so you recognize them in text events yourself, or reach for a parser that does.

When to use something else

For a documentation site with a theme, search and navigation, mdBook or a static site generator already exists and solves it. If you need strict GitHub-flavored Markdown output and a tree to manipulate, comrak builds an AST and mirrors GitHub's renderer. If you need many output formats, pandoc converts between dozens. Use pulldown-cmark directly when you want a fast, small, embeddable converter that you can bend through an iterator, as in a static site generator you are writing, a preview in an editor, or a build step. For the other half of that toolbox, building a Markdown link checker parses the same documents to verify their links.

Running it in a build

A converter earns its place when it runs unattended. In a documentation repository the usual wiring is a build step that converts every page and fails the build if anything goes wrong. Two properties of the tool make that reliable. It exits non-zero with a message on any I/O failure, so a missing or unreadable file stops the job instead of producing a half-built site. And its output is deterministic: the same input and flags give byte-identical HTML, because ids depend only on the heading text and the order of the document, with no clock and no random state. That lets you commit the generated pages and review the diff when a source page changes, or cache the build by hashing the inputs.

Pair it with the link checker from the Markdown link checker guide: convert with --rewrite-links so pages point at each other's HTML, then check that every internal link resolves to a file that exists. A pipeline that converts, checks links and sanitizes the result catches most documentation bugs before a reader sees them. Pin the versions of pulldown-cmark in Cargo.lock and update on purpose, since a parser upgrade can legitimately change the HTML for edge cases, and the snapshot test is where you notice.

Share

Keep reading

Get new posts by email

We send one email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.