Build a Markdown to HTML converter in Python (and keep it safe from XSS)
A converter teaches regular expressions and parsing, but the real lesson is security: unescaped output lets attackers inject scripts.

Markdown is the plain-text format behind README files, documentation and many blogs. Writing a small converter is a great way to practise regular expressions and text processing. It also teaches one of the most important security lessons in web development: never put untrusted text into HTML without escaping it. The code and output below were run with Python 3 before publishing.
What it supports
Headings (# to ######), paragraphs, bullet lists, bold, italic, inline code and links. That is a small subset of Markdown on purpose.
The code
Save as mini_md.py:
"""A tiny Markdown-to-HTML converter: headings, paragraphs, lists, bold, italic, code and links.
It escapes HTML first, so the input cannot inject tags or scripts."""
import html
import re
def inline(text):
text = html.escape(text) # 1. escape everything first
text = re.sub(r"`([^`]+)`", r"<code>\1</code>", text)
text = re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", text)
text = re.sub(r"\*(.+?)\*", r"<em>\1</em>", text)
def link(match):
label, url = match.group(1), match.group(2)
if not re.match(r"(https?://|/)", url): # 2. block javascript: and other schemes
return label
return f'<a href="{url}" rel="nofollow noopener">{label}</a>'
return re.sub(r"\[([^\]]+)\]\(([^)\s]+)\)", link, text)
def convert(markdown):
out, paragraph, items = [], [], []
def flush():
if paragraph:
out.append("<p>" + inline(" ".join(paragraph)) + "</p>")
paragraph.clear()
if items:
out.append("<ul>" + "".join(f"<li>{inline(i)}</li>" for i in items) + "</ul>")
items.clear()
for line in markdown.splitlines():
line = line.rstrip()
heading = re.match(r"(#{1,6})\s+(.*)", line)
bullet = re.match(r"[-*]\s+(.*)", line)
if not line:
flush()
elif heading:
flush()
level = len(heading.group(1))
out.append(f"<h{level}>{inline(heading.group(2))}</h{level}>")
elif bullet:
if paragraph:
flush()
items.append(bullet.group(1))
else:
if items:
flush()
paragraph.append(line)
flush()
return "\n".join(out)
if __name__ == "__main__":
import sys
print(convert(sys.stdin.read()))
Try it
Input, sample.md:
# My notes
Learning **Python** is *fun*. Read the [docs](https://docs.python.org) and run `python3`.
- first item
- second with **bold**
A [bad link](javascript:alert(1)) and <script>alert("hi")</script> stay harmless.
Run python3 mini_md.py < sample.md:
<h1>My notes</h1>
<p>Learning <strong>Python</strong> is <em>fun</em>. Read the <a href="https://docs.python.org" rel="nofollow noopener">docs</a> and run <code>python3</code>.</p>
<ul><li>first item</li><li>second with <strong>bold</strong></li></ul>
<p>A bad link) and <script>alert("hi")</script> stay harmless.</p>
How it works
Line by line. convert reads each line and decides what it is: blank (end of block), heading, bullet or normal text. Normal text lines are collected into a paragraph; bullet lines into a list. flush() writes out the block collected so far.
Regular expressions. (#{1,6})\s+(.*) matches one to six # signs, a space, then the heading text. \*\*(.+?)\*\* matches text between double asterisks, and the ? makes it non-greedy, meaning it stops at the first closing ** rather than the last one on the line. Replacements use \1 to put the captured text back inside HTML tags.
Order matters. We handle **bold** before *italic*, otherwise the single-asterisk rule would eat part of the double one.
The security lesson: escape first
Look at the last line of the sample. The input contained a real <script> tag, and the output shows it as <script>, which a browser displays as harmless text. That happens because the very first step of inline() is html.escape(text), which turns <, >, & and quotes into safe codes.
If we skipped that, anyone who could write Markdown (a comment, a profile bio) could inject <script> and run code in other users' browsers. That is cross-site scripting (XSS). Escape first, then add your own safe tags.
Two more details:
- Link schemes. A link such as
[click](javascript:alert(1))would run script when clicked. We only allow addresses that start withhttp://,https://or/, and otherwise output just the label. This "allow list" approach is safer than trying to block bad schemes one by one. - Quote characters. Because
html.escapealso converts"into", a URL cannot close thehrefattribute and add something likeonmouseover=.... There is a test for it. - We add
rel="nofollow noopener"to links from untrusted content.
Tests
Save as test_mini_md.py:
import unittest
from mini_md import convert, inline
class MarkdownTest(unittest.TestCase):
def test_heading_and_paragraph(self):
self.assertEqual(convert("# Title\n\nHello"), "<h1>Title</h1>\n<p>Hello</p>")
def test_inline_styles(self):
self.assertEqual(inline("**b** *i* `c`"), "<strong>b</strong> <em>i</em> <code>c</code>")
def test_list(self):
self.assertEqual(convert("- a\n- b"), "<ul><li>a</li><li>b</li></ul>")
def test_html_is_escaped(self):
self.assertEqual(inline("<script>alert(1)</script>"), "<script>alert(1)</script>")
def test_dangerous_link_scheme_is_removed(self):
self.assertNotIn("<a", inline("[x](javascript:alert(1))"))
self.assertIn('href="https://example.com"', inline("[x](https://example.com)"))
def test_quotes_cannot_break_out_of_href(self):
out = inline('[x](https://a.com/"onmouseover="alert(1))')
self.assertNotIn('"onmouseover', out)
if __name__ == "__main__":
unittest.main()
$ python3 -m unittest -v
Ran 6 tests in 0.003s
OK
The security tests are the most valuable ones here. Whenever you handle untrusted input, write a test that tries to attack it.
Honest limits
This is a learning project, not a full Markdown parser:
- Not full Markdown. No numbered lists, code blocks, nested lists, tables, images or blockquotes, and no support for text that spans lines inside bold.
- A visible bug in the sample output:
[bad link](javascript:alert(1))left a stray)after "bad link". Our link pattern stops at the first), so URLs containing parentheses are not handled. The result is still safe, just not pretty. Real parsers handle this with a proper parser instead of regex. - Regex parsers are fragile. Markdown has many edge cases. For real projects use a maintained library (Python-Markdown, markdown-it, marked) and sanitise the HTML it produces with a library such as
bleachor DOMPurify. Most Markdown libraries allow raw HTML by default, and escaping or sanitising is your job. - Always combine escaping with a Content-Security-Policy header as a second layer.
Challenges
- Add numbered lists (
1. item). - Add fenced code blocks (three backticks) and escape their content.
- Fix the parentheses-in-URL case.
- Add blockquotes (
> text). - Add a command-line option that writes a complete HTML page to a file.
- Compare your output with a real library on the same input.
Takeaway: parsing text is mostly careful pattern matching, but handling untrusted text safely is the real skill. Escape first, allow-list URL schemes, test your defences, and use a maintained library plus sanitising for production.
Build-along projects series, Part 2 (tools you can reuse): 4 of 5. Previous: Build a rate limiter in Node.js with the token bucket algorithm.


