How I built this website with Python and Markdown

July 12, 2025

I have recently redesigned my website. It now takes a more modern look and remains simple. I thought I would share how I ended up with this result. Many aspects can be improved, as I have limited experience with building websites. Here were my requirements:

To keep things easy, I chose to use Markdown as a format for writing up the content. It 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 detail.

The main page

The main page is created with the following script:

Show code
#!/usr/bin/env python

import argparse
import markdown
import os
import re

from utils import get_ga4_tag, get_rss_link, get_footer, enhance_images, get_meta_tags, get_favicon_link, get_nav

def demote_headings(html):
    """Increase every heading level by one (h1->h2, h2->h3, ...), so section
    headings sit under the page's single <h1>. Done in one pass to avoid
    cascading. Caps at h6."""
    def repl(m):
        new_level = min(int(m.group(2)) + 1, 6)
        return f'<{m.group(1)}h{new_level}'
    return re.sub(r'<(/?)h([1-6])', repl, html)

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.")
    args = parser.parse_args()

    md_path  = args.md_path
    out_html = args.out_html

    sections = {
        "about": "about.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()
        html_content = markdown.markdown(raw_md, extensions=['md_in_html'])
        html_content = enhance_images(html_content)
        html_content = demote_headings(html_content)
        html_sections += f'''<section id="{section_id}">
  <div class="markdown-body main-body">
    {html_content}
  </div>
</section>
'''

    ga4_tag = get_ga4_tag()
    meta_tags = get_meta_tags(
        "Lucas Amoudruz",
        "Lucas Amoudruz, research associate at Harvard, working on simulation and control of microscale systems: microfluidics, blood flow, and artificial microswimmers.",
        "",
    )

    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>
{get_favicon_link()}
{meta_tags}
  <link rel="stylesheet" href="css/main.css" />
  <link rel="preconnect" href="https://fonts.googleapis.com">
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
  <link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Serif:wght@400;500;600&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
{get_rss_link()}
{ga4_tag}
</head>
<body>

{get_nav('./', active='about')}

<main>
<h1 class="visually-hidden">Lucas Amoudruz</h1>
{html_sections}
</main>

<button class="back-to-top" aria-label="Back to top">&uarr;</button>

{get_footer()}

<script>
  (function() {{
    const btn = document.querySelector('.back-to-top');
    if (!btn) return;
    const toggle = () => btn.classList.toggle('visible', window.scrollY > 400);
    window.addEventListener('scroll', toggle, {{ passive: true }});
    toggle();
    btn.addEventListener('click', () => window.scrollTo({{ top: 0, behavior: 'smooth' }}));
  }})();

  // Scroll-spy: highlight the nav link of the section currently in view.
  (function() {{
    const links = {{
      about: document.querySelector('.top-nav a[href="#about"]'),
      publications: document.querySelector('.top-nav a[href="#publications"]'),
    }};
    const sections = Object.keys(links)
      .map(id => document.getElementById(id))
      .filter(Boolean);
    if (sections.length < 2) return;
    const setActive = (id) => {{
      for (const [key, a] of Object.entries(links)) {{
        if (!a) continue;
        const on = key === id;
        a.classList.toggle('active', on);
        if (on) a.setAttribute('aria-current', 'page');
        else a.removeAttribute('aria-current');
      }}
    }};
    const observer = new IntersectionObserver((entries) => {{
      for (const e of entries) {{
        if (e.isIntersecting) setActive(e.target.id);
      }}
    }}, {{ rootMargin: '-80px 0px -60% 0px', threshold: 0 }});
    sections.forEach(s => observer.observe(s));
  }})();
</script>

</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*"([^"]*)")?\s*\)\s*\}\}'

    def replacer(match):
        src = match.group(1)
        caption = match.group(2).strip()
        float_dir = match.group(3)
        width = match.group(4)
        # Optional 5th arg overrides alt text; otherwise alt falls back to caption.
        alt = caption if match.group(5) is None else match.group(5).strip()

        if float_dir == "center":
            float_class = "image-center"
        elif float_dir in ["left", "right"]:
            float_class = f"float-img-{float_dir}"
        else:
            float_class = ""

        style = f'style="width: {width}%;"'
        caption_html = f'<figcaption class="image-caption">{caption}</figcaption>' if caption else ''

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

    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 re
