Add blog template, build script and CI workflow
All checks were successful
Build blog / build (push) Successful in 0s

This commit is contained in:
2026-10-03 14:50:40 +02:00
commit a1fb581d21
5 changed files with 226 additions and 0 deletions

View File

@@ -0,0 +1,27 @@
# Rebuilds pascal-maas.nl on every push to main.
# Runs on the self-hosted runner next to Gitea; /srv/site is what nginx serves.
name: Build blog
on:
push:
branches: [main]
workflow_dispatch:
jobs:
build:
runs-on: linux
defaults:
run:
shell: sh
steps:
- name: Fetch source
run: |
rm -rf src
git clone --depth 1 --branch "${{ gitea.ref_name }}" \
"https://oauth2:${{ gitea.token }}@git.pascal-maas.nl/${{ gitea.repository }}.git" src
- name: Build
run: python3 src/build.py src out
- name: Publish
run: rsync -a --delete out/ /srv/site/

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
public/
out/
__pycache__/

110
build.py Normal file
View File

@@ -0,0 +1,110 @@
#!/usr/bin/env python3
"""Build the blog: every topics/**/*.md becomes a section in one index.html.
Each post may start with a small header:
---
title: My first post
date: 2026-10-03
---
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.
Usage: python3 build.py [source_dir] [output_dir]
"""
import html
import re
import shutil
import sys
from datetime import date
from pathlib import Path
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"
def slugify(text):
return re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-") or "post"
def parse(path):
text = path.read_text(encoding="utf-8")
meta = {}
m = re.match(r"^---\s*\n(.*?)\n---\s*\n", text, re.S)
if m:
for line in m.group(1).splitlines():
key, sep, value = line.partition(":")
if sep:
meta[key.strip().lower()] = value.strip().strip("\"'")
text = text[m.end():]
title = meta.get("title")
if not title:
h1 = re.match(r"^\s*#\s+(.+)\n", text)
if h1:
title = h1.group(1).strip()
text = text[h1.end():] # don't render the title twice
else:
title = re.sub(r"^\d{4}-\d{2}-\d{2}-", "", path.stem).replace("-", " ").capitalize()
raw_date = meta.get("date") or (re.match(r"^\d{4}-\d{2}-\d{2}", path.stem) or [None])[0]
try:
when = date.fromisoformat(raw_date) if raw_date else None
except ValueError:
sys.exit(f"{path.relative_to(SRC)}: date '{raw_date}' is not YYYY-MM-DD")
rel = path.relative_to(TOPICS)
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"]),
}
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)
seen = {}
for p in posts: # keep anchors unique
n = seen.get(p["slug"], 0)
seen[p["slug"]] = n + 1
if n:
p["slug"] += f"-{n + 1}"
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>'
)
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))
if OUT.exists():
shutil.rmtree(OUT)
OUT.mkdir(parents=True)
(OUT / "index.html").write_text(page, encoding="utf-8")
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}")
if __name__ == "__main__":
main()

77
template.html Normal file
View File

@@ -0,0 +1,77 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Pascal Maas · Blog</title>
<style>
:root {
--bg: #fbfaf7; --fg: #1d1d1b; --muted: #6b6a66; --line: #e4e1da;
--accent: #2f5d8a; --code-bg: #f1eee7;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #161616; --fg: #e8e6e1; --muted: #9a978f; --line: #2c2b29;
--accent: #8ab4dc; --code-bg: #222120;
}
}
* { box-sizing: border-box; }
body {
margin: 0; background: var(--bg); color: var(--fg);
font: 17px/1.65 Georgia, "Iowan Old Style", "Times New Roman", serif;
}
.wrap { max-width: 44rem; margin: 0 auto; padding: 3rem 1rem 4rem; }
a { color: var(--accent); }
h1, h2, h3, .meta, nav, footer { font-family: system-ui, -apple-system, "Segoe UI", sans-serif; }
.site-header h1 { margin: 0; font-size: 1.6rem; letter-spacing: -0.01em; }
.site-header p { margin: .25rem 0 0; color: var(--muted); }
nav { margin: 2.5rem 0 1rem; padding: 1rem 0; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); }
nav h2 { margin: 0 0 .5rem; font-size: .8rem; text-transform: uppercase; letter-spacing: .08em; color: var(--muted); }
nav ol { margin: 0; padding: 0; list-style: none; }
nav li { display: flex; justify-content: space-between; gap: 1rem; padding: .2rem 0; }
nav li time { color: var(--muted); font-size: .85rem; white-space: nowrap; }
nav a { text-decoration: none; }
nav a:hover { text-decoration: underline; }
article { padding: 2.5rem 0; border-bottom: 1px solid var(--line); scroll-margin-top: 1rem; }
article header h2 { margin: 0; font-size: 1.45rem; line-height: 1.25; }
article header h2 a { color: inherit; text-decoration: none; }
article header h2 a:hover { color: var(--accent); }
.meta { margin: .35rem 0 1.25rem; font-size: .85rem; color: var(--muted); display: flex; gap: .75rem; align-items: center; }
.tag { border: 1px solid var(--line); border-radius: 999px; padding: 0 .55rem; }
img { max-width: 100%; height: auto; }
pre, code { font-family: ui-monospace, "Cascadia Code", Menlo, Consolas, monospace; font-size: .88em; }
code { background: var(--code-bg); padding: .1em .3em; border-radius: 4px; }
pre { background: var(--code-bg); padding: 1rem; border-radius: 6px; overflow-x: auto; }
pre code { background: none; padding: 0; }
blockquote { margin: 1.25rem 0; padding-left: 1rem; border-left: 3px solid var(--line); color: var(--muted); }
table { border-collapse: collapse; width: 100%; display: block; overflow-x: auto; }
th, td { border: 1px solid var(--line); padding: .4rem .6rem; text-align: left; }
footer { margin-top: 2.5rem; font-size: .85rem; color: var(--muted); }
</style>
</head>
<body>
<div class="wrap">
<header class="site-header">
<h1>Pascal Maas</h1>
<p>Notes and writing.</p>
</header>
<nav aria-label="Posts">
<h2>Posts</h2>
<ol>
{{toc}}
</ol>
</nav>
<main>
{{posts}}
</main>
<footer>© {{year}} Pascal Maas</footer>
</div>
</body>
</html>

View File

@@ -0,0 +1,9 @@
---
title: Hello, world
date: 2026-10-03
---
This is the first post. Every Markdown file in `topics/` becomes a section
on this page, newest first.
To publish a new post, add a file like `topics/2026-10-10-my-post.md` and push.