From a1fb581d219678e5cb7b37752aa0e1c39f4d0bc7 Mon Sep 17 00:00:00 2001 From: Pascal Maas Date: Sat, 3 Oct 2026 14:50:40 +0200 Subject: [PATCH] Add blog template, build script and CI workflow --- .gitea/workflows/build.yml | 27 ++++++++ .gitignore | 3 + build.py | 110 +++++++++++++++++++++++++++++++ template.html | 77 ++++++++++++++++++++++ topics/2026-10-03-hello-world.md | 9 +++ 5 files changed, 226 insertions(+) create mode 100644 .gitea/workflows/build.yml create mode 100644 .gitignore create mode 100644 build.py create mode 100644 template.html create mode 100644 topics/2026-10-03-hello-world.md diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml new file mode 100644 index 0000000..7d2afbc --- /dev/null +++ b/.gitea/workflows/build.yml @@ -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/ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3fee42b --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +public/ +out/ +__pycache__/ diff --git a/build.py b/build.py new file mode 100644 index 0000000..b6d2207 --- /dev/null +++ b/build.py @@ -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'' if p["date"] else "" + tag = f'{html.escape(p["topic"])}' if p["topic"] else "" + toc.append(f'
  • {title}{when}
  • ') + sections.append( + f'
    \n' + f'

    {title}

    {when}{tag}

    \n' + f'{p["body"]}\n
    ' + ) + + page = (SRC / "template.html").read_text(encoding="utf-8") + page = page.replace("{{toc}}", "\n".join(toc) or "
  • No posts yet.
  • ") + 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() diff --git a/template.html b/template.html new file mode 100644 index 0000000..650ad7d --- /dev/null +++ b/template.html @@ -0,0 +1,77 @@ + + + + + +Pascal Maas · Blog + + + +
    + + + + +
    +{{posts}} +
    + + +
    + + diff --git a/topics/2026-10-03-hello-world.md b/topics/2026-10-03-hello-world.md new file mode 100644 index 0000000..0c12a2b --- /dev/null +++ b/topics/2026-10-03-hello-world.md @@ -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.