Milán Major

devsite-builder

1 branch
Code

Initial commit

authorMilán Major <milan.major.work@gmail.com>
date2026-08-07 09:26
commit8a5657d05074113acfb2a17fe38cceb7ecf6f6c1
parent(root commit)
changes22 files, +3124 -0

Diffstat

.gitignore                          |   14 +
 blog.py                             |  663 +++++++++++++++++++++++
 blog/posts/2026-08-05-Netscape.md   |  242 +++++++++
 blog/posts/2026-08-06-first-post.md |   11 +
 gitweb.py                           | 1004 +++++++++++++++++++++++++++++++++++
 repos/.gitkeep                      |    0
 site.py                             |   83 +++
 siteutils.py                        |  274 ++++++++++
 static/style.css                    |  332 ++++++++++++
 templates/about.html                |   27 +
 templates/archive.html              |   32 ++
 templates/git-commit.html           |   52 ++
 templates/git-file.html             |   36 ++
 templates/git-files.html            |   43 ++
 templates/git-index.html            |   33 ++
 templates/git-log.html              |   43 ++
 templates/git-repo.html             |   46 ++
 templates/home.html                 |   32 ++
 templates/index.html                |   36 ++
 templates/post.html                 |   51 ++
 templates/tag.html                  |   36 ++
 templates/tags.html                 |   34 ++
 22 files changed, 3124 insertions(+)

