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));
}})();
// Copy bibtex to clipboard and show a short-lived toast.
(function() {{
const entries = document.querySelectorAll('.pub-entry .bibtex-copy');
if (!entries.length) return;
const toast = document.createElement('div');
toast.className = 'copy-toast';
toast.setAttribute('role', 'status');
toast.textContent = 'Copied to clipboard';
document.body.appendChild(toast);
let timer = null;
const showToast = (msg) => {{
toast.textContent = msg;
toast.classList.add('visible');
clearTimeout(timer);
timer = setTimeout(() => toast.classList.remove('visible'), 2000);
}};
entries.forEach(btn => {{
let restore = null;
btn.addEventListener('click', () => {{
const src = btn.closest('.pub-entry').querySelector('.bibtex-src');
if (!src) return;
const text = (src.content || src).textContent.trim() + '\\n';
navigator.clipboard.writeText(text).then(
() => {{
showToast('Copied to clipboard');
const label = btn.querySelector('.bibtex-label');
btn.classList.add('copied');
if (label) label.textContent = 'copied!';
clearTimeout(restore);
restore = setTimeout(() => {{
btn.classList.remove('copied');
if (label) label.textContent = 'bibtex';
}}, 1600);
}},
() => showToast('Could not copy'),
);
}});
}});
}})();
</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">{render_caption_math(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 html
import markdown
import os
import re
from utils import embed_image_float, embed_image_row, embed_video, embed_video_row, embed_code_from_files, embed_plotly, get_plotly_script_tag, embed_mesh_anim, embed_mesh, embed_mesh_row, get_mesh_anim_script_tag, embed_genealogy, embed_genealogy_map, get_genealogy_script_tag, 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>
"""
# A post only gets a table of contents once it has this many h2/h3 headings;
# short posts render exactly as before.
MIN_TOC_ENTRIES = 4
def build_toc(toc_tokens):
"""Render the sidebar table of contents from the markdown `toc` extension's
tokens (h2/h3 only, see toc_depth below). Returns '' for short posts."""
entries = []
def flatten(tokens):
for tok in tokens:
entries.append((tok['level'], tok['id'], tok['name']))
flatten(tok.get('children', []))
flatten(toc_tokens)
if len(entries) < MIN_TOC_ENTRIES:
return ''
links = '\n'.join(
f' <a class="toc-h{level}" href="#{anchor}">{html.escape(name)}</a>'
for level, anchor, name in entries)
return f'<nav class="toc" aria-label="Table of contents">\n{links}\n</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.")
parser.add_argument('--site-path', type=str, default=None,
help="Site-root-relative path of the page (default: blog/<slug>.html)")
parser.add_argument('--nav-active', type=str, default='blog',
help="Nav item to highlight ('' for none)")
parser.add_argument('--no-nav', action='store_true',
help="Omit the site nav bar (for standalone pages)")
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)
processed_md = embed_mesh_anim(processed_md)
processed_md = embed_mesh_row(processed_md)
processed_md = embed_mesh(processed_md)
processed_md = embed_genealogy_map(processed_md)
processed_md = embed_genealogy(processed_md)
processed_md = embed_plotly(processed_md)
md = markdown.Markdown(extensions=['mdx_math',
'fenced_code',
'codehilite',
'tables',
'footnotes',
'toc'],
extension_configs={
'mdx_math': {'enable_dollar_delimiter': True},
'toc': {'toc_depth': '2-3'}
})
html_content = md.convert(processed_md)
html_content = enhance_images(html_content)
slug = os.path.splitext(os.path.basename(md_path))[0]
post_nav = build_post_nav(slug)
toc = build_toc(md.toc_tokens)
html_section = f"""
<main>
{toc}
<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)
site_path = args.site_path or f"blog/{slug}.html"
meta_tags = get_meta_tags(
bare_title, description, site_path,
image=first_raster_image(html_content), og_type="article",
)
ga4_tag = get_ga4_tag()
# Load plotly.js only on pages that actually embed an interactive figure.
plotly_tag = get_plotly_script_tag() if 'plotly-graph-div' in html_content else ''
if 'class="mesh-anim' in html_content:
plotly_tag += ('\n' if plotly_tag else '') + get_mesh_anim_script_tag()
if 'class="genealogy' in html_content:
plotly_tag += ('\n' if plotly_tag else '') + get_genealogy_script_tag()
# Scroll-spy for the table of contents, only where there is one.
if toc:
plotly_tag += ('\n' if plotly_tag else '') + ' <script src="../js/toc.js" defer></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>{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">
{'' if args.no_nav else get_rss_link('../')}
{ga4_tag}
{plotly_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>
{'' if args.no_nav else get_nav('../', active=args.nav_active)}
{html_section}
{get_footer('' if args.no_nav else '../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;
/* Type: serif for prose, sans for headings and UI chrome, mono for code */
--font-serif: "IBM Plex Serif", Georgia, "Times New Roman", serif;
--font-ui: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--font-mono: "JetBrains Mono", ui-monospace, Consolas, Menlo, Monaco, monospace;
}
/* --- 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: var(--font-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: var(--font-ui);
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 --- */
/* No explicit width: as a block element it fills the viewport already, and
width:100% + padding would overflow it (there is no border-box reset). */
.top-nav {
position: sticky;
top: 0;
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: var(--font-ui);
}
.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;
}
/* Anchor jumps land below the sticky top nav. */
.markdown-body h2[id],
.markdown-body h3[id] {
scroll-margin-top: 90px;
}
/* --- 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;
}
/* Main page: section headings are demoted (h2/h3) so the page has a single
h1, but they keep the visual sizing of a blog post's h1/h2. */
.markdown-body h1,
.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;
}
.markdown-body h2,
.main-body h3 {
font-size: 1.5rem;
font-weight: 600;
margin-top: 1.5rem;
margin-bottom: 0.75rem;
}
.main-body h3 {
color: var(--color-text-muted);
}
.markdown-body h3 {
font-size: 1.25rem;
font-weight: 600;
margin-top: 1rem;
margin-bottom: 0.5rem;
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: var(--font-ui);
font-size: 1.6rem;
font-weight: 600;
letter-spacing: -0.02em;
color: var(--color-text);
margin: 0 0 0.25rem;
}
.profile-role {
font-family: var(--font-ui);
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;
}
/* Shared caption style; the research showcase tightens it below. */
.image-caption,
.showcase-tile figcaption {
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%;
height: auto;
aspect-ratio: 16 / 9;
object-fit: cover;
border-radius: 6px;
margin: 0;
}
.showcase-tile figcaption {
font-size: 0.85rem;
margin-top: 0.35rem;
}
/* --- Video --- */
.video-wrap {
width: 70%;
margin: 1.5rem auto;
text-align: center;
}
/* width, not max-width: a clip whose intrinsic size is smaller than the figure
would otherwise ignore the width% the directive was given and render tiny. */
.video-wrap video {
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);
}
.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: var(--font-mono);
background: none;
border: none;
}
/* --- Pills (publication links, blog tags) --- */
.pub-pill,
.tag-pill {
font-family: var(--font-ui);
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, color 0.15s ease;
}
.pub-pill:hover,
.tag-pill:hover {
background: var(--color-border);
}
/* --- Publications --- */
.pub-entry {
margin-bottom: 1.25rem;
}
.pub-citation {
margin: 0 0 0.4rem;
line-height: 1.45;
}
.pub-venue {
color: var(--color-text-muted);
}
.pub-links {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
align-items: center;
}
.pub-pill {
display: inline-flex;
align-items: center;
gap: 0.3rem;
line-height: 1.4;
text-decoration: none;
}
.pub-pill:hover {
color: var(--color-text);
}
.pub-pill .copy-icon {
flex-shrink: 0;
}
.pub-pill.copied {
background: var(--color-accent-wash-strong);
color: var(--color-accent);
}
.copy-toast {
position: fixed;
bottom: 1.5rem;
right: 1.5rem;
background: var(--color-text);
color: var(--color-bg, #fff);
padding: 0.5rem 1rem;
border-radius: 6px;
font-size: 0.9rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
opacity: 0;
transform: translateY(0.5rem);
transition: opacity 0.2s ease, transform 0.2s ease;
pointer-events: none;
z-index: 100;
}
.copy-toast.visible {
opacity: 1;
transform: translateY(0);
}
/* --- 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: var(--font-ui);
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;
}
/* --- 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: var(--font-ui);
}
.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;
}
/* --- Table of contents (long posts) --- */
/* Plain text in the left margin of the article column, right-aligned toward
the text; no box or rules. Only shown where that margin exists, so phones
and narrow windows are untouched. */
.toc {
display: none;
}
@media (min-width: 1280px) {
.toc {
display: block;
position: fixed;
top: 7rem;
right: calc(50% + 430px);
width: 190px;
max-height: calc(100vh - 9rem);
overflow-y: auto;
text-align: right;
font-family: var(--font-ui);
font-size: 0.85rem;
line-height: 1.45;
}
.toc a {
display: block;
padding: 0.15rem 0;
color: var(--color-text-muted);
}
.toc a:hover {
color: var(--color-text);
text-decoration: none;
}
.toc a.active {
font-weight: 600;
color: var(--color-text);
}
.toc-h3 {
font-size: 0.78rem;
}
}
/* --- 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);
}
/* --- 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!