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:
- One home page containing all: a brief introduction, contact information, a description of my research, and a list of publications. A navigation bar to help jump between sections.
- Keep it simple. I don't want to directly write HTML code, and as little JavaScript as possible. In fact, I use JavaScript only to insert equations in the blog pages.
- Separate pages for blog posts, to keep the main page clean.
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">↑</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:
{{file_full, "path/to/code", "language", "spoiler"} }(spoiler is optional) to include the full file;{{file_partial, "path/to/code", "language", "marker", "spoiler"} }(again, spoiler is optional) to include code between markers# START_markerand# END_marker;
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!