Diff

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..34b5928
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,14 @@
+# Generated site output - rebuilt from source on demand.
+/www/
+
+# Python bytecode cache.
+__pycache__/
+*.pyc
+
+# The repos/ directory holds locally cloned/mirrored bare git
+# repositories that back the git browser. These are large,
+# machine-specific, and not meant to be committed - but we do
+# want the directory itself tracked so a fresh checkout has
+# somewhere to put them.
+/repos/*
+!/repos/.gitkeep
diff --git a/blog.py b/blog.py
new file mode 100755
index 0000000..e8db9f7
--- /dev/null
+++ b/blog.py
@@ -0,0 +1,663 @@
+#!/usr/bin/env python3
+
+import os
+import html
+import shutil
+import argparse
+from dataclasses import dataclass
+from datetime import datetime
+from collections import defaultdict
+
+from siteutils import load_template, render, markdown
+
+POST_DIR = "blog/posts"
+OUTPUT_DIR = "www/blog"
+STATIC_DIR = "static"
+
+
+# ------------------------------------------------------------
+# Post
+# ------------------------------------------------------------
+
+@dataclass
+class Post:
+    title: str
+    date: datetime
+    tags: list
+    slug: str
+    content: str
+    source: str
+
+    @property
+    def url(self):
+        return f"/blog/{self.slug}.html"
+
+    @property
+    def date_iso(self):
+        return self.date.strftime("%Y-%m-%d")
+
+    @property
+    def date_human(self):
+        return self.date.strftime("%d %B %Y")
+
+# ------------------------------------------------------------
+# Post loading
+# ------------------------------------------------------------
+
+def parse_frontmatter(text):
+    if not text.startswith("---"):
+        raise ValueError("missing front matter")
+
+    end = text.find("\n---", 3)
+
+    if end == -1:
+        raise ValueError("unterminated front matter")
+
+    raw = text[3:end].strip()
+
+    # The closing delimiter may have extra trailing dashes
+    # (e.g. "----------"), so skip past the whole line.
+    line_end = text.find("\n", end + 1)
+
+    if line_end == -1:
+        body = ""
+    else:
+        body = text[line_end + 1:].strip()
+
+    metadata = {}
+
+    for line in raw.splitlines():
+
+        if ":" not in line:
+            continue
+
+        key, value = line.split(":", 1)
+
+        key = key.strip()
+        value = value.strip()
+
+        if key == "tags":
+            value = value.strip("[]")
+
+            value = [
+                x.strip()
+                for x in value.split(",")
+                if x.strip()
+            ]
+
+        metadata[key] = value
+
+    return metadata, body
+
+
+def load_post(path):
+    with open(path, "r", encoding="utf-8") as f:
+        text = f.read()
+
+    metadata, body = parse_frontmatter(text)
+
+    filename = os.path.basename(path)
+    slug = os.path.splitext(filename)[0]
+
+    post_date = datetime.strptime(
+        metadata["date"],
+        "%Y-%m-%d"
+    )
+
+    return Post(
+        title=metadata["title"],
+        date=post_date,
+        tags=metadata.get("tags", []),
+        slug=slug,
+        content=markdown(body),
+        source=path
+    )
+
+
+# ------------------------------------------------------------
+# Blog
+# ------------------------------------------------------------
+
+class Blog:
+
+    def __init__(self):
+        self.posts = []
+
+    def load(self):
+
+        if not os.path.isdir(POST_DIR):
+            raise RuntimeError(
+                f"post directory does not exist: {POST_DIR}"
+            )
+
+        self.posts = []
+
+        for filename in os.listdir(POST_DIR):
+
+            if not filename.endswith(".md"):
+                continue
+
+            path = os.path.join(
+                POST_DIR,
+                filename
+            )
+
+            try:
+                post = load_post(path)
+                self.posts.append(post)
+
+            except Exception as e:
+                print(
+                    f"error: {path}: {e}"
+                )
+                raise
+
+        # Newest first.
+        self.posts.sort(
+            key=lambda post: post.date,
+            reverse=True
+        )
+
+    def previous(self, post):
+        index = self.posts.index(post)
+
+        if index + 1 >= len(self.posts):
+            return None
+
+        return self.posts[index + 1]
+
+    def next(self, post):
+        index = self.posts.index(post)
+
+        if index == 0:
+            return None
+
+        return self.posts[index - 1]
+
+    def tags(self):
+        """
+        Return a dict mapping tag -> list of posts (newest
+        first), sorted alphabetically by tag.
+        """
+
+        tags = defaultdict(list)
+
+        for post in self.posts:
+            for tag in post.tags:
+                tags[tag].append(post)
+
+        return dict(sorted(tags.items()))
+
+
+# ------------------------------------------------------------
+# Rendering
+# ------------------------------------------------------------
+
+def render_tags(post):
+
+    result = []
+
+    for tag in post.tags:
+
+        escaped = html.escape(tag)
+
+        result.append(
+            f'<a href="/blog/tags/{escaped}/">'
+            f'{escaped}</a>'
+        )
+
+    return " · ".join(result)
+
+
+def render_post(blog, post):
+
+    template = load_template(
+        "post.html"
+    )
+
+    previous = blog.previous(post)
+    next_post = blog.next(post)
+
+    previous_html = ""
+    next_html = ""
+
+    if previous:
+        previous_html = (
+            f'<a href="{previous.url}">'
+            f'← {html.escape(previous.title)}</a>'
+        )
+
+    if next_post:
+        next_html = (
+            f'<a href="{next_post.url}">'
+            f'{html.escape(next_post.title)} →</a>'
+        )
+
+    values = {
+        "title": html.escape(post.title),
+        "date": post.date.strftime("%Y-%m-%d"),
+        "content": post.content,
+        "tags": render_tags(post),
+        "previous": previous_html,
+        "next": next_html,
+    }
+
+    return render(template, values)
+
+
+def render_excerpt(post, limit=700):
+    """
+    Generate a short HTML preview of a post.
+
+    The preview consists of complete paragraphs. If the
+    complete post does not fit within the limit, a Read more
+    link is appended.
+    """
+
+    paragraphs = post.content.split("</p>")
+
+    output = []
+    length = 0
+    truncated = False
+
+    for paragraph in paragraphs:
+
+        paragraph = paragraph.strip()
+
+        if not paragraph:
+            continue
+
+        paragraph += "</p>"
+
+        if length + len(paragraph) > limit:
+            truncated = True
+            break
+
+        output.append(paragraph)
+        length += len(paragraph)
+
+    if not output:
+        return (
+            f'<p class="read-more">'
+            f'<a href="{post.url}">Read more →</a>'
+            f'</p>'
+        )
+
+    result = "\n".join(output)
+
+    if truncated:
+        result += (
+            f'\n<p class="read-more">'
+            f'<a href="{post.url}">Read more →</a>'
+            f'</p>'
+        )
+
+    return result
+
+
+def render_post_list(posts):
+
+    entries = []
+
+    for post in posts:
+
+        tags = render_tags(post)
+
+        entries.append(
+            f"""
+<article>
+
+<h2>
+<a href="{post.url}">
+{html.escape(post.title)}
+</a>
+</h2>
+
+<p class="metadata">
+{post.date:%Y-%m-%d}
+</p>
+
+{render_excerpt(post)}
+
+<p class="tags">
+Tags: {tags}
+</p>
+
+</article>
+"""
+        )
+
+    return "\n".join(entries)
+
+
+def render_index(blog):
+
+    template = load_template(
+        "index.html"
+    )
+
+    values = {
+        "posts": render_post_list(blog.posts)
+    }
+
+    return render(template, values)
+
+
+def render_tags_index(blog):
+
+    template = load_template(
+        "tags.html"
+    )
+
+    items = []
+
+    for tag, posts in blog.tags().items():
+
+        escaped = html.escape(tag)
+
+        items.append(
+            f'<li><a href="/blog/tags/{escaped}/">'
+            f'{escaped}</a> ({len(posts)})</li>'
+        )
+
+    values = {
+        "tags": "\n".join(items)
+    }
+
+    return render(template, values)
+
+
+def render_tag_page(tag, posts):
+
+    template = load_template(
+        "tag.html"
+    )
+
+    values = {
+        "tag": html.escape(tag),
+        "posts": render_post_list(posts)
+    }
+
+    return render(template, values)
+
+
+
+def render_archive(blog):
+
+    years = defaultdict(
+        lambda: defaultdict(list)
+    )
+
+    for post in blog.posts:
+        year = post.date.year
+        month = post.date.month
+
+        years[year][month].append(post)
+
+    output = []
+
+    for year in sorted(years, reverse=True):
+
+        output.append(
+            f'<section class="archive-year">'
+        )
+
+        output.append(
+            f'<h2>{year}</h2>'
+        )
+
+        for month in sorted(
+            years[year],
+            reverse=True
+        ):
+
+            month_name = datetime(
+                year,
+                month,
+                1
+            ).strftime("%B")
+
+            output.append(
+                f'<section class="archive-month">'
+            )
+
+            output.append(
+                f'<h3>{month_name}</h3>'
+            )
+
+            output.append(
+                '<ul class="archive-list">'
+            )
+
+            posts = sorted(
+                years[year][month],
+                key=lambda post: post.date,
+                reverse=True
+            )
+
+            for post in posts:
+
+                output.append(
+                    '<li>'
+                    f'<time datetime="{post.date_iso}">'
+                    f'{post.date.day:02d}'
+                    '</time> '
+                    f'<a href="{post.url}">'
+                    f'{html.escape(post.title)}'
+                    '</a>'
+                    '</li>'
+                )
+
+            output.append('</ul>')
+            output.append('</section>')
+
+        output.append('</section>')
+
+    return "\n".join(output)
+
+# ------------------------------------------------------------
+# Build
+# ------------------------------------------------------------
+
+def clean():
+
+    if os.path.isdir(OUTPUT_DIR):
+        shutil.rmtree(OUTPUT_DIR)
+
+    os.makedirs(
+        OUTPUT_DIR,
+        exist_ok=True
+    )
+
+
+def build(blog):
+
+    print("Building blog...")
+
+    clean()
+
+    # Posts
+    for post in blog.posts:
+
+        output = os.path.join(
+            OUTPUT_DIR,
+            post.slug + ".html"
+        )
+
+        with open(output, "w", encoding="utf-8") as f:
+            f.write(
+                render_post(blog, post)
+            )
+
+        print(
+            f"  post: {post.slug}"
+        )
+
+    # Index
+    index_path = os.path.join(
+        OUTPUT_DIR,
+        "index.html"
+    )
+
+    with open(index_path, "w", encoding="utf-8") as f:
+        f.write(render_index(blog))
+
+    print("  index")
+
+    # Static files
+    if os.path.isdir(STATIC_DIR):
+
+        for filename in os.listdir(STATIC_DIR):
+
+            source = os.path.join(
+                STATIC_DIR,
+                filename
+            )
+
+            destination = os.path.join(
+                "www",
+                filename
+            )
+
+            shutil.copy2(
+                source,
+                destination
+            )
+
+    build_archive(blog)
+    build_tags(blog)
+
+    print(
+        f"Built {len(blog.posts)} posts."
+    )
+
+def build_archive(blog):
+
+    template = load_template(
+        "archive.html"
+    )
+
+    values = {
+        "archive": render_archive(blog)
+    }
+
+    output = os.path.join(
+        OUTPUT_DIR,
+        "archive",
+        "index.html"
+    )
+
+    os.makedirs(
+        os.path.dirname(output),
+        exist_ok=True
+    )
+
+    with open(
+        output,
+        "w",
+        encoding="utf-8"
+    ) as f:
+        f.write(
+            render(template, values)
+        )
+
+    print("  archive")
+
+
+def build_tags(blog):
+
+    tags = blog.tags()
+
+    # Tag index
+    index_output = os.path.join(
+        OUTPUT_DIR,
+        "tags",
+        "index.html"
+    )
+
+    os.makedirs(
+        os.path.dirname(index_output),
+        exist_ok=True
+    )
+
+    with open(
+        index_output,
+        "w",
+        encoding="utf-8"
+    ) as f:
+        f.write(render_tags_index(blog))
+
+    # One page per tag
+    for tag, posts in tags.items():
+
+        output = os.path.join(
+            OUTPUT_DIR,
+            "tags",
+            tag,
+            "index.html"
+        )
+
+        os.makedirs(
+            os.path.dirname(output),
+            exist_ok=True
+        )
+
+        with open(
+            output,
+            "w",
+            encoding="utf-8"
+        ) as f:
+            f.write(render_tag_page(tag, posts))
+
+    print(
+        f"  tags ({len(tags)})"
+    )
+
+# ------------------------------------------------------------
+# CLI
+# ------------------------------------------------------------
+
+def main():
+
+    parser = argparse.ArgumentParser(
+        description="Milán Major's tiny blog engine"
+    )
+
+    subparsers = parser.add_subparsers(
+        dest="command"
+    )
+
+    subparsers.add_parser(
+        "build",
+        help="build the website"
+    )
+
+    subparsers.add_parser(
+        "clean",
+        help="remove generated files"
+    )
+
+    args = parser.parse_args()
+
+    if args.command == "clean":
+        clean()
+        return
+
+    if args.command == "build":
+
+        blog = Blog()
+        blog.load()
+
+        print(
+            f"Loaded {len(blog.posts)} posts."
+        )
+
+        build(blog)
+
+        return
+
+    parser.print_help()
+
+
+if __name__ == "__main__":
+    main()
diff --git a/blog/posts/2026-08-05-Netscape.md b/blog/posts/2026-08-05-Netscape.md
new file mode 100644
index 0000000..d0233c3
--- /dev/null
+++ b/blog/posts/2026-08-05-Netscape.md
@@ -0,0 +1,242 @@
+---
+
+title: Bringing Netscape Communicator 3.0.2 Back to Life
+date: 2026-08-06
+tags: [netscape, unix, programming, retrocomputing]
+---------------------------------------------------
+
+# Bringing Netscape Communicator 3.0.2 Back to Life
+
+There is something strangely satisfying about taking software that was written nearly thirty years ago and making it run again on a modern computer.
+
+Not in an emulator. Not as a screenshot. Not as an archival curiosity.
+
+Actually compiling it.
+
+That is what I've been doing with Netscape Communicator 3.0.2.
+
+## Why Netscape?
+
+Modern browsers are astonishing pieces of software. They contain JavaScript engines, GPU compositors, sandboxing, multiprocess architectures, networking stacks, media frameworks, cryptography, accessibility systems, developer tools, and enough code to fill an entire bookshelf.
+
+Netscape Communicator was already complicated software, but it came from a very different world.
+
+A browser was still recognizably a desktop application.
+
+The window belonged to the operating system. The widgets belonged to Motif. The browser had a relatively straightforward relationship with the filesystem. The JavaScript implementation was still called Mocha internally. And the entire thing could, at least in principle, be understood by a sufficiently determined person with a debugger and a lot of patience.
+
+That last part is what interests me.
+
+## The first compilation
+
+The first objective was not to make Netscape usable.
+
+It was simply:
+
+> Can this source code still be compiled?
+
+The answer turned out to be yes, although "yes" requires a fairly generous interpretation of the word *compiled*.
+
+The original source code expects a software environment that disappeared a long time ago. Compiler behavior has changed, system libraries have changed, headers have moved, and assumptions that were perfectly reasonable in 1996 are now mysterious.
+
+The first useful trick was therefore to resist the temptation to modernize everything immediately.
+
+When old software refuses to compile, it is tempting to start rewriting it.
+
+That can be useful eventually, but initially it destroys information.
+
+If an old piece of code says:
+
+```c
+char *foo;
+foo = malloc(100);
+```
+
+and the modern compiler complains about the implicit declaration of `malloc()`, I would rather understand why the original environment allowed that code than immediately rewrite every occurrence.
+
+The errors are archaeological evidence.
+
+## Motif is where things get interesting
+
+Netscape's graphical interface depends heavily on the X Window System and Motif.
+
+On a contemporary Linux distribution, this creates an interesting problem. The operating system may provide Motif-compatible libraries, but they are not necessarily the same libraries against which Netscape was originally developed.
+
+Even apparently harmless differences can become important.
+
+At one point I found Netscape loading the system `libXm.so` rather than the Open Motif library I had compiled specifically for the project.
+
+That was particularly entertaining because the resulting crash happened somewhere inside:
+
+```text
+XmCreateScrollBar()
+```
+
+The application itself appeared to be doing something perfectly reasonable.
+
+The debugger, however, had other opinions.
+
+This is one of those situations where `gdb` becomes much more useful than reading compiler errors.
+
+A backtrace can tell you not only where something failed, but which assumptions the program made on its way there.
+
+## Following the crash
+
+The debugger showed a call chain passing through Netscape's window creation code and eventually reaching Motif.
+
+That gave me a much better question than "Why does Netscape crash?"
+
+The question became:
+
+> What does Netscape expect Motif to do here, and what is the Motif library actually doing?
+
+That distinction matters.
+
+If the problem is in Netscape, the correct solution is probably in Netscape.
+
+If the problem is an ABI mismatch, changing Netscape's source may only hide the problem.
+
+And if the problem is that the wrong library is being loaded, absolutely no amount of source-code debugging will solve it.
+
+The dynamic linker has to be interrogated first.
+
+Tools such as `readelf`, `ldd`, and the loader's debugging facilities are therefore just as important as the debugger itself.
+
+For example:
+
+```sh
+readelf -d ./netscape-export
+```
+
+can tell us which shared libraries the executable expects.
+
+The next question is where those libraries are actually coming from.
+
+## Old software has layers
+
+The project has made me think about old software as a stack of historical assumptions.
+
+At the top is Netscape itself.
+
+Under that is Motif.
+
+Under Motif is Xt.
+
+Under Xt is Xlib.
+
+Then there is the X server.
+
+And underneath all of that is a modern Linux system whose behavior is substantially different from the environment in which the program was originally created.
+
+So when something breaks, the problem could be anywhere in that stack.
+
+That is why simply recompiling everything with a modern compiler isn't necessarily enough.
+
+You can have source code that compiles cleanly and still have a program that crashes because two components disagree about an ABI detail.
+
+## The strange pleasure of old interfaces
+
+There is another reason I enjoy this project.
+
+Old graphical software has a different visual vocabulary.
+
+Buttons look like buttons. Menus are menus. Scrollbars are scrollbars. There are fewer layers between the user and the application.
+
+Modern interfaces often try very hard to disappear.
+
+Older interfaces were often more explicit.
+
+That doesn't necessarily make them better, but it makes them interesting.
+
+The same is true of old websites.
+
+A page could simply be a document.
+
+There was no assumption that every interaction required an animation, a framework, a tracking system, or a backend API.
+
+That is partly what I want to reproduce with this website.
+
+Not necessarily the exact appearance of 1996, but the idea that a website can just be a collection of documents.
+
+## Building a small website
+
+This blog is therefore deliberately simple.
+
+Posts are stored as text files.
+
+The blog program reads them, converts them into HTML, and writes the finished pages into the web root.
+
+There is no database.
+
+There is no runtime application.
+
+There is no JavaScript required to read the articles.
+
+The server only needs to serve files.
+
+The complicated part is the program that builds those files.
+
+That seems like a good trade.
+
+It means that once the site has been generated, the server can be extremely boring.
+
+And boring infrastructure is often good infrastructure.
+
+## What comes next
+
+The little blogging program is still very young.
+
+Right now it knows about posts, dates, tags, and templates.
+
+The next features will probably be an archive and tag indexes, followed by RSS.
+
+Eventually I also want the site to contain my Git repositories.
+
+I'd like the Git section to feel like part of the same personal information server rather than an unrelated service bolted onto the side.
+
+There should be a place for projects, patches, notes, configuration files, and other things that don't necessarily belong in a conventional blog post.
+
+The result might be something like this:
+
+```text
+/
+├── blog/
+├── git/
+├── pub/
+├── about.html
+└── index.html
+```
+
+A small personal corner of the Internet.
+
+Nothing particularly clever.
+
+Just documents, source code, and links.
+
+## Why bother?
+
+There are already countless excellent blogging platforms.
+
+There are excellent static-site generators.
+
+There are excellent Git hosting applications.
+
+There are excellent web frameworks.
+
+I don't need to reinvent any of them.
+
+But that's not really the point.
+
+Writing the software myself means I understand exactly how the site works.
+
+If I want a feature, I can add it.
+
+If I don't want a feature, it doesn't exist.
+
+There is something appealing about that constraint.
+
+The same reason I am interested in getting a 1990s browser running again is the reason I am interested in building this tiny blog engine.
+
+Software doesn't always need to be enormous.
+
+Sometimes it is more interesting when you can still see all of it.
diff --git a/blog/posts/2026-08-06-first-post.md b/blog/posts/2026-08-06-first-post.md
new file mode 100644
index 0000000..57a4080
--- /dev/null
+++ b/blog/posts/2026-08-06-first-post.md
@@ -0,0 +1,11 @@
+---
+title: My First Post
+date: 2026-08-06
+tags: [meta, blog, programming]
+---
+
+# Hello
+
+This is my first post on my new website.
+
+The blog engine is being written specifically for this site.
diff --git a/gitweb.py b/gitweb.py
new file mode 100755
index 0000000..2617c74
--- /dev/null
+++ b/gitweb.py
@@ -0,0 +1,1004 @@
+#!/usr/bin/env python3
+
+"""
+A tiny static git repository browser, in the spirit of
+stagit/cgit-static: scan a directory of bare repositories and
+generate plain HTML pages for each one (summary, log, commits,
+file tree, files, refs).
+"""
+
+import os
+import re
+import html
+import shutil
+import argparse
+import subprocess
+from dataclasses import dataclass, field
+from datetime import datetime
+
+from siteutils import load_template, render, markdown
+
+REPO_DIR = "repos"
+OUTPUT_DIR = "www/git"
+CLONE_BASE = "https://git.milanmajor.dev"
+
+RECORD_SEP = "\x1e"
+FIELD_SEP = "\x1f"
+
+LOG_FORMAT = FIELD_SEP.join([
+    "%H", "%P", "%an", "%ae", "%ad", "%s", "%b"
+]) + RECORD_SEP
+
+
+# ------------------------------------------------------------
+# Git plumbing
+# ------------------------------------------------------------
+
+def decode(raw):
+    """
+    Decode git output as UTF-8, falling back to Latin-1 (which
+    can represent any byte sequence) for old repositories that
+    predate UTF-8 conventions.
+    """
+
+    try:
+        return raw.decode("utf-8")
+    except UnicodeDecodeError:
+        return raw.decode("latin-1")
+
+
+def git_bytes(git_dir, *args):
+    """
+    Run a git plumbing command against a specific repository
+    and return its raw stdout bytes.
+    """
+
+    result = subprocess.run(
+        ["git", "--git-dir", git_dir, *args],
+        capture_output=True
+    )
+
+    if result.returncode != 0:
+        raise RuntimeError(
+            f"git {' '.join(args)} failed in {git_dir}: "
+            f"{decode(result.stderr).strip()}"
+        )
+
+    return result.stdout
+
+
+def git(git_dir, *args):
+    """
+    Run a git plumbing command against a specific repository
+    and return its stdout as text.
+    """
+
+    return decode(git_bytes(git_dir, *args))
+
+
+def default_branch(git_dir):
+
+    try:
+        output = git(git_dir, "symbolic-ref", "HEAD", "--short")
+        return output.strip()
+
+    except RuntimeError:
+        return None
+
+
+# ------------------------------------------------------------
+# Commit
+# ------------------------------------------------------------
+
+@dataclass
+class Commit:
+    repo: "Repo"
+    hash: str
+    parent: str
+    author_name: str
+    author_email: str
+    date: datetime
+    subject: str
+    body: str
+
+    @property
+    def short_hash(self):
+        return self.hash[:7]
+
+    @property
+    def url(self):
+        return f"{self.repo.url}commit/{self.hash}.html"
+
+    @property
+    def date_human(self):
+        return self.date.strftime("%Y-%m-%d %H:%M")
+
+
+def parse_commits(repo, output):
+
+    commits = []
+
+    for record in output.split(RECORD_SEP):
+
+        record = record.strip("\n")
+
+        if not record.strip():
+            continue
+
+        fields = record.split(FIELD_SEP)
+
+        commit_hash, parents, author_name, author_email, \
+            date, subject, body = fields
+
+        parent = parents.split(" ")[0] if parents else ""
+
+        commits.append(Commit(
+            repo=repo,
+            hash=commit_hash,
+            parent=parent,
+            author_name=author_name,
+            author_email=author_email,
+            date=datetime.strptime(date, "%Y-%m-%d %H:%M:%S"),
+            subject=subject,
+            body=body.strip()
+        ))
+
+    return commits
+
+
+def parse_shortstat(text):
+    """
+    Parse the summary line produced by `git show --shortstat`,
+    e.g. " 2 files changed, 13 insertions(+), 4 deletions(-)",
+    into a (files, insertions, deletions) tuple of ints. Any
+    component absent from the line (e.g. a commit with no
+    insertions) is reported as 0.
+    """
+
+    def extract(pattern):
+        match = re.search(pattern, text) if text else None
+        return int(match.group(1)) if match else 0
+
+    files = extract(r"(\d+) files? changed")
+    insertions = extract(r"(\d+) insertions?\(\+\)")
+    deletions = extract(r"(\d+) deletions?\(-\)")
+
+    return files, insertions, deletions
+
+
+# ------------------------------------------------------------
+# Repo
+# ------------------------------------------------------------
+
+@dataclass
+class Repo:
+    name: str
+    path: str
+    description: str = ""
+
+    @property
+    def url(self):
+        return f"/git/{self.name}/"
+
+    @property
+    def clone_url(self):
+        return f"{CLONE_BASE}/{self.name}"
+
+    def branch(self):
+        """
+        The repository's "default" branch for building root-level
+        pages. Prefers whatever HEAD points at, but falls back to
+        the first branch if HEAD is unborn/detached or points at a
+        ref that doesn't actually exist (common on repos mirrored
+        from elsewhere, or converted from another VCS).
+        """
+
+        branches = self.branches()
+
+        if not branches:
+            return None
+
+        head = default_branch(self.path)
+
+        return head if head in branches else branches[0]
+
+    def log(self, ref=None, limit=None, path=None):
+
+        args = ["log", "--pretty=format:" + LOG_FORMAT,
+                "--date=format:%Y-%m-%d %H:%M:%S"]
+
+        if limit:
+            args.insert(1, f"-n{limit}")
+
+        if ref:
+            args.append(ref)
+
+        if path:
+            args += ["--", path]
+
+        output = git(self.path, *args)
+
+        return parse_commits(self, output)
+
+    def last_commit(self):
+        try:
+            commits = self.log(limit=1)
+        except RuntimeError:
+            return None
+        return commits[0] if commits else None
+
+    def last_commit_for(self, ref, path):
+        """
+        Return the most recent Commit touching `path` reachable
+        from `ref`, or None if there isn't one (e.g. broken ref).
+        """
+
+        try:
+            commits = self.log(ref=ref, limit=1, path=path)
+        except RuntimeError:
+            return None
+
+        return commits[0] if commits else None
+
+    def branches(self):
+
+        output = git(
+            self.path,
+            "for-each-ref",
+            "--format=%(refname:short)",
+            "refs/heads/"
+        )
+
+        return [
+            line.strip()
+            for line in output.splitlines()
+            if line.strip()
+        ]
+
+    def tree(self, ref, path=""):
+        """
+        Return a non-recursive directory listing at `path` for
+        `ref` as a list of (mode, type, size, name) tuples.
+        """
+
+        target = f"{ref}:{path}" if path else ref
+
+        try:
+            output = git(self.path, "ls-tree", "-l", target)
+        except RuntimeError:
+            return []
+
+        entries = []
+
+        for line in output.splitlines():
+
+            if not line.strip():
+                continue
+
+            meta, name = line.split("\t", 1)
+            mode, kind, blob_hash, size = meta.split()
+
+            entries.append((mode, kind, size, name))
+
+        return entries
+
+    def read_file(self, ref, path):
+
+        try:
+            raw = git_bytes(self.path, "show", f"{ref}:{path}")
+        except RuntimeError:
+            return None
+
+        # A NUL byte is a reliable sign of genuinely binary
+        # content; anything else can be decoded (falling back
+        # to Latin-1 for pre-UTF-8 era files).
+        if b"\x00" in raw:
+            return None
+
+        return decode(raw)
+
+    def readme(self, ref):
+        """
+        Return the contents of a top-level README file, if any
+        (tried in order of preference), for display on the
+        repo's summary page.
+        """
+
+        for name in ("README.md", "README", "README.txt"):
+            content = self.read_file(ref, name)
+            if content is not None:
+                return content
+
+        return None
+
+    def last_change(self, ref, path=None):
+        """
+        Return the date of the most recent commit touching
+        `path` (or the whole tree if `path` is None) reachable
+        from `ref`.
+        """
+
+        args = [
+            "log", "-1",
+            "--pretty=format:%ad",
+            "--date=format:%b %d %H:%M",
+            ref,
+        ]
+
+        if path:
+            args += ["--", path]
+
+        output = git(self.path, *args).strip()
+
+        return output or "-"
+
+    def shortstat(self, commit):
+        """
+        Return (files, insertions, deletions) for a single
+        commit without generating the (potentially large) full
+        patch text.
+        """
+
+        output = git(
+            self.path,
+            "show", "--shortstat", "--format=", commit.hash
+        ).strip()
+
+        return parse_shortstat(output)
+
+    def diff(self, commit):
+
+        stat = git(
+            self.path,
+            "show", "--stat", "--format=", commit.hash
+        ).strip()
+
+        patch = git(
+            self.path,
+            "show", "--format=", commit.hash
+        ).strip()
+
+        return stat, patch, self.shortstat(commit)
+
+
+def resolve_git_dir(path):
+    """
+    Determine the actual git-dir for a discovered `repos/*.git`
+    entry. It might be a proper bare repository, or (as is easy
+    to end up with by accident) a regular working copy whose
+    directory just happens to be named "something.git" - in
+    that case its real git-dir is the nested `.git`.
+
+    Returns the resolved git-dir, or None if `path` isn't a git
+    repository at all.
+    """
+
+    if (
+        os.path.isfile(os.path.join(path, "HEAD"))
+        and os.path.isdir(os.path.join(path, "objects"))
+    ):
+        return path
+
+    nested = os.path.join(path, ".git")
+
+    if os.path.isdir(nested) or os.path.isfile(nested):
+        return nested
+
+    return None
+
+
+def discover_repos():
+
+    repos = []
+
+    if not os.path.isdir(REPO_DIR):
+        return repos
+
+    for name in sorted(os.listdir(REPO_DIR)):
+
+        if not name.endswith(".git"):
+            continue
+
+        candidate = os.path.join(REPO_DIR, name)
+
+        if not os.path.isdir(candidate):
+            continue
+
+        git_dir = resolve_git_dir(candidate)
+
+        if git_dir is None:
+            print(f"  skip: {name} (not a git repository)")
+            continue
+
+        description = ""
+
+        description_path = os.path.join(git_dir, "description")
+
+        if os.path.isfile(description_path):
+
+            with open(description_path, "r", encoding="utf-8") as f:
+                text = f.read().strip()
+
+            # Git ships a placeholder description in every
+            # freshly initialized repository; treat it as empty.
+            if not text.startswith("Unnamed repository"):
+                description = text
+
+        repos.append(Repo(
+            name=name[:-4],
+            path=git_dir,
+            description=description
+        ))
+
+    return repos
+
+
+# ------------------------------------------------------------
+# Rendering
+# ------------------------------------------------------------
+
+def format_stat_summary(files, insertions, deletions):
+
+    if not files:
+        return "—"
+
+    file_label = "file" if files == 1 else "files"
+
+    return f'{files} {file_label}, +{insertions} -{deletions}'
+
+
+def render_commit_row(repo, commit):
+
+    files, insertions, deletions = repo.shortstat(commit)
+
+    return (
+        "<tr>"
+        f'<td>{commit.date_human}</td>'
+        f'<td><a href="{commit.url}">'
+        f'{html.escape(commit.subject)}</a></td>'
+        f'<td>{html.escape(commit.author_name)}</td>'
+        f'<td class="num" align="right">{files}</td>'
+        f'<td class="num" align="right">+{insertions}</td>'
+        f'<td class="num" align="right">-{deletions}</td>'
+        "</tr>"
+    )
+
+
+def render_log(repo, commits):
+
+    if not commits:
+        return '<tr><td colspan="6">No commits.</td></tr>'
+
+    return "\n".join(
+        render_commit_row(repo, commit)
+        for commit in commits
+    )
+
+
+def mode_to_perm(mode, kind):
+    """Render a git file mode as an `ls -l`-style permission string."""
+
+    if kind == "tree":
+        return "drwxr-xr-x"
+
+    if mode == "120000":
+        return "lrwxrwxrwx"
+
+    if mode == "160000":
+        return "m---------"
+
+    def octal_to_rwx(digit):
+        digit = int(digit)
+        return (
+            ("r" if digit & 4 else "-")
+            + ("w" if digit & 2 else "-")
+            + ("x" if digit & 1 else "-")
+        )
+
+    bits = mode[-3:].rjust(3, "0")
+
+    return "-" + "".join(octal_to_rwx(d) for d in bits)
+
+
+def format_size(size):
+    """Render a byte count the way `ls -lh` does: plain below 1024,
+    otherwise one decimal place with a K/M/G suffix."""
+
+    try:
+        n = float(size)
+    except ValueError:
+        return size
+
+    if n < 1024:
+        return str(int(n))
+
+    for unit in ("K", "M"):
+        n /= 1024
+        if n < 1024:
+            return f"{n:.1f}{unit}"
+
+    return f"{n / 1024:.1f}G"
+
+
+FOLDER_ICON = "📁"
+FILE_ICON = "📄"
+
+
+def entry_icon(kind):
+    return FOLDER_ICON if kind == "tree" else FILE_ICON
+
+
+def render_tree_row(perm, link_target, size, date, numeric=True):
+
+    size_cell = (
+        f'<td class="num" align="right">{size}</td>'
+        if numeric else "<td></td>"
+    )
+
+    return (
+        "<tr>"
+        f"<td>{perm}</td>"
+        f"<td>{link_target}</td>"
+        f"{size_cell}"
+        f"<td>{date}</td>"
+        "</tr>"
+    )
+
+
+def render_tree_entries(repo, ref, path, entries, url_base):
+    """
+    Render a directory listing as a table of rows (mode, name,
+    size, last-changed date), in the same tabular style as the
+    commit log page.
+    """
+
+    rows = []
+
+    if path:
+        parent = "/".join(path.split("/")[:-1])
+        parent_url = (
+            f"{url_base}files/{parent}/index.html"
+            if parent else f"{url_base}files/index.html"
+        )
+        rows.append(render_tree_row(
+            "drwxr-xr-x",
+            f'<a href="{parent_url}">{FOLDER_ICON} ..</a>', "", "",
+            numeric=False
+        ))
+
+    for mode, kind, size, name in entries:
+
+        full_path = f"{path}/{name}" if path else name
+        date = repo.last_change(ref, full_path)
+
+        if kind == "tree":
+            url = f"{url_base}files/{full_path}/index.html"
+            label = f"{name}/"
+            size_label = "-"
+        else:
+            url = f"{url_base}file/{full_path}.html"
+            label = name
+            size_label = format_size(size)
+
+        perm = mode_to_perm(mode, kind)
+        icon = entry_icon(kind)
+        link = f'<a href="{url}">{icon} {html.escape(label)}</a>'
+
+        rows.append(render_tree_row(perm, link, size_label, date))
+
+    if not rows:
+        return '<tr><td colspan="4">Empty directory.</td></tr>'
+
+    return "\n".join(rows)
+
+
+def render_repo_files(repo, ref, url_base):
+    """
+    Render the repo's root directory listing for the summary
+    page, GitHub-style: name, last commit touching it (linked
+    to the commit page), and its date.
+    """
+
+    entries = repo.tree(ref, "")
+
+    if not entries:
+        return '<tr><td colspan="3">Empty repository.</td></tr>'
+
+    rows = []
+
+    for mode, kind, size, name in entries:
+
+        icon = entry_icon(kind)
+
+        if kind == "tree":
+            url = f"{url_base}files/{name}/index.html"
+            label = f"{name}/"
+        else:
+            url = f"{url_base}file/{name}.html"
+            label = name
+
+        commit = repo.last_commit_for(ref, name)
+
+        if commit:
+            commit_cell = (
+                f'<a href="{commit.url}">'
+                f'{html.escape(commit.subject)}</a>'
+            )
+            date_cell = commit.date_human
+        else:
+            commit_cell = "—"
+            date_cell = "—"
+
+        rows.append(
+            "<tr>"
+            f'<td><a href="{url}">{icon} {html.escape(label)}</a></td>'
+            f'<td>{commit_cell}</td>'
+            f'<td>{date_cell}</td>'
+            "</tr>"
+        )
+
+    return "\n".join(rows)
+
+
+def branch_paths(repo, branch, default_ref):
+    """
+    Return (url_base, output_base) for a given branch: the
+    default branch's pages live at the repo root, every other
+    branch gets its own mirrored subtree under branch/<name>/.
+    """
+
+    if branch == default_ref:
+        return repo.url, os.path.join(OUTPUT_DIR, repo.name)
+
+    return (
+        f"{repo.url}branch/{branch}/",
+        os.path.join(OUTPUT_DIR, repo.name, "branch", branch),
+    )
+
+
+PAGE_SUFFIX = {
+    "summary": "index.html",
+    "log": "log.html",
+    "files": "files/index.html",
+}
+
+
+def render_branch_select(repo, current_branch, default_ref, page):
+
+    suffix = PAGE_SUFFIX[page]
+    options = []
+
+    for branch in repo.branches():
+
+        url_base, _ = branch_paths(repo, branch, default_ref)
+        selected = " selected" if branch == current_branch else ""
+
+        options.append(
+            f'<option value="{url_base}{suffix}"{selected}>'
+            f'{html.escape(branch)}</option>'
+        )
+
+    return (
+        '<select class="branch-select" '
+        'onchange="if (this.value) location.href = this.value;">'
+        + "".join(options) +
+        "</select>"
+    )
+
+
+def render_repo_bar(repo, current_branch, default_ref, page):
+    """
+    The GitHub-style bar shown at the top of every repo page:
+    a branch switcher plus branch count on the left, and a
+    disclosure widget (a plain <details>, no JS needed to open
+    it) with a copyable HTTPS clone URL on the right.
+    """
+
+    branches = repo.branches()
+    count = len(branches)
+    label = "branch" if count == 1 else "branches"
+    clone_url = html.escape(repo.clone_url)
+
+    return (
+        '<div class="repo-bar">'
+        '<div class="repo-bar-branches">'
+        '<span class="branch-icon">⎇</span>'
+        + render_branch_select(repo, current_branch, default_ref, page) +
+        f'<span class="branch-count">{count} {label}</span>'
+        '</div>'
+        '<details class="clone-box">'
+        '<summary>Code</summary>'
+        '<div class="clone-panel">'
+        '<label for="clone-url" class="clone-label">'
+        'Clone with HTTPS</label>'
+        '<div class="clone-field">'
+        f'<input id="clone-url" type="text" readonly value="{clone_url}" '
+        'onclick="this.select()">'
+        '<button type="button" '
+        'onclick="navigator.clipboard.writeText('
+        'this.previousElementSibling.value); '
+        "this.textContent = 'Copied'; "
+        "setTimeout(() => this.textContent = 'Copy', 1500);"
+        '">Copy</button>'
+        '</div>'
+        '</div>'
+        '</details>'
+        '</div>'
+    )
+
+
+def nav_values(repo, branch, default_ref, page):
+
+    url_base, _ = branch_paths(repo, branch, default_ref)
+
+    return {
+        "name": html.escape(repo.name),
+        "repo_url": url_base,
+        "log_url": f"{url_base}log.html",
+        "files_url": f"{url_base}files/index.html",
+        "repo_bar": render_repo_bar(repo, branch, default_ref, page),
+    }
+
+
+# ------------------------------------------------------------
+# Build
+# ------------------------------------------------------------
+
+def clean():
+
+    if os.path.isdir(OUTPUT_DIR):
+        shutil.rmtree(OUTPUT_DIR)
+
+    os.makedirs(OUTPUT_DIR, exist_ok=True)
+
+
+def write(path, text):
+
+    os.makedirs(os.path.dirname(path), exist_ok=True)
+
+    with open(path, "w", encoding="utf-8") as f:
+        f.write(text)
+
+
+def build_index(repos):
+
+    rows = []
+
+    for repo in repos:
+
+        last = repo.last_commit()
+        last_date = last.date_human if last else "—"
+
+        rows.append(
+            "<tr>"
+            f'<td><a href="{repo.url}">{html.escape(repo.name)}</a></td>'
+            f'<td>{html.escape(repo.description)}</td>'
+            f'<td>{last_date}</td>'
+            "</tr>"
+        )
+
+    template = load_template("git-index.html")
+
+    write(
+        os.path.join(OUTPUT_DIR, "index.html"),
+        render(template, {"repos": "\n".join(rows)})
+    )
+
+
+def build_repo_summary(repo, branch, default_ref):
+
+    readme = repo.readme(branch)
+    url_base, output_base = branch_paths(repo, branch, default_ref)
+
+    values = {
+        **nav_values(repo, branch, default_ref, page="summary"),
+        "description": html.escape(repo.description),
+        "files": render_repo_files(repo, branch, url_base),
+        "readme": (
+            f'<div class="readme">'
+            f'{markdown(readme, heading_offset=1)}</div>'
+            if readme else ""
+        ),
+    }
+
+    template = load_template("git-repo.html")
+
+    write(
+        os.path.join(output_base, "index.html"),
+        render(template, values)
+    )
+
+
+def build_repo_log(repo, branch, default_ref):
+
+    commits = repo.log(ref=branch)
+    _, output_base = branch_paths(repo, branch, default_ref)
+
+    values = {
+        **nav_values(repo, branch, default_ref, page="log"),
+        "log": render_log(repo, commits),
+    }
+
+    template = load_template("git-log.html")
+
+    write(
+        os.path.join(output_base, "log.html"),
+        render(template, values)
+    )
+
+
+def build_repo_commits(repo, commits, default_ref):
+
+    template = load_template("git-commit.html")
+
+    for commit in commits:
+
+        stat, patch, (files, insertions, deletions) = repo.diff(commit)
+
+        parent_html = (
+            f'<a href="{repo.url}commit/{commit.parent}.html">'
+            f'{commit.parent[:7]}</a>'
+            if commit.parent else "(root commit)"
+        )
+
+        body_html = ""
+
+        if commit.body:
+            body_html = (
+                "<pre class=\"commit-body\">"
+                f"{html.escape(commit.body)}</pre>"
+            )
+
+        values = {
+            **nav_values(repo, default_ref, default_ref, page="log"),
+            "subject": html.escape(commit.subject),
+            "author": html.escape(
+                f"{commit.author_name} <{commit.author_email}>"
+            ),
+            "date": commit.date_human,
+            "hash": commit.hash,
+            "parent": parent_html,
+            "body": body_html,
+            "summary": format_stat_summary(files, insertions, deletions),
+            "stat": html.escape(stat) if stat else "(no changes)",
+            "diff": html.escape(patch) if patch else "(no diff)",
+        }
+
+        write(
+            os.path.join(
+                OUTPUT_DIR, repo.name, "commit", f"{commit.hash}.html"
+            ),
+            render(template, values)
+        )
+
+
+def build_repo_tree(repo, branch, default_ref, path=""):
+
+    entries = repo.tree(branch, path)
+    url_base, output_base = branch_paths(repo, branch, default_ref)
+
+    values = {
+        **nav_values(repo, branch, default_ref, page="files"),
+        "path": html.escape(path) if path else "/",
+        "path_suffix": f": {html.escape(path)}" if path else "",
+        "entries": render_tree_entries(
+            repo, branch, path, entries, url_base
+        ),
+    }
+
+    template = load_template("git-files.html")
+
+    output = os.path.join(output_base, "files")
+
+    if path:
+        output = os.path.join(output, path)
+
+    write(
+        os.path.join(output, "index.html"),
+        render(template, values)
+    )
+
+    for mode, kind, size, name in entries:
+
+        full_path = f"{path}/{name}" if path else name
+
+        if kind == "tree":
+            build_repo_tree(repo, branch, default_ref, full_path)
+        elif kind == "blob":
+            build_repo_file(repo, branch, default_ref, full_path)
+
+
+def build_repo_file(repo, branch, default_ref, path):
+
+    content = repo.read_file(branch, path)
+    _, output_base = branch_paths(repo, branch, default_ref)
+
+    if content is None:
+        content = "(binary file, not shown)"
+
+    values = {
+        **nav_values(repo, branch, default_ref, page="files"),
+        "path": html.escape(path),
+        "content": html.escape(content),
+    }
+
+    template = load_template("git-file.html")
+
+    write(
+        os.path.join(output_base, "file", f"{path}.html"),
+        render(template, values)
+    )
+
+
+def build():
+
+    print("Building git...")
+
+    clean()
+
+    repos = discover_repos()
+
+    if not repos:
+        print("  no repositories found")
+
+    for repo in repos:
+
+        ref = repo.branch()
+
+        if ref is None:
+            print(f"  skip: {repo.name} (empty repository)")
+            continue
+
+        for branch in repo.branches():
+            build_repo_summary(repo, branch, ref)
+            build_repo_log(repo, branch, ref)
+            build_repo_tree(repo, branch, ref)
+
+        # Collect every reachable commit (across all branches) so
+        # that links from any log page resolve to a real page.
+        all_commits = {}
+
+        for branch in repo.branches():
+            for commit in repo.log(ref=branch):
+                all_commits[commit.hash] = commit
+
+        build_repo_commits(repo, all_commits.values(), ref)
+
+        print(f"  repo: {repo.name}")
+
+    build_index(repos)
+
+    print("  index")
+    print(f"Built {len(repos)} repositories.")
+
+
+# ------------------------------------------------------------
+# CLI
+# ------------------------------------------------------------
+
+def main():
+
+    parser = argparse.ArgumentParser(
+        description="Milán Major's tiny static git browser"
+    )
+
+    subparsers = parser.add_subparsers(dest="command")
+
+    subparsers.add_parser("build", help="build the git pages")
+    subparsers.add_parser("clean", help="remove generated files")
+
+    args = parser.parse_args()
+
+    if args.command == "clean":
+        clean()
+        return
+
+    if args.command == "build":
+        build()
+        return
+
+    parser.print_help()
+
+
+if __name__ == "__main__":
+    main()
diff --git a/repos/.gitkeep b/repos/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/site.py b/site.py
new file mode 100755
index 0000000..86baf3f
--- /dev/null
+++ b/site.py
@@ -0,0 +1,83 @@
+#!/usr/bin/env python3
+
+"""
+Top-level orchestrator: builds both the blog and the git
+browser into www/ with a single command.
+"""
+
+import argparse
+import os
+
+import blog
+import gitweb
+import siteutils
+
+OUTPUT_DIR = "www"
+
+
+def build_top_level_pages():
+
+    os.makedirs(OUTPUT_DIR, exist_ok=True)
+
+    for name in ("home", "about"):
+
+        template = siteutils.load_template(f"{name}.html")
+        html = siteutils.render(template, {})
+
+        filename = "index.html" if name == "home" else "about.html"
+
+        with open(
+            os.path.join(OUTPUT_DIR, filename),
+            "w",
+            encoding="utf-8"
+        ) as f:
+            f.write(html)
+
+    print("  home")
+    print("  about")
+
+
+def build():
+    blog_site = blog.Blog()
+    blog_site.load()
+
+    print(f"Loaded {len(blog_site.posts)} posts.")
+
+    blog.build(blog_site)
+    gitweb.build()
+
+    print("Building top-level pages...")
+    build_top_level_pages()
+
+
+def clean():
+    blog.clean()
+    gitweb.clean()
+
+
+def main():
+
+    parser = argparse.ArgumentParser(
+        description="Milán Major's tiny site builder"
+    )
+
+    subparsers = parser.add_subparsers(dest="command")
+
+    subparsers.add_parser("build", help="build the whole site")
+    subparsers.add_parser("clean", help="remove all generated files")
+
+    args = parser.parse_args()
+
+    if args.command == "clean":
+        clean()
+        return
+
+    if args.command == "build":
+        build()
+        return
+
+    parser.print_help()
+
+
+if __name__ == "__main__":
+    main()
diff --git a/siteutils.py b/siteutils.py
new file mode 100755
index 0000000..93fb633
--- /dev/null
+++ b/siteutils.py
@@ -0,0 +1,274 @@
+"""
+Shared helpers used by both the blog and git static site
+generators.
+"""
+
+import os
+import re
+import html
+
+TEMPLATE_DIR = "templates"
+
+# The site header (name, nav, and Cross of Jerusalem mark) is
+# identical on every page, so it lives here once instead of
+# being duplicated across every template.
+HEADER_HTML = """<header>
+<a href="/" class="cross-link" aria-label="Home">
+<svg class="cross" viewBox="0 0 100 100" aria-hidden="true">
+<rect x="43" y="6" width="14" height="88"/>
+<rect x="6" y="43" width="88" height="14"/>
+<rect x="23" y="18" width="4" height="14"/>
+<rect x="18" y="23" width="14" height="4"/>
+<rect x="73" y="18" width="4" height="14"/>
+<rect x="68" y="23" width="14" height="4"/>
+<rect x="23" y="68" width="4" height="14"/>
+<rect x="18" y="73" width="14" height="4"/>
+<rect x="73" y="68" width="4" height="14"/>
+<rect x="68" y="73" width="14" height="4"/>
+</svg>
+</a>
+<div class="brand-text">
+<h1><a href="/">Milán Major</a></h1>
+<nav>
+<a href="/">home</a>
+<a href="/blog/">blog</a>
+<a href="/git/">git</a>
+<a href="/about.html">about</a>
+</nav>
+</div>
+</header>"""
+
+
+# ------------------------------------------------------------
+# Markdown
+#
+# A small, dependency-free Markdown renderer shared by the blog
+# (post bodies) and the git browser (README files). It covers
+# the subset of Markdown commonly used in both: headings,
+# paragraphs, code fences, lists, blockquotes, rules, links,
+# images and inline emphasis/code. It doesn't handle nested
+# lists or tables - kept deliberately simple.
+# ------------------------------------------------------------
+
+def inline_markdown(text):
+    text = html.escape(text)
+
+    text = re.sub(
+        r"!\[([^\]]*)\]\(([^)]+)\)",
+        r'<img src="\2" alt="\1">',
+        text
+    )
+
+    text = re.sub(
+        r"\[([^\]]+)\]\(([^)]+)\)",
+        r'<a href="\2">\1</a>',
+        text
+    )
+
+    text = re.sub(
+        r"\*\*(.+?)\*\*",
+        r"<strong>\1</strong>",
+        text
+    )
+
+    text = re.sub(
+        r"\*(.+?)\*",
+        r"<em>\1</em>",
+        text
+    )
+
+    text = re.sub(
+        r"`(.+?)`",
+        r"<code>\1</code>",
+        text
+    )
+
+    return text
+
+
+def markdown(text, heading_offset=0):
+    """
+    `heading_offset` shifts every heading level down (e.g. 1 turns
+    a "# Title" into <h2>), useful when embedding a document (such
+    as a README) below a page that already has its own <h1>.
+    """
+
+    lines = text.splitlines()
+    output = []
+
+    paragraph = []
+    code_block = None
+    code_lang = None
+    list_kind = None
+    list_items = None
+    quote_lines = None
+
+    def flush_paragraph():
+        if not paragraph:
+            return
+
+        value = " ".join(paragraph)
+        output.append(f"<p>{inline_markdown(value)}</p>")
+        paragraph.clear()
+
+    def flush_list():
+        nonlocal list_kind, list_items
+
+        if list_kind is None:
+            return
+
+        items_html = "\n".join(
+            f"<li>{inline_markdown(item)}</li>" for item in list_items
+        )
+        output.append(f"<{list_kind}>\n{items_html}\n</{list_kind}>")
+
+        list_kind = None
+        list_items = None
+
+    def flush_quote():
+        nonlocal quote_lines
+
+        if quote_lines is None:
+            return
+
+        value = " ".join(quote_lines)
+        output.append(f"<blockquote><p>{inline_markdown(value)}</p></blockquote>")
+
+        quote_lines = None
+
+    for line in lines:
+        stripped = line.strip()
+
+        if code_block is not None:
+
+            if stripped.startswith("```"):
+                code = "\n".join(code_block)
+                lang_attr = (
+                    f' class="language-{code_lang}"' if code_lang else ""
+                )
+                output.append(
+                    f"<pre><code{lang_attr}>{html.escape(code)}</code></pre>"
+                )
+                code_block = None
+                code_lang = None
+            else:
+                code_block.append(line)
+
+            continue
+
+        if stripped.startswith("```"):
+
+            # A line that opens *and* closes a fence on its own
+            # (e.g. "``` some command ```") is a single-line code
+            # span, not the start of a multi-line block - treat
+            # it as such instead of swallowing every line up to
+            # the next stray "```" elsewhere in the document.
+            single_line = re.match(r"^`{3,}\s*(.+?)\s*`{3,}$", stripped)
+
+            if single_line and "```" not in single_line.group(1):
+                flush_paragraph()
+                flush_list()
+                flush_quote()
+                output.append(
+                    f"<pre><code>{html.escape(single_line.group(1))}"
+                    "</code></pre>"
+                )
+                continue
+
+            flush_paragraph()
+            flush_list()
+            flush_quote()
+            code_block = []
+            code_lang = stripped[3:].strip() or None
+            continue
+
+        if not stripped:
+            flush_paragraph()
+            flush_list()
+            flush_quote()
+            continue
+
+        if re.match(r"^(-{3,}|\*{3,}|_{3,})$", stripped):
+            flush_paragraph()
+            flush_list()
+            flush_quote()
+            output.append("<hr>")
+            continue
+
+        heading = re.match(r"^(#{1,6})\s+(.*)$", stripped)
+
+        if heading:
+            flush_paragraph()
+            flush_list()
+            flush_quote()
+            level = min(6, len(heading.group(1)) + heading_offset)
+            output.append(
+                f"<h{level}>{inline_markdown(heading.group(2))}</h{level}>"
+            )
+            continue
+
+        if stripped.startswith(">"):
+            flush_paragraph()
+            flush_list()
+
+            if quote_lines is None:
+                quote_lines = []
+
+            quote_lines.append(stripped[1:].strip())
+            continue
+
+        flush_quote()
+
+        ordered = re.match(r"^\d+\.\s+(.*)$", stripped)
+        unordered = re.match(r"^[-*+]\s+(.*)$", stripped)
+
+        if ordered or unordered:
+            flush_paragraph()
+            kind = "ol" if ordered else "ul"
+            item_text = (ordered or unordered).group(1)
+
+            if list_kind != kind:
+                flush_list()
+                list_kind = kind
+                list_items = []
+
+            list_items.append(item_text)
+            continue
+
+        flush_list()
+        paragraph.append(line)
+
+    flush_paragraph()
+    flush_list()
+    flush_quote()
+
+    if code_block is not None:
+        code = "\n".join(code_block)
+        output.append(f"<pre><code>{html.escape(code)}</code></pre>")
+
+    return "\n".join(output)
+
+
+def load_template(name):
+
+    path = os.path.join(
+        TEMPLATE_DIR,
+        name
+    )
+
+    with open(path, "r", encoding="utf-8") as f:
+        return f.read()
+
+
+def render(template, values):
+
+    values = {"header": HEADER_HTML, **values}
+
+    for key, value in values.items():
+
+        template = template.replace(
+            "{{ " + key + " }}",
+            str(value)
+        )
+
+    return template
diff --git a/static/style.css b/static/style.css
new file mode 100644
index 0000000..4c64d80
--- /dev/null
+++ b/static/style.css
@@ -0,0 +1,332 @@
+html {
+    font-family: sans-serif;
+    font-size: 17px;
+    line-height: 1.45;
+}
+
+body {
+    margin: 0;
+    color: #222;
+    background: #fff;
+}
+
+header {
+    display: flex;
+    align-items: center;
+    gap: 1.25rem;
+
+    padding: 2rem 1.5rem 1rem;
+    margin-bottom: 1.75rem;
+    border-bottom: 1px solid #ccc;
+}
+
+.cross {
+    width: 3rem;
+    height: 3rem;
+    flex-shrink: 0;
+    fill: currentColor;
+}
+
+.brand-text {
+    display: flex;
+    flex-direction: column;
+    justify-content: center;
+    height: 3rem;
+}
+
+header h1 {
+    margin: 0;
+    font-size: 1.6rem;
+}
+
+header h1 a {
+    color: inherit;
+    text-decoration: none;
+}
+
+nav a {
+    margin-right: 1rem;
+}
+
+a {
+    color: #174a7e;
+}
+
+main {
+    max-width: 760px;
+    margin: 0 auto 2rem;
+    padding: 0 1.5rem;
+}
+
+article {
+    margin-bottom: 2rem;
+}
+
+/* Rendered Markdown content (blog posts and git READMEs) */
+
+article h1, article h2, article h3,
+article h4, article h5, article h6,
+.readme h1, .readme h2, .readme h3,
+.readme h4, .readme h5, .readme h6 {
+    margin-top: 1.75rem;
+    margin-bottom: 0.5rem;
+}
+
+article ul, article ol,
+.readme ul, .readme ol {
+    padding-left: 1.5rem;
+    margin: 0.75rem 0;
+}
+
+article li,
+.readme li {
+    margin-bottom: 0.25rem;
+}
+
+article blockquote,
+.readme blockquote {
+    margin: 1rem 0;
+    padding-left: 1rem;
+    border-left: 3px solid #ddd;
+    color: #555;
+}
+
+article hr,
+.readme hr {
+    border: none;
+    border-top: 1px solid #ddd;
+    margin: 1.5rem 0;
+}
+
+.metadata,
+.tags {
+    color: #777;
+    font-size: 0.9rem;
+}
+
+.post-navigation {
+    display: flex;
+    justify-content: space-between;
+    border-top: 1px solid #ddd;
+    padding-top: 1rem;
+    font-size: 0.9rem;
+}
+
+.post-list {
+    list-style: none;
+    padding: 0;
+}
+
+.post-list article {
+    margin-bottom: 2rem;
+}
+
+footer {
+    max-width: 760px;
+    margin: 0 auto;
+    padding: 1rem 1.5rem 2rem;
+    border-top: 1px solid #ddd;
+    text-align: center;
+    color: #777;
+    font-size: 0.85rem;
+}
+
+code,
+pre {
+    font-family: monospace;
+}
+
+pre {
+    overflow-x: auto;
+    padding: 0.85rem 1rem;
+    margin: 1rem 0;
+    background: #f6f6f6;
+    border: 1px solid #ddd;
+}
+
+pre code {
+    padding: 0;
+    background: none;
+}
+
+.tag-list {
+    padding: 0;
+    list-style: none;
+}
+
+/* Git browser */
+
+table {
+    width: 100%;
+    border-collapse: collapse;
+    margin: 0.75rem 0 1.5rem;
+}
+
+td {
+    padding: 0.3rem 0.5rem;
+    border-bottom: 1px solid #eee;
+    text-align: left;
+    vertical-align: top;
+}
+
+thead td {
+    border-bottom: 1px solid #ccc;
+}
+
+.num {
+    white-space: nowrap;
+    font-family: monospace;
+}
+
+.repo-nav {
+    margin: 0.75rem 0 1.25rem;
+}
+
+.repo-nav a {
+    margin-right: 1rem;
+}
+
+.repo-bar {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    flex-wrap: wrap;
+    gap: 0.75rem;
+    margin: 0.75rem 0;
+    padding-bottom: 0.75rem;
+    border-bottom: 1px solid #eee;
+}
+
+.repo-bar-branches {
+    display: flex;
+    align-items: center;
+    gap: 0.5rem;
+}
+
+.branch-icon {
+    color: #555;
+}
+
+.branch-count {
+    color: #777;
+    font-size: 0.85rem;
+}
+
+.clone-box {
+    position: relative;
+}
+
+.clone-box summary {
+    cursor: pointer;
+    font-size: 0.9rem;
+    color: #174a7e;
+    list-style: none;
+}
+
+.clone-box summary::-webkit-details-marker {
+    display: none;
+}
+
+.clone-box summary::before {
+    content: "<> ";
+}
+
+.clone-panel {
+    position: absolute;
+    right: 0;
+    z-index: 1;
+    margin-top: 0.5rem;
+    padding: 0.85rem 1rem;
+    background: #fff;
+    border: 1px solid #ccc;
+    box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
+    min-width: 20rem;
+}
+
+.clone-label {
+    display: block;
+    font-size: 0.8rem;
+    color: #777;
+    margin-bottom: 0.4rem;
+}
+
+.clone-field {
+    display: flex;
+    gap: 0.4rem;
+}
+
+.clone-field input {
+    flex: 1;
+    font-family: monospace;
+    font-size: 0.85rem;
+    padding: 0.35rem 0.5rem;
+    border: 1px solid #ccc;
+    background: #f6f6f6;
+}
+
+.clone-field button {
+    font: inherit;
+    font-size: 0.85rem;
+    padding: 0.35rem 0.75rem;
+    border: 1px solid #ccc;
+    background: #fff;
+    cursor: pointer;
+}
+
+.clone-field button:hover {
+    background: #f0f0f0;
+}
+
+.branch-select {
+    font: inherit;
+    font-size: 0.9rem;
+}
+
+.readme {
+    margin-top: 1.5rem;
+    padding-top: 1rem;
+    border-top: 1px solid #ddd;
+}
+
+.archive-year {
+    margin-bottom: 2rem;
+}
+
+.archive-month {
+    margin-bottom: 1.25rem;
+}
+
+.archive-month h3 {
+    color: #777;
+    font-weight: normal;
+}
+
+.archive-list {
+    margin: 0;
+    padding: 0;
+    list-style: none;
+}
+
+.archive-list time {
+    display: inline-block;
+    width: 2rem;
+    color: #888;
+}
+
+@media (max-width: 600px) {
+    header,
+    main,
+    footer {
+        padding-left: 1rem;
+        padding-right: 1rem;
+    }
+
+    .post-navigation {
+        display: block;
+    }
+
+    .post-navigation span {
+        display: block;
+        margin-bottom: 0.5rem;
+    }
+}
diff --git a/templates/about.html b/templates/about.html
new file mode 100644
index 0000000..4bfd608
--- /dev/null
+++ b/templates/about.html
@@ -0,0 +1,27 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<title>About - Milán Major</title>
+<link rel="stylesheet" href="/style.css">
+</head>
+
+<body>
+
+{{ header }}
+
+<main>
+
+<h1>About</h1>
+
+<p>Hi, I'm Milán Major. This site is my personal developer blog
+and a browsable mirror of some of my git repositories.</p>
+
+</main>
+
+<footer>
+<p>Milán Major</p>
+</footer>
+
+</body>
+</html>
diff --git a/templates/archive.html b/templates/archive.html
new file mode 100644
index 0000000..e2b7f24
--- /dev/null
+++ b/templates/archive.html
@@ -0,0 +1,32 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<title>Archive - Milán Major</title>
+<link rel="stylesheet" href="/style.css">
+</head>
+
+<body>
+
+{{ header }}
+
+<main>
+
+<h1>Archive</h1>
+
+{{ archive }}
+
+<p>
+<a href="/blog/">back to blog</a> ·
+<a href="/blog/tags/">tags</a> ·
+<a href="/rss.xml">RSS</a>
+</p>
+
+</main>
+
+<footer>
+<p>Milán Major</p>
+</footer>
+
+</body>
+</html>
diff --git a/templates/git-commit.html b/templates/git-commit.html
new file mode 100644
index 0000000..efdf983
--- /dev/null
+++ b/templates/git-commit.html
@@ -0,0 +1,52 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<title>{{ subject }} - {{ name }} - Milán Major</title>
+<link rel="stylesheet" href="/style.css">
+</head>
+
+<body>
+
+{{ header }}
+
+<main>
+
+<h1>{{ name }}</h1>
+
+{{ repo_bar }}
+
+<nav class="repo-nav">
+<a href="{{ repo_url }}">summary</a>
+<a href="{{ log_url }}">log</a>
+<a href="{{ files_url }}">files</a>
+</nav>
+
+<h2>{{ subject }}</h2>
+
+<table>
+<tr><td><b>author</b></td><td>{{ author }}</td></tr>
+<tr><td><b>date</b></td><td>{{ date }}</td></tr>
+<tr><td><b>commit</b></td><td><code>{{ hash }}</code></td></tr>
+<tr><td><b>parent</b></td><td>{{ parent }}</td></tr>
+<tr><td><b>changes</b></td><td>{{ summary }}</td></tr>
+</table>
+
+{{ body }}
+
+<h3>Diffstat</h3>
+
+<pre class="diffstat">{{ stat }}</pre>
+
+<h3>Diff</h3>
+
+<pre class="diff">{{ diff }}</pre>
+
+</main>
+
+<footer>
+<p><a href="/git/">back to repositories</a></p>
+</footer>
+
+</body>
+</html>
diff --git a/templates/git-file.html b/templates/git-file.html
new file mode 100644
index 0000000..c8e34d7
--- /dev/null
+++ b/templates/git-file.html
@@ -0,0 +1,36 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<title>{{ path }} - {{ name }} - Milán Major</title>
+<link rel="stylesheet" href="/style.css">
+</head>
+
+<body>
+
+{{ header }}
+
+<main>
+
+<h1>{{ name }}</h1>
+
+{{ repo_bar }}
+
+<nav class="repo-nav">
+<a href="{{ repo_url }}">summary</a>
+<a href="{{ log_url }}">log</a>
+<a href="{{ files_url }}">files</a>
+</nav>
+
+<h2>{{ path }}</h2>
+
+<pre class="file-content">{{ content }}</pre>
+
+</main>
+
+<footer>
+<p><a href="/git/">back to repositories</a></p>
+</footer>
+
+</body>
+</html>
diff --git a/templates/git-files.html b/templates/git-files.html
new file mode 100644
index 0000000..7ca7f51
--- /dev/null
+++ b/templates/git-files.html
@@ -0,0 +1,43 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<title>{{ path }} - {{ name }} - Milán Major</title>
+<link rel="stylesheet" href="/style.css">
+</head>
+
+<body>
+
+{{ header }}
+
+<main>
+
+<h1>{{ name }}</h1>
+
+{{ repo_bar }}
+
+<nav class="repo-nav">
+<a href="{{ repo_url }}">summary</a>
+<a href="{{ log_url }}">log</a>
+<a href="{{ files_url }}">files</a>
+</nav>
+
+<h2>Files{{ path_suffix }}</h2>
+
+<table>
+<thead>
+<tr><td><b>Mode</b></td><td><b>Name</b></td><td class="num" align="right"><b>Size</b></td><td><b>Last change</b></td></tr>
+</thead>
+<tbody>
+{{ entries }}
+</tbody>
+</table>
+
+</main>
+
+<footer>
+<p><a href="/git/">back to repositories</a></p>
+</footer>
+
+</body>
+</html>
diff --git a/templates/git-index.html b/templates/git-index.html
new file mode 100644
index 0000000..3925d5e
--- /dev/null
+++ b/templates/git-index.html
@@ -0,0 +1,33 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<title>Git - Milán Major</title>
+<link rel="stylesheet" href="/style.css">
+</head>
+
+<body>
+
+{{ header }}
+
+<main>
+
+<h1>Repositories</h1>
+
+<table>
+<thead>
+<tr><td><b>Name</b></td><td><b>Description</b></td><td><b>Last change</b></td></tr>
+</thead>
+<tbody>
+{{ repos }}
+</tbody>
+</table>
+
+</main>
+
+<footer>
+<p>Milán Major</p>
+</footer>
+
+</body>
+</html>
diff --git a/templates/git-log.html b/templates/git-log.html
new file mode 100644
index 0000000..7386a78
--- /dev/null
+++ b/templates/git-log.html
@@ -0,0 +1,43 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<title>Log - {{ name }} - Milán Major</title>
+<link rel="stylesheet" href="/style.css">
+</head>
+
+<body>
+
+{{ header }}
+
+<main>
+
+<h1>{{ name }}</h1>
+
+{{ repo_bar }}
+
+<nav class="repo-nav">
+<a href="{{ repo_url }}">summary</a>
+<a href="{{ log_url }}">log</a>
+<a href="{{ files_url }}">files</a>
+</nav>
+
+<h2>Log</h2>
+
+<table>
+<thead>
+<tr><td><b>Date</b></td><td><b>Commit message</b></td><td><b>Author</b></td><td class="num" align="right"><b>Files</b></td><td class="num" align="right"><b>+</b></td><td class="num" align="right"><b>-</b></td></tr>
+</thead>
+<tbody>
+{{ log }}
+</tbody>
+</table>
+
+</main>
+
+<footer>
+<p><a href="/git/">back to repositories</a></p>
+</footer>
+
+</body>
+</html>
diff --git a/templates/git-repo.html b/templates/git-repo.html
new file mode 100644
index 0000000..818223a
--- /dev/null
+++ b/templates/git-repo.html
@@ -0,0 +1,46 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<title>{{ name }} - Milán Major</title>
+<link rel="stylesheet" href="/style.css">
+</head>
+
+<body>
+
+{{ header }}
+
+<main>
+
+<h1>{{ name }}</h1>
+
+<p class="metadata">{{ description }}</p>
+
+{{ repo_bar }}
+
+<nav class="repo-nav">
+<a href="{{ log_url }}">log</a>
+<a href="{{ files_url }}">files</a>
+</nav>
+
+<h2>Files</h2>
+
+<table>
+<thead>
+<tr><td><b>Name</b></td><td><b>Last commit</b></td><td><b>Date</b></td></tr>
+</thead>
+<tbody>
+{{ files }}
+</tbody>
+</table>
+
+{{ readme }}
+
+</main>
+
+<footer>
+<p><a href="/git/">back to repositories</a></p>
+</footer>
+
+</body>
+</html>
diff --git a/templates/home.html b/templates/home.html
new file mode 100644
index 0000000..db6813d
--- /dev/null
+++ b/templates/home.html
@@ -0,0 +1,32 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<title>Milán Major</title>
+<link rel="stylesheet" href="/style.css">
+</head>
+
+<body>
+
+{{ header }}
+
+<main>
+
+<h1>Milán Major</h1>
+
+<p>Developer blog and git repositories.</p>
+
+<p>
+<a href="/blog/">blog</a> ·
+<a href="/git/">git</a> ·
+<a href="/about.html">about</a>
+</p>
+
+</main>
+
+<footer>
+<p>Milán Major</p>
+</footer>
+
+</body>
+</html>
diff --git a/templates/index.html b/templates/index.html
new file mode 100644
index 0000000..31d08aa
--- /dev/null
+++ b/templates/index.html
@@ -0,0 +1,36 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<title>Blog - Milán Major</title>
+<link rel="stylesheet" href="/style.css">
+</head>
+
+<body>
+
+{{ header }}
+
+<main>
+
+<h1>Blog</h1>
+
+<p>
+<a href="/blog/archive/">archive</a> ·
+<a href="/blog/tags/">tags</a> ·
+<a href="/rss.xml">RSS</a>
+</p>
+
+<section class="post-list">
+
+{{ posts }}
+
+</section>
+
+</main>
+
+<footer>
+<p>Milán Major</p>
+</footer>
+
+</body>
+</html>
diff --git a/templates/post.html b/templates/post.html
new file mode 100644
index 0000000..2bdffce
--- /dev/null
+++ b/templates/post.html
@@ -0,0 +1,51 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<title>{{ title }} - Milán Major</title>
+<link rel="stylesheet" href="/style.css">
+</head>
+
+<body>
+
+{{ header }}
+
+<main>
+
+<article>
+
+<h1>{{ title }}</h1>
+
+<p class="metadata">
+{{ date }}
+</p>
+
+{{ content }}
+
+<p class="tags">
+Tags: {{ tags }}
+</p>
+
+</article>
+
+<nav class="post-navigation">
+
+<span>
+{{ previous }}
+</span>
+
+<span>
+{{ next }}
+</span>
+
+</nav>
+
+</main>
+
+<footer>
+<a href="/blog/">Blog</a> |
+<a href="/rss.xml">RSS</a>
+</footer>
+
+</body>
+</html>
diff --git a/templates/tag.html b/templates/tag.html
new file mode 100644
index 0000000..0ed53ef
--- /dev/null
+++ b/templates/tag.html
@@ -0,0 +1,36 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<title>Tag: {{ tag }} - Milán Major</title>
+<link rel="stylesheet" href="/style.css">
+</head>
+
+<body>
+
+{{ header }}
+
+<main>
+
+<h1>Tag: {{ tag }}</h1>
+
+<section class="post-list">
+
+{{ posts }}
+
+</section>
+
+<p>
+<a href="/blog/tags/">all tags</a> ·
+<a href="/blog/">back to blog</a> ·
+<a href="/rss.xml">RSS</a>
+</p>
+
+</main>
+
+<footer>
+<p>Milán Major</p>
+</footer>
+
+</body>
+</html>
diff --git a/templates/tags.html b/templates/tags.html
new file mode 100644
index 0000000..17012cd
--- /dev/null
+++ b/templates/tags.html
@@ -0,0 +1,34 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<title>Tags - Milán Major</title>
+<link rel="stylesheet" href="/style.css">
+</head>
+
+<body>
+
+{{ header }}
+
+<main>
+
+<h1>Tags</h1>
+
+<ul class="tag-list">
+{{ tags }}
+</ul>
+
+<p>
+<a href="/blog/">back to blog</a> ·
+<a href="/blog/archive/">archive</a> ·
+<a href="/rss.xml">RSS</a>
+</p>
+
+</main>
+
+<footer>
+<p>Milán Major</p>
+</footer>
+
+</body>
+</html>