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 text on the left converted into HTML on the right

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 &lt;script&gt;alert(&quot;hi&quot;)&lt;/script&gt; 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 &lt;script&gt;, 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 with http://, 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.escape also converts " into &quot;, a URL cannot close the href attribute and add something like onmouseover=.... 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>"), "&lt;script&gt;alert(1)&lt;/script&gt;")

    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 bleach or 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

  1. Add numbered lists (1. item).
  2. Add fenced code blocks (three backticks) and escape their content.
  3. Fix the parentheses-in-URL case.
  4. Add blockquotes (> text).
  5. Add a command-line option that writes a complete HTML page to a file.
  6. 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.

Keep reading