Project · after Level 4

Markdown Converter 📝

Turn README.md into a web page with a program you wrote. This is a real parser with real edge cases, and it is Lesson 18's string mastery earning its keep. Every static site generator started life as exactly this project.

📋 The spec

cargo run -- notes.md writes notes.html, converting:

MarkdownHTML
# / ## / ### Title<h1> / <h2> / <h3>
**bold**<strong>bold</strong>
*italic*<em>italic</em>
`code`<code>code</code>
- item linesa <ul> of <li>s
any other text<p>...</p>

Requirements:

  • &, <, and > in the source are escaped so they display as text instead of becoming HTML.
  • Consecutive - lines share one list; the <ul> closes when the bullets stop.
  • An unpaired marker (2 ** 3 with no closing) is left alone, not mangled.
  • The conversion functions have at least four unit tests.
🧪 Playground-friendly All the interesting logic is string-to-string, so build and test convert() in the Playground first. Only the final file in, file out step needs a real project.

The design: two layers

Markdown has block structure (headings, lists, paragraphs: decided line by line) and inline structure (bold, italic, code: decided inside a line). Split the problem exactly there: convert() walks lines and decides blocks; convert_inline() handles emphasis within a line. Two small problems instead of one hairy one.

Two std tools carry the block layer:

if let Some(text) = trimmed.strip_prefix("## ") {
    // it's an h2, and `text` is everything after the marker
}

And find, which returns Option<usize>, the byte position of a substring, powering the inline layer.

⚠️ Escape first, always Escape & before < and >, and escape before inserting your own tags, or you will escape the tags you just added. Ordering bugs like this are the daily bread of text processing; meet yours in a toy project.

Hints, in order of desperation

Hint 1: The block loop skeleton
fn convert(markdown: &str) -> String {
    let mut html = String::new();
    let mut in_list = false;

    for line in markdown.lines() {
        let trimmed = line.trim();
        // 1. if in_list and this line is not a bullet: close the ul
        // 2. try ### , then ## , then #  (order matters!)
        // 3. try "- "  (open the ul if not already in one)
        // 4. blank line: skip
        // 5. otherwise: a paragraph
    }
    // don't forget: close the ul if the file ends mid-list
    html
}

Why try ### before #? Because strip_prefix("# ") would happily match a ### line's first character and turn your h3 into an h1 with two stray hashes. Most-specific first.

Hint 2: The escape function
fn escape_html(text: &str) -> String {
    text.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;")
}

The & replacement must come first. If < went first, the &lt; it produces would then get its & escaped into &amp;lt;. Try it on paper; this is the project's first ordering trap and it is a classic.

Hint 3: Converting **pairs** into tags

One function handles bold, italic, and code, because they are all "find a pair of markers, wrap what's between":

fn convert_pairs(text: &str, marker: &str, open: &str, close: &str) -> String {
    let mut result = String::new();
    let mut rest = text;
    loop {
        let Some(start) = rest.find(marker) else { break };
        let after_open = &rest[start + marker.len()..];
        let Some(len) = after_open.find(marker) else { break };

        // adjacent markers with nothing between them are not emphasis
        if len == 0 {
            result.push_str(&rest[..start + marker.len()]);
            rest = after_open;
            continue;
        }

        result.push_str(&rest[..start]);
        result.push_str(open);
        result.push_str(&after_open[..len]);
        result.push_str(close);
        rest = &after_open[len + marker.len()..];
    }
    result.push_str(rest);
    result
}

Then convert_inline is three calls: ** first, then *, then `. Bold before italic, or the single stars inside ** get claimed first. Ordering trap number two.

That len == 0 branch? Our first draft did not have it, and the test caught 2 ** 3 becoming an empty <em></em>: the italic pass was eating the leftover double star. Tests exist for exactly this.

Hint 4: The full reference solution
use std::env;
use std::fs;
use std::process;

/// Replace pairs of `marker` with an opening and closing tag, in turn.
fn convert_pairs(text: &str, marker: &str, open: &str, close: &str) -> String {
    let mut result = String::new();
    let mut rest = text;
    loop {
        let Some(start) = rest.find(marker) else { break };
        let after_open = &rest[start + marker.len()..];
        let Some(len) = after_open.find(marker) else { break };

        if len == 0 {
            result.push_str(&rest[..start + marker.len()]);
            rest = after_open;
            continue;
        }

        result.push_str(&rest[..start]);
        result.push_str(open);
        result.push_str(&after_open[..len]);
        result.push_str(close);
        rest = &after_open[len + marker.len()..];
    }
    result.push_str(rest);
    result
}

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

fn convert_inline(text: &str) -> String {
    let mut out = escape_html(text);
    out = convert_pairs(&out, "**", "<strong>", "</strong>");
    out = convert_pairs(&out, "*", "<em>", "</em>");
    out = convert_pairs(&out, "`", "<code>", "</code>");
    out
}

