Document Converter
Documents come in many formats,Markdown for writing, HTML for the web, plain text for quick sharing. Manually converting between them is tedious and error-prone. In this project, you will build a Python tool that reads Markdown files, converts them to HTML or plain text, extracts metadata from document headers, and processes entire directories in one command.
- Run it in your browser. An interactive companion notebook is ready, open it in Colab, Kaggle, or Binder and follow along top-to-bottom.
What You’ll Learn
- Parse Markdown syntax into structured data
- Convert Markdown to HTML with proper tags
- Strip HTML to plain text
- Extract metadata from document frontmatter
- Process multiple files in batch
What You’ll Build
A document converter that:
- Reads Markdown files and converts to HTML
- Applies CSS styling to generated HTML
- Converts HTML back to plain text
- Extracts metadata from YAML frontmatter
- Processes entire directories in one command
Setup
uv init document-converter
cd document-converter
Step 1: Read Markdown Files
Objective: Load the contents of a Markdown file into a Python string so you can work with it.
Explanation: File reading is the foundation of any converter. Python’s open() function with the "r" mode opens a file for reading. The .read() method pulls the entire file contents into a single string. Always use a with statement so the file closes automatically, even if an error occurs.
Starter hint: You need a sample Markdown file to test with. Create sample.md first, then write a function that reads it.
Create the sample file
Create a file called sample.md in your project root with this content:
# Hello World
This is a **bold** word and this is an *italic* word.
## Features
- Item one
- Item two
- Item three
[A link to Python](https://python.org)
```python
print("Hello!")
### Working code
```python
def read_markdown(filepath: str) -> str:
"""Read a Markdown file and return its contents."""
with open(filepath, "r", encoding="utf-8") as f:
return f.read()
if __name__ == "__main__":
content = read_markdown("sample.md")
print(f"Read {len(content)} characters from sample.md")
print("---")
print(content[:200])
Expected output
Read 189 characters from sample.md
---
# Hello World
This is a **bold** word and this is an *italic* word.
## Features
- Item one
- Item two
- Item three
[A link to Python](https://python.org)
```python
print("Hello!")
### Troubleshooting
- **`FileNotFoundError`**: Check the file path. Use `os.path.exists(filepath)` to verify the file exists before reading.
- **`UnicodeDecodeError`**: Some files use non-UTF-8 encoding. Add `errors="replace"` to `open()` to skip bad characters.
- **Empty output**: The file might be empty or the path points to the wrong file. Print `filepath` before opening.
### Checklist
- [ ] Created `sample.md` with Markdown content
- [ ] Function returns the full file contents as a string
- [ ] Used `with` statement for safe file handling
- [ ] Verified output prints the first 200 characters
### Socratic question
Why does using `with open(...)` matter compared to calling `open()` and `close()` manually? What happens if an exception is raised between `open()` and `close()`?
---
## Step 2: Convert Markdown to HTML
**Objective:** Transform Markdown syntax into corresponding HTML tags.
**Explanation:** Markdown has a simple, consistent syntax: `#` for headings, `**text**` for bold, `*text*` for italic, `-` for list items, `[text](url)` for links, and triple backticks for code blocks. You can write a converter by mapping each pattern to its HTML equivalent using regular expressions.
**Starter hint:** Use the `re` module. For each Markdown element, write a pattern that matches it and a replacement that wraps it in HTML tags.
### Working code
```python
import re
def markdown_to_html(md_text: str) -> str:
"""Convert Markdown text to HTML."""
html = md_text
# Code blocks (``` ... ```)
html = re.sub(
r"```(\w*)\n(.*?)```",
r'<pre><code class="language-\1">\2</code></pre>',
html,
flags=re.DOTALL,
)
# Inline code
html = re.sub(r"`([^`]+)`", r"<code>\1</code>", html)
# Headings
html = re.sub(r"^### (.+)$", r"<h3>\1</h3>", html, flags=re.MULTILINE)
html = re.sub(r"^## (.+)$", r"<h2>\1</h2>", html, flags=re.MULTILINE)
html = re.sub(r"^# (.+)$", r"<h1>\1</h1>", html, flags=re.MULTILINE)
# Bold and italic
html = re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", html)
html = re.sub(r"\*(.+?)\*", r"<em>\1</em>", html)
# Links
html = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", r'<a href="\2">\1</a>', html)
# Unordered lists
lines = html.split("\n")
in_list = False
result = []
for line in lines:
if line.startswith("- "):
if not in_list:
result.append("<ul>")
in_list = True
result.append(f" <li>{line[2:]}</li>")
else:
if in_list:
result.append("</ul>")
in_list = False
result.append(line)
if in_list:
result.append("</ul>")
html = "\n".join(result)
# Paragraphs (wrap remaining plain text lines)
html = re.sub(r"\n\n+", "\n\n", html)
return html
if __name__ == "__main__":
content = read_markdown("sample.md")
html = markdown_to_html(content)
print(html)
Expected output
<h1>Hello World</h1>
<p>This is a <strong>bold</strong> word and this is an <em>italic</em> word.</p>
<h2>Features</h2>
<ul>
<li>Item one</li>
<li>Item two</li>
<li>Item three</li>
</ul>
<a href="https://python.org">A link to Python</a>
<pre><code class="language-python">print("Hello!")</code></pre>
Troubleshooting
- Bold not converting: Make sure
**patterns are processed before*patterns. Otherwise the italic regex will match the first*of**and break the bold pattern. - Code blocks eating content: The
re.DOTALLflag lets.match newlines inside the code block. Without it, the regex only matches single-line code blocks. - Lists not wrapping: The list parser relies on consecutive lines starting with
-. Blank lines between items break the group. That is fine for this project,each list block is handled separately.
Checklist
- Headings convert to
<h1>,<h2>,<h3>tags - Bold (
**) becomes<strong>and italic (*) becomes<em> - Links convert to
<a href="...">tags - List items wrap in
<ul>and<li>tags - Code blocks wrap in
<pre><code>with language class
Socratic question
Why should bold patterns be processed before italic patterns? What would happen if the order were reversed?
Step 3: Add CSS Styling
Objective: Wrap generated HTML in a full document structure with embedded CSS for a polished look.
Explanation: Raw HTML without a <head> or <style> block renders as unstyled text in a browser. By wrapping your converted content in a complete HTML document with embedded CSS, you get a presentable page with no external dependencies.
Starter hint: Create a string constant that holds the HTML skeleton with a <style> block, then insert your converted content into the body.
Working code
CSS = """
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
max-width: 700px;
margin: 2rem auto;
padding: 0 1rem;
line-height: 1.6;
color: #333;
}
h1, h2, h3 { color: #111; }
code {
background: #f4f4f4;
padding: 0.2em 0.4em;
border-radius: 3px;
font-size: 0.9em;
}
pre {
background: #1e1e1e;
color: #d4d4d4;
padding: 1rem;
border-radius: 6px;
overflow-x: auto;
}
pre code { background: none; padding: 0; color: inherit; }
a { color: #0066cc; }
ul { padding-left: 1.5rem; }
"""
def wrap_html(body: str, title: str = "Converted Document") -> str:
"""Wrap HTML body in a full document with CSS."""
return f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{title}</title>
<style>{CSS}</style>
</head>
<body>
{body}
</body>
</html>"""
if __name__ == "__main__":
content = read_markdown("sample.md")
html_body = markdown_to_html(content)
full_html = wrap_html(html_body, title="My Document")
print(full_html[:500])
Expected output
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>My Document</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
max-width: 700px;
margin: 2rem auto;
...
</style>
</head>
<body>
<h1>Hello World</h1>
...
</body>
</html>
Troubleshooting
- CSS not showing: Make sure the
<style>tag is inside<head>, not<body>. - Special characters in title: If the title contains quotes, they will break the HTML attribute. Use
html.escape(title)from thehtmlmodule to sanitize it. - File not rendering in browser: Save as
.html(not.md) and open in a browser.
Checklist
- HTML includes
<!DOCTYPE html>declaration - CSS is embedded in a
<style>block inside<head> - Page has a configurable title
- Body content is inserted between
<body>tags - Opening the output file in a browser shows styled content
Socratic question
Why embed CSS directly in the HTML file instead of linking to an external stylesheet? What are the trade-offs of each approach?
Step 4: Convert HTML to Plain Text
Objective: Strip all HTML tags and return clean plain text.
Explanation: Converting HTML back to plain text is useful for previews, search indexing, or email bodies. The approach is straightforward: remove all tags with a regex, then clean up extra whitespace. This is not a full HTML parser, but it works well for simple documents.
Starter hint: Use re.sub(r"<[^>]+>", "", html) to remove tags, then collapse multiple spaces and blank lines.
Working code
import html as html_module
def html_to_text(html_str: str) -> str:
"""Convert HTML to plain text by stripping tags."""
text = html_str
# Replace block elements with newlines for spacing
text = re.sub(r"<(br|hr)\s*/?>", "\n", text)
text = re.sub(r"</(p|div|h[1-6]|li|pre)>", "\n", text)
# Remove all remaining tags
text = re.sub(r"<[^>]+>", "", text)
# Decode HTML entities (& -> &, < -> <, etc.)
text = html_module.unescape(text)
# Clean up whitespace
text = re.sub(r"\n{3,}", "\n\n", text)
text = re.sub(r"[ \t]+", " ", text)
text = text.strip()
return text
if __name__ == "__main__":
content = read_markdown("sample.md")
html_body = markdown_to_html(content)
plain = html_to_text(html_body)
print(plain)
Expected output
Hello World
This is a bold word and this is an italic word.
Features
- Item one
- Item two
- Item three
A link to Python
print("Hello!")
Troubleshooting
- Extra blank lines: The
\n{3,}pattern collapses three or more newlines into two. Adjust the threshold if you want tighter spacing. - Entities not decoding: Make sure you call
html_module.unescape()after removing tags, not before. Some entities live inside tag attributes and should not be decoded into the text body. - Formatting lost: This is expected. Plain text has no concept of bold or italic. The strip approach produces clean text but loses formatting information.
Checklist
- All HTML tags are removed
- HTML entities are decoded to their characters
- Extra whitespace and blank lines are collapsed
- Output is clean, readable plain text
Socratic question
What happens if you run this function on HTML that contains a <script> tag with JavaScript code? How would you handle that case?
Step 5: Extract Metadata
Objective: Parse YAML frontmatter from the top of a Markdown file and return it as a dictionary.
Explanation: Many Markdown files begin with a YAML frontmatter block delimited by ---. This block contains metadata like title, author, date, and tags. Extracting this data lets your converter add it to HTML <meta> tags or use it for file organization.
Starter hint: Split the file content on ---. The first segment is frontmatter (if it exists). Parse it line by line, splitting on the first : to get key-value pairs.
Create a test file
Create sample_with_meta.md:
---
title: My Blog Post
author: Jane Doe
date: 2025-01-15
tags: python, tutorial, beginner
---
# My Blog Post
This post covers Python basics.
## Why Python?
Python is great for beginners.
Working code
def extract_frontmatter(text: str) -> tuple[dict, str]:
"""Extract YAML frontmatter and return (metadata_dict, body_text)."""
lines = text.split("\n")
if not lines or lines[0].strip() != "---":
return {}, text
# Find the closing ---
end_index = None
for i, line in enumerate(lines[1:], start=1):
if line.strip() == "---":
end_index = i
break
if end_index is None:
return {}, text
# Parse frontmatter lines
metadata = {}
for line in lines[1:end_index]:
line = line.strip()
if not line or ":" not in line:
continue
key, _, value = line.partition(":")
key = key.strip()
value = value.strip()
# Convert comma-separated values to list
if "," in value:
value = [v.strip() for v in value.split(",")]
metadata[key] = value
body = "\n".join(lines[end_index + 1 :])
return metadata, body
if __name__ == "__main__":
text = read_markdown("sample_with_meta.md")
meta, body = extract_frontmatter(text)
print("Metadata:", meta)
print("---")
print("Body preview:", body[:100])
Expected output
Metadata: {'title': 'My Blog Post', 'author': 'Jane Doe', 'date': '2025-01-15', 'tags': ['python', 'tutorial', 'beginner']}
---
Body preview:
# My Blog Post
This post covers Python basics.
## Why Python?
Python is great for beginners.
Troubleshooting
- No metadata returned: The file must start with
---on the very first line. No blank lines before it. - Values with colons: If a value contains a colon (like
url: https://example.com),partition(":")handles it correctly because it splits on the first colon only. - Nested YAML: This parser handles flat key-value pairs. It does not support nested YAML structures. For those, use the
pyyamllibrary.
Checklist
- Frontmatter is extracted when present
- Non-frontmatter files return empty dict and full body
- Comma-separated tags become a list
- Body text starts after the closing
--- - Keys and values are stripped of whitespace
Socratic question
Why does this project parse frontmatter manually instead of using a library like PyYAML? When would you choose the manual approach versus reaching for a library?
Step 6: Batch Conversion
Objective: Process every Markdown file in a directory and convert them to HTML.
Explanation: Real-world use requires converting many files at once. Python’s os and pathlib modules let you walk directories, find .md files, and apply your converter to each one. Batch processing turns a one-file tool into a real utility.
Starter hint: Use pathlib.Path.glob("**/*.md") to recursively find all Markdown files in a directory.
Working code
from pathlib import Path
def batch_convert(
input_dir: str,
output_dir: str,
format: str = "html",
) -> list[str]:
"""Convert all Markdown files in input_dir to the target format.
Returns a list of output file paths.
"""
input_path = Path(input_dir)
output_path = Path(output_dir)
output_path.mkdir(parents=True, exist_ok=True)
converted_files = []
for md_file in input_path.glob("**/*.md"):
text = md_file.read_text(encoding="utf-8")
metadata, body = extract_frontmatter(text)
html_body = markdown_to_html(body)
# Determine output path
rel_path = md_file.relative_to(input_path)
if format == "html":
out_file = output_path / rel_path.with_suffix(".html")
full_html = wrap_html(html_body, title=metadata.get("title", rel_path.stem))
out_file.write_text(full_html, encoding="utf-8")
elif format == "text":
out_file = output_path / rel_path.with_suffix(".txt")
plain = html_to_text(html_body)
out_file.write_text(plain, encoding="utf-8")
converted_files.append(str(out_file))
print(f" Converted: {md_file.name} -> {out_file.name}")
return converted_files
if __name__ == "__main__":
print("Converting sample.md to HTML...")
files = batch_convert(".", "output/html", format="html")
print(f"\nDone. Converted {len(files)} file(s).")
Expected output
Converting sample.md to HTML...
Converted: sample.md -> sample.html
Converted: sample_with_meta.md -> sample_with_meta.html
Done. Converted 2 file(s).
Troubleshooting
FileExistsErroron mkdir: Useexist_ok=Trueto avoid errors if the output directory already exists.- Encoding errors on read: Some files may not be UTF-8. Wrap the
read_textcall in a try/except and fall back toerrors="replace". - Empty output directory: Check that the glob pattern matches your files.
**/*.mdis recursive;*.mdonly matches the top level.
Checklist
- All
.mdfiles in the input directory are found - Output files are created in the output directory
- HTML files include metadata as the page title
- Directory structure is preserved in the output
- Empty output directory is created if it does not exist
Socratic question
What would change if you needed to also process .markdown files (not just .md)? How would you modify the glob pattern?
Step 7: Build the CLI
Objective: Wrap all functionality in a command-line interface so users can run conversions from the terminal.
Explanation: A CLI makes your tool usable without writing Python code. Python’s argparse module handles argument parsing, help text, and validation. This is the final step that turns your scripts into a real command-line tool.
Starter hint: Use argparse.ArgumentParser with subcommands or flags for format, input, and output.
Working code
import argparse
import sys
def main():
parser = argparse.ArgumentParser(
description="Convert Markdown files to HTML or plain text.",
epilog="Examples:\n"
" python converter.py sample.md\n"
" python converter.py sample.md -o output.html -f text\n"
' python converter.py docs/ -o converted/ -f html',
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument(
"input",
help="Input file or directory to convert",
)
parser.add_argument(
"-o", "--output",
help="Output file or directory (default: stdout for files, ./output/ for dirs)",
)
parser.add_argument(
"-f", "--format",
choices=["html", "text"],
default="html",
help="Output format (default: html)",
)
parser.add_argument(
"--title",
default="Converted Document",
help="Title for HTML output (default: Converted Document)",
)
parser.add_argument(
"--extract-meta",
action="store_true",
help="Print extracted metadata and exit",
)
args = parser.parse_args()
input_path = Path(args.input)
# Extract metadata mode
if args.extract_meta:
if not input_path.is_file():
parser.error("--extract-meta requires a file, not a directory")
text = input_path.read_text(encoding="utf-8")
meta, _ = extract_frontmatter(text)
for key, value in meta.items():
print(f" {key}: {value}")
return
# Single file conversion
if input_path.is_file():
text = input_path.read_text(encoding="utf-8")
metadata, body = extract_frontmatter(text)
html_body = markdown_to_html(body)
if args.format == "html":
title = metadata.get("title", args.title)
result = wrap_html(html_body, title=title)
else:
result = html_to_text(html_body)
if args.output:
Path(args.output).write_text(result, encoding="utf-8")
print(f"Converted {input_path.name} -> {args.output}")
else:
print(result)
# Directory batch conversion
elif input_path.is_dir():
output_dir = args.output or "output"
files = batch_convert(str(input_path), output_dir, format=args.format)
print(f"\nConverted {len(files)} file(s) to {output_dir}/")
else:
print(f"Error: {args.input} does not exist", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()
Expected output
Single file to stdout:
python converter.py sample.md
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Converted Document</title>
<style>body { font-family: sans-serif; ... }</style>
</head>
<body>
<h1>Hello World</h1>
...
</body>
</html>
Single file to output:
python converter.py sample.md -o output.html
Converted sample.md -> output.html
Batch directory:
python converter.py docs/ -o converted/ -f html
Converted: intro.md -> intro.html
Converted: guide.md -> guide.html
Converted 2 file(s) to converted/
Extract metadata:
python converter.py sample_with_meta.md --extract-meta
title: My Blog Post
author: Jane Doe
date: 2025-01-15
tags: python, tutorial, beginner
Help:
python converter.py --help
usage: converter.py [-h] [-o OUTPUT] [-f {html,text}] [--title TITLE]
[--extract-meta] input
Convert Markdown files to HTML or plain text.
positional arguments:
input Input file or directory to convert
options:
-h, --help show this help message and exit
-o, --output Output file or directory
-f, --format Output format (default: html)
--title Title for HTML output
--extract-meta Print extracted metadata and exit
Examples:
python converter.py sample.md
python converter.py sample.md -o output.html -f text
python converter.py docs/ -o converted/ -f html
Troubleshooting
argparsesays unrecognized arguments: Make sure flags come after the positional argument, not before.sys.exitin tests: If you are testing this in a REPL, wrapmain()in a try/exceptSystemExit.- No output when piping: If piping to a file, make sure you are not also printing to stdout. The single-file mode prints to stdout when
-ois not specified.
Checklist
- CLI accepts input path, output path, and format flag
- Single file mode prints to stdout or writes to output file
- Directory mode converts all
.mdfiles -
--extract-metaprints frontmatter and exits -
--helpshows usage examples - Invalid input paths produce a clear error message
Socratic question
Why does the CLI use sys.exit(1) for errors instead of just printing a message? What does the exit code communicate to other programs or scripts that call your tool?
Complete converter.py
Here is the full file with all steps combined:
"""Document Converter - Convert between Markdown, HTML, and plain text."""
import re
import html as html_module
import argparse
import sys
from pathlib import Path
def read_markdown(filepath: str) -> str:
"""Read a Markdown file and return its contents."""
with open(filepath, "r", encoding="utf-8") as f:
return f.read()
def markdown_to_html(md_text: str) -> str:
"""Convert Markdown text to HTML."""
html = md_text
# Code blocks
html = re.sub(
r"```(\w*)\n(.*?)```",
r'<pre><code class="language-\1">\2</code></pre>',
html,
flags=re.DOTALL,
)
# Inline code
html = re.sub(r"`([^`]+)`", r"<code>\1</code>", html)
# Headings
html = re.sub(r"^### (.+)$", r"<h3>\1</h3>", html, flags=re.MULTILINE)
html = re.sub(r"^## (.+)$", r"<h2>\1</h2>", html, flags=re.MULTILINE)
html = re.sub(r"^# (.+)$", r"<h1>\1</h1>", html, flags=re.MULTILINE)
# Bold and italic
html = re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", html)
html = re.sub(r"\*(.+?)\*", r"<em>\1</em>", html)
# Links
html = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", r'<a href="\2">\1</a>', html)
# Lists
lines = html.split("\n")
in_list = False
result = []
for line in lines:
if line.startswith("- "):
if not in_list:
result.append("<ul>")
in_list = True
result.append(f" <li>{line[2:]}</li>")
else:
if in_list:
result.append("</ul>")
in_list = False
result.append(line)
if in_list:
result.append("</ul>")
html = "\n".join(result)
html = re.sub(r"\n{3,}", "\n\n", html)
return html
CSS = """
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
max-width: 700px;
margin: 2rem auto;
padding: 0 1rem;
line-height: 1.6;
color: #333;
}
h1, h2, h3 { color: #111; }
code {
background: #f4f4f4;
padding: 0.2em 0.4em;
border-radius: 3px;
font-size: 0.9em;
}
pre {
background: #1e1e1e;
color: #d4d4d4;
padding: 1rem;
border-radius: 6px;
overflow-x: auto;
}
pre code { background: none; padding: 0; color: inherit; }
a { color: #0066cc; }
ul { padding-left: 1.5rem; }
"""
def wrap_html(body: str, title: str = "Converted Document") -> str:
"""Wrap HTML body in a full document with CSS."""
return f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{title}</title>
<style>{CSS}</style>
</head>
<body>
{body}
</body>
</html>"""
def html_to_text(html_str: str) -> str:
"""Convert HTML to plain text by stripping tags."""
text = html_str
text = re.sub(r"<(br|hr)\s*/?>", "\n", text)
text = re.sub(r"</(p|div|h[1-6]|li|pre)>", "\n", text)
text = re.sub(r"<[^>]+>", "", text)
text = html_module.unescape(text)
text = re.sub(r"\n{3,}", "\n\n", text)
text = re.sub(r"[ \t]+", " ", text)
text = text.strip()
return text
def extract_frontmatter(text: str) -> tuple[dict, str]:
"""Extract YAML frontmatter and return (metadata_dict, body_text)."""
lines = text.split("\n")
if not lines or lines[0].strip() != "---":
return {}, text
end_index = None
for i, line in enumerate(lines[1:], start=1):
if line.strip() == "---":
end_index = i
break
if end_index is None:
return {}, text
metadata = {}
for line in lines[1:end_index]:
line = line.strip()
if not line or ":" not in line:
continue
key, _, value = line.partition(":")
key = key.strip()
value = value.strip()
if "," in value:
value = [v.strip() for v in value.split(",")]
metadata[key] = value
body = "\n".join(lines[end_index + 1 :])
return metadata, body
def batch_convert(
input_dir: str,
output_dir: str,
format: str = "html",
) -> list[str]:
"""Convert all Markdown files in input_dir to the target format."""
input_path = Path(input_dir)
output_path = Path(output_dir)
output_path.mkdir(parents=True, exist_ok=True)
converted_files = []
for md_file in input_path.glob("**/*.md"):
text = md_file.read_text(encoding="utf-8")
metadata, body = extract_frontmatter(text)
html_body = markdown_to_html(body)
rel_path = md_file.relative_to(input_path)
if format == "html":
out_file = output_path / rel_path.with_suffix(".html")
full_html = wrap_html(html_body, title=metadata.get("title", rel_path.stem))
out_file.write_text(full_html, encoding="utf-8")
elif format == "text":
out_file = output_path / rel_path.with_suffix(".txt")
plain = html_to_text(html_body)
out_file.write_text(plain, encoding="utf-8")
converted_files.append(str(out_file))
print(f" Converted: {md_file.name} -> {out_file.name}")
return converted_files
def main():
parser = argparse.ArgumentParser(
description="Convert Markdown files to HTML or plain text.",
epilog="Examples:\n"
" python converter.py sample.md\n"
" python converter.py sample.md -o output.html -f text\n"
' python converter.py docs/ -o converted/ -f html',
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument("input", help="Input file or directory to convert")
parser.add_argument(
"-o", "--output",
help="Output file or directory (default: stdout for files)",
)
parser.add_argument(
"-f", "--format",
choices=["html", "text"],
default="html",
help="Output format (default: html)",
)
parser.add_argument(
"--title",
default="Converted Document",
help="Title for HTML output",
)
parser.add_argument(
"--extract-meta",
action="store_true",
help="Print extracted metadata and exit",
)
args = parser.parse_args()
input_path = Path(args.input)
if args.extract_meta:
if not input_path.is_file():
parser.error("--extract-meta requires a file, not a directory")
text = input_path.read_text(encoding="utf-8")
meta, _ = extract_frontmatter(text)
for key, value in meta.items():
print(f" {key}: {value}")
return
if input_path.is_file():
text = input_path.read_text(encoding="utf-8")
metadata, body = extract_frontmatter(text)
html_body = markdown_to_html(body)
if args.format == "html":
title = metadata.get("title", args.title)
result = wrap_html(html_body, title=title)
else:
result = html_to_text(html_body)
if args.output:
Path(args.output).write_text(result, encoding="utf-8")
print(f"Converted {input_path.name} -> {args.output}")
else:
print(result)
elif input_path.is_dir():
output_dir = args.output or "output"
files = batch_convert(str(input_path), output_dir, format=args.format)
print(f"\nConverted {len(files)} file(s) to {output_dir}/")
else:
print(f"Error: {args.input} does not exist", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()
🧩 Challenges
-
Support ordered lists: Extend
markdown_to_htmlto convert1.,2.,3.lines into<ol>and<li>tags. -
Images: Add support for
converting to<img src="image.png" alt="alt text">. -
Tables: Markdown tables use
|and-characters. Add a converter that turns them into<table>tags. -
Word count: Add a
--statsflag that prints word count, line count, and character count instead of converting. -
Watch mode: Add a
--watchflag that monitors the input directory and re-converts when files change.
What You Learned
- Reading files with
open()and thewithstatement for safe resource handling - Using
re(regular expressions) to match and transform text patterns - Building HTML documents with embedded CSS for standalone pages
- Stripping HTML tags and decoding entities to produce clean plain text
- Parsing simple YAML-style frontmatter without external libraries
- Walking directories with
pathlib.Path.glob()for batch file processing - Building a CLI with
argparsethat supports flags, subcommands, and help text - Handling errors gracefully with exit codes and user-friendly messages
Finish every step, then mark this project complete to claim its XP.