import markdown

from utils import get_ga4_tag, get_rss_link, get_footer, get_meta_tags, get_favicon_link, get_nav


def parse_blog(md_text):
    """Parse blog.md into (intro_markdown, list_of_entries).

    Each entry is built from consecutive lines after a `### [title](link)` line:
    a `*date*` line, a description line, and an optional `Tags: a, b` line.
    """
    lines = md_text.split('\n')
    intro_lines = []
    entries = []
    current = None
    in_entries = False

    link_re = re.compile(r'###\s*\[([^\]]+)\]\(([^)]+)\)')

    for line in lines:
        stripped = line.strip()
        m = link_re.match(stripped)
        if m:
            in_entries = True
            if current:
                entries.append(current)
            current = {'title': m.group(1), 'link': m.group(2),
                       'date': '', 'description': '', 'tags': []}
            continue

        if not in_entries:
            intro_lines.append(line)
            continue

        if stripped in ('', '---'):
            continue

        date_m = re.match(r'\*([^*]+)\*', stripped)
        if date_m:
            current['date'] = date_m.group(1).strip()
            continue

        if stripped.lower().startswith('tags:'):
            tag_str = stripped[len('tags:'):]
            current['tags'] = [t.strip() for t in tag_str.split(',') if t.strip()]
            continue

        if not current['description']:
            current['description'] = stripped

    if current:
        entries.append(current)

    return '\n'.join(intro_lines), entries


def build_blog_html(intro_md, entries):
    intro_html = markdown.markdown(intro_md)

    all_tags = sorted({t for e in entries for t in e['tags']})

    filter_html = '  <div class="blog-filter" role="group" aria-label="Filter posts by tag">\n'
    filter_html += '    <button class="filter-btn active" data-tag="all" aria-pressed="true">All</button>\n'
    for tag in all_tags:
        filter_html += f'    <button class="filter-btn" data-tag="{tag}" aria-pressed="false">{tag}</button>\n'
    filter_html += '  </div>\n'

    entries_html = '  <div class="blog-list">\n'
    for e in entries:
        data_tags = ','.join(e['tags'])
        pills = ''.join(
            f'<button class="tag-pill" data-tag="{t}">{t}</button>' for t in e['tags']
        )
        entries_html += f'''    <article class="blog-entry" data-tags="{data_tags}">
      <h3><a href="{e['link']}">{e['title']}</a></h3>
      <p class="blog-date">{e['date']}</p>
      <p class="blog-desc">{e['description']}</p>
      <div class="blog-tags">{pills}</div>
    </article>
'''
    entries_html += '  </div>\n'

    return intro_html + '\n' + filter_html + '\n' + entries_html


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()

    intro_md, entries = parse_blog(raw_md)
    html_content = build_blog_html(intro_md, entries)

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

    ga4_tag = get_ga4_tag()
    meta_tags = get_meta_tags(
        "Blog – Lucas Amoudruz",
        "Short writeups, experiments, and stories from projects by Lucas Amoudruz.",
        "blog.html",
    )
    blog_scripts = '''  <script>
    (function() {
      const entries = document.querySelectorAll('.blog-entry');
      function applyFilter(tag) {
        entries.forEach(e => {
          const tags = (e.dataset.tags || '').split(',').map(t => t.trim());
          e.style.display = (tag === 'all' || tags.includes(tag)) ? '' : 'none';
        });
        document.querySelectorAll('.filter-btn').forEach(b => {
          const on = b.dataset.tag === tag;
          b.classList.toggle('active', on);
          b.setAttribute('aria-pressed', on ? 'true' : 'false');
        });
      }
      document.querySelectorAll('.filter-btn, .tag-pill').forEach(btn => {
        btn.addEventListener('click', () => applyFilter(btn.dataset.tag));
      });
    })();
  </script>'''

    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>
{get_favicon_link()}
{meta_tags}
  <link rel="stylesheet" href="css/main.css" />
  <link rel="preconnect" href="https://fonts.googleapis.com">
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
  <link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Serif:wght@400;500;600&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
{get_rss_link()}
{ga4_tag}
</head>
<body>

{get_nav('./', active='blog')}

{html_section}

{get_footer()}

{blog_scripts}