fn convert(markdown: &str) -> String {
    let mut html = String::new();
    let mut in_list = false;

    for line in markdown.lines() {
        let trimmed = line.trim();

        if in_list && !trimmed.starts_with("- ") {
            html.push_str("</ul>\n");
            in_list = false;
        }

        if let Some(text) = trimmed.strip_prefix("### ") {
            html.push_str(&format!("<h3>{}</h3>\n", convert_inline(text)));
        } else if let Some(text) = trimmed.strip_prefix("## ") {
            html.push_str(&format!("<h2>{}</h2>\n", convert_inline(text)));
        } else if let Some(text) = trimmed.strip_prefix("# ") {
            html.push_str(&format!("<h1>{}</h1>\n", convert_inline(text)));
        } else if let Some(text) = trimmed.strip_prefix("- ") {
            if !in_list {
                html.push_str("<ul>\n");
                in_list = true;
            }
            html.push_str(&format!("  <li>{}</li>\n", convert_inline(text)));
        } else if trimmed.is_empty() {
            // blank line: paragraph break, nothing to emit
        } else {
            html.push_str(&format!("<p>{}</p>\n", convert_inline(trimmed)));
        }
    }
    if in_list {
        html.push_str("</ul>\n");
    }
    html
}

fn main() {
    let args: Vec<String> = env::args().collect();
    let Some(path) = args.get(1) else {
        eprintln!("usage: mdconvert <file.md>");
        process::exit(1);
    };

    let markdown = match fs::read_to_string(path) {
        Ok(m) => m,
        Err(e) => {
            eprintln!("could not read {path}: {e}");
            process::exit(1);
        }
    };

    let html = convert(&markdown);
    let out_path = path.replace(".md", ".html");
    fs::write(&out_path, &html).expect("could not write output");
    println!("wrote {out_path} ({} bytes)", html.len());
}

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

    #[test]
    fn headings_convert() {
        assert_eq!(convert("# Title"), "<h1>Title</h1>\n");
        assert_eq!(convert("## Sub"), "<h2>Sub</h2>\n");
    }

    #[test]
    fn bold_italic_code() {
        assert_eq!(convert_inline("a **b** c"), "a <strong>b</strong> c");
        assert_eq!(convert_inline("a *b* c"), "a <em>b</em> c");
        assert_eq!(convert_inline("run `cargo`"), "run <code>cargo</code>");
    }

    #[test]
    fn html_is_escaped() {
        assert_eq!(convert_inline("1 < 2 & 3"), "1 &lt; 2 &amp; 3");
    }

    #[test]
    fn lists_open_and_close() {
        let html = convert("- one\n- two\n\ndone");
        assert_eq!(html, "<ul>\n  <li>one</li>\n  <li>two</li>\n</ul>\n<p>done</p>\n");
    }

    #[test]
    fn unclosed_marker_left_alone() {
        assert_eq!(convert_inline("2 ** 3"), "2 ** 3");
    }
}

Convert your own project's README, open the result in a browser, and enjoy the fact that a program you wrote just published a document.

Stretch goals

Make it yours

  • Links. [text](url) into <a href="url">text</a>: two finds and some slicing.
  • Code blocks. Toggle a "raw mode" on ``` lines: inside it, escape but do not convert, and wrap in <pre><code>.
  • A full page. Wrap the output in a proper HTML skeleton with a <style> block, and your converter becomes a one-file static site generator.
  • Numbered lists. 1. lines into <ol>: your in_list flag needs to grow up into an enum (None, Bullets, Numbers). Feel how state machines evolve.
  • Watch mode. Loop forever, re-converting whenever the file's fs::metadata modified time changes.