This commit is contained in:
193
build.py
193
build.py
@@ -1,16 +1,20 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build the blog: every topics/**/*.md becomes a section in one index.html.
|
||||
"""Build the blog: every topics/**/*.md becomes its own page at <slug>/,
|
||||
index.html lists them all with a short teaser, and feed.xml is an RSS feed
|
||||
with every post in full.
|
||||
|
||||
Each post may start with a small header:
|
||||
|
||||
---
|
||||
title: My first post
|
||||
date: 2026-10-03
|
||||
description: One or two sentences for the teaser and search results.
|
||||
---
|
||||
|
||||
Without it, the title comes from the first "# Heading" (or the file name)
|
||||
and the date from a YYYY-MM-DD file-name prefix, if any. A post in a
|
||||
subfolder (topics/python/foo.md) is tagged with that folder's name.
|
||||
Without it, the title comes from the first "# Heading" (or the file name),
|
||||
the date from a YYYY-MM-DD file-name prefix, if any, and the description
|
||||
from the first paragraph. A post in a subfolder (topics/python/foo.md) is
|
||||
tagged with that folder's name.
|
||||
|
||||
Usage: python3 build.py [source_dir] [output_dir]
|
||||
"""
|
||||
@@ -18,7 +22,8 @@ import html
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
from datetime import date
|
||||
from datetime import date, datetime, time, timezone
|
||||
from email.utils import format_datetime
|
||||
from pathlib import Path
|
||||
|
||||
import markdown
|
||||
@@ -26,6 +31,9 @@ import markdown
|
||||
SRC = Path(sys.argv[1] if len(sys.argv) > 1 else ".").resolve()
|
||||
OUT = Path(sys.argv[2] if len(sys.argv) > 2 else "public").resolve()
|
||||
TOPICS = SRC / "topics"
|
||||
SITE_URL = "https://pascal-maas.nl"
|
||||
SITE_NAME = "Pascal Maas"
|
||||
SITE_DESCRIPTION = "A blog about bioinformatics in metabolomics"
|
||||
|
||||
|
||||
def slugify(text):
|
||||
@@ -59,54 +67,173 @@ def parse(path):
|
||||
sys.exit(f"{path.relative_to(SRC)}: date '{raw_date}' is not YYYY-MM-DD")
|
||||
|
||||
rel = path.relative_to(TOPICS)
|
||||
body = markdown.markdown(
|
||||
text,
|
||||
extensions=[
|
||||
"fenced_code", "tables", "footnotes", "codehilite", "abbr",
|
||||
],
|
||||
extension_configs={"codehilite": {"guess_lang": False}},
|
||||
)
|
||||
return {
|
||||
"title": title,
|
||||
"date": when,
|
||||
"topic": rel.parts[0] if len(rel.parts) > 1 else meta.get("topic"),
|
||||
"slug": slugify(meta.get("slug") or re.sub(r"^\d{4}-\d{2}-\d{2}-", "", path.stem)),
|
||||
"body": markdown.markdown(
|
||||
text,
|
||||
extensions=[
|
||||
"fenced_code", "tables", "footnotes", "codehilite", "abbr",
|
||||
],
|
||||
extension_configs={"codehilite": {"guess_lang": False}},
|
||||
),
|
||||
"summary": meta.get("description") or first_paragraph(body),
|
||||
"body": body,
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
posts = [parse(p) for p in sorted(TOPICS.rglob("*.md"))]
|
||||
# Newest first; undated posts go last.
|
||||
posts.sort(key=lambda p: (p["date"] is not None, p["date"] or date.min), reverse=True)
|
||||
def first_paragraph(body):
|
||||
"""Plain text of the first paragraph, without footnote markers."""
|
||||
match = re.search(r"<p>(.*?)</p>", body, re.S)
|
||||
if not match:
|
||||
return ""
|
||||
text = re.sub(r"<sup.*?</sup>", "", match.group(1), flags=re.S)
|
||||
text = html.unescape(re.sub(r"<[^>]+>", "", text))
|
||||
return " ".join(text.split())
|
||||
|
||||
|
||||
def load_posts():
|
||||
"""All posts, newest first (undated last), with unique slugs."""
|
||||
posts = [parse(p) for p in sorted(TOPICS.rglob("*.md"))]
|
||||
posts.sort(
|
||||
key=lambda p: (p["date"] is not None, p["date"] or date.min),
|
||||
reverse=True,
|
||||
)
|
||||
seen = {}
|
||||
for p in posts: # keep anchors unique
|
||||
for p in posts: # keep page folders unique
|
||||
n = seen.get(p["slug"], 0)
|
||||
seen[p["slug"]] = n + 1
|
||||
if n:
|
||||
p["slug"] += f"-{n + 1}"
|
||||
return posts
|
||||
|
||||
toc, sections = [], []
|
||||
for p in posts:
|
||||
title = html.escape(p["title"])
|
||||
when = f'<time datetime="{p["date"].isoformat()}">{p["date"]:%d %B %Y}</time>' if p["date"] else ""
|
||||
tag = f'<span class="tag">{html.escape(p["topic"])}</span>' if p["topic"] else ""
|
||||
toc.append(f'<li><a href="#{p["slug"]}">{title}</a>{when}</li>')
|
||||
sections.append(
|
||||
f'<article id="{p["slug"]}">\n'
|
||||
f'<header><h2><a href="#{p["slug"]}">{title}</a></h2><p class="meta">{when}{tag}</p></header>\n'
|
||||
f'{p["body"]}\n</article>'
|
||||
|
||||
def time_tag(post):
|
||||
if not post["date"]:
|
||||
return ""
|
||||
return (
|
||||
f'<time datetime="{post["date"].isoformat()}">'
|
||||
f'{post["date"]:%d %B %Y}</time>'
|
||||
)
|
||||
|
||||
|
||||
def meta_line(post):
|
||||
tag = ""
|
||||
if post["topic"]:
|
||||
tag = f'<span class="tag">{html.escape(post["topic"])}</span>'
|
||||
return f'<p class="meta">{time_tag(post)}{tag}</p>'
|
||||
|
||||
|
||||
def render_toc(posts, current, root):
|
||||
"""The side list; the post being shown (if any) is marked current."""
|
||||
items = []
|
||||
for post in posts:
|
||||
css = ' class="current"' if post is current else ""
|
||||
items.append(
|
||||
f'<li><a href="{root}{post["slug"]}/"{css}>'
|
||||
f'{html.escape(post["title"])}</a>{time_tag(post)}</li>'
|
||||
)
|
||||
return "\n".join(items) or "<li>No posts yet.</li>"
|
||||
|
||||
page = (SRC / "template.html").read_text(encoding="utf-8")
|
||||
page = page.replace("{{toc}}", "\n".join(toc) or "<li>No posts yet.</li>")
|
||||
page = page.replace("{{posts}}", "\n\n".join(sections))
|
||||
page = page.replace("{{year}}", str(date.today().year))
|
||||
|
||||
def index_content(posts):
|
||||
teasers = []
|
||||
for post in posts:
|
||||
link = f'./{post["slug"]}/'
|
||||
teasers.append(
|
||||
f'<article>\n<header><h2><a href="{link}">'
|
||||
f'{html.escape(post["title"])}</a></h2>{meta_line(post)}'
|
||||
f'</header>\n<p>{html.escape(post["summary"])}</p>\n'
|
||||
f'<p><a href="{link}">Read more</a></p>\n</article>'
|
||||
)
|
||||
return "\n\n".join(teasers)
|
||||
|
||||
|
||||
def rebase_static(body, prefix):
|
||||
"""Put prefix in front of the static/ paths in a post body."""
|
||||
return re.sub(r'(src|href)="static/', rf'\1="{prefix}static/', body)
|
||||
|
||||
|
||||
def post_content(post):
|
||||
# Post pages sit one folder deeper, so static/ paths need ../ in front.
|
||||
body = rebase_static(post["body"], "../")
|
||||
return (
|
||||
f'<article>\n<header><h2>{html.escape(post["title"])}</h2>'
|
||||
f'{meta_line(post)}</header>\n{body}\n</article>'
|
||||
)
|
||||
|
||||
|
||||
def write_page(folder, template, posts, current, title, description, content):
|
||||
"""Fill the template for one page and write it as folder/index.html."""
|
||||
root = "./" if current is None else "../"
|
||||
fields = {
|
||||
"title": html.escape(title),
|
||||
"description": html.escape(description),
|
||||
"root": root,
|
||||
"toc": render_toc(posts, current, root),
|
||||
"content": content,
|
||||
"year": str(date.today().year),
|
||||
}
|
||||
for key, value in fields.items():
|
||||
template = template.replace("{{" + key + "}}", value)
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
(folder / "index.html").write_text(template, encoding="utf-8")
|
||||
|
||||
|
||||
def feed_item(post):
|
||||
link = f'{SITE_URL}/{post["slug"]}/'
|
||||
# Feed readers show posts away from the site, so paths must be full.
|
||||
body = rebase_static(post["body"], f"{SITE_URL}/")
|
||||
lines = [
|
||||
"<item>",
|
||||
f'<title>{html.escape(post["title"])}</title>',
|
||||
f"<link>{link}</link>",
|
||||
f'<guid isPermaLink="true">{link}</guid>',
|
||||
]
|
||||
if post["date"]:
|
||||
moment = datetime.combine(post["date"], time(), timezone.utc)
|
||||
lines.append(f"<pubDate>{format_datetime(moment)}</pubDate>")
|
||||
lines.append(f"<description>{html.escape(body)}</description>")
|
||||
lines.append("</item>")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def write_feed(posts):
|
||||
"""feed.xml: an RSS feed with every post in full, newest first."""
|
||||
items = "\n".join(feed_item(post) for post in posts)
|
||||
feed = (
|
||||
'<?xml version="1.0" encoding="utf-8"?>\n'
|
||||
'<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">\n'
|
||||
"<channel>\n"
|
||||
f"<title>{html.escape(SITE_NAME)}</title>\n"
|
||||
f"<link>{SITE_URL}/</link>\n"
|
||||
f"<description>{html.escape(SITE_DESCRIPTION)}</description>\n"
|
||||
"<language>en</language>\n"
|
||||
f'<atom:link href="{SITE_URL}/feed.xml" rel="self" '
|
||||
'type="application/rss+xml"/>\n'
|
||||
f"{items}\n</channel>\n</rss>\n"
|
||||
)
|
||||
(OUT / "feed.xml").write_text(feed, encoding="utf-8")
|
||||
|
||||
|
||||
def main():
|
||||
posts = load_posts()
|
||||
template = (SRC / "template.html").read_text(encoding="utf-8")
|
||||
if OUT.exists():
|
||||
shutil.rmtree(OUT)
|
||||
OUT.mkdir(parents=True)
|
||||
(OUT / "index.html").write_text(page, encoding="utf-8")
|
||||
write_page(
|
||||
OUT, template, posts, None, f"{SITE_NAME} · Blog", SITE_DESCRIPTION,
|
||||
index_content(posts),
|
||||
)
|
||||
for post in posts:
|
||||
write_page(
|
||||
OUT / post["slug"], template, posts, post,
|
||||
f'{post["title"]} · {SITE_NAME}', post["summary"],
|
||||
post_content(post),
|
||||
)
|
||||
write_feed(posts)
|
||||
if (SRC / "static").is_dir(): # images etc.: topics can link to static/foo.png
|
||||
shutil.copytree(SRC / "static", OUT / "static")
|
||||
print(f"Built {len(posts)} post(s) into {OUT}")
|
||||
|
||||
@@ -3,7 +3,12 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Pascal Maas · Blog</title>
|
||||
<title>{{title}}</title>
|
||||
<meta name="description" content="{{description}}">
|
||||
<meta property="og:title" content="{{title}}">
|
||||
<meta property="og:description" content="{{description}}">
|
||||
<link rel="alternate" type="application/rss+xml" title="Pascal Maas"
|
||||
href="{{root}}feed.xml">
|
||||
<script>
|
||||
// Runs before the page is drawn, so a saved dark choice doesn't flash.
|
||||
try {
|
||||
@@ -16,19 +21,19 @@
|
||||
/* Served from static/; Georgia shows until these have loaded. */
|
||||
@font-face {
|
||||
font-family: "Poltawski Nowy"; font-weight: 400; font-style: normal;
|
||||
src: url("static/PoltawskiNowy-Regular.ttf"); font-display: swap;
|
||||
src: url("{{root}}static/PoltawskiNowy-Regular.ttf"); font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Poltawski Nowy"; font-weight: 400; font-style: italic;
|
||||
src: url("static/PoltawskiNowy-Italic.ttf"); font-display: swap;
|
||||
src: url("{{root}}static/PoltawskiNowy-Italic.ttf"); font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Poltawski Nowy"; font-weight: 700; font-style: normal;
|
||||
src: url("static/PoltawskiNowy-Bold.ttf"); font-display: swap;
|
||||
src: url("{{root}}static/PoltawskiNowy-Bold.ttf"); font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Poltawski Nowy"; font-weight: 700; font-style: italic;
|
||||
src: url("static/PoltawskiNowy-BoldItalic.ttf"); font-display: swap;
|
||||
src: url("{{root}}static/PoltawskiNowy-BoldItalic.ttf"); font-display: swap;
|
||||
}
|
||||
:root {
|
||||
--latte: #f4f2ee; --espresso: #231c15;
|
||||
@@ -59,6 +64,7 @@
|
||||
.site-header h1 {
|
||||
margin: 0; font-size: 1.6rem; line-height: 1.2; letter-spacing: -0.01em;
|
||||
}
|
||||
.site-header h1 a { color: inherit; text-decoration: none; }
|
||||
.site-header p { display: none; margin: 0; color: var(--muted); }
|
||||
.site-header {
|
||||
display: flex; justify-content: space-between;
|
||||
@@ -167,7 +173,7 @@
|
||||
<div class="wrap">
|
||||
<header class="site-header">
|
||||
<div class="site-title">
|
||||
<h1>Pascal Maas</h1>
|
||||
<h1><a href="{{root}}">Pascal Maas</a></h1>
|
||||
<p>A blog about bioinformatics in metabolomics</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
@@ -188,11 +194,11 @@
|
||||
</nav>
|
||||
|
||||
<main>
|
||||
{{posts}}
|
||||
{{content}}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<footer>© {{year}} Pascal Maas</footer>
|
||||
<footer>© {{year}} Pascal Maas · <a href="{{root}}feed.xml">RSS</a></footer>
|
||||
</div>
|
||||
<script>
|
||||
const root = document.documentElement;
|
||||
@@ -219,9 +225,7 @@
|
||||
} catch (error) {}
|
||||
});
|
||||
|
||||
const header = document.querySelector(".site-header");
|
||||
const articles = Array.from(document.querySelectorAll("main article"));
|
||||
const links = document.querySelectorAll("nav a");
|
||||
const articles = document.querySelectorAll("main article");
|
||||
|
||||
// First use of each term per post keeps its definition, moved out of
|
||||
// "title" so the browser's plain tooltip doesn't show beside the bubble.
|
||||
@@ -238,30 +242,6 @@
|
||||
term.tabIndex = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// The current post is the last one whose top has passed a line 3rem
|
||||
// (48px) below the header; at the very bottom, it is always the last post.
|
||||
function markCurrentPost() {
|
||||
if (articles.length === 0) {
|
||||
return;
|
||||
}
|
||||
const line = header.getBoundingClientRect().bottom + 48;
|
||||
const viewBottom = window.innerHeight + window.scrollY;
|
||||
const atBottom = viewBottom >= root.scrollHeight - 2;
|
||||
let current = articles[0];
|
||||
for (const article of articles) {
|
||||
if (atBottom || article.getBoundingClientRect().top <= line) {
|
||||
current = article;
|
||||
}
|
||||
}
|
||||
for (const link of links) {
|
||||
link.classList.toggle("current", link.hash === "#" + current.id);
|
||||
}
|
||||
}
|
||||
|
||||
markCurrentPost();
|
||||
window.addEventListener("scroll", markCurrentPost, { passive: true });
|
||||
window.addEventListener("resize", markCurrentPost);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
title: Starting a blog
|
||||
description: Hi there! This is my first post in hopefully many. I'm Pascal and work as a bioinformatician in the field of metabolomics. I studied bioinformatics, getting my master degree in 2020 at the University of Amsterdam and since then I work at the Metabolomics Analytical Centre, which is part of the University of Leiden. Here I develop software to support the data analysis of large volumes of samples. This however, remains a challenge for reasons that hopefully become clear through posts on this blog. Ofcourse, I will also attempt to provide solutions for practical challenges I've encounted.
|
||||
date: 2026-10-04
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user