</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
import re

from utils import embed_image_float, embed_image_row, embed_video, embed_video_row, embed_code_from_files, get_ga4_tag, get_rss_link, get_footer, enhance_images, get_meta_tags, first_raster_image, get_favicon_link, get_nav

def get_blog_description(slug, blog_md='source/blog.md'):
    """Look up a post's hand-written description from blog.md by slug."""
    try:
        with open(blog_md) as f:
            lines = f.readlines()
    except FileNotFoundError:
        return ''
    target = f'blog/{slug}.html'
    for i, line in enumerate(lines):
        if target in line and line.strip().startswith('###'):
            for j in range(i + 1, min(i + 6, len(lines))):
                s = lines[j].strip()
                if not s or s == '---' or s.startswith('*') or s.lower().startswith('tags:'):
                    continue
                return s
    return ''

def get_ordered_posts(blog_md='source/blog.md'):
    """Return [(slug, title), ...] in the order listed in blog.md (newest first)."""
    try:
        with open(blog_md) as f:
            text = f.read()
    except FileNotFoundError:
        return []
    posts = []
    link_re = re.compile(r'###\s*\[([^\]]+)\]\(blog/([^)]+)\.html\)')
    for line in text.splitlines():
        m = link_re.match(line.strip())
        if m:
            posts.append((m.group(2), m.group(1)))
    return posts


def build_post_nav(slug):
    """Render prev/next navigation for the post `slug`, based on blog.md order."""
    posts = get_ordered_posts()
    idx = next((i for i, (s, _) in enumerate(posts) if s == slug), None)
    if idx is None:
        return ''
    newer = posts[idx - 1] if idx > 0 else None
    older = posts[idx + 1] if idx + 1 < len(posts) else None
    if not newer and not older:
        return ''

    def link(post, direction, label):
        if not post:
            return '<span class="post-nav-link post-nav-empty"></span>'
        s, t = post
        return (f'<a class="post-nav-link post-nav-{direction}" href="{s}.html">'
                f'<span class="post-nav-dir">{label}</span>'
                f'<span class="post-nav-title">{t}</span></a>')

    return f"""
<nav class="post-nav" aria-label="Post navigation">
  {link(newer, 'prev', '← Newer')}
  {link(older, 'next', 'Older →')}
</nav>
"""


def extract_title(raw_md, md_path):
    """Use the post's first `# Heading` as its title; fall back to the slug."""
    for line in raw_md.splitlines():
        stripped = line.strip()
        if stripped.startswith('# '):
            return stripped[2:].strip()
    return 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_video(processed_md)
    processed_md = embed_video_row(processed_md)
    processed_md = embed_code_from_files(processed_md)

    html_content = markdown.markdown(processed_md,
                                     extensions=['mdx_math',
                                                 'fenced_code',
                                                 'codehilite',
                                                 'tables',
                                                 'footnotes'],
                                     extension_configs={
                                         'mdx_math': {'enable_dollar_delimiter': True}
                                     })

    html_content = enhance_images(html_content)

    slug = os.path.splitext(os.path.basename(md_path))[0]
    post_nav = build_post_nav(slug)

    html_section = f"""
<main>
<article class="markdown-body blog-body">
  {html_content}
</article>
{post_nav}
</main>
"""

    bare_title = extract_title(raw_md, md_path)
    title = f"{bare_title} – Lucas Amoudruz"

    description = get_blog_description(slug)
    meta_tags = get_meta_tags(
        bare_title, description, f"blog/{slug}.html",
        image=first_raster_image(html_content), og_type="article",
    )

    ga4_tag = get_ga4_tag()

    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>
{get_favicon_link('../')}
{meta_tags}
  <link rel="stylesheet" href="../css/main.css" />
  <link rel="stylesheet" href="../css/codehilite.css" />
  <link rel="preconnect" href="https://fonts.googleapis.com">
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
  <link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Serif:wght@400;500;600&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
{get_rss_link('../')}
{ga4_tag}
  <script>
    MathJax = {{
      options: {{
        renderActions: {{
          find: [10, function (doc) {{
            for (const node of document.querySelectorAll('script[type^="math/tex"]')) {{
              const display = node.type.indexOf('display') >= 0;
              const math = new doc.options.MathItem(node.textContent, doc.inputJax[0], display);
              const text = document.createTextNode('');
              node.parentNode.insertBefore(text, node);
              math.start = {{node: text, delim: '', n: 0}};
              math.end   = {{node: text, delim: '', n: 0}};
              doc.math.push(math);
            }}
          }}, '']
        }}
      }}
    }};
  </script>
  <script src="../mathjax/tex-chtml.min.js"></script>
