Add blog template, build script and CI workflow
All checks were successful
Build blog / build (push) Successful in 0s
All checks were successful
Build blog / build (push) Successful in 0s
This commit is contained in:
27
.gitea/workflows/build.yml
Normal file
27
.gitea/workflows/build.yml
Normal 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
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
public/
|
||||||
|
out/
|
||||||
|
__pycache__/
|
||||||
110
build.py
Normal file
110
build.py
Normal 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
77
template.html
Normal 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>
|
||||||
9
topics/2026-10-03-hello-world.md
Normal file
9
topics/2026-10-03-hello-world.md
Normal 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.
|
||||||
Reference in New Issue
Block a user