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:
| Markdown | HTML |
|---|---|
# / ## / ### Title | <h1> / <h2> / <h3> |
**bold** | <strong>bold</strong> |
*italic* | <em>italic</em> |
`code` | <code>code</code> |
- item lines | a <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 ** 3with no closing) is left alone, not mangled. - The conversion functions have at least four unit tests.
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.
& 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('&', "&").replace('<', "<").replace('>', ">")
}
The & replacement must come first. If <
went first, the < it produces would then get its
& escaped into &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('&', "&").replace('<', "<").replace('>', ">")
}
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 < 2 & 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.
Make it yours
- Links.
[text](url)into<a href="url">text</a>: twofinds 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>: yourin_listflag 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::metadatamodified time changes.