</head>
<body>

{get_nav('../', active='blog')}

{html_section}

{get_footer('../feed.xml')}

</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
/* --- Custom Properties --- */

:root {
  /* Accent — used sparingly: links, active states, highlights */
  --color-accent: #2b6cb0;
  --color-accent-hover: #1f5184;
  --color-accent-wash: rgba(43, 108, 176, 0.08);
  --color-accent-wash-strong: rgba(43, 108, 176, 0.14);

  /* Text (cool near-black + muted gray) */
  --color-text: #1f2328;
  --color-text-muted: #59636e;

  /* Borders & surfaces (consistent cool gray scale) */
  --color-border: #d1d9e0;
  --color-border-muted: #e4e8ec;
  --color-surface: #f6f8fa;
  --color-surface-2: #eaeef2;
}

/* --- Global --- */

html {
  scroll-behavior: smooth;
}

.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

body {
  font-family: "IBM Plex Serif", Georgia, "Times New Roman", serif;
  background-color: white;
  color: var(--color-text);
  line-height: 1.65;
  margin: 0;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-rendering: optimizeLegibility;
}

h1, h2, h3 {
  font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
  font-weight: 600;
  color: var(--color-text);
  letter-spacing: -0.02em;
}

a {
  color: var(--color-accent);
  text-decoration: none;
}

a:hover {
  color: var(--color-accent-hover);
  text-decoration: underline;
  text-underline-offset: 0.15em;
}

/* --- Navigation --- */

.top-nav {
  position: sticky;
  top: 0;
  width: 100%;
  background: white;
  border-bottom: 1px solid var(--color-border);
  padding: 1rem;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 1.5rem;
  z-index: 1000;
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
  font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}

.top-nav a {
  text-decoration: none;
  color: var(--color-text);
  font-weight: 500;
  font-size: 1rem;
  padding: 0.1rem 0.15rem;
  border-bottom: 2px solid transparent;
  transition: color 0.15s ease, border-color 0.15s ease;
}

.top-nav a:hover {
  color: var(--color-accent);
  border-bottom-color: var(--color-border);
}

.top-nav a.active {
  color: var(--color-accent);
  font-weight: 600;
  border-bottom-color: var(--color-accent);
}

/* --- Layout --- */

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

/* --- Markdown body --- */

.markdown-body img:not(.float-img-left):not(.float-img-right):not(.profile-photo):not(.showcase-img) {
  max-width: 100%;
  width: 100%;
  height: auto;
  display: block;
  margin: 1rem auto;
}

.markdown-body h1 {
  font-size: 2rem;
  font-weight: 700;
  margin-top: 2rem;
  margin-bottom: 1rem;
  border-bottom: 1px solid var(--color-border-muted);
  padding-bottom: 0.3rem;
}

.markdown-body h2 {
  font-size: 1.5rem;
  font-weight: 600;
  margin-top: 1.5rem;
  margin-bottom: 0.75rem;
  color: var(--color-text);
}

.markdown-body h3 {
  font-size: 1.25rem;
  font-weight: 600;
  margin-top: 1rem;
  margin-bottom: 0.5rem;
  color: var(--color-text-muted);
}

/* Main page: section headings are demoted (h2/h3) so the page has a single
   h1, but they keep the original visual sizing of the old h1/h2. */
.main-body h2 {
  font-size: 2rem;
  font-weight: 700;
  margin-top: 2rem;
  margin-bottom: 1rem;
  border-bottom: 1px solid var(--color-border-muted);
  padding-bottom: 0.3rem;
  color: var(--color-text);
}

.main-body h3 {
  font-size: 1.5rem;
  font-weight: 600;
  margin-top: 1.5rem;
  margin-bottom: 0.75rem;
  color: var(--color-text-muted);
}

/* --- Profile header --- */

