How I built this website with Python and Markdown

July 12, 2025

I have recently redesigned my website to take a more modern look, and thought I would share how I ended up with this result. I'm sure many aspects could be improved, as I have limited experience building websites. My requirements were the following:

To keep things easy to write and maintain, I chose to use Markdown, which is simple and powerful enough for my needs. Furthermore it is easy to convert Markdown to HTML with the python package markdown. Due to my very limited knowledge in CSS styling, I have heavily used chatGPT and I must say I was happy with the result. Below, I describe the structure of the code in more details.

The main page

The main page is created with the following script:

Show code
#!/usr/bin/env python

import argparse
import markdown
import os

from utils import embed_image_float

def main():
    parser = argparse.ArgumentParser()
    parser.add_argument('md_path', type=str, help="Directory containing md files.")
    parser.add_argument('--out-html', type=str, help="Path to output html file.")
    parser.add_argument('--blog', action='store_true', default=False, help="Enable link to blog.")
    args = parser.parse_args()

    md_path  = args.md_path
    out_html = args.out_html
    blog     = args.blog

    sections = {
        "about": "about.md",
        "research": "research.md",
        "publications": "publications.md"
    }

    html_sections = ""
    for section_id, filename in sections.items():
        with open(os.path.join(md_path, filename)) as f:
            raw_md = f.read()
        processed_md = embed_image_float(raw_md)
        html_content = markdown.markdown(processed_md)
        html_sections += f'''<section id="{section_id}">
  <div class="markdown-body">
    {html_content}
  </div>
</section>
'''

    nav_bar = ""
    for key in sections.keys():
        name = key.title()
        nav_bar += f'  <a href="#{key}">{name}</a>\n'

    if blog:
        nav_bar += '  <a href="./blog.html">Blog</a>\n'


    with open(out_html, "w") as f:
        f.write(f"""
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Lucas Amoudruz</title>
  <link rel="stylesheet" href="css/main.css" />
</head>
<body>

<nav class="top-nav">
{nav_bar}
</nav>

{html_sections}

</body>
</html>
""")


if __name__ == '__main__':
    main()

It converts Markdown files, parses custom directives, and combines everything into a single HTML file. The pre-processing stage helps with placing floating images more easily, and it uses the function embed_custom_images. This function handles parsing custom image blocks and turns them into proper HTML:

Show code
def embed_image_float(md_text):
    pattern = r'\{\{\s*image\("([^"]+)",\s*"([^"]*)",\s*"([^"]+)",\s*(\d+)\)\s*\}\}'

    def replacer(match):
        src = match.group(1)
        caption = match.group(2).strip()
        float_dir = match.group(3)
        width = match.group(4)
        float_class = f"float-img-{float_dir}" if float_dir in ["left", "center", "right"] else ""
        style = f'style="width: {width}%;"'

        caption_html = f'<p class="image-caption">{caption}</p>' if caption else ''

        return f'''
<div class="image-wrap {float_class}" {style}>
  <img src="{src}" alt="{caption}">
  {caption_html}
</div>
        '''

    return re.sub(pattern, replacer, md_text)

It replaces patterns of the form {{image, "path/to/image.png", "caption", "left/right/center", width-in-percent}} with the appropriate HTML content.

After being processed, each md file is placed in section, and a link is added to a navigation bar.

Blog pages

The main blog page is similar to the main page, except that there is a single section containing links to individual blog posts:

Show code
#!/usr/bin/env python

import argparse
import markdown
import os

def main():
    parser = argparse.ArgumentParser()
    parser.add_argument('md_path', type=str, help="path to blog.md")
    parser.add_argument('--out-html', type=str, help="Path to output html file.")
    args = parser.parse_args()

    md_path  = args.md_path
    out_html = args.out_html

    with open(md_path) as f:
        raw_md = f.read()
    processed_md = raw_md

    html_content = markdown.markdown(processed_md)

    html_section = f"""
<section class="markdown-body">
  {html_content}
</section>
"""


    with open(out_html, "w") as f:
        f.write(f"""
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Blog – Lucas Amoudruz</title>
  <link rel="stylesheet" href="css/main.css" />
</head>
<body>

<nav class="top-nav">
  <a href="./index.html">Home</a>
  <a href="./blog.html">Blog</a>
</nav>

{html_section}

</body>
</html>
""")


if __name__ == '__main__':
    main()

