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 details.
The main page is created with the following script:
#!/usr/bin/env python
import argparse
import markdown
import os
from utils import embed_image_float
def main():
parser = argparse.ArgumentParser()
parser.add_argument('md_path', type=str, help="Directory containing md files.")
parser.add_argument('--out-html', type=str, help="Path to output html file.")
parser.add_argument('--blog', action='store_true', default=False, help="Enable link to blog.")
args = parser.parse_args()
md_path = args.md_path
out_html = args.out_html
blog = args.blog
sections = {
"about": "about.md",
"research": "research.md",
"publications": "publications.md"
}
html_sections = ""
for section_id, filename in sections.items():
with open(os.path.join(md_path, filename)) as f:
raw_md = f.read()
processed_md = embed_image_float(raw_md)
html_content = markdown.markdown(processed_md)
html_sections += f'''<section id="{section_id}">
<div class="markdown-body">
{html_content}
</div>
</section>
'''
nav_bar = ""
for key in sections.keys():
name = key.title()
nav_bar += f' <a href="#{key}">{name}</a>\n'
if blog:
nav_bar += ' <a href="./blog.html">Blog</a>\n'
with open(out_html, "w") as f:
f.write(f"""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Lucas Amoudruz</title>
<link rel="stylesheet" href="css/main.css" />
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Serif&family=JetBrains+Mono&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans&display=swap" rel="stylesheet">
</head>
<body>
<nav class="top-nav">
{nav_bar}
</nav>
{html_sections}
</body>
</html>
""")
if __name__ == '__main__':
main()
It converts Markdown files, parses custom directives, and combines everything into a single HTML file.
The pre-processing stage helps with placing floating images more easily, and it uses the function embed_custom_images.
This function handles parsing custom image blocks and turns them into proper HTML:
def embed_image_float(md_text):
pattern = r'\{\{\s*image\("([^"]+)",\s*"([^"]*)",\s*"([^"]+)",\s*(\d+)\)\s*\}\}'
def replacer(match):
src = match.group(1)
caption = match.group(2).strip()
float_dir = match.group(3)
width = match.group(4)
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'<p class="image-caption">{caption}</p>' if caption else ''
return f'''
<div class="image-wrap {float_class}" {style}>
<img src="{src}" alt="{caption}">
{caption_html}
</div>
'''
return re.sub(pattern, replacer, md_text)
It replaces patterns of the form {{image, "path/to/image.png", "caption", "left/right/center", width-in-percent}} with the appropriate HTML content.
After being processed, each md file is placed in section, and a link is added to a navigation bar.
The main blog page is similar to the main page, except that there is a single section containing links to individual blog posts:
#!/usr/bin/env python
import argparse
import markdown
import os
def main():
parser = argparse.ArgumentParser()
parser.add_argument('md_path', type=str, help="path to blog.md")
parser.add_argument('--out-html', type=str, help="Path to output html file.")
args = parser.parse_args()
md_path = args.md_path
out_html = args.out_html
with open(md_path) as f:
raw_md = f.read()
processed_md = raw_md
html_content = markdown.markdown(processed_md)
html_section = f"""
<section class="markdown-body">
{html_content}
</section>
"""
with open(out_html, "w") as f:
f.write(f"""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Blog – Lucas Amoudruz</title>
<link rel="stylesheet" href="css/main.css" />
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Serif&family=JetBrains+Mono&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans&display=swap" rel="stylesheet">
</head>
<body>
<nav class="top-nav">
<a href="./index.html">Home</a>
<a href="./blog.html">Blog</a>
</nav>
{html_section}
</body>
</html>
""")
if __name__ == '__main__':
main()
Individual blog pages follow the same pattern: a single md file is preprocessed and then embedded into a common html file format.
#!/usr/bin/env python
import argparse
import markdown
import os
from utils import embed_image_float, embed_image_row, embed_code_from_files
def md_path_to_title(md_path):
return "blog - " + md_path.split("/")[-1].split(".md")[0]
def main():
parser = argparse.ArgumentParser()
parser.add_argument('md_path', type=str, help="path to md file")
parser.add_argument('--out-html', type=str, help="Path to output html file.")
args = parser.parse_args()
md_path = args.md_path
out_html = args.out_html
with open(md_path) as f:
raw_md = f.read()
processed_md = embed_image_float(raw_md)
processed_md = embed_image_row(processed_md)
processed_md = embed_code_from_files(processed_md)
html_content = markdown.markdown(processed_md,
extensions=['mdx_math',
'fenced_code',
'codehilite',
'tables'])
html_section = f"""
<section class="markdown-body">
{html_content}
</section>
"""
title = md_path_to_title(md_path)
with open(out_html, "w") as f:
f.write(f"""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{title}</title>
<link rel="stylesheet" href="../css/main.css" />
<link rel="stylesheet" href="../css/codehilite.css" />
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Serif&family=JetBrains+Mono&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans&display=swap" rel="stylesheet">
<script type="text/javascript" async
src="https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.7/MathJax.js?config=TeX-MML-AM_CHTML">
</script>
</head>
<body>
<nav class="top-nav">
<a href="../index.html">Home</a>
<a href="../blog.html">Blog</a>
</nav>
{html_section}
</body>
</html>
""")
if __name__ == '__main__':
main()
Note that there is an additional stylesheet for code, generated as follows:
pygmentize -S trac -f html -a .codehilite > css/codehilite.css
This, together with the extensions provided to the Markdown converter, allows code highlighting in the final rendering.
Similarly, the blog pages include a mathjax snippet to allow equations rendering.
Code and equations are not needed in the main page.
The pre-processing stage also contains an additional step, to include code from files:
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_marker and # END_marker;Here is my full CSS configuration.
body {
font-family: "IBM Plex Serif", serif;
background-color: white;
color: #222;
line-height: 1.6;
margin: 0;
scroll-behavior: smooth;
}
h1, h2, h3 {
font-family: "IBM Plex Sans", sans-serif;
font-weight: 600;
color: #3e6075;
}
a {
color: #3e6075;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
html {
scroll-behavior: smooth;
}
.top-nav {
position: sticky;
top: 0;
width: 100%;
height: auto;
background: white;
border-bottom: 1px solid #ddd;
padding: 1rem;
display: flex;
align-items: center;
justify-content: center;
gap: 1.5rem;
z-index: 1000;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.5);
font-family: "IBM Plex Sans", sans-serif;
}
.top-nav a {
text-decoration: none;
color: #3e6075;
font-weight: 500;
font-size: 1rem;
padding: 0.3rem 0.6rem;
border-radius: 4px;
transition: background 0.2s ease;
}
.top-nav a:hover {
background-color: rgba(62, 96, 117, 0.1); /* subtle hover */
}
section {
scroll-margin-top: 80px;
max-width: 800px;
margin-left: auto;
margin-right: auto;
padding-bottom: 2rem;
}
.markdown-body img:not(.float-img-left):not(.float-img-right) {
max-width: 100%;
width: 100%;
height: auto;
display: block;
margin: 1rem auto; /* optional: center the image */
}
@media (max-width: 700px) {
.markdown-body {
padding: 0 1rem;
}
}
.markdown-body p {
text-align: justify
}
.markdown-body h1 {
font-size: 2rem;
font-weight: 700;
margin-top: 2rem;
margin-bottom: 1rem;
border-bottom: 2px solid #eee;
padding-bottom: 0.3rem;
}
.markdown-body h2 {
font-size: 1.5rem;
font-weight: 600;
margin-top: 1.5rem;
margin-bottom: 0.75rem;
color: #5a738b;
}
.markdown-body h3 {
font-size: 1.25rem;
font-weight: 500;
margin-top: 1rem;
margin-bottom: 0.5rem;
color: #5a738b;
}
.float-img-right {
float: right;
width: 40%;
margin: 0 0 1em 1em;
}
.float-img-left {
float: left;
width: 40%;
margin: 0 1em 1em 0;
}
.image-row {
display: flex;
gap: 2%;
justify-content: center;
flex-wrap: wrap;
margin: 1.5rem 0;
}
.image-row .image-wrap {
margin: 0;
}
.image-row img {
width: 100%;
height: auto;
}
@media (max-width: 700px) {
.image-row {
flex-direction: column;
}
.image-row .image-wrap {
width: 100% !important;
}
}
.image-caption {
font-style: italic;
font-size: 0.9rem;
color: #555;
margin-top: 0.25rem;
text-align: center;
line-height: 1.3;
}
.image-wrap {
margin-bottom: 1rem;
}
.float-img-left {
float: left;
margin: 0 1em 1em 0;
}
.float-img-right {
float: right;
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;
}
details {
margin-bottom: 1rem;
}
.code-block summary {
cursor: pointer;
font-weight: bold;
margin: 0.5rem 0;
color: #3e6075;
}
.pub-entry summary {
cursor: pointer;
font-weight: normal;
color: inherit;
margin: 0;
padding: 0;
}
.pub-entry pre {
background: #f9f9f9;
border: 1px solid #ddd;
padding: 0.75rem;
border-radius: 4px;
overflow-x: auto;
font-size: 0.9rem;
}
.codehilite {
background-color: #f9f9f9;
border: 1px solid #ddd;
border-radius: 4px;
padding: 1rem;
overflow-x: auto;
font-size: 0.95rem;
line-height: 1.5;
}
.codehilite pre {
margin: 0;
font-family: "JetBrains Mono", Consolas, Menlo, Monaco, monospace;
background: none;
border: none;
}
table {
border-collapse: collapse;
width: 100%;
margin: 1rem 0;
}
th, td {
border: 1px solid #ccc;
padding: 0.4rem 0.75rem;
text-align: left;
}
th {
background-color: #f5f5f5;
font-weight: 600;
}
hr {
border: none;
height: 1px;
background: #ccc; /* light gray line */
opacity: 0.4; /* softer appearance */
}
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!