.profile-header {
  display: flex;
  align-items: flex-start;
  gap: 2rem;
  margin: 1.5rem 0 1.5rem;
}

.profile-card {
  flex: 1;
  min-width: 0;
}

.profile-card > :first-child {
  margin-top: 0;
}

.profile-card > :last-child {
  margin-bottom: 0;
}

.profile-name {
  font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
  font-size: 1.6rem;
  font-weight: 600;
  letter-spacing: -0.02em;
  color: var(--color-text);
  margin: 0 0 0.25rem;
}

.profile-role {
  font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
  color: var(--color-text-muted);
  margin: 0 0 1rem;
  line-height: 1.4;
}

.profile-contact {
  font-size: 0.95rem;
  line-height: 1.7;
  margin: 0;
}

.profile-photo-wrap {
  flex-shrink: 0;
  width: 190px;
  max-width: 50%;
}

.profile-photo {
  width: 100%;
  height: auto;
  display: block;
  border-radius: 0;
}

@media (max-width: 700px) {
  .profile-header {
    flex-direction: column-reverse;
    align-items: center;
    gap: 1rem;
    text-align: center;
  }

  .profile-photo-wrap {
    width: 160px;
    max-width: 50%;
  }
}

/* --- Images --- */

figure {
  margin: 0;
}

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

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

.image-center {
  display: block;
  margin-left: auto;
  margin-right: auto;
  text-align: center;
}

.image-center img {
  display: block;
  margin: 0 auto;
}

.image-wrap {
  margin-bottom: 1rem;
}

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

.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;
}

/* --- Research showcase (2x2 tiled grid) --- */

.research-showcase {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 0.75rem;
  margin: 1.5rem 0 2rem;
}

.showcase-tile {
  margin: 0;
}

.showcase-tile a {
  display: block;
}

.showcase-img {
  display: block;
  width: 100%;
  aspect-ratio: 16 / 9;
  object-fit: cover;
  border-radius: 6px;
  margin: 0;
}

.showcase-tile figcaption {
  font-style: italic;
  font-size: 0.85rem;
  color: var(--color-text-muted);
  margin-top: 0.35rem;
  text-align: center;
  line-height: 1.3;
}

/* --- Video --- */

.video-wrap {
  width: 70%;
  margin: 1.5rem auto;
  text-align: center;
}

.video-wrap video {
  max-width: 100%;
  height: auto;
  display: block;
  border-radius: 8px;
}

.video-row {
  display: flex;
  gap: 2%;
  justify-content: center;
  align-items: flex-start;
  margin: 1.5rem 0;
}

.video-row .video-wrap {
  width: 49%;
}

/* --- Code --- */

details {
  margin-bottom: 1rem;
}

.code-block summary {
  cursor: pointer;
  font-weight: bold;
  margin: 0.5rem 0;
  color: var(--color-accent);
}

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

.pub-entry pre {
  background: var(--color-surface);
  border: 1px solid var(--color-border);
  padding: 0.75rem;
  border-radius: 6px;
  overflow-x: auto;
  font-size: 0.9rem;
}

.codehilite {
  background-color: var(--color-surface);
  border: 1px solid var(--color-border);
  border-radius: 6px;
  padding: 1rem;
  overflow-x: auto;
  font-size: 0.95rem;
  line-height: 1.5;
}

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

/* --- Tables --- */

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

th, td {
  border: 1px solid var(--color-border);
  padding: 0.4rem 0.75rem;
  text-align: left;
}

th {
  background-color: var(--color-surface);
  font-weight: 600;
}

/* --- Blog index --- */

.blog-filter {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem;
  margin: 1.5rem 0 2rem;
}

.filter-btn {
  font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
  font-size: 0.85rem;
  color: var(--color-text-muted);
  background: none;
  border: 1px solid var(--color-border);
  border-radius: 999px;
  padding: 0.25rem 0.85rem;
  cursor: pointer;
  transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
}

.filter-btn:hover {
  background: var(--color-surface-2);
  border-color: var(--color-text-muted);
}

.filter-btn.active {
  background: var(--color-text);
  border-color: var(--color-text);
  color: white;
}

.blog-entry {
  padding: 1.1rem 0;
  border-bottom: 1px solid var(--color-border-muted);
}

.blog-entry h3 {
  margin: 0 0 0.2rem;
}