Individual blog pages follow the same pattern: a single md file is preprocessed and then embedded into a common html file format.

Show code
#!/usr/bin/env python

import argparse
import markdown
import os

from utils import embed_image_float, embed_image_row, embed_code_from_files

def md_path_to_title(md_path):
    return "blog - " + md_path.split("/")[-1].split(".md")[0]

def main():
    parser = argparse.ArgumentParser()
    parser.add_argument('md_path', type=str, help="path to md file")
    parser.add_argument('--out-html', type=str, help="Path to output html file.")
    args = parser.parse_args()

    md_path  = args.md_path
    out_html = args.out_html

    with open(md_path) as f:
        raw_md = f.read()
    processed_md = embed_image_float(raw_md)
    processed_md = embed_image_row(processed_md)
    processed_md = embed_code_from_files(processed_md)

    html_content = markdown.markdown(processed_md,
                                     extensions=['mdx_math',
                                                 'fenced_code',
                                                 'codehilite',
                                                 'tables'])


    html_section = f"""
<section class="markdown-body">
  {html_content}
</section>
"""

    title = md_path_to_title(md_path)

    with open(out_html, "w") as f:
        f.write(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>
  <link rel="stylesheet" href="../css/main.css" />
  <link rel="stylesheet" href="../css/codehilite.css" />
  <script type="text/javascript" async
      src="https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.7/MathJax.js?config=TeX-MML-AM_CHTML">
  </script>
</head>
<body>

<nav class="top-nav">
  <a href="../index.html">Home</a>
  <a href="../blog.html">Blog</a>
</nav>

{html_section}

</body>
</html>
""")


if __name__ == '__main__':
    main()

Note that there is an additional stylesheet for code, generated as follows:

pygmentize -S trac -f html -a .codehilite > css/codehilite.css

This, together with the extensions provided to the Markdown converter, allows code highlighting in the final rendering. Similarly, the blog pages include a mathjax snippet to allow equations rendering. Code and equations are not needed in the main page.

The pre-processing stage also contains an additional step, to include code from files:

Show code
def embed_code_from_files(md_text):
    full_pattern = r'\{\{\s*file_full,\s*"([^"]+)",\s*"([^"]+)"(?:,\s*"([^"]+)")?\s*\}\}'
    partial_pattern = r'\{\{\s*file_partial,\s*"([^"]+)",\s*"([^"]+)",\s*"([^"]+)"(?:,\s*"([^"]+)")?\s*\}\}'

    def full_replacer(match):
        path, lang = match.group(1), match.group(2)
        spoiler = match.group(3) == "spoiler"
        try:
            with open(path, "r") as f:
                code = f.read().strip()
            code_block = f"\n```{lang}\n{code}\n```\n"
            if spoiler:
                return f'<details class="code-block">\n<summary>Show code</summary>\n\n{code_block}</details>\n'
            else:
                return code_block
        except Exception as e:
            return f"**Error including file: {path} ({e})**"

    def partial_replacer(match):
        path, lang, marker = match.group(1), match.group(2), match.group(3)
        spoiler = match.group(4) == "spoiler"
        start_tag = f"# START_{marker}"
        end_tag = f"# END_{marker}"
        try:
            with open(path, "r") as f:
                lines = f.readlines()
            inside = False
            extracted = []
            for line in lines:
                if start_tag in line:
                    inside = True
                    continue
                if end_tag in line:
                    inside = False
                    continue
                if inside:
                    extracted.append(line)
            code = ''.join(extracted).strip()
            code_block = f"\n```{lang}\n{code}\n```\n"
            if spoiler:
                return f"""<details class="code-block">\n<summary>Show code</summary>\n\n{code_block}</details>\n"""
            else:
                return code_block
        except Exception as e:
            return f"**Error including partial code: {path} ({e})**"

    md_text = re.sub(full_pattern, full_replacer, md_text)
    md_text = re.sub(partial_pattern, partial_replacer, md_text)
    return md_text

This function uses a format similar to the one used by the image pattern. Here I have two options:

CSS

Here is my full CSS configuration.

Show code
body {
    font-family: "Georgia", "Times New Roman", serif;
    background-color: white;
    color: #222;
    line-height: 1.6;
    margin: 0;
    scroll-behavior: smooth;
}

h1, h2, h3 {
  font-family: "Helvetica Neue", "Segoe UI", sans-serif;
  font-weight: 600;
  color: #3e6075;
}

a {
  color: #3e6075;
  text-decoration: none;
}

a:hover {
  text-decoration: underline;
}

html {
  scroll-behavior: smooth;
}

.top-nav {
  position: sticky;
  top: 0;
  width: 100%;
  height: auto;
  background: white;
  border-bottom: 1px solid #ddd;
  padding: 1rem;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 1.5rem;
  z-index: 1000;
  box-shadow: 0 2px 5px rgba(0, 0, 0, 0.5);
  font-family: "Helvetica Neue", "Segoe UI", sans-serif;
}

.top-nav a {
  text-decoration: none;
  color: #3e6075;
  font-weight: 500;
  font-size: 1rem;
  padding: 0.3rem 0.6rem;
  border-radius: 4px;
  transition: background 0.2s ease;
}

.top-nav a:hover {
  background-color: rgba(62, 96, 117, 0.1); /* subtle hover */
}

section {
  scroll-margin-top: 80px;
  max-width: 800px;
  margin-left: auto;
  margin-right: auto;
  padding-bottom: 2rem;
}


.markdown-body img:not(.float-img-left):not(.float-img-right) {
  max-width: 100%;
  width: 100%;
  height: auto;
  display: block;
  margin: 1rem auto; /* optional: center the image */
}

@media (max-width: 700px) {
  .markdown-body {
    padding: 0 1rem;
  }
}

.markdown-body p {
    text-align: justify
}

.markdown-body h1 {
  font-size: 2rem;
  font-weight: 700;
  margin-top: 2rem;
  margin-bottom: 1rem;
  border-bottom: 2px solid #eee;
  padding-bottom: 0.3rem;
}

.markdown-body h2 {
  font-size: 1.5rem;
  font-weight: 600;
  margin-top: 1.5rem;
  margin-bottom: 0.75rem;
  color: #5a738b;
}

.markdown-body h3 {
  font-size: 1.25rem;
  font-weight: 500;
  margin-top: 1rem;
  margin-bottom: 0.5rem;
  color: #5a738b;
}


.float-img-right {
  float: right;
  width: 40%;
  margin: 0 0 1em 1em;
}
.float-img-left {
  float: left;
  width: 40%;
  margin: 0 1em 1em 0;
}


.image-row {
  display: flex;
  gap: 2%;
  justify-content: center;
  flex-wrap: wrap;
  margin: 1.5rem 0;
}

.image-row .image-wrap {
  margin: 0;
}

.image-row img {
  width: 100%;
  height: auto;
}

@media (max-width: 700px) {
  .image-row {
    flex-direction: column;
  }

  .image-row .image-wrap {
    width: 100% !important;
  }
}

.image-caption {
  font-style: italic;
  font-size: 0.9rem;
  color: #555;
  margin-top: 0.25rem;
  text-align: center;
  line-height: 1.3;
}

details {
  margin-bottom: 1rem;
}

.code-block summary {
  cursor: pointer;
  font-weight: bold;
  margin: 0.5rem 0;
  color: #3e6075;
}

.pub-entry summary {
  cursor: pointer;
  font-weight: normal;
  color: inherit;
  margin: 0;
  padding: 0;
}

.pub-entry pre {
  background: #f9f9f9;
  border: 1px solid #ddd;
  padding: 0.75rem;
  border-radius: 4px;
  overflow-x: auto;
  font-size: 0.9rem;
}

.codehilite {
  background-color: #f9f9f9;
  border: 1px solid #ddd;
  border-radius: 4px;
  padding: 1rem;
  overflow-x: auto;
  font-size: 0.95rem;
  line-height: 1.5;
}

.codehilite pre {
  margin: 0;
  font-family: Consolas, Menlo, Monaco, "Courier New", monospace;
  background: none;
  border: none;
}

table {
  border-collapse: collapse;
  width: 100%;
  margin: 1rem 0;
}

th, td {
  border: 1px solid #ccc;
  padding: 0.4rem 0.75rem;
  text-align: left;
}

th {
  background-color: #f5f5f5;
  font-weight: 600;
}

Conclusion

I could quite easily get everything working with this approach. Markdown is a great format to write content without needing to write any HTML, and the python code is useful to process these Markdown files automatically and convert them into HTML. Here is the github repository of the full source code, hopefully this is useful to somebody.