.blog-entry h3 a {
  color: var(--color-text);
}

.blog-entry h3 a:hover {
  color: var(--color-text);
  text-decoration: underline;
  text-decoration-color: var(--color-border);
  text-underline-offset: 0.2em;
}

.blog-date {
  font-size: 0.85rem;
  font-style: italic;
  color: var(--color-text-muted);
  margin: 0 0 0.4rem;
}

.blog-desc {
  margin: 0 0 0.7rem;
  text-align: left;
}

.blog-tags {
  display: flex;
  flex-wrap: wrap;
  gap: 0.4rem;
}

.tag-pill {
  font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
  font-size: 0.75rem;
  color: var(--color-text-muted);
  background: var(--color-surface-2);
  border: none;
  border-radius: 999px;
  padding: 0.15rem 0.65rem;
  cursor: pointer;
  transition: background 0.15s ease;
}

.tag-pill:hover {
  background: var(--color-border);
}

/* --- Post navigation (prev/next) --- */

.post-nav {
  max-width: 800px;
  margin: 2.5rem auto 0;
  padding: 1.5rem 0 0;
  border-top: 1px solid var(--color-border-muted);
  display: flex;
  justify-content: space-between;
  gap: 1.5rem;
}

.post-nav-link {
  display: flex;
  flex-direction: column;
  max-width: 48%;
  font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}

.post-nav-prev {
  text-align: left;
}

.post-nav-next {
  text-align: right;
  margin-left: auto;
}

.post-nav-empty {
  visibility: hidden;
}

.post-nav-dir {
  font-size: 0.8rem;
  color: var(--color-text-muted);
  margin-bottom: 0.2rem;
}

.post-nav-title {
  font-weight: 600;
  color: var(--color-accent);
}

.post-nav-link:hover .post-nav-title {
  text-decoration: underline;
  text-underline-offset: 0.15em;
}

/* --- Footer --- */

.site-footer {
  text-align: center;
  font-size: 0.9rem;
  color: var(--color-text-muted);
  margin-top: 3rem;
  padding: 1rem 0;
  border-top: 1px solid var(--color-border-muted);
}

.site-footer a {
  color: var(--color-accent);
  text-decoration: none;
}

.site-footer a:hover {
  text-decoration: underline;
}

/* --- Back to top --- */

.back-to-top {
  position: fixed;
  bottom: 1.5rem;
  right: 1.5rem;
  width: 2.5rem;
  height: 2.5rem;
  display: flex;
  align-items: center;
  justify-content: center;
  background: var(--color-surface);
  color: var(--color-text-muted);
  border: 1px solid var(--color-border);
  border-radius: 999px;
  font-size: 1.1rem;
  line-height: 1;
  cursor: pointer;
  opacity: 0;
  visibility: hidden;
  transform: translateY(0.5rem);
  transition: opacity 0.2s ease, transform 0.2s ease, visibility 0.2s ease;
  z-index: 1000;
  box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
}

.back-to-top.visible {
  opacity: 1;
  visibility: visible;
  transform: translateY(0);
}

.back-to-top:hover {
  color: var(--color-accent);
  border-color: var(--color-accent);
}

/* --- Misc --- */

hr {
  border: none;
  height: 1px;
  background: var(--color-border);
}

.cv-line {
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  gap: 1rem;
}

.cv-line span:first-child {
  font-weight: 500;
}

.cv-line span:last-child {
  white-space: nowrap;
  color: var(--color-text-muted);
}

.cv-sub {
  font-size: 0.9rem;
  color: var(--color-text-muted);
  margin: 0.1rem 0 0.75rem;
}

/* --- Responsive --- */

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

  .top-nav {
    padding: 0.6rem 0.5rem;
    gap: 0.5rem;
    flex-wrap: wrap;
  }

  .top-nav a {
    font-size: 0.95rem;
    padding: 0.3rem 0.4rem;
  }

  .image-row {
    flex-direction: column;
  }

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

  .video-row {
    flex-direction: column;
  }

  .video-row .video-wrap {
    width: 100%;
  }

  .post-nav {
    padding-left: 1rem;
    padding-right: 1rem;
  }
}

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. I'll probably extend the tools to handle bibtex and other things that I can't think of as of now. Probably for a future blog post!